diff --git a/README.md b/README.md index ec1cd00..ecf8c9a 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,15 @@ On `class=server` it stays open — key-only, as bootstrap left it — because root there is the **automation** identity the control plane (Coolify) SSHes in as. It is a machine door, never a human one. +**Where this diverges from #17's original table:** that table let the +`runner` role close root ("no Coolify involved"). The class model supersedes +the per-role call: runner is `class=server` — an automation identity, not a +person's box — and on every server-class machine root SSH is the management +plane rig itself converges through, so `close-root` refuses there +deliberately, runner included. A CI box you mean to administer like a human +machine is `--class human` at bootstrap, not an exception carved out of the +gate. + **The detection side benefit:** once humans never use root, any root login that is not the control plane is anomalous *by definition* — a cheap, high-signal alert that a shared root identity makes impossible to write. @@ -520,6 +529,20 @@ refused as a username: this file names operators; root's fate is class policy. `--file -` reads stdin. A bad file exits 2 with **every** error listed at once, before anything changes — one fix cycle, not one round-trip per line. +**`@root` — seed keys from the door you came in through (#17).** A key field +of exactly `@root` means "this user's `authorized_keys` becomes root's +CURRENT `/root/.ssh/authorized_keys`". The point is lockout-avoidance: you +provably hold a root private key — you SSHed in with it to run apply at all — +so the seeded key is the one key rig can *know* opens for you; any pasted +literal can be a key you do not hold. `@root` mixes with literal lines +(seeded keys land first, literals append after), re-runs re-seed from root's +then-current file — convergent to it, so a seeded key you hand-remove from +the admin returns until you switch the line to literal keys — and apply dies +if root has no `authorized_keys` to seed. Root's key lines are copied +verbatim, options included: a `from=`/`command=` restriction follows the key, +and on a Coolify-managed box root's file also carries *Coolify's* key — on +`class=server`, prefer literal keys. + **Public tool, private state, here too.** The users file lives in *your* private infra repo and is passed per invocation — rig never persists it. It holds nothing secret anyway: usernames, roles, and *public* keys. @@ -602,9 +625,15 @@ automation identity and closing it severs fleet management. Then at least one non-empty `authorized_keys` alone proves a file, not a door: the gate checks the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the user and not group/world-writable), a real login shell, and an unexpired -account, and its refusal names which check failed, per candidate. It proves -the door *should* open, not that it does — which is why the separate-session -verification below stays load-bearing. Never close the only door. +account — and then two **reachability** proofs (#17): `sudo -n true` under +`runuser` must answer, so NOPASSWD sudo is effective rather than merely +written, and `sshd -T -C user=` must resolve a per-user effective +config that accepts the login (`pubkeyauthentication yes`, no `DenyUsers` +hit, `AllowUsers` — if set — names them), so a `Match` block elsewhere cannot +quietly exclude the admin while every file looks right. The refusal names +which check failed, per candidate. What no local check can prove is that you +*hold* the private key — which is why the separate-session verification below +stays load-bearing. Never close the only door. Before running it, prove the admin door in a **separate** session — `ssh @` while this one stays open. Root SSH is being welded shut; the diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index d5512d4..5cd037a 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -131,7 +131,14 @@ assert_marker_human() { *class=server*) # Root SSH on a server IS the control plane's (Coolify's) automation # identity — closing it severs fleet management. No --force exists. - printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management" + # Deliberately per-CLASS, not per-role: #17's original table let the + # runner role close root ("no Coolify involved"), but the class model + # (#26) supersedes that — every server-class box, runner included, is + # an automation identity whose management plane is root SSH, and rig + # itself converges through that door. A CI box someone administers + # like a human machine is class=human at bootstrap, not an exception + # carved out here. + printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management. Every server-class box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --class human at bootstrap, not an exception here" return 1 ;; *) printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind" diff --git a/test/cli.sh b/test/cli.sh index 0e211e0..db4ebc0 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -584,6 +584,12 @@ check "users close-root: absent marker refuses, names bootstrap as the repair" \ 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" check "users close-root: class=server refuses, names the control plane" \ 1 "control plane" marker_gate "$MARKER_DIR/server" +# #17's original table let the runner ROLE close root; the class model +# supersedes it — runner is class=server, an automation identity, and the +# refusal must SAY so or the divergence reads as a bug to anyone holding the +# old table. +check "users close-root: the server refusal owns the runner row (#17)" \ + 1 "runner included" marker_gate "$MARKER_DIR/server" check "users close-root: class=human passes the gate" \ 0 "" marker_gate "$MARKER_DIR/human" rm -rf "$MARKER_DIR"