Skip to main content

max / alloy

Arm USB device authorization by default, and make that survivable The ruling from 2026-08-22 landing: enforcement on by default (GoingsOn alloy 63de3d4c). The preset line is the smallest part of it. Arming is four states, and taking the line without the other three is how the image deauthorizes its own hardware at boot. The policy is written on the machine, not shipped in the image. alloy-usb-seed runs generate-policy before the daemon starts, so the first armed boot enforces a statement about this machine rather than the empty rules.conf Fedora ships. It refuses to touch a policy that already exists, so it seeds once and is a no-op after; the policy file is the state rather than a stamp beside it, because a stamp would let a machine whose rules.conf was emptied by hand arm against nothing. It generates into a temporary file and moves it, since a redirect truncates first and a generate that failed halfway leaves a partial allow-list, which is a machine silently denying whatever did not make the file. usbguard now carries a ConditionFileNotEmpty on that file, so it will not start against an empty policy at all. A machine whose seeding failed boots unarmed rather than with every device denied. A condition and not an assertion: not enforcing is not a fault and must not be reported as one. That changes what `systemctl start usbguard` means, so the gate stops trusting it. A unit skipped by a condition returns 0 and stays inactive, and close_gate would have said enforcement was back on while nothing was enforcing. It asks the daemon now. 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, because the internal keyboard is i8042 and outside usbguard's jurisdiction, so the gate correctly stays shut and the machine comes up with its webcam and everything in a port silently dead. Verified on a booted machine. From an empty policy: seeder writes 3 rules at +0.823s, usbguard starts at +0.881s and applies them at +0.920s with every device target.new='allow'. With the seeder blocked, usbguard is skipped with "unmet condition check ConditionFileNotEmpty" and the machine boots usable and unprotected. The Containerfile's "usbguard is still disarmed" assertion is the one it predicted would have to be consciously deleted. It now asserts all four states together, and checks the drop-in by path and exact line rather than by asking systemd: `systemctl show -p ConditionFileNotEmpty` prints nothing even where the drop-in is live, so the first version of that check would have failed the build for a drop-in that was working. Still missing, and named in the docs: the prompt. usbguard watch --exec is the hook and nothing runs it, so a device attached after setup is denied silently and `alloy usb` is how a person finds out.
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 20:12 UTC
Signed with PGP, not checked
Commit: 0038862410bf842e841424305429c3883c329a22
Parent: c41f27f
9 files changed, +497 insertions, -32 deletions
M Containerfile +71 -30
@@ -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