Skip to main content

max / alloy

Put usbguard in both profiles, and assert that it stays disarmed Step 2 of the `alloy usb` work. The package is what gives the view's action half something to call; the daemon is not enabled, and that is the finding rather than caution. Measured on the built client and server images rather than read out of usbguard's documentation. /etc/usbguard/rules.conf ships empty, ImplicitPolicyTarget=block blocks anything matching no rule, and PresentDevicePolicy and InsertedDevicePolicy both apply-policy. Those four together deauthorize every USB device at boot, keyboard included, which is unrecoverable without a second one. So the enable line waits for the policy and the keyboard gate, and two assertions hold that: one beside the config checks for the knobs and the empty rule file, one after preset-all for the unit still being disabled, because before preset-all every unit reads disabled and the check would mean nothing. Cost is 1.3 MiB and a 4.8 MiB closure on both profiles, against 16.4 on a bare fedora-bootc:43 -- usbguard-selinux pulls the policycoreutils python stack, which this image already carries.
Author: Max Johnson <me@maxj.phd> · 2026-08-22 23:39 UTC
Signed with PGP, not checked
Commit: 239d216f7cfcf0f43dbc184c06a6ac011ec8eb77
Parent: 474d84f
4 files changed, +141 insertions, -5 deletions
M Containerfile +101
@@ -1223,6 +1223,39 @@
1223 1223 # and the zone assignment is further down beside the other config-tree
1224 1224 # assertions.
1225 1225 firewalld \
1226 + # Device authorization for the USB bus, ruled 2026-08-22 (GoingsOn
1227 + # alloy 63de3d4c: enforcement on by default, interactive activation,
1228 + # and the gate drops whenever no usable keyboard is present).
1229 + #
1230 + # The view half already shipped and needs none of this: `alloy usb`
1231 + # reads sysfs and works on an image that has never heard of usbguard.
1232 + # What the package adds is the ability to act on what that screen
1233 + # shows — deauthorize an attachment, and remember a decision.
1234 + #
1235 + # 1.3 MiB, and the closure is 4.8 MiB on this image: protobuf (3.3),
1236 + # libqb (0.2) and usbguard-selinux (0.01). Measured on the built
1237 + # client and server images both, and the number is worth stating
1238 + # because on a bare fedora-bootc:43 the same install is 16.4 MiB —
1239 + # usbguard-selinux pulls policycoreutils-python-utils and with it
1240 + # python3-policycoreutils and python3-setools, all of which this
1241 + # image already carries. Neither profile pays for them twice.
1242 + #
1243 + # In the base rather than on `client` alone. A server-profile machine
1244 + # has a USB bus too, and the ruling gives that profile the stricter
1245 + # half of the policy (no keyboard gate, because it has no keyboard by
1246 + # design), so it is the profile that needs this more, not less.
1247 + #
1248 + # THE UNIT IS NOT ENABLED HERE, and 50-alloy.preset deliberately does
1249 + # not list it. The reason is measured rather than cautious: the
1250 + # package ships an EMPTY /etc/usbguard/rules.conf and
1251 + # `ImplicitPolicyTarget=block`, and `PresentDevicePolicy=apply-policy`
1252 + # applies that to devices already attached when the daemon starts. So
1253 + # enabling the stock unit with the stock policy deauthorizes every USB
1254 + # device on the machine at boot, keyboard included. The policy (step 3
1255 + # of the task) and the keyboard gate (step 4) are what make the enable
1256 + # line safe, and it lands with them, after the three bench tests the
1257 + # task names. The assertion further down is what keeps that true.
1258 + usbguard \
1226 1259 # Hardware health. The base ships nvme-cli, so NVMe wear and SMART
1227 1260 # were already readable, and nothing else was: no way to read ECC
1228 1261 # corrected-error counts, no SMART for SATA, no path to a BMC. That
@@ -3286,6 +3319,51 @@
3286 3319 || { echo "the trusted zone no longer accepts; the binding above would bind to a zone that drops" >&2; exit 1; }; \
3287 3320 echo "firewall: tailscale0 bound to the trusted zone"
3288 3321
3322 + # =====================================================================
3323 + # USBGuard is present and is NOT armed, asserted on both profiles.
3324 + #
3325 + # This is the unusual case where the assertion's job is to hold a feature
3326 + # OFF. The ruling (GoingsOn alloy 63de3d4c) is enforcement on by default,
3327 + # and the daemon is one preset line away from that — which is exactly the
3328 + # problem, because the policy it would enforce today is the package's
3329 + # stock one and the package's stock policy denies everything.
3330 + #
3331 + # Four facts, all read off the fedora-43 package on 2026-08-22 rather
3332 + # than out of its documentation, and each is why one line below exists:
3333 + #
3334 + # ImplicitPolicyTarget=block a device matching no rule is blocked
3335 + # PresentDevicePolicy=apply-policy including devices already attached
3336 + # InsertedDevicePolicy=apply-policy and ones plugged in later
3337 + # /etc/usbguard/rules.conf ships EMPTY, so nothing matches a rule
3338 + #
3339 + # Together those four are "deauthorize every USB device at boot". On a
3340 + # desktop that is the keyboard, and the machine is then unrecoverable
3341 + # without another one. So the shape of this check is: the package must be
3342 + # here (the console's action half depends on it), the knobs must still
3343 + # read the way they were measured (if Fedora changes one, the reasoning
3344 + # above stops holding and someone must look again), and the unit must not
3345 + # be enabled until the policy and the keyboard gate exist.
3346 + #
3347 + # The fourth line of that check — that the unit is not enabled — cannot
3348 + # live here: `systemctl preset-all` runs several hundred lines further
3349 + # down, so an assertion at this point would read the state before anything
3350 + # had a chance to arm the daemon and would pass on an image that boots
3351 + # armed. It sits immediately after preset-all instead.
3352 + # =====================================================================
3353 + RUN set -eu; \
3354 + conf=/etc/usbguard/usbguard-daemon.conf; \
3355 + test -f "$conf" \
3356 + || { echo "$conf is missing; the usbguard package did not land" >&2; exit 1; }; \
3357 + command -v usbguard >/dev/null \
3358 + || { echo "the usbguard CLI is missing; alloy usb has nothing to front for its action half" >&2; exit 1; }; \
3359 + for knob in ImplicitPolicyTarget=block PresentDevicePolicy=apply-policy InsertedDevicePolicy=apply-policy; do \
3360 + grep -qx "$knob" "$conf" \
3361 + || { echo "$conf no longer reads $knob; the default-deny reasoning in the package block was measured against it and needs re-reading" >&2; exit 1; }; \
3362 + done; \
3363 + test ! -s /etc/usbguard/rules.conf \
3364 + || { echo "/etc/usbguard/rules.conf is no longer empty; Fedora shipped a policy and this image would enforce someone else's" >&2; exit 1; }; \
3365 + echo "usbguard: installed, stock deny-all policy (the disarmed check runs after preset-all)"
3366 +
3289 3367 # =====================================================================
3290 3368 # Machine identity, from the builder: hostname and ssh pubkey.
3291 3369 # =====================================================================
@@ -3515,6 +3593,29 @@
3515 3593
3516 3594 RUN systemctl preset-all
3517 3595
3596 + # =====================================================================
3597 + # ...and usbguard is still disarmed, which only this side of preset-all
3598 + # can say.
3599 + #
3600 + # The package block and the config assertion above establish that the
3601 + # daemon would enforce a deny-everything policy if it ran. This is the
3602 + # line that says it does not run. It has to be here rather than beside
3603 + # them: `preset-all` is what turns a preset line into an enable symlink,
3604 + # so before it every unit in the image reads disabled and the check would
3605 + # pass without meaning anything.
3606 + #
3607 + # Two ways it could arm, and this catches both. A line added to
3608 + # 50-alloy.preset — the step-3-and-4 work will eventually add exactly
3609 + # that, and this assertion is the thing it has to consciously delete.
3610 + # And Fedora's own 90-default.preset, if the usbguard package ever starts
3611 + # shipping an enable line of its own; today it does not, and that is a
3612 + # fact about someone else's file rather than one about ours.
3613 + # =====================================================================
3614 + RUN set -eu; \
3615 + ! systemctl is-enabled usbguard.service >/dev/null 2>&1 \
3616 + || { 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; }; \
3617 + echo "usbguard: disarmed after preset-all"
3618 +
3518 3619 # Template instances have to be enabled by name. `preset-all` iterates over
3519 3620 # the unit *files* that exist, and `alloy-debug-shell@.service` is a template
3520 3621 # with no instance of its own, so a preset line naming `@tty9` matches nothing
M docs/CONSOLE.md +11 -1
@@ -228,6 +228,16 @@
228 228 of them, so the reads go straight to `/sys` the way `alloy display` does, and
229 229 every parse is a pure function over a string.
230 230
231 + That stays true now that the image carries usbguard (2026-08-22). The package is
232 + installed on both profiles and its daemon is deliberately not enabled, so the
233 + screen still has nothing to depend on and still reads the same files on a machine
234 + where the daemon never starts. When the action half lands it fronts
235 + `usbguard list-devices`, `allow-device` and `block-device` the way `alloy
236 + bluetooth` fronts `bluetoothctl`, which keeps the log pane's promise intact: the
237 + plug-then-prompt hook is `usbguard watch --exec`, a CLI with an argv, rather than
238 + the D-Bus interface the stock applets use. See docs/STACK.md for why the daemon
239 + ships disarmed.
240 +
231 241 **Two tabs, because a charger and a keyboard are answered by different
232 242 subsystems.** The bus knows what enumerated and what each interface claims to be;
233 243 the Type-C connectors know which way power is flowing, whether the partner speaks
@@ -375,7 +385,7 @@
375 385 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only.
376 386 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
377 387 - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page, added after the summary as a sixth step. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install now starts from this screen rather than from the summary, which is the ordering the page argues for: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
378 - - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. What is left is the policy the view was filed to front: usbguard into both profiles, deny-unknown with an activation the user performs from this screen, and the keyboard gate that suspends enforcement whenever the machine has no usable keyboard. That gate is the part that makes deny-unknown safe, and it has to count i8042 keyboards as usable or it opens permanently on every Framework laptop, whose internal keyboard is not a USB device at all.
388 + - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles as of 2026-08-22 and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. What is left is that policy: deny-unknown with an activation the user performs from this screen, and the keyboard gate that suspends enforcement whenever the machine has no usable keyboard. That gate is the part that makes deny-unknown safe, and it has to count i8042 keyboards as usable or it opens permanently on every Framework laptop, whose internal keyboard is not a USB device at all.
379 389 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
380 390 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
381 391 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -395,6 +395,22 @@
395 395
396 396 Not settled here, and filed as its own work: whether `alloy net` should show the zone alongside the interface. firewalld is a NetworkManager-integrated daemon and `alloy net` is an nmcli front, so the state is one `nmcli` field away, but showing it is a console design question rather than part of the pick.
397 397
398 + ## USB device authorization: **usbguard, installed and not yet armed**
399 +
400 + **usbguard on both profiles, with the enforcement half deliberately switched off until the policy and the keyboard gate ship.** The view came first and is already here: `alloy usb` reads sysfs, needs no package, and works on an image that has never heard of usbguard. What the package adds is the ability to act on what that screen shows, which is deauthorizing an attachment and remembering the decision.
401 +
402 + Measured on `localhost/alloy:firewall` and `:firewall-server` before taking it, 2026-08-22. usbguard is 1.3 MiB and the closure is 4.8 MiB on both profiles: `protobuf` (3.3), `libqb` (0.2), `usbguard-selinux` (0.01). The comparison worth keeping is against a bare `fedora-bootc:43`, where the same install costs 16.4 MiB, because `usbguard-selinux` pulls `policycoreutils-python-utils` and behind it `python3-policycoreutils` and `python3-setools`. This image already carries all three, so neither profile pays for them twice. Same shape as firewalld above: a package that looks expensive in isolation and is mostly already present.
403 +
404 + **The daemon is one preset line from armed, and that line is the dangerous one.** Read off the fedora-43 package rather than its documentation: `/etc/usbguard/rules.conf` ships empty, `ImplicitPolicyTarget=block` blocks anything matching no rule, and `PresentDevicePolicy=apply-policy` and `InsertedDevicePolicy=apply-policy` apply that both to devices already attached at daemon start and to ones plugged in later. Together those four are "deauthorize every USB device at boot", which on a desktop is the keyboard, and the machine is then unrecoverable without a second one. So the package ships and the unit does not, and the Containerfile asserts both: that the knobs still read the way they were measured, and that `usbguard.service` is still disabled on the far side of `preset-all`.
405 +
406 + **What has to land before the enable line does** is the ruling in GoingsOn alloy `63de3d4c`: deny unknown and prompt, activation from `alloy usb`, permanent allow rules keyed on vendor and product plus serial, and enforcement suspended whenever the machine has zero usable keyboards. That last clause is the one that makes the rest safe, and it has a measured subtlety. The internal keyboard on a Framework laptop is `AT Translated Set 2 keyboard` at `isa0060/serio0`, an i8042 device that usbguard has no jurisdiction over, so a client with every USB device denied still has a working keyboard and the gate must stay shut. A gate that counted only USB keyboards would open permanently on every Framework laptop, which is the inverted failure and is worth a test.
407 +
408 + **The verbs are all in the CLI, so there is nothing to front but a CLI.** `usbguard list-devices`, `allow-device`, `block-device`, `append-rule`, and `generate-policy` cover the whole of it, and `allow-device --permanent` is exactly the "save this device" action the ruling describes. `usbguard watch --exec <path>` runs a program per plug event and is the plug-then-prompt hook, which means the console needs no D-Bus client: the same correction already made for `alloy bluetooth`, and it holds here for the same reason, that the log pane can only show an argv if there was one. `DeviceRulesWithPort=false` is already the default, so a permanent rule is keyed on the device rather than the socket it was in, which is what the ruling asks for and what a device that moves ports requires.
409 +
410 + 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.
411 +
412 + 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.
413 +
398 414 ## VPN
399 415
400 416 **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.
@@ -54,10 +54,19 @@
54 54 //!
55 55 //! The console's habit is to front someone else's CLI so the log pane can show
56 56 //! the argv (see [`cli`](crate::cli)). There is no CLI here to front: `lsusb`
57 - //! reads the same files and drops most of them, and the enforcement half will
58 - //! bring `usbguard` with it. Following `display`, the reads go straight to
59 - //! sysfs and every parse is a pure function over a string, so the tests describe
60 - //! machines this one is not.
57 + //! reads the same files and drops most of them. Following `display`, the reads
58 + //! go straight to sysfs and every parse is a pure function over a string, so the
59 + //! tests describe machines this one is not.
60 + //!
61 + //! The image gained `usbguard` on 2026-08-22 and this module still does not use
62 + //! it, which is deliberate on both counts. The package is there so the action
63 + //! half has something to call; its daemon ships disabled, because its stock
64 + //! policy is an empty rule file with `ImplicitPolicyTarget=block` and arming
65 + //! that deauthorizes the machine's own keyboard at boot. Reading sysfs rather
66 + //! than `usbguard list-devices` is what keeps this screen answering on a machine
67 + //! where the daemon is off, which today is every machine. The action half, when
68 + //! it arrives, fronts the CLI: `allow-device`, `block-device`, and
69 + //! `allow-device --permanent` for a decision that survives a reboot.
61 70 //!
62 71 //! <!-- wiki: alloy-console -->
63 72