Skip to main content

max / alloy

Run the ssh installer under run0, so the headless flow can install The headless flow is how a machine with no screen gets an OS: boot the ISO, `ssh installer@<name>.local`, answer the wizard. It could not install anything. `alloy install` does not escalate -- on tty1 it does not need to, because alloy-installer.service runs it as root -- and over ssh it landed in the `installer` account, a plain `useradd --system` with no grant anywhere: error: Installing to disk: Querying root privilege: This command must be executed as the root user mkdir: cannot create directory '/run/alloy-target': Permission denied wipefs: error: /dev/vda: probing initialization failed: Permission denied The wizard drew and answered all six steps and the erase confirmation first, so every build-time assertion passed and the door looked open. What was unasserted was that the session could do the thing it exists to do. Measured 2026-08-26 by build/vmtest/offline-first-boot.sh, which had picked that route as its way in. Ruled (a) by Max: ForceCommand runs the wizard under run0, and usr/share/polkit-1/rules.d/50-alloy-installer.rules grants `installer` the one action run0 asks for. Against uid 0 and against a NOPASSWD sudoers line, both of which would have worked; this keeps the account unprivileged in its own right, matches wiki `alloy-privilege` (run0-first, not sudo), and puts the grant in a file somebody can read. THE GATE IS UNCHANGED, which is what makes the grant affordable. The rule names an account that alloy-installer-ssh.service creates only under ConditionKernelCommandLine=alloy.installer, so on an installed machine it matches nobody, exactly as the sshd drop-in beside it matches nobody. A polkit rule cannot read the kernel command line, so the Containerfile asserts the three files agree instead: the ForceCommand names run0, the rules file names the same account and the same action, and it returns exactly one result. Widening it is then a decision rather than an edit. The grant is deliberately over ssh, which 50-alloy-settings.rules refuses on principle. That file's justification is "the person sitting at the machine" and is untouched; this one rests on the account being unable to exist anywhere a grant would matter. Both files say so. `--background=` empty because run0 tints the terminal to signal privilege, which is right for a shell and wrong in front of a TUI that paints its own surface. Verified end to end: `VMTEST_VIA=ssh build/vmtest/offline-first-boot.sh` exits 0 against a server-profile ISO built from this tree -- wizard driven over ssh, install completed, offline first boot laid the console down, repo set ordinary, check-installed clean. So the vmtest goes back to the ssh route as its default, and `VMTEST_VIA=serial` keeps the GRUB debug shell for a medium with no baked key, and for the day this comes back. GO alloy 2cf04f20.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 18:41 UTC
Signed with PGP, not checked
Commit: 5a2e6faa31f2029650623436a2410acb98065238
Parent: 4232bd9
6 files changed, +169 insertions, -46 deletions
M Containerfile +22 -1
@@ -2511,6 +2511,16 @@
2511 2511 # quietly stopped applying to anyone but the installer. Two `Match`
2512 2512 # lines and `Match all` last is the shape that cannot do that.
2513 2513 #
2514 + # 4. THE PRIVILEGE. `alloy install` does not escalate, so the wizard runs
2515 + # under run0 and run0 asks polkit for manage-units. Three ways for
2516 + # that to break quietly, and the door is remote in all of them: the
2517 + # ForceCommand loses run0 and the wizard fails on the first
2518 + # privileged command (measured 2026-08-26, GO alloy 2cf04f20); the
2519 + # rules file names a different account than the one that logs in, and
2520 + # run0 asks a human who is not there; or the grant widens past the
2521 + # one action run0 needs. The first two look identical from outside —
2522 + # an install that stops — and the third looks like nothing at all.
2523 + #
2514 2524 # Comments stay out of the RUN below: a `#` line inside a line
2515 2525 # continuation is handled differently by different parsers, and one that
2516 2526 # reaches the shell ends the command at that point rather than being
@@ -2528,7 +2538,18 @@
2528 2538 || { echo "$conf must hold exactly two Match lines: the block and its terminator" >&2; exit 1; }; \
2529 2539 tail -n 1 "$conf" | grep -q '^Match all$' \
2530 2540 || { echo "$conf must end with 'Match all' or its block leaks into every later sshd drop-in" >&2; exit 1; }; \
2531 - echo "installer ssh: gated on alloy.installer, scope closed"
2541 + rules=/usr/share/polkit-1/rules.d/50-alloy-installer.rules; \
2542 + grep -q '^ForceCommand run0 .*/usr/bin/alloy install$' "$conf" \
2543 + || { echo "$conf does not run the installer under run0; the wizard would land unprivileged and fail on the first command that writes a disk" >&2; exit 1; }; \
2544 + test -f "$rules" \
2545 + || { echo "$rules is missing, so run0 would ask a human for a password on a headless install" >&2; exit 1; }; \
2546 + grep -q '"installer"' "$rules" \
2547 + || { echo "$rules does not name the account $unit creates, so the grant reaches nobody" >&2; exit 1; }; \
2548 + grep -q '"org.freedesktop.systemd1.manage-units"' "$rules" \
2549 + || { echo "$rules does not grant manage-units, which is the action run0 asks for" >&2; exit 1; }; \
2550 + [ "$(grep -c 'polkit.Result' "$rules")" = 1 ] \
2551 + || { echo "$rules returns more than one polkit result; this grant is one action for one user and widening it is a decision, not an edit" >&2; exit 1; }; \
2552 + echo "installer ssh: gated on alloy.installer, scope closed, run0 granted manage-units"
2532 2553
2533 2554 # =====================================================================
2534 2555 # The installer's mDNS unit, gated by the same flag as the two above.
@@ -99,10 +99,12 @@
99 99 then written once in its own syntax with nothing to quote through nushell. Use
100 100 `sh -c '...'` only when stdin is carrying something else.
101 101
102 - **The wizard cannot be driven over the medium's ssh installer**, because that
103 - session is unprivileged and every stage of the install fails. It is the obvious
104 - way in and it is the wrong one until GO alloy `2cf04f20` is answered; the root
105 - shell on GRUB's debug entry is what works. See `install_drive.py`.
102 + **The medium's ssh installer could not install anything until 2026-08-26.**
103 + `alloy install` does not escalate, and that session landed unprivileged, so the
104 + wizard drew and answered every step and then failed on the first command that
105 + writes a disk. Found by running `offline-first-boot.sh` (GO alloy `2cf04f20`)
106 + and fixed by running the wizard under `run0` with a polkit grant for the one
107 + action run0 asks for. `VMTEST_VIA=serial` is the way that never needed it.
106 108
107 109 ## Check the result, not only the screen
108 110
@@ -178,17 +180,18 @@
178 180 fails, so it never reaches its `systemctl reboot`, so no `RESET` arrives and
179 181 `/usr/bin/alloy` is absent afterwards.
180 182
181 - The installer is driven from the root shell GRUB's debug entry starts
182 - (`alloy-debug-shell@ttyS0`), not over the medium's ssh installer account: that
183 - account is unprivileged and cannot install anything (GO alloy `2cf04f20`).
184 - `install_drive.py --via ssh` keeps that route for the day it is fixed.
183 + The installer is driven over the medium's own headless ssh account, which is
184 + the route a person installing a screenless machine takes. So the ISO needs a
185 + baked pubkey matching the key given here:
185 186
186 - build/build-iso.sh --build-arg PROFILE=server --build-arg BROWSER=none
187 + build/build-iso.sh --build-arg PROFILE=server --build-arg BROWSER=none \
188 + --build-arg ALLOY_SSH_KEY="$(cat ~/.ssh/id_ed25519.pub)"
187 189 build/vmtest/offline-first-boot.sh
188 190
189 - The key it is given is not a credential for the medium; it is what the wizard
190 - puts in the new machine's `authorized_keys`, which is how the assertions get in
191 - afterwards.
191 + That key does two jobs: it is the installer session's only credential, and it is
192 + what the wizard puts in the new machine's `authorized_keys`, which is how the
193 + assertions get in afterwards. `VMTEST_VIA=serial` drives GRUB's debug shell
194 + instead and needs only the second.
192 195
193 196 GRUB is navigated by reading the menu off the serial console and checking the
194 197 mark before pressing return, so neither of the countdown warnings above applies
@@ -9,29 +9,30 @@
9 9 for a title the installer only draws once it is on that step, and a slow
10 10 install and a stuck one look different. Pictures cannot answer that.
11 11
12 - ## Two ways in, and the default is not the obvious one
12 + ## Two ways in
13 13
14 - `--via serial` (the default) boots GRUB's debug entry, which carries
15 - `alloy.debug` and so starts `alloy-debug-shell@ttyS0`: a root bash on the
16 - guest's serial console. `alloy install` is run from there.
14 + `--via ssh` (the default) uses the medium's own headless flow:
15 + `alloy-installer-ssh.service` creates the `installer` account on the live medium
16 + only, and the sshd drop-in makes that account's session BE `alloy install` under
17 + a real tty. It is the route a person installing a screenless machine takes, so
18 + it is the one worth exercising.
17 19
18 - `--via ssh` uses the medium's own headless flow instead:
19 - `alloy-installer-ssh.service` creates the `installer` account on the live
20 - medium only, and the sshd drop-in makes that account's session BE
21 - `alloy install` under a real tty. It is the nicer route and it is not the
22 - default, because **it cannot currently install anything**. Measured
23 - 2026-08-26: the wizard draws and answers correctly all the way to the erase
24 - confirmation, and then every privileged stage fails.
20 + It could not install anything until 2026-08-26. `alloy install` does not
21 + escalate, and that session landed unprivileged:
25 22
26 23 error: Installing to disk: Querying root privilege: This command must be
27 24 executed as the root user
28 - mkdir: cannot create directory '/run/alloy-target': Permission denied
29 25 wipefs: error: /dev/vda: probing initialization failed: Permission denied
30 26
31 - `alloy install` never escalates: on tty1 it is already root, because
32 - alloy-installer.service runs it as root. Over ssh it runs as `installer`, a
33 - `useradd --system` account, and nothing in the tree or the image grants that
34 - account anything. The flag is kept so the day that is fixed, this is one word.
27 + Found by running this script (GO alloy `2cf04f20`), and fixed by running the
28 + wizard under `run0` with a polkit grant for the one action run0 asks for. The
29 + Containerfile asserts the three files still agree; if that assertion ever fails,
30 + this is the route that stops working.
31 +
32 + `--via serial` boots GRUB's debug entry instead, which carries `alloy.debug` and
33 + so starts `alloy-debug-shell@ttyS0`: a root bash on the guest's serial console.
34 + It was the default while the ssh route was broken, and it is worth keeping for
35 + the case that comes back, and for a medium built without a baked pubkey.
35 36
36 37 The answers are the ones a regression test wants and not the ones a person
37 38 would pick:
@@ -44,8 +45,8 @@
44 45
45 46 Use:
46 47
47 - install_drive.py --pubkey "$(cat ~/.ssh/id_ed25519.pub)"
48 - install_drive.py --via ssh --key ~/.ssh/id_ed25519
48 + install_drive.py --key ~/.ssh/id_ed25519
49 + install_drive.py --via serial --pubkey "$(cat ~/.ssh/id_ed25519.pub)"
49 50
50 51 Exits 0 when the install reports success, 1 when it reports failure, and 3 when
51 52 the run could not get far enough to have a verdict. The screen is printed on
@@ -336,10 +337,10 @@
336 337
337 338 def main():
338 339 p = argparse.ArgumentParser(description="drive `alloy install` to the end")
339 - p.add_argument("--via", choices=("serial", "ssh"), default="serial",
340 - help="serial: GRUB's debug entry and its root shell (the default, "
341 - "because the ssh route cannot install). ssh: the medium's own "
342 - "headless installer account")
340 + p.add_argument("--via", choices=("ssh", "serial"), default="ssh",
341 + help="ssh: the medium's own headless installer account (the default; "
342 + "needs a medium whose baked pubkey matches --key). serial: GRUB's "
343 + "debug entry and its root shell")
343 344 p.add_argument("--rows", type=int, default=40)
344 345 p.add_argument("--cols", type=int, default=120)
345 346 p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
@@ -52,18 +52,23 @@
52 52 #
53 53 # ## How the installer is driven
54 54 #
55 - # Over the serial console, from the root shell GRUB's debug entry starts
56 - # (`alloy-debug-shell@ttyS0`, gated on `alloy.debug`). NOT over the medium's
57 - # own headless ssh installer, which would be the nicer route and cannot
58 - # install: that session runs as the unprivileged `installer` account and every
59 - # privileged stage fails. Measured 2026-08-26; install_drive.py's header
60 - # carries the errors, and the GoingsOn task is alloy `cd2da818`.
55 + # Over the medium's own headless ssh installer, which is the route a person
56 + # installing a screenless machine takes. `VMTEST_VIA=serial` takes GRUB's debug
57 + # entry and its root shell instead, which is what this used while the ssh route
58 + # could not install anything (GO alloy `2cf04f20`, fixed 2026-08-26 by running
59 + # the wizard under run0).
61 60 #
62 61 # ## Requirements
63 62 #
64 - # Everything build/vmtest/README.md lists, and an ISO. No baked key is needed:
65 - # the key below is what the wizard puts in the NEW machine's authorized_keys,
66 - # which is how the assertions get in afterwards.
63 + # Everything build/vmtest/README.md lists, and an ISO whose baked pubkey matches
64 + # the key given here:
65 + #
66 + # build/build-iso.sh --build-arg PROFILE=server --build-arg BROWSER=none \
67 + # --build-arg ALLOY_SSH_KEY="$(cat ~/.ssh/id_ed25519.pub)"
68 + #
69 + # That key is doing two jobs: it is the installer session's only credential, and
70 + # it is what the wizard puts in the NEW machine's authorized_keys, which is how
71 + # the assertions get in afterwards. `VMTEST_VIA=serial` needs only the second.
67 72 #
68 73 # ## Use
69 74 #
@@ -95,6 +100,9 @@
95 100 PASSWORD="${VMTEST_PASSWORD:-alloytest}"
96 101 PORT="${VMTEST_PORT:-2222}"
97 102 KEEP_STATE=0
103 + # Which way the wizard is reached. See install_drive.py's header; `ssh` needs a
104 + # medium whose baked pubkey matches $KEY.
105 + VIA="${VMTEST_VIA:-ssh}"
98 106
99 107 # The layering transaction is the long part, and it is the one whose absence is
100 108 # the finding. Generous, because a slow host must not read as a regression.
@@ -206,7 +214,7 @@
206 214 say "driving the wizard"
207 215 rc=0
208 216 python3 "$HERE/install_drive.py" \
209 - --via serial --pubkey "$(cat "$KEY.pub")" --disk "$DISK_NAME" \
217 + --via "$VIA" --key "$KEY" --pubkey "$(cat "$KEY.pub")" --disk "$DISK_NAME" \
210 218 --hostname "$HOSTNAME_" --user "$USER_" --password "$PASSWORD" \
211 219 --install-timeout "$INSTALL_TIMEOUT" || rc=$?
212 220 shutdown_vm
@@ -44,7 +44,26 @@
44 44 # next person to connect should meet a machine that says so rather than a fresh
45 45 # wizard offering to go over it again. That is the same ruling
46 46 # alloy-installer.service records for tty1 in its `Restart=no`.
47 - ForceCommand /usr/bin/alloy install
47 + #
48 + # UNDER `run0`, AND WITHOUT IT THIS DOOR OPENS ONTO NOTHING. `alloy install`
49 + # does not escalate: on tty1 it does not need to, because
50 + # alloy-installer.service runs it as root, and here it would land in the
51 + # unprivileged account this file matches. Measured 2026-08-26 (GO alloy
52 + # 2cf04f20): the wizard drew and answered all six steps and the erase
53 + # confirmation, then failed on `Querying root privilege` and every command
54 + # after it. The whole headless flow reached a wizard that could not write a
55 + # disk.
56 + #
57 + # run0 rather than sudo, per wiki `alloy-privilege`, and the grant it needs is
58 + # usr/share/polkit-1/rules.d/50-alloy-installer.rules — one action, one user,
59 + # and inert wherever this file is inert, because both hang off an account that
60 + # only the live medium creates.
61 + #
62 + # `--background=` empty on purpose. run0 tints the terminal to signal a
63 + # privileged session, which is right for a shell and wrong in front of a
64 + # full-screen TUI that paints its own surface from the theme. The cue this
65 + # session needs is the one the installer draws itself.
66 + ForceCommand run0 --background= /usr/bin/alloy install
48 67
49 68 # A TUI needs a terminal. ForceCommand alone does not allocate one, and the
50 69 # installer would draw into a pipe and be unusable.
@@ -1,0 +1,71 @@
1 + /* Alloy: the installer, reached over ssh, becoming root.
2 + *
3 + * `alloy install` does not escalate. On tty1 it does not need to —
4 + * alloy-installer.service runs it as root — and over ssh it lands in the
5 + * unprivileged `installer` account, where every privileged stage fails:
6 + *
7 + * error: Installing to disk: Querying root privilege: This command must be
8 + * executed as the root user
9 + * wipefs: error: /dev/vda: probing initialization failed: Permission denied
10 + *
11 + * Measured 2026-08-26 (GO alloy 2cf04f20). The wizard drew and answered all six
12 + * steps and the erase confirmation before dying, so the headless flow the whole
13 + * minting design rests on — boot the ISO, `ssh installer@<name>.local`, answer
14 + * the questions — reached a wizard that could not write a disk.
15 + *
16 + * So etc/ssh/sshd_config.d/20-alloy-installer.conf runs the wizard under
17 + * `run0`, and run0 asks polkit for org.freedesktop.systemd1.manage-units.
18 + * Without a rule it asks a human, and there is no human on the far end of a
19 + * headless install. This is that rule.
20 + *
21 + * WHY THIS GRANTS OVER SSH WHEN ITS SIBLING REFUSES TO. 50-alloy-settings.rules
22 + * requires `active && local` and says so at length: its justification is "the
23 + * person sitting at the machine", which an ssh login is not. That reasoning is
24 + * untouched here because this grant rests on something else entirely — not on
25 + * where the caller is, but on the fact that the account cannot exist anywhere a
26 + * grant would matter.
27 + *
28 + * THE GATE IS STILL THE ACCOUNT. `installer` is created by
29 + * alloy-installer-ssh.service, which carries
30 + * ConditionKernelCommandLine=alloy.installer, which only the ISO's GRUB entries
31 + * set. On an installed machine there is no such user, so the rule below matches
32 + * nobody and this file is inert — exactly as the sshd drop-in beside it is
33 + * inert for the same reason. A polkit rule cannot read the kernel command line,
34 + * so it cannot check that itself; the Containerfile asserts the three files
35 + * agree instead.
36 + *
37 + * WHAT THIS IS WORTH TO SOMEONE WHO HAS THE KEY, stated plainly rather than
38 + * elided: manage-units is a password-less path to root, so anyone who can open
39 + * this session can run anything as root on that medium. They could already
40 + * partition its disks and write an OS to them, which is what the session is
41 + * for. The exposure is a live installer medium on a LAN, and the credential is
42 + * a key baked in at mint time. Widening it further would be a different
43 + * decision; this does not.
44 + *
45 + * Ruled (a) by Max on 2026-08-26, against giving the account uid 0 and against
46 + * a NOPASSWD sudoers line. Both would have worked. This one keeps the account
47 + * unprivileged in its own right and puts the grant in a file somebody can read.
48 + *
49 + * Ordering: polkit reads /etc/polkit-1/rules.d and /usr/share/polkit-1/rules.d
50 + * as one lexically sorted set, and the first rule to return a value wins. 50-
51 + * so a user drop-in in /etc with a lower number overrides it outright.
52 + */
53 +
54 + polkit.addRule(function (action, subject) {
55 + /* One action and one user. run0 asks for manage-units and nothing else, and
56 + * the only thing that can use the grant is the ForceCommand that needs it:
57 + * sshd replaces whatever the client asked to run, so there is no command
58 + * line to get wrong and no shell to fall back to.
59 + *
60 + * Returning nothing rather than NOT for everything else, so this file
61 + * grants and never denies — same rule as its sibling, and for the same
62 + * reason: a NOT here would override a later rule with a better reason to
63 + * allow something.
64 + */
65 + if (action.id !== "org.freedesktop.systemd1.manage-units") {
66 + return;
67 + }
68 + if (subject.user === "installer") {
69 + return polkit.Result.YES;
70 + }
71 + });