max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
9 files changed,
+497 insertions,
-32 deletions
| @@ -4016,13 +4016,19 @@ | |||
| 4016 | 4016 | echo "firewall: tailscale0 bound to the trusted zone" | |
| 4017 | 4017 | ||
| 4018 | 4018 | # ===================================================================== | |
| 4019 | - | # USBGuard is present and is NOT armed, asserted on both profiles. | |
| 4019 | + | # USBGuard is present, and its stock policy is empty. Both profiles. | |
| 4020 | 4020 | # | |
| 4021 | - | # This is the unusual case where the assertion's job is to hold a feature | |
| 4022 | - | # OFF. The ruling (GoingsOn alloy 63de3d4c) is enforcement on by default, | |
| 4023 | - | # and the daemon is one preset line away from that — which is exactly the | |
| 4024 | - | # problem, because the policy it would enforce today is the package's | |
| 4025 | - | # stock one and the package's stock policy denies everything. | |
| 4021 | + | # This block was the unusual case where an assertion's job was to hold a | |
| 4022 | + | # feature OFF. It is not any more: the preset line landed 2026-09-08 and | |
| 4023 | + | # the check after preset-all now asserts the daemon IS armed, along with | |
| 4024 | + | # the seeding and the empty-policy refusal that make arming safe. | |
| 4025 | + | # | |
| 4026 | + | # What survives here is the half that is still about the package rather | |
| 4027 | + | # than about our decision: usbguard present, its knobs reading the way | |
| 4028 | + | # they were measured, and its stock policy still empty. That last one | |
| 4029 | + | # matters more now, not less. The policy this machine enforces is written | |
| 4030 | + | # on the machine at first boot by alloy-usb-seed; a policy appearing in | |
| 4031 | + | # the image would be Fedora's idea of what to allow, enforced everywhere. | |
| 4026 | 4032 | # | |
| 4027 | 4033 | # Four facts, all read off the fedora-43 package on 2026-08-22 rather | |
| 4028 | 4034 | # than out of its documentation, and each is why one line below exists: | |
| @@ -4037,14 +4043,14 @@ | |||
| 4037 | 4043 | # without another one. So the shape of this check is: the package must be | |
| 4038 | 4044 | # here (the console's action half depends on it), the knobs must still | |
| 4039 | 4045 | # read the way they were measured (if Fedora changes one, the reasoning | |
| 4040 | - | # above stops holding and someone must look again), and the unit must not | |
| 4041 | - | # be enabled until the policy and the keyboard gate exist. | |
| 4046 | + | # above stops holding and someone must look again), and the stock policy | |
| 4047 | + | # must still be empty. | |
| 4042 | 4048 | # | |
| 4043 | - | # The fourth line of that check — that the unit is not enabled — cannot | |
| 4044 | - | # live here: `systemctl preset-all` runs several hundred lines further | |
| 4045 | - | # down, so an assertion at this point would read the state before anything | |
| 4046 | - | # had a chance to arm the daemon and would pass on an image that boots | |
| 4047 | - | # armed. It sits immediately after preset-all instead. | |
| 4049 | + | # Whether the unit is enabled cannot be read here: `systemctl preset-all` | |
| 4050 | + | # runs several hundred lines further down, so an assertion at this point | |
| 4051 | + | # would read the state before any preset line had been applied and would | |
| 4052 | + | # pass on any image at all. It sits immediately after preset-all instead, | |
| 4053 | + | # where it now asserts the whole arming invariant rather than its absence. | |
| 4048 | 4054 | # ===================================================================== | |
| 4049 | 4055 | RUN set -eu; \ | |
| 4050 | 4056 | conf=/etc/usbguard/usbguard-daemon.conf; \ | |
| @@ -4521,27 +4527,62 @@ | |||
| 4521 | 4527 | RUN systemctl preset-all | |
| 4522 | 4528 | ||
| 4523 | 4529 | # ===================================================================== | |
| 4524 | - | # ...and usbguard is still disarmed, which only this side of preset-all | |
| 4525 | - | # can say. | |
| 4530 | + | # ...and usbguard is armed, together with the three things that make that | |
| 4531 | + | # safe. Only this side of preset-all can say any of it. | |
| 4526 | 4532 | # | |
| 4527 | - | # The package block and the config assertion above establish that the | |
| 4528 | - | # daemon would enforce a deny-everything policy if it ran. This is the | |
| 4529 | - | # line that says it does not run. It has to be here rather than beside | |
| 4530 | - | # them: `preset-all` is what turns a preset line into an enable symlink, | |
| 4531 | - | # so before it every unit in the image reads disabled and the check would | |
| 4532 | - | # pass without meaning anything. | |
| 4533 | + | # This block replaced the "usbguard is still disarmed" assertion on | |
| 4534 | + | # 2026-09-08, which is the deletion that one predicted: it said the | |
| 4535 | + | # step-3-and-4 work would eventually add the preset line and would have to | |
| 4536 | + | # consciously delete it. Both halves shipped, and the three bench tests | |
| 4537 | + | # the ruling asked for were run on real hardware (the second failed and | |
| 4538 | + | # found a boot-ordering lockout; see GoingsOn alloy 2347ed2c). | |
| 4533 | 4539 | # | |
| 4534 | - | # Two ways it could arm, and this catches both. A line added to | |
| 4535 | - | # 50-alloy.preset — the step-3-and-4 work will eventually add exactly | |
| 4536 | - | # that, and this assertion is the thing it has to consciously delete. | |
| 4537 | - | # And Fedora's own 90-default.preset, if the usbguard package ever starts | |
| 4538 | - | # shipping an enable line of its own; today it does not, and that is a | |
| 4539 | - | # fact about someone else's file rather than one about ours. | |
| 4540 | + | # Arming is not one line, and asserting only the one line is how it would | |
| 4541 | + | # quietly become unsafe again. Four states, and every one of them has to | |
| 4542 | + | # hold together: | |
| 4543 | + | # | |
| 4544 | + | # 1. usbguard.service enabled enforcement actually happens | |
| 4545 | + | # 2. rules.conf still EMPTY in the image the policy is a fact about the | |
| 4546 | + | # machine, written at first boot, | |
| 4547 | + | # never shipped by us or Fedora | |
| 4548 | + | # 3. alloy-usb-seed.service enabled so state 2 becomes a real policy | |
| 4549 | + | # before the daemon ever starts | |
| 4550 | + | # 4. the ConditionFileNotEmpty drop-in so if 3 fails the machine boots | |
| 4551 | + | # UNARMED rather than with every | |
| 4552 | + | # USB device denied | |
| 4553 | + | # | |
| 4554 | + | # State 4 is checked by its path and its exact line, and deliberately not by | |
| 4555 | + | # asking systemd. `systemctl show usbguard.service -p ConditionFileNotEmpty` | |
| 4556 | + | # prints NOTHING even where the drop-in is live and working: systemd exposes | |
| 4557 | + | # only the collapsed `ConditionResult`, not the individual conditions. | |
| 4558 | + | # Measured in a booted guest, and it was written the other way first, which | |
| 4559 | + | # would have failed this build for a drop-in that was perfectly good. Path | |
| 4560 | + | # plus exact key is enough anyway, because drop-in resolution is a filename | |
| 4561 | + | # convention: `<unit>.d/*.conf` under /etc/systemd/system is the whole rule, | |
| 4562 | + | # so the two ways it could be inert are the wrong directory and a mistyped | |
| 4563 | + | # key, and both are what is asserted here. | |
| 4564 | + | # | |
| 4565 | + | # Take 1 without 3 and 4 and the image deauthorizes its own hardware at | |
| 4566 | + | # boot: on a desktop that is the keyboard, and on a laptop it is the | |
| 4567 | + | # webcam and everything in a port, silently, because the internal i8042 | |
| 4568 | + | # keyboard keeps the gate correctly shut. That was measured, not feared. | |
| 4569 | + | # | |
| 4570 | + | # The keyboard gate is state 5 and is asserted in its own block above, | |
| 4571 | + | # because it is client-only and this one is not. | |
| 4540 | 4572 | # ===================================================================== | |
| 4541 | 4573 | RUN set -eu; \ | |
| 4542 | - | ! systemctl is-enabled usbguard.service >/dev/null 2>&1 \ | |
| 4543 | - | || { echo "usbguard.service is enabled, and /etc/usbguard/rules.conf is empty with ImplicitPolicyTarget=block: this image deauthorizes its own keyboard at boot" >&2; exit 1; }; \ | |
| 4544 | - | echo "usbguard: disarmed after preset-all" | |
| 4574 | + | systemctl is-enabled usbguard.service >/dev/null 2>&1 \ | |
| 4575 | + | || { echo "usbguard.service is not enabled; the ruling is enforcement on by default (GoingsOn alloy 63de3d4c) and 50-alloy.preset is where that lands" >&2; exit 1; }; \ | |
| 4576 | + | systemctl is-enabled alloy-usb-seed.service >/dev/null 2>&1 \ | |
| 4577 | + | || { echo "alloy-usb-seed.service is not enabled, and usbguard is: this image arms against the empty rules.conf it ships and denies every USB device at boot" >&2; exit 1; }; \ | |
| 4578 | + | test ! -s /etc/usbguard/rules.conf \ | |
| 4579 | + | || { echo "/etc/usbguard/rules.conf is no longer empty in the image; the policy is meant to be written on the machine by alloy-usb-seed, not shipped" >&2; exit 1; }; \ | |
| 4580 | + | dropin=/etc/systemd/system/usbguard.service.d/10-alloy-require-policy.conf; \ | |
| 4581 | + | test -f "$dropin" \ | |
| 4582 | + | || { echo "$dropin is gone; nothing now stops usbguard arming against an empty policy when seeding fails" >&2; exit 1; }; \ | |
| 4583 | + | grep -qx "ConditionFileNotEmpty=/etc/usbguard/rules.conf" "$dropin" \ | |
| 4584 | + | || { echo "$dropin no longer refuses an empty policy, which is the one thing it exists to do" >&2; exit 1; }; \ | |
| 4585 | + | echo "usbguard: armed after preset-all, with seeding and the empty-policy refusal both in place" | |
| 4545 | 4586 | ||
| 4546 | 4587 | # Template instances have to be enabled by name. `preset-all` iterates over | |
| 4547 | 4588 | # the unit *files* that exist, and `alloy-debug-shell@.service` is a template |
| @@ -308,6 +308,20 @@ | |||
| 308 | 308 | holds the address rather than the row index, since the list can re-sort while it | |
| 309 | 309 | is up. | |
| 310 | 310 | ||
| 311 | + | **As of 2026-09-08 the daemon is armed on every install, so these keys are the | |
| 312 | + | normal path rather than a thing you turn on first.** A machine writes its own | |
| 313 | + | policy at first boot from the devices it has (`alloy-usb-seed.service`), and | |
| 314 | + | usbguard refuses to start at all against an empty one, so the screen's four | |
| 315 | + | enforcement states are all still reachable and all still mean what they said. | |
| 316 | + | What changes is which one you will usually see: `USB authorization is enforced | |
| 317 | + | by usbguard`, with the rule for the selected device in the detail pane and three | |
| 318 | + | keys that act on it. | |
| 319 | + | ||
| 320 | + | A device attached after setup is denied, and today it is denied silently: the | |
| 321 | + | plug-then-prompt hook is `usbguard watch --exec` and nothing runs it yet, so | |
| 322 | + | this screen is where a person finds out. That is the gap worth knowing about | |
| 323 | + | when a stick appears not to work. | |
| 324 | + | ||
| 311 | 325 | **Two tabs, because a charger and a keyboard are answered by different | |
| 312 | 326 | subsystems.** The bus knows what enumerated and what each interface claims to be; | |
| 313 | 327 | the Type-C connectors know which way power is flowing, whether the partner speaks |
| @@ -477,6 +477,18 @@ | |||
| 477 | 477 | ||
| 478 | 478 | Rejected: **enabling the unit now and writing the policy later.** It is one line and it would work on the bench for as long as the bench machine has a PS/2 keyboard, then take out the first machine that does not. There is no partial credit available here: an image that boots armed with an empty policy is worse than one with no usbguard at all, because the second is merely unprotected and the first is bricked. | |
| 479 | 479 | ||
| 480 | + | **Enforcement is on by default as of 2026-09-08, and the preset line is the smallest part of that.** The ruling always said enforcement on by default; what took until now is the three things that make it survivable, because arming is four states and not one. | |
| 481 | + | ||
| 482 | + | The policy is written on the machine, not shipped in the image. `alloy-usb-seed.service` runs `usbguard generate-policy` before the daemon starts and writes an allow rule per attached device, so the first armed boot enforces a statement about this machine rather than the empty `rules.conf` the Fedora package ships. That is the position, stated plainly: a machine trusts the peripherals it was set up with, and everything after that is a decision the person makes from `alloy usb`. The install medium is usually still in a port and gets a rule of its own, named plainly enough to delete. The image's `rules.conf` stays empty and the Containerfile still asserts it, more strictly than before: a policy appearing in the image would be Fedora's idea of what to allow, enforced on every machine. | |
| 483 | + | ||
| 484 | + | `usbguard.service` carries a `ConditionFileNotEmpty` on that file, so it refuses to start against an empty policy at all. A machine whose seeding failed boots **unarmed** rather than with every USB device denied. Failing open, for the same reason the keyboard gate fails open. It is a condition rather than an assertion on purpose: a machine that is simply not enforcing is fine and must not be reported as broken. | |
| 485 | + | ||
| 486 | + | Why this mattered more than it looks. On a desktop, arming against an empty policy takes the keyboard and the gate catches it. On a laptop it does not take the keyboard, because the internal one is i8042 and outside USBGuard's jurisdiction, so the gate correctly stays shut and the machine comes up with its webcam, fingerprint reader and everything in a port silently dead, with nothing on screen connecting any of it to a policy decision. The silent failure is the worse of the two. | |
| 487 | + | ||
| 488 | + | Verified on a booted machine rather than reasoned about. From an empty policy: seeder starts at +0.779s, writes 3 rules at +0.823s, finishes at +0.839s; usbguard starts at +0.881s and applies the policy at +0.920s with every device `target.new='allow'`. End state is enforcement on, every device authorized, keyboard working. With the seeder prevented from running, usbguard is skipped with `unmet condition check ConditionFileNotEmpty` and the machine boots usable and unprotected, which is the intended failure. | |
| 489 | + | ||
| 490 | + | One thing this does not have and the ruling asked for: the prompt. `usbguard watch --exec` is the hook and nothing runs it yet, so a device plugged in after setup is denied silently and the way to find out is `alloy usb`. That gap is worth closing before this reaches anyone who did not build it. | |
| 491 | + | ||
| 480 | 492 | ## VPN | |
| 481 | 493 | ||
| 482 | 494 | **WireGuard, through NetworkManager, with no plugin.** NetworkManager 1.54 speaks WireGuard natively, and the plugin packages a search turns up (`NetworkManager-wireguard` and its `-gnome` variant) do not exist in Fedora 43. What is missing without help is key generation: `nmcli` will import a peer config but will not mint one. `wireguard-tools` supplies `wg` and `wg-quick` and is the whole of what this needed. |
| @@ -284,14 +284,23 @@ | |||
| 284 | 284 | # off, the bus authorized, and nothing recording that the gate is what did | |
| 285 | 285 | # it — so the next run would see a state it thinks it never made and leave | |
| 286 | 286 | # it there. | |
| 287 | - | if systemctl start "$UNIT" 2>/dev/null; then | |
| 287 | + | systemctl start "$UNIT" 2>/dev/null || true | |
| 288 | + | # Asked of the daemon rather than of the start command, because the two now | |
| 289 | + | # disagree. usbguard carries a ConditionFileNotEmpty on its rule file (see | |
| 290 | + | # etc/systemd/system/usbguard.service.d/), and a unit skipped by a condition | |
| 291 | + | # is not a failure: `systemctl start` returns 0 and leaves it inactive. So a | |
| 292 | + | # machine with an empty policy would have been told enforcement was back on | |
| 293 | + | # while nothing was enforcing anything. | |
| 294 | + | if armed; then | |
| 288 | 295 | # RESCUED goes with it: enforcement is back on, so the episode this | |
| 289 | 296 | # gate was in is over and a future latch gets its own one attempt. | |
| 290 | 297 | rm -f "$STAMP" "$RESCUED" 2>/dev/null || true | |
| 291 | 298 | say "USB device authorization is back on: a keyboard is present." | |
| 292 | 299 | else | |
| 293 | 300 | say "A keyboard is present, but USB device authorization could not be" | |
| 294 | - | say "turned back on. It stays off. See: systemctl status $UNIT" | |
| 301 | + | say "turned back on. It stays off. This machine may have no policy to" | |
| 302 | + | say "enforce yet; `alloy usb` shows whether one is written." | |
| 303 | + | say "See also: systemctl status $UNIT" | |
| 295 | 304 | fi | |
| 296 | 305 | } | |
| 297 | 306 |
| @@ -478,3 +478,167 @@ | |||
| 478 | 478 | "the server branch removes the gate without proving it is gone", | |
| 479 | 479 | ); | |
| 480 | 480 | } | |
| 481 | + | ||
| 482 | + | // ---- arming, and the three things that make it safe ---- | |
| 483 | + | ||
| 484 | + | fn preset() -> String { | |
| 485 | + | repo("etc/systemd/system-preset/50-alloy.preset") | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | fn seed_script() -> String { | |
| 489 | + | repo("usr/bin/alloy-usb-seed") | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | fn seed_unit() -> String { | |
| 493 | + | repo("etc/systemd/system/alloy-usb-seed.service") | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | fn require_policy() -> String { | |
| 497 | + | repo("etc/systemd/system/usbguard.service.d/10-alloy-require-policy.conf") | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | // The ruling landing: enforcement on by default (GoingsOn alloy 63de3d4c). | |
| 501 | + | // Held here rather than only in the Containerfile so a preset edit fails the | |
| 502 | + | // suite rather than only the image build. | |
| 503 | + | #[test] | |
| 504 | + | fn enforcement_is_armed_by_default() { | |
| 505 | + | let preset = code(&preset()); | |
| 506 | + | assert!( | |
| 507 | + | preset.contains("enable usbguard.service"), | |
| 508 | + | "50-alloy.preset no longer arms usbguard, so the ruling's \ | |
| 509 | + | enforcement-on-by-default holds on no machine", | |
| 510 | + | ); | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // And the reason that line is safe to have. Arming is four states, not one, | |
| 514 | + | // and any one of them going missing is an image that deauthorizes its own | |
| 515 | + | // hardware at boot: on a desktop the keyboard, on a laptop the webcam and | |
| 516 | + | // everything in a port, silently, because the internal i8042 keyboard keeps | |
| 517 | + | // the gate correctly shut. | |
| 518 | + | #[test] | |
| 519 | + | fn arming_never_ships_without_the_things_that_make_it_survivable() { | |
| 520 | + | let preset = code(&preset()); | |
| 521 | + | assert!( | |
| 522 | + | preset.contains("enable alloy-usb-seed.service"), | |
| 523 | + | "usbguard is armed and nothing seeds a policy, so the first boot \ | |
| 524 | + | enforces the empty rules.conf the image ships and denies every device", | |
| 525 | + | ); | |
| 526 | + | assert!( | |
| 527 | + | preset.contains("enable alloy-usb-gate.service"), | |
| 528 | + | "usbguard is armed and the keyboard gate is not enabled, so a machine \ | |
| 529 | + | whose only keyboard is USB has no way back in", | |
| 530 | + | ); | |
| 531 | + | ||
| 532 | + | let condition = code(&require_policy()); | |
| 533 | + | assert!( | |
| 534 | + | condition.contains("ConditionFileNotEmpty=/etc/usbguard/rules.conf"), | |
| 535 | + | "nothing refuses to start usbguard against an empty policy, so a \ | |
| 536 | + | machine whose seeding failed boots with every USB device denied \ | |
| 537 | + | instead of merely unarmed", | |
| 538 | + | ); | |
| 539 | + | // A condition, never an assertion. A machine that is simply not enforcing | |
| 540 | + | // is fine and must not be reported as broken, and an Assert= would fail | |
| 541 | + | // the unit and pull that into whatever waits on it. | |
| 542 | + | assert!( | |
| 543 | + | !condition.contains("AssertFileNotEmpty"), | |
| 544 | + | "the empty-policy refusal is an Assert=, which fails the unit rather \ | |
| 545 | + | than skipping it; not enforcing is not a fault", | |
| 546 | + | ); | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // The seeder writes the policy before anything can enforce one, and reads the | |
| 550 | + | // bus after udev has populated it. Getting either edge wrong writes a policy | |
| 551 | + | // from a half-enumerated /sys, which denies whatever had not appeared yet. | |
| 552 | + | #[test] | |
| 553 | + | fn the_seeder_runs_after_udev_and_before_the_daemon() { | |
| 554 | + | let unit = code(&seed_unit()); | |
| 555 | + | assert!( | |
| 556 | + | unit.contains("Before=usbguard.service"), | |
| 557 | + | "the seeder no longer runs before usbguard, so the daemon can start \ | |
| 558 | + | against the empty policy the seeder was going to replace", | |
| 559 | + | ); | |
| 560 | + | assert!( | |
| 561 | + | unit.contains("After=systemd-udevd.service"), | |
| 562 | + | "the seeder no longer waits for udev, so it reads a half-populated bus", | |
| 563 | + | ); | |
| 564 | + | for forbidden in ["Wants=usbguard.service", "Requires=usbguard.service"] { | |
| 565 | + | assert!( | |
| 566 | + | !unit.contains(forbidden), | |
| 567 | + | "the seeder pulls usbguard in with {forbidden}; preparing a policy \ | |
| 568 | + | must never be a reason for enforcement to start", | |
| 569 | + | ); | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // Seeding is idempotent against a policy that already exists, and the policy | |
| 574 | + | // file is the state rather than a stamp beside it. A stamp would let a machine | |
| 575 | + | // whose rules.conf was emptied by hand arm against nothing. | |
| 576 | + | #[test] | |
| 577 | + | fn the_seeder_refuses_to_replace_a_policy_that_already_says_something() { | |
| 578 | + | let code = code(&seed_script()); | |
| 579 | + | let guard = code | |
| 580 | + | .find(r#"if [ -s "$POLICY" ]"#) | |
| 581 | + | .expect("the seeder no longer checks whether a policy is already written"); | |
| 582 | + | let generate = code | |
| 583 | + | .find("usbguard generate-policy") | |
| 584 | + | .expect("the seeder no longer generates a policy"); | |
| 585 | + | assert!( | |
| 586 | + | guard < generate, | |
| 587 | + | "the seeder generates before it checks, so it overwrites the policy \ | |
| 588 | + | this machine already decided on", | |
| 589 | + | ); | |
| 590 | + | assert!( | |
| 591 | + | !code.contains("/run/alloy/usb-seed"), | |
| 592 | + | "the seeder keeps a stamp file; the policy is the state, and a stamp \ | |
| 593 | + | lets an emptied rules.conf stay empty forever", | |
| 594 | + | ); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // Never truncate the real file. A redirect empties it before generate-policy | |
| 598 | + | // runs, so a command that failed halfway leaves a partial allow-list, which is | |
| 599 | + | // a machine silently denying whatever did not make it into the file. | |
| 600 | + | #[test] | |
| 601 | + | fn the_seeder_never_writes_the_policy_in_place() { | |
| 602 | + | let code = code(&seed_script()); | |
| 603 | + | assert!( | |
| 604 | + | !code.contains(r#"generate-policy > "$POLICY""#) | |
| 605 | + | && !code.contains("generate-policy > /etc/usbguard/rules.conf"), | |
| 606 | + | "the seeder redirects over the live policy, so a failed generate \ | |
| 607 | + | leaves a partial allow-list behind", | |
| 608 | + | ); | |
| 609 | + | assert!( | |
| 610 | + | code.contains(r#"mv -f "$temporary" "$POLICY""#), | |
| 611 | + | "the seeder no longer moves a finished file into place", | |
| 612 | + | ); | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // The gate has to ask the daemon, not the start command. usbguard now carries a | |
| 616 | + | // ConditionFileNotEmpty, and a unit skipped by a condition is not a failure: | |
| 617 | + | // `systemctl start` returns 0 and leaves it inactive. Trusting the exit code | |
| 618 | + | // would tell someone enforcement was back on while nothing was enforcing. | |
| 619 | + | #[test] | |
| 620 | + | fn closing_the_gate_believes_the_daemon_rather_than_the_start_command() { | |
| 621 | + | let code = code(&script()); | |
| 622 | + | let closing = code | |
| 623 | + | .split_once("close_gate()") | |
| 624 | + | .expect("the gate still has a close_gate function") | |
| 625 | + | .1 | |
| 626 | + | .split_once("\n}") | |
| 627 | + | .expect("close_gate is a shell function") | |
| 628 | + | .0; | |
| 629 | + | assert!( | |
| 630 | + | !closing.contains(r#"if systemctl start "$UNIT""#), | |
| 631 | + | "close_gate branches on the exit code of `systemctl start`, which is 0 \ | |
| 632 | + | for a unit skipped by its condition", | |
| 633 | + | ); | |
| 634 | + | let start = closing | |
| 635 | + | .find("systemctl start") | |
| 636 | + | .expect("close_gate no longer starts the daemon"); | |
| 637 | + | let check = closing | |
| 638 | + | .find("if armed") | |
| 639 | + | .expect("close_gate no longer verifies the daemon actually came back"); | |
| 640 | + | assert!( | |
| 641 | + | start < check, | |
| 642 | + | "close_gate checks whether the daemon is armed before trying to start it", | |
| 643 | + | ); | |
| 644 | + | } |
| @@ -212,3 +212,43 @@ | |||
| 212 | 212 | # there and asserts it is gone, which is what keeps this no-op intentional | |
| 213 | 213 | # rather than a rename nobody noticed. | |
| 214 | 214 | enable alloy-usb-gate.service | |
| 215 | + | ||
| 216 | + | # The policy this machine will enforce, written from the devices this machine | |
| 217 | + | # actually has. It has to be enabled wherever usbguard might be armed, which is | |
| 218 | + | # both profiles, and it is ordered before the daemon in its own unit rather than | |
| 219 | + | # here. On a machine where enforcement is never turned on it writes a policy | |
| 220 | + | # nothing reads, which is the point: the day someone arms it from `alloy usb`, | |
| 221 | + | # there is already something to enforce. | |
| 222 | + | # | |
| 223 | + | # Unlike the gate this is NOT client-only. A server profile has no keyboard and | |
| 224 | + | # therefore no gate, so seeding is the only thing standing between it and a boot | |
| 225 | + | # that denies every USB device it has. | |
| 226 | + | enable alloy-usb-seed.service | |
| 227 | + | ||
| 228 | + | # And enforcement itself, which is the ruling (GoingsOn alloy 63de3d4c: | |
| 229 | + | # enforcement on by default) finally landing. This line was deliberately absent | |
| 230 | + | # from 2026-08-22 until the three bench tests had been run, because until then | |
| 231 | + | # the machine had no way to recover from what it turns on. | |
| 232 | + | # | |
| 233 | + | # What makes it safe is not this line, and that is worth saying here because | |
| 234 | + | # this is the line someone will find. Three things, all of which had to exist | |
| 235 | + | # first: | |
| 236 | + | # | |
| 237 | + | # - alloy-usb-seed.service writes a policy from the devices present, before | |
| 238 | + | # the daemon starts, so the first armed boot enforces a statement about | |
| 239 | + | # THIS machine rather than the empty file the image ships. | |
| 240 | + | # - usbguard.service.d/10-alloy-require-policy.conf refuses to start the | |
| 241 | + | # daemon at all against an empty policy, so a machine whose seeding failed | |
| 242 | + | # boots unarmed rather than with every USB device denied. | |
| 243 | + | # - alloy-usb-gate.service drops enforcement whenever the machine has no | |
| 244 | + | # usable keyboard, which is the lockout valve. It is client-only; see above. | |
| 245 | + | # | |
| 246 | + | # Removing any one of those three and leaving this line is how this image | |
| 247 | + | # deauthorizes its own hardware. The Containerfile asserts all four states | |
| 248 | + | # together after preset-all for that reason. | |
| 249 | + | # | |
| 250 | + | # The exposure this line changes is inbound at the USB port: a device plugged | |
| 251 | + | # into an Alloy machine after it was set up does not work until the person says | |
| 252 | + | # so, from `alloy usb`. That is the ruling, and it is the one default in this | |
| 253 | + | # file that makes the machine do less than it did yesterday. | |
| 254 | + | enable usbguard.service |
| @@ -1,0 +1,45 @@ | |||
| 1 | + | # Write this machine's USB policy before anything enforces one. | |
| 2 | + | # | |
| 3 | + | # The reasoning is all in usr/bin/alloy-usb-seed; this file is only what runs | |
| 4 | + | # it. The short version: the image ships an empty rules.conf on purpose, and | |
| 5 | + | # an empty policy plus ImplicitPolicyTarget=block is "deny every USB device on | |
| 6 | + | # this machine". The seeder turns that into "allow what this machine already | |
| 7 | + | # had", which is the only policy the machine itself can honestly assert. | |
| 8 | + | # | |
| 9 | + | # Ordering is the whole point of the unit. Before=usbguard.service, so the | |
| 10 | + | # daemon never sees the empty file; After=systemd-udevd.service, so the bus has | |
| 11 | + | # been enumerated and generate-policy has something to read. Without the second | |
| 12 | + | # the policy would be written from a half-populated /sys and would deny whatever | |
| 13 | + | # had not appeared yet. | |
| 14 | + | # | |
| 15 | + | # Not Wants=usbguard.service, and never Requires=. This must not be a reason for | |
| 16 | + | # enforcement to start; it prepares the ground for a daemon that is armed | |
| 17 | + | # somewhere else, and on the majority of machines — usbguard installed and not | |
| 18 | + | # enabled — it writes a policy nothing reads. That is deliberate: it means the | |
| 19 | + | # day a person arms enforcement from `alloy usb`, the policy is already there. | |
| 20 | + | # | |
| 21 | + | # The seeder is idempotent against a written policy, so this is enabled on every | |
| 22 | + | # install rather than gated on a stamp. See the script for why the policy file | |
| 23 | + | # itself is the state. | |
| 24 | + | ||
| 25 | + | [Unit] | |
| 26 | + | Description=Seed the USB device policy from this machine's own devices | |
| 27 | + | Documentation=https://makenot.work/git/max/alloy | |
| 28 | + | After=systemd-udevd.service | |
| 29 | + | Wants=systemd-udevd.service | |
| 30 | + | Before=usbguard.service | |
| 31 | + | # On a machine with no usbguard the script exits saying so, which is harmless | |
| 32 | + | # and pointless. The condition says it out loud instead. | |
| 33 | + | ConditionPathExists=/usr/bin/usbguard | |
| 34 | + | ||
| 35 | + | [Service] | |
| 36 | + | Type=oneshot | |
| 37 | + | RemainAfterExit=yes | |
| 38 | + | ExecStart=/usr/bin/alloy-usb-seed | |
| 39 | + | # The script returns 0 on every path it means to take, including the ones where | |
| 40 | + | # it decides not to write anything, so a non-zero here is a real fault. It must | |
| 41 | + | # not be fatal to the boot: a seeder that failed leaves the policy empty, and an | |
| 42 | + | # empty policy is refused by usbguard's own drop-in rather than enforced. | |
| 43 | + | ||
| 44 | + | [Install] | |
| 45 | + | WantedBy=multi-user.target |
| @@ -1,0 +1,32 @@ | |||
| 1 | + | # Never arm USBGuard against an empty policy. | |
| 2 | + | # | |
| 3 | + | # This is the structural version of the sentence the whole `alloy usb` work has | |
| 4 | + | # been repeating since 2026-08-22: an image that boots armed with an empty | |
| 5 | + | # policy is worse than one with no usbguard at all, because the second is merely | |
| 6 | + | # unprotected and the first has deauthorized every USB device on the machine. | |
| 7 | + | # | |
| 8 | + | # The four facts behind it, measured off usbguard-1.1.4-1.fc43 rather than read: | |
| 9 | + | # rules.conf ships empty, ImplicitPolicyTarget=block, PresentDevicePolicy and | |
| 10 | + | # InsertedDevicePolicy are both apply-policy. Together they mean the daemon | |
| 11 | + | # denies everything the instant it starts against that file. | |
| 12 | + | # | |
| 13 | + | # etc/systemd/system/alloy-usb-seed.service is what normally stops that from | |
| 14 | + | # happening: it writes a policy from the machine's own devices, ordered before | |
| 15 | + | # this unit. This condition is what happens when that fails — a machine whose | |
| 16 | + | # seeder could not run, or whose policy was emptied by hand, boots UNARMED | |
| 17 | + | # instead of bricked. Failing open is the correct direction for the same reason | |
| 18 | + | # the keyboard gate fails open. | |
| 19 | + | # | |
| 20 | + | # A condition rather than an assertion on purpose. `ConditionFileNotEmpty` makes | |
| 21 | + | # systemd skip the unit and record it as such; `AssertFileNotEmpty` would put | |
| 22 | + | # the unit into a failed state and pull that into whatever is waiting on it. The | |
| 23 | + | # machine is fine here, it is simply not enforcing, and it should not be | |
| 24 | + | # reported as broken. | |
| 25 | + | # | |
| 26 | + | # The consequence for the keyboard gate is real and handled there: `systemctl | |
| 27 | + | # start usbguard` on a machine with no policy now returns success while leaving | |
| 28 | + | # the unit inactive, so the gate checks whether the daemon is actually running | |
| 29 | + | # rather than trusting the exit code. | |
| 30 | + | ||
| 31 | + | [Unit] | |
| 32 | + | ConditionFileNotEmpty=/etc/usbguard/rules.conf |
| @@ -1,0 +1,108 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # alloy-usb-seed — write this machine's USB policy from the devices it has. | |
| 3 | + | # | |
| 4 | + | # The piece that makes arming usbguard safe, and the reason the preset line | |
| 5 | + | # could not land on its own. | |
| 6 | + | # | |
| 7 | + | # ## What it is for | |
| 8 | + | # | |
| 9 | + | # The image ships `/etc/usbguard/rules.conf` EMPTY, because that is what the | |
| 10 | + | # Fedora package ships and the Containerfile asserts it stays that way: an image | |
| 11 | + | # carrying a policy would be enforcing somebody else's idea of this machine. | |
| 12 | + | # Empty plus `ImplicitPolicyTarget=block` plus `PresentDevicePolicy=apply-policy` | |
| 13 | + | # means the daemon deauthorizes every USB device the moment it starts. | |
| 14 | + | # | |
| 15 | + | # On a desktop that is the keyboard, and the keyboard gate catches it. On a | |
| 16 | + | # laptop it is NOT the keyboard — the internal one is i8042 and USBGuard has no | |
| 17 | + | # jurisdiction over it — so the gate correctly stays shut and the machine comes | |
| 18 | + | # up with its webcam, its fingerprint reader and everything plugged into it | |
| 19 | + | # silently dead. Measured rather than feared: a machine booted armed against the | |
| 20 | + | # empty policy denied its only USB keyboard within a second of the daemon | |
| 21 | + | # starting. A silent denial is the worse failure of the two, because nothing on | |
| 22 | + | # screen connects the dead webcam to a policy decision. | |
| 23 | + | # | |
| 24 | + | # So the first armed boot has to enforce a policy about THIS machine, and the | |
| 25 | + | # only honest source for that is the machine. `usbguard generate-policy` reads | |
| 26 | + | # the bus and emits an allow rule per attached device. It is the one verb that | |
| 27 | + | # does not go through the daemon's IPC socket (measured on usbguard-1.1.4), | |
| 28 | + | # which is exactly why it can run here, before the daemon exists. | |
| 29 | + | # | |
| 30 | + | # ## What it therefore trusts | |
| 31 | + | # | |
| 32 | + | # Everything attached the first time the machine boots. That is the position, | |
| 33 | + | # stated rather than hidden: a machine trusts the peripherals it was set up | |
| 34 | + | # with, and everything after that is a decision the person makes from | |
| 35 | + | # `alloy usb`. The install medium is usually still in a port at that moment and | |
| 36 | + | # will get a rule of its own; it is the user's own medium, and the line names it | |
| 37 | + | # plainly enough to delete. | |
| 38 | + | # | |
| 39 | + | # ## Why it is safe to run on every boot | |
| 40 | + | # | |
| 41 | + | # It refuses to touch a policy that already exists, so it seeds once and is a | |
| 42 | + | # no-op forever after. It is not gated on a stamp file: the question "has this | |
| 43 | + | # machine got a policy" is answered by the policy, and a stamp would let a | |
| 44 | + | # machine whose rules.conf was emptied by hand arm against nothing. | |
| 45 | + | # | |
| 46 | + | # A machine with no USB devices at all generates nothing, and this leaves the | |
| 47 | + | # file empty rather than writing an empty one, so the next boot with something | |
| 48 | + | # attached seeds properly. Arming against an empty policy is refused separately | |
| 49 | + | # and structurally: etc/systemd/system/usbguard.service.d/ carries a | |
| 50 | + | # `ConditionFileNotEmpty` on the rule file, so a machine that reaches this state | |
| 51 | + | # boots unarmed rather than bricked, whatever happens in here. | |
| 52 | + | # | |
| 53 | + | # Dependency-light for the same reason the gate is: sh and usbguard, on the boot | |
| 54 | + | # path, before anything else is up. | |
| 55 | + | ||
| 56 | + | set -u | |
| 57 | + | ||
| 58 | + | POLICY=/etc/usbguard/rules.conf | |
| 59 | + | ||
| 60 | + | say() { | |
| 61 | + | printf 'alloy-usb-seed: %s\n' "$1" >&2 | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | # A policy that already says something is this machine's own answer, and not | |
| 65 | + | # ours to replace. `-s` is the whole test: see the header for why there is no | |
| 66 | + | # stamp file beside it. | |
| 67 | + | if [ -s "$POLICY" ]; then | |
| 68 | + | say "policy already written, leaving it alone" | |
| 69 | + | exit 0 | |
| 70 | + | fi | |
| 71 | + | ||
| 72 | + | command -v usbguard >/dev/null 2>&1 || { | |
| 73 | + | say "no usbguard on this machine, nothing to seed" | |
| 74 | + | exit 0 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | # Generated into a temporary file beside the real one rather than redirected | |
| 78 | + | # over it. A redirect truncates before the command runs, so a generate-policy | |
| 79 | + | # that failed halfway would leave a partial policy that looks like a complete | |
| 80 | + | # one — and a partial allow-list is a machine that denies whatever did not make | |
| 81 | + | # it into the file, silently. | |
| 82 | + | # | |
| 83 | + | # Beside it, rather than in /tmp, so the move is a rename within one filesystem | |
| 84 | + | # and therefore atomic. /etc/usbguard is 0700 root:root, so the temporary file | |
| 85 | + | # is no more readable than the policy it becomes. | |
| 86 | + | temporary="$POLICY.seed.$$" | |
| 87 | + | trap 'rm -f "$temporary"' EXIT INT TERM | |
| 88 | + | ||
| 89 | + | if ! usbguard generate-policy > "$temporary" 2>/dev/null; then | |
| 90 | + | say "generate-policy failed; leaving the policy empty, which leaves this machine unarmed" | |
| 91 | + | exit 0 | |
| 92 | + | fi | |
| 93 | + | ||
| 94 | + | if [ ! -s "$temporary" ]; then | |
| 95 | + | say "no USB devices attached, so there is nothing to allow; leaving the policy empty" | |
| 96 | + | exit 0 | |
| 97 | + | fi | |
| 98 | + | ||
| 99 | + | # The mode is set on the temporary file rather than after the move, so the | |
| 100 | + | # policy is never briefly readable under its real name. | |
| 101 | + | chmod 0600 "$temporary" 2>/dev/null || true | |
| 102 | + | if ! mv -f "$temporary" "$POLICY" 2>/dev/null; then | |
| 103 | + | say "could not write $POLICY; this machine stays unarmed" | |
| 104 | + | exit 0 | |
| 105 | + | fi | |
| 106 | + | ||
| 107 | + | say "wrote $(grep -c '^allow' "$POLICY" 2>/dev/null) rules for the devices attached to this machine" | |
| 108 | + | exit 0 |