diff --git a/README.md b/README.md index 3a69ad1..6d9b465 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ A CLI that turns a **pristine Debian server into a hardened, tailnet-joined node** — one curl, one command. A second command installs a version-pinned -Coolify on a control-plane box. +Coolify on a control-plane box. And inside a [box](https://github.com/heavy-duty/box)-minted +guest, the same verb converges the **box tenants** — claude, codex, grok, +staging — from thin, creds-free seeds (see *the box tenants* below). Philosophy (shared with [box](https://github.com/heavy-duty/box)): **public tool, private state**. rig carries plumbing logic only — no @@ -20,19 +22,20 @@ 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. +second run changes nothing. (The box TENANT roles — `claude`, `codex`, +`grok`, `staging` — share the verb but are their own family; see *the box +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 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 +rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey ``` - `--hostname ` — system + tailnet hostname (default: the role name; @@ -59,10 +62,20 @@ presets nothing and requires `--hostname` plus all three traits. | `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 | +> **Where the `staging` preset went.** Before #31, `staging` was the VM-host +> preset (`class=server host=yes join=authkey`). The name now belongs to the +> box TENANT family — the *guest*, not the host under it — because that is +> what "staging" mostly names in practice (the box the control plane will +> manage), and two meanings on one word was the worse bug. The host shape +> lost nothing: it is one flag away — `rig bootstrap dev --class server +> --hostname my-vm-host` — or fully spelled as `custom --class server --host +> yes --join authkey`. Tag policy is unchanged: mint the host's key with +> `tag:local`; an effective `tag:server` is refused on every role but +> `control-plane` and `workload`. + 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 @@ -160,19 +173,20 @@ 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. -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`. +The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by +the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside +with the tenant roles and (for staging guests) `rig bootstrap workload` — +rides the traits since #31 (`--class server --host yes --join authkey`; see +the note above). 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 an effective **`tag:server` is +refused**, same mechanism as `runner`. On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving a to-do: after the role marker is written it **installs the `box` CLI globally and runs box's own `setup-host`**, so the Incus stack is ready for -`box new --template staging` when bootstrap returns. rig **delegates to box; it +`box new` when bootstrap returns. rig **delegates to box; it never touches Incus itself** — it does not `apt-get install incus`, does not configure the daemon, does not create the `incus` group. It runs box's global installer (`curl … | BOX_YES=1 bash`) as root, and box installs Incus via its @@ -200,11 +214,88 @@ merges box's root install lands in `/root`.) > fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you > manage by hand. -`dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local` -shape with a person living on it, box CLI installed the same way — 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. +`dev` is the human-class VM-hosting shape — `tag:local`, box CLI installed as +above, a person living on it (`--class server` turns it into the unattended +VM-host appliance) — 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. + +### `rig bootstrap ` — the box tenants + +Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted +guest. Convergent — safe to re-run; a second run changes nothing. + +```sh +rig bootstrap claude # or codex, grok — the agent tenants +rig bootstrap staging # the server tenant (docker + sshd hardening) +rig bootstrap claude --user dev # when the seed's BOX_USER differs +``` + +**The layering** (rig#31 ↔ box#81): a box template stops being where tenant +content lives. box mints a **thin, creds-free seed** — base image, the +`BOX_USER`, rig (+ tmux) preinstalled, and nothing that joins or admits — and +everything the guest *becomes* is a rig tenant role. cloud-init is a first-boot +one-shot: not convergent, not re-runnable, and only parse-and-grep testable. +rig roles are idempotent scripts with effective-state asserts, driven by the +same harness as everything else — and re-runnable on an *existing* box to +converge it to a new spec instead of re-minting it. One convergence engine; +the guests were the hole. + +It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh` +holds the whole per-tenant table), not four hand-maintained scripts: + +| tenant | user | what lands | +|-----------|----------|------------| +| `claude` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` | +| `codex` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` | +| `grok` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` | +| `staging` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) | + +Every install is **asserted on effective state**, not exit codes: the CLI must +*answer* (`--version`, run as the tenant user — a CLI that exists but cannot +run has already cost a drill), docker must answer, `sshd -T` must resolve the +hardening. The CLI also lands on the **system** PATH (`/usr/local/bin`): +`box exec -- claude …` runs a non-interactive shell that reads no rc +files, so a PATH export alone is invisible to it. + +**Creds-free and non-interactive, by contract.** box auto-runs these at mint +(`box exec … rig bootstrap claude`), so nothing here prompts, joins, or admits +— no tailnet, no keys (the harness pins this by *absence*: no `tailscale`, no +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 +`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. + +**The agent-context file carries the box#80 guard, once.** Every agent tenant +writes its agent's instructions file (`CLAUDE.md` / `AGENTS.md`), rendered +from one shared template: the creds-free contract, the isolation and +disposability facts, and the guard note — **never run `box setup-host`, +`box teardown-host`, or the drill inside a box; the box you are in is not a +host you own**. A nested box stack claims the guest's own uplink subnet and +silently breaks its networking (box#80). The note lives in +`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was +the point of moving it here. + +**Tenants and the role marker.** A tenant run writes `role= tenant=yes +host=no` — no `class=`, because a guest has no root-door policy of its own +(`rig users close-root` fails closed on it, by design). The guard runs the +other way too: a box already carrying a **machine** role refuses the agent +tenants outright, and *any* tenant refuses a `host=yes` box — a VM host is +the opposite of a guest, and a pre-#31 staging *host* re-running its old +command is exactly who that refusal catches (it names the new spelling). + +> **The rig install in the seed is unpinned — same honesty as the box note +> above.** The seed preinstalls rig via its curl installer, which resolves +> `RIG_REPO`/`RIG_REF` (default `heavy-duty/rig@main`, branches only — rig +> cuts no tags yet). That inverts the install edge on this page: rig installs +> box on host-class machines, and box guests now install rig — both tracking +> a moving `main` until a release flow exists (rig#32). `RIG_REPO`/`RIG_REF` +> are the pin points the day there is something to pin to, or point them at a +> frozen branch of your own fork. The seed side of this edge is box#81's to +> document. ### The identity model @@ -223,10 +314,15 @@ comparison, translated onto the traits that replaced the class binary: | `control-plane` | server | no | authkey | nobody — Coolify runs here | open — the automation door | | `workload` | server | no | authkey | nobody — deployed services run here | open — the automation door | | `runner` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | -| `staging` | server | yes | authkey | nobody — an unattended VM appliance | open — the automation door | | `dev` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` | | `workstation` | human | yes | login | its owner | closed by `rig users close-root` | +(The unattended VM-host appliance — formerly the `staging` preset — is the +`class=server host=yes join=authkey` shape: nobody lives there, root SSH stays +open as the automation door. The box TENANT roles sit outside this table on +purpose: a guest is not a tailnet machine, and its marker carries no `class=`, +so `rig users close-root` fails closed on it.) + Who installs what, and who runs as what: **bootstrap is always root** and installs everything a role needs — on `host=yes` that includes the box CLI (globally) and box's own `setup-host`. **Humans always run as themselves**: @@ -720,7 +816,14 @@ to do" and exits 0. `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. +functions, non-root and network-free. The tenant family follows the same +split: the harness proves the arg/refusal surface, the marker guards (off +fixture markers), the pure parameter table, and the rendered agent-context +file — guard note included — plus absence-greps for the creds-free contract; +the real converge belongs to the rehearsal. 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. The tenant rehearsal +is the same shape, creds-free: container + seed user → `rig bootstrap claude` +/ `staging` → assert the CLI answers, docker answers, `sshd -T`, the context +file — then re-run and watch it no-op. diff --git a/bin/rig b/bin/rig index 56ffc5a..4ee24a3 100755 --- a/bin/rig +++ b/bin/rig @@ -8,7 +8,7 @@ usage() { usage: rig [args] commands: - bootstrap + bootstrap [--hostname ] [--class ] [--host ] [--join ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, @@ -19,6 +19,14 @@ commands: 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. + bootstrap [--user ] + The box TENANT roles: converge a box-minted guest. claude|codex|grok + land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on + the system PATH, and the agent-context file (with the box#80 guard: + never run box setup-host inside a box). staging lands box#69's server + posture — docker + sshd hardening; its tailnet join stays operator-run + via 'rig bootstrap workload'. Creds-free and non-interactive: box + auto-runs these at mint. Run as root, inside the box. coolify install --version Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh new file mode 100755 index 0000000..a09f23b --- /dev/null +++ b/commands/bootstrap-tenant.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env bash +# rig bootstrap — the box TENANT roles: what a +# box-minted guest becomes (issue #31). box mints the thin, creds-free seed +# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the +# tenant content that used to live in the templates' cloud-init, idempotent and +# effective-state asserted, so an EXISTING box can be re-run to a new spec +# instead of re-minted. One mechanism, parameterized per tenant through +# lib/tenant-config.sh — never four hand-maintained copies. +# +# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig +# bootstrap claude'), so every path here is non-interactive and nothing joins +# or admits — no tailnet, no keys, no prompts. staging's tailnet join stays +# operator-run ('rig bootstrap workload' through 'box shell'), exactly the +# creds split box#69 designed. +# Convergent: safe to re-run; a second run changes nothing. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/tenant-config.sh +. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context +# shellcheck source=SCRIPTDIR/lib/users-config.sh +. "$HERE/lib/users-config.sh" # read_role_marker +# shellcheck source=SCRIPTDIR/lib/sshd.sh +. "$HERE/lib/sshd.sh" # harden_sshd (the staging tenant) + +log() { printf 'rig-bootstrap: %s\n' "$*"; } +warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig bootstrap [--user ] + +Box TENANT roles — what a box-minted guest becomes. box mints the thin, +creds-free seed (base image, user, rig preinstalled); this converges the +tenant on top, and re-runs converge an existing box to a new spec. + + claude|codex|grok the agent tenants: base tooling (git, gh, tmux, …), + docker, the agent's CLI on the system PATH, and the + agent-context file — including the box#80 guard: never + run `box setup-host` or the drill inside a box. + staging the server tenant (box#69's posture): docker + sshd + hardening. The tailnet workload join is deliberately + NOT here — it holds a credential, so it stays + operator-run: `box shell` → `sudo rig bootstrap + workload` with a tagged pre-auth key. + + --user the tenant user the box seed created (default: the + role's name; staging defaults to `ops`) + +Tenant roles are creds-free and non-interactive by contract — box auto-runs +them at mint (`box exec … rig bootstrap claude`). They take none of the +machine-role traits (--hostname/--class/--host/--join): a tenant is a guest, +not a tailnet machine. Run as root, inside the box. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +ROLE="${1:-}" +case "$ROLE" in + claude|codex|grok|staging) shift ;; + -h|--help) usage; exit 0 ;; + "") usage >&2; die "tenant role required (claude|codex|grok|staging)" 2 ;; + *) die "unknown tenant role: $ROLE (want claude|codex|grok|staging)" 2 ;; +esac + +TENANT_USER="$(tenant_user "$ROLE")" +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + TENANT_USER="$2"; shift 2 ;; + --hostname|--class|--host|--join) + # The machine-role traits, refused with a story rather than "unknown + # flag": a tenant is a guest, not a tailnet machine — its shape comes + # from the box seed, and the one trait-shaped thing a staging guest + # eventually does (join the tailnet as a workload) is deliberately not + # here: it holds a credential, so it stays operator-run. + die "tenant roles have no traits: $1 belongs to the machine roles (control-plane|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;; + --ts-tag) + [ $# -ge 2 ] && shift + die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', where the tag comes from the pre-auth key." 2 ;; + *) die "unknown flag: $1" 2 ;; + esac +done +# Same charset the users file enforces, for the same reasons (a leading '-' +# reads as a usermod flag; '|', ':' corrupt things downstream). +[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ + || die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2 + +# --- guards ------------------------------------------------------------------ +# A tenant role converges a box GUEST. A box already carrying a machine-role +# marker is a tailnet machine rig built on purpose, and quietly turning it into +# a tenant (or clobbering its marker) is how a fleet box gets poisoned. Checked +# BEFORE the root check so the refusals are testable non-root, off fixture +# markers (repo precedent: the coolify marker warning). Two refusals, one +# tolerance: +# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest. +# Names the staging rename out loud — before #31, `staging` was the VM-host +# PRESET; that shape is now spelled through the traits. +# - class= (agent tenants) → refuse: an agent box is never a tailnet machine. +# - class=server with host=no (staging only) → PROCEED, and leave the marker +# alone: that is the staging guest AFTER its operator-run workload join, and +# re-converging docker+hardening on it is exactly what convergence is for. +# ONLY that shape — any other class (say class=human, via `custom`) is a +# machine rig built on purpose, and staging hardening it with server rules +# would die with server-specific messaging on a box that was never one. +MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}" +EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")" +case "$EXISTING_MARKER" in + *host=yes*) + die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;; + *class=*) + if [ "$ROLE" != "staging" ]; then + die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run." + fi + case "$EXISTING_MARKER" in + *class=server*) ;; + *) + die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging tolerates only the workload-joined guest (class=server host=no). If this really is a staging guest, remove ${MARKER_PATH} and re-run." ;; + esac ;; +esac + +[ "$(id -u)" -eq 0 ] || die "must run as root" +if [ -r /etc/os-release ]; then + # Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. — + # sourcing it in the main shell silently clobbers same-named script vars. + # 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 + +# The tenant user is the SEED's to create (box.env BOX_USER + cloud-init), not +# rig's to conjure: a missing user means the seed and the role disagree, and +# inventing an account here would paper over exactly that mismatch. +id -u "$TENANT_USER" >/dev/null 2>&1 \ + || die "user '$TENANT_USER' does not exist — the box seed creates it (BOX_USER); pass --user if this box's user differs" +TENANT_HOME="$(getent passwd "$TENANT_USER" | cut -d: -f6)" +TENANT_GROUP="$(id -gn "$TENANT_USER")" +[ -d "$TENANT_HOME" ] || die "user '$TENANT_USER' has no home directory ($TENANT_HOME)" + +# append_line_once — converge a literal rc line: present exactly +# once, appended only when missing, ownership converged to the tenant user. +append_line_once() { + local file="$1" line="$2" + if [ ! -e "$file" ] || ! grep -qxF "$line" "$file"; then + printf '%s\n' "$line" >> "$file" + log "appended to ${file}: ${line}" + fi + chown "$TENANT_USER:$TENANT_GROUP" "$file" +} + +# --- packages ---------------------------------------------------------------- +export DEBIAN_FRONTEND=noninteractive +log "installing base packages (tenant ${ROLE})" +apt-get update -qq +case "$ROLE" in + claude) + # The claude tenant keeps zsh (its shell UX ships with the box); the + # remaining list is the shared agent toolbelt the templates carried. + apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;; + codex|grok) + apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;; + staging) + # openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/, + # which only exists once the package is installed — pristine container/VM + # images (and thin seeds) do not ship it. + apt-get install -y -qq curl ca-certificates tmux openssh-server ;; +esac +# Assert the effective toolbelt, not apt's exit code — tmux is the box#65 +# contract ('box tmux' runs tmux new-session inside every box) and gh is how +# the operator's git credential lands. +command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it" +if [ "$ROLE" != "staging" ]; then + command -v gh >/dev/null 2>&1 || die "gh missing after package install" + command -v git >/dev/null 2>&1 || die "git missing after package install" +fi + +# --- docker ------------------------------------------------------------------ +# Every tenant gets docker (the templates all carried it; staging's guests run +# their workloads in it). Docker's own installer, convergence-guarded — its +# script is not a no-op when docker exists, so rig supplies the guard. +if ! command -v docker >/dev/null 2>&1; then + log "installing docker (get.docker.com)" + curl -fsSL https://get.docker.com | sh +else + log "docker already installed" +fi +docker --version >/dev/null 2>&1 || die "docker installed but 'docker --version' does not answer" +# The client answering is not the effective state — a dead dockerd would still +# pass it. Ask the daemon, with a bounded settle for the freshly-installed case +# (get.docker.com starts it, but not instantaneously on a slow guest). +docker_up="" +for _ in 1 2 3 4 5 6; do + if docker info >/dev/null 2>&1; then docker_up=1; break; fi + sleep 5 +done +[ -n "$docker_up" ] || die "dockerd does not answer 'docker info' after 30s — the daemon is not running; check 'systemctl status docker' (or the container's init) before re-running" +log "dockerd answering" +if getent group docker >/dev/null 2>&1; then + if id -nG "$TENANT_USER" | tr ' ' '\n' | grep -qx docker; then + log "${TENANT_USER} already in the docker group" + else + usermod -aG docker "$TENANT_USER" + log "added ${TENANT_USER} to the docker group" + fi +else + warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install" +fi + +# --- node (claude, codex) ---------------------------------------------------- +# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified +# upstream when the template was written); the claude tenant ships node as part +# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node. +node_ok() { + command -v node >/dev/null 2>&1 || return 1 + local major + major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')" + [ "${major:-0}" -ge 22 ] 2>/dev/null +} +if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then + if node_ok; then + log "node $(node --version) already present" + else + log "installing node 22 (nodesource)" + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y -qq nodejs + fi + node_ok || die "node >= 22 still missing after install — check the nodesource setup" +fi + +# --- the agent CLI ----------------------------------------------------------- +# Per-agent install, shared discipline: install only when the CLI is absent +# (upgrades are the CLI's own business), then put it on the SYSTEM path — +# 'box exec -- …' runs a NON-interactive shell that reads no rc +# files, so a PATH export alone is invisible to it (the #15 lesson) — and +# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is +# what cost the last drill (the grok template's scar). +CLI="" CLI_SRC="" +case "$ROLE" in + claude) + CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude" + if [ ! -e "$CLI_SRC" ]; then + log "installing the Claude Code CLI as ${TENANT_USER}" + runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash' + else + log "claude CLI already installed" + fi ;; + codex) + CLI=codex + if ! command -v codex >/dev/null 2>&1; then + log "installing the Codex CLI (npm global)" + npm install -g @openai/codex + else + log "codex CLI already installed" + fi + CLI_SRC="$(npm prefix -g)/bin/codex" ;; + grok) + # The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`, + # a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir. + # Run it AS the tenant user, never root: a symlink into root's 0700 home + # would be a CLI that exists and cannot run. + CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok" + if [ ! -e "$CLI_SRC" ]; then + log "installing the Grok CLI as ${TENANT_USER}" + runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash' + else + log "grok CLI already installed" + fi ;; + staging) ;; # no agent lives on the staging tenant +esac +if [ -n "$CLI" ]; then + [ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?" + ln -sf "$CLI_SRC" "/usr/local/bin/$CLI" + # One capture serves both the assert and the log line; emptiness IS the + # failure signal (head exits 0 regardless, so a pipeline status can't be). + CLI_VER="$(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1)" + [ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target" + log "${CLI} CLI on the system PATH and answering (${CLI_VER})" + + # The interactive-shell PATH exports the templates carried, converged as + # literal rc lines (written once, never duplicated). Single quotes are the + # point: the line must expand in the USER's shell, not here. + # shellcheck disable=SC2016 + case "$ROLE" in + claude) + append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;; + codex) + append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;; + grok) + append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;; + esac +fi + +# --- the agent-context file -------------------------------------------------- +# The one file every agent reads before touching anything. Rendered from +# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or +# the drill inside a box; the box you are in is not a host you own") lives +# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded +# like every file rig converges. +if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then + CTX_DIR="$(dirname "$CTX_PATH")" + if [ ! -d "$CTX_DIR" ]; then + mkdir -p "$CTX_DIR" + log "created ${CTX_DIR}" + fi + # The dotdir is the AGENT's (it writes state next to its instructions), so + # its ownership is converged on every run, not only on creation. + chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR" + CTX_TMP="$(mktemp)" + render_tenant_context "$ROLE" > "$CTX_TMP" + if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then + install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH" + log "agent-context file written: ${CTX_PATH}" + else + log "agent-context file already current" + fi + rm -f "$CTX_TMP" +fi + +# --- claude shell niceties --------------------------------------------------- +# The claude template shipped zsh + oh-my-zsh + tmux mouse mode; they move with +# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a +# bootstrap whose real work (CLI, context, docker) already converged. +if [ "$ROLE" = "claude" ]; then + if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then + chsh -s /usr/bin/zsh "$TENANT_USER" + log "login shell set to zsh for ${TENANT_USER}" + else + log "login shell already zsh for ${TENANT_USER}" + fi + if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then + log "installing oh-my-zsh for ${TENANT_USER}" + # Single quotes on purpose: the $(...) must run in the USER's shell. + # shellcheck disable=SC2016 + runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \ + || warn "oh-my-zsh install failed — cosmetic only; continuing" + else + log "oh-my-zsh already installed" + fi + # After oh-my-zsh (it rewrites .zshrc on first install). + # shellcheck disable=SC2016 + append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"' + append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on' +fi + +# --- staging server posture -------------------------------------------------- +# box#69's posture, minus the join: docker (above) + sshd hardening, through +# the SAME code the machine roles use (lib/sshd.sh) — the staging guest is a +# workload server in waiting, and its door must never be password-open even +# before the operator joins it. class=server: root SSH stays the control +# plane's future automation door. +if [ "$ROLE" = "staging" ]; then + harden_sshd server +fi + +# --- role marker -------------------------------------------------------------- +# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant +# has no root-door policy of its own — close-root fails closed on it), and +# host=no so `rig users apply` box-role gating keeps working. staging SKIPS the +# write when a machine marker is already present: after the operator-run +# workload join, the workload marker is the truer statement and rig never +# clobbers state a joined box earned. +if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then + MARKER_TMP="$(mktemp)" + printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP" + if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then + mkdir -p "$(dirname "$MARKER_PATH")" + install -m 0644 "$MARKER_TMP" "$MARKER_PATH" + log "role marker written: role=$ROLE tenant=yes host=no" + else + log "role marker already current" + fi + rm -f "$MARKER_TMP" +else + log "machine role marker present (${EXISTING_MARKER}); leaving it alone" +fi + +log "done — tenant ${ROLE}, user ${TENANT_USER}" +if [ "$ROLE" = "staging" ]; then + log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname with a tagged pre-auth key" +else + log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides" +fi diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 0146f04..4c47062 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -6,6 +6,8 @@ set -euo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" # shellcheck source=SCRIPTDIR/lib/runner-config.sh . "$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 log() { printf 'rig-bootstrap: %s\n' "$*"; } warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } @@ -13,9 +15,11 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap +usage: rig bootstrap [--hostname ] [--class ] [--host ] [--join ] + rig bootstrap [--user ] + (the box TENANT roles — see their own --help) --hostname system + tailnet hostname (default: the role name; custom has no default and requires it) @@ -32,10 +36,13 @@ custom presets nothing and requires --hostname plus all three traits. 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 former staging VM-host preset is now spelled through the traits: +'custom --class server --host yes --join authkey' (or 'dev --class server'). +'staging' names the box TENANT role today — the guest, not the host. + 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 @@ -56,10 +63,16 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner|staging|dev|workstation|custom) shift ;; + control-plane|workload|runner|dev|workstation|custom) shift ;; + claude|codex|grok|staging) + # The box TENANT roles (#31) are a different family — guests a box mints, + # never tailnet machines — and live in their own mechanism, one script + # parameterized per tenant. Dispatched here so `rig bootstrap ` + # stays the single entrypoint for both families. + exec "$HERE/bootstrap-tenant.sh" "$@" ;; -h|--help) usage; exit 0 ;; - "") 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 ;; + "") usage >&2; die "role required (control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; + *) die "unknown role: $ROLE (want control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; esac # Role→traits map — the single place a role's shape is declared (issue #26). @@ -71,7 +84,6 @@ 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) ;; @@ -182,74 +194,13 @@ APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; EOF -# --- sshd hardening (restart only when the drop-in actually changed) --------- -# The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins -# ("for each keyword, the first obtained value will be used" — sshd_config(5)), -# and Include expands the glob in lexical order. Cloud images ship -# /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication -# yes`, so the old 99-rig.conf was read second and silently lost every keyword -# it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped -# was still serving `passwordauthentication yes`. The Incus rehearsal never -# caught it — a pristine Debian container has no cloud-init drop-in.) -DROPIN=/etc/ssh/sshd_config.d/00-rig.conf -LEGACY_DROPIN=/etc/ssh/sshd_config.d/99-rig.conf -TMP="$(mktemp)" -cat > "$TMP" <<'EOF' -PermitRootLogin prohibit-password -PasswordAuthentication no -EOF -if ! cmp -s "$TMP" "$DROPIN" 2>/dev/null || [ -e "$LEGACY_DROPIN" ]; then - BACKUP="" - [ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; } - install -m 0644 "$TMP" "$DROPIN" - rm -f "$LEGACY_DROPIN" # sweep the losing file from already-bootstrapped boxes - - # Validate the MERGED config BEFORE bouncing the daemon. On a box whose only - # door is SSH, `systemctl restart ssh` against a config sshd refuses to parse - # leaves no listener and no way back in. `sshd -t` parses everything sshd - # would parse — our drop-in, cloud-init's, and any third-party file — so a - # broken neighbour is caught here rather than after the door has shut. - if ! sshd -t 2>/dev/null; then - if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi - rm -f "$TMP" "$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 - log "sshd hardening drop-in installed" -else - log "sshd hardening drop-in already in place" -fi -rm -f "$TMP" - -# Assert the EFFECTIVE config, not the file's existence — asserting the file is -# what let the first-wins bug ship green. `sshd -T` is what the daemon actually -# resolved, cloud-init and all. -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/" -# 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)" +# --- sshd hardening ---------------------------------------------------------- +# The whole block lives in lib/sshd.sh, shared with the staging TENANT role — +# one drop-in, one converger, never two copies drifting apart. Everything the +# block learned the hard way (00- beats cloud-init's 50- under first-wins, +# validate-then-restart, assert sshd -T not the file, the class-gated +# permitrootlogin acceptance) moved with it, verbatim. +harden_sshd "$CLASS" # --- system hostname ---------------------------------------------------------- # Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig @@ -342,9 +293,11 @@ verify_effective_tag() { 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." ;; *) + # This arm now also owns the VM-host shape the old staging preset + # covered (custom/dev --class server): a host is never managed by the + # control plane — its guest VMs are — so tag:server is refused there + # like everywhere else outside control-plane|workload. 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 diff --git a/commands/lib/sshd.sh b/commands/lib/sshd.sh new file mode 100644 index 0000000..518ddaa --- /dev/null +++ b/commands/lib/sshd.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Shared sshd hardening — sourced by bootstrap.sh (machine roles) and by +# bootstrap-tenant.sh (the staging tenant). Root-requiring, unlike the pure +# parsing libs: it converges /etc/ssh and bounces the daemon. Extracted so the +# two roles converging ONE drop-in stay literally the same code — two copies of +# a hardening block is drift by construction, the same law that keeps rig's +# hands off Incus. Callers provide log/warn/die. + +# harden_sshd — install the 00-rig.conf hardening drop-in, +# validate the merged config before touching the daemon, restart only when the +# drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the +# permitrootlogin acceptance gated on the class passed in. +harden_sshd() { + local class="$1" + local dropin=/etc/ssh/sshd_config.d/00-rig.conf + local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf + local tmp backup eff + # The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins + # ("for each keyword, the first obtained value will be used" — sshd_config(5)), + # and Include expands the glob in lexical order. Cloud images ship + # /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication + # yes`, so the old 99-rig.conf was read second and silently lost every keyword + # it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped + # was still serving `passwordauthentication yes`. The Incus rehearsal never + # caught it — a pristine Debian container has no cloud-init drop-in.) + tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +PermitRootLogin prohibit-password +PasswordAuthentication no +EOF + if ! cmp -s "$tmp" "$dropin" 2>/dev/null || [ -e "$legacy_dropin" ]; then + backup="" + [ -e "$dropin" ] && { backup="$(mktemp)"; cp -a "$dropin" "$backup"; } + install -m 0644 "$tmp" "$dropin" + rm -f "$legacy_dropin" # sweep the losing file from already-bootstrapped boxes + + # Validate the MERGED config BEFORE bouncing the daemon. On a box whose only + # door is SSH, `systemctl restart ssh` against a config sshd refuses to parse + # leaves no listener and no way back in. `sshd -t` parses everything sshd + # would parse — our drop-in, cloud-init's, and any third-party file — so a + # broken neighbour is caught here rather than after the door has shut. + if ! sshd -t 2>/dev/null; then + if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi + rm -f "$tmp" "$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 + log "sshd hardening drop-in installed" + else + log "sshd hardening drop-in already in place" + fi + rm -f "$tmp" + + # Assert the EFFECTIVE config, not the file's existence — asserting the file is + # what let the first-wins bug ship green. `sshd -T` is what the daemon actually + # resolved, cloud-init and all. + 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/" + # 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 function + # installs. Hardening 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)" +} diff --git a/commands/lib/tenant-config.sh b/commands/lib/tenant-config.sh new file mode 100644 index 0000000..cd6950e --- /dev/null +++ b/commands/lib/tenant-config.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Shared parameters for the box TENANT roles (claude, codex, grok, staging) — +# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no +# side effects: the per-tenant differences live HERE, in one table, so the +# mechanism stays one script parameterized per tenant instead of four +# hand-maintained copies (repo precedent: parse_users_file, runner-config). + +# tenant_user — the user the box seed creates (box.env BOX_USER). The +# agent tenants are named after their agent; staging keeps box#69's `ops`. +tenant_user() { + case "$1" in + claude) printf 'claude' ;; + codex) printf 'codex' ;; + grok) printf 'grok' ;; + staging) printf 'ops' ;; + *) return 1 ;; + esac +} + +# tenant_context_path — where the agent-context file lands. Each +# agent CLI reads its own instructions file from its own dotdir; staging has no +# agent and no context file (return 1). +tenant_context_path() { + case "$1" in + claude) printf '%s/.claude/CLAUDE.md' "$2" ;; + codex) printf '%s/.codex/AGENTS.md' "$2" ;; + grok) printf '%s/.grok/AGENTS.md' "$2" ;; + *) return 1 ;; + esac +} + +# render_tenant_context — the agent-context file's content, on stdout. +# One renderer for all three agents: only the creds paragraph is per-vendor, +# and the box#80 guard note lives HERE once — never copy-pasted per template. +# staging renders nothing (return 1): no agent lives there. +render_tenant_context() { + local role="$1" creds + # The single-quoted markdown below carries literal `$`-free backtick prose; + # single quotes are deliberate — nothing in it may expand here. + # shellcheck disable=SC2016 + case "$role" in + claude) + creds='- **Creds-free by default.** The box starts with no Claude and no git + credentials. If you need to authenticate Claude, the operator runs `/login` + interactively. For git, the operator adds their own credentials (a PAT or + `gh auth login`). Never assume credentials are present; never ask for or + store secrets on disk beyond what the operator sets up.' ;; + codex) + creds='- **Creds-free by default.** The box starts with no OpenAI and no git + credentials. If you need to authenticate Codex, the operator runs the + login flow (`codex`) interactively. For git, the operator adds their own + credentials (a PAT or `gh auth login`). Never assume credentials are + present; never ask for or store secrets on disk beyond what the operator + sets up.' ;; + grok) + creds='- **Creds-free by default.** The box starts with no xAI and no git + credentials. If you need to authenticate, the operator runs + `grok login` interactively (SuperGrok / X Premium+). For git, the + operator adds their own credentials (a PAT or `gh auth login`). Never + assume credentials are present; never ask for or store secrets on disk + beyond what the operator sets up.' ;; + *) return 1 ;; + esac + cat <` stays the single entrypoint. The real +# converge needs root, a tenant user, and the network — the container +# rehearsal's job — so the harness proves what it can non-root: the whole +# arg/refusal surface, the pure parameter table, the rendered agent-context +# file (guard note included), and grep-pins on the shipped script. +check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help +check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato +check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude --nope +check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude --user +check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude --user 'fo|o' +# The docker converge asserts the DAEMON answers, not just the client binary — +# a dead dockerd passing `docker --version` is the "linked but cannot run" +# scar in daemon form. Grep-pinned so the assert cannot ship deleted. +check "tenant: dockerd effective-state assert is present" 0 "" \ + grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh" +# The machine-role traits die with the tenant story, never "unknown flag" — an +# operator reaching for --hostname must learn where the trait family went. +check "tenant: trait flags die with the tenant story" 2 "have no traits" \ + "$ROOT/commands/bootstrap-tenant.sh" claude --class human +check "tenant: --hostname dies the same way" 2 "have no traits" \ + "$ROOT/commands/bootstrap-tenant.sh" staging --hostname my-guest +# Dispatch: the machine-role entrypoint hands tenant roles to the tenant +# mechanism with args intact (--help reaching the TENANT usage proves both). +check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude|codex|grok|staging" \ + "$ROOT/commands/bootstrap.sh" claude --help +# The marker guard fires BEFORE the root check (repo precedent: the coolify +# marker warning), so the refusals are provable here off fixture markers. A +# VM host (host=yes) refuses for every tenant — and names the staging rename, +# because a pre-#31 staging HOST re-running its old command is exactly who +# lands here. An agent tenant refuses ANY machine-role box; staging tolerates +# ONLY class=server with host=no — that is the staging guest after its +# operator-run workload join, and re-converging it is what convergence is for. +# A non-server machine (class=human via custom) is NOT that guest, and server +# hardening would die at it with server-specific messaging — refuse instead. +TEN_FIX="$(mktemp -d)" +printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host" +printf 'role=workload class=server host=no join=authkey\n' > "$TEN_FIX/machine" +printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human" +printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant" +check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \ + env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging +check "tenant: refuses a host=yes box (a VM host is never a guest)" 1 "hosts VMs" \ + env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude +check "tenant: the host refusal names the old staging preset's new spelling" 1 "custom --class server --host yes" \ + env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging +check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \ + env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude +if [ "$(id -u)" -ne 0 ]; then + # RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before + # the root check, and the harness machine may carry a real /etc/rig/role. + check "tenant: claude parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude + check "tenant: codex parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex + check "tenant: grok parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok + check "tenant: staging tolerates a workload-joined guest's marker" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging + check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude +else + echo "skip: tenant non-root refusals (running as root)" +fi +rm -rf "$TEN_FIX" + +# The per-tenant parameter table and the agent-context renderer are pure lib +# functions on purpose (repo precedent: parse_users_file, json_string_array): +# the CLI path to them sits behind root + a real tenant user, so the harness +# proves them here, sourced, non-root and network-free. +tuser() { bash -c 'set -euo pipefail + . "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; } +tpath() { bash -c 'set -euo pipefail + . "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; } +tctx() { bash -c 'set -euo pipefail + . "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; } +check "tenant params: agent users are named after their agent" 0 "claude" tuser claude +check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging +check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude /home/claude +check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex /home/codex +check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok /home/grok +check "tenant params: staging has no context file" 1 "" tpath staging /home/ops +# The box#80 guard note lives ONCE, in the renderer, and every agent's file +# carries it — the layering decision's whole point: never per-template again. +check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude +check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex +check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok +check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude +check "tenant context: the guard cites box#80" 0 "box#80" tctx claude +check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude +check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude +check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex +check "tenant context: grok names its login flow" 0 "grok login" tctx grok +check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging +# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal +# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or +# apt-installs incus. A grep that finds nothing (exit 1) is the pass. +check "tenant: never touches the tailnet" 1 "" \ + grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: non-interactive — nothing prompts" 1 "" \ + grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \ + grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh" +# staging's posture rides the SAME hardening code as the machine roles — the +# shared lib call is the anti-drift property, so pin the call, not the words. +check "tenant: staging hardens through the shared sshd lib" 0 "" \ + grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: docker lands via docker's own installer" 0 "" \ + grep -q "get.docker.com" "$ROOT/commands/bootstrap-tenant.sh" +# The #15 lesson pinned: 'box exec' shells read no rc files, so the CLI must +# land on the SYSTEM path — and a claimed install is verified, not trusted: +# it must ANSWER as the tenant user (the grok template's scar: linked but +# cannot run). The $CLI/$TENANT_USER are literals we grep for in the script. +# shellcheck disable=SC2016 +check "tenant: the agent CLI lands on the system PATH" 0 "" \ + grep -qF '/usr/local/bin/$CLI' "$ROOT/commands/bootstrap-tenant.sh" +# shellcheck disable=SC2016 +check "tenant: the CLI install is verified as the tenant user" 0 "" \ + grep -qF 'runuser -l "$TENANT_USER" -c "$CLI --version"' "$ROOT/commands/bootstrap-tenant.sh" +# Ordering is the safety property, as with bootstrap's marker-then-box assert: +# the tenant marker may only describe converges that already happened, so the +# write sits after the context-file converge. Defaults fail closed. +ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)" +# shellcheck disable=SC2016 +ten_marker_at="$(grep -nF 'install -m 0644 "$MARKER_TMP" "$MARKER_PATH"' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)" +check "tenant: the marker write follows the context-file converge" \ + 0 "" test "${ten_ctx_at:-999999}" -lt "${ten_marker_at:-0}" + check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version @@ -743,16 +882,28 @@ else 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 widened assertion so a revert cannot ship green. The hardening block +# lives in lib/sshd.sh since #31 — ONE converger shared by the machine roles +# and the staging tenant — so the greps pin the lib, and a call-site grep pins +# that bootstrap actually runs it (a function nobody calls is not hardening). +check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \ + grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/lib/sshd.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" +check "sshd lib: 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/lib/sshd.sh" +# Validate-then-apply survived the extraction: sshd -t on the merged config +# must still precede the restart (same idiom as the close-root ordering check). +libt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)" +librestart_at="$(grep -nE '^[[:space:]]*systemctl restart ssh$' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)" +check "sshd lib: sshd -t precedes the ssh restart" \ + 0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}" +# shellcheck disable=SC2016 +check "bootstrap: hardening runs through the shared lib" 0 "" \ + grep -qE '^harden_sshd "\$CLASS"$' "$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