max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+169 insertions,
-46 deletions
| @@ -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 | + | }); |