max / alloy
9 files changed,
+772 insertions,
-2 deletions
| @@ -3364,6 +3364,66 @@ | |||
| 3364 | 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 | 3365 | echo "usbguard: installed, stock deny-all policy (the disarmed check runs after preset-all)" | |
| 3366 | 3366 | ||
| 3367 | + | # ===================================================================== | |
| 3368 | + | # The USB keyboard gate: on the client, and deliberately absent on the server. | |
| 3369 | + | # | |
| 3370 | + | # Step 4 of the `alloy usb` work, and the clause that makes deny-unknown safe | |
| 3371 | + | # to arm. It drops USB enforcement whenever the machine has zero usable | |
| 3372 | + | # keyboards and restores it when one appears. The reasoning, and the two | |
| 3373 | + | # measurements it rests on, are in usr/bin/alloy-usb-gate; the short version is | |
| 3374 | + | # that suspending enforcement means stopping the daemon AND re-authorizing the | |
| 3375 | + | # bus, because usbguard restores nothing on its way out. | |
| 3376 | + | # | |
| 3377 | + | # THE SPLIT IS THE RULING'S, and it is the opposite of what "the client is the | |
| 3378 | + | # careful profile" would suggest. A server-profile machine has no keyboard by | |
| 3379 | + | # design, so a gate counting keyboards would find zero on a healthy box and hold | |
| 3380 | + | # enforcement off forever, on exactly the machines that are physically exposed | |
| 3381 | + | # and hardest to visit. That profile enforces unconditionally and its recovery | |
| 3382 | + | # path is the provider console or a KVM. | |
| 3383 | + | # | |
| 3384 | + | # So the three files ride in from the COPY layers on both profiles, and the | |
| 3385 | + | # server branch takes them out again. Both branches assert, per the rule this | |
| 3386 | + | # file follows throughout and tests/profile_split.rs enforces: the client proves | |
| 3387 | + | # the gate is really there, and the server proves it is really gone rather than | |
| 3388 | + | # trusting that a branch it did not take was the right one. | |
| 3389 | + | # | |
| 3390 | + | # The client branch also checks the udev rule's number, which is load-bearing | |
| 3391 | + | # rather than cosmetic. The property the script reads is ID_INPUT_KEYBOARD, and | |
| 3392 | + | # udev's input_id builtin is what sets it; a rule sorting ahead of that reads | |
| 3393 | + | # the property before anything wrote it, counts zero keyboards on a machine full | |
| 3394 | + | # of them, and opens the gate on every boot. | |
| 3395 | + | # ===================================================================== | |
| 3396 | + | RUN set -eu; \ | |
| 3397 | + | gate=/usr/bin/alloy-usb-gate; \ | |
| 3398 | + | unit=/etc/systemd/system/alloy-usb-gate.service; \ | |
| 3399 | + | rule=/etc/udev/rules.d/70-alloy-usb-gate.rules; \ | |
| 3400 | + | if [ "$PROFILE" = client ]; then \ | |
| 3401 | + | test -x "$gate" \ | |
| 3402 | + | || { echo "$gate is missing or not executable; deny-unknown has no release valve on the profile that has a keyboard to lose" >&2; exit 1; }; \ | |
| 3403 | + | sh -n "$gate" \ | |
| 3404 | + | || { echo "$gate does not parse; the release valve is a syntax error and nobody finds out until a machine has no keyboard" >&2; exit 1; }; \ | |
| 3405 | + | test -f "$unit" \ | |
| 3406 | + | || { echo "$unit is missing; 50-alloy.preset enables a unit that does not exist and the gate never runs" >&2; exit 1; }; \ | |
| 3407 | + | test -f "$rule" \ | |
| 3408 | + | || { echo "$rule is missing; the gate would be boot-only, and a keyboard that dies mid-session is the same lockout" >&2; exit 1; }; \ | |
| 3409 | + | grep -q 'ID_INPUT_KEYBOARD' "$gate" \ | |
| 3410 | + | || { echo "$gate no longer reads ID_INPUT_KEYBOARD; ID_INPUT_KEY is set by power buttons and consumer controls, and counting those keeps the gate shut on a machine with no keyboard" >&2; exit 1; }; \ | |
| 3411 | + | case "$(basename "$rule")" in \ | |
| 3412 | + | [7-9][0-9]-*) : ;; \ | |
| 3413 | + | *) echo "$rule sorts before udev's input_id builtin, so ID_INPUT_KEYBOARD is unset when the script reads it and every boot counts zero keyboards" >&2; exit 1 ;; \ | |
| 3414 | + | esac; \ | |
| 3415 | + | echo "usb gate: installed on the client profile"; \ | |
| 3416 | + | else \ | |
| 3417 | + | rm -f "$gate" "$unit" "$rule"; \ | |
| 3418 | + | test ! -e "$unit" \ | |
| 3419 | + | || { echo "profile=server still carries the USB keyboard gate; a machine with no keyboard by design would hold enforcement off forever" >&2; exit 1; }; \ | |
| 3420 | + | test ! -e "$gate" \ | |
| 3421 | + | || { echo "profile=server still carries $gate" >&2; exit 1; }; \ | |
| 3422 | + | test ! -e "$rule" \ | |
| 3423 | + | || { echo "profile=server still carries the gate's udev rule, which would start a unit that is no longer there on every input event" >&2; exit 1; }; \ | |
| 3424 | + | echo "usb gate: correctly absent on a profile with no keyboard to lock out"; \ | |
| 3425 | + | fi | |
| 3426 | + | ||
| 3367 | 3427 | # ===================================================================== | |
| 3368 | 3428 | # Machine identity, from the builder: hostname and ssh pubkey. | |
| 3369 | 3429 | # ===================================================================== |
| @@ -385,7 +385,7 @@ | |||
| 385 | 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. | |
| 386 | 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). | |
| 387 | 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. | |
| 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. | |
| 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. The keyboard gate landed the same day and is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. What is left is the policy itself: deny-unknown, with an activation the user performs from this screen. | |
| 389 | 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. | |
| 390 | 390 | - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.) | |
| 391 | 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,7 +395,7 @@ | |||
| 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** | |
| 398 | + | ## USB device authorization: **usbguard, installed, gated, and not yet armed** | |
| 399 | 399 | ||
| 400 | 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 | 401 | ||
| @@ -405,6 +405,16 @@ | |||
| 405 | 405 | ||
| 406 | 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 | 407 | ||
| 408 | + | **The keyboard gate is built, 2026-08-22**, as `usr/bin/alloy-usb-gate` with `alloy-usb-gate.service` and a udev rule on the input subsystem, client profile only. Two measurements shaped it and both contradicted the obvious implementation. | |
| 409 | + | ||
| 410 | + | The first is what to count. The property is `ID_INPUT_KEYBOARD`, which udev's own `input_id` builtin sets, and not `ID_INPUT_KEY`. On fw13 four things carry `ID_INPUT_KEY` and are not keyboards: the power button, the video bus, the wireless radio control and the consumer control. A gate counting those stays shut on a machine whose only real keyboard has just been denied, and reports itself working while doing it. Reading the input subsystem rather than the USB bus is also what answers the i8042 case by construction rather than by a special case, since the Framework's internal keyboard is present there no matter what the USB policy says. A denied USB keyboard is absent from the same list for the same structural reason, because deauthorizing a device unbinds its interfaces, so "how many keyboards can this person type on" and "how many does the kernel show" turn out to be one question. | |
| 411 | + | ||
| 412 | + | The second is what suspending enforcement actually means. usbguard ships `RestoreControllerDeviceState=false` and its unit has no `ExecStop` at all, so nothing restores device authorization when the daemon goes away. A gate that stopped the daemon and stopped there would leave every denied device denied, the rescued keyboard included, and would do nothing whatever for the person it exists to rescue. Opening the gate is therefore stop, then authorize every device on the bus, then set `authorized_default` so a keyboard attached afterwards also comes up usable. | |
| 413 | + | ||
| 414 | + | It cannot arm anything, which is what makes it safe to enable on every client install including the overwhelming majority where usbguard is not running. The script only ever starts usbguard to undo a stop it recorded itself in a stamp file under `/run`. On a machine where nobody turned enforcement on it reads the keyboard count and exits. | |
| 415 | + | ||
| 416 | + | One case the ruling did not foresee, and the gate refuses to loop on it. A keyboard the policy has never heard of is denied the moment enforcement resumes, which returns the count to zero and reopens the gate, several times a second. A close followed by another open inside sixty seconds therefore latches the gate open and says so on VT1. Failing open is the right direction for a lockout valve. Whether the rescuing keyboard should instead be allowed permanently on the spot is a real decision with a security cost, and it is filed rather than taken. | |
| 417 | + | ||
| 408 | 418 | **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 | 419 | ||
| 410 | 420 | 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. |
| @@ -68,6 +68,15 @@ | |||
| 68 | 68 | //! it arrives, fronts the CLI: `allow-device`, `block-device`, and | |
| 69 | 69 | //! `allow-device --permanent` for a decision that survives a reboot. | |
| 70 | 70 | //! | |
| 71 | + | //! What did arrive on 2026-08-22 is the keyboard gate, `usr/bin/alloy-usb-gate`, | |
| 72 | + | //! which is the clause that makes deny-unknown safe to arm at all: it drops | |
| 73 | + | //! enforcement whenever the machine has zero usable keyboards. It is not this | |
| 74 | + | //! module's code and deliberately does not go through it — the gate has to work | |
| 75 | + | //! on a machine with no keyboard, which is a machine nobody is driving a TUI on. | |
| 76 | + | //! Worth knowing here for one reason: it decides what a keyboard is by reading | |
| 77 | + | //! `ID_INPUT_KEYBOARD` off the input subsystem, so a device this screen shows as | |
| 78 | + | //! denied is a device that has already stopped counting toward the gate. | |
| 79 | + | //! | |
| 71 | 80 | //! <!-- wiki: alloy-console --> | |
| 72 | 81 | ||
| 73 | 82 | use std::path::Path; |
| @@ -190,3 +190,24 @@ | |||
| 190 | 190 | # not a network listener. | |
| 191 | 191 | enable cups.socket | |
| 192 | 192 | enable cups.path | |
| 193 | + | ||
| 194 | + | # The USB keyboard gate, which is what makes deny-unknown safe to arm at all. | |
| 195 | + | # It drops USB enforcement whenever the machine has zero usable keyboards and | |
| 196 | + | # puts it back when one appears (usr/bin/alloy-usb-gate, and the udev rule that | |
| 197 | + | # makes it continuous). | |
| 198 | + | # | |
| 199 | + | # Enabled on every client install, including the overwhelming majority where | |
| 200 | + | # usbguard is not armed and this does nothing. That is safe by construction | |
| 201 | + | # rather than by luck: the script only ever *starts* usbguard to undo a stop it | |
| 202 | + | # performed itself, recorded in a stamp file under /run, so on a machine where | |
| 203 | + | # nobody turned enforcement on it reads the keyboard count and exits. It cannot | |
| 204 | + | # be the thing that arms deny-everything. | |
| 205 | + | # | |
| 206 | + | # Client only. This line matches nothing on the server profile, which is the | |
| 207 | + | # silent no-op this file warns about above and is deliberate here: that profile | |
| 208 | + | # has no keyboard by design, so a gate reading "zero keyboards" would hold | |
| 209 | + | # enforcement off permanently on exactly the machines that need it most. Its | |
| 210 | + | # recovery path is the provider console. The Containerfile removes the unit | |
| 211 | + | # there and asserts it is gone, which is what keeps this no-op intentional | |
| 212 | + | # rather than a rename nobody noticed. | |
| 213 | + | enable alloy-usb-gate.service |
| @@ -1,0 +1,344 @@ | |||
| 1 | + | //! The USB keyboard gate, checked on the axes that regress without a word. | |
| 2 | + | //! | |
| 3 | + | //! `usr/bin/alloy-usb-gate` is what makes USBGuard's deny-unknown posture safe | |
| 4 | + | //! to arm: it drops enforcement whenever the machine has zero usable keyboards. | |
| 5 | + | //! Everything about it fails silently. A gate that counts the wrong thing | |
| 6 | + | //! reports itself working on a machine that is already lost; a gate that stops | |
| 7 | + | //! the daemon without re-authorizing the bus leaves the keyboard it opened for | |
| 8 | + | //! still denied; a udev rule numbered one digit lower reads a property nothing | |
| 9 | + | //! has set yet and opens the gate on every boot of every machine. | |
| 10 | + | //! | |
| 11 | + | //! None of those show up in a build, and two of them do not show up on the | |
| 12 | + | //! bench either, because the bench machine has a PS/2 keyboard. So they are | |
| 13 | + | //! asserted here, in the same spirit as `layer_notice.rs` and `polkit_rules.rs`: | |
| 14 | + | //! a text check that runs on every `cargo test`, which is where these files are | |
| 15 | + | //! actually edited. | |
| 16 | + | //! | |
| 17 | + | //! The three end-to-end checks the ruling asks for — a machine booted with no | |
| 18 | + | //! USB keyboard, a Framework laptop on its i8042 keyboard alone, and unplugging | |
| 19 | + | //! the only USB keyboard mid-session — need real hardware and are not these. | |
| 20 | + | ||
| 21 | + | use std::path::PathBuf; | |
| 22 | + | ||
| 23 | + | fn repo(relative: &str) -> String { | |
| 24 | + | let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 25 | + | .join("../..") | |
| 26 | + | .join(relative); | |
| 27 | + | std::fs::read_to_string(&path) | |
| 28 | + | .unwrap_or_else(|error| panic!("reading {}: {error}", path.display())) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | fn script() -> String { | |
| 32 | + | repo("usr/bin/alloy-usb-gate") | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | fn unit() -> String { | |
| 36 | + | repo("etc/systemd/system/alloy-usb-gate.service") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn rule() -> String { | |
| 40 | + | repo("etc/udev/rules.d/70-alloy-usb-gate.rules") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// The script with comments dropped, because every string this file looks for | |
| 44 | + | /// also appears in the reasoning above it. | |
| 45 | + | fn code(text: &str) -> String { | |
| 46 | + | text.lines() | |
| 47 | + | .filter(|line| !line.trim_start().starts_with('#')) | |
| 48 | + | .collect::<Vec<_>>() | |
| 49 | + | .join("\n") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // ---- what counts as a keyboard ---- | |
| 53 | + | ||
| 54 | + | // The inverted failure, and the expensive one. Measured on fw13 2026-08-22: | |
| 55 | + | // Power Button, Video Bus, Wireless Radio Control and Consumer Control all | |
| 56 | + | // carry ID_INPUT_KEY=1, and none of them is a keyboard. A gate counting those | |
| 57 | + | // stays shut on a machine whose only real keyboard has just been denied, and | |
| 58 | + | // says nothing, because from its own point of view it is working. | |
| 59 | + | #[test] | |
| 60 | + | fn the_gate_counts_keyboards_and_not_everything_that_emits_a_key() { | |
| 61 | + | let code = code(&script()); | |
| 62 | + | assert!( | |
| 63 | + | code.contains("ID_INPUT_KEYBOARD=1"), | |
| 64 | + | "the gate no longer reads ID_INPUT_KEYBOARD, which is the only property \ | |
| 65 | + | that distinguishes a keyboard from a power button", | |
| 66 | + | ); | |
| 67 | + | assert!( | |
| 68 | + | !code.contains("ID_INPUT_KEY=1"), | |
| 69 | + | "the gate matches ID_INPUT_KEY, which a power button, a video bus and a \ | |
| 70 | + | consumer control all set: it would hold the gate shut on a machine \ | |
| 71 | + | with no keyboard at all", | |
| 72 | + | ); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // The i8042 half of the ruling, asserted as an absence. The internal Framework | |
| 76 | + | // keyboard is not a USB device, so it is counted by reading the input subsystem | |
| 77 | + | // and would be missed entirely by reading the USB bus. A gate rewritten over | |
| 78 | + | // /sys/bus/usb would open permanently on every Framework laptop. | |
| 79 | + | #[test] | |
| 80 | + | fn the_gate_reads_the_input_subsystem_rather_than_the_usb_bus() { | |
| 81 | + | let code = code(&script()); | |
| 82 | + | let counting = code | |
| 83 | + | .split_once("keyboard_count()") | |
| 84 | + | .expect("the gate still has a keyboard_count function") | |
| 85 | + | .1 | |
| 86 | + | .split_once("\n}") | |
| 87 | + | .expect("keyboard_count is a shell function") | |
| 88 | + | .0; | |
| 89 | + | assert!( | |
| 90 | + | counting.contains("/sys/class/input/"), | |
| 91 | + | "keyboard_count no longer reads /sys/class/input", | |
| 92 | + | ); | |
| 93 | + | assert!( | |
| 94 | + | !counting.contains("/sys/bus/usb"), | |
| 95 | + | "keyboard_count reads the USB bus, which cannot see the i8042 keyboard \ | |
| 96 | + | every Framework laptop has: the gate would open permanently on them", | |
| 97 | + | ); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // ---- suspending enforcement is not stopping the daemon ---- | |
| 101 | + | ||
| 102 | + | // usbguard-1.1.4-1.fc43 ships RestoreControllerDeviceState=false and its unit | |
| 103 | + | // has no ExecStop, so nothing restores device authorization when the daemon | |
| 104 | + | // goes away. Stopping it and stopping there would leave the denied keyboard | |
| 105 | + | // denied, which is the entire failure this gate exists to undo. | |
| 106 | + | #[test] | |
| 107 | + | fn opening_the_gate_re_authorizes_the_bus_and_not_only_the_daemon() { | |
| 108 | + | let code = code(&script()); | |
| 109 | + | let opening = code | |
| 110 | + | .split_once("open_gate()") | |
| 111 | + | .expect("the gate still has an open_gate function") | |
| 112 | + | .1 | |
| 113 | + | .split_once("\n}") | |
| 114 | + | .expect("open_gate is a shell function") | |
| 115 | + | .0; | |
| 116 | + | ||
| 117 | + | let stop = opening | |
| 118 | + | .find("systemctl stop") | |
| 119 | + | .expect("open_gate no longer stops the daemon"); | |
| 120 | + | let authorize = opening.find("authorize_everything").expect( | |
| 121 | + | "open_gate no longer re-authorizes the bus; usbguard restores nothing \ | |
| 122 | + | on its way out, so the keyboard it opened for is still denied", | |
| 123 | + | ); | |
| 124 | + | assert!( | |
| 125 | + | stop < authorize, | |
| 126 | + | "open_gate authorizes before it stops the daemon, so the daemon denies \ | |
| 127 | + | each device again as the loop walks the bus", | |
| 128 | + | ); | |
| 129 | + | ||
| 130 | + | let authorizing = code | |
| 131 | + | .split_once("authorize_everything()") | |
| 132 | + | .expect("the gate still has an authorize_everything function") | |
| 133 | + | .1 | |
| 134 | + | .split_once("\n}") | |
| 135 | + | .expect("authorize_everything is a shell function") | |
| 136 | + | .0; | |
| 137 | + | assert!( | |
| 138 | + | authorizing.contains("authorized_default"), | |
| 139 | + | "authorize_everything does not set authorized_default, so a keyboard \ | |
| 140 | + | plugged in after the gate opened still arrives denied", | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // The gate must never be the thing that turns deny-everything on. It is enabled | |
| 145 | + | // on every client install, including the ones where nobody has armed usbguard, | |
| 146 | + | // and on those it has to be inert forever. | |
| 147 | + | #[test] | |
| 148 | + | fn the_gate_only_ever_starts_usbguard_to_undo_its_own_stop() { | |
| 149 | + | let code = code(&script()); | |
| 150 | + | let starts: Vec<&str> = code | |
| 151 | + | .lines() | |
| 152 | + | .filter(|line| line.contains("systemctl start")) | |
| 153 | + | .collect(); | |
| 154 | + | assert_eq!( | |
| 155 | + | starts.len(), | |
| 156 | + | 1, | |
| 157 | + | "more than one place starts a unit; the gate may only start usbguard, \ | |
| 158 | + | and only to undo a stop it recorded: {starts:?}", | |
| 159 | + | ); | |
| 160 | + | ||
| 161 | + | let closing = code | |
| 162 | + | .split_once("close_gate()") | |
| 163 | + | .expect("the gate still has a close_gate function") | |
| 164 | + | .1 | |
| 165 | + | .split_once("\n}") | |
| 166 | + | .expect("close_gate is a shell function") | |
| 167 | + | .0; | |
| 168 | + | assert!( | |
| 169 | + | closing.contains("systemctl start"), | |
| 170 | + | "the one start is not in close_gate", | |
| 171 | + | ); | |
| 172 | + | assert!( | |
| 173 | + | code.contains("[ -e \"$STAMP\" ]"), | |
| 174 | + | "nothing guards close_gate on the stamp, so the gate could arm \ | |
| 175 | + | enforcement on a machine where nobody turned it on", | |
| 176 | + | ); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // A keyboard the policy has never heard of is denied the moment enforcement | |
| 180 | + | // resumes, which returns the count to zero and reopens the gate. Without a | |
| 181 | + | // latch that is a loop several times a second. | |
| 182 | + | #[test] | |
| 183 | + | fn a_flapping_gate_latches_open_rather_than_looping() { | |
| 184 | + | let code = code(&script()); | |
| 185 | + | assert!( | |
| 186 | + | code.contains("flapping"), | |
| 187 | + | "the flap guard is gone; a keyboard outside the policy makes the gate \ | |
| 188 | + | open and close forever", | |
| 189 | + | ); | |
| 190 | + | assert!(code.contains("FLAP_WINDOW"), "the flap window is gone",); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // A latch that nothing can clear is worse than no latch: enforcement back on, | |
| 194 | + | // the release valve permanently disabled, and nothing saying so. It has to | |
| 195 | + | // clear on the daemon being armed rather than on the stamp being gone, because | |
| 196 | + | // a person running `systemctl start usbguard` by hand removes no stamp. | |
| 197 | + | #[test] | |
| 198 | + | fn the_latch_clears_when_a_person_re_arms_enforcement() { | |
| 199 | + | let code = code(&script()); | |
| 200 | + | let latched = code | |
| 201 | + | .split_once("if [ -e \"$LATCH\" ]; then") | |
| 202 | + | .expect("the latch branch is still there") | |
| 203 | + | .1 | |
| 204 | + | .split_once("\nfi") | |
| 205 | + | .expect("the latch branch ends") | |
| 206 | + | .0; | |
| 207 | + | assert!( | |
| 208 | + | latched.contains("if armed;"), | |
| 209 | + | "the latch branch no longer clears on the daemon being armed, so a \ | |
| 210 | + | machine whose owner turned enforcement back on keeps a disabled gate \ | |
| 211 | + | forever", | |
| 212 | + | ); | |
| 213 | + | assert!( | |
| 214 | + | latched.contains("rm -f \"$LATCH\""), | |
| 215 | + | "nothing in the latch branch removes the latch", | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Removing the stamp before knowing the daemon came back loses the one record | |
| 220 | + | // that the gate is what suspended enforcement, on exactly the machine where | |
| 221 | + | // resuming it failed. | |
| 222 | + | #[test] | |
| 223 | + | fn closing_the_gate_drops_the_stamp_only_if_the_daemon_really_started() { | |
| 224 | + | let code = code(&script()); | |
| 225 | + | let closing = code | |
| 226 | + | .split_once("close_gate()") | |
| 227 | + | .expect("the gate still has a close_gate function") | |
| 228 | + | .1 | |
| 229 | + | .split_once("\n}") | |
| 230 | + | .expect("close_gate is a shell function") | |
| 231 | + | .0; | |
| 232 | + | let start = closing | |
| 233 | + | .find("systemctl start") | |
| 234 | + | .expect("close_gate no longer starts the daemon"); | |
| 235 | + | let unstamp = closing | |
| 236 | + | .find("rm -f \"$STAMP\"") | |
| 237 | + | .expect("close_gate no longer removes the stamp"); | |
| 238 | + | assert!( | |
| 239 | + | start < unstamp, | |
| 240 | + | "close_gate removes the stamp before starting the daemon, so a failed \ | |
| 241 | + | start leaves enforcement off with nothing recording that the gate did it", | |
| 242 | + | ); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // ---- the unit and the rule ---- | |
| 246 | + | ||
| 247 | + | // The gate has to be continuous. A boot-only check has already passed by the | |
| 248 | + | // time the keyboard dies. | |
| 249 | + | #[test] | |
| 250 | + | fn the_udev_rule_covers_both_edges_and_starts_the_unit() { | |
| 251 | + | let rule = rule(); | |
| 252 | + | let line = rule | |
| 253 | + | .lines() | |
| 254 | + | .find(|line| line.starts_with("SUBSYSTEM==")) | |
| 255 | + | .expect("the rule still has a match line"); | |
| 256 | + | assert!( | |
| 257 | + | line.contains("\"input\""), | |
| 258 | + | "the rule no longer watches input" | |
| 259 | + | ); | |
| 260 | + | assert!( | |
| 261 | + | line.contains("add") && line.contains("remove"), | |
| 262 | + | "the rule does not cover both edges, and remove is the one where the \ | |
| 263 | + | machine loses its last keyboard: {line}", | |
| 264 | + | ); | |
| 265 | + | assert!( | |
| 266 | + | line.contains("systemctl start") && line.contains("alloy-usb-gate.service"), | |
| 267 | + | "the rule no longer starts the unit: {line}", | |
| 268 | + | ); | |
| 269 | + | assert!( | |
| 270 | + | !line.contains("RUN+=\"/usr/bin/alloy-usb-gate"), | |
| 271 | + | "the rule runs the script directly from udev, which kills a RUN program \ | |
| 272 | + | that outlives its event and deadlocks on the udevadm call inside it", | |
| 273 | + | ); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // udev's input_id builtin is what sets ID_INPUT_KEYBOARD. A rule sorting ahead | |
| 277 | + | // of it reads the property before anything wrote it, counts zero keyboards, and | |
| 278 | + | // opens the gate on a healthy machine. | |
| 279 | + | #[test] | |
| 280 | + | fn the_udev_rule_sorts_after_the_builtin_that_sets_the_property_it_reads() { | |
| 281 | + | let name = "70-alloy-usb-gate.rules"; | |
| 282 | + | let prefix: u32 = name | |
| 283 | + | .split('-') | |
| 284 | + | .next() | |
| 285 | + | .and_then(|number| number.parse().ok()) | |
| 286 | + | .expect("the rule is numbered"); | |
| 287 | + | assert!( | |
| 288 | + | prefix >= 70, | |
| 289 | + | "the rule sorts before udev's input_id builtin at 60-input-id.rules, so \ | |
| 290 | + | ID_INPUT_KEYBOARD is unset when the gate reads it", | |
| 291 | + | ); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn the_unit_runs_the_gate_and_is_enabled_on_the_client() { | |
| 296 | + | // Comments dropped, because the unit explains in prose why it carries no | |
| 297 | + | // `Restart=`, and that sentence contains the string this is looking for. | |
| 298 | + | let unit = code(&unit()); | |
| 299 | + | assert!( | |
| 300 | + | unit.contains("ExecStart=/usr/bin/alloy-usb-gate"), | |
| 301 | + | "the unit no longer runs the gate", | |
| 302 | + | ); | |
| 303 | + | assert!( | |
| 304 | + | unit.contains("Type=oneshot"), | |
| 305 | + | "the unit is no longer a oneshot; it is edge-triggered and must not linger", | |
| 306 | + | ); | |
| 307 | + | assert!( | |
| 308 | + | !unit.contains("Restart="), | |
| 309 | + | "the unit restarts, which for an edge-triggered oneshot is a loop", | |
| 310 | + | ); | |
| 311 | + | ||
| 312 | + | let preset = repo("etc/systemd/system-preset/50-alloy.preset"); | |
| 313 | + | assert!( | |
| 314 | + | preset.contains("enable alloy-usb-gate.service"), | |
| 315 | + | "50-alloy.preset no longer enables the gate, so it runs on no machine", | |
| 316 | + | ); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // The ruling's split, and it is the counter-intuitive direction: the profile | |
| 320 | + | // with no keyboard is the one that must not have the gate. | |
| 321 | + | #[test] | |
| 322 | + | fn the_containerfile_asserts_the_gate_on_both_profiles() { | |
| 323 | + | let containerfile = repo("Containerfile"); | |
| 324 | + | let block = containerfile | |
| 325 | + | .split_once("gate=/usr/bin/alloy-usb-gate") | |
| 326 | + | .expect("the Containerfile still checks the gate") | |
| 327 | + | .1 | |
| 328 | + | .split_once("\n\n") | |
| 329 | + | .expect("the block ends") | |
| 330 | + | .0; | |
| 331 | + | assert!( | |
| 332 | + | block.contains("test -x \"$gate\""), | |
| 333 | + | "the client branch no longer proves the gate is present and executable", | |
| 334 | + | ); | |
| 335 | + | assert!( | |
| 336 | + | block.contains("rm -f \"$gate\""), | |
| 337 | + | "the server branch no longer removes the gate, so a keyboardless machine \ | |
| 338 | + | would hold enforcement off forever", | |
| 339 | + | ); | |
| 340 | + | assert!( | |
| 341 | + | block.contains("test ! -e \"$unit\""), | |
| 342 | + | "the server branch removes the gate without proving it is gone", | |
| 343 | + | ); | |
| 344 | + | } |
| @@ -1,0 +1,47 @@ | |||
| 1 | + | # Drop USB enforcement while this machine has no keyboard. | |
| 2 | + | # | |
| 3 | + | # Step 4 of the `alloy usb` work. The reasoning, the measurements and the | |
| 4 | + | # ordering all live in usr/bin/alloy-usb-gate; this file is only what runs it. | |
| 5 | + | # | |
| 6 | + | # Two triggers, and both are needed. | |
| 7 | + | # | |
| 8 | + | # The boot-time one is this unit's [Install]. It answers the machine that boots | |
| 9 | + | # with its keyboard already denied — the state a person actually arrives in, | |
| 10 | + | # since the policy was written on a day when a different keyboard was attached. | |
| 11 | + | # | |
| 12 | + | # The continuous one is etc/udev/rules.d/70-alloy-usb-gate.rules, which starts | |
| 13 | + | # this unit on every input add and remove. A keyboard that dies mid-session is | |
| 14 | + | # the same lockout with a running session behind it, and a boot-only check would | |
| 15 | + | # already have passed. The rule starts the unit rather than running the script | |
| 16 | + | # from `RUN+=`: udev kills a RUN program that outlives its event, and the script | |
| 17 | + | # calls systemctl and udevadm, both of which are the wrong things to do from | |
| 18 | + | # inside udev's own context. | |
| 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. | |
| 23 | + | # | |
| 24 | + | # It cannot arm anything: the script only ever starts usbguard to undo a stop it | |
| 25 | + | # performed itself. So enabling this on a machine where enforcement was never | |
| 26 | + | # turned on is a no-op forever, which is what makes it safe to enable on every | |
| 27 | + | # client install. | |
| 28 | + | ||
| 29 | + | [Unit] | |
| 30 | + | Description=Suspend USB device authorization while no keyboard is present | |
| 31 | + | Documentation=https://makenot.work/git/max/alloy | |
| 32 | + | After=systemd-udevd.service | |
| 33 | + | Wants=systemd-udevd.service | |
| 34 | + | # On a machine with no usbguard at all the script reads the keyboard count and | |
| 35 | + | # exits, which is harmless but pointless. The condition says so out loud rather | |
| 36 | + | # than leaving a unit that runs on every input event to do nothing. | |
| 37 | + | ConditionPathExists=/usr/bin/usbguard | |
| 38 | + | ||
| 39 | + | [Service] | |
| 40 | + | Type=oneshot | |
| 41 | + | ExecStart=/usr/bin/alloy-usb-gate | |
| 42 | + | # The script is best-effort throughout and returns 0 on every path it means to | |
| 43 | + | # take, so a non-zero here is a real fault worth seeing in the journal. No | |
| 44 | + | # Restart=: this is edge-triggered, and the next input event runs it again. | |
| 45 | + | ||
| 46 | + | [Install] | |
| 47 | + | WantedBy=multi-user.target |