max / alloy
4 files changed,
+141 insertions,
-5 deletions
| @@ -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 |
| @@ -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 |