users: finish #17 — close-root proves the door (sudo -n, per-user sshd -T), @root key seeding, runner row owned #30

Merged
dan-claude-bot merged 6 commits from feat/close-root-reachability into main 2026-07-18 17:39:35 +00:00
6 changed files with 563 additions and 23 deletions

View file

@ -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 root there is the **automation** identity the control plane (Coolify) SSHes
in as. It is a machine door, never a human one. 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 **The detection side benefit:** once humans never use root, any root login
that is not the control plane is anomalous *by definition* — a cheap, that is not the control plane is anomalous *by definition* — a cheap,
high-signal alert that a shared root identity makes impossible to write. 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 `--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. 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* **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 private infra repo and is passed per invocation — rig never persists it. It
holds nothing secret anyway: usernames, roles, and *public* keys. holds nothing secret anyway: usernames, roles, and *public* keys.
@ -602,9 +625,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 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 the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the
user and not group/world-writable), a real login shell, and an unexpired 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 account — and then two **reachability** proofs (#17): `sudo -n true` under
the door *should* open, not that it does — which is why the separate-session `runuser` must answer, so NOPASSWD sudo is effective rather than merely
verification below stays load-bearing. Never close the only door. written, and `sshd -T -C user=<admin>` 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 Before running it, prove the admin door in a **separate** session — `ssh
<admin>@<box>` while this one stays open. Root SSH is being welded shut; the <admin>@<box>` while this one stays open. Root SSH is being welded shut; the

View file

@ -13,6 +13,16 @@
# Repeated username lines are additional authorized keys; the roles field must # Repeated username lines are additional authorized keys; the roles field must
# be IDENTICAL on each — a repeated line means "another key", never a quiet # be IDENTICAL on each — a repeated line means "another key", never a quiet
# role edit hiding mid-file. '#' comments and blank lines are skipped. # 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 <path> # parse_users_file <path>
# #
@ -24,9 +34,11 @@
# Refusals: unknown role (the valid set is named), differing roles across one # 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 # 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 # 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 — # not start with an SSH key type and is not exactly '@root'), '@root' with
# '|' would corrupt this parser's own delimited stream, a leading '-' reads # trailing material (the token IS the whole field), invalid username (the
# as a useradd flag), duplicate identical key line. # 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() { parse_users_file() {
local path="$1" local path="$1"
local -a errs=() out=() rlist=() local -a errs=() out=() rlist=()
@ -41,9 +53,13 @@ parse_users_file() {
continue continue
fi fi
case "$k" in 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-*) ;; 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 ;; continue ;;
esac esac
# The username feeds this parser's own '|'-delimited stream and then # The username feeds this parser's own '|'-delimited stream and then
@ -115,10 +131,91 @@ assert_marker_human() {
*class=server*) *class=server*)
# Root SSH on a server IS the control plane's (Coolify's) automation # Root SSH on a server IS the control plane's (Coolify's) automation
# identity — closing it severs fleet management. No --force exists. # 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 ;; return 1 ;;
*) *)
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind" printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
return 1 ;; return 1 ;;
esac esac
} }
# deny_verdict <user> <denyusers token...>
#
# Judge sshd's effective DenyUsers list against ONE candidate, fail closed.
# Empty output = every token is PROVABLY irrelevant to <user>: 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 <space-separated groups> <denygroups token...>
#
# 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 <space-separated groups> <allowgroups token...>
#
# 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
}

View file

@ -32,6 +32,17 @@ keys; the roles must be identical on every line of one user.
dan admin,box ssh-ed25519 AAAA... dan@laptop dan admin,box ssh-ed25519 AAAA... dan@laptop
maria rig,box ssh-ed25519 AAAA... maria@mac 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: roles:
admin group rig-admin — full NOPASSWD sudo admin group rig-admin — full NOPASSWD sudo
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
@ -78,11 +89,12 @@ fi
PARSED="$(parse_users_file "$FILE")" \ PARSED="$(parse_users_file "$FILE")" \
|| die "invalid users file: $FILE — every error is listed above; nothing was changed" 2 || 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=() USERS=()
BOX_USERS=() BOX_USERS=()
NEED_SUDO=0 NEED_SUDO=0
NEED_INCUS=0 NEED_INCUS=0
NEED_SEED=0
while IFS='|' read -r u r k; do while IFS='|' read -r u r k; do
[ -n "$u" ] || continue [ -n "$u" ] || continue
if [ -z "${USER_ROLES[$u]:-}" ]; then if [ -z "${USER_ROLES[$u]:-}" ]; then
@ -90,7 +102,14 @@ while IFS='|' read -r u r k; do
USER_ROLES[$u]="$r" USER_ROLES[$u]="$r"
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
fi 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 *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac
case ",$r," in *,box,*) NEED_INCUS=1 ;; esac case ",$r," in *,box,*) NEED_INCUS=1 ;; esac
done <<< "$PARSED" 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)" 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 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 # 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. # EVERY class — what differs is root SSH's fate once they exist.
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in 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)" home="$(getent passwd "$u" | cut -d: -f6)"
ugroup="$(id -gn "$u")" ugroup="$(id -gn "$u")"
mkdir -p "$home/.ssh" 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)" 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 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" install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys"
log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)" log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)"

View file

@ -12,6 +12,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/users-config.sh" . "$HERE/lib/users-config.sh"
log() { printf 'rig-users: %s\n' "$*"; } 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}"; } die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { 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 automation identity — closing it severs fleet management — so close-root
refuses there, with no --force. It also refuses without a role marker (re-run 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 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 member holds a login this box would actually honor. Per candidate, in order:
and non-empty, home/.ssh/keys owned by the user and not group/world-writable the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys
(sshd's StrictModes rejects the key otherwise), a real login shell, account owned by the user and not group/world-writable, a real login shell, account
not expired. The refusal names which check failed, per candidate. Run rig not expired), then two reachability proofs (#17) — `sudo -n true` under
users apply first; never close the only door. 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 Before running, verify your admin login in a SEPARATE session — `ssh
<admin>@<box>` while this one stays open. Root SSH is the door being welded <admin>@<box>` 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. Run as root. Convergent: once root is closed, a re-run is a clean no-op.
EOF EOF
@ -70,16 +81,33 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
fi fi
# Admin-door gate — never close the only door. Root SSH goes away below, so # 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 # at least one rig-admin member must hold a login this box would actually
# — a non-empty authorized_keys alone proves a file exists, not a door: # HONOR — a non-empty authorized_keys alone proves a file exists, not a door:
# StrictModes rejects keys behind wrongly-owned or group/world-writable # StrictModes rejects keys behind wrongly-owned or group/world-writable
# paths, a nologin shell never logs in, and an expired account fails PAM # 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 # before the key is read. So every candidate is checked for the StrictModes
# shape, and the refusal names, per candidate, WHICH check failed — an # shape, and then for REACHABILITY (#17): the shape checks prove the door
# operator staring at a refusal must see the repair. Honestly: this proves # SHOULD open, these prove what can be proven from inside — that NOPASSWD
# the door SHOULD open per StrictModes, not that it does — the # sudo actually answers (`sudo -n true` under runuser; a sudoers drop-in
# verify-in-a-separate-session advisory in --help stays load-bearing. # 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 )) 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 <path> <uid> <label> — flag the two StrictModes complaints # path_strict <path> <uid> <label> — flag the two StrictModes complaints
flag() { bad="${bad:+$bad, }$1"; } flag() { bad="${bad:+$bad, }$1"; }
path_strict() { path_strict() {
@ -123,6 +151,62 @@ while IFS= read -r a; do
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
flag "account expired" flag "account expired"
fi fi
# Reachability proof 1 — NOPASSWD sudo answers for real. `sudo -n` never
# prompts: with the %rig-admin NOPASSWD rule effective it exits 0, and a
# sudoers drop-in that failed to land (or a sudo that is simply absent)
# exits non-zero right here instead of after root is welded shut.
if [ "$HAVE_RUNUSER" -eq 1 ]; then
if ! runuser -u "$a" -- sudo -n true >/dev/null 2>&1; then
flag "sudo -n true fails as this user (NOPASSWD sudo not effective — re-run rig users apply)"
fi
fi
# Reachability proof 2 — sshd's per-user EFFECTIVE config accepts them.
# `sshd -T -C user=...` resolves Match blocks for exactly this login, so an
# exclusion the global `sshd -T` never shows is caught. Allow/Deny entries
# are judged fail-closed in BOTH directions: AllowUsers must name the admin
# literally (a pattern that would in fact admit them still flags — the
# operator proves patterns by hand), and DenyUsers flags on a literal hit
# OR on any pattern/host-qualified token (deny_verdict, in the lib) —
# 'DenyUsers dan*' really denies admin 'dan', and a token this check cannot
# prove irrelevant must count as a hit, never as a pass. What no local
# probe can resolve is a Match on the CLIENT's address — the -C probe pins
# addr=127.0.0.1 — which is why the separate-session verification stays
# load-bearing.
if perT="$(sshd -T -C "user=$a,host=$(hostname),addr=127.0.0.1" 2>/dev/null)"; then
if ! printf '%s\n' "$perT" | grep -qx 'pubkeyauthentication yes'; then
flag "sshd resolves pubkeyauthentication != yes for this user"
fi
deny_line="$(printf '%s\n' "$perT" | grep -i '^denyusers ' | head -n1)"
if [ -n "$deny_line" ]; then
# shellcheck disable=SC2086 # word-splitting the tokens is the point
deny_reason="$(deny_verdict "$a" ${deny_line#* })"
[ -n "$deny_reason" ] && flag "$deny_reason"
fi
# The group directives close the same door through the other hinge: sshd
# enforces Allow/DenyGroups against the candidate's ACTUAL membership, so
# the gate resolves id -Gn and judges both with the same fail-closed
# discipline as the *Users pair. id failing yields no groups, which makes
# a set AllowGroups flag — the safe direction.
a_groups="$(id -Gn -- "$a" 2>/dev/null)"
denyg_line="$(printf '%s\n' "$perT" | grep -i '^denygroups ' | head -n1)"
if [ -n "$denyg_line" ]; then
# shellcheck disable=SC2086 # word-splitting the tokens is the point
denyg_reason="$(group_deny_verdict "$a_groups" ${denyg_line#* })"
[ -n "$denyg_reason" ] && flag "$denyg_reason"
fi
allowg_line="$(printf '%s\n' "$perT" | grep -i '^allowgroups ' | head -n1)"
if [ -n "$allowg_line" ]; then
# shellcheck disable=SC2086 # word-splitting the tokens is the point
allowg_reason="$(group_allow_verdict "$a_groups" ${allowg_line#* })"
[ -n "$allowg_reason" ] && flag "$allowg_reason"
fi
if printf '%s\n' "$perT" | grep -qi '^allowusers ' \
&& ! printf '%s\n' "$perT" | grep -i '^allowusers ' | tr ' ' '\n' | grep -qx "$a"; then
flag "sshd AllowUsers is set and does not name this user"
fi
else
flag "sshd -T -C user=$a failed — cannot resolve the per-user config sshd would apply"
fi
if [ -z "$bad" ]; then ADMIN_OK=1; break; fi if [ -z "$bad" ]; then ADMIN_OK=1; break; fi
DETAIL="$DETAIL; $a: $bad" DETAIL="$DETAIL; $a: $bad"
done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n') done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n')

View file

@ -0,0 +1,161 @@
# close-root proves the door + `@root` key seeding — Implementation Plan (finishes #17)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish what remains of issue #17 on top of the merged fleet-users
design (#27). Main already ships the admin user (`rig users apply` — role
`admin``rig-admin` group, NOPASSWD sudo) and the role-aware root door
(`rig users close-root` — `PermitRootLogin no` gated on `class=human`, a
StrictModes-shaped admin check, and the invoker gate). Three gaps remain,
all named in #17: (1) close-root's gate proves the admin door *should* open,
not that it *does* — add the two reachability proofs #17 specifies
(`sudo -n true` under `runuser`, `sshd -T -C user=<admin>`); (2) #17's
headline lockout-avoidance — seed the admin's `authorized_keys` from root's
own, via a literal `@root` key-field token in the users file; (3) #17's
table said runner "can close root once an admin is proven", but main's class
model refuses close-root on `class=server` (runner) — reconcile in prose,
not code.
**Why this shape:** the admin-door gate exists so close-root never welds
shut the only door. But every check it makes today reads *files*: a sudoers
drop-in that failed to land, or an `AllowUsers`/`Match` block elsewhere in
sshd's config, leaves every file looking right while the door stays shut.
#17 names the two checks that interrogate *behavior* instead: `sudo -n true`
run as the admin (NOPASSWD sudo answers, or it doesn't — `-n` never
prompts), and `sshd -T -C user=<admin>,host=...,addr=...` (the per-user
EFFECTIVE config sshd would apply to exactly that login, Match blocks
resolved). And the one thing no local check can prove — that the operator
*holds* the admin's private key — is what `@root` answers at apply time: the
operator is connected as root *right now* using one of root's keys, so
seeding those keys is live evidence the private key is in their hands,
strictly better than any check rig could invent over a pasted literal.
## The measured lock-root table (from #17, verbatim constraints)
| Measure | Key-based root SSH after | Verdict |
|---|---|---|
| `passwd -l root` | works | harmless |
| `PermitRootLogin prohibit-password` | works | safe — bootstrap's state |
| `usermod --expiredate 1 root` | **breaks** | never |
| root shell → nologin | **breaks** | never |
| `PermitRootLogin no` | breaks root only | close-root's move, gated |
Nothing here may introduce `usermod --expiredate` or a nologin shell for
root — both break key SSH and rig's own root-run convergence path.
## Architecture
- `commands/users-close-root.sh`: the reachability proofs join the existing
per-candidate gate loop as **additive** `flag()` checks — same refusal
shape, naming the failing check per candidate. `runuser` may be absent
off-Debian: precomputed once; absence skips the sudo proof with a loud
`warn`, never a die (a missing prover must not block the door — but the
operator is told what to verify by hand). `sshd -T -C` failing *is* a
flag: fail closed. Allow/Deny entries are matched literally — a pattern
that would admit the admin still refuses (fail closed; the operator proves
patterns by hand). Both proofs sit before the drop-in install, pinned by
line-number ordering asserts.
- `commands/lib/users-config.sh` (`parse_users_file`): the key field admits
the literal token `@root` — shape-validated in the parse pass (exit 2,
pre-root-check, testable non-root): exactly `@root`, no trailing material;
a second `@root` for one user falls into the existing duplicate-line
refusal for free; `root` as username stays refused. Mixing semantics
(the simplest sound call): `@root` mixes with literal key lines — seeded
keys land FIRST, literals append after, fixed order so the cmp-guard sees
deterministic bytes.
- `commands/users-apply.sh`: resolves `@root` ONCE, after the root check
(/root/.ssh needs root): root's current `authorized_keys`, comments and
blanks dropped, key lines copied verbatim (options included — a
`from=`/`command=` restriction follows its key; rig will not silently
widen what a key can do). Empty/absent root keys die with the repair.
Convergence: every run re-seeds from root's then-current file — the
honest exception #17 weighs (a hand-removed seeded key returns) is
resolved toward convergence, with "switch the line to literal keys" as
the escape hatch, documented in usage and README.
- The runner row: **no gate change.** The `class=server` refusal in
`assert_marker_human` grows the explanation (server-class machines are
automation identities; root is the management plane; runner stays
server-class deliberately; `--class human` at bootstrap is the path for a
humanly-administered CI box), and the README identity-model section gets
one short divergence paragraph.
**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar
shellcheck + `bash test/cli.sh`) — no workflow change.
## Non-Goals
- No change to the class gate: `class=server` still refuses close-root, no
`--force`, runner included.
- No `--admin-key` flag (#17's option (b)) — the users file already carries
literal keys; `@root` covers the lockout case.
- No filtering/rewriting of root's key options on seed (no stripping
`from=`/`command=`) — verbatim copy, documented caveat.
- No root `authorized_keys` management (Coolify owns its key material).
- No pattern-matching engine for `AllowUsers`/`DenyUsers` — literal match,
fail closed, documented.
## Global Constraints
- `set -euo pipefail`; `rig-users:` log/warn/die prefixes; exit 2 usage
(pre-root-check), exit 1 runtime refusal; cmp-guarded convergent writes.
- Never `usermod --expiredate 1 root`, never a nologin shell for root.
- shellcheck-clean as CI runs it (`shopt -s globstar; shellcheck -x bin/*
**/*.sh`); `bash test/cli.sh` green as non-root.
---
### Task 1: reachability proofs in close-root's gate
**Files:** `commands/users-close-root.sh`, `test/cli.sh`
- [x] Tests: grep the two calls (`runuser -u "$a" -- sudo -n true`,
`sshd -T -C "user=`) and pin both before the drop-in install line
(`install -m 0644 "$TMP" "$DROPIN"`) with fail-closed line-number
asserts; grep the graceful runuser-absent branch.
- [x] Implement: per-candidate flags after the shape checks; `HAVE_RUNUSER`
precomputed with a single warn; `sshd -T -C` parsed for
`pubkeyauthentication yes`, literal DenyUsers hit, AllowUsers-without-
the-admin; resolve failure flags.
- [x] `bash test/cli.sh` green; shellcheck clean.
### Task 2: `@root` seeding
**Files:** `commands/lib/users-config.sh`, `commands/users-apply.sh`,
`test/cli.sh`
- [x] Tests through the sourced `parse()` harness: exact token parses and
emits `user|roles|@root`; trailing material refused; mixes with literal
lines; second `@root` is a duplicate; root cannot seed itself; an
`@root` fixture reaches the non-root refusal (proving parse-pass
validation); grep apply's keyless-root die.
- [x] Implement: parser token cases; apply resolves post-root-check, dies
on empty; seeded-first/literals-after write through the existing
cmp-guard; usage documents the semantics.
- [x] `bash test/cli.sh` green; shellcheck clean.
### Task 3: the runner row, reconciled in prose
**Files:** `commands/lib/users-config.sh` (refusal message), `README.md`,
`test/cli.sh`
- [x] Extend the `class=server` refusal to own the divergence ("runner
included"); fixture test asserts the new wording alongside the existing
"control plane" assert.
- [x] README: one divergence paragraph in the identity-model section; the
`@root` paragraph in the apply section; the reachability sentence in the
close-root section. Surgical — a concurrent PR adds a class table to the
same README area.
## Test Plan
- **Harness:** the `@root` refusal matrix through the sourced parser
(non-root, network-free); grep-the-shipped-script guards for both
reachability calls with before-the-drop-in ordering asserts; the extended
server-refusal wording through the fixture marker gate.
- **Rehearsal (manual, out of harness — the #17 shape):** throwaway cloud
box: bootstrap `--class human`; `users apply` a file whose admin line is
`@root`; from a second terminal, SSH in as the admin with the SAME key
used for root (the seeded one) — the inbound connection is the proof rig
cannot self-assert; `close-root`; confirm root refused, admin accepted;
re-run apply and close-root — both no-op; add a literal key line, re-run,
confirm seeded-first ordering and convergence.

View file

@ -425,10 +425,40 @@ check "users parser: one run reports the root line" 0 "" grep -q "not a rig-man
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS" check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
rm -f "$MULTI_ERRS" rm -f "$MULTI_ERRS"
# --- '@root': seed the admin's keys from root's own authorized_keys (#17) ----
# The operator provably holds a root private key — they SSHed in with it to
# run apply at all — so seeding root's CURRENT authorized_keys is the one key
# source that cannot lock them out. The parser owns only the token's SHAPE
# (reading /root/.ssh needs root and is apply's business), so the shape is
# proven here: the exact token parses, trailing material is refused, literal
# key lines mix (append semantics), a second '@root' is a duplicate, and root
# cannot seed itself.
printf '%s\n' 'dan admin @root' > "$FIX_BAD"
check "users parser: '@root' is a valid key field" 0 "dan|admin|@root" parse "$FIX_BAD"
printf '%s\n' 'dan admin @root ssh-ed25519 AAAA x' > "$FIX_BAD"
check "users parser: '@root' takes no trailing material" 1 "whole key field" parse "$FIX_BAD"
printf '%s\n' 'dan admin @root' 'dan admin ssh-ed25519 AAAAC3lit dan@desk' > "$FIX_BAD"
check "users parser: '@root' mixes with literal key lines" \
0 "dan|admin|ssh-ed25519 AAAAC3lit dan@desk" parse "$FIX_BAD"
printf '%s\n' 'dan admin @root' 'dan admin @root' > "$FIX_BAD"
check "users parser: a second '@root' line is a duplicate" 1 "duplicate key line" parse "$FIX_BAD"
printf '%s\n' 'root admin @root' > "$FIX_BAD"
check "users parser: root cannot seed from itself" 1 "not a rig-managed user" parse "$FIX_BAD"
# The empty-seed refusal (root has no authorized_keys) sits behind the root
# check — /root/.ssh is unreadable before it — so grep the die message, the
# same way every root-only refusal in this harness is pinned.
check "users apply: '@root' with a keyless root dies naming the repair" 0 "" \
grep -q "root has no authorized_keys" "$ROOT/commands/users-apply.sh"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
# A VALID fixture proves the whole file-validation pass sits before the # A VALID fixture proves the whole file-validation pass sits before the
# root check — a parse failure here would exit 2, not 1. # root check — a parse failure here would exit 2, not 1.
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK" check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
# An '@root' fixture reaching the root check proves the token is parse-pass
# validation, not a runtime surprise.
printf '%s\n' 'dan admin @root' > "$FIX_BAD"
check "users apply: '@root' fixture parses, refuses non-root" 1 "must run as root" \
"$ROOT/commands/users-apply.sh" --file "$FIX_BAD"
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh" check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
else else
echo "skip: users non-root refusals (running as root)" echo "skip: users non-root refusals (running as root)"
@ -509,6 +539,87 @@ check "users close-root: no-op needs a daemon start newer than the config" 0 ""
# needs root + real accounts, so grep the load-bearing check's wording. # needs root + real accounts, so grep the load-bearing check's wording.
check "users close-root: gate checks the StrictModes shape" 0 "" \ check "users close-root: gate checks the StrictModes shape" 0 "" \
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh" grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
# The reachability proofs (#17): the shape checks prove the door SHOULD open;
# these prove what can be proven from inside — NOPASSWD sudo actually answers
# (`runuser ... sudo -n true`) and sshd's per-user EFFECTIVE config accepts
# the login (`sshd -T -C user=...`). Both need root, real accounts, and a
# live sshd, so grep the calls — and pin their ordering BEFORE the drop-in
# install, because reachability proven after the door shut is no proof at
# all. Match the calls, not the words (comments mention neither literal);
# defaults fail closed.
# The $a/$TMP/$DROPIN below are LITERALS we grep for in the script — single
# quotes are the point, as in the db and box-install checks.
# shellcheck disable=SC2016
check "users close-root: gate proves NOPASSWD sudo answers" 0 "" \
grep -qF -- 'runuser -u "$a" -- sudo -n true' "$ROOT/commands/users-close-root.sh"
check "users close-root: gate resolves sshd's per-user config" 0 "" \
grep -qF -- 'sshd -T -C "user=' "$ROOT/commands/users-close-root.sh"
# shellcheck disable=SC2016
sudon_at="$(grep -nF -- 'runuser -u "$a" -- sudo -n true' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
pert_at="$(grep -nF -- 'sshd -T -C "user=' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
# shellcheck disable=SC2016
dropin_at="$(grep -nF 'install -m 0644 "$TMP" "$DROPIN"' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
check "users close-root: sudo -n proof precedes the drop-in install" \
0 "" test "${sudon_at:-999999}" -lt "${dropin_at:-0}"
check "users close-root: per-user sshd resolve precedes the drop-in install" \
0 "" test "${pert_at:-999999}" -lt "${dropin_at:-0}"
# runuser may be absent off-Debian; the gate must skip that one proof loudly,
# never die on a missing prover. Grep the graceful branch.
check "users close-root: a missing runuser skips the sudo proof, loudly" 0 "" \
grep -q "runuser not found" "$ROOT/commands/users-close-root.sh"
# DenyUsers judged fail-closed through the lib's pure deny_verdict — sshd
# accepts patterns and USER@HOST forms, and 'DenyUsers dan*' REALLY denies
# admin 'dan', so a token the check cannot prove irrelevant must flag, never
# pass (the review's regression: a wildcard denial). Empty output is the only
# pass; every hit names its reason.
deny_v() { # deny_v <user> <token...>
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"; shift
deny_verdict "$@"' _ "$ROOT" "$@"
}
check "users close-root: deny_verdict flags a literal hit" \
0 "names this user" deny_v admin root admin
check "users close-root: deny_verdict fails closed on a wildcard (dan* vs dan)" \
0 "pattern entry 'dan*'" deny_v dan "dan*"
check "users close-root: deny_verdict fails closed on '?' patterns" \
0 "pattern entry" deny_v admin "admi?"
check "users close-root: deny_verdict fails closed on USER@HOST forms" \
0 "host-qualified" deny_v admin "admin@10.0.0.1"
deny_pass() { [ -z "$(deny_v "$@")" ]; } # empty verdict IS the pass
check "users close-root: deny_verdict passes provably-irrelevant literals" \
0 "" deny_pass admin root git backup
# The group directives, same discipline, judged against the candidate's ACTUAL
# membership (the review's regressions: an unmet AllowGroups, a DenyGroups
# naming a group they hold). First arg is the id -Gn word list.
groups_v() { # groups_v <fn> <groups> <token...>
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"; shift
"$@"' _ "$ROOT" "$@"
}
groups_pass() { [ -z "$(groups_v "$@")" ]; }
check "users close-root: DenyGroups naming a held group flags" \
0 "a group this user is in" groups_v group_deny_verdict "dan sudo rig-admin" backup sudo
check "users close-root: DenyGroups fails closed on patterns" \
0 "pattern entry 'rig-*'" groups_v group_deny_verdict "dan rig-admin" "rig-*"
check "users close-root: DenyGroups passes provably-irrelevant literals" \
0 "" groups_pass group_deny_verdict "dan rig-admin" docker backup
check "users close-root: an unmet AllowGroups flags (fail closed)" \
0 "no entry literally names a group this user is in" groups_v group_allow_verdict "dan rig-admin" sudo
check "users close-root: AllowGroups pattern is no proof (fail closed)" \
0 "no entry literally names" groups_v group_allow_verdict "dan rig-admin" "rig-*"
check "users close-root: a literally-named held group passes AllowGroups" \
0 "" groups_pass group_allow_verdict "dan rig-admin" sudo rig-admin
# ...and the shipped gate consults both, against real membership.
# shellcheck disable=SC2016
check "users close-root: the gate consults the group verdicts" 0 "" \
grep -qE '^[[:space:]]*denyg_reason="\$\(group_deny_verdict ' "$ROOT/commands/users-close-root.sh"
# shellcheck disable=SC2016
check "users close-root: the gate resolves real membership (id -Gn)" 0 "" \
grep -qF -- 'id -Gn -- "$a"' "$ROOT/commands/users-close-root.sh"
# ...and the shipped gate must actually consult it (call, not comment).
# shellcheck disable=SC2016
check "users close-root: the gate consults deny_verdict" 0 "" \
grep -qE '^[[:space:]]*deny_reason="\$\(deny_verdict ' "$ROOT/commands/users-close-root.sh"
# Marker-gate refusals through the sourced lib against fixture markers: the CLI # Marker-gate refusals through the sourced lib against fixture markers: the CLI
# path sits behind the root check, so the gate is a pure lib function on # path sits behind the root check, so the gate is a pure lib function on
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command # purpose (repo precedent: parse_users_file, assert_runner_repo). The command
@ -526,6 +637,12 @@ check "users close-root: absent marker refuses, names bootstrap as the repair" \
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
check "users close-root: class=server refuses, names the control plane" \ check "users close-root: class=server refuses, names the control plane" \
1 "control plane" marker_gate "$MARKER_DIR/server" 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" \ check "users close-root: class=human passes the gate" \
0 "" marker_gate "$MARKER_DIR/human" 0 "" marker_gate "$MARKER_DIR/human"
rm -rf "$MARKER_DIR" rm -rf "$MARKER_DIR"