max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
3 files changed,
+57 insertions,
-3 deletions
| @@ -469,6 +469,8 @@ | |||
| 469 | 469 | ||
| 470 | 470 | **Arming survives an update, and that is now measured rather than assumed.** `rules.conf` lives in `/etc`, which is the merged directory rather than part of the image, and seeding it with `usbguard generate-policy` immediately made `ostree admin config-diff` report `M usbguard/rules.conf`. That entry is the mechanism: a file ostree tracks as locally modified is carried into the next deployment, so a policy written on one boot is the policy the machine arms with after `bootc upgrade`. A policy that silently vanished on update would have been worse than none, which is why this was answered before the preset line was allowed anywhere near the image. | |
| 471 | 471 | ||
| 472 | + | **The gate's boot-time run has to wait for the daemon, and it did not.** Found by the bench test rather than by reading: a machine booted with no keyboard at all, enforcement armed, and the gate did nothing. It acts only when enforcement is already on — that is what makes it safe to enable on every install — so it asks `systemctl is-active usbguard`, and at boot it was losing that race. Measured in qemu (`build/vmtest`, `KEYBOARD=none`): the gate started at +10.202s, finished at +10.619s having read enforcement as off, and usbguard reached active at +10.708s. Nothing re-ran it, because the retrigger is an input add or remove and no keyboard was ever plugged in, so the machine sat with enforcement on and no way to type. Being `WantedBy=basic.target` is not ordering, and usbguard's unit declares no `Before=`, so nothing held it ahead of a `multi-user.target` unit. The fix is one `After=usbguard.service`, ordering only: never a `Wants=`, since the gate must never be a reason for the daemon to start, and `After=` naming a unit outside the transaction is a no-op on every machine Alloy ships today. Re-run on the same machine, the gate now starts after usbguard is active, stops it, and prints its notice. | |
| 473 | + | ||
| 472 | 474 | **Stopping the daemon does not put the bus back, and that is a trap for anyone arming by hand.** `systemctl stop usbguard` leaves `authorized_default` at `0` on every root hub, because `RestoreControllerDeviceState=false` is the shipped default and the unit has no `ExecStop`. Devices already attached keep the authorization they had, so nothing appears wrong; the next device plugged in comes up unauthorized, on a machine whose daemon is no longer running to explain why. Measured on fw12 by starting and stopping the daemon and reading the four hubs against fw13, which has never run one and reads `1` on all eight. This is the same mechanism `usr/bin/alloy-usb-gate` was already built around, confirmed here from the daemon's own lifecycle rather than from the gate's behaviour: opening the gate is stop, then authorize every device, then set `authorized_default`, and a hand-run disarm has to do the last step too. It is runtime state, so a reboot clears it; the machine in front of you does not. | |
| 473 | 475 | ||
| 474 | 476 | Rejected: **shipping the package on `client` only.** A server-profile machine has a USB bus, and the ruling gives that profile the stricter half of the policy precisely because it has no keyboard to lock out and its recovery path is a provider console. It needs this more than a laptop does, not less. |
| @@ -421,6 +421,37 @@ | |||
| 421 | 421 | ); | |
| 422 | 422 | } | |
| 423 | 423 | ||
| 424 | + | // The boot-time trigger is a race, and losing it is the lockout the whole | |
| 425 | + | // mechanism exists to prevent. | |
| 426 | + | // | |
| 427 | + | // The script acts only when enforcement is already on, which is what makes it | |
| 428 | + | // safe to enable everywhere, and it asks `systemctl is-active usbguard`. Without | |
| 429 | + | // ordering, the gate's own unit can finish before the daemon reaches active: | |
| 430 | + | // measured in qemu on a machine with no keyboard at all (build/vmtest, | |
| 431 | + | // KEYBOARD=none), the gate ran at +10.202s and finished at +10.619s having read | |
| 432 | + | // enforcement as off, and usbguard reached active at +10.708s. Nothing re-ran | |
| 433 | + | // it, because the retrigger is an input add or remove and no keyboard was ever | |
| 434 | + | // plugged in, so the machine sat armed with no way to type. | |
| 435 | + | // | |
| 436 | + | // Ordering only. A `Wants=` here would make the gate a reason for the daemon to | |
| 437 | + | // start, and the gate must never be able to arm anything. | |
| 438 | + | #[test] | |
| 439 | + | fn the_unit_waits_for_usbguard_before_it_decides_at_boot() { | |
| 440 | + | let unit = code(&unit()); | |
| 441 | + | assert!( | |
| 442 | + | unit.contains("After=usbguard.service"), | |
| 443 | + | "the gate no longer orders after usbguard, so its boot-time run can read \ | |
| 444 | + | enforcement as off on the machine that most needs it and do nothing", | |
| 445 | + | ); | |
| 446 | + | for forbidden in ["Wants=usbguard.service", "Requires=usbguard.service"] { | |
| 447 | + | assert!( | |
| 448 | + | !unit.contains(forbidden), | |
| 449 | + | "the unit pulls usbguard in with {forbidden}; the gate must never be \ | |
| 450 | + | a reason for enforcement to start", | |
| 451 | + | ); | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 424 | 455 | // The ruling's split, and it is the counter-intuitive direction: the profile | |
| 425 | 456 | // with no keyboard is the one that must not have the gate. | |
| 426 | 457 | #[test] |
| @@ -17,9 +17,26 @@ | |||
| 17 | 17 | # calls systemctl and udevadm, both of which are the wrong things to do from | |
| 18 | 18 | # inside udev's own context. | |
| 19 | 19 | # | |
| 20 | - | # Ordering is deliberately loose. This has to be able to run at any moment, | |
| 21 | - | # because the event it responds to happens at any moment, so it orders after | |
| 22 | - | # nothing except the units that make its two tools work. | |
| 20 | + | # Ordering is loose for the udev trigger and NOT loose for the boot one, and the | |
| 21 | + | # difference is a lockout. The script only acts when enforcement is already on, | |
| 22 | + | # because a gate that could arm nothing is what makes it safe to enable | |
| 23 | + | # everywhere; it asks `systemctl is-active usbguard`. At boot that is a race it | |
| 24 | + | # used to lose. Measured in qemu on a machine with no keyboard at all | |
| 25 | + | # (build/vmtest, KEYBOARD=none): this unit started at +10.202s, usbguard started | |
| 26 | + | # at +10.390s, this unit finished at +10.619s having read `usbguard` as not | |
| 27 | + | # running and done nothing, and usbguard reached active at +10.708s. Nothing ever | |
| 28 | + | # re-ran the gate, because the retrigger is an input add or remove and no | |
| 29 | + | # keyboard was ever plugged in. The machine sat with enforcement on and no way to | |
| 30 | + | # type, which is the exact failure this whole mechanism exists to prevent. | |
| 31 | + | # | |
| 32 | + | # Being WantedBy=basic.target is not ordering: usbguard.service declares no | |
| 33 | + | # Before=, so nothing held it ahead of a multi-user.target unit. | |
| 34 | + | # | |
| 35 | + | # So: After=usbguard.service, ordering only. No Wants= and no Requires=, because | |
| 36 | + | # this must never be a reason for the daemon to start. `After=` naming a unit | |
| 37 | + | # that is not in the transaction is a no-op, so on every machine Alloy ships | |
| 38 | + | # today — usbguard installed, deliberately not enabled — this changes nothing and | |
| 39 | + | # the gate still reads the keyboard count and exits. | |
| 23 | 40 | # | |
| 24 | 41 | # It cannot arm anything: the script only ever starts usbguard to undo a stop it | |
| 25 | 42 | # performed itself. So enabling this on a machine where enforcement was never | |
| @@ -31,6 +48,10 @@ | |||
| 31 | 48 | Documentation=https://makenot.work/git/max/alloy | |
| 32 | 49 | After=systemd-udevd.service | |
| 33 | 50 | Wants=systemd-udevd.service | |
| 51 | + | # Ordering only, and never a Wants=. See the header: without this the boot-time | |
| 52 | + | # run reads enforcement as off and silently does nothing on the one machine that | |
| 53 | + | # needs it most. | |
| 54 | + | After=usbguard.service | |
| 34 | 55 | # On a machine with no usbguard at all the script reads the keyboard count and | |
| 35 | 56 | # exits, which is harmless but pointless. The condition says so out loud rather | |
| 36 | 57 | # than leaving a unit that runs on every input event to do nothing. |