diff --git a/README.md b/README.md index efd0361..3a69ad1 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,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. @@ -554,6 +563,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. @@ -636,9 +659,22 @@ 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 — where any pattern or `USER@HOST` entry counts as a hit, fail closed, +since `DenyUsers dan*` really denies admin `dan` and rig will not re-implement +sshd's pattern engine to prove a miss — `AllowUsers`, if set, names them +literally, and the same fail-closed pair for `DenyGroups`/`AllowGroups` +judged against the admin's actual groups), 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: that you *hold* the private key, +and how a `Match Address` rule treats your real client address (the probe +resolves against a synthetic `addr=127.0.0.1`) — 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 69251ee..81c9c15 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -13,6 +13,16 @@ # Repeated username lines are additional authorized keys; the roles field must # be IDENTICAL on each — a repeated line means "another key", never a quiet # role edit hiding mid-file. '#' comments and blank lines are skipped. +# +# The key field may also be the literal token '@root' (#17): "this user's +# authorized_keys becomes root's CURRENT /root/.ssh/authorized_keys at apply +# time". The operator provably holds a root private key — they SSHed in with +# it to run apply at all — so seeding it is the one key source that cannot +# lock them out; any pasted literal can be a key they do not hold. '@root' +# mixes with literal key lines: seeded keys come first, literal keys are +# APPENDED after them, and re-runs converge to root's then-current keys plus +# the literals. The parser only owns the token's shape — reading root's file +# needs root and is apply's business. # parse_users_file # @@ -24,9 +34,11 @@ # Refusals: unknown role (the valid set is named), differing roles across one # user's lines, root as username (root's keys are class policy's business, not # this file's), malformed line (fewer than 3 fields, or a key field that does -# not start with an SSH key type), invalid username (the charset below — -# '|' would corrupt this parser's own delimited stream, a leading '-' reads -# as a useradd flag), duplicate identical key line. +# not start with an SSH key type and is not exactly '@root'), '@root' with +# trailing material (the token IS the whole field), invalid username (the +# charset below — '|' would corrupt this parser's own delimited stream, a +# leading '-' reads as a useradd flag), duplicate identical key line (a +# second '@root' for one user counts — the seen[] map catches it for free). parse_users_file() { local path="$1" local -a errs=() out=() rlist=() @@ -41,9 +53,13 @@ parse_users_file() { continue fi case "$k" in + @root) ;; # seed token — apply reads root's authorized_keys (#17) + @root*) + errs+=("line $n: '@root' is the whole key field — it names root's authorized_keys as this user's key source and takes no trailing material") + continue ;; ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;; *) - errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)") + errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...) or be the literal '@root'") continue ;; esac # The username feeds this parser's own '|'-delimited stream and then @@ -115,10 +131,91 @@ 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" return 1 ;; esac } + +# deny_verdict +# +# Judge sshd's effective DenyUsers list against ONE candidate, fail closed. +# Empty output = every token is PROVABLY irrelevant to : literal (no +# sshd pattern metacharacters, no host qualifier) and not this username. +# Anything else prints the reason and the caller flags the candidate: +# +# - a literal hit — DenyUsers really names them; +# - ANY pattern token (* or ?) — 'DenyUsers dan*' genuinely denies admin +# 'dan', and this side of sshd cannot re-implement its pattern engine +# just to prove a miss, so an unprovable token counts as a hit; +# - ANY host-qualified token (USER@HOST) — whether it bites depends on the +# client's address, which no local probe knows. +# +# The asymmetry with AllowUsers is deliberate and points the same direction: +# AllowUsers must name the admin literally (a pattern that WOULD admit them +# still refuses — over-refusing is safe), DenyUsers refuses on anything it +# cannot prove misses. Both errors close toward "repair first", never toward +# a welded-shut root door. Pure text→text, sourced by the harness. +deny_verdict() { + local u="$1" tok; shift + for tok in "$@"; do + case "$tok" in + "$u") printf 'sshd DenyUsers names this user'; return 0 ;; + *[*?]*) printf "sshd DenyUsers has pattern entry '%s' — cannot prove it misses this user; make it literal or remove it, then re-run" "$tok"; return 0 ;; + *@*) printf "sshd DenyUsers has host-qualified entry '%s' — whether it bites depends on the client address, which no local check can prove; make it literal or remove it, then re-run" "$tok"; return 0 ;; + esac + done + return 0 +} + +# group_deny_verdict +# +# deny_verdict's sibling for sshd's DenyGroups, judged against the +# candidate's ACTUAL group membership (id -Gn), same fail-closed rule: +# empty output = every token is provably irrelevant — literal and naming +# none of the candidate's groups. A literal token naming a group they are +# in flags, and so does any pattern or host-qualified token, because a +# token this side of sshd cannot prove irrelevant may be the one that +# denies. Pure text→text, sourced by the harness. +group_deny_verdict() { + local groups="$1" tok g; shift + for tok in "$@"; do + case "$tok" in + *[*?]*) printf "sshd DenyGroups has pattern entry '%s' — cannot prove it misses this user's groups; make it literal or remove it, then re-run" "$tok"; return 0 ;; + *@*) printf "sshd DenyGroups has host-qualified entry '%s' — whether it bites depends on the client address, which no local check can prove; make it literal or remove it, then re-run" "$tok"; return 0 ;; + *) for g in $groups; do + if [ "$tok" = "$g" ]; then + printf "sshd DenyGroups names '%s' — a group this user is in" "$g"; return 0 + fi + done ;; + esac + done + return 0 +} + +# group_allow_verdict +# +# AllowGroups' direction: when the directive is set, sshd admits only +# members of a matching group, so the proof must be a LITERAL token +# naming a group the candidate is in. A pattern that would in fact admit +# them proves nothing here (same stance as AllowUsers: over-refusing is +# the safe error), so no literal hit → flag. Pure text→text. +group_allow_verdict() { + local groups="$1" tok g; shift + for tok in "$@"; do + for g in $groups; do + [ "$tok" = "$g" ] && return 0 + done + done + printf "sshd AllowGroups is set and no entry literally names a group this user is in — add their group (or them to a named group), then re-run" + return 0 +} diff --git a/commands/users-apply.sh b/commands/users-apply.sh index 39021bd..97f0539 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -32,6 +32,17 @@ keys; the roles must be identical on every line of one user. dan admin,box ssh-ed25519 AAAA... dan@laptop maria rig,box ssh-ed25519 AAAA... maria@mac +The key field may also be the literal token '@root': this user's +authorized_keys is seeded from root's CURRENT /root/.ssh/authorized_keys. +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 that cannot lock you out. '@root' +mixes with literal key lines: seeded keys land first, literal keys are +appended after them, and every re-run re-seeds from root's then-current file +(convergent to it — a seeded key you remove from the admin by hand returns; +switch the line to literal keys to pin them). Root's key lines are copied +verbatim, options included — a from= or command= restriction on a root key +follows it to the user. Apply dies if root has no authorized_keys to seed. + roles: admin group rig-admin — full NOPASSWD sudo rig group rig — NOPASSWD sudo for /usr/local/bin/rig only @@ -78,11 +89,12 @@ fi PARSED="$(parse_users_file "$FILE")" \ || die "invalid users file: $FILE — every error is listed above; nothing was changed" 2 -declare -A USER_ROLES=() USER_KEYS=() +declare -A USER_ROLES=() USER_KEYS=() USER_SEED=() USERS=() BOX_USERS=() NEED_SUDO=0 NEED_INCUS=0 +NEED_SEED=0 while IFS='|' read -r u r k; do [ -n "$u" ] || continue if [ -z "${USER_ROLES[$u]:-}" ]; then @@ -90,7 +102,14 @@ while IFS='|' read -r u r k; do USER_ROLES[$u]="$r" case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac fi - USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n' + # '@root' is a key SOURCE, not a key: remember who seeds and resolve the + # actual lines after the root check — /root/.ssh is unreadable before it. + if [ "$k" = "@root" ]; then + USER_SEED[$u]=1 + NEED_SEED=1 + else + USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n' + fi case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac case ",$r," in *,box,*) NEED_INCUS=1 ;; esac done <<< "$PARSED" @@ -108,6 +127,24 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \ die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)" fi +# --- @root seed source (#17) ------------------------------------------------- +# The lockout-avoidance move: the operator SSHed in as root to run this at +# all, so root's CURRENT authorized_keys provably contains a key they hold — +# the one claim no local check can make about a pasted literal. Resolved ONCE +# here (post-root-check: /root/.ssh needs root) and copied verbatim, options +# included: a from=/command= restriction on a root key line follows it to the +# user, which is honest — rig will not silently widen what a key can do. +# Comments and blanks are dropped so the seeded block is exactly key lines; +# an empty result is a hard stop, because seeding nothing would converge the +# admin's authorized_keys to empty and close-root would then refuse — better +# to name the real problem now. +ROOT_SEED_KEYS="" +if [ "$NEED_SEED" -eq 1 ]; then + ROOT_SEED_KEYS="$(grep -Ev '^[[:space:]]*(#|$)' /root/.ssh/authorized_keys 2>/dev/null || true)" + [ -n "$ROOT_SEED_KEYS" ] \ + || die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead" +fi + # Class is a note, never a refusal: #26's call is that operators belong on # EVERY class — what differs is root SSH's fate once they exist. case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in @@ -199,8 +236,16 @@ for u in "${USERS[@]}"; do home="$(getent passwd "$u" | cut -d: -f6)" ugroup="$(id -gn "$u")" mkdir -p "$home/.ssh" + # Seeded (@root) keys land FIRST, literal lines append after — fixed order + # so the cmp-guard sees identical bytes on identical state and re-runs + # converge to root's then-current keys plus the literals (#17). A literal + # that duplicates a seeded key writes twice; sshd does not mind and the + # bytes stay deterministic. AK_TMP="$(mktemp)" - printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP" + { + if [ -n "${USER_SEED[$u]:-}" ]; then printf '%s\n' "$ROOT_SEED_KEYS"; fi + printf '%s' "${USER_KEYS[$u]:-}" + } > "$AK_TMP" if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys" log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)" diff --git a/commands/users-close-root.sh b/commands/users-close-root.sh index 26d8d74..4330f82 100755 --- a/commands/users-close-root.sh +++ b/commands/users-close-root.sh @@ -12,6 +12,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" . "$HERE/lib/users-config.sh" log() { printf 'rig-users: %s\n' "$*"; } +warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; } die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { @@ -26,15 +27,25 @@ Human class ONLY. On class=server, root SSH is the control plane's (Coolify's) automation identity — closing it severs fleet management — so close-root refuses there, with no --force. It also refuses without a role marker (re-run rig bootstrap; never shut the root door blind) and refuses while no rig-admin -member holds a login sshd would plausibly accept — authorized_keys present -and non-empty, home/.ssh/keys owned by the user and not group/world-writable -(sshd's StrictModes rejects the key otherwise), a real login shell, account -not expired. The refusal names which check failed, per candidate. Run rig -users apply first; never close the only door. +member holds a login this box would actually honor. Per candidate, in order: +the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys +owned by the user and not group/world-writable, a real login shell, account +not expired), then two reachability proofs (#17) — `sudo -n true` under +runuser must answer (NOPASSWD sudo is effective, not merely written), and +`sshd -T -C user=...` must resolve a per-user effective config that accepts +the login (pubkeyauthentication yes, no DenyUsers hit — where any pattern or +host-qualified Deny entry counts as a hit, fail closed — AllowUsers, if set, +names them literally, and the same pair of rules for DenyGroups/AllowGroups +judged against the admin's actual groups from id -Gn). The refusal names +which check failed, per candidate. Run rig users apply first; never close +the only door. Before running, verify your admin login in a SEPARATE session — `ssh @` while this one stays open. Root SSH is the door being welded -shut; the admin door must be proven, not presumed. +shut; the admin door must be proven, not presumed. This is not ceremony: the +local probe resolves Match blocks against a synthetic loopback client +(addr=127.0.0.1), so a `Match Address` rule that treats real inbound clients +differently is invisible to it — only a real login proves the real door. Run as root. Convergent: once root is closed, a re-run is a clean no-op. EOF @@ -70,16 +81,33 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then fi # Admin-door gate — never close the only door. Root SSH goes away below, so -# at least one rig-admin member must hold a login sshd would plausibly ACCEPT -# — a non-empty authorized_keys alone proves a file exists, not a door: +# at least one rig-admin member must hold a login this box would actually +# HONOR — a non-empty authorized_keys alone proves a file exists, not a door: # StrictModes rejects keys behind wrongly-owned or group/world-writable # paths, a nologin shell never logs in, and an expired account fails PAM # before the key is read. So every candidate is checked for the StrictModes -# shape, and the refusal names, per candidate, WHICH check failed — an -# operator staring at a refusal must see the repair. Honestly: this proves -# the door SHOULD open per StrictModes, not that it does — the -# verify-in-a-separate-session advisory in --help stays load-bearing. +# shape, and then for REACHABILITY (#17): the shape checks prove the door +# SHOULD open, these prove what can be proven from inside — that NOPASSWD +# sudo actually answers (`sudo -n true` under runuser; a sudoers drop-in +# that never landed is a shape the file checks cannot see), and that sshd's +# per-user EFFECTIVE config would accept the login (`sshd -T -C user=...` — +# an AllowUsers or Match block elsewhere can quietly exclude the admin while +# every file looks right). The refusal names, per candidate, WHICH check +# failed — an operator staring at a refusal must see the repair. Honestly: +# the one thing no local check can prove is that the operator HOLDS the +# private key — the verify-in-a-separate-session advisory in --help stays +# load-bearing. today=$(( $(date +%s) / 86400 )) +# runuser ships in util-linux on Debian — rig's target — but the gate must +# not die on a box without it: skip the live sudo proof with a loud warning +# rather than block close-root on a missing prover. Warned once, not per +# candidate. +HAVE_RUNUSER=0 +if command -v runuser >/dev/null 2>&1; then + HAVE_RUNUSER=1 +else + warn "runuser not found; skipping the live NOPASSWD-sudo proof — verify 'sudo -n true' as your admin by hand before trusting the closed door" +fi # path_strict