Skip to main content

max / alloy

Make the USB gate wait for usbguard before it decides at boot Found by running the bench test the task has been waiting on rather than by reading. A machine booted with no keyboard at all and enforcement armed, and the gate did nothing: no stamp, no console notice, usbguard still running. The machine sat with deny-unknown on and no way to type, which is the exact lockout this mechanism exists to prevent. The gate acts only when enforcement is already on, because a gate that could arm nothing is what makes it safe to enable on every install, so it asks systemctl is-active usbguard. At boot that was a race it lost. Measured in qemu with KEYBOARD=none: gate started at +10.202s, finished at +10.619s having read enforcement as off, 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. 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. One After=usbguard.service, ordering only. Never a Wants=: the gate must not be a reason for the daemon to start. After= naming a unit outside the transaction is a no-op, so on every machine Alloy ships today, with usbguard installed and deliberately not enabled, this changes nothing. Re-run on the same machine, the gate now starts after usbguard is active, stops it, re-authorizes the bus and prints its notice.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 19:44 UTC
Signed with PGP, not checked
Commit: c9debbb11e270ccfc6b4c0a7c219d45c00b2913a
Parent: 26f7d65
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.