Skip to main content

max / alloy

Enable the encrypted install, and answer the audit's open absences bootc gates --block-setup behind the image's own install config. 00-alloy.toml declared a root filesystem type and no `block` key, so bootc permitted `direct` alone and refused `--block-setup tpm2-luks` with "tpm2-luks not enabled in installation config". The installer offers encryption on step 4 and defaults it to on, so every user taking the default hit a dead path on a disk already selected for erasure, on hardware with a working TPM. Nothing caught it because the flag lives in Rust and the permission lives in TOML. `direct` stays first: bootc takes the first entry as the default for an install passing no --block-setup, and the installer omits the flag precisely when the user declined encryption. Reversed, declining would hand back an encrypted disk nobody was asked for a passphrase for. fprintd, through authselect rather than a hand-written PAM file. system-auth is generated and an Alloy-authored copy would be discarded on the next apply-changes. swaylock reaches it without knowing: its PAM file includes login, which includes system-auth, so the lock screen, the greeter and run0 all gain the same unlock from one switch. pam_fprintd lands `sufficient`, so a bad finger, a busy reader or nothing enrolled all fall through to the password. Printing: CUPS, driverless only. IPP Everywhere and nothing else, with foomatic-db, gutenprint and hplip asserted out rather than assumed out. Its own layer with weak deps off, which is 33 packages instead of 52. Discovery was already paid for by nss-mdns. cups.socket, never cups.service. No scanning, now recorded as a rejection rather than left as silence. Bluetooth was already on by inheritance from Fedora's 90-default.preset and by nobody's decision, the same defect previously corrected for sshd and avahi. State unchanged, accountability changed. Also drops the second SwayOSD unit shim. The copy in the packaging block already created the path the later block tested, so its guard always took the first branch and printed "the shim can go", which read as the workaround being obsolete when it was the earlier block satisfying it. The survivor is the one that fails the build loudly when Fedora corrects the packaging.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 01:46 UTC
Signed with PGP, not checked
Commit: 8cae9790d3a6f50aa13f19d85af3a3fdf459ac5d
Parent: bf5dc80
4 files changed, +242 insertions, -29 deletions
M Containerfile +144 -28
@@ -411,6 +411,19 @@
411 411 swaylock \
412 412 swayidle \
413 413 swayosd \
414 + # Fingerprint unlock, which swaylock reaches without knowing it: its
415 + # /etc/pam.d/swaylock is `auth include login`, login includes
416 + # system-auth, and system-auth is what authselect rewrites when the
417 + # with-fingerprint feature is enabled below. So the lock screen, the
418 + # greeter and run0 all gain the same unlock from one switch, and none
419 + # of them needs an Alloy-authored PAM file.
420 + #
421 + # fprintd-pam is the half that matters and is not pulled in by fprintd:
422 + # the daemon can enroll a finger all day, but without the PAM module
423 + # nothing ever asks it. Both named so neither arrives as somebody
424 + # else's transitive dependency.
425 + fprintd \
426 + fprintd-pam \
414 427 playerctl \
415 428 gammastep \
416 429 # Continuity
@@ -628,6 +641,56 @@
628 641 jq \
629 642 && dnf clean all
630 643
644 + # =====================================================================
645 + # Printing — driverless only, and deliberately in its own layer.
646 + #
647 + # Alloy prints to IPP Everywhere devices and nothing else. That is the
648 + # whole position: every printer sold since roughly 2015 speaks it, the
649 + # printer advertises its own capabilities, and there is no driver to
650 + # pick, install or match. A printer that needs a vendor driver is not
651 + # supported, stated plainly rather than left to be discovered.
652 + #
653 + # What that buys is the absence of the usual printing stack:
654 + # foomatic-db is thousands of PPDs, gutenprint and hplip are vendor
655 + # driver estates, and none of them is reachable from a driverless-only
656 + # position. Verified against the resolved transaction rather than
657 + # assumed — none of the three appears. ghostscript does, as a hard
658 + # dependency of cups-filters for rasterizing, and is accepted.
659 + #
660 + # The prerequisite was already in place, which is what makes this cheap:
661 + # discovery is mDNS, and nss-mdns is installed with /etc/nsswitch.conf
662 + # already resolving .local (see the avahi block). So a driverless
663 + # printer appears by itself and most users never configure anything.
664 + #
665 + # Weak dependencies off, and that is why this is a separate `dnf
666 + # install` rather than more lines in the block above: on the default
667 + # `install_weak_deps=True` this transaction is 52 packages instead of
668 + # 33, and the extra 19 are recommends nothing here asked for. The block
669 + # above does not pass the flag, so folding these in would have taken
670 + # the larger set silently.
671 + #
672 + # No scanning. sane-backends is per-device backends and USB permission
673 + # work, its failures are opaque, and a scanner is far rarer on a laptop
674 + # than a printer. Recorded as a rejection in docs/STACK.md rather than
675 + # left as silence, per principle 5.
676 + #
677 + # The surface is CUPS' own web UI on localhost:631. It needs no GTK and
678 + # costs nothing when unused, which matters because socket activation
679 + # means the daemon is not running until something connects.
680 + # =====================================================================
681 + RUN dnf install -y --setopt=install_weak_deps=False \
682 + cups \
683 + cups-filters \
684 + && dnf clean all
685 + RUN set -eu; \
686 + for unwanted in foomatic-db gutenprint hplip; do \
687 + rpm -q "$unwanted" >/dev/null 2>&1 \
688 + && { echo "$unwanted arrived with the printing stack; the driverless-only position is no longer true" >&2; exit 1; }; \
689 + done; \
690 + test -f /usr/lib/systemd/system/cups.socket \
691 + || { echo "cups ships no socket unit; the preset would enable nothing and printing would need a running daemon" >&2; exit 1; }; \
692 + echo "printing: driverless only, no vendor driver packages"
693 +
631 694 # SwayOSD ships its system unit where systemd does not look.
632 695 #
633 696 # Fedora's SwayOSD-0.3.2 installs swayosd-libinput-backend.service into
@@ -813,6 +876,77 @@
813 876 COPY usr/ /usr/
814 877 COPY --from=rust-build /staged-skel/ /
815 878
879 + # =====================================================================
880 + # The encrypted install path — assert bootc will actually permit it.
881 + #
882 + # `bootc install --block-setup tpm2-luks` is gated by the image's own
883 + # install config, not by the hardware: with no `block` key bootc allows
884 + # `direct` alone and refuses the flag with "tpm2-luks not enabled in
885 + # installation config". That is exactly what happened, on a machine with
886 + # a working TPM, because the config declared a root filesystem and
887 + # nothing else. The installer offers encryption on step 4 and defaults
888 + # it to on, so every user taking the default hit a dead path.
889 + #
890 + # Nothing failed at build time, which is why this check exists: the flag
891 + # lives in crates/alloy/src/install.rs and the permission lives in a
892 + # TOML file, and neither knows the other exists. The coupling is only
893 + # observable at the moment a disk is being erased.
894 + #
895 + # The order check is not pedantry. bootc uses the FIRST entry as the
896 + # default for an install that passes no --block-setup, and the installer
897 + # omits the flag precisely when the user declined encryption. Put
898 + # tpm2-luks first and declining encryption would hand back an encrypted
899 + # disk whose passphrase nobody was asked for.
900 + # =====================================================================
901 + RUN set -eu; \
902 + conf=/usr/lib/bootc/install/00-alloy.toml; \
903 + test -f "$conf" \
904 + || { echo "$conf is missing; bootc would lose both the rootfs type and the block allowlist" >&2; exit 1; }; \
905 + block="$(sed -n 's/^[[:space:]]*block[[:space:]]*=[[:space:]]*//p' "$conf")"; \
906 + test -n "$block" \
907 + || { echo "$conf declares no 'block' key, so bootc permits 'direct' only and the installer's encryption step cannot work" >&2; exit 1; }; \
908 + echo "$block" | grep -q 'tpm2-luks' \
909 + || { echo "$conf does not enable tpm2-luks: $block" >&2; exit 1; }; \
910 + echo "$block" | grep -q '^\[[[:space:]]*"direct"' \
911 + || { echo "$conf must list \"direct\" first; bootc takes the first entry as the default and an unencrypted install passes no --block-setup: $block" >&2; exit 1; }; \
912 + echo "bootc install config: block = $block"
913 +
914 + # =====================================================================
915 + # Fingerprint unlock — turn it on through authselect, not by hand.
916 + #
917 + # /etc/pam.d/system-auth is generated: on this base /etc/nsswitch.conf is
918 + # already a symlink into authselect's tree, and hand-editing a PAM file
919 + # authselect owns means the next `authselect apply-changes` silently
920 + # discards it. The feature switch is the supported edit and the only one
921 + # that survives.
922 + #
923 + # `sufficient` is what the feature installs, which is the property worth
924 + # stating: a finger that does not match, a reader that is busy, or a user
925 + # with nothing enrolled all fall through to the password prompt rather
926 + # than locking anyone out. That matters here more than usual, because
927 + # nothing enrolls a finger at install time — `fprintd-enroll` is a thing
928 + # the user runs later, and until they do this changes nothing at all.
929 + #
930 + # Asserted rather than trusted, twice. authselect reports success for a
931 + # feature it did not apply if the profile does not offer it, and the
932 + # generated file is the only place the answer is visible.
933 + #
934 + # authselect prints "make sure fprintd service is configured and enabled"
935 + # here. Nothing to do: fprintd is Type=dbus with BusName and a
936 + # system-services activation file, so the first PAM call starts it. No
937 + # preset line, for the same reason gnome-keyring does not get one.
938 + # =====================================================================
939 + RUN set -eu; \
940 + authselect enable-feature with-fingerprint; \
941 + authselect apply-changes; \
942 + grep -q 'pam_fprintd\.so' /etc/pam.d/system-auth \
943 + || { echo "with-fingerprint did not reach system-auth; the lock screen would never ask the reader" >&2; exit 1; }; \
944 + grep -q '^auth.*sufficient.*pam_fprintd\.so' /etc/pam.d/system-auth \
945 + || { echo "pam_fprintd is in system-auth but not as 'sufficient'; a failed or absent finger would not fall through to the password" >&2; exit 1; }; \
946 + test -f /etc/pam.d/swaylock \
947 + || { echo "swaylock ships no PAM file; the include chain this relies on is gone" >&2; exit 1; }; \
948 + echo "fingerprint: $(grep -c pam_fprintd /etc/pam.d/system-auth) pam_fprintd line(s) in system-auth"
949 +
816 950 # =====================================================================
817 951 # The session wrapper — assert it can actually run.
818 952 #
@@ -1054,34 +1188,16 @@
1054 1188 # run after the config tree is in place, so it lives here.
1055 1189 # =====================================================================
1056 1190
1057 - # SwayOSD's system unit is not where systemd looks for units. Fedora's
1058 - # SwayOSD 0.3.2 installs swayosd-libinput-backend.service into
1059 - # /usr/lib64/systemd/system, and systemd's search path is /etc/systemd/system,
1060 - # /usr/local/lib/systemd/system and /usr/lib/systemd/system. On this base
1061 - # /usr/lib64 is a real directory rather than a symlink to /usr/lib, so the
1062 - # unit is invisible: `systemctl enable` answers "Unit
1063 - # swayosd-libinput-backend.service does not exist", and the preset line for it
1064 - # matches nothing and says nothing. That is a packaging bug upstream, not a
1065 - # choice Alloy gets to make, so link the unit into the path systemd reads.
1066 - #
1067 - # Without this the caps-lock and num-lock overlays have no daemon, and the
1068 - # sway config's `bindsym --release Caps_Lock` does nothing at all.
1069 - #
1070 - # Written to survive the fix rather than assume the bug is permanent: if a
1071 - # later SwayOSD ships the unit at the canonical path, this leaves it alone
1072 - # instead of replacing a real unit file with a link to a file that moved. If
1073 - # the unit is at neither path the build stops, because the alternative is
1074 - # shipping the dead bind again and not knowing.
1075 - RUN canon=/usr/lib/systemd/system/swayosd-libinput-backend.service; \
1076 - real=/usr/lib64/systemd/system/swayosd-libinput-backend.service; \
1077 - if [ -e "$canon" ]; then \
1078 - echo "swayosd unit is already in the search path; the shim can go"; \
1079 - elif [ -e "$real" ]; then \
1080 - ln -s "$real" "$canon"; \
1081 - else \
1082 - echo "swayosd-libinput-backend.service is at neither path" >&2; exit 1; \
1083 - fi
1084 -
1191 + # The SwayOSD unit is already on the search path by the time preset-all runs:
1192 + # the copy that puts it there is up with the rest of the packaging shims, next
1193 + # to the udev rule that has the same defect. A second block used to stand here
1194 + # doing the same job with a symlink, guarded by `if [ -e "$canon" ]`. Because
1195 + # the copy had already created that path, the guard always took its first
1196 + # branch and printed "the shim is already in the search path" — which read
1197 + # like the workaround having become unnecessary, when in fact the earlier copy
1198 + # was what satisfied it. Two fixes for one bug, landed the same day, and the
1199 + # survivor is the one that fails the build loudly when Fedora corrects the
1200 + # packaging rather than the one that quietly steps aside.
1085 1201 RUN systemctl preset-all
1086 1202
1087 1203 # Template instances have to be enabled by name. `preset-all` iterates over
M docs/STACK.md +39 -1
@@ -42,7 +42,11 @@
42 42
43 43 ## Lock
44 44
45 - **swaylock** (C, canonical, minimal, security-audited). Bound Mod+Ctrl+L. A Wayland session-lock surface is inherently graphical and cannot be a TUI, so this is one of the pieces Alloy adopts rather than authors. The prior plan for a custom egui lockscreen (the first marquee app) was dropped in the pivot; lock crash-recovery is now swaylock/PAM/greetd's concern. fprintd unlock, if wanted, wires through swaylock's PAM stack.
45 + **swaylock** (C, canonical, minimal, security-audited). Bound Mod+Ctrl+L. A Wayland session-lock surface is inherently graphical and cannot be a TUI, so this is one of the pieces Alloy adopts rather than authors. The prior plan for a custom egui lockscreen (the first marquee app) was dropped in the pivot; lock crash-recovery is now swaylock/PAM/greetd's concern.
46 +
47 + **Fingerprint unlock ships, and swaylock gains it without knowing.** `/etc/pam.d/swaylock` is `auth include login`, login includes system-auth, and system-auth is what authselect rewrites when `with-fingerprint` is enabled. So the lock screen, the greeter and `run0` all gain the same unlock from one switch, and Alloy still ships no `/etc/pam.d` file of its own. Two packages: `fprintd` and `fprintd-pam`, the second being the half that makes anything ask the reader.
48 +
49 + `pam_fprintd` is installed `sufficient`, which is the property worth stating: a finger that does not match, a reader that is busy, and a user with nothing enrolled all fall through to the password prompt. Nothing enrols a finger at install time, so until someone runs `fprintd-enroll` this changes nothing at all.
46 50
47 51 ## Launcher
48 52
@@ -356,6 +360,40 @@
356 360
357 361 Rejected: redshift (X11), wlsunset (simpler but time-only, no location provider; gammastep can do both).
358 362
363 + ## Peripherals
364 +
365 + Two absences the 2026-07-29 feature audit turned up, both of which this document was obliged to argue and had not. Printing was missing entirely, including from the rejected lists. Bluetooth was in the image and enabled, by inheritance from Fedora's own preset rather than by any decision recorded here.
366 +
367 + ### Printing: **CUPS, driverless only**
368 +
369 + **Alloy prints to IPP Everywhere devices and nothing else.** The printer advertises its own capabilities, so there is no driver to choose, install or match against a model number. Every printer sold since roughly 2015 speaks it. A printer that needs a vendor driver is not supported, and saying so is the point: the alternative is a user discovering it at the moment they need to print.
370 +
371 + **Two packages, `cups` and `cups-filters`, and the position is visible in what is absent.** foomatic-db is thousands of PPDs, gutenprint and hplip are vendor driver estates, and a driverless-only stack can reach none of them. The Containerfile asserts all three stay out rather than trusting the dependency solver, because that is the assertion that encodes the decision. ghostscript does arrive, as a hard dependency of cups-filters for rasterizing, and is accepted.
372 +
373 + **Discovery was already paid for.** Driverless printing finds printers over mDNS, and nss-mdns is installed with `/etc/nsswitch.conf` already resolving `.local`. That was added for the minted-installer flow and happens to be the whole prerequisite here, which is why this cost two packages rather than a subsystem. A driverless printer on the same link appears by itself.
374 +
375 + **`cups.socket`, never `cups.service`.** Nothing runs until something connects to the socket, so printing costs a unit file on the laptops that never print. Enabling the service outright would buy a daemon on every install for a feature most sessions never touch.
376 +
377 + The surface is CUPS' own web UI on `localhost:631`. It needs no GTK, and with driverless discovery most users never open it. A console verb is not ruled out later; it is not worth authoring before anyone has wanted it.
378 +
379 + Rejected: **system-config-printer**, the usual Fedora answer, which is GTK and drags a settings UI into a distro whose settings live in a TUI. **A `alloy print` console view**, deferred rather than rejected, on the grounds that driverless discovery leaves it very little to do.
380 +
381 + ### Scanning: **not shipped**
382 +
383 + **sane-backends is not in the image and this is a decision.** Scanning is per-device backends and USB permission work, its failure modes are opaque in a way driverless printing's are not, and a scanner is far rarer on a laptop than a printer. The honest version is that nobody has wanted one yet.
384 +
385 + Anyone who needs it installs `sane-backends` through the package view. Most network scanners also scan to a folder or to email without a host driver at all, which is the path worth trying first.
386 +
387 + ### Bluetooth: **bluez, kept and now chosen**
388 +
389 + **It was already on, which was the defect.** Fedora's `90-default.preset` carries `enable bluetooth.*`, so every Alloy install has had a Bluetooth daemon that this document never mentioned and nobody decided on. Same correction as sshd and avahi: the state does not change, the accountability does. The preset entry is now Alloy's.
390 +
391 + **Kept on because Alloy is laptop-first**, and on a laptop wireless headphones and a wireless mouse are ordinary. The audio half needs nothing further: PipeWire's bluez5 SPA plugins ship in the image, LC3 and AAC included, so a paired headset plays.
392 +
393 + **What is missing is a surface, not a daemon.** Pairing today means `bluetoothctl`, an interactive prompt rather than a view, and it works. The intended answer is an `alloy bluetooth` console view over bluez's D-Bus, in the shape `alloy display` and `alloy pkg` already use.
394 +
395 + Rejected: **bluetuith**, the obvious ready-made TUI, which is in no Fedora repository and would mean adopting another COPR for a view this project builds itself anyway. **blueman** and **GNOME Settings**, both GTK, both off-thesis for the same reason system-config-printer is.
396 +
359 397 ## Cursor theme
360 398
361 399 **Bibata Modern Classic.** Classic tilted-arrow pointer shape (universally recognizable since Mac OS 1984), soft edges, subtle drop shadow, Wayland-friendly, actively maintained, Fedora repos. Its warmth pairs correctly with Akari Dawn's warm-clay palette without fighting the chrome.
@@ -92,3 +92,48 @@
92 92 enable sshd.service
93 93
94 94 disable tailscaled.service
95 +
96 + # bluetooth was on before this line existed, and that was the problem: Fedora's
97 + # 90-default.preset carries `enable bluetooth.*`, so every Alloy install has had
98 + # a Bluetooth daemon by inheritance rather than by decision. Same shape as sshd
99 + # and avahi above, and stated here for the same reason.
100 + #
101 + # Kept on. Alloy is laptop-first, and on a laptop wireless headphones and a
102 + # wireless mouse are ordinary rather than exotic. The audio half already works
103 + # and needs nothing here: PipeWire's bluez5 SPA plugins ship in the image,
104 + # including the LC3 and AAC codecs, so a paired headset plays without further
105 + # packages.
106 + #
107 + # What is missing is a surface, not a daemon: pairing today means `bluetoothctl`,
108 + # which is an interactive prompt rather than a view, and Alloy's answer is an
109 + # `alloy bluetooth` console view over bluez's D-Bus in the shape `display` and
110 + # `pkg` already use. bluetuith was the obvious ready-made TUI and is not in the
111 + # Fedora repositories, so taking it would mean another COPR; not worth it for a
112 + # view this project builds itself anyway.
113 + #
114 + # The exposure is a daemon holding an adapter that can be discoverable. bluez
115 + # does not make it discoverable on its own — that is a `bluetoothctl` command a
116 + # user runs — so the default state is a radio that scans when asked and answers
117 + # nobody.
118 + enable bluetooth.service
119 +
120 + # cups.socket, not cups.service, and the distinction is the whole point: the
121 + # socket is what makes printing cost nothing on the machines that never print.
122 + # Nothing runs until something connects to /run/cups/cups.sock, and then systemd
123 + # starts the daemon. Enabling cups.service instead would buy a daemon on every
124 + # laptop for a feature most sessions never touch.
125 + #
126 + # cups.path goes with it: it watches the spool directory so a job queued while
127 + # the daemon is down still wakes it.
128 + #
129 + # Fedora's 90-default.preset already enables both, so this changes no state. It
130 + # is here because "printing works" should be an Alloy decision with the
131 + # driverless-only position behind it (see the Containerfile printing block and
132 + # docs/STACK.md), rather than a default inherited from a server base and never
133 + # examined. That is the same correction made for sshd and avahi.
134 + #
135 + # The exposure is a unix socket under /run, owned by root and reachable by the
136 + # local machine only. CUPS' web UI on localhost:631 rides the same daemon and is
137 + # not a network listener.
138 + enable cups.socket
139 + enable cups.path
@@ -5,5 +5,19 @@
5 5 # "missing required info: DefaultRootFs" and any consumer has to pass
6 6 # --rootfs on the CLI. xfs matches Fedora bootc's own default.
7 7
8 + [install]
9 + # Which to-disk backends this image permits. Unset means `direct` alone, and
10 + # `bootc install --block-setup tpm2-luks` then fails with "tpm2-luks not
11 + # enabled in installation config" — which is what every encrypted install did
12 + # until this line existed, on hardware with a perfectly good TPM. The
13 + # installer offers encryption on step 4 and defaults it to on, so the whole
14 + # encrypted path was unreachable.
15 + #
16 + # Order is load-bearing: bootc takes the FIRST entry as the default for an
17 + # install that passes no --block-setup, and the installer passes the flag only
18 + # when the user asked for encryption. `direct` must therefore stay first, or
19 + # declining encryption would silently produce an encrypted disk.
20 + block = ["direct", "tpm2-luks"]
21 +
8 22 [install.filesystem.root]
9 23 type = "xfs"