diff --git a/CHANGELOG.md b/CHANGELOG.md index 4458365..e2e4c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,44 @@ on the way to cutting its first release, and this file starts there. `incus-admin` member is warned, not fatal — `box grant` refuses them today, which heavy-duty/box#99 fixes box-side with no rig change needed. +### Changed + +- **BREAKING: `rig bootstrap` takes the users file, and requires it** (#51) — + bootstrap already knew everything else about what a box *is* (class, host, + join, hostname) and wrote `/etc/rig/role` to say so; the users file was the + last piece of that answer it did not take, so bring-up was two commands and + the second was the forgettable one. `--users ` now runs the `users + apply` convergence as bootstrap's **final phase** — after the traits, after + the verified tailnet join, after the role marker (apply *reads* that + marker), and after the `host=yes` box install (so box-role users find the + `incus` group box's own `setup-host` built). One command, and the box has + its people on it. The file is still passed per invocation and **never + persisted**; `--users -` is refused, because bootstrap's stdin belongs to + the pre-auth key prompt. + + **Migration: every existing `rig bootstrap` invocation must add `--users + ` or `--no-users`.** Omitting both is now a usage error (exit 2) + naming both flags, and passing both is a usage error too. Scripted + bring-up that already ran `rig users apply` as a separate step can either + fold it in (`--users ./users`, and drop the separate call) or keep the old + shape verbatim by adding `--no-users`. Required on `class=server` as well + as `class=human`: a server nobody logs into routinely is exactly where + shared-root access rots, and per-human accounts keep attribution intact + for the times someone does go in — so the complete path is the default + path, and skipping it is deliberate rather than an omission that looks + identical to forgetting. The box TENANT roles (`claude|codex|grok| + staging`) take neither flag: a guest is minted non-interactively by box, + never joins the tailnet, and has no SSH door of its own — entry is `box + shell`, gated by the host's `incus` grants. + + A bad users file is caught **up front** now (the same parser apply uses, + before `apt`, the hostname change, and any spent pre-auth key), and on + `host=yes` with `RIG_SKIP_BOX_INSTALL=1` a box-role user with no `incus` + group refuses immediately instead of a hundred lines later — the one case + where the outcome is already certain. rig still never installs Incus and + never calls `box setup-host` on its own account; every other way that step + can fail lands in `users apply`'s existing refusal, unchanged. + ### Fixed - **A `host=no` box with an `incus` group no longer hands out the bare diff --git a/README.md b/README.md index 8e655d1..b5e5b41 100644 --- a/README.md +++ b/README.md @@ -98,21 +98,76 @@ second run changes nothing. (The box TENANT roles — `claude`, `codex`, tenants* below.) ```sh -rig bootstrap control-plane --hostname my-coolify-box -rig bootstrap workload --hostname my-prod-box -rig bootstrap runner --hostname my-ci-box -rig bootstrap dev --hostname my-dev-box -rig bootstrap workstation --hostname my-laptop -rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey +rig bootstrap control-plane --hostname my-coolify-box --users ./users +rig bootstrap workload --hostname my-prod-box --users ./users +rig bootstrap runner --hostname my-ci-box --users ./users +rig bootstrap dev --hostname my-dev-box --users ./users +rig bootstrap workstation --hostname my-laptop --users ./users +rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey --users ./users ``` +- `--users ` / `--no-users` — **required**, one or the other: the users + file this box's operators come from, converged as bootstrap's last phase + (see *One command, box ready* below) - `--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) + the users phase (see *The identity model* below) - `--host ` — does this box host VMs (box/Incus) - `--join ` — how it enters the tailnet +#### One command, box ready — `--users` is required + +`rig bootstrap` already knows everything else about what a box *is* — class, +host, join, hostname — and writes `/etc/rig/role` to say so. The users file +was the last piece of that answer it did not take, so bring-up was two +commands and the second one was easy to forget. Now it takes it, and +**requires** it: + +```sh +rig bootstrap dev --hostname my-dev-box --users ./users # one command, people included +rig bootstrap dev --hostname my-dev-box --no-users # deliberately root-only +``` + +`--users ` runs exactly what `rig users apply --file ` runs, as +bootstrap's **final phase** — after the traits are set, after the tailnet +join is verified, and after `/etc/rig/role` is written, because apply *reads* +that marker (`class=` picks its root-SSH note, `host=` decides what a missing +`incus` group means). On a `host=yes` box it also lands after the `box` +install, so box-role users find the `incus` group box's `setup-host` built. +The file is passed per invocation and **never persisted** — bootstrap reads +it through apply and keeps nothing; `--users -` is refused, because +bootstrap's stdin belongs to the pre-auth key prompt. + +Required on **every** role, `class=server` included. A bootstrapped box with +no users converges to a box only root can enter — on `class=human` a +half-built machine, and on `class=server` something worse than half-built: a +machine nobody logs into routinely is exactly where shared-root access rots, +and per-human accounts keep attribution intact for the times someone does go +in. So the complete path is the default path, and skipping it is a deliberate +`--no-users` rather than an omission that looks identical to forgetting. +Omitting both is a usage error naming both flags; passing both is a usage +error too — rig will not silently pick a winner. + +A bad users file is caught **up front**, in the same breath as a bad +`--class`: bootstrap pre-flights it with the same parser apply uses, before +`apt`, before the hostname change, and before a single-use pre-auth key is +spent. And on `host=yes` with `RIG_SKIP_BOX_INSTALL=1`, a box-role user with +no `incus` group refuses immediately rather than a hundred lines later — +that is the one case where the outcome is already certain, since the run has +been told it will not install box. rig still **never** installs Incus or runs +`box setup-host` on its own account; the box CLI's own installer does that +(see the `host` trait), and every other way that step can fail lands in +apply's existing refusal at the end. + +`--users` does **not** reach the box TENANT roles (`claude`, `codex`, `grok`, +`staging`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at +mint, non-interactively, with no file to hand it; the guest never joins the +tailnet and has no SSH door of its own — you enter with `box shell`, gated by +the **host's** `incus` grants, which the host's own users file already +converged. A fleet-wide operator file has nothing to converge in there, and +requiring one would break the mint-time path outright. + **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 @@ -332,7 +387,8 @@ files, so a PATH export alone is invisible to it. prompt, in the shipped script). The one creds-holding step a staging guest eventually needs — the tailnet workload join — stays **operator-run**, exactly as box#69 designed it: `box shell` → `sudo rig bootstrap workload --hostname -` with a single-use tagged pre-auth key. After that join, re-running + --users ` (or `--no-users` — a guest's door is `box shell`, gated +by the host's grants) with a single-use tagged pre-auth key. After that join, re-running `rig bootstrap staging` still converges docker + hardening and leaves the workload marker alone — the machine role is the truer statement of what the box became. @@ -581,7 +637,7 @@ Runner box only, run after `rig bootstrap runner` (the same two-step rhythm as `bootstrap control-plane` → `coolify install`): ```sh -rig bootstrap runner --hostname my-ci-box +rig bootstrap runner --hostname my-ci-box --users ./users rig runner install --repo acme/widgets ``` @@ -712,6 +768,10 @@ 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". +This is also what `rig bootstrap --users ` runs as its last phase, so on +a fresh box you rarely call it by hand — it is the *re-converge* verb (a key +added, an operator revoked, a `--no-users` box growing people later). + ``` # user roles ssh public key dan admin,box ssh-ed25519 AAAA... dan@laptop diff --git a/bin/rig b/bin/rig index 98a2190..0af2016 100755 --- a/bin/rig +++ b/bin/rig @@ -14,10 +14,16 @@ usage: rig [args] commands: bootstrap + (--users | --no-users) [--hostname ] [--class ] [--host ] [--join ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, - tailscale join. Roles are presets over the three traits; any flag + tailscale join, then this box's operators. --users is REQUIRED on + every role — it converges the users file as bootstrap's last phase + (identical to 'rig users apply --file ', marker written first) + so one command leaves a box with its people on it; --no-users is the + deliberate opt-out and leaves root as the only door. The tenant roles + below take neither. 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 — diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index ab2626d..e0b31dd 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -8,6 +8,13 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" . "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap # shellcheck source=SCRIPTDIR/lib/sshd.sh . "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant +# shellcheck source=SCRIPTDIR/lib/users-config.sh +. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only +# The users lib is sourced for validation, never for convergence: `users apply` +# stays the single owner of what a users file DOES to a box (#51). Bootstrap +# borrows the parser so a typo'd users file is caught in the same breath as a +# bad --class — before apt, before the tailnet join, before a pre-auth key is +# spent — instead of at the very end of a run the operator already paid for. log() { printf 'rig-bootstrap: %s\n' "$*"; } warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } @@ -16,19 +23,40 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' usage: rig bootstrap + (--users | --no-users) [--hostname ] [--class ] [--host ] [--join ] rig bootstrap [--user ] - (the box TENANT roles — see their own --help) + (the box TENANT roles — see their own --help; they take + no --users, see below) + --users the users file this box's operators come from — REQUIRED. It is + applied as bootstrap's last phase, exactly as `rig users apply + --file ` would, so one command leaves a box with its + people on it. Passed per invocation and never persisted. + --no-users the deliberate opt-out — bootstrap converges the OS and the + tailnet and leaves the box with root as its only door. --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. + the users phase: human closes it (`rig users close-root`), + 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 +One of --users/--no-users is required on every role, class=server included: +a box nobody logs into routinely is exactly where shared-root access rots, +and per-human accounts keep attribution intact for the times someone does go +in. So the complete path is the default path and skipping it is a deliberate +--no-users, not an omission. + +--users does NOT reach the box TENANT roles (claude|codex|grok|staging). A +tenant is a box-minted GUEST: box auto-runs its bootstrap at mint, +non-interactively, with no file to hand it; the guest never joins the tailnet +and has no SSH door of its own — entry is `box shell`, gated by the HOST's +incus grants, which the host's own users file already converged. A fleet-wide +operator file has nothing to converge in there. + Roles are presets over the three traits; any flag overrides its trait. custom presets nothing and requires --hostname plus all three traits. @@ -92,8 +120,15 @@ esac # custom has no hostname default: a made-up name on a made-up shape helps nobody. TS_HOSTNAME="$ROLE" [ "$ROLE" = "custom" ] && TS_HOSTNAME="" +USERS_FILE="" +NO_USERS=0 while [ $# -gt 0 ]; do case "$1" in + --users) + [ $# -ge 2 ] || die "--users needs a value" 2 + USERS_FILE="$2"; shift 2 ;; + --no-users) + NO_USERS=1; shift ;; --hostname) [ $# -ge 2 ] || die "--hostname needs a value" 2 TS_HOSTNAME="$2"; shift 2 ;; @@ -150,6 +185,90 @@ if [ "$JOIN" = "login" ] && [ -n "${TS_AUTHKEY:-}" ]; then die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2 fi +# --- who lives here (#51) ----------------------------------------------------- +# The users file is the last piece of "what this box is" that bootstrap did not +# take, and it is REQUIRED rather than optional: a bootstrapped box with no +# users converges to a box only root can enter, and on class=human that is a +# half-built machine waiting for a second command the operator has to remember +# (`rig users close-root` is itself gated behind "once your admin key works" — +# which needs an admin to exist). class=server gets the same requirement on +# purpose: a server nobody logs into routinely is exactly where shared-root +# access rots, and per-human accounts keep attribution intact for the times +# someone does go in. +# +# The opt-out is a FLAG, not a default. Both states are then something the +# operator said out loud, which is the whole point — an omitted --users used to +# be indistinguishable from "I meant to and forgot", and the box that resulted +# looked identical either way. Contradicting yourself is a usage error too: +# --users and --no-users together is not a precedence puzzle rig should silently +# resolve, because whichever way it resolved would be the wrong one half the +# time. +if [ -n "$USERS_FILE" ] && [ "$NO_USERS" -eq 1 ]; then + die "--users and --no-users are contradictory: pass the file, or say --no-users, not both" 2 +fi +if [ -z "$USERS_FILE" ] && [ "$NO_USERS" -eq 0 ]; then + die "one of --users or --no-users is required: bootstrap converges this box's operators as its last phase, and a box with no named users is one only root can enter. Pass --users , or --no-users to leave it root-only deliberately" 2 +fi + +# The users file is PRE-FLIGHTED here and applied at the very end: everything +# below this point costs the operator something — apt, a hostname change, a +# single-use pre-auth key — and a users file with a typo in it must not be +# discovered after all of that was already spent. Same reason every other flag +# is validated before the root check: errors belong at the top of the run. +USERS_HAS_BOX_ROLE=0 +if [ -n "$USERS_FILE" ]; then + # '-' (stdin) is apply's own convenience and cannot survive the trip through + # bootstrap: stdin here belongs to the pre-auth key prompt, and a users file + # piped in would either eat that prompt or be eaten by it. Refuse the token + # rather than let the two credentials-shaped reads fight over one pipe. + [ "$USERS_FILE" != "-" ] \ + || die "--users needs a real path: bootstrap's stdin is the pre-auth key prompt's, so it cannot also carry the users file. Write it to a file, or run 'rig users apply --file -' separately after --no-users" 2 + [ -r "$USERS_FILE" ] || die "cannot read users file: $USERS_FILE" 2 + if ! USERS_PARSED="$(parse_users_file "$USERS_FILE")"; then + die "invalid users file: $USERS_FILE — every error is listed above; nothing was changed" 2 + fi + # Does anyone in the file carry role box? That single fact decides whether the + # incus precondition below applies at all — a users file naming only admins + # converges perfectly well on a host that has never seen Incus, and refusing + # it there would be rig inventing a prerequisite its own apply does not have. + if printf '%s\n' "$USERS_PARSED" | cut -d'|' -f2 | grep -qE '(^|,)box(,|$)'; then + USERS_HAS_BOX_ROLE=1 + fi +fi + +# host=yes + a box-role user + no incus group = the refusal `users apply` already +# owns ("rig NEVER installs Incus: box's setup-host owns the daemon and its +# group"). rig does not build that stack here and does not call setup-host — +# whether rig should install box on a VM host is an open boundary question, and +# resolving it by accident inside a users change would be the worst way to +# answer it. What bootstrap CAN do is stop early instead of late. +# +# Early only where the outcome is already PROVEN, though. The ordinary host=yes +# path installs box further down, and box's own installer runs setup-host — so +# the group that is missing now will exist by the time the users phase runs, and +# an unconditional refusal here would reject the exact bring-up this issue is +# about. RIG_SKIP_BOX_INSTALL=1 is the one case with no such rescue: the +# operator has said this run will not touch box, so the group's absence is final +# and the run is doomed a hundred lines before it notices. The other failure +# shapes (no network, box's installer breaking) are not knowable this early and +# land in apply's own refusal at the end — the same message, one phase later. +# +# #49 (merged) added a SECOND host-level refusal to apply: the box CLI itself +# missing on host=yes, because the group is only the socket and the tier is +# `box grant`, which apply calls rather than reimplements. Under +# RIG_SKIP_BOX_INSTALL=1 that absence is just as final and just as knowable +# now as the group's, so the early check mirrors both rather than being a +# weaker proxy for one of them. Either one alone dooms the run. +if [ "$USERS_HAS_BOX_ROLE" -eq 1 ] && [ "$HOST" = "yes" ] \ + && [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then + if ! getent group incus >/dev/null 2>&1; then + die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent and RIG_SKIP_BOX_INSTALL=1 means this run will not install box — install the box CLI and run 'box setup-host' first; rig never installs Incus. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2 + fi + if ! command -v box >/dev/null 2>&1; then + die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH and RIG_SKIP_BOX_INSTALL=1 means this run will not install it — the incus group is only the socket; the restricted tier is 'box grant', which rig calls rather than reimplements. Install the box CLI first. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2 + fi +fi + # --- guards ------------------------------------------------------------------ [ "$(id -u)" -eq 0 ] || die "must run as root" if [ -r /etc/os-release ]; then @@ -520,6 +639,38 @@ if [ "$HOST" = "yes" ]; then fi fi +# --- users (the last phase, and it must be last) ------------------------------- +# Ordering is a correctness property, not a preference. `users apply` READS +# /etc/rig/role: class= decides which root-SSH note it prints, and host= decides +# what an absent incus group means (refuse on yes, skip the box role with a +# warning on no). Run before the marker write, apply would see no marker at all +# and warn "re-run rig bootstrap so this box knows what it is" — in the middle +# of the very bootstrap that is teaching it. Run before the box install, a +# host=yes box would refuse its own box-role users for a group arriving twenty +# lines later. So: traits, then join, then marker, then box, then people. +# +# NOT persisted. rig takes the path, reads it once through apply, and keeps +# nothing — the users file lives in your private infra repo and is passed per +# invocation (README: "rig never persists it"). Taking it as a bootstrap flag +# must not quietly turn it into box state, so nothing here copies it anywhere, +# and /etc/rig keeps only the ledger of NAMES apply already wrote. +# +# Invoked as a child, not exec'd: bootstrap still owns the last word (the +# next-steps below), and a failing apply must fail the bootstrap — under +# `set -e` a non-zero apply ends the run right here, which is correct. The box +# is already hardened and joined at this point; what failed is one named phase, +# and it is re-runnable on its own with `rig users apply --file `. +# +# A child also keeps apply's INVOKER gate intact, which is the point: SUDO_USER +# rides through, so `sudo rig bootstrap --users ` by a +# role-rig user refuses exactly as `sudo rig users apply` would. Bootstrap must +# not become a laundering path around the one gate that stops rig's scoped sudo +# from being root-equivalent. Bring-up runs as real root and is unaffected. +if [ -n "$USERS_FILE" ]; then + log "converging operators from ${USERS_FILE} (rig users apply)" + "$HERE/users-apply.sh" --file "$USERS_FILE" +fi + log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " @@ -528,9 +679,19 @@ elif [ "$ROLE" = "runner" ]; then 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" +# SSH's fate once named users exist. With --users the accounts exist already, so +# the note that used to point at the missing command now points at what is left +# to do; --no-users still owes the box its people, and says so. +if [ -n "$USERS_FILE" ]; then + if [ "$CLASS" = "human" ]; then + log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST" + else + log "operators are converged; root SSH stays — it is the control plane's automation door" + fi else - log "next: rig users apply --file for named operator logins; root SSH stays — it is the control plane's automation door" + if [ "$CLASS" = "human" ]; then + log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file , then 'rig users close-root' once your admin key works" + else + log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file " + fi fi diff --git a/test/cli.sh b/test/cli.sh index 6b6703a..9390f09 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -168,6 +168,169 @@ check "bootstrap: the claim follows the doctor gate" \ 0 "" test "${doctor_at:-999999}" -lt "${claim_at:-0}" check "bootstrap: a failed doctor warns without claiming the host" 0 "" \ grep -q "the CLI landed, the host stack is unproven" "$ROOT/commands/bootstrap.sh" +# --- the users phase (#51): --users is required, --no-users is the opt-out ---- +# Bootstrap takes the users file and applies it as its LAST phase, so one +# command leaves a box with its people on it. Everything about the FLAG is +# provable here — the whole surface sits before the root check, deliberately, +# because a users file with a typo must not be discovered after apt, a hostname +# change and a spent pre-auth key. +BOOT_USERS="$(mktemp -d)" +cat > "$BOOT_USERS/ok" <<'USERS' +dan admin ssh-ed25519 AAAAC3fixture dan@laptop +maria rig ssh-ed25519 AAAAC3fixture maria@mac +USERS +printf '%s\n' 'dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop' > "$BOOT_USERS/box" +printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$BOOT_USERS/bad" +# The REQUIREMENT, and the message that carries it: omitting both flags must +# name BOTH ways out, because an operator who forgot the file and one who meant +# to skip it type the identical command — the error is the only place rig can +# tell them apart. +check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users or --no-users is required" \ + "$ROOT/commands/bootstrap.sh" workload +check "bootstrap: the requirement names --no-users as the way out" 2 "--no-users to leave it root-only" \ + "$ROOT/commands/bootstrap.sh" dev --hostname b +check "bootstrap: the requirement holds on class=server too" 2 "one of --users" \ + "$ROOT/commands/bootstrap.sh" control-plane --hostname cp +check "bootstrap: --users needs a value" 2 "needs a value" \ + "$ROOT/commands/bootstrap.sh" workload --users +# MUTUAL EXCLUSION, both orders: rig refuses to pick a winner rather than let a +# precedence rule decide who may enter the box. Both orders, because a +# "last flag wins" implementation would pass one of them silently. +check "bootstrap: --users with --no-users exits 2" 2 "contradictory" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok" --no-users +check "bootstrap: --no-users with --users exits 2 (either order)" 2 "contradictory" \ + "$ROOT/commands/bootstrap.sh" workload --no-users --users "$BOOT_USERS/ok" +# Pre-flight: an unreadable or invalid file dies at the top of the run, exit 2, +# before the root check — the same contract every other flag here has. +check "bootstrap: an unreadable users file exits 2" 2 "cannot read users file" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/nope" +check "bootstrap: an invalid users file exits 2 with the parser's errors" 2 "invalid users file" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/bad" +check "bootstrap: the invalid-file refusal carries the parser's own line error" 2 "valid roles: admin rig box" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/bad" +# '-' is apply's stdin convenience and cannot survive the trip through +# bootstrap: stdin here is the pre-auth key prompt's. Refused, with the split +# ('--no-users' then apply by hand) named. +check "bootstrap: --users - is refused, naming the pre-auth key prompt" 2 "pre-auth key prompt" \ + "$ROOT/commands/bootstrap.sh" workload --users - +# The host=yes box-role precondition, surfaced EARLY — but only where the +# outcome is already proven: RIG_SKIP_BOX_INSTALL=1 means this run will not +# install box, so a missing incus group can no longer be rescued by the install +# further down. The group's presence is a property of whatever machine runs +# this harness, so it is driven with a shim `getent` instead — both directions, +# on any machine (repo precedent: the install.sh getent shim below). +# +# The box CLI's presence is the SECOND half of the precondition (#49 merged a +# matching die into apply), and it is a property of the runner in exactly the +# same way — this machine happens to have box on PATH, a CI runner may not. So +# it is shimmed in both directions too, and `box` is deliberately NOT inherited +# from the real PATH in these runs: a test that passes only where box happens +# to be installed proves nothing about the machine where it isn't. +INCUS_SHIM_NO="$BOOT_USERS/shim-no"; INCUS_SHIM_YES="$BOOT_USERS/shim-yes" +BOXLESS_SHIM="$BOOT_USERS/shim-nobox" +mkdir -p "$INCUS_SHIM_NO" "$INCUS_SHIM_YES" "$BOXLESS_SHIM" +# Answer only the `group incus` question; everything else falls through to the +# real getent, so the shim cannot quietly change some other lookup's answer. +cat > "$INCUS_SHIM_NO/getent" <<'SHIM' +#!/bin/sh +if [ "$1" = group ] && [ "$2" = incus ]; then exit 2; fi +exec /usr/bin/getent "$@" +SHIM +cat > "$INCUS_SHIM_YES/getent" <<'SHIM' +#!/bin/sh +if [ "$1" = group ] && [ "$2" = incus ]; then echo "incus:x:900:"; exit 0; fi +exec /usr/bin/getent "$@" +SHIM +# Group present, box absent — the shape #49's die now owns, and the one the +# old group-only precondition let through to fail a hundred lines later. +cat > "$BOXLESS_SHIM/getent" <<'SHIM' +#!/bin/sh +if [ "$1" = group ] && [ "$2" = incus ]; then echo "incus:x:900:"; exit 0; fi +exec /usr/bin/getent "$@" +SHIM +# A `box` that exists, for the satisfied case — so INCUS_SHIM_YES proves the +# precondition passes on its own terms rather than on the runner's luck. +printf '#!/bin/sh\nexit 0\n' > "$INCUS_SHIM_YES/box" +chmod +x "$INCUS_SHIM_NO/getent" "$INCUS_SHIM_YES/getent" \ + "$BOXLESS_SHIM/getent" "$INCUS_SHIM_YES/box" +check "bootstrap: host=yes + box role + no incus + skipped box install exits 2" 2 "group incus is absent" \ + env RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box" +check "bootstrap: that refusal points at box setup-host, not at rig" 2 "rig never installs Incus" \ + env RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box" +# The group can be there while the CLI is not — #49's die owns that shape, and +# under the skip it is just as final and just as knowable now. PATH is built +# WITHOUT the real one so the absence is the test's, not the machine's. +check "bootstrap: host=yes + box role + incus group + no box CLI + skip exits 2" 2 "box CLI is not on PATH" \ + env RIG_SKIP_BOX_INSTALL=1 PATH="$BOXLESS_SHIM:/usr/bin:/bin" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box" +check "bootstrap: that refusal names the tier, not just the socket" 2 "the restricted tier is 'box grant'" \ + env RIG_SKIP_BOX_INSTALL=1 PATH="$BOXLESS_SHIM:/usr/bin:/bin" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box" +if [ "$(id -u)" -ne 0 ]; then + # It must NOT fire in the three shapes that are not doomed. A users file with + # no box-role user converges fine on a host that never saw Incus (refusing it + # would be rig inventing a prerequisite apply does not have); an incus group + # that exists satisfies it outright; and WITHOUT RIG_SKIP_BOX_INSTALL the + # missing group is the box install's to create further down — refusing there + # would reject the exact one-command bring-up this flag is for. Reaching the + # root check (exit 1) is the proof each passed the precondition. + check "bootstrap: no box-role user means no incus precondition" 1 "must run as root" \ + env TS_AUTHKEY=x RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/ok" + check "bootstrap: an existing incus group satisfies the precondition" 1 "must run as root" \ + env TS_AUTHKEY=x RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_YES:$PATH" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box" + check "bootstrap: without the skip, the box install is left to create the group" 1 "must run as root" \ + env TS_AUTHKEY=x PATH="$INCUS_SHIM_NO:$PATH" \ + "$ROOT/commands/bootstrap.sh" dev --hostname h --users "$BOOT_USERS/box" + # host=no is the other side of apply's host= rule — the box role is skipped + # with a warning there, never refused, so bootstrap must not refuse it either. + check "bootstrap: host=no never gets the incus precondition" 1 "must run as root" \ + env TS_AUTHKEY=x RIG_SKIP_BOX_INSTALL=1 PATH="$INCUS_SHIM_NO:$PATH" \ + "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/box" +fi +# rig does NOT resolve the open "should rig install box" question here: the +# precondition refuses, it never calls setup-host itself. A grep that finds +# nothing (exit 1) is the pass — same shape as the never-apt-install-incus law. +check "bootstrap: the users phase never runs box setup-host itself" 1 "" \ + grep -nE '^[[:space:]]*box setup-host' "$ROOT/commands/bootstrap.sh" +# ORDERING is a correctness property, not taste: apply READS /etc/rig/role +# (class= picks its root-SSH note, host= decides what a missing incus group +# means), and on host=yes it needs the group box's installer built. So the +# users phase must sit after BOTH the marker write and the box install. Line +# numbers, same idiom as the marker/box-install ordering asserts above; +# defaults fail closed. The apply call is grepped as a literal — single quotes +# intended, $HERE/$USERS_FILE are the script's own. +# shellcheck disable=SC2016 +users_apply_at="$(grep -n '"$HERE/users-apply.sh" --file "$USERS_FILE"' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)" +check "bootstrap: the users phase invokes users apply" 0 "" \ + test -n "$users_apply_at" +check "bootstrap: the users phase runs after the role marker write" \ + 0 "" test "${box_marker_at:-999999}" -lt "${users_apply_at:-0}" +check "bootstrap: the users phase runs after the box install" \ + 0 "" test "${box_install_at:-999999}" -lt "${users_apply_at:-0}" +# The users file is passed per invocation and NEVER persisted (README: "rig +# never persists it"). Taking it as a bootstrap flag must not quietly turn it +# into box state, so nothing may copy it anywhere. A grep that finds nothing +# (exit 1) is the pass — same shape as the never-apt-install-incus law. +check "bootstrap: the users file is never copied onto the box" 1 "" \ + grep -nE '^[[:space:]]*(cp|install|mv|tee|cat)[[:space:]].*USERS_FILE' "$ROOT/commands/bootstrap.sh" +# Usage must carry both flags: an operator hitting the new requirement reads +# --help next, and finding only --users there would leave the opt-out a secret. +check "bootstrap: usage documents --users" 0 "--users" "$ROOT/commands/bootstrap.sh" --help +check "bootstrap: usage documents --no-users" 0 "--no-users" "$ROOT/commands/bootstrap.sh" --help +check "rig usage documents the bootstrap users flags" 0 "(--users | --no-users)" \ + "$ROOT/bin/rig" --help +# The TENANT family takes neither flag. Dispatch happens before this parser +# runs, so --users lands in the tenant script's own unknown-flag refusal — the +# decision (a box-minted guest has no SSH door of its own; entry is `box shell`, +# gated by the HOST's incus grants) is documented in usage and the README. +check "bootstrap: --users does not reach the tenant roles" 2 "unknown flag" \ + "$ROOT/commands/bootstrap.sh" claude --users "$BOOT_USERS/ok" +check "bootstrap: usage explains why tenants take no --users" 0 "box-minted GUEST" \ + "$ROOT/commands/bootstrap.sh" --help # --- README: the box rename (#12) -------------------------------------------- # The philosophy line must point at heavy-duty/box — the old claudebox slug # only works through a GitHub redirect that one squatted rename away from @@ -192,8 +355,14 @@ check "README: documents the mismatch strip on host=no" 0 "" \ check "README: no stale 'group absent decides' semantics" 1 "" \ grep -n "when the \`incus\` group is absent, the \`host=\` trait decides" "$ROOT/README.md" 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 + # Every machine-role invocation now states its users answer — the flag is + # required (#51), so reaching the root check at all proves it was accepted. + # --no-users here keeps these asserts about the ROOT CHECK; the --users path + # gets its own root-check assert below, against a valid fixture. + check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --no-users + check "bootstrap: --users file reaches the root check" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok" + check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner --no-users # staging dispatches to the tenant mechanism now; reaching ITS root check # through bootstrap.sh proves the dispatch and the tenant arg pass in one go. # RIG_ROLE_MARKER points at an absent fixture: the tenant marker guard runs @@ -201,10 +370,10 @@ if [ "$(id -u)" -ne 0 ]; then # a real /etc/rig/role of its own. check "bootstrap: staging dispatches to the tenant mechanism, refuses non-root" 1 "must run as root" \ env RIG_ROLE_MARKER=/nonexistent/rig-role "$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: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --no-users + check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation --no-users 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 + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey --no-users else echo "skip: bootstrap non-root refusals (running as root)" fi