forked from heavy-duty/rig
The trait was named for who lives on a box; what it decides is whether root SSH stays open as the control plane's automation door. Those are different questions, and `dev-server` proved it: an unattended VM-host appliance nobody lives on, correctly class=human because its root door must close. After #76 gave `-server` the job of naming the machine family, that box carried a suffix saying server and a trait saying human. `dev-server --root-door closed` says what is true, once. Unlike #76's role rename this field is read back on live machines, so the compat read is mandatory rather than courteous: one resolver, root_door_of, reads both vocabularies and every consumer goes through it — close-root's gate, apply's note, and bootstrap-tenant's machine-marker guard, which used the presence of `class=` as its "is this a real fleet machine?" test and would otherwise have let a tenant converge clobber a live box. New markers are written as `root-door=` only. Markers carrying both fields in disagreement, or neither, fail closed with a re-run-bootstrap repair. Fixture markers are kept deliberately at the retired spelling (the convention #76's pre-rename-cp fixture established) and pinned at both consumers; deleting the compat arm turns ten checks red. Closes #77 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
340 lines
17 KiB
Bash
340 lines
17 KiB
Bash
#!/usr/bin/env bash
|
|
# Shared parsing for the rig users family. Sourced by the users-* commands and
|
|
# by the test harness against fixture files; never executed on its own.
|
|
|
|
# The users file is line-based and whitespace-separated on purpose: a
|
|
# rig-bootstrapped box has no YAML parser and no jq, and `read` parses this
|
|
# shape for free — same jq-free reason runner-config.sh greps JSON. One line
|
|
# per key:
|
|
#
|
|
# # user roles ssh public key
|
|
# dan admin,box ssh-ed25519 AAAA... dan@laptop
|
|
#
|
|
# 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 <path>
|
|
#
|
|
# Emits one normalized 'user|roles|key' line per key line on stdout. On ANY
|
|
# validation error: EVERY error goes to stderr, each with its line number, no
|
|
# stdout, return 1. All errors in one pass because a bad file should cost one
|
|
# fix cycle, not one round-trip per line.
|
|
#
|
|
# Refusals: unknown role (the valid set is named), differing roles across one
|
|
# user's lines, root as username (root's keys are root-door 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 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=()
|
|
local -A first_roles=() seen=()
|
|
local line u r k role ok n=0
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
n=$((n + 1))
|
|
if [[ "$line" =~ ^[[:space:]]*(#|$) ]]; then continue; fi
|
|
read -r u r k <<< "$line"
|
|
if [ -z "${k:-}" ]; then
|
|
errs+=("line $n: malformed — expected 'user roles ssh-public-key' (3+ whitespace-separated fields)")
|
|
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-...) or be the literal '@root'")
|
|
continue ;;
|
|
esac
|
|
# The username feeds this parser's own '|'-delimited stream and then
|
|
# useradd: 'fo|o' silently becomes user 'fo' with garbage keys, and a
|
|
# leading '-' reads as a useradd flag mid-convergence. One safe charset
|
|
# refuses both by construction (and ':', which would corrupt passwd).
|
|
if ! [[ "$u" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
|
|
errs+=("line $n: invalid username '$u' — must match ^[a-z_][a-z0-9_-]{0,31}\$ (lowercase letter or '_' first, then lowercase, digits, '_', '-'; max 32)")
|
|
continue
|
|
fi
|
|
if [ "$u" = "root" ]; then
|
|
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is root-door policy")
|
|
continue
|
|
fi
|
|
ok=1
|
|
IFS=',' read -ra rlist <<< "$r"
|
|
for role in "${rlist[@]}"; do
|
|
case "$role" in
|
|
admin|rig|box) ;;
|
|
*) errs+=("line $n: unknown role '$role' for $u (valid roles: admin rig box)"); ok=0 ;;
|
|
esac
|
|
done
|
|
if [ -n "${first_roles[$u]:-}" ] && [ "${first_roles[$u]}" != "$r" ]; then
|
|
errs+=("line $n: $u has roles '$r' here but '${first_roles[$u]}' earlier — repeated lines add keys, roles must be identical")
|
|
ok=0
|
|
fi
|
|
if [ -z "${first_roles[$u]:-}" ]; then first_roles[$u]="$r"; fi
|
|
if [ -n "${seen[$u|$k]:-}" ]; then
|
|
errs+=("line $n: duplicate key line for $u (same key already on line ${seen[$u|$k]})")
|
|
continue
|
|
fi
|
|
seen[$u|$k]="$n"
|
|
if [ "$ok" -eq 1 ]; then out+=("$u|$r|$k"); fi
|
|
done < "$path"
|
|
if [ "${#errs[@]}" -gt 0 ]; then
|
|
printf '%s\n' "${errs[@]}" >&2
|
|
return 1
|
|
fi
|
|
if [ "${#out[@]}" -gt 0 ]; then printf '%s\n' "${out[@]}"; fi
|
|
return 0
|
|
}
|
|
|
|
# read_role_marker <path> — the marker line bootstrap wrote
|
|
# (`role=... root-door=... host=... join=...`), or nothing when absent. NO
|
|
# policy here: what an absent marker or a given trait MEANS is each caller's
|
|
# call (apply notes it, close-root refuses on it) — this reader only reads.
|
|
read_role_marker() {
|
|
[ -r "$1" ] || return 0
|
|
head -n1 "$1"
|
|
}
|
|
|
|
# root_door_of <marker line> — resolve the root-door trait from a marker LINE,
|
|
# reading both the current `root-door=` vocabulary and the `class=` one it
|
|
# replaced (#77). Prints exactly one of:
|
|
#
|
|
# closed the root SSH door is meant to shut once named operators exist
|
|
# (`rig users close-root`) — was class=human
|
|
# open root SSH stays as the control plane's automation door
|
|
# — was class=server
|
|
# conflict the marker names BOTH vocabularies and they DISAGREE
|
|
# (empty) the marker names neither, or names one with a value that is
|
|
# not in its value set
|
|
#
|
|
# Text->text and total, so the harness proves every arm off a literal string
|
|
# (repo precedent: deny_verdict, group_allow_verdict). Callers turn a verdict
|
|
# into policy; this function has none.
|
|
#
|
|
# WHY THE COMPAT READ IS NOT OPTIONAL, and why it is here rather than at each
|
|
# call site. #77 renamed the trait because `class=human|server` was named for
|
|
# who lives on the box while what it decides is whether root SSH stays open as
|
|
# the control plane's automation door — the axis that made `dev-server` a
|
|
# `class=human` box, a suffix and a trait that read as a contradiction. But
|
|
# unlike #76's role rename, this field is not informational: it is written into
|
|
# /etc/rig/role and read back on live machines, where it gates `rig users
|
|
# close-root`. Every box bootstrapped before this change carries `class=` and
|
|
# carries it FOREVER, until someone re-bootstraps it — there is no migration
|
|
# step that reaches a fleet. So dropping the old read breaks in both directions
|
|
# at once, and both are incidents: a machine whose door should close stops
|
|
# being able to close it (close-root refuses on a marker it no longer
|
|
# understands), and — through bootstrap-tenant's machine-marker guard, which
|
|
# used `class=` as its "this is a machine" detector — a real fleet box stops
|
|
# looking like a machine at all and a tenant converge will happily clobber it.
|
|
# One resolver, consulted everywhere the trait is read, is what keeps those
|
|
# two readings from drifting apart.
|
|
#
|
|
# BOTH FIELDS PRESENT is a state bootstrap never writes — it writes one line,
|
|
# fresh, in the new vocabulary only — so a marker carrying both was
|
|
# hand-edited, and the two answers are a question about intent that rig cannot
|
|
# settle. Agreement is taken (it says one thing twice); disagreement resolves
|
|
# to `conflict` and every caller fails CLOSED on it, because the alternative is
|
|
# picking a winner between two equally-authored claims about a root door. The
|
|
# repair is the same one the rest of the marker family names: re-run bootstrap,
|
|
# which rewrites the line whole.
|
|
#
|
|
# NEITHER FIELD PRESENT resolves empty and is likewise fail-closed everywhere,
|
|
# unchanged from before: a marker that names no door policy cannot authorize
|
|
# shutting a door.
|
|
root_door_of() {
|
|
local marker="$1" new="" old=""
|
|
case "$marker" in
|
|
*root-door=closed*) new=closed ;;
|
|
*root-door=open*) new=open ;;
|
|
esac
|
|
case "$marker" in
|
|
*class=human*) old=closed ;;
|
|
*class=server*) old=open ;;
|
|
esac
|
|
if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then
|
|
printf 'conflict'; return 0
|
|
fi
|
|
# New wins where both are readable and agree; the old field answers alone on
|
|
# every marker written before #77, which is the whole point.
|
|
printf '%s' "${new:-$old}"
|
|
}
|
|
|
|
# assert_marker_closes_root <marker_path> — close-root's marker gate: return 0,
|
|
# silently, only when the marker's root-door trait resolves to `closed`;
|
|
# otherwise print the refusal reason on stdout and return 1 (the caller wraps
|
|
# it in its own die). The policy is a pure lib function on purpose: the CLI
|
|
# path sits behind the root check, so the harness proves every refusal HERE,
|
|
# against fixture markers, non-root (repo precedent: parse_users_file,
|
|
# assert_runner_repo).
|
|
assert_marker_closes_root() {
|
|
local marker
|
|
marker="$(read_role_marker "$1")"
|
|
if [ -z "$marker" ]; then
|
|
# No marker means rig cannot know whether root here is a human's bad habit
|
|
# or the control plane's automation door — refuse to shut it blind.
|
|
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
|
|
return 1
|
|
fi
|
|
case "$(root_door_of "$marker")" in
|
|
closed) return 0 ;;
|
|
open)
|
|
# Root SSH on such a box IS the control plane's (Coolify's) automation
|
|
# identity — closing it severs fleet management. No --force exists.
|
|
# Deliberately keyed on the DOOR, not on the role: #17's original table
|
|
# let the runner role close root ("no Coolify involved"), but the trait
|
|
# model (#26) supersedes that — every root-door=open 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 --root-door closed at bootstrap,
|
|
# not an exception carved out here.
|
|
printf '%s\n' "root-door=open: root here is the control plane's automation identity — closing it severs fleet management. Every root-door=open 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 --root-door closed at bootstrap, not an exception here"
|
|
return 1 ;;
|
|
conflict)
|
|
# Hand-edited into naming both vocabularies, disagreeing. Fail closed:
|
|
# see root_door_of's header for why rig refuses to pick a winner.
|
|
printf '%s\n' "marker names both root-door= and the pre-#77 class= and they disagree (${marker}): rig will not pick a winner between two claims about a root door — re-run rig bootstrap to rewrite the marker, and refusing to shut the root door meanwhile"
|
|
return 1 ;;
|
|
*)
|
|
printf '%s\n' "marker names no root-door policy (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
|
return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# assert_marker_hosts_vms <marker_path> — the box role's gate: return 0,
|
|
# silently, only when the marker says host=yes; otherwise print the reason on
|
|
# stdout and return 1 (the caller decides whether that is a warn or a die).
|
|
# Same shape and same reason as assert_marker_closes_root above: the policy is a
|
|
# pure marker->verdict function so the harness can prove every arm against
|
|
# fixture markers, non-root, while the CLI path sits behind the root check.
|
|
#
|
|
# The MARKER decides, not the machine (#58). Apply used to consult host= only
|
|
# when group incus was ABSENT — so a box whose marker said host=no but which
|
|
# happened to carry the group (box's setup-host ran, then the box was
|
|
# re-bootstrapped with different traits, or given --host no) handed box-role
|
|
# users a bare `usermod -aG incus`: the socket with no tier behind it. That is
|
|
# the worst of the three states, because incus-user answers a socket it is
|
|
# given by lazily creating an UNHARDENED project for whoever opens it —
|
|
# incusbr-<uid>, NAT on v4 and v6, no ACL, no dns.mode=none, no port
|
|
# isolation. The alternative considered was to let the group's presence win
|
|
# and converge anyway with a warning, on the theory that a real incus install
|
|
# is evidence the machine really does host VMs. It was rejected: the marker is
|
|
# what this box CLAIMS to be, and every other host= decision in the family
|
|
# already treats it as authoritative rather than as a hint to be second-
|
|
# guessed by probing the machine. A box that lies about itself gets its lie
|
|
# taken seriously and gets told, loudly, to re-run bootstrap — which is a
|
|
# cheap repair — instead of rig quietly provisioning a VM-host tier on a box
|
|
# that does not claim to be one. Deciding from the marker alone also means the
|
|
# verdict is the SAME whether or not the group exists, which is the property
|
|
# that was missing.
|
|
#
|
|
# No marker, and a marker with no host= trait, both land here as "not a VM
|
|
# host" for the same fail-closed reason: rig cannot tell an unbootstrapped box
|
|
# from a repurposed one, and the safe error is withholding VM access that can
|
|
# be granted by a re-run, not granting VM access that cannot be un-granted
|
|
# once a project exists under it.
|
|
assert_marker_hosts_vms() {
|
|
local marker
|
|
marker="$(read_role_marker "$1")"
|
|
case "$marker" in
|
|
*host=yes*) return 0 ;;
|
|
*host=no*)
|
|
printf '%s\n' "this box does not host VMs (host=no)"
|
|
return 1 ;;
|
|
"")
|
|
printf '%s\n' "no /etc/rig/role marker, so this box names no host= trait — re-run rig bootstrap so it knows whether it hosts VMs"
|
|
return 1 ;;
|
|
*)
|
|
printf '%s\n' "the role marker names no host= trait (${marker}) — re-run rig bootstrap so this box knows whether it hosts VMs"
|
|
return 1 ;;
|
|
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
|
|
}
|