From 0bb6b638df85060f99213436f4c56f3e58e04a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:16:35 +0000 Subject: [PATCH 01/14] feat(db): bring ad-hoc dump/restore on-box as `rig db` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `rig db dump [outfile]` and `rig db restore [db] [--yes]` — imperative on-box PostgreSQL tooling, the interactive counterpart to the scheduled, declarative `coolify backup install`. Key decisions: - Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner --no-acl` is mandatory for cross-instance restores: the target's superuser differs (Coolify randomizes it), so a plain dump aborts under ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role. - $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted `sh -c`), never hardcoded to `postgres` on the host. - restore connects as the container's own superuser and runs with ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a shared container, passed in via a container env var rather than string splicing. - restore overwrites the target, so it prompts y/N; --yes/--force is the automation bypass. Artifact existence/non-emptiness is checked before the confirm gate and before anything touches the DB. - dump uses pipefail + a sibling temp promoted only on success, and refuses to keep an empty artifact — a failed pg_dump must never leave a plausible-looking .gz behind. Args are validated before the root check (testable without root); guards are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and a `### rig db` README section. Closes #15 Co-Authored-By: Claude Opus 4.8 --- README.md | 63 +++++++++++++++++ bin/rig | 17 +++++ commands/db.sh | 181 +++++++++++++++++++++++++++++++++++++++++++++++++ test/cli.sh | 46 +++++++++++++ 4 files changed, 307 insertions(+) create mode 100755 commands/db.sh diff --git a/README.md b/README.md index 36e0fd1..f16497c 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,69 @@ journalctl -u coolify-dump.service -n 20 --no-pager A backup you have never read back is not yet a backup. +### `rig db ` + +Ad-hoc PostgreSQL dump/restore for a container running on this box. Run as +root. + +```sh +rig db dump coolify-db # -> coolify-db-20260717T041500Z.sql.gz +rig db dump my-app-db /srv/snapshots/pre-migrate.sql.gz +rig db restore pre-migrate.sql.gz my-app-db # prompts before overwriting +rig db restore umami.sql.gz shared-pg umami --yes +``` + +This is **imperative on-box tooling** — the "give me a copy of that database +right now", "put this artifact back" verbs you reach for by hand. It is the +counterpart to [`rig coolify backup install`](#rig-coolify-backup-install), +which is the *scheduled, declarative, forensics-only* path; `db` is +interactive, targets any container, and (on restore) overwrites live data +behind a confirm gate. Declarative convergence lives elsewhere by design — this +verb exists precisely for the moments that are not convergent. + +**`dump`** pipes `pg_dump` straight into `gzip`: + +```sh +docker exec sh -c \ + 'pg_dump -U "$POSTGRES_USER" --clean --if-exists --no-owner --no-acl "$POSTGRES_DB"' | gzip +``` + +- **`--no-owner --no-acl` is mandatory, not cosmetic.** A cross-instance restore + runs as the *target's* superuser, and Coolify randomizes that role per + database — so the source's `ALTER OWNER`/`GRANT` statements name a role that + does not exist on the target and, under `ON_ERROR_STOP=1`, abort the whole + restore on the first one. Stripping ownership and ACLs makes the dump describe + *data and schema*, portable onto any instance. +- **`$POSTGRES_USER` / `$POSTGRES_DB` are read inside the container** — that is + why the command is a *single-quoted* `sh -c`: it evaluates the container's own + environment, never the host's. The host never hardcodes `postgres`; on the + next container that role name is simply wrong. +- With no `[outfile]`, it writes `-.sql.gz` in the + current directory (same timestamp shape as the nightly dump). `pipefail` is + load-bearing: without it a failing `pg_dump` still exits 0 through the pipe and + `gzip` compresses the truncated output into a valid `.gz` that looks exactly + like a good backup. rig dumps to a sibling temp, promotes it only on success, + and refuses to keep an empty artifact. + +**`restore [db]`** streams the artifact back in: + +```sh +gunzip -c | docker exec -i sh -c \ + 'psql -U "$POSTGRES_USER" -d "${db:-$POSTGRES_DB}" -v ON_ERROR_STOP=1' +``` + +- It connects as the container's **own superuser** (`$POSTGRES_USER`), again + never a hardcoded role, and runs with `ON_ERROR_STOP=1` so a bad restore fails + loudly instead of limping to a half-applied state and reporting success. +- **`[db]` targets a named database in a shared container** — e.g. a `umami` + database living in a Postgres that also hosts other apps. Omit it to restore + into the container's default `$POSTGRES_DB`. rig passes the name *into* the + container as an env var rather than splicing it into the command string. +- **Restore overwrites the target**, so it prompts `y/N` first. `--yes` (or + `--force`) is the automation bypass. The artifact is checked for existence and + non-emptiness *before* the prompt and before anything touches the database, so + a fat-fingered path fails cheaply. + ### `rig runner install --repo ` Runner box only, run after `rig bootstrap runner` (the same two-step rhythm diff --git a/bin/rig b/bin/rig index 8301af6..b425fec 100755 --- a/bin/rig +++ b/bin/rig @@ -20,6 +20,11 @@ commands: systemd timer. rig installs the machinery and templates an empty 0600 bindings file; you fill in the age recipient and S3 details. Control-plane box only. Run as root. + db ... + Ad-hoc PostgreSQL dump/restore for a container on this box. `dump` + writes a gzipped SQL artifact (--no-owner --no-acl, so it restores + onto a different instance); `restore` loads one back, connecting as + the container's own superuser, behind a confirm gate. Run as root. runner install --repo [options] GitHub Actions runner as a systemd service under an unprivileged user — outbound-only, no Docker. Prompts for the short-lived @@ -71,6 +76,18 @@ case "$cmd" in ;; esac ;; + db) + shift + case "${1:-}" in + dump|restore|-h|--help) + exec "$ROOT/commands/db.sh" "$@" + ;; + *) + usage >&2 + exit 2 + ;; + esac + ;; runner) shift sub="${1:-}" diff --git a/commands/db.sh b/commands/db.sh new file mode 100755 index 0000000..8b094f7 --- /dev/null +++ b/commands/db.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# rig db — ad-hoc PostgreSQL dump/restore for the containers running on THIS box. +# +# Imperative on-box tooling, deliberately: this is the "I need a copy of that +# database right now" / "put this artifact back" verb an operator reaches for by +# hand. It is the counterpart to `coolify backup install`, which is the +# scheduled, declarative, forensics-only path — this one is interactive, targets +# any container, and (for restore) overwrites live data behind a confirm gate. +# +# Two rules run through everything below and are non-negotiable: +# * $POSTGRES_USER / $POSTGRES_DB are read INSIDE the container (that is why +# every pg_dump/psql lives in a SINGLE-quoted `sh -c '...'` — the container's +# own environment, not the host's). Coolify randomizes the superuser per +# database, so the host must never hardcode `postgres`; a hardcoded role is +# simply wrong on the next container. +# * dumps carry `--no-owner --no-acl`. Without them a cross-instance restore +# aborts under ON_ERROR_STOP=1 the moment psql hits a GRANT/ALTER OWNER for +# a role that does not exist on the target (source and target superusers +# differ by construction). The dump must describe *data and schema*, not the +# source box's role graph. +set -euo pipefail + +log() { printf 'rig-db: %s\n' "$*"; } +warn() { printf 'rig-db: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-db: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig db ... + + dump [outfile] + Dump the PostgreSQL database inside to a gzipped SQL file. + When [outfile] is omitted, writes -.sql.gz in + the current directory. The dump is taken with --clean --if-exists + --no-owner --no-acl, so it restores cleanly onto a different instance + whose superuser differs from this one's. + + restore [db] [--yes] + Restore a gzipped SQL artifact into , connecting as the + container's OWN superuser. [db] targets a NAMED database in a shared + container (e.g. a `umami` database in a shared Postgres); omit it to + restore into the container's default database. Restore OVERWRITES the + target and prompts before doing so — pass --yes (or --force) to run + non-interactively. +EOF +} + +# --- common guards ---------------------------------------------------------- +# Called AFTER arg validation (arg errors must stay testable without root). +require_common_guards() { + [ "$(id -u)" -eq 0 ] || die "must run as root" + if [ -r /etc/os-release ]; then + # Sourced in a subshell — /etc/os-release defines VERSION and would clobber + # a caller's variables (see test/cli.sh's regression check). + local OS_FAMILY + # shellcheck source=/dev/null + OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")" + case "$OS_FAMILY" in + *debian*) ;; + *) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;; + esac + else + warn "cannot read /etc/os-release; proceeding anyway" + fi + # dump/restore only make sense on a box actually running the containers. + command -v docker >/dev/null \ + || die "docker not found — rig db operates on containers running on this box" +} + +# --- db dump ---------------------------------------------------------------- +cmd_dump() { + local container="" outfile="" tmp + local -a pos=() + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --*|-*) die "unknown flag: $1" 2 ;; + *) pos+=("$1"); shift ;; + esac + done + + # Args first, so these errors are reachable without docker or root. + [ "${#pos[@]}" -le 2 ] || die "dump takes at most [outfile]" 2 + container="${pos[0]:-}" + outfile="${pos[1]:-}" + [ -n "$container" ] || die "dump needs a container name" 2 + # Mirror coolify-dump.sh's timestamp style for the default name. + [ -n "$outfile" ] || outfile="${container}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz" + + require_common_guards + command -v gzip >/dev/null || die "gzip not found (part of coreutils on Debian)" + + log "dumping database in container ${container} -> ${outfile}" + # Write to a sibling temp and promote on success, so a failed dump never + # leaves a plausible-looking artifact in place of a real one. Same directory + # as the target keeps the final mv atomic. + tmp="$(mktemp "${outfile}.XXXXXX")" || die "could not create a temp file next to ${outfile}" + trap 'rm -f "$tmp"' EXIT + + # pipefail (set above) is load-bearing: without it a failing pg_dump still + # exits 0 through the pipe and gzip faithfully compresses the truncated output + # into a valid .gz that looks exactly like a good backup. The `sh -c` is + # single-quoted on purpose — $POSTGRES_USER/$POSTGRES_DB are the CONTAINER's. + # shellcheck disable=SC2016 + if ! docker exec "$container" \ + sh -c 'pg_dump -U "$POSTGRES_USER" --clean --if-exists --no-owner --no-acl "$POSTGRES_DB"' \ + | gzip > "$tmp"; then + die "pg_dump failed — no artifact written" + fi + # Even an empty dump gzips to a ~20-byte VALID file; refuse to keep one. + [ -s "$tmp" ] || die "refusing to keep an empty dump artifact" + mv "$tmp" "$outfile" + trap - EXIT + log "wrote ${outfile} ($(stat -c %s "$outfile") bytes)" +} + +# --- db restore ------------------------------------------------------------- +cmd_restore() { + local artifact="" container="" target_db="" assume_yes=0 reply="" + local -a pos=() + while [ $# -gt 0 ]; do + case "$1" in + --yes|--force) assume_yes=1; shift ;; + -h|--help) usage; exit 0 ;; + --*|-*) die "unknown flag: $1" 2 ;; + *) pos+=("$1"); shift ;; + esac + done + + # Args first: required-arg errors are exit 2 and reachable without root. + [ "${#pos[@]}" -le 3 ] || die "restore takes at most [db]" 2 + artifact="${pos[0]:-}" + container="${pos[1]:-}" + target_db="${pos[2]:-}" + [ -n "$artifact" ] || die "restore needs an artifact file" 2 + [ -n "$container" ] || die "restore needs a target container" 2 + # Artifact existence/non-emptiness is checkable without docker or root, so we + # check it HERE — a fat-fingered path fails clearly and cheaply, before the + # confirm gate and before anything touches the database. + [ -e "$artifact" ] || die "artifact not found: ${artifact}" + [ -f "$artifact" ] || die "artifact is not a regular file: ${artifact}" + [ -s "$artifact" ] || die "artifact is empty: ${artifact}" + + require_common_guards + command -v gunzip >/dev/null || die "gunzip not found (part of coreutils on Debian)" + + # Destructive: restore overwrites the target database in place. Default to + # prompting; --yes/--force is the automation bypass. + if [ "$assume_yes" -eq 0 ]; then + printf 'rig-db: restore OVERWRITES the database in container %s%s. Continue? [y/N] ' \ + "$container" "${target_db:+ (database ${target_db})}" >&2 + read -r reply || reply="" + case "$reply" in + y|Y|yes|YES|Yes) ;; + *) die "aborted — no changes made" ;; + esac + fi + + log "restoring ${artifact} into container ${container}${target_db:+ (database ${target_db})}" + # Connect as the container's OWN superuser ($POSTGRES_USER); never hardcode + # postgres. RIG_TARGET_DB carries the optional [db] arg INTO the container so + # a shared Postgres can be restored into a NAMED database; empty falls back to + # the container's own $POSTGRES_DB. ON_ERROR_STOP=1 aborts on the first error + # rather than limping to a half-applied restore and reporting success. + # shellcheck disable=SC2016 + if ! gunzip -c "$artifact" \ + | docker exec -i -e RIG_TARGET_DB="$target_db" "$container" \ + sh -c 'psql -U "$POSTGRES_USER" -d "${RIG_TARGET_DB:-$POSTGRES_DB}" -v ON_ERROR_STOP=1'; then + die "restore failed — the database may be partially applied; inspect container ${container}" + fi + log "restore complete" +} + +# --- dispatch --------------------------------------------------------------- +sub="${1:-}" +case "$sub" in + dump) shift; cmd_dump "$@" ;; + restore) shift; cmd_restore "$@" ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac diff --git a/test/cli.sh b/test/cli.sh index 0332275..4ef2b06 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -66,6 +66,52 @@ else echo "skip: coolify backup non-root refusal (running as root)" fi +# --- rig db (ad-hoc dump/restore) ------------------------------------------- +check "bare db shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" db +check "db --help exits 0" 0 "usage:" "$ROOT/bin/rig" db --help +check "db bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" db frobnicate +check "db dump: --help exits 0" 0 "usage:" "$ROOT/commands/db.sh" dump --help +check "db dump: container required, exit 2" 2 "needs a container" "$ROOT/commands/db.sh" dump +check "db dump: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/db.sh" dump --nope +check "db restore: artifact required, exit 2" 2 "needs an artifact" "$ROOT/commands/db.sh" restore +check "db restore: container required, exit 2" 2 "needs a target container" \ + "$ROOT/commands/db.sh" restore /tmp/whatever.sql.gz +check "db restore: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/db.sh" restore --nope +# Artifact existence is checked BEFORE docker/root, so a fat-fingered path fails +# clearly and cheaply — and is testable here without root or a live container. +check "db restore: missing artifact fails before the docker/root path" \ + 1 "artifact not found" "$ROOT/commands/db.sh" restore /no/such/artifact.sql.gz somecontainer --yes + +# The two DB invariants live as embedded command strings (single-quoted sh -c), +# not an extractable heredoc, so guard them directly: dropping --no-owner/--no-acl +# breaks every cross-instance restore, and hardcoding a role instead of the +# container's own $POSTGRES_USER/$POSTGRES_DB is wrong on Coolify's randomized +# superuser. ON_ERROR_STOP=1 is what makes a bad restore fail instead of limp. +check "db dump embeds --no-owner --no-acl" 0 "" \ + grep -qF -- "--no-owner --no-acl" "$ROOT/commands/db.sh" +# The $POSTGRES_USER below is a LITERAL we grep for in db.sh (it must read the +# container's env, not the host's) — single quotes are the point here. +# shellcheck disable=SC2016 +check "db dump reads the container's own \$POSTGRES_USER/\$POSTGRES_DB" 0 "" \ + grep -qF 'pg_dump -U "$POSTGRES_USER"' "$ROOT/commands/db.sh" +# shellcheck disable=SC2016 +check "db restore connects as the container's own \$POSTGRES_USER" 0 "" \ + grep -qF 'psql -U "$POSTGRES_USER"' "$ROOT/commands/db.sh" +check "db restore uses ON_ERROR_STOP=1" 0 "" \ + grep -qF "ON_ERROR_STOP=1" "$ROOT/commands/db.sh" +if [ "$(id -u)" -ne 0 ]; then + # Valid args, so validation passes and we reach the root guard. + check "db dump: refuses non-root" 1 "must run as root" "$ROOT/commands/db.sh" dump somecontainer + # Restore needs a real, non-empty artifact to get PAST the artifact check and + # reach the root guard; --yes skips the confirm prompt so the check is exit-clean. + DB_ART="$(mktemp)"; printf 'SELECT 1;\n' > "$DB_ART" + check "db restore: refuses non-root" 1 "must run as root" \ + "$ROOT/commands/db.sh" restore "$DB_ART" somecontainer --yes + rm -f "$DB_ART" +else + echo "skip: db non-root refusals (running as root)" +fi + check "bare runner shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" runner check "runner: --help exits 0" 0 "usage:" "$ROOT/commands/runner-install.sh" --help check "runner: repo required, exit 2" 2 "--repo" "$ROOT/commands/runner-install.sh" --version 2.335.1 -- 2.45.2 From 9551ad482fa80f6aaa8ac6b84b3d5495224a7e62 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:48:51 +0000 Subject: [PATCH 02/14] test(db): add real dump/restore round-trip probe + CI job + manual proof docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The db PR only unit-tested arg parsing; this adds executable proof that dump/restore actually works end to end. - test/db-integration.sh: stands up two throwaway Postgres containers whose superusers DIFFER by construction (src_super vs dst_super), seeds a known checksummable fixture, runs the real `rig db dump`/`rig db restore`, and reads the rows back out — proving both invariants db.sh cares about: the code reads the container's OWN $POSTGRES_USER/$POSTGRES_DB (a hardcoded `postgres` would break on the non-default source superuser), and --no-owner --no-acl makes the dump portable across differing superusers (a plain dump would abort under ON_ERROR_STOP=1 on the missing role). Also asserts default-outfile naming, restore idempotency (--clean --if-exists), and the named-[db] scratch-database path. Skips cleanly (exit 0) when Docker is absent/unreachable or root is unobtainable; always cleans up via trap. - ci.yml: separate `db-integration` job on ubuntu-latest (Docker preinstalled), kept apart from the fast shellcheck+cli.sh `check` job so an image pull can't slow lint feedback. - README: "Verifying a dump/restore actually works" — the safe manual round-trip against a real Coolify container via a fresh scratch db, echoing "a backup you have never read back is not yet a backup." Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 13 +++ README.md | 38 +++++++++ test/db-integration.sh | 178 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100755 test/db-integration.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa2ef1b..40454c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,16 @@ jobs: shellcheck -x "${files[@]}" - name: cli tests run: bash test/cli.sh + + # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and + # stands up throwaway containers, and a slow image pull must never delay the + # fast shellcheck + cli.sh feedback above. ubuntu-latest ships Docker running + # and passwordless sudo, so test/db-integration.sh EXECUTES here (it only + # skips where Docker is absent). It is the automated proof that dump/restore + # actually round-trips, not just that the args parse. + db-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: db dump/restore round-trip + run: bash test/db-integration.sh diff --git a/README.md b/README.md index f16497c..34da5ce 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,44 @@ gunzip -c | docker exec -i sh -c \ non-emptiness *before* the prompt and before anything touches the database, so a fat-fingered path fails cheaply. +#### Verifying a dump/restore actually works + +A `.gz` that opens without error is not proof of a good backup: a `pg_dump` +truncated mid-stream still compresses into a perfectly valid gzip file that +*looks* exactly like a complete one. The same ethos as the nightly dump applies +here — **a backup you have never read back is not yet a backup.** The only +fully-trustworthy proof is to restore the artifact and read the rows back out. + +On a real Coolify box you can do that **without touching prod data** by +restoring into a fresh *scratch* database rather than over the live one: + +```sh +# 1. dump the live database (read-only; harmless) +rig db dump coolify-db /srv/snapshots/verify.sql.gz + +# 2. create a throwaway database as the container's OWN superuser +docker exec coolify-db sh -c 'createdb -U "$POSTGRES_USER" rig_verify' + +# 3. restore the artifact INTO the scratch db (not the live one) +rig db restore /srv/snapshots/verify.sql.gz coolify-db rig_verify --yes + +# 4. spot-check a table you expect to see +docker exec coolify-db sh -c \ + 'psql -U "$POSTGRES_USER" -d rig_verify -c "\dt"' +docker exec coolify-db sh -c \ + 'psql -U "$POSTGRES_USER" -d rig_verify -c "SELECT count(*) FROM "' + +# 5. drop the scratch db — live data was never touched +docker exec coolify-db sh -c 'dropdb -U "$POSTGRES_USER" rig_verify' +``` + +If the counts and tables are there, the artifact is real. This is exactly the +round-trip `test/db-integration.sh` automates in CI (the `db-integration` job): +it seeds a known table in a source container whose superuser is *not* the +default, dumps it, restores into a second container whose superuser differs, and +asserts the rows and an ordered checksum survived — the same proof, done against +throwaway containers on every push. + ### `rig runner install --repo ` Runner box only, run after `rig bootstrap runner` (the same two-step rhythm diff --git a/test/db-integration.sh b/test/db-integration.sh new file mode 100755 index 0000000..996b218 --- /dev/null +++ b/test/db-integration.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# test/db-integration.sh — a REAL dump/restore round-trip for `rig db`. +# +# test/cli.sh proves the arg parsing; this proves the actual thing works. It +# stands up throwaway PostgreSQL containers, seeds a known table, runs the real +# `rig db dump` / `rig db restore`, and reads the rows back out the far side — +# because a dump piped through gzip can look perfectly valid while being +# truncated, and only restoring it and reading it back proves otherwise. +# +# It exercises the two invariants db.sh is built around: +# * the source superuser is a NON-default name (src_super), so a green run +# proves the code reads the CONTAINER's own $POSTGRES_USER/$POSTGRES_DB and +# never a hardcoded `postgres`; +# * the destination superuser is a DIFFERENT name (dst_super), so the restore +# only succeeds because --no-owner --no-acl stripped the source role graph — +# a plain dump would abort under ON_ERROR_STOP=1 on the first missing role. +# +# Skips cleanly (exit 0) when it cannot run — no Docker, no reachable daemon, or +# no way to become root (rig db requires root) — so it never reddens a dev box +# that simply has no Docker. On ubuntu-latest CI, Docker is preinstalled and +# running and passwordless sudo works, so it EXECUTES for real there. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RIG="$ROOT/bin/rig" +PG_IMAGE="${RIG_DBIT_PG_IMAGE:-postgres:16-alpine}" + +PASS=0 FAIL=0 +ok() { echo "ok: $*"; PASS=$((PASS + 1)); } +bad() { echo "FAIL: $*"; FAIL=$((FAIL + 1)); } +skip() { echo "skip: $*"; exit 0; } +die() { echo "FAIL: $*" >&2; exit 1; } # trap still runs cleanup + +# --- privilege / docker preflight ------------------------------------------- +# rig db requires root (require_common_guards). CI's runner user is not root but +# has passwordless sudo and docker-group access; mirror that exactly. EVERYTHING +# that touches Docker or rig goes through as_root so containers and the artifact +# share one owner and cleanup is uniform (root can always reach the socket). +if [ "$(id -u)" -eq 0 ]; then + as_root() { "$@"; } +elif sudo -n true >/dev/null 2>&1; then + as_root() { sudo "$@"; } +else + skip "not root and no passwordless sudo — rig db requires root; cannot run the round-trip here" +fi + +command -v docker >/dev/null 2>&1 || skip "docker not installed — nothing to exercise" +as_root docker info >/dev/null 2>&1 || skip "docker daemon not reachable — skipping the live round-trip" +# Pull up front so an offline box SKIPS (not FAILS): a missing network is not a +# regression in rig. On CI the image is fetched here and the run below is fast. +as_root docker pull "$PG_IMAGE" >/dev/null 2>&1 || skip "could not pull ${PG_IMAGE} (offline?) — skipping" + +# --- unique names + guaranteed cleanup -------------------------------------- +PREFIX="rig_dbit_$$" +SRC="${PREFIX}_src" +DST="${PREFIX}_dst" +WORKDIR="$(mktemp -d)" +ARTIFACT="$WORKDIR/roundtrip.sql.gz" + +cleanup() { + as_root docker rm -f "$SRC" "$DST" >/dev/null 2>&1 || true + as_root rm -rf "$WORKDIR" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# A stale run must never collide with this one. +as_root docker rm -f "$SRC" "$DST" >/dev/null 2>&1 || true + +# --- helpers ---------------------------------------------------------------- +# Poll until answers a real query as /. pg_isready alone +# reports "ready" during the entrypoint's temp-server phase, so gate on an +# actual SELECT succeeding instead. Bounded (~60s) so a wedged box fails, loudly. +wait_ready() { # + local c="$1" u="$2" d="$3" i=0 + while [ "$i" -lt 60 ]; do + if as_root docker exec "$c" psql -U "$u" -d "$d" -tAc 'SELECT 1' >/dev/null 2>&1; then + return 0 + fi + i=$((i + 1)) + sleep 1 + done + die "container ${c} never started accepting connections as ${u}/${d}" +} + +# An ordered, checksummable fingerprint of the fixture table — the thing that +# must survive the round-trip byte for byte. +fingerprint() { # + as_root docker exec "$1" psql -U "$2" -d "$3" -tAc \ + "SELECT id||'|'||name||'|'||qty FROM widgets ORDER BY id" | md5sum | cut -d' ' -f1 +} +rowcount() { # + as_root docker exec "$1" psql -U "$2" -d "$3" -tAc 'SELECT count(*) FROM widgets' | tr -d '[:space:]' +} + +# --- source: NON-default superuser, seeded fixture -------------------------- +as_root docker run -d --name "$SRC" \ + -e POSTGRES_USER=src_super -e POSTGRES_DB=src_appdb -e POSTGRES_PASSWORD=srcpw \ + "$PG_IMAGE" >/dev/null +wait_ready "$SRC" src_super src_appdb + +as_root docker exec "$SRC" psql -U src_super -d src_appdb -v ON_ERROR_STOP=1 -c " + CREATE TABLE widgets (id int PRIMARY KEY, name text NOT NULL, qty int NOT NULL); + INSERT INTO widgets VALUES (1,'alpha',10),(2,'beta',20),(3,'gamma',30); +" >/dev/null + +# assert_eq +assert_eq() { + if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 — wanted [$2] got [$3]"; fi +} + +SRC_FP="$(fingerprint "$SRC" src_super src_appdb)" +assert_eq "source seeded with 3 known rows" 3 "$(rowcount "$SRC" src_super src_appdb)" + +# --- dump (explicit outfile) ------------------------------------------------ +if as_root "$RIG" db dump "$SRC" "$ARTIFACT" >/dev/null 2>&1; then + ok "rig db dump exited 0" +else + bad "rig db dump failed" +fi +if as_root test -s "$ARTIFACT"; then + ok "dump wrote a non-empty artifact" +else + die "dump produced no usable artifact — nothing to restore" +fi + +# --- dump (default outfile naming: -.sql.gz) ------- +( cd "$WORKDIR" && as_root "$RIG" db dump "$SRC" >/dev/null 2>&1 ) +if compgen -G "$WORKDIR/${SRC}-*.sql.gz" >/dev/null; then + ok "dump with no outfile wrote -.sql.gz in cwd" +else + bad "default dump name not produced" +fi + +# --- destination: DIFFERENT superuser (the portability proof) --------------- +as_root docker run -d --name "$DST" \ + -e POSTGRES_USER=dst_super -e POSTGRES_DB=dst_appdb -e POSTGRES_PASSWORD=dstpw \ + "$PG_IMAGE" >/dev/null +wait_ready "$DST" dst_super dst_appdb + +# --- restore into the default database -------------------------------------- +if as_root "$RIG" db restore "$ARTIFACT" "$DST" --yes >/dev/null 2>&1; then + ok "rig db restore exited 0 across a differing superuser" +else + bad "rig db restore failed (a role-graph leak would abort here)" +fi + +DST_FP="$(fingerprint "$DST" dst_super dst_appdb)" +assert_eq "destination has exactly 3 rows after restore" 3 "$(rowcount "$DST" dst_super dst_appdb)" +if [ "$DST_FP" = "$SRC_FP" ]; then + ok "restored fingerprint matches source ($SRC_FP)" +else + bad "restored data differs — src=$SRC_FP dst=$DST_FP" +fi + +# --- idempotency: --clean --if-exists means a second restore is a no-op-ish -- +if as_root "$RIG" db restore "$ARTIFACT" "$DST" --yes >/dev/null 2>&1; then + ok "second restore exited 0 (dump is --clean --if-exists)" +else + bad "second restore failed" +fi +assert_eq "still exactly 3 rows after re-restore (no duplication)" 3 "$(rowcount "$DST" dst_super dst_appdb)" +assert_eq "fingerprint stable after re-restore" "$SRC_FP" "$(fingerprint "$DST" dst_super dst_appdb)" + +# --- restore into a NAMED scratch db (the [db] arg / shared-Postgres path) --- +# This is exactly the safe manual proof the README documents: restore into a +# fresh scratch database rather than over live data. +as_root docker exec "$DST" createdb -U dst_super rig_verify >/dev/null +if as_root "$RIG" db restore "$ARTIFACT" "$DST" rig_verify --yes >/dev/null 2>&1; then + ok "rig db restore into a named [db] exited 0" +else + bad "restore into named database failed" +fi +assert_eq "named-database restore reproduced the source fingerprint" \ + "$SRC_FP" "$(fingerprint "$DST" dst_super rig_verify)" + +echo "---" +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] -- 2.45.2 From 1b68d3ca49aa92b11fe2b17ce3078a848204aefb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:50:23 +0000 Subject: [PATCH 03/14] docs: plan for the staging bootstrap role Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-07-17-staging-role.md | 216 ++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/plans/2026-07-17-staging-role.md diff --git a/docs/plans/2026-07-17-staging-role.md b/docs/plans/2026-07-17-staging-role.md new file mode 100644 index 0000000..9f42893 --- /dev/null +++ b/docs/plans/2026-07-17-staging-role.md @@ -0,0 +1,216 @@ +# rig `bootstrap staging` Implementation Plan + +> **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:** Add a `staging` bootstrap role alongside `control-plane` / `workload` / +`runner` — the host archetype for a machine whose job is to **host staging +VMs**: Incus VMs minted by the `box` CLI (heavy-duty/box) from its `staging` +template, each later converged from *inside* with `rig bootstrap workload` and +registered in the control plane as its own server. + +**Why this shape:** the host and its guests sit on opposite sides of a trust +boundary. The guests are servers the control plane manages; the **host is +not** — it must never carry `tag:server`, and the fleet has already been bitten +once by a host that wrongly did. The role is deliberately minimal host +plumbing: hardening, tailnet join (key minted with `tag:local`), hostname — +and **nothing about Incus or box**. box's own `setup-host` owns Incus +configuration; two tools converging the same daemon is drift by construction, +so rig only *points* at box's installer in its closing log. The guest side +needs zero rig changes — `rig bootstrap workload` already is the staging-box +role. + +**Architecture:** `staging` joins the existing role case in +`commands/bootstrap.sh`; no new files, no new flags. Since issue #16 / PR #20, +the tailnet tag is **not a rig argument** — the pre-auth key carries its tags +and rig asserts on the tag control actually *granted* (`.Self.Tags`), post-join +and on every re-run. So the role's tag policy lands in `verify_effective_tag`, +exactly where the `runner` policy lives: role `staging` **refuses an effective +`tag:server`** (die, exit 1 — a runtime refusal, not a usage error). The +`/dev/kvm` advisory and the box next-step pointer live in the execution path; +argument validation stays pure and root-free. + +**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck + +`bash test/cli.sh`) — no workflow change needed. + +## Non-Goals + +- **No Incus, no box install** — box's `setup-host` is the single owner of the + Incus daemon's configuration. rig prints a pointer, nothing more. +- **No VM provisioning** — minting boxes is box's job (`box new --template + staging`, companion issue heavy-duty/box#68). +- **No control-plane/Coolify API usage** — guests register themselves via the + existing workload flow. +- **No `dev` role** — a dev-box host role is anticipated (same plumbing, honest + name) but explicitly out of scope here. + +## Global Constraints + +- `#!/usr/bin/env bash` + `set -euo pipefail`; log prefix `rig-bootstrap:` + via the existing `log`/`warn`/`die` helpers. +- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All argument + validation runs BEFORE the root check** so error paths are testable as + non-root. +- The tag policy asserts the **effective** tag, never a requested one — there + is no `--ts-tag` to refuse anymore (it died in PR #20; passing it exits 2 + with a pointer at the key). The issue's original "refuse `--ts-tag + tag:server`" acceptance is therefore satisfied at the stronger, post-join + layer, same as `runner`. +- `/dev/kvm` absence is a **warning, not a failure** — the role is rehearsed in + containers where `/dev/kvm` legitimately isn't there. +- Convergent: a second run changes nothing and exits 0. +- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x + bin/* **/*.sh`); `bash test/cli.sh` green as non-root. +- Keep the diff minimal — no drive-by refactors. (One deliberate exception: + `bin/rig`'s bootstrap usage line still advertises the removed `--ts-tag` + flag and the old `tag:ci` default — stale since PR #20. It gets corrected in + the same breath as adding `staging` to the role list, because shipping a new + role into a help text that lies about the flag surface would be worse than + the drive-by.) + +--- + +### Task 1: role wiring in `commands/bootstrap.sh` + dispatcher usage + tests + +**Files:** +- Modify: `commands/bootstrap.sh` (role case, effective-tag refusal, `/dev/kvm` + advisory, closing next-step log, usage heredoc) +- Modify: `bin/rig` (bootstrap usage line: role list + stale-flag correction) +- Modify: `test/cli.sh` (bootstrap section additions) + +**Behavior contract, in file order:** + +1. Usage heredoc: role list becomes ``; + one added sentence: staging hosts box-minted staging VMs, its key should be + minted with `tag:local`, and it refuses `tag:server` — the host is never + managed by the control plane; its guest VMs are. +2. Role case arm: `control-plane|workload|runner|staging) shift ;;` and both + error messages (`role required`, `unknown role`) name the four roles. +3. `verify_effective_tag`: after the `runner` refusal, the `staging` one — same + shape (`grep -qx 'tag:server'` against the effective tags), message + `role staging joined with tag:server ...` naming the repair (mint a + `tag:local` key), rationale comment: hosts are never managed by the control + plane, their guest VMs are; the fleet has been bitten by a host wrongly + carrying `tag:server`. `die` with default status → exit 1. +4. Guards section (execution path, after the root check): when role is + `staging` and `/dev/kvm` is absent, `warn` — the host exists to run VMs, but + a container rehearsal legitimately has no `/dev/kvm`, so this must not fail. +5. Closing log: `staging` branch pointing at the box CLI — install box, run + `box setup-host` to prepare Incus, then `box new --template staging`. +6. `bin/rig` usage: `bootstrap + [--hostname ]`; drop the stale `[--ts-tag ]` and + `tag:ci`-default sentence; say the tag comes from the pre-auth key and that + roles `runner` and `staging` refuse `tag:server`. + +- [ ] **Step 1: Append failing tests** + +In `test/cli.sh`, bootstrap section: + +```bash +check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \ + "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server +# The staging tag:server refusal rides the EFFECTIVE tag, inside +# verify_effective_tag — a path that needs a real tailnet, so it belongs to the +# rehearsal. What the harness CAN prove is that the refusal exists in the shipped +# script: grep the die message, so a deleted guard cannot ship green (the same +# reason the runner-install repo guard is grepped below). +check "bootstrap: staging effective-tag refusal is present" 0 "" \ + grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh" +``` + +and in the existing non-root block: + +```bash +check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging +``` + +The existing `unknown role exits 2` (potato) check already covers the +still-fails-usage path and stays untouched. + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `bash test/cli.sh` +Expected: the staging checks FAIL (`unknown role: staging` → wrong exit/output +for the first and third; missing die message for the grep); everything existing +stays green; harness exits 1. + +- [ ] **Step 3: Implement the role** + +Per the behavior contract above. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bash test/cli.sh` +Expected: all checks pass, exit 0. + +- [ ] **Step 5: shellcheck + syntax** + +Run: `shopt -s globstar; shellcheck -x bin/* **/*.sh` (exactly CI's +invocation) and `bash -n` on each edited script. +Expected: exit 0, no findings. + +- [ ] **Step 6: Commit** + +```bash +git add commands/bootstrap.sh bin/rig test/cli.sh +git commit -m "feat(bootstrap): staging role — the host archetype for box-minted staging VMs" +``` + +--- + +### Task 2: README roles documentation + +**Files:** +- Modify: `README.md` (bootstrap section: heading role list, example block, the + roles paragraph) + +- [ ] **Step 1: Write it** + +Content requirements (in the README's existing voice): + +- Heading/example gain `staging` (`rig bootstrap staging --hostname my-vm-host`). +- One honest paragraph in the roles discussion: `staging` is the box that + *hosts* staging boxes — Incus VMs minted by the `box` CLI, each converged + from inside with `rig bootstrap workload` and registered in the control plane + as its own server. Mint its key with `tag:local`; the role **refuses an + effective `tag:server`** — the host is never managed by the control plane, + its guests are. rig deliberately installs no Incus and no box (box's + `setup-host` owns that); it points there when done. + +- [ ] **Step 2: Full local gate** + +Run: CI's shellcheck invocation + `bash test/cli.sh`. +Expected: silent shellcheck; all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: README section for the staging bootstrap role" +``` + +--- + +## Test Plan + +- **Harness (`bash test/cli.sh`, non-root, network-free):** staging parses and + reaches the root check (exit 1 `must run as root`); staging + the removed + `--ts-tag` dies at arg validation (exit 2, message points at the key — + proving validation precedes the root check); the effective-tag refusal + message is present in the script; unknown roles still exit 2. +- **CI:** unchanged `ci.yml` covers the edits (globstar shellcheck + harness). +- **Rehearsal (manual, out of harness):** pristine Debian box → `rig bootstrap + staging` with a real single-use `tag:local` key → hardened sshd drop-in, + tailnet join, hostname `staging`, `/dev/kvm` warning absent on real hardware, + closing log points at box; second run is a no-op. A `tag:server` key must + die post-join with the staging refusal. + +## Addendum (2026-07-17, written before implementation) + +Issue #22 predates the merge of PR #20 (issue #16: the tag comes from the key). +Its acceptance criterion "`rig bootstrap staging --ts-tag tag:server` exits 1 +with a refusal, before the root check" names a flag that no longer exists — +`--ts-tag` now dies (exit 2) for every role, before the root check, pointing at +the key. The staging `tag:server` policy therefore lands where the runner's +did: on the **effective** tag in `verify_effective_tag`, exit 1, which is the +strictly stronger check (it guards the tag the key actually granted, not the +one rig hoped for). This plan is the up-to-date statement of the work. -- 2.45.2 From c4d64fb0370ba806376738a7be63ffd1ea3ae316 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:51:36 +0000 Subject: [PATCH 04/14] =?UTF-8?q?feat(bootstrap):=20staging=20role=20?= =?UTF-8?q?=E2=80=94=20the=20host=20archetype=20for=20box-minted=20staging?= =?UTF-8?q?=20VMs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- bin/rig | 9 +++++---- commands/bootstrap.sh | 30 ++++++++++++++++++++++++++---- test/cli.sh | 10 ++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/bin/rig b/bin/rig index 8301af6..767bfa3 100755 --- a/bin/rig +++ b/bin/rig @@ -8,11 +8,12 @@ usage() { usage: rig [args] commands: - bootstrap [--hostname ] [--ts-tag ] + bootstrap [--hostname ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, - tailscale join. Prompts for a single-use tailnet pre-auth key - (TS_AUTHKEY env overrides the prompt). Run as root. Role runner - defaults to tag:ci and refuses tag:server. + tailscale join. Prompts for a single-use TAGGED tailnet pre-auth key + (TS_AUTHKEY env overrides the prompt); the key's tags are the tailnet + tag, verified after join. Run as root. Roles runner and staging + refuse tag:server. coolify install --version Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 45f8092..6b5c06a 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -13,10 +13,15 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--hostname ] +usage: rig bootstrap [--hostname ] --hostname system + tailnet hostname (default: the role name) +Role staging is the host for box-minted staging VMs (Incus guests converged +from inside with `rig bootstrap workload`). Mint its key with tag:local: the +host is never managed by the control plane — its guest VMs are — so a staging +host may not carry tag:server. + The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, so the key is the single source of truth: rig no longer requests a tag it might disagree with. After the box joins, rig reads the tag control actually GRANTED @@ -31,10 +36,10 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner) shift ;; + control-plane|workload|runner|staging) shift ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane|workload|runner)" 2 ;; - *) die "unknown role: $ROLE (want control-plane|workload|runner)" 2 ;; + "") usage >&2; die "role required (control-plane|workload|runner|staging)" 2 ;; + *) die "unknown role: $ROLE (want control-plane|workload|runner|staging)" 2 ;; esac TS_HOSTNAME="$ROLE" @@ -71,6 +76,12 @@ if [ -r /etc/os-release ]; then else warn "cannot read /etc/os-release; proceeding anyway" fi +# A staging host exists to run VMs, so no /dev/kvm deserves a loud note — but +# only a note: the role is rehearsed in containers, where /dev/kvm is +# legitimately absent, and rig cannot tell a rehearsal from a misconfigured box. +if [ "$ROLE" = "staging" ] && [ ! -e /dev/kvm ]; then + warn "/dev/kvm is absent — a staging host is expected to run VMs. Harmless in a container rehearsal; on real hardware, enable virtualization (VT-x/AMD-V) in firmware." +fi # The pre-auth key is acquired LATER, in the tailscale block — and only if the # box has not already joined. rig is convergent by contract, so re-running it to @@ -216,6 +227,15 @@ verify_effective_tag() { die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." fi + # Same policy, staging flavor: a staging HOST is never managed by the control + # plane — its guest VMs are, each registered there as its own server. The + # fleet has already been bitten by a host wrongly carrying tag:server, which + # extends every server grant to a box the control plane does not even know. + # Refused, never warned; rig can DETECT this but not FIX it, so name the repair. + if [ "$ROLE" = "staging" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then + die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." + fi + log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')" } @@ -272,4 +292,6 @@ if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " elif [ "$ROLE" = "runner" ]; then log "next: rig runner install --repo --version " +elif [ "$ROLE" = "staging" ]; then + log "next: install the box CLI and run 'box setup-host' to prepare Incus, then mint staging boxes with 'box new --template staging'" fi diff --git a/test/cli.sh b/test/cli.sh index 6571de0..15aeae3 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -47,9 +47,19 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \ "$ROOT/commands/bootstrap.sh" runner --ts-tag +check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \ + "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server +# The staging tag:server refusal rides the EFFECTIVE tag, inside +# verify_effective_tag — a path that needs a real tailnet, so it belongs to the +# rehearsal. What the harness CAN prove is that the refusal exists in the +# shipped script: grep the die message, so a deleted guard cannot ship green +# (the same reason the runner-install repo guard is grepped below). +check "bootstrap: staging effective-tag refusal is present" 0 "" \ + grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh" if [ "$(id -u)" -ne 0 ]; then check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner + check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging else echo "skip: bootstrap non-root refusals (running as root)" fi -- 2.45.2 From 583ac25448b53aa79f3c6440e41bc3cfc0181a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:51:58 +0000 Subject: [PATCH 05/14] docs: README section for the staging bootstrap role Co-Authored-By: Claude Opus 4.8 --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cdcdd43..d025a35 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. @@ -29,6 +29,7 @@ second run changes nothing. rig bootstrap control-plane --hostname my-coolify-box rig bootstrap workload --hostname my-prod-box rig bootstrap runner --hostname my-ci-box +rig bootstrap staging --hostname my-vm-host ``` - `--hostname ` — tailnet hostname (default: the role name) @@ -106,6 +107,19 @@ grant `tag:server` to repo-controlled code." A runner executes that code, and the check turns the worst misconfiguration from a documentation warning into a hard, post-join error. +`staging` is the box that *hosts* staging boxes — Incus VMs minted by the +[`box`](https://github.com/heavy-duty/box) CLI, each converged from inside with +`rig bootstrap workload` and registered in the control plane as its own server. +Mint its key with `tag:local`: the host and its guests sit on opposite sides of +a trust boundary, and the *host* is never managed by the control plane — so the +role **refuses an effective `tag:server`**, same mechanism as `runner`. rig +deliberately installs no Incus and no box here — box's own `setup-host` is the +single owner of the Incus daemon's configuration, and two tools converging one +daemon is drift by construction. The closing log points you at it: install box, +run `box setup-host`, then `box new --template staging`. If `/dev/kvm` is +absent, rig warns (a host that exists to run VMs should have it) but does not +fail — the role is rehearsed in containers, which legitimately lack it. + ### `rig coolify install --version ` Control-plane box only. Installs Coolify at exactly the pinned version with -- 2.45.2 From 35ce40d05e0b8b44da8f7c57ac2a9c026ced37fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:10:20 +0000 Subject: [PATCH 06/14] docs: plan for machine traits + fleet users (#26 + #24, one release) Traits (class/host/join) under the existing roles with dev/workstation/custom presets and the /etc/rig/role marker; rig users apply/status/close-root under the hybrid access model decided in #26's comments: operators on every class, root SSH closed on class=human, kept as the control plane's automation identity on class=server. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-07-17-traits-and-users.md | 413 ++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 docs/plans/2026-07-17-traits-and-users.md diff --git a/docs/plans/2026-07-17-traits-and-users.md b/docs/plans/2026-07-17-traits-and-users.md new file mode 100644 index 0000000..cdea9b7 --- /dev/null +++ b/docs/plans/2026-07-17-traits-and-users.md @@ -0,0 +1,413 @@ +# Machine Traits + `rig users` Implementation Plan (issues #26 + #24, one release) + +> **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:** Ship the trait model (#26) and declarative fleet users (#24) as a +single release. Bootstrap's role list becomes presets over three orthogonal +traits (`class`, `host`, `join`), recorded in a convergent marker +`/etc/rig/role`. A new `rig users` command family makes operator accounts a +first-class, declarative concern on **every** class — the hybrid access model: +humans always enter as themselves and elevate via sudo; root SSH is what +`class` decides *after* users exist. `class=human` machines close it +(`rig users close-root`); `class=server` machines keep it as the **automation** +identity the control plane (Coolify) SSHes in as. The tailnet is network-only +(no Tailscale SSH), so named users are the only attribution at the door. + +**Why this shape:** #25's server/host binary bundled three questions that +merely correlated (who lives there / hosts VMs / how it joins). #26 unbundles +them; roles stay as presets so the usual shapes remain one command. The +earlier "server is humanless — `rig users` refuses" was superseded in #26's +comments: a shared root login is unattributable, so operators belong +everywhere; what stays class-specific is the *fate of root SSH*. Root's key +hygiene on servers (`from=`-locking Coolify's key line) is README guidance, +not automation — Coolify owns its key material on the servers it registers, +and two tools converging one file is drift by construction. + +**Architecture:** + +- `commands/bootstrap.sh` gains a **role→traits map** — the single place a + role's shape is declared — plus `--class/--host/--join` overrides and roles + `dev`, `workstation`, `custom`. All per-class behavior keys off traits: + `/dev/kvm` advisory (`host=yes`), tag policy (derived), next-steps log. + The marker is written post-join, convergently. +- `join=login` (workstation): a set `TS_AUTHKEY` is a usage error (exit 2, + before the root check — testable); interactive `tailscale up`; the tag + assertion **inverts** — any effective tag is a refusal, backed out with + `tailscale logout`, mirroring the untagged-key refusal on the authkey path. +- **tag:server policy is derived, not a trait**: only `control-plane` and + `workload` may carry it; every other role refuses it on the effective tags + (generalizes today's runner + staging checks into one rule). +- New `commands/users-apply.sh`, `commands/users-status.sh`, + `commands/users-close-root.sh`, with parsing/marker helpers in + `commands/lib/users-config.sh` so the harness can exercise refusals via + sourced functions against fixtures (repo precedent: `assert_runner_repo`, + `json_string_array`). +- `close-root` installs `/etc/ssh/sshd_config.d/00-rig-users.conf` + (`PermitRootLogin no`). The **name is load-bearing**: sshd_config is + first-wins, the Include glob expands lexically, and `-` (0x2D) < `.` + (0x2E), so `00-rig-users.conf` is read before bootstrap's `00-rig.conf` + and wins. Validate-then-apply exactly like bootstrap (`sshd -t`, rollback, + `sshd -T` assertion). Bootstrap's effective-config assertion learns to + accept `permitrootlogin no` (strictly harder) so a re-run never reopens + root. +- Managed-user bookkeeping: `apply` maintains `/etc/rig/users` (one username + per line) so a user removed from the input file is found and **locked** + (never deleted) on the next run. + +**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck ++ `bash test/cli.sh`) — no workflow change needed. + +## Non-Goals + +- No user deletion; no passwords (all locked, always); no LDAP/SSO/PAM; no + per-user quotas (Incus project limits are a box concern). +- No management of root's `authorized_keys` (Coolify owns its key on the + servers it manages; the `from=` lock is README guidance, not code). +- No Coolify application-account (web UI) management. +- No Incus / box installation (box's `setup-host` owns the daemon; the `box` + role *asserts* the `incus` group exists and refuses with a pointer + otherwise). +- No behavior change for control-plane/workload/runner beyond the marker + write and the generalized (identical-in-effect) tag policy. +- Cross-repo box work (restricted-tier verification, project-awareness, + global install) is referenced in #24 and tracked in heavy-duty/box. + +## Global Constraints + +- `#!/usr/bin/env bash` + `set -euo pipefail`; per-command log prefix via + `log`/`warn`/`die` helpers (`rig-users:` for the new family). +- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All + argument validation runs BEFORE the root check** so error paths are + testable as non-root. File parsing counts as validation: a bad users file + exits 2 with **all** errors reported at once. +- Convergent everywhere: a second identical run is a no-op, says so, exits 0. +- Validate-then-apply for anything that can lock the door or break sudo: + `sshd -t` before restart (rollback on failure), `visudo -c` before the + sudoers drop-in lands. +- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x + bin/* **/*.sh`); `bash test/cli.sh` green as non-root. +- Keep the diff minimal — no drive-by refactors. + +--- + +### Task 1: role→traits map, trait flags, marker, login join path (bootstrap) + +**Files:** +- Modify: `commands/bootstrap.sh` (usage heredoc, role case + trait map + + override flags, TS_AUTHKEY/login validation, kvm advisory keyed on traits, + generalized tag policy, login-path inverted assertion, marker write, + next-steps log) +- Modify: `bin/rig` (bootstrap usage lines: roles + trait flags) +- Modify: `test/cli.sh` (bootstrap section additions) + +**Behavior contract, in file order:** + +1. Usage heredoc: roles ``; + flags `--hostname `, `--class `, `--host `, + `--join `. Document: roles are presets over traits, any + trait overridable; `custom` requires `--hostname` and all three traits; + `join=login` needs no pre-auth key and refuses a set `TS_AUTHKEY` + (unset it or pass `--join authkey`); the preset table in one compact + block. +2. Role case: the seven roles shift; `role required` / `unknown role` + messages name them. Then the **role→traits map** — one `case "$ROLE"` + assigning `CLASS`/`HOST`/`JOIN` per the preset table in issue #26; + `custom` leaves all three empty. +3. Flag loop gains `--class/--host/--join`, each validating its value set + (bad value → exit 2 naming the valid values). After the loop: + `custom` missing `--hostname` → exit 2; `custom` missing any trait → + exit 2. `TS_HOSTNAME` default stays the role name (custom has none). +4. Post-parse validation (still pre-root-check): `JOIN=login` with a set + `TS_AUTHKEY` → `die` exit 2: "join=login is interactive: unset TS_AUTHKEY + or pass --join authkey". +5. Guards: the `/dev/kvm` advisory keys on `HOST=yes` (message unchanged in + spirit; drops the staging-only wording). +6. `verify_effective_tag` generalizes the runner/staging refusals into the + derived policy: if role is not `control-plane`/`workload` and the + effective tags contain `tag:server` → die (message keeps the + role-specific repair pointers for runner/staging; a generic message for + other shapes). Keep the existing two die-message strings greppable — + the harness greps them (`role staging joined with tag:server`, + runner equivalent). +7. **login path**: when `JOIN=login`, skip the pre-auth key acquisition; + run `tailscale up --hostname="$TS_HOSTNAME"` (interactive, no + `--authkey`); then the **inverted** assertion — poll as + `verify_effective_tag` does, but *any* non-empty effective tag → + `tailscale logout` + die: "joined TAGGED (…) but join=login expects a + user-owned, untagged node — a tag here means control granted this device + fleet identity; use a pre-auth key path (--join authkey) for fleet + machines." Empty tags + `Running` → OK, log "user-owned join verified". + The already-joined path runs the same class of check (tags present → + refusal; no logout on a box that was already joined — detect, refuse, + name the repair by hand). +8. **Marker**: after tag verification, write `/etc/rig/role` (mkdir -p + `/etc/rig`) with exactly one line: + `role=$ROLE class=$CLASS host=$HOST join=$JOIN` — cmp-guarded + (write+log only on change; "marker already current" otherwise). +9. Next-steps log keys off traits + role: `control-plane` → coolify install + pointer (as today); `runner` → runner install pointer (as today); + `HOST=yes` → box setup-host pointer (replaces the staging-only branch); + all classes → `rig users apply` pointer, with the class-specific tail: + `class=human` adds "then `rig users close-root` once your admin key + works"; `class=server` adds "root SSH stays — it is the control plane's + automation door". +10. `bin/rig` usage: bootstrap line gains the three roles and trait flags, + one sentence for the trait model. + +- [ ] **Step 1: Append failing tests** (`test/cli.sh`, bootstrap section) + +```bash +# --- traits: roles are presets, every trait individually settable (#26) ----- +check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato +check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato +check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe +check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon +check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \ + "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey +check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 +# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it +# must die BEFORE the root check — provable non-root, which also proves the +# preset actually landed. +check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation +# A trait override changes derived behavior, provable non-root: dev is +# join=authkey (TS_AUTHKEY fine → falls through to the root check), but +# --join login flips it into the TS_AUTHKEY refusal. +check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login +# The login-path inverted assertion needs a real tailnet; grep the refusal so a +# deleted guard cannot ship green (repo precedent: staging/runner tag greps). +check "bootstrap: login-path tagged refusal is present" 0 "" \ + grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh" +# The marker is the traits' ground truth for rig users; assert the write exists. +check "bootstrap: role marker write is present" 0 "" \ + grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" +``` + +and in the existing non-root block: + +```bash +check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev +check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation +check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey +``` + +- [ ] **Step 2: Run `bash test/cli.sh`** — new checks FAIL (unknown role / + unknown flag / missing messages), existing stay green, harness exits 1. +- [ ] **Step 3: Implement** per the behavior contract. +- [ ] **Step 4: `bash test/cli.sh`** — all green, exit 0. +- [ ] **Step 5: shellcheck + syntax** — `shopt -s globstar; shellcheck -x + bin/* **/*.sh`; `bash -n` each edited script. +- [ ] **Step 6: Commit** + +```bash +git add commands/bootstrap.sh bin/rig test/cli.sh +git commit -m "feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker" +``` + +--- + +### Task 2: `rig users apply` + `rig users status` + +**Files:** +- Create: `commands/lib/users-config.sh` (users-file parser, marker reader, + role→group mapping — pure functions, no side effects, sourceable by the + harness) +- Create: `commands/users-apply.sh`, `commands/users-status.sh` +- Modify: `bin/rig` (users subcommand dispatch + usage) +- Modify: `test/cli.sh` (users section) + +**Behavior contract:** + +1. `commands/lib/users-config.sh`: + - `parse_users_file `: emits normalized `user|roles|key` lines on + stdout, or **all** validation errors on stderr and returns 1. Refusals: + unknown role (naming the valid set `admin rig box`), differing roles + across one user's lines, `root` as username, malformed line (fewer than + 3 fields / key not starting `ssh-` or `ecdsa-`), duplicate identical + key line. `#` comments and blanks skipped. + - `read_role_marker `: prints `class=` etc. from the marker; + empty output when absent. No policy here — callers decide. +2. `users-apply.sh` (`rig-users:` prefix): + - Args: `--file ` required (`-` = stdin, read once into a temp); + `--help`; unknown flag exit 2. Parse+validate the whole file (exit 2 on + any error, all reported) **before** the root check. + - Root check. Then marker note (never a refusal): `class=server` or no + marker → log that root SSH stays the automation door / advise + bootstrapping a marker, respectively. + - Install `sudo` if missing and any parsed role needs it (`admin`, `rig`). + - `groupadd -f rig-admin rig`; if any user carries `box`, assert group + `incus` exists else die 1 pointing at box `setup-host`. + - Converge each user: `useradd -m -s /bin/bash` if absent, then always + `usermod -L`; membership in the three rig-managed groups exactly + (add and remove; other groups untouched); `~/.ssh/authorized_keys` + written 0700/0600, user-owned, to exactly the file's keys (cmp-guarded). + - Previously managed users absent from the file (diff against + `/etc/rig/users`): `usermod -L`, strip the three rig groups, keep home, + `warn` each. Then rewrite `/etc/rig/users` to the file's users. + - Sudoers: write the two `%rig-admin`/`%rig` rules to a temp, `visudo -c` + against it, then install atomically to `/etc/sudoers.d/rig-roles` 0440 + (cmp-guarded; die 1 with the temp preserved for inspection on a + `visudo` failure, sudoers untouched). + - Converged-no-change run says "already converged; no changes". +3. `users-status.sh`: `--help`; root check; reads `/etc/rig/users`, prints + per user: roles (derived from actual group membership), key count from + `authorized_keys`, locked/active. Exits 0 with "no rig-managed users" + when the ledger is absent. Reads only — no network, no writes. +4. `bin/rig`: `users` dispatch (`apply`/`status`/`close-root` → their + scripts; bare or unknown sub → usage exit 2); usage block documents the + three subcommands in the existing voice. + +- [ ] **Step 1: Append failing tests** (`test/cli.sh`; fixtures via mktemp, + parser exercised through the sourced lib — precedent: `guard()`/`tags()`): + bare `users` exit 2; `users frobnicate` exit 2; apply `--help` 0; + `--file` required 2; `--file` needs value 2; missing file 2; unknown flag + 2; parser fixtures: unknown role (message lists valid set), role mismatch + across lines, `root` refused, malformed line, a valid two-user file parses + (and a multi-error file reports **both** errors in one run); status + `--help` 0; non-root refusals for apply (valid fixture file) and status; + ordering grep: `visudo -c` line precedes the `sudoers.d/rig-roles` + install line in `users-apply.sh`. +- [ ] **Step 2: `bash test/cli.sh`** — new checks fail. +- [ ] **Step 3: Implement** per contract. +- [ ] **Step 4: `bash test/cli.sh`** — green. +- [ ] **Step 5: shellcheck + syntax** (CI invocation). +- [ ] **Step 6: Commit** + +```bash +git add commands/lib/users-config.sh commands/users-apply.sh commands/users-status.sh bin/rig test/cli.sh +git commit -m "feat(users): declarative operators — apply/status over a users file, every class" +``` + +--- + +### Task 3: `rig users close-root` + bootstrap accepts the closed state + +**Files:** +- Create: `commands/users-close-root.sh` +- Modify: `commands/bootstrap.sh` (effective-config assertion accepts + `permitrootlogin no`) +- Modify: `test/cli.sh` + +**Behavior contract:** + +1. `users-close-root.sh`: `--help` documents the model (human-class only; + verify your admin login in a separate session first) ; unknown flag 2; + root check; then, in order: + - Marker gate via `read_role_marker` (path overridable for tests via + `RIG_ROLE_MARKER`, default `/etc/rig/role`): absent marker → die 1 + "no /etc/rig/role marker: re-run rig bootstrap so this box knows what + it is; refusing to shut the root door blind"; `class=server` → die 1 + "class=server: root here is the control plane's automation identity — + closing it severs fleet management"; only `class=human` proceeds. + - Admin-door gate: at least one member of `rig-admin` with a non-empty + `~/.ssh/authorized_keys` → else die 1 "no admin user with a key on this + box — run rig users apply first; never close the only door". + - Install `/etc/ssh/sshd_config.d/00-rig-users.conf` containing exactly + `PermitRootLogin no`, cmp-guarded; `sshd -t` on the merged config + BEFORE `systemctl restart ssh`, rollback (remove/restore) on failure — + the bootstrap shape verbatim. Then assert `sshd -T` resolves + `permitrootlogin no` or die. + - Second run: "root already closed; nothing to do", exit 0. +2. `bootstrap.sh`: the `permitrootlogin` assertion regex becomes + `(no|prohibit-password|without-password)` with a comment: `no` is the + post-`close-root` state, strictly harder; bootstrap must never read a + closed door as a broken one (or reopen it — its drop-in loses to + `00-rig-users.conf` by first-wins, which is the point). + +- [ ] **Step 1: Append failing tests** + +```bash +check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help +check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope +# The whole command rests on first-wins + lexical include order: '-' < '.', so +# 00-rig-users.conf is read before 00-rig.conf. Assert the actual comparison the +# glob makes, so a renamed drop-in cannot silently lose the fight. +check "users close-root: drop-in name sorts before bootstrap's" 0 "" \ + bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]' +check "users close-root: drop-in name is the load-bearing one" 0 "" \ + grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh" +# Validate-then-apply ordering, greppable (repo precedent: repo-guard ordering). +# sshd -t must precede the restart in file order. +# marker refusals via the sourced lib against fixture markers: +# class=server marker → refusal names the control plane +# absent marker → refusal names bootstrap as the repair +# class=human marker → gate passes (function returns 0) +# non-root: close-root refuses non-root (exit 1) after arg validation. +``` + +(Exact harness lines mirror the `guard()` fixture pattern; ordering check +mirrors the `guard_at`/`start_at` line-number comparison.) + +- [ ] **Step 2: `bash test/cli.sh`** — new checks fail. +- [ ] **Step 3: Implement** per contract. +- [ ] **Step 4: `bash test/cli.sh`** — green. +- [ ] **Step 5: shellcheck + syntax** (CI invocation). +- [ ] **Step 6: Commit** + +```bash +git add commands/users-close-root.sh commands/bootstrap.sh test/cli.sh +git commit -m "feat(users): close-root — shut the human-class root door once an admin key works" +``` + +--- + +### Task 4: README — identity model, trait tables, users commands + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Write it** (in the README's existing voice): + - Bootstrap section: role list/examples gain `dev`, `workstation`, + `custom`; the trait table and roles-as-presets table from #26; the + marker; the `join=login` story (untagged/user-owned is the *assertion*, + a tag is the refusal). + - New **identity model** subsection: the hybrid access model — operators + on every class, humans never enter as root, `class` decides root SSH's + fate after `rig users apply`; the attribution rationale (network-only + tailnet, no identity broker at the door); the detection side benefit + (any root login that isn't the control plane is anomalous by + definition); the honest caveat (attribution, not privilege reduction — + sudo on a Docker box is root-equivalent). + - `rig users` section: file format, roles table (admin/rig/box, + incus-admin deliberately not a role), apply/status/close-root, the + locked-not-deleted convergence rule, close-root's gates, and the + **README-only** guidance: on `class=server`, lock root's + `authorized_keys` to the control plane with a + `from=""` clause on Coolify's key line (rig will + not write that file — Coolify owns it). +- [ ] **Step 2: Full local gate** — CI shellcheck invocation + + `bash test/cli.sh`. +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: README identity model — traits, presets, fleet users, root's two fates" +``` + +--- + +## Test Plan + +- **Harness (`bash test/cli.sh`, non-root, network-free):** everything in the + per-task steps — trait/flag validation, preset-driven TS_AUTHKEY refusals + (proving both presets and overrides), users-file refusal matrix through the + sourced parser, marker-gate refusals through fixtures, the lexical + drop-in-name assertion, and the two validate-then-apply ordering greps + (`visudo -c` before sudoers install, `sshd -t` before restart). +- **CI:** unchanged `ci.yml` (globstar shellcheck + harness) covers all new + files. +- **Rehearsal (manual, out of harness):** Incus container pair — + 1. human-class: `bootstrap dev` (real `tag:local` key) → marker says + `class=human host=yes join=authkey`; `users apply` a two-user file → + users/groups/keys/sudoers as specified; re-apply no-ops; remove a user + → locked, home intact; `status` truthful; `close-root` → `sshd -T` + resolves `permitrootlogin no`; re-run bootstrap → green, root stays + closed; drop a user's key, `ssh` as them fails, as the other succeeds. + 2. server-class: `bootstrap workload` → marker `class=server`; `users + apply` proceeds (operators exist); `close-root` refuses naming the + control plane. + 3. workstation: `bootstrap workstation` with no key → interactive login + join, untagged asserted; with a tagged key's identity → refused and + backed out. -- 2.45.2 From f2d343b0ef27d7752949812d387381e72b066cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:15:06 +0000 Subject: [PATCH 07/14] =?UTF-8?q?feat(bootstrap):=20traits=20under=20the?= =?UTF-8?q?=20roles=20=E2=80=94=20class/host/join,=20dev/workstation/custo?= =?UTF-8?q?m,=20/etc/rig/role=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roles become presets over three orthogonal traits declared in one map: class (who lives here), host (runs VMs), join (authkey or interactive login). Every per-role behavior now keys off the traits — the /dev/kvm advisory rides host=yes, the next-steps log rides class and host — and tag:server is derived policy, not a trait: only control-plane and workload are shapes the control plane manages, so every other role refuses the effective tag. join=login inverts the tag assertion (a user-owned node must come up untagged; a tag is refused and backed out on first join, refused without back-out on a box already joined) and refuses a set TS_AUTHKEY before the root check. The verified shape is recorded convergently in /etc/rig/role as ground truth for rig users. Co-Authored-By: Claude Fable 5 --- bin/rig | 15 ++- commands/bootstrap.sh | 222 +++++++++++++++++++++++++++++++++++------- test/cli.sh | 29 ++++++ 3 files changed, 226 insertions(+), 40 deletions(-) diff --git a/bin/rig b/bin/rig index 767bfa3..3dc2103 100755 --- a/bin/rig +++ b/bin/rig @@ -8,12 +8,17 @@ usage() { usage: rig [args] commands: - bootstrap [--hostname ] + bootstrap + [--hostname ] [--class ] [--host ] + [--join ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, - tailscale join. Prompts for a single-use TAGGED tailnet pre-auth key - (TS_AUTHKEY env overrides the prompt); the key's tags are the tailnet - tag, verified after join. Run as root. Roles runner and staging - refuse tag:server. + tailscale join. Roles are presets over the three traits; any flag + overrides its trait, and custom states all of them. Prompts for a + single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the + prompt); the key's tags are the tailnet tag, verified after join — + only control-plane and workload may carry tag:server. join=login + (workstation) needs no key: interactive login, node must come up + untagged. Run as root. coolify install --version Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 6b5c06a..79e715a 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -13,41 +13,99 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--hostname ] +usage: rig bootstrap + [--hostname ] [--class ] + [--host ] [--join ] - --hostname system + tailnet hostname (default: the role name) + --hostname system + tailnet hostname (default: the role name; custom has + no default and requires it) + --class who lives here — human|server. Decides root SSH's fate after + `rig users apply`: human closes it, server keeps it as the + control plane's automation door. + --host does this box host VMs (box/Incus) — yes|no + --join how it enters the tailnet — authkey|login -Role staging is the host for box-minted staging VMs (Incus guests converged -from inside with `rig bootstrap workload`). Mint its key with tag:local: the -host is never managed by the control plane — its guest VMs are — so a staging -host may not carry tag:server. +Roles are presets over the three traits; any flag overrides its trait. +custom presets nothing and requires --hostname plus all three traits. + + role class host join + control-plane server no authkey + workload server no authkey + runner server no authkey + staging server yes authkey + dev human yes authkey + workstation human yes login The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, so the key is the single source of truth: rig no longer requests a tag it might disagree with. After the box joins, rig reads the tag control actually GRANTED (tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused -outright, and a runner may not carry tag:server. Mint a correctly-tagged key. +outright, and only control-plane and workload may carry tag:server (they are +the only shapes the control plane manages). Mint a correctly-tagged key. -Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or -enter it at the interactive prompt. It is used once and never written to disk. +join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY +env var, or enter it at the interactive prompt. Used once, never written to disk. + +join=login: no pre-auth key — `tailscale up` prints a login URL and the human +at the keyboard is the credential, so the node comes up user-owned and +UNTAGGED (a tag here is refused and backed out). A set TS_AUTHKEY is a usage +error: unset it, or pass --join authkey. EOF } # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner|staging) shift ;; + control-plane|workload|runner|staging|dev|workstation|custom) shift ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane|workload|runner|staging)" 2 ;; - *) die "unknown role: $ROLE (want control-plane|workload|runner|staging)" 2 ;; + "") usage >&2; die "role required (control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;; + *) die "unknown role: $ROLE (want control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;; esac +# Role→traits map — the single place a role's shape is declared (issue #26). +# Roles are presets, nothing more: every behavior below keys off the traits, +# so a flag override changes behavior without a new role, and custom exists +# for the shape nobody foresaw — it declares nothing and must state all three. +CLASS="" HOST="" JOIN="" +case "$ROLE" in + control-plane) CLASS=server HOST=no JOIN=authkey ;; + workload) CLASS=server HOST=no JOIN=authkey ;; + runner) CLASS=server HOST=no JOIN=authkey ;; + staging) CLASS=server HOST=yes JOIN=authkey ;; + dev) CLASS=human HOST=yes JOIN=authkey ;; + workstation) CLASS=human HOST=yes JOIN=login ;; + custom) ;; +esac + +# custom has no hostname default: a made-up name on a made-up shape helps nobody. TS_HOSTNAME="$ROLE" +[ "$ROLE" = "custom" ] && TS_HOSTNAME="" while [ $# -gt 0 ]; do case "$1" in --hostname) [ $# -ge 2 ] || die "--hostname needs a value" 2 TS_HOSTNAME="$2"; shift 2 ;; + --class) + [ $# -ge 2 ] || die "--class needs a value" 2 + case "$2" in + human|server) CLASS="$2" ;; + *) die "bad --class: $2 (want human|server)" 2 ;; + esac + shift 2 ;; + --host) + [ $# -ge 2 ] || die "--host needs a value" 2 + case "$2" in + yes|no) HOST="$2" ;; + *) die "bad --host: $2 (want yes|no)" 2 ;; + esac + shift 2 ;; + --join) + [ $# -ge 2 ] || die "--join needs a value" 2 + case "$2" in + authkey|login) JOIN="$2" ;; + *) die "bad --join: $2 (want authkey|login)" 2 ;; + esac + shift 2 ;; --ts-tag) # --ts-tag is GONE, but this is a deliberate death with a message, not an # "unknown flag": the flag shipped for a month and scripts still pass it, @@ -62,6 +120,24 @@ while [ $# -gt 0 ]; do esac done +# custom must state its whole shape — collect every gap and report them at once, +# so the operator fixes the command line in one round trip, not four. +if [ "$ROLE" = "custom" ]; then + MISSING="" + [ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname" + [ -n "$CLASS" ] || MISSING="$MISSING --class" + [ -n "$HOST" ] || MISSING="$MISSING --host" + [ -n "$JOIN" ] || MISSING="$MISSING --join" + [ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2 +fi + +# A set TS_AUTHKEY on a login join is a usage error, caught before the root +# check: the operator plainly expected the key to be spent, and silently +# ignoring a credential is how the wrong join path ships unnoticed. +if [ "$JOIN" = "login" ] && [ -n "${TS_AUTHKEY:-}" ]; then + die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2 +fi + # --- guards ------------------------------------------------------------------ [ "$(id -u)" -eq 0 ] || die "must run as root" if [ -r /etc/os-release ]; then @@ -76,11 +152,11 @@ if [ -r /etc/os-release ]; then else warn "cannot read /etc/os-release; proceeding anyway" fi -# A staging host exists to run VMs, so no /dev/kvm deserves a loud note — but -# only a note: the role is rehearsed in containers, where /dev/kvm is +# A host=yes box exists to run VMs, so no /dev/kvm deserves a loud note — but +# only a note: the shape is rehearsed in containers, where /dev/kvm is # legitimately absent, and rig cannot tell a rehearsal from a misconfigured box. -if [ "$ROLE" = "staging" ] && [ ! -e /dev/kvm ]; then - warn "/dev/kvm is absent — a staging host is expected to run VMs. Harmless in a container rehearsal; on real hardware, enable virtualization (VT-x/AMD-V) in firmware." +if [ "$HOST" = "yes" ] && [ ! -e /dev/kvm ]; then + warn "/dev/kvm is absent — a host=yes box is expected to run VMs. Harmless in a container rehearsal; on real hardware, enable virtualization (VT-x/AMD-V) in firmware." fi # The pre-auth key is acquired LATER, in the tailscale block — and only if the @@ -218,27 +294,65 @@ verify_effective_tag() { 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 - # Role policy now rides the EFFECTIVE tag — strictly stronger than the old - # request-time check, which only guarded the tag rig HOPED for. This guards the - # tag the key ACTUALLY granted to repo-controlled code: a runner carrying - # tag:server would extend every grant your servers hold to CI code. Refused, - # never warned. rig can DETECT this but cannot FIX it, so name the repair. - if [ "$ROLE" = "runner" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then - die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." - fi - - # Same policy, staging flavor: a staging HOST is never managed by the control - # plane — its guest VMs are, each registered there as its own server. The - # fleet has already been bitten by a host wrongly carrying tag:server, which - # extends every server grant to a box the control plane does not even know. - # Refused, never warned; rig can DETECT this but not FIX it, so name the repair. - if [ "$ROLE" = "staging" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then - die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." + # tag:server policy is DERIVED, not a trait: it means "the control plane + # manages this box", and only control-plane and workload are shapes the + # control plane manages. Everything else refuses it on the EFFECTIVE tag — + # strictly stronger than the old request-time check, which only guarded the + # tag rig HOPED for. The fleet has been bitten both ways: a runner carrying + # tag:server extends every server grant to repo-controlled code, and a + # staging host carrying it extends them to a box the control plane does not + # even know. Refused, never warned; rig can DETECT this but cannot FIX it, + # so each refusal names its repair. + if printf '%s\n' "$tags" | grep -qx 'tag:server'; then + case "$ROLE" in + control-plane|workload) ;; + runner) + die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;; + staging) + die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." ;; + *) + die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;; + esac fi log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')" } +# verify_user_owned — join=login INVERTS the tag assertion: +# the whole point of a login join is a user-owned, untagged node, so here a tag +# is the hazard (control granted this device fleet identity) and untagged is +# the success case. Same poll as verify_effective_tag — tags ride the netmap — +# but the empty read is what we WANT once the backend reaches Running. +# back-out: first join, so a refusal logs the node out (mirror of the +# untagged-key back-out on the authkey path). keep: the box was already joined +# before this run — never back out state rig did not create; detect, refuse, +# and name the by-hand repair instead. +verify_user_owned() { + local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json shown + json="$(mktemp)" + while :; do + if tailscale status --json > "$json" 2>/dev/null; then + tags="$(json_string_array "$json" Tags)" + state="$(json_field "$json" BackendState)" + if [ -n "$tags" ] || [ "$state" = "Running" ]; then break; fi + fi + if [ "$SECONDS" -ge "$deadline" ]; then break; fi + sleep 2 + done + rm -f "$json" + + if [ -n "$tags" ]; then + shown="$(printf '%s' "$tags" | tr '\n' ' ')" + if [ "$mode" = "back-out" ]; then + tailscale logout >/dev/null 2>&1 \ + || warn "tailscale logout failed — this node is joined TAGGED; remove it from the tailnet by hand" + die "joined TAGGED (${shown}) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity; use a pre-auth key path (--join authkey) for fleet machines. Backed it out." + 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." + fi + log "user-owned join verified (untagged)" +} + if ! command -v tailscale >/dev/null 2>&1; then log "installing tailscale" curl -fsSL https://tailscale.com/install.sh | sh @@ -270,7 +384,19 @@ if tailscale status >/dev/null 2>&1; then # rig's back, on the very next ordinary re-run. Skipping `tailscale up` here is # deliberate and stays — re-running an identical tagged-authkey `up` errors — # but skipping the CHECK was how the M900s stayed mis-tagged unnoticed. - verify_effective_tag + # The check the traits demand: authkey wants the granted tag, login wants none + # (`keep`: never back out a join this run did not perform). + if [ "$JOIN" = "login" ]; then + verify_user_owned keep + else + verify_effective_tag + fi +elif [ "$JOIN" = "login" ]; then + # No pre-auth key on this path — the human at the keyboard is the credential. + # `tailscale up` prints a login URL and blocks until the browser login lands. + log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)" + tailscale up --hostname="$TS_HOSTNAME" + verify_user_owned back-out else # env override, else prompt; never touches disk if [ -z "${TS_AUTHKEY:-}" ]; then @@ -287,11 +413,37 @@ else verify_effective_tag fi +# --- role marker -------------------------------------------------------------- +# /etc/rig/role is the traits' ground truth for later rig commands (`rig users` +# reads class from it to decide root SSH's fate). Written only AFTER the tag +# verification, so a marker never describes a box that failed to become what it +# claims — and cmp-guarded like every file rig converges. +MARKER=/etc/rig/role +MARKER_TMP="$(mktemp)" +printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$HOST" "$JOIN" > "$MARKER_TMP" +if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then + mkdir -p /etc/rig + install -m 0644 "$MARKER_TMP" "$MARKER" + log "role marker written: role=$ROLE class=$CLASS host=$HOST join=$JOIN" +else + log "role marker already current" +fi +rm -f "$MARKER_TMP" + log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " elif [ "$ROLE" = "runner" ]; then log "next: rig runner install --repo --version " -elif [ "$ROLE" = "staging" ]; then - log "next: install the box CLI and run 'box setup-host' to prepare Incus, then mint staging boxes with 'box new --template staging'" +fi +if [ "$HOST" = "yes" ]; then + log "next: install the box CLI and run 'box setup-host' to prepare Incus for guest boxes" +fi +# Every class gets operators: humans always enter as themselves and elevate via +# sudo — a shared root login is unattributable. What differs by class is root +# SSH's fate once named users exist. +if [ "$CLASS" = "human" ]; then + log "next: rig users apply --file , then 'rig users close-root' once your admin key works" +else + log "next: rig users apply --file for named operator logins; root SSH stays — it is the control plane's automation door" fi diff --git a/test/cli.sh b/test/cli.sh index 15aeae3..a6ae065 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -56,10 +56,39 @@ check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth # (the same reason the runner-install repo guard is grepped below). check "bootstrap: staging effective-tag refusal is present" 0 "" \ grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh" +# --- traits: roles are presets, every trait individually settable (#26) ----- +check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato +check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato +check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe +check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon +check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \ + "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey +check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 +# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it +# must die BEFORE the root check — provable non-root, which also proves the +# preset actually landed. +check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation +# A trait override changes derived behavior, provable non-root: dev is +# join=authkey (TS_AUTHKEY fine → falls through to the root check), but +# --join login flips it into the TS_AUTHKEY refusal. +check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login +# The login-path inverted assertion needs a real tailnet; grep the refusal so a +# deleted guard cannot ship green (repo precedent: staging/runner tag greps). +check "bootstrap: login-path tagged refusal is present" 0 "" \ + grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh" +# The marker is the traits' ground truth for rig users; assert the write exists. +check "bootstrap: role marker write is present" 0 "" \ + grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" if [ "$(id -u)" -ne 0 ]; then check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging + check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev + check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation + check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey else echo "skip: bootstrap non-root refusals (running as root)" fi -- 2.45.2 From 9bdc4db57596290e248c0eb8d4a57462873063b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:23:47 +0000 Subject: [PATCH 08/14] =?UTF-8?q?feat(users):=20declarative=20operators=20?= =?UTF-8?q?=E2=80=94=20apply/status=20over=20a=20users=20file,=20every=20c?= =?UTF-8?q?lass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators become a declared fact, not an accumulation of adduser runs: a line-based, bash-parseable users file (no YAML, no jq — a rig box has neither) names each user, their roles, and their keys, and apply converges the box to exactly that. Roles map to groups (admin→rig-admin with full NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no sudo — box's setup-host owns Incus, rig only asserts the group). Every password stays locked always; the SSH key at the door is the authentication. A user dropped from the file is found via the /etc/rig/users ledger and locked, never deleted — deleting frees the uid and rots attribution. The sudoers drop-in lands only after visudo -c passes, because a bad file under sudoers.d takes down all of sudo. Class never gates apply (#26: a shared root login is unattributable, so operators belong on every class); the marker only colors what root SSH does next. The whole file is validated in one pass before the root check, every error named with its line, so refusals are provable in the non-root harness through the sourced parser. Co-Authored-By: Claude Fable 5 --- bin/rig | 34 +++++ commands/lib/users-config.sh | 86 +++++++++++++ commands/users-apply.sh | 236 +++++++++++++++++++++++++++++++++++ commands/users-status.sh | 65 ++++++++++ test/cli.sh | 69 ++++++++++ 5 files changed, 490 insertions(+) create mode 100644 commands/lib/users-config.sh create mode 100755 commands/users-apply.sh create mode 100755 commands/users-status.sh diff --git a/bin/rig b/bin/rig index 3dc2103..1fe8f95 100755 --- a/bin/rig +++ b/bin/rig @@ -42,6 +42,18 @@ commands: re-register, reusing the binary already on the box. Needs a removal token for the old repo and a registration token for the new one. Run as root. + users apply --file + Converge named operator accounts from a declarative users file, on + every class: groups by role (admin/rig/box), passwords locked always, + authorized_keys made exact, visudo-gated sudoers rules. Users dropped + from the file are locked, never deleted. '-' reads stdin. Run as root. + users status + Roles (derived from actual group membership), key counts and lock + state for the rig-managed users. Reads the box only. Run as root. + users close-root + Shut root SSH on a class=human box once an admin key works. Refuses + on class=server — root there is the control plane's automation door — + and while no admin holds a key. Run as root. install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash @@ -103,6 +115,28 @@ case "$cmd" in ;; esac ;; + users) + shift + sub="${1:-}" + case "$sub" in + apply) + shift + exec "$ROOT/commands/users-apply.sh" "$@" + ;; + status) + shift + exec "$ROOT/commands/users-status.sh" "$@" + ;; + close-root) + shift + exec "$ROOT/commands/users-close-root.sh" "$@" + ;; + *) + usage >&2 + exit 2 + ;; + esac + ;; -h|--help|help) usage exit 0 diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh new file mode 100644 index 0000000..a468758 --- /dev/null +++ b/commands/lib/users-config.sh @@ -0,0 +1,86 @@ +#!/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. + +# parse_users_file +# +# 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 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), duplicate identical key line. +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 + ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;; + *) + errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)") + continue ;; + esac + if [ "$u" = "root" ]; then + errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class 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 — the marker line bootstrap wrote +# (`role=... class=... host=... join=...`), or nothing when absent. NO policy +# here: what an absent marker or a given class MEANS is each caller's call +# (apply notes it, close-root refuses on it) — the lib only reads. +read_role_marker() { + [ -r "$1" ] || return 0 + head -n1 "$1" +} diff --git a/commands/users-apply.sh b/commands/users-apply.sh new file mode 100755 index 0000000..36977d2 --- /dev/null +++ b/commands/users-apply.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# rig users apply — converge named operator accounts from a declarative users +# file, on every class. Humans always enter as themselves and elevate via +# sudo: a shared root login is unattributable, so operators belong on servers +# too — class never gates this command, it only decides root SSH's fate AFTER +# users exist (close-root on human, kept as the control plane's automation +# door on server). Convergent: a second identical run changes nothing and +# says so. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/users-config.sh +. "$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() { + cat <<'EOF' +usage: rig users apply --file + + --file users file (required; '-' reads it from stdin) + +The file is line-based and bash-parseable on purpose — a rig box has no YAML +parser and no jq, and gets neither for this. Whitespace-separated: user, +comma-joined roles, then the SSH public key (the rest of the line). '#' +comments and blank lines are fine. Repeated username lines add authorized +keys; the roles must be identical on every line of one user. + + # user roles ssh public key + dan admin,box ssh-ed25519 AAAA... dan@laptop + maria rig,box ssh-ed25519 AAAA... maria@mac + +roles: + admin group rig-admin — full NOPASSWD sudo + rig group rig — NOPASSWD sudo for /usr/local/bin/rig only + box group incus — Incus restricted tier, no sudo (box's setup-host + owns the Incus install; rig only asserts it) + +All passwords stay locked, always — the SSH key at the door is the +authentication, and NOPASSWD sudo does not weaken it. Convergent: membership +in the three rig-managed groups is made exact (other groups are never +touched), authorized_keys becomes exactly the file's keys, and a user dropped +from the file is locked and stripped of the rig groups — home kept, never +deleted. Run as root. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +FILE="" +while [ $# -gt 0 ]; do + case "$1" in + --file) + [ $# -ge 2 ] || die "--file needs a value" 2 + FILE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done +[ -n "$FILE" ] || die "--file is required" 2 + +# stdin is read ONCE into a temp: the file is parsed for validation and then +# walked again to converge, and a pipe only plays once. +if [ "$FILE" = "-" ]; then + STDIN_TMP="$(mktemp)" + cat > "$STDIN_TMP" + FILE="$STDIN_TMP" +fi +[ -r "$FILE" ] || die "cannot read users file: $FILE" 2 + +# File parsing is argument validation: every error in the file is reported in +# one pass, exit 2, still before the root check. +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=() +USERS=() +NEED_SUDO=0 +NEED_INCUS=0 +while IFS='|' read -r u r k; do + [ -n "$u" ] || continue + if [ -z "${USER_ROLES[$u]:-}" ]; then + USERS+=("$u") + USER_ROLES[$u]="$r" + fi + USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n' + case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac + case ",$r," in *,box,*) NEED_INCUS=1 ;; esac +done <<< "$PARSED" + +# --- guards ------------------------------------------------------------------ +[ "$(id -u)" -eq 0 ] || die "must run as root" + +# 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 /etc/rig/role)" in + *class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;; + *class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;; + "") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;; +esac + +CHANGED=0 + +# --- sudo (only when some role actually grants through it) ------------------- +if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then + log "installing sudo (admin/rig grants go through it)" + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo + CHANGED=1 +fi + +# --- groups ------------------------------------------------------------------ +groupadd -f rig-admin +groupadd -f rig +# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An +# absent incus group means that never ran — refuse with the pointer rather +# than conjure a group the (nonexistent) daemon would never consult. +if [ "$NEED_INCUS" -eq 1 ] && ! getent group incus >/dev/null; then + die "a user carries role box but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" +fi + +in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } + +# --- converge each user ------------------------------------------------------ +for u in "${USERS[@]}"; do + if ! id -u "$u" >/dev/null 2>&1; then + useradd -m -s /bin/bash "$u" + log "created user $u" + CHANGED=1 + fi + # Locked always, created or found: no password ever exists to guess or + # rotate — the SSH key at the door is the authentication. Idempotent. + usermod -L "$u" + + # Membership in the three rig-managed groups is made EXACT — added and + # removed to match the file. Other groups are never touched: they are not + # rig's to converge. + roles="${USER_ROLES[$u]}" + want="" + case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac + case ",$roles," in *,rig,*) want="$want rig" ;; esac + case ",$roles," in *,box,*) want="$want incus" ;; esac + for g in rig-admin rig incus; do + case " $want " in + *" $g "*) + if ! in_group "$u" "$g"; then + usermod -aG "$g" "$u" + log "added $u to $g" + CHANGED=1 + fi ;; + *) + if in_group "$u" "$g"; then + gpasswd -d "$u" "$g" >/dev/null + log "removed $u from $g" + CHANGED=1 + fi ;; + esac + done + + # authorized_keys becomes exactly the file's keys — cmp-guarded like every + # file rig converges, so an unchanged file is a clean no-op. + home="$(getent passwd "$u" | cut -d: -f6)" + ugroup="$(id -gn "$u")" + AK_TMP="$(mktemp)" + printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP" + if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then + mkdir -p "$home/.ssh" + chmod 0700 "$home/.ssh" + chown "$u:$ugroup" "$home/.ssh" + install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys" + log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)" + CHANGED=1 + fi + rm -f "$AK_TMP" +done + +# --- previously managed users no longer in the file -------------------------- +# The ledger is what lets a REMOVED user be found at all. Locked, not deleted: +# deleting frees the uid for reuse and orphans file ownership — attribution +# would rot. Home stays for the same reason. +LEDGER=/etc/rig/users +if [ -r "$LEDGER" ]; then + while IFS= read -r prev; do + [ -n "$prev" ] || continue + case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac + id -u "$prev" >/dev/null 2>&1 || continue + usermod -L "$prev" + for g in rig-admin rig incus; do + if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi + done + warn "$prev is no longer in the file: locked and stripped of the rig groups (home kept — rig never deletes a user)" + CHANGED=1 + done < "$LEDGER" +fi +LEDGER_TMP="$(mktemp)" +if [ "${#USERS[@]}" -gt 0 ]; then printf '%s\n' "${USERS[@]}" > "$LEDGER_TMP"; fi +if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then + mkdir -p /etc/rig + install -m 0644 "$LEDGER_TMP" "$LEDGER" + CHANGED=1 +fi +rm -f "$LEDGER_TMP" + +# --- sudoers ----------------------------------------------------------------- +# Both group rules ship in one drop-in whether or not both roles are in use: +# the groups exist and the rules are inert without members. visudo gates the +# install because a bad file under /etc/sudoers.d can take down ALL of sudo — +# locking every admin out of the very escalation path apply just granted. +SUDOERS_TMP="$(mktemp)" +cat > "$SUDOERS_TMP" <<'EOF' +# Managed by `rig users apply` — do not edit; the next apply converges it. +%rig-admin ALL=(ALL:ALL) NOPASSWD: ALL +%rig ALL=(root) NOPASSWD: /usr/local/bin/rig +EOF +if command -v visudo >/dev/null 2>&1; then + visudo -c -f "$SUDOERS_TMP" >/dev/null \ + || die "sudoers candidate failed validation — /etc/sudoers.d untouched; candidate kept at $SUDOERS_TMP for inspection" + if ! cmp -s "$SUDOERS_TMP" /etc/sudoers.d/rig-roles 2>/dev/null; then + install -m 0440 "$SUDOERS_TMP" /etc/sudoers.d/rig-roles + log "sudoers role rules installed (/etc/sudoers.d/rig-roles)" + CHANGED=1 + fi + rm -f "$SUDOERS_TMP" +else + # No sudo on the box means no role needed it (the install above would have + # run otherwise): rules for a binary that is not there can wait for the + # apply that brings a sudo-bearing role. + rm -f "$SUDOERS_TMP" + log "sudo not installed and no role needs it; skipping the sudoers drop-in" +fi + +if [ "$CHANGED" -eq 0 ]; then + log "already converged; no changes" +else + log "converged ${#USERS[@]} user(s)" +fi diff --git a/commands/users-status.sh b/commands/users-status.sh new file mode 100755 index 0000000..8234bab --- /dev/null +++ b/commands/users-status.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# rig users status — what this box's operator accounts actually are, read from +# the machine itself: roles derived from REAL group membership (not the +# ledger's memory of an apply), key counts from authorized_keys, lock state +# from shadow. Reads only — no network, no writes. +set -euo pipefail + +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() { + cat <<'EOF' +usage: rig users status + +Per rig-managed user (the /etc/rig/users ledger): roles derived from the +groups the user is ACTUALLY in (rig-admin -> admin, rig -> rig, incus -> box), +the authorized_keys count, and whether the account is locked or active. +Reads the box only — no network, no writes. Run as root (shadow is read). +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +[ "$(id -u)" -eq 0 ] || die "must run as root" + +LEDGER=/etc/rig/users +if [ ! -r "$LEDGER" ]; then + log "no rig-managed users (no $LEDGER yet — rig users apply creates it)" + exit 0 +fi + +while IFS= read -r u; do + [ -n "$u" ] || continue + if ! id -u "$u" >/dev/null 2>&1; then + # In the ledger but off the box: someone deleted by hand what rig only + # ever locks. Say so rather than crash or silently skip. + warn "$u: in the ledger but not on the box (rig never deletes — removed by hand?)" + continue + fi + groups=" $(id -nG "$u") " + roles="" + case "$groups" in *" rig-admin "*) roles="admin" ;; esac + case "$groups" in *" rig "*) roles="${roles:+$roles,}rig" ;; esac + case "$groups" in *" incus "*) roles="${roles:+$roles,}box" ;; esac + [ -n "$roles" ] || roles="none" + home="$(getent passwd "$u" | cut -d: -f6)" + keys=0 + if [ -r "$home/.ssh/authorized_keys" ]; then + keys="$(grep -c . "$home/.ssh/authorized_keys" || true)" + fi + # Field 2 of `passwd -S` is the lock flag; locked is apply's resting state + # for a user dropped from the file, so it is the fact worth surfacing. + state=active + case "$(passwd -S "$u" 2>/dev/null | awk '{print $2}')" in + L|LK) state=locked ;; + esac + log "$u roles=$roles keys=$keys $state" +done < "$LEDGER" diff --git a/test/cli.sh b/test/cli.sh index a6ae065..143b4b0 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -266,6 +266,75 @@ else echo "skip: runner status/remove/repoint non-root refusals (running as root)" fi +check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users +check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate + +check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help +check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh" +check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file +check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users +check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope +check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help + +# --- users file refusal matrix, through the sourced parser ------------------- +# Reaching the parser via the CLI stops at the root check; it is pure and +# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array), +# so the refusals are proven here against fixtures, non-root and network-free. +parse() { # parse — the users-file parser, exactly as apply runs it + bash -c 'set -euo pipefail + . "$1/commands/lib/users-config.sh" + parse_users_file "$2"' _ "$ROOT" "$1" +} +FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line +FIX_BAD="$(mktemp)" # rewritten per refusal below +cat > "$FIX_OK" <<'USERS' +# fleet operators +dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop +dan admin,box ssh-ed25519 AAAAC3second dan@desk + +maria rig ssh-ed25519 AAAAC3fixture maria@mac +USERS +printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD" +check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD" +printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD" +check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD" +printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD" +check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD" +printf '%s\n' 'dan admin' > "$FIX_BAD" +check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD" +check "users parser: valid file emits dan (both keys' roles agree)" \ + 0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK" +check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK" +# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error. +# A single invocation, both messages asserted from its one stderr. +printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD" +MULTI_ERRS="$(mktemp)" +parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$? +check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1 +check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS" +check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS" +rm -f "$MULTI_ERRS" + +if [ "$(id -u)" -ne 0 ]; then + # A VALID fixture proves the whole file-validation pass sits before the + # 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 status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh" +else + echo "skip: users non-root refusals (running as root)" +fi +rm -f "$FIX_OK" "$FIX_BAD" + +# Validate-then-apply: `visudo -c` must pass before anything lands in +# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin +# out of the escalation path apply just granted. Assert the order in the file, +# matching the calls rather than comments (repo precedent: the runner-install +# repo-guard ordering check). Defaults fail closed. +visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)" +check "users apply: visudo -c precedes the sudoers install" \ + 0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}" + # 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 # control plane. Extract it and syntax-check what actually gets written. -- 2.45.2 From 2dc396d5572778c619d3f236ff085dc551cc87b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:28:25 +0000 Subject: [PATCH 09/14] =?UTF-8?q?feat(users):=20close-root=20=E2=80=94=20s?= =?UTF-8?q?hut=20the=20human-class=20root=20door=20once=20an=20admin=20key?= =?UTF-8?q?=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit class decides root SSH's fate, and this is human's: install /etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME is the mechanism — sshd_config is first-wins, the Include glob expands lexically, and '-' sorts before '.', so it is read before bootstrap's 00-rig.conf and wins. Gated three ways, no --force: a marker must exist (never shut the root door blind), it must say class=human (on a server root is the control plane's automation identity — closing it severs fleet management), and some rig-admin member must already hold a non-empty authorized_keys (never close the only door). The gate's policy lives in the lib as assert_marker_human so the harness proves every refusal against fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim — cmp-guard, sshd -t on the merged config before the restart with rollback, then the sshd -T effective assertion. Bootstrap's own permitrootlogin assertion widens to accept 'no': the closed door is strictly harder, never broken, and by first-wins bootstrap cannot reopen it. Co-Authored-By: Claude Fable 5 --- commands/bootstrap.sh | 6 +- commands/lib/users-config.sh | 30 +++++++++- commands/users-close-root.sh | 112 +++++++++++++++++++++++++++++++++++ test/cli.sh | 51 ++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100755 commands/users-close-root.sh diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 79e715a..96ee088 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -229,7 +229,11 @@ rm -f "$TMP" eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box" 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/" -echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \ +# `no` is accepted because it is the post-`rig users close-root` state — +# strictly harder than the prohibit-password this script installs. Bootstrap +# must never read a closed door as a broken one, and it cannot reopen one +# either: by first-wins its own drop-in loses to 00-rig-users.conf. +echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \ || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" log "sshd hardening verified (sshd -T: passwordauthentication no)" diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index a468758..e78f874 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -79,8 +79,36 @@ parse_users_file() { # read_role_marker — the marker line bootstrap wrote # (`role=... class=... host=... join=...`), or nothing when absent. NO policy # here: what an absent marker or a given class MEANS is each caller's call -# (apply notes it, close-root refuses on it) — the lib only reads. +# (apply notes it, close-root refuses on it) — this reader only reads. read_role_marker() { [ -r "$1" ] || return 0 head -n1 "$1" } + +# assert_marker_human — close-root's marker gate: return 0, +# silently, only when the marker says class=human; 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_human() { + 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 "$marker" in + *class=human*) return 0 ;; + *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" + return 1 ;; + *) + printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind" + return 1 ;; + esac +} diff --git a/commands/users-close-root.sh b/commands/users-close-root.sh new file mode 100755 index 0000000..9dc6eee --- /dev/null +++ b/commands/users-close-root.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# rig users close-root — shut the human-class root SSH door, once and only +# once a named admin can already get in. class decides root SSH's fate (#26): +# on class=human a root login is unattributable noise, so it goes; on +# class=server root IS the control plane's automation identity, so closing it +# would sever fleet management — this command refuses there, and no --force +# exists. Convergent: a second run is a no-op and says so. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/users-config.sh +. "$HERE/lib/users-config.sh" + +log() { printf 'rig-users: %s\n' "$*"; } +die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig users close-root + +Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf +carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by +first-wins include order. + +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 working authorized_keys (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. + +Run as root. Convergent: once root is closed, a re-run is a clean no-op. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- guards ------------------------------------------------------------------ +[ "$(id -u)" -eq 0 ] || die "must run as root" + +# Marker gate — the policy lives in assert_marker_human (lib) so the harness +# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER +# exists for the same reason: it keeps the command's own gate pointable at +# fixtures instead of only at the real /etc/rig/role. +if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then + die "$WHY" +fi + +# Admin-door gate — never close the only door. Root SSH goes away below, so at +# least one rig-admin member must already hold a non-empty authorized_keys: +# "verified in a separate session" cannot be automated, but a key at the door +# can be, and its absence is proof enough to stop. +ADMIN_OK=0 +while IFS= read -r a; do + [ -n "$a" ] || continue + h="$(getent passwd "$a" | cut -d: -f6)" + if [ -n "$h" ] && [ -s "$h/.ssh/authorized_keys" ]; then ADMIN_OK=1; break; fi +done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n') +[ "$ADMIN_OK" -eq 1 ] \ + || die "no admin user with a key on this box — run rig users apply first; never close the only door" + +# --- the drop-in -------------------------------------------------------------- +# The NAME is the entire mechanism: sshd_config is FIRST-wins ("for each +# keyword, the first obtained value will be used" — sshd_config(5)), Include +# expands its glob in lexical order, and '-' (0x2D) sorts before '.' (0x2E), +# so 00-rig-users.conf is read BEFORE bootstrap's 00-rig.conf and this +# PermitRootLogin beats its prohibit-password. Rename the file and it silently +# loses that fight — the harness asserts the comparison the glob makes. +DROPIN=/etc/ssh/sshd_config.d/00-rig-users.conf +TMP="$(mktemp)" +printf 'PermitRootLogin no\n' > "$TMP" +if cmp -s "$TMP" "$DROPIN" 2>/dev/null; then + rm -f "$TMP" + log "root already closed; nothing to do" + exit 0 +fi + +BACKUP="" +[ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; } +install -m 0644 "$TMP" "$DROPIN" +rm -f "$TMP" + +# Validate the MERGED config BEFORE bouncing the daemon (the bootstrap shape): +# on a box whose only door is SSH — exactly what this box is about to become — +# restarting into a config the daemon refuses to parse leaves no listener and +# no way back in. Roll back and stop rather than shut the door on a maybe. +if ! sshd -t 2>/dev/null; then + if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi + rm -f "$BACKUP" + die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad." +fi +rm -f "$BACKUP" + +systemctl restart ssh + +# Assert the EFFECTIVE config, not the file's existence — a drop-in sorting +# even earlier would win the first-wins fight silently. `sshd -T` is what the +# daemon actually resolved. +eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim root is closed" +echo "$eff" | grep -qx 'permitrootlogin no' \ + || die "sshd still resolves permitrootlogin != no — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/" +log "root door closed (sshd -T resolves permitrootlogin no); humans enter as themselves now" diff --git a/test/cli.sh b/test/cli.sh index 143b4b0..60ed8f5 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -335,6 +335,57 @@ sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-ap check "users apply: visudo -c precedes the sudoers install" \ 0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}" +# --- users close-root: the human-class root-door shutter --------------------- +check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help +check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope +# The whole command rests on first-wins + lexical include order: '-' (0x2D) +# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's +# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the +# glob makes, so a renamed drop-in cannot silently lose the fight. +check "users close-root: drop-in name sorts before bootstrap's" 0 "" \ + bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]' +check "users close-root: drop-in name is the load-bearing one" 0 "" \ + grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh" +# Validate-then-apply: `sshd -t` on the merged config must precede the restart — +# on a box whose only door is SSH (exactly what this box is about to become), +# bouncing the daemon into a config it refuses to parse leaves no way back in. +# Match the call, not the word (repo precedent: the repo-guard ordering check); +# defaults fail closed. +sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)" +restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)" +check "users close-root: sshd -t precedes the ssh restart" \ + 0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}" +# 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 +# purpose (repo precedent: parse_users_file, assert_runner_repo). The command +# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate +# stays pointable at fixtures. +marker_gate() { # marker_gate + bash -c 'set -euo pipefail + . "$1/commands/lib/users-config.sh" + assert_marker_human "$2"' _ "$ROOT" "$1" +} +MARKER_DIR="$(mktemp -d)" +printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server" +printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human" +check "users close-root: absent marker refuses, names bootstrap as the repair" \ + 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" +check "users close-root: class=server refuses, names the control plane" \ + 1 "control plane" marker_gate "$MARKER_DIR/server" +check "users close-root: class=human passes the gate" \ + 0 "" marker_gate "$MARKER_DIR/human" +rm -rf "$MARKER_DIR" +if [ "$(id -u)" -ne 0 ]; then + check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh" +else + echo "skip: users close-root non-root refusal (running as root)" +fi +# Bootstrap must read the closed door as hardened, not broken: `no` is the +# post-close-root state, strictly harder than what bootstrap installs. Byte-grep +# the widened assertion so a revert cannot ship green. +check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \ + grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh" + # 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 # control plane. Extract it and syntax-check what actually gets written. -- 2.45.2 From 22ef458b1a76a5826d26796284da15d1c89c6938 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:32:23 +0000 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20README=20identity=20model=20?= =?UTF-8?q?=E2=80=94=20traits,=20presets,=20fleet=20users,=20root's=20two?= =?UTF-8?q?=20fates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap section now documents what shipped: roles as presets over the three orthogonal traits (class/host/join, every one overridable, custom states all of them), the derived tag:server policy, the /etc/rig/role marker that records effective traits so an overridden role never lies, and the join=login path where the tag assertion inverts — untagged is asserted, a tag is the refusal. A new identity-model section carries the hybrid access model: named operators on every class, humans never entering as root, class deciding root SSH's fate after `rig users apply` — closed on human, kept as the control plane's automation door on server — with the detection benefit and the honest attribution-not-privilege caveat stated plainly. Per-command sections cover apply/status/close-root, including the first-wins drop-in mechanism and the README-only from= guidance for Coolify's key on servers. Co-Authored-By: Claude Fable 5 --- README.md | 225 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 209 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d025a35..2a299ce 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. @@ -30,9 +30,61 @@ rig bootstrap control-plane --hostname my-coolify-box rig bootstrap workload --hostname my-prod-box rig bootstrap runner --hostname my-ci-box rig bootstrap staging --hostname my-vm-host +rig bootstrap dev --hostname my-dev-box +rig bootstrap workstation --hostname my-laptop +rig bootstrap custom --hostname odd-duck --class server --host yes --join authkey ``` -- `--hostname ` — tailnet hostname (default: the role name) +- `--hostname ` — system + tailnet hostname (default: the role name; + `custom` has no default and requires it) +- `--class ` — who lives here; decides root SSH's fate after + `rig users apply` (see *The identity model* below) +- `--host ` — does this box host VMs (box/Incus) +- `--join ` — how it enters the tailnet + +**Roles are presets over three orthogonal traits**, nothing more — every +per-role behavior keys off a trait, so any flag overrides its trait without +needing a new role (`rig bootstrap workstation --host no` for a laptop that +will never run VMs), and `custom` exists for the shape nobody foresaw: it +presets nothing and requires `--hostname` plus all three traits. + +| trait | values | what it drives | +|---------|--------------------|----------------| +| `class` | `human`, `server` | root SSH's fate once operators exist — human closes it, server keeps it as the control plane's automation door | +| `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and the `box setup-host` pointer | +| `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) | + +| role | class | host | join | tailnet tag | +|-----------------|--------|------|---------|-------------| +| `control-plane` | server | no | authkey | `tag:server` | +| `workload` | server | no | authkey | `tag:server` | +| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` | +| `staging` | server | yes | authkey | `tag:local` — refuses `tag:server` | +| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` | +| `workstation` | human | yes | login | untagged — any tag refused | + +The tag column is **derived policy, not a fourth trait**: `tag:server` means +"the control plane manages this box", and `control-plane` and `workload` are +the only shapes it manages — every other role refuses an effective +`tag:server` after join, one rule instead of per-role exceptions. + +After the tag verification passes, bootstrap writes `/etc/rig/role` — one +line, `role=… class=… host=… join=…` — recording the **effective** traits, +overrides and all, so an overridden role never lies to the commands that read +the marker later (`rig users` keys root policy off `class=`). Written +post-join and cmp-guarded, so a marker never describes a box that failed to +become what it claims. + +**`join=login` inverts the tag assertion.** A workstation joins as a +user-owned device: there is no pre-auth key — a set `TS_AUTHKEY` is a loud +usage error (exit 2; unset it, or pass `--join authkey`) — `tailscale up` +prints a login URL, and the human at the keyboard is the credential. After +join the assertion flips: **untagged** is what rig asserts, and any effective +tag is the refusal — a tag here means control granted this device fleet +identity, and on a first join the half-joined node is backed out with +`tailscale logout` (a box that was already joined is refused without backout; +rig never unwinds state it did not create). Same principle as the authkey +path, mirrored: verify what control **granted**, never what was requested. There is **no `--ts-tag` flag**. A pre-auth key is minted *with* its tags, so the key is the single source of truth for the tailnet tag — rig no longer states @@ -68,7 +120,8 @@ admin console keeps that name; rig will not fight it. > `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf` > on re-run, and refuses to claim success unless `sshd -T` agrees. -**The pre-auth key:** provide it via the `TS_AUTHKEY` env var or type it at +**The pre-auth key** (`join=authkey` roles — everything but `workstation`): +provide it via the `TS_AUTHKEY` env var or type it at the interactive prompt. Use a **single-use, tagged, short-expiry** key — the **tagged** part is now load-bearing, not advice (see below). It lives in process memory only — rig never writes a credential to disk. @@ -110,15 +163,46 @@ hard, post-join error. `staging` is the box that *hosts* staging boxes — Incus VMs minted by the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside with `rig bootstrap workload` and registered in the control plane as its own server. -Mint its key with `tag:local`: the host and its guests sit on opposite sides of -a trust boundary, and the *host* is never managed by the control plane — so the -role **refuses an effective `tag:server`**, same mechanism as `runner`. rig -deliberately installs no Incus and no box here — box's own `setup-host` is the -single owner of the Incus daemon's configuration, and two tools converging one -daemon is drift by construction. The closing log points you at it: install box, -run `box setup-host`, then `box new --template staging`. If `/dev/kvm` is -absent, rig warns (a host that exists to run VMs should have it) but does not -fail — the role is rehearsed in containers, which legitimately lack it. +It is `class=server`: an unattended VM appliance — operators converge it and +leave; nobody lives there. Mint its key with `tag:local`: the host and its +guests sit on opposite sides of a trust boundary, and the *host* is never +managed by the control plane — so the role **refuses an effective +`tag:server`**, same mechanism as `runner`. rig deliberately installs no Incus +and no box here — box's own `setup-host` is the single owner of the Incus +daemon's configuration, and two tools converging one daemon is drift by +construction. The closing log points you at it: install box, run +`box setup-host`, then `box new --template staging`. If `/dev/kvm` is absent, +rig warns (a host that exists to run VMs should have it) but does not fail — +the shape is rehearsed in containers, which legitimately lack it. + +`dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local` +shape with a person living on it — and `workstation` is the machine at the +keyboard end of all the SSH connections: human-class, `join=login`, entering +the tailnet as *your* device rather than the fleet's. + +### The identity model + +**Named operators exist on every class, and humans never enter as root.** The +tailnet is network-only — no Tailscale SSH — so there is no identity broker at +the door: whoever holds a key to an account *is* that account, and a shared +root login is unattributable by construction. `rig users apply` puts named +operators on every box, server-class included; a human always enters as +themself and elevates via sudo. + +**`class` decides root SSH's fate — after `rig users apply`, never before.** +On `class=human`, root SSH closes entirely (`rig users close-root`, below). +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. + +**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. + +**The honest caveat:** on a Docker-running box this buys attribution, not +privilege reduction — an operator with sudo is root-equivalent anyway. +Attribution is the goal: *who did what* survives, even where *what they could +do* is everything. ### `rig coolify install --version ` @@ -299,6 +383,112 @@ prints the exact `runner install` line that finishes the job. Convergent — repointing to the repo it is already on changes nothing, exits 0, and never asks for a token. +### `rig users apply --file ` + +Converges named operator accounts from a declarative users file — on **every** +class (see *The identity model*). Run as root. Convergent: a second identical +run says "already converged; no changes". + +``` +# user roles ssh public key +dan admin,box ssh-ed25519 AAAA... dan@laptop +dan admin,box ssh-ed25519 AAAA... dan@desktop +maria rig,box ssh-ed25519 AAAA... maria@mac +``` + +One line per key — user, comma-joined roles, then the SSH public key (the rest +of the line). The format is bash-parseable on purpose: a rig box has no YAML +parser and no jq, and gets neither for this. Repeated username lines add +authorized keys, and the roles must be identical on each — a repeated line +always means "another key", never a quiet role edit hiding mid-file. `root` is +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. + +**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. + +| role | grants | via group | +|---------|----------------------------------------------|-------------| +| `admin` | full NOPASSWD sudo | `rig-admin` | +| `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` | +| `box` | Incus **restricted** tier, no sudo | `incus` | + +`box` carries a refusal with it: rig never installs Incus — box's `setup-host` +owns the daemon — so an absent `incus` group means that never ran, and apply +dies pointing at `box setup-host` rather than conjure a group the +(nonexistent) daemon would never consult. `incus-admin` is deliberately +**not** a role: that group is host-root-equivalent, break-glass by hand only. + +**All passwords stay locked, always** — created or found. The SSH key at the +door is the authentication, and NOPASSWD sudo does not weaken it: there was +never a password to guess or rotate. + +Convergence is exact. Membership in the three rig-managed groups is made to +match the file — added *and* removed — while every other group is left alone: +not rig's to converge. `authorized_keys` becomes exactly the file's keys. A +user dropped from the file is found via the `/etc/rig/users` ledger and +**locked, never deleted** — deletion frees the uid for reuse and orphans file +ownership, so attribution would rot; home stays for the same reason. And the +sudoers rules land in `/etc/sudoers.d/rig-roles` only after `visudo -c` +passes on the candidate — a bad file under `/etc/sudoers.d` can take down +*all* of sudo, locking every admin out of the very escalation path apply just +granted. + +### `rig users status` + +```sh +rig users status +``` + +Read-only truth: per rig-managed user, the roles derived from the groups the +user is **actually** in — not the ledger's memory of an apply — plus the +`authorized_keys` count and whether the account is locked or active. Reads the +box only; no network, no writes. Run as root (shadow is read). + +### `rig users close-root` + +```sh +rig users close-root +``` + +Shuts the root SSH door — `class=human` boxes only, and only once a named +admin can already get in. The gates run in order: the `/etc/rig/role` marker +must say `class=human` — an absent marker refuses (never shut the root door +blind; re-run bootstrap so the box knows what it is), and `class=server` +refuses with no `--force`, because root there is the control plane's +automation identity and closing it severs fleet management. Then at least one +`rig-admin` member must hold a non-empty `authorized_keys` — 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 +admin login must be proven, not presumed. + +> **The drop-in's name is the entire mechanism.** close-root installs +> `/etc/ssh/sshd_config.d/00-rig-users.conf` carrying exactly +> `PermitRootLogin no`. sshd_config is first-wins, `Include` expands its glob +> lexically, and `-` (0x2D) sorts before `.` (0x2E) — so `00-rig-users.conf` +> is read *before* bootstrap's `00-rig.conf` and beats its +> `prohibit-password`. Bootstrap's effective-config assertion accepts the +> closed state (`no` is strictly harder than what it installs), and by the +> same first-wins order its own drop-in can never reopen it — a bootstrap +> re-run on a closed box leaves it closed. Validate-then-apply as everywhere: +> `sshd -t` before the restart, rollback on failure, and success is only +> claimed once `sshd -T` resolves `permitrootlogin no`. + +Convergent — once root is closed, a re-run says "root already closed; nothing +to do" and exits 0. + +> **On `class=server`, root stays — so lock its key instead.** This is README +> guidance, deliberately not automation: prefix Coolify's line in root's +> `authorized_keys` with a `from=""` clause, so the +> automation identity only opens from the one address supposed to use it. rig +> will not write that file — Coolify owns its key material on the servers it +> registers, and two tools converging one file is drift by construction (the +> same argument that keeps rig's hands off Incus). + ## What rig deliberately does NOT do - **Provider firewalls** — Docker publishes ports past host firewalls, so @@ -313,7 +503,10 @@ and never asks for a token. ## Testing `bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The -end-to-end rehearsal is a throwaway VM/container: pristine Debian → install → -`bootstrap workload` with a real single-use key → assert the sshd drop-in, -tailnet join, and a no-op second run → destroy, remove the node from the -tailnet. +`rig users` family is covered the same way: the harness drives its refusal +matrix — users-file parsing, the marker gates, the lexical drop-in-name +assertion, the validate-then-apply ordering — through the sourced lib +functions, non-root and network-free. The end-to-end rehearsal is a throwaway +VM/container: pristine Debian → install → `bootstrap workload` with a real +single-use key → assert the sshd drop-in, tailnet join, and a no-op second +run → destroy, remove the node from the tailnet. -- 2.45.2 From 062dad4eadab2517ab8cd5c131fafc8a9e94f0a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:51:53 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(bootstrap):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20keep-mode=20for=20authkey=20re-runs,=20fail-closed?= =?UTF-8?q?=20login=20verify,=20class-gated=20root-door=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three refusals, one doctrine: detect, refuse, name the repair — and never back out state rig did not create. - verify_effective_tag grows the same 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 --- commands/bootstrap.sh | 69 ++++++++++++++++++++++++++++++++++--------- test/cli.sh | 19 ++++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 96ee088..a47bec1 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -229,12 +229,26 @@ rm -f "$TMP" eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box" 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/" -# `no` is accepted because it is the post-`rig users close-root` state — -# strictly harder than the prohibit-password this script installs. Bootstrap -# must never read a closed door as a broken one, and it cannot reopen one -# either: by first-wins its own drop-in loses to 00-rig-users.conf. -echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \ - || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" +# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite +# things on the two classes. class=human: `no` is the post-`rig users +# close-root` state — strictly harder than the prohibit-password this script +# installs. Bootstrap must never read a closed door as a broken one, and it +# cannot reopen one either: by first-wins its own drop-in loses to +# 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)" # --- system hostname ---------------------------------------------------------- @@ -270,8 +284,15 @@ fi # 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 # the backend reaches Running (past which an empty Tags is real, not just early). +# +# verify_effective_tag — 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() { - local deadline=$((SECONDS + 30)) tags="" state="" json + local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json json="$(mktemp)" while :; do 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 # 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, - # 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 - tailscale logout >/dev/null 2>&1 \ - || 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." + if [ "$mode" = "back-out" ]; then + tailscale logout >/dev/null 2>&1 \ + || 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 # tag:server policy is DERIVED, not a trait: it means "the control plane @@ -354,6 +384,17 @@ verify_user_owned() { 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." 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)" } @@ -389,11 +430,11 @@ if tailscale status >/dev/null 2>&1; then # deliberate and stays — re-running an identical tagged-authkey `up` errors — # 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 - # (`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 verify_user_owned keep else - verify_effective_tag + verify_effective_tag keep fi elif [ "$JOIN" = "login" ]; then # 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). log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)" tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" - verify_effective_tag + verify_effective_tag back-out fi # --- role marker -------------------------------------------------------------- diff --git a/test/cli.sh b/test/cli.sh index 60ed8f5..be1b1fe 100644 --- a/test/cli.sh +++ b/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). check "bootstrap: login-path tagged refusal is present" 0 "" \ 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. check "bootstrap: role marker write is present" 0 "" \ grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" @@ -385,6 +397,13 @@ fi # the widened assertion so a revert cannot ship green. check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \ 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 # error in it would be invisible here and would first surface at 04:00 on a live -- 2.45.2 From 3eeab687d06a2372c01f533881fa293c51acbb89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 20:01:19 +0000 Subject: [PATCH 12/14] =?UTF-8?q?fix(users):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20invoker=20gate,=20real=20SSH=20revocation,=20Strict?= =?UTF-8?q?Modes-shaped=20close-root=20gate,=20trait-aware=20box=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven review findings on the users family, each with the harness check that would have caught it: - Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but not argument-scoped, so `sudo rig users apply --file ` made role rig silently root-equivalent through the very command that granted it. Identity management now refuses any sudo invoker outside rig-admin; direct root (bring-up, a root shell) proceeds. - Offboarding revokes SSH, not just the password: a '!'-locked password is not a closed door under UsePAM — Debian sshd still honors the pubkey. A dropped user's account is now expired (usermod -L -e 1, the switch PAM actually enforces) and authorized_keys is renamed to authorized_keys.revoked-by-rig — access revoked, data kept, convergence never destroys. Present users get their expiry cleared idempotently, so a re-added user comes back to life. - The ledger remembers: two-field lines ('name active' / 'name revoked', legacy bare names read as active), so dropped users no longer vanish from rig's memory on the next rewrite. status now reports the ledger state corroborated by the account's real expiry — passwd -S read L for everyone (apply locks all passwords always), so its locked/active was meaningless — and flags a mismatch loudly as drift. - Perms are part of the converged state: ~/.ssh and authorized_keys ownership and mode converge on every run, not only when content changes — StrictModes treats them as load-bearing, so drifted perms were a broken login that "already converged" lied about. Only the content write stays cmp-guarded. - close-root's admin-door gate checks the StrictModes shape per candidate — ownership, group/world-writability of home/.ssh/authorized_keys, a real login shell, an unexpired account — and names which check failed. It proves the door SHOULD open, not that it does; the separate-session advisory stays load-bearing. - Usernames are validated in the parser's one-pass refusal matrix (^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited stream, and a leading '-' read as a useradd flag mid-convergence. - The box role is trait-aware: on a host=no box an absent incus group skips the role with a warning and converges everything else — one box-role user in a fleet-wide file must not abort apply everywhere VMs don't live. host=yes still dies pointing at box setup-host; a classless marker warns toward a bootstrap re-run. Co-Authored-By: Claude Fable 5 --- README.md | 63 +++++++++++++++------ commands/lib/users-config.sh | 12 +++- commands/users-apply.sh | 104 +++++++++++++++++++++++++++-------- commands/users-close-root.sh | 84 ++++++++++++++++++++++++---- commands/users-status.sh | 49 ++++++++++++----- test/cli.sh | 40 ++++++++++++++ 6 files changed, 288 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 2a299ce..47fbc06 100644 --- a/README.md +++ b/README.md @@ -415,11 +415,24 @@ holds nothing secret anyway: usernames, roles, and *public* keys. | `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` | | `box` | Incus **restricted** tier, no sudo | `incus` | -`box` carries a refusal with it: rig never installs Incus — box's `setup-host` -owns the daemon — so an absent `incus` group means that never ran, and apply -dies pointing at `box setup-host` rather than conjure a group the -(nonexistent) daemon would never consult. `incus-admin` is deliberately -**not** a role: that group is host-root-equivalent, break-glass by hand only. +**The honest limit of the `rig` role:** its sudo grant is binary-scoped, not +argument-scoped — it trusts its holder with every rig verb *except* identity +management. The `rig users` commands gate their **invoker**: run under sudo +by anyone outside `rig-admin`, they refuse. Without that gate, `sudo rig +users apply` against a file naming yourself admin would make the scoped grant +silently root-equivalent through the very tool it scopes. Direct root — a +bring-up shell, before any admin exists — proceeds. + +`box` binds where VMs live, and a users file is fleet-wide — its box grants +are not. rig never installs Incus — box's `setup-host` owns the daemon — so +when the `incus` group is absent, the `host=` trait decides: on `host=yes` +apply dies pointing at `box setup-host` (a VM host missing Incus is a real +problem) rather than conjure a group the (nonexistent) daemon would never +consult; on `host=no` the box role is **skipped with a warning** and +everything else — admins included — still converges, because one box-role +user somewhere in the fleet must not stop apply everywhere VMs don't live. +`incus-admin` is deliberately **not** a role: that group is +host-root-equivalent, break-glass by hand only. **All passwords stay locked, always** — created or found. The SSH key at the door is the authentication, and NOPASSWD sudo does not weaken it: there was @@ -427,14 +440,21 @@ never a password to guess or rotate. Convergence is exact. Membership in the three rig-managed groups is made to match the file — added *and* removed — while every other group is left alone: -not rig's to converge. `authorized_keys` becomes exactly the file's keys. A -user dropped from the file is found via the `/etc/rig/users` ledger and -**locked, never deleted** — deletion frees the uid for reuse and orphans file -ownership, so attribution would rot; home stays for the same reason. And the -sudoers rules land in `/etc/sudoers.d/rig-roles` only after `visudo -c` -passes on the candidate — a bad file under `/etc/sudoers.d` can take down -*all* of sudo, locking every admin out of the very escalation path apply just -granted. +not rig's to converge. `authorized_keys` becomes exactly the file's keys, and +its ownership and mode (and `.ssh`'s) are converged on **every** run, not +just when content changes — sshd's `StrictModes` treats them as +load-bearing, so drifted perms are a broken login that "already converged" +would lie about. A user dropped from the file is found via the +`/etc/rig/users` ledger and **revoked, never deleted**: the account is +expired — the switch PAM actually enforces; a locked password alone still +lets a pubkey in under Debian's `UsePAM` — and `authorized_keys` is renamed +to `authorized_keys.revoked-by-rig`. Access revoked, data kept: deletion +frees the uid for reuse and orphans file ownership, so attribution would rot; +home stays for the same reason, and re-adding the user to the file brings +them back, fresh keys and all. And the sudoers rules land in +`/etc/sudoers.d/rig-roles` only after `visudo -c` passes on the candidate — a +bad file under `/etc/sudoers.d` can take down *all* of sudo, locking every +admin out of the very escalation path apply just granted. ### `rig users status` @@ -444,8 +464,12 @@ rig users status Read-only truth: per rig-managed user, the roles derived from the groups the user is **actually** in — not the ledger's memory of an apply — plus the -`authorized_keys` count and whether the account is locked or active. Reads the -box only; no network, no writes. Run as root (shadow is read). +`authorized_keys` count (`revoked` when only the `.revoked-by-rig` rename +remains) and the user's state, **active** or **revoked**. The state is the +ledger's word corroborated by the account's real expiry — the switch that +actually revokes — and a mismatch is flagged loudly as drift: a box someone +changed behind rig's back must never read as healthy. Reads the box only; no +network, no writes. Run as root (shadow is read). ### `rig users close-root` @@ -459,8 +483,13 @@ must say `class=human` — an absent marker refuses (never shut the root door blind; re-run bootstrap so the box knows what it is), and `class=server` refuses with no `--force`, because root there is the control plane's automation identity and closing it severs fleet management. Then at least one -`rig-admin` member must hold a non-empty `authorized_keys` — never close the -only door. +`rig-admin` member must hold a login sshd would plausibly **accept** — a +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. 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 e78f874..69251ee 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -24,7 +24,9 @@ # 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), duplicate identical key line. +# 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. parse_users_file() { local path="$1" local -a errs=() out=() rlist=() @@ -44,6 +46,14 @@ parse_users_file() { errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)") 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 class policy") continue diff --git a/commands/users-apply.sh b/commands/users-apply.sh index 36977d2..39021bd 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -42,8 +42,12 @@ All passwords stay locked, always — the SSH key at the door is the authentication, and NOPASSWD sudo does not weaken it. Convergent: membership in the three rig-managed groups is made exact (other groups are never touched), authorized_keys becomes exactly the file's keys, and a user dropped -from the file is locked and stripped of the rig groups — home kept, never -deleted. Run as root. +from the file is REVOKED: account expired (which blocks SSH keys too, not +just the password), authorized_keys renamed to authorized_keys.revoked-by-rig, +rig groups stripped — home kept, nothing deleted, and re-adding the user +brings them back. Run as root; under sudo, only rig-admin members may — the +users family changes who holds root, so role rig's scoped sudo does not reach +it. EOF } @@ -76,6 +80,7 @@ PARSED="$(parse_users_file "$FILE")" \ declare -A USER_ROLES=() USER_KEYS=() USERS=() +BOX_USERS=() NEED_SUDO=0 NEED_INCUS=0 while IFS='|' read -r u r k; do @@ -83,6 +88,7 @@ while IFS='|' read -r u r k; do if [ -z "${USER_ROLES[$u]:-}" ]; then USERS+=("$u") USER_ROLES[$u]="$r" + case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac fi USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n' case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac @@ -92,9 +98,19 @@ done <<< "$PARSED" # --- guards ------------------------------------------------------------------ [ "$(id -u)" -eq 0 ] || die "must run as root" +# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule +# is binary-scoped but not argument-scoped, so without this gate a rig-role +# user could run `sudo rig users apply --file ` — the scoped +# grant silently root-equivalent through this very command. Direct root (no +# SUDO_USER: bring-up, a root shell) proceeds. +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \ + && ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then + 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 + # 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 /etc/rig/role)" in +case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in *class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;; *class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;; "") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;; @@ -113,10 +129,22 @@ fi groupadd -f rig-admin groupadd -f rig # rig NEVER installs Incus: box's setup-host owns the daemon and its group. An -# absent incus group means that never ran — refuse with the pointer rather -# than conjure a group the (nonexistent) daemon would never consult. -if [ "$NEED_INCUS" -eq 1 ] && ! getent group incus >/dev/null; then - die "a user carries role box but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" +# absent incus group means that never ran — but what that MEANS is the host= +# trait's call. The box role binds where VMs live; a users file is fleet-wide, +# its box grants are not. So on host=yes an absent group is a broken VM host +# (refuse, point at setup-host), while on host=no it is simply not this box's +# role to converge — skip it, never abort the admins the file also carries. +INCUS_OK=0 +if getent group incus >/dev/null; then INCUS_OK=1; fi +if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then + case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in + *host=yes*) + die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;; + *host=no*) + warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;; + *) + warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;; + esac fi in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } @@ -129,8 +157,10 @@ for u in "${USERS[@]}"; do CHANGED=1 fi # Locked always, created or found: no password ever exists to guess or - # rotate — the SSH key at the door is the authentication. Idempotent. - usermod -L "$u" + # rotate — the SSH key at the door is the authentication. The expiry is + # cleared just as idempotently: revocation below IS an expiry date, so a + # user dropped once and re-added comes back to life on this line. + usermod -L -e '' "$u" # Membership in the three rig-managed groups is made EXACT — added and # removed to match the file. Other groups are never touched: they are not @@ -139,7 +169,10 @@ for u in "${USERS[@]}"; do want="" case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac case ",$roles," in *,rig,*) want="$want rig" ;; esac - case ",$roles," in *,box,*) want="$want incus" ;; esac + # incus joins the wanted set only when the group exists (host=no boxes + # skipped it above): converging membership in a conjured group would hand + # the daemon's arrival an audience it never granted. + case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac for g in rig-admin rig incus; do case " $want " in *" $g "*) @@ -157,43 +190,68 @@ for u in "${USERS[@]}"; do esac done - # authorized_keys becomes exactly the file's keys — cmp-guarded like every - # file rig converges, so an unchanged file is a clean no-op. + # authorized_keys becomes exactly the file's keys — only the content WRITE + # is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode + # converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing + # (a group-writable .ssh is a rejected key), so drifted perms behind + # matching content would otherwise stay broken while apply logs "already + # converged". Perms are part of the converged state. home="$(getent passwd "$u" | cut -d: -f6)" ugroup="$(id -gn "$u")" + mkdir -p "$home/.ssh" AK_TMP="$(mktemp)" printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP" if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then - mkdir -p "$home/.ssh" - chmod 0700 "$home/.ssh" - chown "$u:$ugroup" "$home/.ssh" install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys" log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)" CHANGED=1 fi rm -f "$AK_TMP" + chmod 0700 "$home/.ssh" + chown "$u:$ugroup" "$home/.ssh" + chmod 0600 "$home/.ssh/authorized_keys" + chown "$u:$ugroup" "$home/.ssh/authorized_keys" done # --- previously managed users no longer in the file -------------------------- -# The ledger is what lets a REMOVED user be found at all. Locked, not deleted: -# deleting frees the uid for reuse and orphans file ownership — attribution -# would rot. Home stays for the same reason. +# The ledger is what lets a REMOVED user be found at all — so it must REMEMBER +# them: two-field lines, 'name active' / 'name revoked' (a legacy bare name +# reads as active). Revoked, not deleted: deleting frees the uid for reuse and +# orphans file ownership — attribution would rot. Home stays for the same +# reason. But revoked must actually mean revoked: a '!'-locked password is not +# a closed door under UsePAM — Debian sshd still honors the pubkey — so the +# lock alone left a dropped operator with working SSH. Account expiry (a date +# in the past) is the switch PAM actually enforces, against every auth method +# including keys; the keys themselves are renamed, never deleted — access +# revoked, data kept, convergence never destroys. LEDGER=/etc/rig/users +REVOKED=() if [ -r "$LEDGER" ]; then - while IFS= read -r prev; do + while read -r prev pstate _; do [ -n "$prev" ] || continue case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac id -u "$prev" >/dev/null 2>&1 || continue - usermod -L "$prev" + usermod -L -e 1 "$prev" + prevhome="$(getent passwd "$prev" | cut -d: -f6)" + if [ -f "$prevhome/.ssh/authorized_keys" ]; then + mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig" + fi for g in rig-admin rig incus; do if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi done - warn "$prev is no longer in the file: locked and stripped of the rig groups (home kept — rig never deletes a user)" - CHANGED=1 + REVOKED+=("$prev") + # Warn on the TRANSITION only: an already-revoked user is converged above + # (quietly — repairing drift, not announcing news) so a second identical + # run stays a clean no-op. + if [ "${pstate:-active}" != "revoked" ]; then + warn "$prev is no longer in the file: account expired (blocks SSH keys too, not just the password), authorized_keys renamed to authorized_keys.revoked-by-rig, rig groups stripped (home kept — rig never deletes a user)" + CHANGED=1 + fi done < "$LEDGER" fi LEDGER_TMP="$(mktemp)" -if [ "${#USERS[@]}" -gt 0 ]; then printf '%s\n' "${USERS[@]}" > "$LEDGER_TMP"; fi +if [ "${#USERS[@]}" -gt 0 ]; then printf '%s active\n' "${USERS[@]}" > "$LEDGER_TMP"; fi +if [ "${#REVOKED[@]}" -gt 0 ]; then printf '%s revoked\n' "${REVOKED[@]}" >> "$LEDGER_TMP"; fi if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then mkdir -p /etc/rig install -m 0644 "$LEDGER_TMP" "$LEDGER" diff --git a/commands/users-close-root.sh b/commands/users-close-root.sh index 9dc6eee..093ebe5 100755 --- a/commands/users-close-root.sh +++ b/commands/users-close-root.sh @@ -26,8 +26,11 @@ 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 working authorized_keys (run rig users apply first; never close -the only door). +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. Before running, verify your admin login in a SEPARATE session — `ssh @` while this one stays open. Root SSH is the door being welded @@ -48,6 +51,16 @@ done # --- guards ------------------------------------------------------------------ [ "$(id -u)" -eq 0 ] || die "must run as root" +# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule +# is binary-scoped but not argument-scoped, so without this gate a rig-role +# user could reshape who enters this box as whom — the scoped grant silently +# root-equivalent through the users family. Direct root (no SUDO_USER: +# bring-up, a root shell) proceeds. +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \ + && ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then + 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 + # Marker gate — the policy lives in assert_marker_human (lib) so the harness # can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER # exists for the same reason: it keeps the command's own gate pointable at @@ -56,18 +69,69 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then die "$WHY" fi -# Admin-door gate — never close the only door. Root SSH goes away below, so at -# least one rig-admin member must already hold a non-empty authorized_keys: -# "verified in a separate session" cannot be automated, but a key at the door -# can be, and its absence is proof enough to stop. +# 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: +# 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. +today=$(( $(date +%s) / 86400 )) +# path_strict