fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion
Three refusals, one doctrine: detect, refuse, name the repair — and never back out state rig did not create. - verify_effective_tag grows the same <back-out|keep> mode discipline as verify_user_owned. First join keeps the logout-and-die on an untagged key; the already-joined path now refuses WITHOUT logout — the untagged node may be a login-joined workstation (untagged by design) that a join=authkey re-run must not tear off the tailnet. The die names both ways out. - verify_user_owned fails CLOSED on a stalled backend: empty tags is its success signal, so a 30s poll that never saw Running waved a tagged node on a slow tailscaled through as user-owned. state!=Running now dies in both modes, logging nothing out — nothing was verified, so the repair is to re-run and verify, not to undo a join that may be fine. - The permitrootlogin acceptance is class-gated. class=human keeps no|prohibit-password|without-password (`no` is the close-root state). class=server accepts only prohibit-password|without-password: root SSH is the control plane's automation door, and `no` there means a leftover 00-rig-users.conf from a former class=human life has fleet management silently dead. Refused loudly, drop-in named, never auto-removed — silently reopening a root door is worse than a loud stop. Harness greps pin all three die messages so a deleted guard cannot ship green (repo precedent: the tag-refusal greps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
22ef458b1a
commit
062dad4ead
2 changed files with 74 additions and 14 deletions
|
|
@ -229,12 +229,26 @@ rm -f "$TMP"
|
||||||
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
||||||
echo "$eff" | grep -qx 'passwordauthentication no' \
|
echo "$eff" | grep -qx 'passwordauthentication no' \
|
||||||
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
||||||
# `no` is accepted because it is the post-`rig users close-root` state —
|
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
|
||||||
# strictly harder than the prohibit-password this script installs. Bootstrap
|
# things on the two classes. class=human: `no` is the post-`rig users
|
||||||
# must never read a closed door as a broken one, and it cannot reopen one
|
# close-root` state — strictly harder than the prohibit-password this script
|
||||||
# either: by first-wins its own drop-in loses to 00-rig-users.conf.
|
# installs. Bootstrap must never read a closed door as a broken one, and it
|
||||||
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
# cannot reopen one either: by first-wins its own drop-in loses to
|
||||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
|
||||||
|
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
|
||||||
|
# management silently dead, and the likely culprit is a drop-in left over from
|
||||||
|
# a former class=human life on a repurposed box. rig can DETECT that but must
|
||||||
|
# not FIX it: silently reopening a root door is worse than a loud stop, so —
|
||||||
|
# same doctrine as the tag checks — detect, refuse, and name the repair.
|
||||||
|
if [ "$CLASS" = "human" ]; then
|
||||||
|
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
||||||
|
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||||
|
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
|
||||||
|
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
|
||||||
|
else
|
||||||
|
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
||||||
|
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||||
|
fi
|
||||||
log "sshd hardening verified (sshd -T: passwordauthentication no)"
|
log "sshd hardening verified (sshd -T: passwordauthentication no)"
|
||||||
|
|
||||||
# --- system hostname ----------------------------------------------------------
|
# --- system hostname ----------------------------------------------------------
|
||||||
|
|
@ -270,8 +284,15 @@ fi
|
||||||
# Tags ride in with the netmap, not synchronously out of `up`, so a single read
|
# Tags ride in with the netmap, not synchronously out of `up`, so a single read
|
||||||
# right after join can legitimately come back empty; poll until tags appear OR
|
# right after join can legitimately come back empty; poll until tags appear OR
|
||||||
# the backend reaches Running (past which an empty Tags is real, not just early).
|
# the backend reaches Running (past which an empty Tags is real, not just early).
|
||||||
|
#
|
||||||
|
# verify_effective_tag <back-out|keep> — same mode discipline as
|
||||||
|
# verify_user_owned: back-out on first join (rig just spent the key, so an
|
||||||
|
# untagged result is rig's own mess to undo), keep on the already-joined path
|
||||||
|
# (never back out state rig did not create — the join there may be a
|
||||||
|
# legitimately login-joined, user-owned workstation that someone re-ran with
|
||||||
|
# join=authkey by mistake).
|
||||||
verify_effective_tag() {
|
verify_effective_tag() {
|
||||||
local deadline=$((SECONDS + 30)) tags="" state="" json
|
local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json
|
||||||
json="$(mktemp)"
|
json="$(mktemp)"
|
||||||
while :; do
|
while :; do
|
||||||
if tailscale status --json > "$json" 2>/dev/null; then
|
if tailscale status --json > "$json" 2>/dev/null; then
|
||||||
|
|
@ -291,11 +312,20 @@ verify_effective_tag() {
|
||||||
# node anyway, so rig must now catch this out loud. A wrong tag cannot be fixed
|
# node anyway, so rig must now catch this out loud. A wrong tag cannot be fixed
|
||||||
# in place (`tailscale set` has no tag flag; re-tagging needs a fresh key via
|
# in place (`tailscale set` has no tag flag; re-tagging needs a fresh key via
|
||||||
# `up --force-reauth`), so back the node out rather than leave a half-joined,
|
# `up --force-reauth`), so back the node out rather than leave a half-joined,
|
||||||
# user-owned device squatting a hostname.
|
# user-owned device squatting a hostname. That back-out is EARNED only on the
|
||||||
|
# first-join path, where rig itself just performed the join; on the
|
||||||
|
# already-joined path an untagged node may be exactly what someone built on
|
||||||
|
# purpose — a login-joined workstation is untagged BY DESIGN — and tearing it
|
||||||
|
# off the tailnet because a re-run said join=authkey would destroy state rig
|
||||||
|
# did not create. keep mode refuses without touching the join and names both
|
||||||
|
# ways out, since rig cannot tell which one the operator meant.
|
||||||
if [ -z "$tags" ]; then
|
if [ -z "$tags" ]; then
|
||||||
tailscale logout >/dev/null 2>&1 \
|
if [ "$mode" = "back-out" ]; then
|
||||||
|| warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand"
|
tailscale logout >/dev/null 2>&1 \
|
||||||
die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run."
|
|| warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand"
|
||||||
|
die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run."
|
||||||
|
fi
|
||||||
|
die "this box is joined but UNTAGGED — possibly a login-joined (user-owned) machine re-run with join=authkey. It was joined before this run, so nothing was backed out. If it should be fleet-owned: run 'tailscale logout' and re-run with a TAGGED pre-auth key. If it is a workstation: re-run with --join login."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# tag:server policy is DERIVED, not a trait: it means "the control plane
|
# tag:server policy is DERIVED, not a trait: it means "the control plane
|
||||||
|
|
@ -354,6 +384,17 @@ verify_user_owned() {
|
||||||
fi
|
fi
|
||||||
die "this node is TAGGED (${shown}) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity. It was joined before this run, so nothing was backed out: run 'tailscale logout' and re-run bootstrap, or re-run with --join authkey."
|
die "this node is TAGGED (${shown}) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity. It was joined before this run, so nothing was backed out: run 'tailscale logout' and re-run bootstrap, or re-run with --join authkey."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Fail CLOSED on a poll that never reached Running: empty tags is this
|
||||||
|
# function's SUCCESS signal, which makes a timeout uniquely dangerous here —
|
||||||
|
# a tagged node on a slow tailscaled reads as empty and would be waved
|
||||||
|
# through as user-owned (verify_effective_tag has the mirror problem, but
|
||||||
|
# there timeout-empty already lands in a refusal). Nothing was verified
|
||||||
|
# either way, and the join may be perfectly fine, so neither mode logs out;
|
||||||
|
# the only honest move is to stop and have the operator re-run the verify.
|
||||||
|
if [ "$state" != "Running" ]; then
|
||||||
|
die "tailscale backend never reached Running within 30s — could not verify the join is user-owned and untagged. Nothing was backed out; re-run bootstrap to verify once tailscaled settles."
|
||||||
|
fi
|
||||||
log "user-owned join verified (untagged)"
|
log "user-owned join verified (untagged)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,11 +430,11 @@ if tailscale status >/dev/null 2>&1; then
|
||||||
# deliberate and stays — re-running an identical tagged-authkey `up` errors —
|
# deliberate and stays — re-running an identical tagged-authkey `up` errors —
|
||||||
# but skipping the CHECK was how the M900s stayed mis-tagged unnoticed.
|
# but skipping the CHECK was how the M900s stayed mis-tagged unnoticed.
|
||||||
# The check the traits demand: authkey wants the granted tag, login wants none
|
# The check the traits demand: authkey wants the granted tag, login wants none
|
||||||
# (`keep`: never back out a join this run did not perform).
|
# — both in `keep` mode: never back out a join this run did not perform.
|
||||||
if [ "$JOIN" = "login" ]; then
|
if [ "$JOIN" = "login" ]; then
|
||||||
verify_user_owned keep
|
verify_user_owned keep
|
||||||
else
|
else
|
||||||
verify_effective_tag
|
verify_effective_tag keep
|
||||||
fi
|
fi
|
||||||
elif [ "$JOIN" = "login" ]; then
|
elif [ "$JOIN" = "login" ]; then
|
||||||
# No pre-auth key on this path — the human at the keyboard is the credential.
|
# No pre-auth key on this path — the human at the keyboard is the credential.
|
||||||
|
|
@ -414,7 +455,7 @@ else
|
||||||
# cannot be rescued by one (verify_effective_tag refuses it and logs out).
|
# cannot be rescued by one (verify_effective_tag refuses it and logs out).
|
||||||
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
|
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
|
||||||
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
||||||
verify_effective_tag
|
verify_effective_tag back-out
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- role marker --------------------------------------------------------------
|
# --- role marker --------------------------------------------------------------
|
||||||
|
|
|
||||||
19
test/cli.sh
19
test/cli.sh
|
|
@ -78,6 +78,18 @@ check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
||||||
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
|
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
|
||||||
check "bootstrap: login-path tagged refusal is present" 0 "" \
|
check "bootstrap: login-path tagged refusal is present" 0 "" \
|
||||||
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
|
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# Re-running with join=authkey on a box that was legitimately login-joined
|
||||||
|
# (untagged BY DESIGN) lands in verify_effective_tag's untagged branch. Backing
|
||||||
|
# out a join this run did not perform would tear down a user-owned workstation;
|
||||||
|
# the already-joined path must refuse WITHOUT logout and name both repairs.
|
||||||
|
# Needs a real tailnet to exercise, so grep the keep-mode die instead.
|
||||||
|
check "bootstrap: already-joined untagged refusal keeps the join" 0 "" \
|
||||||
|
grep -q "joined but UNTAGGED" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# verify_user_owned must fail CLOSED on a stalled backend: empty tags is its
|
||||||
|
# SUCCESS signal, so a 30s poll that never saw Running would wave a tagged node
|
||||||
|
# through as user-owned. Grep the timeout die (same real-tailnet excuse).
|
||||||
|
check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
|
||||||
|
grep -q "could not verify the join is user-owned" "$ROOT/commands/bootstrap.sh"
|
||||||
# The marker is the traits' ground truth for rig users; assert the write exists.
|
# The marker is the traits' ground truth for rig users; assert the write exists.
|
||||||
check "bootstrap: role marker write is present" 0 "" \
|
check "bootstrap: role marker write is present" 0 "" \
|
||||||
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
||||||
|
|
@ -385,6 +397,13 @@ fi
|
||||||
# the widened assertion so a revert cannot ship green.
|
# the widened assertion so a revert cannot ship green.
|
||||||
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
|
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
|
||||||
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
|
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# ...but only for class=human. On class=server a closed root door is a BROKEN
|
||||||
|
# box — root SSH is the control plane's automation door — and the usual cause
|
||||||
|
# is a 00-rig-users.conf left over from a former class=human life. The refusal
|
||||||
|
# must name that drop-in or the operator greps sshd configs blind; the path
|
||||||
|
# needs root + a doctored sshd, so grep the die message (repo precedent above).
|
||||||
|
check "bootstrap: class=server refusal names the stale close-root drop-in" 0 "" \
|
||||||
|
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/bootstrap.sh"
|
||||||
|
|
||||||
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
||||||
# error in it would be invisible here and would first surface at 04:00 on a live
|
# error in it would be invisible here and would first surface at 04:00 on a live
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue