From 1845468765c5c1d1d9c5c880c97bc77711148674 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 00:01:16 +0000 Subject: [PATCH 1/5] feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored rig builds two kinds of thing on opposite sides of a trust boundary -- tailnet machines it converges, and guests a box mints -- and both families lived in one flat namespace with nothing in a role name saying which you meant. `staging` is where that stopped being cosmetic: the word names the metal that hosts guests and the guests on it, only one could have it, and #31 gave it to the guests. The VM-host shape was left nameless, spelled `custom --class server --host yes --join authkey`, which is what every refusal recited at an operator who had confused the two. The suffix now names the family: control-plane-server, workload-server, runner-server, dev-server, plus the restored staging-server (class=server host=yes join=authkey). host=yes already installs the box CLI and runs box's setup-host, so staging-server is a table row, not new machinery. It stays OUT of the tag:server allow-list deliberately -- a host is never managed by the control plane, its guests are -- so its key is minted tag:local. custom and workstation keep bare names as the rule, not an exception to it: custom presets nothing and can be any shape including a guest, so a family claim is one it cannot make; a workstation is somebody's own device, joined by interactive login, user-owned and untagged, never tailnet-managed. Hard cut, no aliases -- old names are refused as unknown. Two consequences this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name, so a box taking the default now comes up control-plane-server. And the two coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so they now look for role=control-plane-server; a pre-rename control plane takes their warning branch, which is advisory and never a gate, so the run proceeds and the message names the repair. dev-server is class=human, which reads like a contradiction and is not: the suffix names the family, the class names the root-SSH door policy. The two axes share the word "server", which is a real wart -- #77 renames the class trait to what it controls, kept separate because it reaches markers on live machines that guard root SSH. Tests cover both directions of the cut: every new name resolves, every old name is refused as unknown, and the two deliberately-bare roles are proven NOT to have been swept up -- the inverse error, which would otherwise only surface at somebody's laptop. Closes #76 (machine-role half) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 50 +++++++++++ README.md | 120 +++++++++++++++---------- bin/rig | 9 +- commands/bootstrap.sh | 85 +++++++++++------- commands/coolify-backup-install.sh | 7 +- commands/coolify-install.sh | 11 ++- test/cli.sh | 137 ++++++++++++++++++----------- 7 files changed, 282 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d7fb0..80b243c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,56 @@ on the way to cutting its first release, and this file starts there. ## Unreleased +### Changed + +- **BREAKING: machine roles carry a `-server` suffix, and the VM host gets its + name back** (#76) — rig builds two kinds of thing that sit on opposite sides + of a trust boundary: tailnet **machines** it converges, and **guests** a box + mints. Both families lived in one flat namespace, and no role name said which + one you were asking for. `staging` is where that stopped being cosmetic — the + word names the metal that hosts guests *and* the guests on it, only one of + them could have the name, and #31 gave it to the guests. The VM-host shape + was left with no name at all, spelled `custom --class server --host yes + --join authkey`, which is what every refusal in the tree recited at an + operator who had confused the two. + + So the suffix names the family: `control-plane-server`, `workload-server`, + `runner-server`, `dev-server`, and the restored `staging-server` + (`class=server host=yes join=authkey` — the preset #31 retired, back under a + name that cannot be mistaken for its own guests). `host=yes` already installs + the box CLI and runs box's `setup-host`, so `staging-server` is a table row + rather than new machinery, and it stays **out** of the `tag:server` + allow-list on purpose: a host is never managed by the control plane, its + guests are, so mint its key with `tag:local`. + + **`custom` and `workstation` keep bare names**, and that is the rule rather + than an exception to it. `custom` presets nothing and can be any shape — a + guest included — so a family claim is one it cannot make. `workstation` is + somebody's own device rather than fleet infrastructure: it joins by + interactive login, comes up user-owned and untagged, and the tailnet never + manages it. + + **Migration — this is a hard cut, with no aliases.** The old names are + refused as unknown roles; a box bootstrapped under one is re-bootstrapped + rather than migrated, which at this fleet size costs less than four + deprecation paths each quietly keeping an old name alive. Two consequences + worth knowing before you re-run anything. `TS_HOSTNAME` defaults to the role + name, so a box that took the default now comes up as `control-plane-server` + rather than `control-plane` — pass `--hostname` to hold a name steady, and + check anything pinning one (ACL entries, a `cast` `environments.yaml` server + name, host keys). And `rig coolify install` / `rig coolify backup install` + match the **role name** in `/etc/rig/role`, so they now look for + `role=control-plane-server`; a pre-rename control plane takes their warning + branch until it is re-bootstrapped. That check has always been advisory and + never a gate, so the run still proceeds and the warning names the repair. + + `dev-server` is `class=human`, which reads like a contradiction and is not: + the suffix names the family, the class names the root-SSH door policy, and + operators enter a dev box as themselves so `close-root` shuts its door. The + two axes genuinely share the word "server", which is a wart — #77 renames the + class trait to what it actually controls, and is kept separate because it + reaches markers on live machines that guard root SSH. + ## 0.2.0 — 2026-07-19 ### Added diff --git a/README.md b/README.md index cb9d443..23890ea 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ itself is untouched — what bootstrap converged stays converged. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. (The box TENANT roles — `claude`, `codex`, @@ -98,10 +98,10 @@ second run changes nothing. (The box TENANT roles — `claude`, `codex`, tenants* below.) ```sh -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 control-plane-server --hostname my-coolify-box --users ./users +rig bootstrap workload-server --hostname my-prod-box --users ./users +rig bootstrap runner-server --hostname my-ci-box --users ./users +rig bootstrap dev-server --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 ``` @@ -125,8 +125,8 @@ 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 +rig bootstrap dev-server --hostname my-dev-box --users ./users # one command, people included +rig bootstrap dev-server --hostname my-dev-box --no-users # deliberately root-only ``` `--users ` runs exactly what `rig users apply --file ` runs, as @@ -187,27 +187,50 @@ presets nothing and requires `--hostname` plus all three traits. | `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and, on `yes`, installing the `box` CLI + running box's `setup-host` | | `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) | -| role | class | host | join | tailnet tag | -|-----------------|--------|------|---------|-------------| -| `control-plane` | server | no | authkey | `tag:server` | -| `workload` | server | no | authkey | `tag:server` | -| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` | -| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` | -| `workstation` | human | yes | login | untagged — any tag refused | +| role | class | host | join | tailnet tag | +|------------------------|--------|------|---------|-------------| +| `control-plane-server` | server | no | authkey | `tag:server` | +| `workload-server` | server | no | authkey | `tag:server` | +| `runner-server` | server | no | authkey | `tag:ci` — refuses `tag:server` | +| `staging-server` | server | yes | authkey | `tag:local` — refuses `tag:server` | +| `dev-server` | 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 suffix names the family, not the class** (#76). rig builds two kinds +> of thing on opposite sides of a trust boundary — tailnet **machines** it +> converges, and **guests** a box mints — and for a while nothing in a role +> name said which you were asking for. `staging` made that concrete: the word +> named both the metal that hosts guests and the guests on it, and only one of +> them could have it. So `-server` marks a fleet machine and `-box` marks a +> box tenant, everywhere, and `staging-server` / `staging-box` are simply the +> two halves spelled out. `staging-server` restores the VM-host preset #31 +> retired, under a name that cannot be confused with its own guests. +> +> Two roles take **no** suffix, on purpose. `custom` presets nothing and can +> be any shape — a guest included — so a family claim is one it cannot make. +> `workstation` is somebody's own device rather than fleet infrastructure: it +> joins by interactive login, comes up user-owned and untagged, and the +> tailnet never manages it. +> +> **`dev-server` is `class=human`, and that is not a contradiction** — though +> it is a wart. The suffix names the *family*; the class names the *root-SSH +> door policy*, and operators enter a dev box as themselves, so `close-root` +> shuts its door. Two orthogonal axes that happen to share the word "server". +> [#77](https://github.com/heavy-duty/rig/issues/77) renames the class trait +> to what it actually controls, which is the real fix; it touches markers on +> live machines, so it is deliberately not folded in here. +> +> **This was a hard cut — no aliases.** Old role names stop working, and a +> box bootstrapped under one is re-bootstrapped rather than migrated. Two +> things follow. The default tailnet hostname is the role name, so a box that +> took the default now comes up as `control-plane-server`; pass `--hostname` +> to hold a name steady. And `rig coolify install` / `rig coolify backup +> install` match `role=control-plane-server` in the marker, so a pre-rename +> control plane takes their (advisory, non-fatal) warning until it is +> re-bootstrapped. 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 control plane manages this box", and `control-plane-server` and `workload-server` are the only shapes it manages — every other role refuses an effective `tag:server` after join, one rule instead of per-role exceptions. @@ -292,9 +315,9 @@ catches a box bootstrapped before this change, or retagged behind rig's back). > tag flag, re-tagging needs a fresh key via `up --force-reauth` — so rig detects > and refuses, and never claims a convergence it cannot perform. -`control-plane` and `workload` are identical today except the default +`control-plane-server` and `workload-server` are identical today except the default hostname; they exist because the boxes diverge over time, and because each -follow-up command applies to exactly one role. `runner` is the box a CI agent +follow-up command applies to exactly one role. `runner-server` is the box a CI agent will live on, and it differs behaviorally: it **refuses `tag:server`**. That refusal moved onto the *effective* tag and is strictly stronger for it — it is no longer "don't advertise `tag:server`" but "the key you actually used must not @@ -305,13 +328,13 @@ hard, post-join error. 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 +with the tenant roles and (for staging guests) `rig bootstrap workload-server` — +is the `staging-server` role (`--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`. +refused**, same mechanism as `runner-server`. 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 @@ -344,7 +367,7 @@ 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 the human-class VM-hosting shape — `tag:local`, box CLI installed as +`dev-server` 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 @@ -393,7 +416,7 @@ files, so a PATH export alone is invisible to it. — 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 +as box#69 designed it: `box shell` → `sudo rig bootstrap workload-server --hostname --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 @@ -441,17 +464,18 @@ themself and elevates via sudo. Per role, the whole identity picture at a glance — issue #25's class comparison, translated onto the traits that replaced the class binary: -| role | class | host | join | who lives here | root SSH after `rig users apply` | -|-----------------|--------|------|---------|--------------------------------------|----------------------------------| -| `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 | -| `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` | +| role | class | host | join | who lives here | root SSH after `rig users apply` | +|------------------------|--------|------|---------|--------------------------------------|----------------------------------| +| `control-plane-server` | server | no | authkey | nobody — Coolify runs here | open — the automation door | +| `workload-server` | server | no | authkey | nobody — deployed services run here | open — the automation door | +| `runner-server` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | +| `staging-server` | server | yes | authkey | nobody — it mints and hosts guests | open — the automation door | +| `dev-server` | 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 +(`staging-server` is that unattended VM-host appliance, and the row above is +the whole of it: 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.) @@ -463,7 +487,7 @@ operators land via `rig users apply` on every class and elevate through sudo as root. **Machine identities stay machine-shaped**: Coolify's automation SSHes in as root (that is what server-class root *is*), CI jobs run as the unprivileged `github-runner`, and guest VMs are their own server-class boxes, -converged from inside by `rig bootstrap workload`. +converged from inside by `rig bootstrap workload-server`. **`class` decides root SSH's fate — after `rig users apply`, never before.** On `class=human`, root SSH closes entirely (`rig users close-root`, below). @@ -472,7 +496,7 @@ root there is the **automation** identity the control plane (Coolify) SSHes in as. It is a machine door, never a human one. **Where this diverges from #17's original table:** that table let the -`runner` role close root ("no Coolify involved"). The class model supersedes +`runner-server` role close root ("no Coolify involved"). The class model supersedes the per-role call: runner is `class=server` — an automation identity, not a person's box — and on every server-class machine root SSH is the management plane rig itself converges through, so `close-root` refuses there @@ -640,11 +664,11 @@ throwaway containers on every push. ### `rig runner install --repo ` -Runner box only, run after `rig bootstrap runner` (the same two-step rhythm -as `bootstrap control-plane` → `coolify install`): +Runner box only, run after `rig bootstrap runner-server` (the same two-step rhythm +as `bootstrap control-plane-server` → `coolify install`): ```sh -rig bootstrap runner --hostname my-ci-box --users ./users +rig bootstrap runner-server --hostname my-ci-box --users ./users rig runner install --repo acme/widgets ``` @@ -1006,7 +1030,7 @@ 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 +throwaway VM/container: pristine Debian → install → `bootstrap workload-server` 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` diff --git a/bin/rig b/bin/rig index 0af2016..e0a2b02 100755 --- a/bin/rig +++ b/bin/rig @@ -13,7 +13,8 @@ usage() { usage: rig [args] commands: - bootstrap + bootstrap (--users | --no-users) [--hostname ] [--class ] [--host ] [--join ] @@ -27,7 +28,9 @@ commands: overrides its trait, and custom states all of them. Prompts for a single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the prompt); the key's tags are the tailnet tag, verified after join — - only control-plane and workload may carry tag:server. join=login + only control-plane-server and workload-server may carry tag:server. + The '-server' suffix names the FAMILY (a fleet machine, vs the '-box' + tenant roles); custom and workstation take none. join=login (workstation) needs no key: interactive login, node must come up untagged. Run as root. bootstrap [--user ] @@ -36,7 +39,7 @@ commands: 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 + via 'rig bootstrap workload-server'. 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. diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 2374dfe..1d9a7de 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -22,7 +22,8 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap +usage: rig bootstrap (--users | --no-users) [--hostname ] [--class ] [--host ] [--join ] @@ -60,23 +61,39 @@ 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. - role class host join - control-plane server no authkey - workload server no authkey - runner server no authkey - dev human yes authkey - workstation human yes login + role class host join + control-plane-server server no authkey + workload-server server no authkey + runner-server server no authkey + staging-server server yes authkey + dev-server 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 SUFFIX NAMES THE FAMILY, not the class. '-server' means this role builds a +fleet MACHINE — a tailnet node rig converges; '-box' (the tenant roles) means a +GUEST a box mints. Two families lived in one flat namespace and nothing in a +name said which you were asking for; 'staging' made that concrete by naming +both the metal and the guests on it. + + custom no suffix: it presets nothing and can be any shape, a guest + included, so a family claim would be one it cannot make. + workstation no suffix: somebody's own device, not fleet infrastructure — + it joins by interactive login and comes up user-owned and + untagged, and the tailnet never manages it. + +'dev-server' is class=human, and that is not a contradiction: the suffix names +the family, the CLASS names the root-SSH door policy (operators enter a dev box +as themselves, so 'users close-root' shuts its door). The two axes share the +word 'server' and that is a genuine wart — tracked in #77, which renames the +class trait to what it actually controls. The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, so the key is the single source of truth: rig no longer requests a tag it might disagree with. After the box joins, rig reads the tag control actually GRANTED (tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused -outright, and only control-plane and workload may carry tag:server (they are -the only shapes the control plane manages). Mint a correctly-tagged key. +outright, and only control-plane-server and workload-server may carry +tag:server (they are the only shapes the control plane manages). Mint a +correctly-tagged key. join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or enter it at the interactive prompt. Used once, never written to disk. @@ -91,7 +108,7 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner|dev|workstation|custom) shift ;; + control-plane-server|workload-server|runner-server|staging-server|dev-server|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 @@ -99,8 +116,8 @@ case "$ROLE" in # 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|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 ;; + "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; + *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|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). @@ -109,11 +126,16 @@ esac # for the shape nobody foresaw — it declares nothing and must state all three. CLASS="" HOST="" JOIN="" case "$ROLE" in - control-plane) CLASS=server HOST=no JOIN=authkey ;; - workload) CLASS=server HOST=no JOIN=authkey ;; - runner) CLASS=server HOST=no JOIN=authkey ;; - dev) CLASS=human HOST=yes JOIN=authkey ;; - workstation) CLASS=human HOST=yes JOIN=login ;; + control-plane-server) CLASS=server HOST=no JOIN=authkey ;; + workload-server) CLASS=server HOST=no JOIN=authkey ;; + runner-server) CLASS=server HOST=no JOIN=authkey ;; + # The unattended VM host — the shape #31 retired when 'staging' moved to the + # tenant family, restored under a name that cannot be confused with its own + # guests. host=yes is the whole point: it is what installs the box CLI and + # runs box's setup-host further down, so this is a table row, not machinery. + staging-server) CLASS=server HOST=yes JOIN=authkey ;; + dev-server) CLASS=human HOST=yes JOIN=authkey ;; + workstation) CLASS=human HOST=yes JOIN=login ;; custom) ;; esac @@ -430,25 +452,26 @@ verify_effective_tag() { fi # tag:server policy is DERIVED, not a trait: it means "the control plane - # manages this box", and only control-plane and workload are shapes the + # manages this box", and only control-plane-server and workload-server are shapes the # control plane manages. Everything else refuses it on the EFFECTIVE tag — # strictly stronger than the old request-time check, which only guarded the - # tag rig HOPED for. The fleet has been bitten both ways: a runner carrying + # tag rig HOPED for. The fleet has been bitten both ways: a runner-server carrying # tag:server extends every server grant to repo-controlled code, and a # staging host carrying it extends them to a box the control plane does not # even know. Refused, never warned; rig can DETECT this but cannot FIX it, # so each refusal names its repair. if printf '%s\n' "$tags" | grep -qx 'tag:server'; then case "$ROLE" in - control-plane|workload) ;; - runner) - die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;; + control-plane-server|workload-server) ;; + runner-server) + die "role runner-server 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)." ;; *) - # 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 + # This arm owns the VM-host shape too — 'staging-server' by name now, + # plus custom/dev-server --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)." ;; + # like everywhere else outside the two control-plane-managed shapes. + # Mint the metal's key with tag:local. + die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane-server and workload-server 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 @@ -703,9 +726,9 @@ if [ -n "$USERS_FILE" ]; then fi log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" -if [ "$ROLE" = "control-plane" ]; then +if [ "$ROLE" = "control-plane-server" ]; then log "next: rig coolify install --version " -elif [ "$ROLE" = "runner" ]; then +elif [ "$ROLE" = "runner-server" ]; then log "next: rig runner install --repo --version " fi # Every class gets operators: humans always enter as themselves and elevate via diff --git a/commands/coolify-backup-install.sh b/commands/coolify-backup-install.sh index a656472..de17bca 100755 --- a/commands/coolify-backup-install.sh +++ b/commands/coolify-backup-install.sh @@ -77,10 +77,13 @@ done # certainly means the wrong SSH session — but the marker is advisory and may be # absent, so WARN, never die, and warn before the root check so the harness can # prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent). +# Matches the ROLE NAME, so #76's rename reaches it the same way it reaches +# `coolify install` — a pre-rename marker takes the warning branch, which is +# the hard cut behaving as designed rather than a regression. MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" case "$MARKER_LINE" in - ""|"role=control-plane"|"role=control-plane "*) ;; - *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane; if this is the wrong box, stop here and re-check your SSH session." ;; + ""|"role=control-plane-server"|"role=control-plane-server "*) ;; + *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session." ;; esac # --- guards ---------------------------------------------------------------- diff --git a/commands/coolify-install.sh b/commands/coolify-install.sh index 888e838..616fb32 100755 --- a/commands/coolify-install.sh +++ b/commands/coolify-install.sh @@ -51,10 +51,17 @@ fi # are: the harness proves it non-root, and reading a 0644 file needs no # privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures # (repo precedent: users-apply, users-close-root). +# +# This match is on the ROLE NAME, which #76's rename therefore reaches: a box +# bootstrapped before the rename carries 'role=control-plane' and now takes the +# warning branch. That is the hard cut behaving as designed — the marker is +# advisory, the run still proceeds, and the warning names the re-bootstrap that +# makes the marker true again. Nothing here is load-bearing enough to justify +# carrying the old name forever. MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" case "$MARKER_LINE" in - ""|"role=control-plane"|"role=control-plane "*) ;; - *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane' first so the marker tells the truth." ;; + ""|"role=control-plane-server"|"role=control-plane-server "*) ;; + *) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane-server' first so the marker tells the truth." ;; esac [ "$(id -u)" -eq 0 ] || die "must run as root" diff --git a/test/cli.sh b/test/cli.sh index 70e2e8e..5ce8b10 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -34,8 +34,8 @@ check "bare coolify shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify check "bootstrap: role required, exit 2" 2 "role required" "$ROOT/commands/bootstrap.sh" check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap.sh" --help check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato -check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope -check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname +check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload-server --nope +check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload-server --hostname # --ts-tag is REMOVED, not demoted: the tag now comes from the pre-auth key and # rig verifies the GRANTED tag after join. The old runner-refuses-tag:server test # asserted the request-time refusal THROUGH this flag; that policy now lives on @@ -44,25 +44,60 @@ check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bo # at the key (exit 2, a usage error), rather than an "unknown flag" that would # leave an operator guessing where the tag went — value present or absent. check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pre-auth key" \ - "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server + "$ROOT/commands/bootstrap.sh" runner-server --ts-tag tag:server check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \ - "$ROOT/commands/bootstrap.sh" runner --ts-tag + "$ROOT/commands/bootstrap.sh" runner-server --ts-tag # staging is a box TENANT role since #31 (the guest, not the VM host), and it # never joins the tailnet — but --ts-tag on it must still die with a story, # not an "unknown flag": scripts from its trait-preset life may pass it, and # the message must say where both the tag AND the join went. check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \ "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server -# The old staging effective-tag refusal guarded the VM-HOST shape, which now -# rides the traits (custom/dev --class server) — the catch-all tag:server -# refusal must still own that shape, so grep the general die instead. +# The VM-HOST shape has a named role again (staging-server, #76), but it is +# still not one of the two the control plane manages, so the catch-all +# tag:server refusal must own it. Grep-pinned so a deleted guard cannot ship +# green (repo precedent: the login-path refusal below). check "bootstrap: the catch-all tag:server refusal is present" 0 "" \ - grep -q "Only control-plane and workload are managed by the control plane" "$ROOT/commands/bootstrap.sh" + grep -q "Only control-plane-server and workload-server are managed by the control plane" "$ROOT/commands/bootstrap.sh" +# ...and staging-server must NOT have slipped into the allow-list arm beside +# control-plane-server|workload-server. A new preset silently landing there +# would extend every server grant to a VM host, which is the exact shape the +# refusal exists to prevent — and nothing else in the suite would notice. +check "bootstrap: staging-server is not in the tag:server allow-list" 1 "" \ + grep -qE '^ *control-plane-server\|workload-server\)[^#]*staging-server' "$ROOT/commands/bootstrap.sh" + +# --- the role taxonomy (#76): -server names the family, and it was a hard cut - +# Every machine role carries the suffix; custom and workstation deliberately do +# not. Proven by reaching the ROOT CHECK, which is the last thing before the +# converge and therefore proof the name resolved to a preset. +if [ "$(id -u)" -ne 0 ]; then + for r in control-plane-server workload-server runner-server staging-server dev-server; do + check "bootstrap: role $r resolves" 1 "must run as root" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" "$r" --no-users + done +fi +# THE HARD CUT. No aliases: the pre-#76 names are gone, and must fail as +# UNKNOWN rather than quietly resolving to anything. Asserted per name because +# an alias accidentally left in for one role is exactly the shape that survives +# review — the taxonomy reads as complete while one old name still works. +# 'staging' is deliberately absent: it still routes to the TENANT mechanism at +# this point in the stack, and the tenant rename lands in its own change. +for r in control-plane workload runner dev; do + check "bootstrap: the pre-#76 name '$r' is gone (hard cut)" 2 "unknown role" \ + "$ROOT/commands/bootstrap.sh" "$r" +done +# ...but the two roles that legitimately carry no suffix must NOT have been +# swept up in the rename. This is the inverse error and it fails silently: a +# workstation that stopped resolving would only surface at someone's laptop. +check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \ + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation +check "bootstrap: custom keeps its bare name" 2 "--hostname" \ + "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey # --- traits: roles are presets, every trait individually settable (#26) ----- check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato -check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato -check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe -check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon +check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato +check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload-server --host maybe +check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload-server --join carrier-pigeon check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \ "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 @@ -75,7 +110,7 @@ check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ # join=authkey (TS_AUTHKEY fine → falls through to the root check), but # --join login flips it into the TS_AUTHKEY refusal. check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \ - env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev-server --join login # The login-path inverted assertion needs a real tailnet; grep the refusal so a # deleted guard cannot ship green (repo precedent: staging/runner tag greps). check "bootstrap: login-path tagged refusal is present" 0 "" \ @@ -186,33 +221,33 @@ printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$BOOT_USER # 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 + "$ROOT/commands/bootstrap.sh" workload-server 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 + "$ROOT/commands/bootstrap.sh" dev-server --hostname b check "bootstrap: the requirement holds on class=server too" 2 "one of --users" \ - "$ROOT/commands/bootstrap.sh" control-plane --hostname cp + "$ROOT/commands/bootstrap.sh" control-plane-server --hostname cp check "bootstrap: --users needs a value" 2 "needs a value" \ - "$ROOT/commands/bootstrap.sh" workload --users + "$ROOT/commands/bootstrap.sh" workload-server --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 + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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 - + "$ROOT/commands/bootstrap.sh" workload-server --users - # A file that parses to ZERO users (#57). Not a parse error — the parser is # right to accept empty, comments-only and whitespace-only files — but it walks # straight through #51's requirement: `--users ./empty` and `--no-users` @@ -227,23 +262,23 @@ cat > "$BOOT_USERS/comments" <<'USERS' USERS printf ' \n\t\n\n' > "$BOOT_USERS/blank" check "bootstrap: an empty users file exits 2" 2 "names no users" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/empty" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/empty" check "bootstrap: a comments-only users file exits 2" 2 "names no users" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/comments" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/comments" check "bootstrap: a whitespace-only users file exits 2" 2 "names no users" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/blank" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/blank" # The refusal must name --no-users, for the same reason the missing-flag one # does: the root-only box IS reachable, it just has to be said out loud. An # error that only reported "no users" would leave the operator who genuinely # wants root-only with no named way to ask for it. check "bootstrap: the zero-user refusal names --no-users as the way to say it" 2 "pass --no-users to leave this box root-only" \ - "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/empty" + "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/empty" # It must NOT over-refuse: a file that names even one operator passes pre-flight # untouched. Reaching the root check (exit 1) is the proof — same idiom as the # incus precondition's negative cases below. if [ "$(id -u)" -ne 0 ]; then check "bootstrap: a users file naming operators still passes pre-flight" 1 "must run as root" \ - env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload --users "$BOOT_USERS/ok" + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/ok" fi # Scope guard (#57): the refusal is BOOTSTRAP's contract, not the parser's and # not apply's. A standalone `rig users apply` against an emptied file is a real @@ -295,19 +330,19 @@ 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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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 @@ -318,18 +353,18 @@ if [ "$(id -u)" -ne 0 ]; then # 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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" dev-server --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" + "$ROOT/commands/bootstrap.sh" workload-server --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 @@ -399,10 +434,10 @@ if [ "$(id -u)" -ne 0 ]; then # 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: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --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 + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --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-server --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 @@ -410,7 +445,7 @@ 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 --no-users + check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev-server --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 --no-users @@ -457,8 +492,8 @@ check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude|codex|gr # 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=dev-server class=human host=yes join=authkey\n' > "$TEN_FIX/host" +printf 'role=workload-server 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" \ @@ -586,9 +621,9 @@ marker_warns() { # marker_warns — how many warnings fir env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true } MARKER_FIX="$(mktemp -d)" -printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_FIX/workload" -printf 'role=control-plane class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" -printf 'role=control-plane\n' > "$MARKER_FIX/bare-control-plane" +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload" +printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" +printf 'role=control-plane-server\n' > "$MARKER_FIX/bare-control-plane" if [ "$(id -u)" -ne 0 ]; then check "coolify: warns on a non-control-plane marker" 0 "1" \ marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2 @@ -596,7 +631,7 @@ if [ "$(id -u)" -ne 0 ]; then marker_warns "$MARKER_FIX/control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2 # A bare marker line with no trailing traits must read the same as the full # one — the guard must not couple to the marker's field formatting. - check "coolify: a bare 'role=control-plane' line (no traits) stays silent" 0 "0" \ + check "coolify: a bare 'role=control-plane-server' line (no traits) stays silent" 0 "0" \ marker_warns "$MARKER_FIX/bare-control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2 check "coolify: absent marker stays silent (advisory, not a gate)" 0 "0" \ marker_warns "$MARKER_FIX/absent" "$ROOT/commands/coolify-install.sh" --version 4.1.2 @@ -992,11 +1027,11 @@ hostvm_gate() { # hostvm_gate assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" } HOSTVM_FIX="$(mktemp -d)" -printf 'role=dev class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" -printf 'role=workload class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" +printf 'role=dev-server class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" +printf 'role=workload-server class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" # A marker that predates the host= trait (or was hand-edited): present, but it # names no host=. Distinct from an ABSENT marker and it must not read as yes. -printf 'role=workload class=server join=authkey\n' > "$HOSTVM_FIX/traitless" +printf 'role=workload-server class=server join=authkey\n' > "$HOSTVM_FIX/traitless" check "users apply: host=yes passes the box-role gate" \ 0 "" hostvm_gate "$HOSTVM_FIX/yes" check "users apply: host=no fails the box-role gate" \ @@ -1384,8 +1419,8 @@ marker_gate() { # marker_gate assert_marker_human "$2"' _ "$ROOT" "$1" } MARKER_DIR="$(mktemp -d)" -printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server" -printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human" +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/server" +printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/human" check "users close-root: absent marker refuses, names bootstrap as the repair" \ 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" check "users close-root: class=server refuses, names the control plane" \ @@ -1549,7 +1584,7 @@ check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" -- # host itself — /etc/rig/role. The deliberate decision: warn and proceed. # Driven against a fixture marker; counting fires proves silence too. MARK="$WORK/role-marker" -printf 'role=workload class=server host=no join=authkey\n' > "$MARK" +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARK" H2="$WORK/h2"; B2="$WORK/b2" check "flip gate: baseline install" 0 "done" inst "$H2" "$B2" check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \ -- 2.45.2 From 00f6351e2815da596c9a51fcb49efb65c108d863 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 00:21:35 +0000 Subject: [PATCH 2/5] fix(bootstrap): stop telling operators to run bare roles; pin the migration Two review findings from the bot round on this stack. BLOCKING (codex-bot, claude-bot -- both, independently). bootstrap-tenant.sh emits the staging guest's tailnet-join next step at the end of a converge ("box shell -> sudo rig bootstrap workload"), repeats it in usage, and two of its refusals recite the old machine-role list. Fixed here rather than on the stacked tenant PR because THIS is the branch that removes the `workload` role -- shipping it alone would print a next step naming a role that no longer exists. None of those four sites is code that ACCEPTS a role, which is why the rename missed them, and is also what makes them the worse failure. A stale flag dies immediately with a usage error. A stale next-step is copy-pasted by a human onto a DIFFERENT box, minutes after the run that printed it reported success, and dies there with no thread back to the cause. So test/cli.sh sweeps every shipped script under bin/ and commands/ for `rig bootstrap ` rather than pinning the four known sites: the next instance of this class will be somewhere else. Proven non-vacuous -- reintroducing the bare `workload` next-step turns the suite red (412/1), restoring it turns it green (413/0). NON-BLOCKING (claude-bot). The migration story was documented and untested: every marker fixture was renamed alongside the code, so nothing asserted what a real pre-rename box does. A `role=control-plane` fixture now pins both halves of the promise -- such a box WARNS on the coolify verbs (its marker no longer names a role that exists) and is never REFUSED. Both halves matter: a rename that turned this into a refusal would break the exact boxes the CHANGELOG promises keep working, on the command that installs the control plane. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 +++++++++ commands/bootstrap-tenant.sh | 10 +++++----- test/cli.sh | 30 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b243c..f7c1ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,15 @@ on the way to cutting its first release, and this file starts there. branch until it is re-bootstrapped. That check has always been advisory and never a gate, so the run still proceeds and the warning names the repair. + The rename also reaches every string that *tells an operator to run a role*, + not just the code that accepts one — `bootstrap-tenant.sh` emits the staging + guest's tailnet-join next step (`sudo rig bootstrap workload-server`), and + two of its refusals recite the machine-role list. A stale next-step is worse + than a stale flag: it fails when someone copy-pastes it, on a different box, + minutes after the run that printed it reported success. `test/cli.sh` sweeps + every shipped script for pre-rename role names rather than pinning the known + sites, because the next instance of this will be somewhere else. + `dev-server` is `class=human`, which reads like a contradiction and is not: the suffix names the family, the class names the root-SSH door policy, and operators enter a dev box as themselves so `close-root` shuts its door. The diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index a09f23b..f5ea140 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -10,7 +10,7 @@ # 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 +# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the # creds split box#69 designed. # Convergent: safe to re-run; a second run changes nothing. set -euo pipefail @@ -43,7 +43,7 @@ tenant on top, and re-runs converge an existing box to a new spec. 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. + workload-server` with a tagged pre-auth key. --user the tenant user the box seed created (default: the role's name; staging defaults to `ops`) @@ -77,10 +77,10 @@ while [ $# -gt 0 ]; do # 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 ;; + die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 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 "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;; *) die "unknown flag: $1" 2 ;; esac done @@ -384,7 +384,7 @@ 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" + log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --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/test/cli.sh b/test/cli.sh index 5ce8b10..1ff95ac 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -93,6 +93,19 @@ check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \ env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation check "bootstrap: custom keeps its bare name" 2 "--hostname" \ "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey +# NOTHING may still TELL an operator to run a pre-#76 role. The rename is a +# hard cut, so a next-step string, a usage line or a refusal that still recites +# a bare role name is a command that fails when someone copy-pastes it — and it +# fails later and further from the cause than a broken flag would, because it +# fails on a different box, minutes after this run reported success. The tenant +# script is the one that emits the staging guest's workload-join next step, so +# it is where this bites first (caught in review on #80, fixed here where the +# rename actually happens). Swept across every shipped script rather than +# asserted at the one known site: the next instance of this will be somewhere +# else, and a site-specific check would not see it. +check "roles: no shipped script tells an operator to run a pre-#76 role name" 1 "" \ + grep -rnE "rig bootstrap (control-plane|workload|runner|dev)( |'|\"|$)" \ + "$ROOT/bin/rig" "$ROOT/commands/" # --- traits: roles are presets, every trait individually settable (#26) ----- check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato @@ -624,6 +637,12 @@ MARKER_FIX="$(mktemp -d)" printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload" printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" printf 'role=control-plane-server\n' > "$MARKER_FIX/bare-control-plane" +# A PRE-#76 marker, verbatim as a real box bootstrapped before the rename +# carries it. This is the one fixture that must keep its old spelling: the +# CHANGELOG promises such a box takes the warning branch and keeps working, +# and until this existed nothing asserted it — every other fixture here was +# renamed with the code, so the migration story was documented and untested. +printf 'role=control-plane class=server host=no join=authkey\n' > "$MARKER_FIX/pre-rename-cp" if [ "$(id -u)" -ne 0 ]; then check "coolify: warns on a non-control-plane marker" 0 "1" \ marker_warns "$MARKER_FIX/workload" "$ROOT/commands/coolify-install.sh" --version 4.1.2 @@ -635,6 +654,17 @@ if [ "$(id -u)" -ne 0 ]; then marker_warns "$MARKER_FIX/bare-control-plane" "$ROOT/commands/coolify-install.sh" --version 4.1.2 check "coolify: absent marker stays silent (advisory, not a gate)" 0 "0" \ marker_warns "$MARKER_FIX/absent" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + # The migration story, pinned in both halves: a pre-#76 control plane WARNS + # (its marker no longer names a role that exists) but is never refused. Both + # halves matter — a rename that turned this into a refusal would break the + # exact boxes the CHANGELOG promises keep working, and it would do it on the + # command that installs the control plane. + check "coolify: a PRE-#76 'role=control-plane' marker warns (migration)" 0 "1" \ + marker_warns "$MARKER_FIX/pre-rename-cp" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify: ...and is still never refused" 1 "must run as root" \ + env RIG_ROLE_MARKER="$MARKER_FIX/pre-rename-cp" "$ROOT/commands/coolify-install.sh" --version 4.1.2 + check "coolify backup: a PRE-#76 'role=control-plane' marker warns (migration)" 0 "1" \ + marker_warns "$MARKER_FIX/pre-rename-cp" "$ROOT/commands/coolify-backup-install.sh" # The warning must stay a warning: the run proceeds past it and stops at the # root check (exit 1), never turned into a marker refusal. check "coolify: the marker warns but never refuses" 1 "must run as root" \ -- 2.45.2 From 94d9628766b163d0bd9b9756bc1ec90fec10388b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 00:05:19 +0000 Subject: [PATCH 3/5] feat(bootstrap)!: box tenant roles carry a -box suffix The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap ` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 23 +++++++ README.md | 49 +++++++------ bin/rig | 13 ++-- commands/bootstrap-tenant.sh | 99 ++++++++++++++------------- commands/bootstrap.sh | 10 +-- commands/lib/tenant-config.sh | 34 +++++---- test/cli.sh | 125 ++++++++++++++++++---------------- 7 files changed, 201 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7c1ad1..3f72587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ on the way to cutting its first release, and this file starts there. ### Changed +- **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other + half of the rename below. `claude` → `claude-box`, `codex` → `codex-box`, + `grok` → `grok-box`, `staging` → `staging-box`, so a role name always says + which family it belongs to: `-server` builds a fleet machine, `-box` + converges a guest a box minted. + + **The role carries the suffix; nothing inside the guest does.** A tenant user + is the account the box *seed* created (`BOX_USER`) and each agent CLI reads + its own dotdir, so `claude-box` still converges the `claude` user and still + writes `~/.claude/CLAUDE.md`. The suffix is rig's word for "this is a guest", + not a rename of anything the guest contains — no path, no account, and no CLI + binary moved. + + **Migration: hard cut, no aliases**, same as the machine roles. The old names + are refused as unknown tenant roles at both entrypoints — `rig bootstrap + ` and the tenant script directly — and the suite asserts each one at + both, because an alias left in for a single tenant is exactly the shape that + survives review: the taxonomy reads complete while one old name still quietly + converges. The practical consequence is cross-repo: a box seed carrying + `BOX_BOOTSTRAP_ROLE="claude"` now fails its own mint-time bootstrap, so + heavy-duty/box#125 (closing heavy-duty/box#123) updates the seeds and must + land after this. + - **BREAKING: machine roles carry a `-server` suffix, and the VM host gets its name back** (#76) — rig builds two kinds of thing that sit on opposite sides of a trust boundary: tailnet **machines** it converges, and **guests** a box diff --git a/README.md b/README.md index 23890ea..50de763 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,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. 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). +guest, the same verb converges the **box tenants** — claude-box, codex-box, +grok-box, staging-box — 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 @@ -93,9 +94,9 @@ itself is untouched — what bootstrap converged stays converged. ### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a -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.) +second run changes nothing. (The box TENANT roles — `claude-box`, `codex-box`, +`grok-box`, `staging-box` — share the verb but are their own family; the +`-box` suffix says so. See *the box tenants* below.) ```sh rig bootstrap control-plane-server --hostname my-coolify-box --users ./users @@ -167,8 +168,8 @@ 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 +`--users` does **not** reach the box TENANT roles (`claude-box`, `codex-box`, +`grok-box`, `staging-box`). 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 @@ -373,15 +374,15 @@ 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 +### `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 +rig bootstrap claude-box # or codex-box, grok-box — the agent tenants +rig bootstrap staging-box # the server tenant (docker + sshd hardening) +rig bootstrap claude-box --user dev # when the seed's BOX_USER differs ``` **The layering** (rig#31 ↔ box#81): a box template stops being where tenant @@ -397,12 +398,18 @@ 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) | +| tenant role | user | what lands | +|---------------|----------|------------| +| `claude-box` | `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-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` | +| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` | +| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) | + +**The role carries the suffix; the user does not.** A tenant user is the +account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir +hangs off it — `claude-box` converges the `claude` user and writes +`~/.claude/CLAUDE.md`. The suffix is rig's word for "this is a guest", not a +rename of anything inside the box, so nothing in the guest's filesystem moved. 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 @@ -412,14 +419,14 @@ hardening. The CLI also lands on the **system** PATH (`/usr/local/bin`): 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 +(`box exec … rig bootstrap claude-box`), 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-server --hostname --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 +`rig bootstrap staging-box` still converges docker + hardening and leaves the workload marker alone — the machine role is the truer statement of what the box became. @@ -1033,6 +1040,6 @@ the real converge belongs to the rehearsal. The end-to-end rehearsal is a throwaway VM/container: pristine Debian → install → `bootstrap workload-server` 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 +is the same shape, creds-free: container + seed user → `rig bootstrap claude-box` +/ `staging-box` → 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 e0a2b02..3de7315 100755 --- a/bin/rig +++ b/bin/rig @@ -33,13 +33,14 @@ commands: tenant roles); custom and workstation take none. 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 + bootstrap [--user ] + The box TENANT roles: converge a box-minted guest. The '-box' suffix + names the family (a guest, vs the '-server' machine roles above). + claude-box|codex-box|grok-box 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-server'. Creds-free and non-interactive: box + never run box setup-host inside a box). staging-box lands box#69's + server posture — docker + sshd hardening; its tailnet join stays + operator-run via 'rig bootstrap workload-server'. 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. diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index f5ea140..66ac4b5 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# rig bootstrap — the box TENANT roles: what a +# rig bootstrap — the box TENANT +# roles ('-box' names the family: a guest, vs the '-server' machine 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 @@ -8,8 +9,8 @@ # 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 +# bootstrap claude-box'), so every path here is non-interactive and nothing joins +# or admits — no tailnet, no keys, no prompts. staging-box's tailnet join stays # operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the # creds split box#69 designed. # Convergent: safe to re-run; a second run changes nothing. @@ -21,7 +22,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" # 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) +. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant) log() { printf 'rig-bootstrap: %s\n' "$*"; } warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } @@ -29,27 +30,29 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--user ] +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, …), + claude-box|codex-box|grok-box + 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 + staging-box 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-server` with a tagged pre-auth key. --user the tenant user the box seed created (default: the - role's name; staging defaults to `ops`) + role's name minus the suffix; staging-box 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 +them at mint (`box exec … rig bootstrap claude-box`). 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 @@ -58,10 +61,10 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - claude|codex|grok|staging) shift ;; + claude-box|codex-box|grok-box|staging-box) 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 ;; + "") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|staging-box)" 2 ;; + *) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|staging-box)" 2 ;; esac TENANT_USER="$(tenant_user "$ROLE")" @@ -74,13 +77,13 @@ while [ $# -gt 0 ]; do --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 + # from the box seed, and the one trait-shaped thing a staging-box 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-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;; + die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging-box's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 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-server', where the tag comes from the pre-auth key." 2 ;; + die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging-box's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;; *) die "unknown flag: $1" 2 ;; esac done @@ -97,28 +100,28 @@ done # 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. +# Names the staging PAIR out loud, because whoever lands here has the two +# halves confused: the metal is `staging-server`, the guest `staging-box`. # - 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 +# - class=server with host=no (staging-box only) → PROCEED, and leave the +# marker alone: that is the 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 +# machine rig built on purpose, and staging-box 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')." ;; + die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;; *class=*) - if [ "$ROLE" != "staging" ]; then + if [ "$ROLE" != "staging-box" ]; 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." ;; + die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (class=server host=no). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." ;; esac ;; esac @@ -161,13 +164,13 @@ 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 + claude-box) + # The claude-box 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) + codex-box|grok-box) apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;; - staging) + staging-box) # 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. @@ -177,13 +180,13 @@ esac # 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 +if [ "$ROLE" != "staging-box" ]; 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 +# Every tenant gets docker (the templates all carried it; staging-box's workloads 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 @@ -214,9 +217,9 @@ else warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install" fi -# --- node (claude, codex) ---------------------------------------------------- +# --- node (claude-box, codex-box) ---------------------------------------------------- # 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 +# upstream when the template was written); the claude-box 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 @@ -224,7 +227,7 @@ node_ok() { 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 [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then if node_ok; then log "node $(node --version) already present" else @@ -241,10 +244,10 @@ fi # '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). +# what cost the last drill (the grok-box template's scar). CLI="" CLI_SRC="" case "$ROLE" in - claude) + claude-box) CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude" if [ ! -e "$CLI_SRC" ]; then log "installing the Claude Code CLI as ${TENANT_USER}" @@ -252,7 +255,7 @@ case "$ROLE" in else log "claude CLI already installed" fi ;; - codex) + codex-box) CLI=codex if ! command -v codex >/dev/null 2>&1; then log "installing the Codex CLI (npm global)" @@ -261,7 +264,7 @@ case "$ROLE" in log "codex CLI already installed" fi CLI_SRC="$(npm prefix -g)/bin/codex" ;; - grok) + grok-box) # 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 @@ -273,7 +276,7 @@ case "$ROLE" in else log "grok CLI already installed" fi ;; - staging) ;; # no agent lives on the staging tenant + staging-box) ;; # no agent lives on the staging-box tenant esac if [ -n "$CLI" ]; then [ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?" @@ -289,11 +292,11 @@ if [ -n "$CLI" ]; then # point: the line must expand in the USER's shell, not here. # shellcheck disable=SC2016 case "$ROLE" in - claude) + claude-box) append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;; - codex) + codex-box) append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;; - grok) + grok-box) append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;; esac fi @@ -324,11 +327,11 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then rm -f "$CTX_TMP" fi -# --- claude shell niceties --------------------------------------------------- -# The claude template shipped zsh + oh-my-zsh + tmux mouse mode; they move with +# --- claude-box shell niceties --------------------------------------------------- +# The claude-box 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 [ "$ROLE" = "claude-box" ]; 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}" @@ -350,20 +353,20 @@ if [ "$ROLE" = "claude" ]; then append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on' fi -# --- staging server posture -------------------------------------------------- +# --- staging-box 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 +# the SAME code the machine roles use (lib/sshd.sh) — the staging-box 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 +if [ "$ROLE" = "staging-box" ]; 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 +# host=no so `rig users apply` box-role gating keeps working. staging-box 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. @@ -383,7 +386,7 @@ else fi log "done — tenant ${ROLE}, user ${TENANT_USER}" -if [ "$ROLE" = "staging" ]; then +if [ "$ROLE" = "staging-box" ]; then log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --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" diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 1d9a7de..17c8bfc 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -27,7 +27,7 @@ usage: rig bootstrap | --no-users) [--hostname ] [--class ] [--host ] [--join ] - rig bootstrap [--user ] + rig bootstrap [--user ] (the box TENANT roles — see their own --help; they take no --users, see below) @@ -51,7 +51,7 @@ 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 +--users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|staging-box). 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 @@ -109,15 +109,15 @@ EOF ROLE="${1:-}" case "$ROLE" in control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;; - claude|codex|grok|staging) + claude-box|codex-box|grok-box|staging-box) # 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-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; - *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;; + "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|staging-box)" 2 ;; + *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|staging-box)" 2 ;; esac # Role→traits map — the single place a role's shape is declared (issue #26). diff --git a/commands/lib/tenant-config.sh b/commands/lib/tenant-config.sh index cd6950e..7c6938c 100644 --- a/commands/lib/tenant-config.sh +++ b/commands/lib/tenant-config.sh @@ -1,30 +1,34 @@ #!/usr/bin/env bash -# Shared parameters for the box TENANT roles (claude, codex, grok, staging) — +# Shared parameters for the box TENANT roles (claude-box, codex-box, grok-box, +# staging-box) — the '-box' suffix names the FAMILY (a guest a box mints, vs the +# '-server' machine roles rig converges); see #76. # 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`. +# agent tenants are named after their agent (minus the suffix — the USER is not +# the role); staging-box keeps box#69's `ops`. tenant_user() { case "$1" in - claude) printf 'claude' ;; - codex) printf 'codex' ;; - grok) printf 'grok' ;; - staging) printf 'ops' ;; + claude-box) printf 'claude' ;; + codex-box) printf 'codex' ;; + grok-box) printf 'grok' ;; + staging-box) 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). +# agent CLI reads its own instructions file from its own dotdir (named for the +# agent, not the role — the dotdir is the CLI's, and the suffix is rig's); +# staging-box 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" ;; + claude-box) printf '%s/.claude/CLAUDE.md' "$2" ;; + codex-box) printf '%s/.codex/AGENTS.md' "$2" ;; + grok-box) printf '%s/.grok/AGENTS.md' "$2" ;; *) return 1 ;; esac } @@ -32,27 +36,27 @@ tenant_context_path() { # 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. +# staging-box 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) + claude-box) 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) + codex-box) 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) + grok-box) 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 diff --git a/test/cli.sh b/test/cli.sh index 1ff95ac..a5f4729 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -47,12 +47,12 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr "$ROOT/commands/bootstrap.sh" runner-server --ts-tag tag:server check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \ "$ROOT/commands/bootstrap.sh" runner-server --ts-tag -# staging is a box TENANT role since #31 (the guest, not the VM host), and it +# staging-box is a box TENANT role (the guest, not the VM host), and it # never joins the tailnet — but --ts-tag on it must still die with a story, # not an "unknown flag": scripts from its trait-preset life may pass it, and # the message must say where both the tag AND the join went. -check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \ - "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server +check "bootstrap: staging-box + removed --ts-tag exits 2" 2 "never join the tailnet" \ + "$ROOT/commands/bootstrap.sh" staging-box --ts-tag tag:server # The VM-HOST shape has a named role again (staging-server, #76), but it is # still not one of the two the control plane manages, so the catch-all # tag:server refusal must own it. Grep-pinned so a deleted guard cannot ship @@ -80,8 +80,8 @@ fi # UNKNOWN rather than quietly resolving to anything. Asserted per name because # an alias accidentally left in for one role is exactly the shape that survives # review — the taxonomy reads as complete while one old name still works. -# 'staging' is deliberately absent: it still routes to the TENANT mechanism at -# this point in the stack, and the tenant rename lands in its own change. +# 'staging' is deliberately absent HERE: it is a TENANT name, and its own hard +# cut is asserted in the tenant section below, at both entrypoints. for r in control-plane workload runner dev; do check "bootstrap: the pre-#76 name '$r' is gone (hard cut)" 2 "unknown role" \ "$ROOT/commands/bootstrap.sh" "$r" @@ -416,7 +416,7 @@ check "rig usage documents the bootstrap users flags" 0 "(--users | --no- # 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" + "$ROOT/commands/bootstrap.sh" claude-box --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) -------------------------------------------- @@ -451,13 +451,13 @@ if [ "$(id -u)" -ne 0 ]; then check "bootstrap: --users file reaches the root check" 1 "must run as root" \ env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --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-server --no-users - # staging dispatches to the tenant mechanism now; reaching ITS root check + # staging-box dispatches to the tenant mechanism; 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 # before the root check, and the machine running this harness may well have # 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: staging-box 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-box check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev-server --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" \ @@ -466,7 +466,7 @@ else echo "skip: bootstrap non-root refusals (running as root)" fi -# --- box tenant roles (#31): claude|codex|grok|staging ------------------------ +# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|staging-box --- # What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh), # parameterized per tenant through lib/tenant-config.sh, dispatched from # bootstrap.sh so `rig bootstrap ` stays the single entrypoint. The real @@ -474,12 +474,23 @@ fi # 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. +# THE HARD CUT, tenant half (#76). The pre-rename names are gone and must fail +# as UNKNOWN — asserted per name, because an alias left in for one tenant is the +# shape that survives review: the taxonomy reads complete while one old name +# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has +# its own dispatch list and a name could survive in one and not the other. +for r in claude codex grok staging; do + check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \ + "$ROOT/commands/bootstrap-tenant.sh" "$r" + check "tenant: the pre-#76 name '$r' is gone (bootstrap dispatch)" 2 "unknown role" \ + "$ROOT/commands/bootstrap.sh" "$r" +done 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' +check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude-box --nope +check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user +check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --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. @@ -488,19 +499,19 @@ check "tenant: dockerd effective-state assert is present" 0 "" \ # 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 + "$ROOT/commands/bootstrap-tenant.sh" claude-box --class human check "tenant: --hostname dies the same way" 2 "have no traits" \ - "$ROOT/commands/bootstrap-tenant.sh" staging --hostname my-guest + "$ROOT/commands/bootstrap-tenant.sh" staging-box --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 +check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|staging-box" \ + "$ROOT/commands/bootstrap.sh" claude-box --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 +# VM host (host=yes) refuses for every tenant — and names the staging PAIR, +# because whoever lands here has the two halves confused and wants the metal +# (staging-server). An agent tenant refuses ANY machine-role box; staging-box +# tolerates ONLY class=server with host=no — that is the 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. @@ -508,28 +519,28 @@ TEN_FIX="$(mktemp -d)" printf 'role=dev-server class=human host=yes join=authkey\n' > "$TEN_FIX/host" printf 'role=workload-server 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 +printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant" +check "tenant: staging-box refuses a non-server machine box" 1 "non-server machine role" \ + env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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 + env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude-box +check "tenant: the host refusal sends you to the metal half of the pair" 1 "staging-server" \ + env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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 + env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box 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: claude-box parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box + check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box + check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box + check "tenant: staging-box 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-box 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 + env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box else echo "skip: tenant non-root refusals (running as root)" fi @@ -545,24 +556,24 @@ 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 +check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box +check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box +check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude +check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex +check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok +check "tenant params: staging has no context file" 1 "" tpath staging-box /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 +check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box +check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box +check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box +check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box +check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box +check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box +check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box +check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box +check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box +check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box # 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. @@ -572,15 +583,15 @@ 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 +# staging-box'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 "" \ +check "tenant: staging-box 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 +# it must ANSWER as the tenant user (the grok-box 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 "" \ @@ -1473,7 +1484,7 @@ fi # post-close-root state, strictly harder than what bootstrap installs. Byte-grep # 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 +# and the staging-box 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" -- 2.45.2 From b1c1357f2b954b06a591bd6630408aeff3391deb Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 10:02:50 +0000 Subject: [PATCH 4/5] feat(users)!: --class human|server becomes --root-door closed|open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trait was named for who lives on a box; what it decides is whether root SSH stays open as the control plane's automation door. Those are different questions, and `dev-server` proved it: an unattended VM-host appliance nobody lives on, correctly class=human because its root door must close. After #76 gave `-server` the job of naming the machine family, that box carried a suffix saying server and a trait saying human. `dev-server --root-door closed` says what is true, once. Unlike #76's role rename this field is read back on live machines, so the compat read is mandatory rather than courteous: one resolver, root_door_of, reads both vocabularies and every consumer goes through it — close-root's gate, apply's note, and bootstrap-tenant's machine-marker guard, which used the presence of `class=` as its "is this a real fleet machine?" test and would otherwise have let a tenant converge clobber a live box. New markers are written as `root-door=` only. Markers carrying both fields in disagreement, or neither, fail closed with a re-run-bootstrap repair. Fixture markers are kept deliberately at the retired spelling (the convention #76's pre-rename-cp fixture established) and pinned at both consumers; deleting the compat arm turns ten checks red. Closes #77 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 90 ++++++++++++++- LABELS.md | 4 +- README.md | 199 +++++++++++++++++++++------------ commands/bootstrap-tenant.sh | 72 +++++++----- commands/bootstrap.sh | 108 ++++++++++-------- commands/lib/sshd.sh | 31 ++--- commands/lib/users-config.sh | 124 +++++++++++++++----- commands/users-apply.sh | 38 +++++-- commands/users-close-root.sh | 30 +++-- test/cli.sh | 211 ++++++++++++++++++++++++++++------- 10 files changed, 646 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f72587..74a878a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,83 @@ on the way to cutting its first release, and this file starts there. ### Changed +- **BREAKING: `--class human|server` is now `--root-door closed|open`** (#77) — + the trait was named for who *lives on* a box; what it decides is one thing, + and it is not occupancy: whether root SSH stays open as the control plane's + automation door, or `rig users close-root` shuts it once named operators can + get in. The roles had been saying so for a while. `dev-server` is an + unattended VM-host appliance — nobody lives there, operators visit it to mint + boxes and leave — and by occupancy it is plainly a server. It was + `class=human` anyway, and correctly so, because operators enter it *as + themselves* and its root door must close. The trait was right; its name + described the wrong axis. + + That stayed cheap until a second thing wanted the word "server". After #76 + the `-server` suffix names the machine *family*, so `dev-server` carried a + suffix saying server and a trait saying human, and nothing in the name told a + reader that the two words were answering unrelated questions. `dev-server + --root-door closed` says exactly what is true, and `-server` means one thing + everywhere. The values moved with the name: `human` → `closed`, `server` → + `open`, and the marker field follows as `root-door=`. + + Other names were considered. `--root-door open|closed` describes a + *destination* rather than the state at bootstrap time — bootstrap leaves root + SSH open on every box, and the door only shuts later, when `close-root` runs + — so `--root-door closes|stays` was on the table for naming the fate as a + verb, as was `--automation-door yes|no` for naming the thing itself. Both were + rejected in favour of the plainer pair: the marker is already a declaration of + *intent* rather than a report of observed state everywhere else in this repo + (`host=yes` claims a box hosts VMs; #58 settled that the marker's claim wins + over probing the machine), so a trait that states the door's designed end + state is consistent with how every other field is read. Every string that + prints the trait says "once operators exist" or names `close-root` explicitly, + so the tense never has to be inferred. + + **Old markers still resolve, permanently, and that is the substance of this + change.** Unlike #76's role rename — role names are informational, nothing + reads them back — this field is written into `/etc/rig/role` and read *from* + there on live machines, where it gates `rig users close-root`. Every box + bootstrapped before this carries `class=human` or `class=server` and carries + it until someone re-bootstraps it, which for a fleet is never. Dropping the + old read would have broken in both directions at once and both are incidents: + a machine whose door is supposed to close loses the ability to close it, and + — through `bootstrap-tenant`'s machine-marker guard, which used the presence + of `class=` as its "is this a real fleet machine?" test — a live box stops + looking like a machine at all, so a tenant converge sails past the refusal + that exists to protect it and clobbers its marker. That second one is the + fail-*open* direction and was the least obvious part of the change. + + So one resolver, `root_door_of`, reads both vocabularies, and every consumer + goes through it — close-root's gate, apply's root-SSH note, and the tenant + guard — because a compat read that lives at three call sites is three chances + to drift. `root-door=` wins where both fields are present and agree; + `class=` answers alone on every pre-#77 marker. A marker carrying **both and + disagreeing** resolves to a refusal rather than a winner: bootstrap writes one + line fresh and never produces that state, so a marker in it was hand-edited, + and rig declines to arbitrate between two equally-authored claims about a root + door. A marker naming **neither** refuses too, unchanged from before. Both + refusals fail closed, which here means the door stays open and the operator is + told to re-run bootstrap — never a door welded shut on a machine whose only + entrance it was. + + **New markers are written in the new vocabulary only.** Writing both would + keep an old rig reading a new marker, but it would entrench the retired + spelling on every box rig ever converges and make the disagreement case + reachable from rig's own hand instead of only from a text editor. The compat + obligation runs the other way and only the other way: new rig reads old + markers. The bounded consequence to know about is downgrade — flipping a + box back to a pre-#77 rig with `rig use` leaves that older code unable to + recognize the new marker; the flip already WARNS on a bootstrapped host (#35), + and re-running bootstrap under whichever rig you settle on rewrites the line. + + The suite proves the compat read rather than asserting it. Fixture markers are + kept **deliberately** at the retired spelling — byte for byte as a real + pre-#77 box reads, the same convention #76's `pre-rename-cp` fixture + established — and pinned at both consumers: `close-root` still passes on + `class=human` and still *refuses* on `class=server`, with today's refusal text + naming today's flag, and the tenant guard still recognizes a pre-#77 machine + marker as a machine. Deleting the compat arm turns ten of them red. + - **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other half of the rename below. `claude` → `claude-box`, `codex` → `codex-box`, `grok` → `grok-box`, `staging` → `staging-box`, so a role name always says @@ -81,12 +158,13 @@ on the way to cutting its first release, and this file starts there. every shipped script for pre-rename role names rather than pinning the known sites, because the next instance of this will be somewhere else. - `dev-server` is `class=human`, which reads like a contradiction and is not: - the suffix names the family, the class names the root-SSH door policy, and - operators enter a dev box as themselves so `close-root` shuts its door. The - two axes genuinely share the word "server", which is a wart — #77 renames the - class trait to what it actually controls, and is kept separate because it - reaches markers on live machines that guard root SSH. + `dev-server` was `class=human` when this landed, which read like a + contradiction and was not: the suffix names the family, the class named the + root-SSH door policy, and operators enter a dev box as themselves so + `close-root` shuts its door. The two axes genuinely shared the word "server", + which was a wart — #77, above, renames the trait to what it actually controls + and retires it. It stayed a separate change because it reaches markers on live + machines that guard root SSH, and so needed a compat read this rename did not. ## 0.2.0 — 2026-07-19 diff --git a/LABELS.md b/LABELS.md index 3755d2f..913a9ef 100644 --- a/LABELS.md +++ b/LABELS.md @@ -38,7 +38,7 @@ All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. | Label | Covers | |---|---| | `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node | -| `scope:users` | `commands/users-*` — the class model, apply/status, close-root | +| `scope:users` | `commands/users-*` — the root-door model, apply/status, close-root | | `scope:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status | | `scope:coolify` | `commands/coolify-*` — Coolify and its backup install | | `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof | @@ -74,7 +74,7 @@ gh label create "stale" --color B60205 --description "No activity gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force gh label create "scope:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force -gh label create "scope:users" --color C5DEF5 --description "users-* — class model, apply/status, close-root" --force +gh label create "scope:users" --color C5DEF5 --description "users-* — root-door model, apply/status, close-root" --force gh label create "scope:runner" --color C5DEF5 --description "runner-* — GitHub runner lifecycle" --force gh label create "scope:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force diff --git a/README.md b/README.md index 50de763..806c0ab 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ rig bootstrap workload-server --hostname my-prod-box --users ./users rig bootstrap runner-server --hostname my-ci-box --users ./users rig bootstrap dev-server --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 +rig bootstrap custom --hostname my-vm-host --root-door open --host yes --join authkey --users ./users ``` - `--users ` / `--no-users` — **required**, one or the other: the users @@ -112,17 +112,19 @@ rig bootstrap custom --hostname my-vm-host --class server --host yes --join auth (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 - the users phase (see *The identity model* below) +- `--root-door ` — what happens to root SSH after the users + phase: `closed` means `rig users close-root` shuts it once named operators + can get in, `open` means it stays as the control plane's automation door + (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 +`rig bootstrap` already knows everything else about what a box *is* — the +root-door policy, 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 @@ -133,16 +135,16 @@ rig bootstrap dev-server --hostname my-dev-box --no-users # deliberately `--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 +that marker (`root-door=` 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 +Required on **every** role, `root-door=open` included. A bootstrapped box with +no users converges to a box only root can enter — on `root-door=closed` a +half-built machine, and on `root-door=open` 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 @@ -151,7 +153,7 @@ 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 +`--root-door`: 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. A file that names **no users** is refused there too — empty, comments-only and whitespace-only files all parse fine, but bootstrapping @@ -184,20 +186,20 @@ presets nothing and requires `--hostname` plus all three traits. | trait | values | what it drives | |---------|--------------------|----------------| -| `class` | `human`, `server` | root SSH's fate once operators exist — human closes it, server keeps it as the control plane's automation door | +| `root-door` | `closed`, `open` | root SSH's fate once operators exist — `closed` shuts it via `rig users close-root`, `open` keeps it as the control plane's automation door | | `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and, on `yes`, installing the `box` CLI + running box's `setup-host` | | `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) | -| role | class | host | join | tailnet tag | -|------------------------|--------|------|---------|-------------| -| `control-plane-server` | server | no | authkey | `tag:server` | -| `workload-server` | server | no | authkey | `tag:server` | -| `runner-server` | server | no | authkey | `tag:ci` — refuses `tag:server` | -| `staging-server` | server | yes | authkey | `tag:local` — refuses `tag:server` | -| `dev-server` | human | yes | authkey | `tag:local` — refuses `tag:server` | -| `workstation` | human | yes | login | untagged — any tag refused | +| role | root-door | host | join | tailnet tag | +|------------------------|-----------|------|---------|-------------| +| `control-plane-server` | open | no | authkey | `tag:server` | +| `workload-server` | open | no | authkey | `tag:server` | +| `runner-server` | open | no | authkey | `tag:ci` — refuses `tag:server` | +| `staging-server` | open | yes | authkey | `tag:local` — refuses `tag:server` | +| `dev-server` | closed | yes | authkey | `tag:local` — refuses `tag:server` | +| `workstation` | closed | yes | login | untagged — any tag refused | -> **The suffix names the family, not the class** (#76). rig builds two kinds +> **The suffix names the family, not the door policy** (#76). rig builds two kinds > of thing on opposite sides of a trust boundary — tailnet **machines** it > converges, and **guests** a box mints — and for a while nothing in a role > name said which you were asking for. `staging` made that concrete: the word @@ -213,13 +215,16 @@ presets nothing and requires `--hostname` plus all three traits. > joins by interactive login, comes up user-owned and untagged, and the > tailnet never manages it. > -> **`dev-server` is `class=human`, and that is not a contradiction** — though -> it is a wart. The suffix names the *family*; the class names the *root-SSH -> door policy*, and operators enter a dev box as themselves, so `close-root` -> shuts its door. Two orthogonal axes that happen to share the word "server". -> [#77](https://github.com/heavy-duty/rig/issues/77) renames the class trait -> to what it actually controls, which is the real fix; it touches markers on -> live machines, so it is deliberately not folded in here. +> **`dev-server --root-door closed` says what is true, and says it once** +> (#77). The suffix names the *family* — a fleet machine — and the trait names +> the *door*: operators enter a dev box as themselves, so `close-root` shuts +> its door. Until #77 this trait was `--class human|server`, which named the +> wrong axis (who lives on the box) and made `dev-server` read as a +> `class=human` contradiction: one word, "server", doing duty on two unrelated +> questions. Nobody *lives* on a dev box; what distinguishes it is that its +> root door closes. Markers written before the rename still say +> `class=human|server` and are still read — see *The root-door trait was +> renamed* below. > > **This was a hard cut — no aliases.** Old role names stop working, and a > box bootstrapped under one is re-bootstrapped rather than migrated. Two @@ -236,9 +241,9 @@ the only shapes it manages — every other role refuses an effective `tag:server` after join, one rule instead of per-role exceptions. After the tag verification passes, bootstrap writes `/etc/rig/role` — one -line, `role=… class=… host=… join=…` — recording the **effective** traits, +line, `role=… root-door=… host=… join=…` — recording the **effective** traits, overrides and all, so an overridden role never lies to the commands that read -the marker later (`rig users` keys root policy off `class=`). Written +the marker later (`rig users` keys root policy off `root-door=`). Written post-join and cmp-guarded, so a marker never describes a box that failed to become what it claims. @@ -330,14 +335,14 @@ hard, post-join error. 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-server` — -is the `staging-server` role (`--class server --host yes --join authkey`; see -the note above). It is `class=server`: an unattended VM appliance — operators +is the `staging-server` role (`--root-door open --host yes --join authkey`; see +the note above). It is `root-door=open`: 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-server`. -On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving +On a VM-hosting 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` when bootstrap returns. rig **delegates to box; it @@ -368,11 +373,11 @@ 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-server` 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. +`dev-server` is the closed-door VM-hosting shape — `tag:local`, box CLI installed as +above, operators entering as themselves (`--root-door open` turns it into the +unattended VM-host appliance) — and `workstation` is the machine at the keyboard +end of all the SSH connections: `root-door=closed`, `join=login`, entering the +tailnet as *your* device rather than the fleet's. ### `rig bootstrap ` — the box tenants @@ -403,7 +408,7 @@ holds the whole per-tenant table), not four hand-maintained scripts: | `claude-box` | `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-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` | | `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` | -| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) | +| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) | **The role carries the suffix; the user does not.** A tenant user is the account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir @@ -441,7 +446,7 @@ silently breaks its networking (box#80). The note lives in 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 +host=no` — no `root-door=`, 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 @@ -455,61 +460,106 @@ command is exactly who that refusal catches (it names the new spelling). > dev channel. Until rig cuts 0.1.0 there is no release to resolve, so the > seed must set `RIG_REF=main` explicitly (the default channel fails loudly > rather than falling back). That inverts the install edge on this page: -> rig installs box on host-class machines, and box guests now install rig. +> rig installs box on VM-hosting machines, and box guests now install rig. > `RIG_REPO`/`RIG_REF` are the pin points, 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 -**Named operators exist on every class, and humans never enter as root.** The +**Named operators exist on every box, and humans never enter as root.** The tailnet is network-only — no Tailscale SSH — so there is no identity broker at the door: whoever holds a key to an account *is* that account, and a shared root login is unattributable by construction. `rig users apply` puts named -operators on every box, server-class included; a human always enters as +operators on every box, `root-door=open` included; a human always enters as themself and elevates via sudo. Per role, the whole identity picture at a glance — issue #25's class -comparison, translated onto the traits that replaced the class binary: +comparison, translated onto the traits that replaced the class binary. Note +that "who lives here" and the root-door trait are **different columns**: that +they were ever one word is exactly what #77 fixed. -| role | class | host | join | who lives here | root SSH after `rig users apply` | -|------------------------|--------|------|---------|--------------------------------------|----------------------------------| -| `control-plane-server` | server | no | authkey | nobody — Coolify runs here | open — the automation door | -| `workload-server` | server | no | authkey | nobody — deployed services run here | open — the automation door | -| `runner-server` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | -| `staging-server` | server | yes | authkey | nobody — it mints and hosts guests | open — the automation door | -| `dev-server` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` | -| `workstation` | human | yes | login | its owner | closed by `rig users close-root` | +| role | root-door | host | join | who lives here | root SSH after `rig users apply` | +|------------------------|-----------|------|---------|--------------------------------------|----------------------------------| +| `control-plane-server` | open | no | authkey | nobody — Coolify runs here | open — the automation door | +| `workload-server` | open | no | authkey | nobody — deployed services run here | open — the automation door | +| `runner-server` | open | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | +| `staging-server` | open | yes | authkey | nobody — it mints and hosts guests | open — the automation door | +| `dev-server` | closed | yes | authkey | operators, minting boxes | closed by `rig users close-root` | +| `workstation` | closed | yes | login | its owner | closed by `rig users close-root` | (`staging-server` is that unattended VM-host appliance, and the row above is the whole of it: 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=`, +purpose: a guest is not a tailnet machine, and its marker carries no `root-door=`, 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**: -operators land via `rig users apply` on every class and elevate through sudo +operators land via `rig users apply` on every box and elevate through sudo (roles `admin`/`rig`) or the `incus` group (role `box`) — never by logging in as root. **Machine identities stay machine-shaped**: Coolify's automation -SSHes in as root (that is what server-class root *is*), CI jobs run as the -unprivileged `github-runner`, and guest VMs are their own server-class boxes, +SSHes in as root (that is what `root-door=open` root *is*), CI jobs run as the +unprivileged `github-runner`, and guest VMs are their own open-door boxes, converged from inside by `rig bootstrap workload-server`. -**`class` decides root SSH's fate — after `rig users apply`, never before.** -On `class=human`, root SSH closes entirely (`rig users close-root`, below). -On `class=server` it stays open — key-only, as bootstrap left it — because +**`root-door` decides root SSH's fate — after `rig users apply`, never before.** +On `root-door=closed`, root SSH closes entirely (`rig users close-root`, below). +On `root-door=open` it stays open — key-only, as bootstrap left it — because root there is the **automation** identity the control plane (Coolify) SSHes in as. It is a machine door, never a human one. +#### The root-door trait was renamed, and old markers still resolve + +This trait was `--class human|server` until +[#77](https://github.com/heavy-duty/rig/issues/77). `class` named who *lived +on* the box; what it actually decides is whether root SSH stays open as the +control plane's automation door. Those are different questions, and the roles +proved it: `dev-server` is an unattended VM-host appliance — nobody lives +there — yet it was `class=human`, correctly, because operators enter it as +themselves and its root door must close. After #76 gave `-server` a second +job (naming the machine *family*), `dev-server` carried a suffix saying server +and a trait saying human, and nothing in the name said which axis was which. +`--root-door closed|open` names the axis rig actually branches on. + +**This is not a cosmetic rename, and it is not a hard cut.** Unlike role +names — which nothing reads back — this trait is written into `/etc/rig/role` +and read *from* there on live machines, where it gates `rig users close-root`. +Every box bootstrapped before #77 carries `class=human` or `class=server` and +carries it **forever**, until someone re-bootstraps it; nothing migrates a +fleet. So rig **reads both spellings, permanently**: + +| marker says | resolves to | means | +|---|---|---| +| `root-door=closed` | closed | the current spelling | +| `root-door=open` | open | the current spelling | +| `class=human` | closed | pre-#77, still honored | +| `class=server` | open | pre-#77, still honored | +| both, agreeing | that value | one claim said twice | +| both, **disagreeing** | refusal | rig will not pick a winner — re-run bootstrap | +| neither | refusal | no door policy to act on — re-run bootstrap | + +New markers are written in the **new vocabulary only**. Writing both would +keep an old rig reading a new marker, but it would also entrench the retired +spelling on every box rig ever converges and make the disagreement row +reachable from rig's own hand rather than only from a text editor. The compat +obligation runs the other way: new rig reads old markers, because those exist +in the field and nothing will rewrite them. + +The two refusal rows are **fail-closed** on purpose. A marker that names no +door policy, or names two that disagree, leaves rig unable to say whether root +here is a human's bad habit or the fleet's management plane — and the safe +error is a door that stays open and a loud instruction to re-run bootstrap, +never a door welded shut on a machine whose only entrance it was. + **Where this diverges from #17's original table:** that table let the -`runner-server` role close root ("no Coolify involved"). The class model supersedes -the per-role call: runner is `class=server` — an automation identity, not a -person's box — and on every server-class machine root SSH is the management +`runner-server` role close root ("no Coolify involved"). The trait model supersedes +the per-role call: runner is `root-door=open` — an automation identity, not a +person's box — and on every open-door machine root SSH is the management plane rig itself converges through, so `close-root` refuses there deliberately, runner included. A CI box you mean to administer like a human -machine is `--class human` at bootstrap, not an exception carved out of the -gate. +machine is `--root-door closed` at bootstrap, not an exception carved out of +the gate. **The detection side benefit:** once humans never use root, any root login that is not the control plane is anomalous *by definition* — a cheap, @@ -803,7 +853,7 @@ and never asks for a token. ### `rig users apply --file ` Converges named operator accounts from a declarative users file — on **every** -class (see *The identity model*). Run as root. Convergent: a second identical +box, whatever its root-door policy (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 @@ -822,7 +872,7 @@ of the line). The format is bash-parseable on purpose: a rig box has no YAML parser and no jq, and gets neither for this. Repeated username lines add authorized keys, and the roles must be identical on each — a repeated line always means "another key", never a quiet role edit hiding mid-file. `root` is -refused as a username: this file names operators; root's fate is class policy. +refused as a username: this file names operators; root's fate is root-door policy. `--file -` reads stdin. A bad file exits 2 with **every** error listed at once, before anything changes — one fix cycle, not one round-trip per line. @@ -838,7 +888,7 @@ the admin returns until you switch the line to literal keys — and apply dies if root has no `authorized_keys` to seed. Root's key lines are copied verbatim, options included: a `from=`/`command=` restriction follows the key, and on a Coolify-managed box root's file also carries *Coolify's* key — on -`class=server`, prefer literal keys. +`root-door=open`, prefer literal keys. **Public tool, private state, here too.** The users file lives in *your* private infra repo and is passed per invocation — rig never persists it. It @@ -955,12 +1005,15 @@ network, no writes. Run as root (shadow is read). rig users close-root ``` -Shuts the root SSH door — `class=human` boxes only, and only once a named +Shuts the root SSH door — `root-door=closed` boxes only, and only once a named admin can already get in. The gates run in order: the `/etc/rig/role` marker -must say `class=human` — an absent marker refuses (never shut the root door -blind; re-run bootstrap so the box knows what it is), and `class=server` +must resolve to `closed` — an absent marker refuses (never shut the root door +blind; re-run bootstrap so the box knows what it is), and `root-door=open` refuses with no `--force`, because root there is the control plane's -automation identity and closing it severs fleet management. Then at least one +automation identity and closing it severs fleet management. A marker written +before #77 says `class=human|server` and resolves to `closed|open` +respectively, so a box bootstrapped before the rename gates exactly as it +always did. Then at least one `rig-admin` member must hold a login sshd would plausibly **accept** — a non-empty `authorized_keys` alone proves a file, not a door: the gate checks the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the @@ -1001,7 +1054,7 @@ admin login must be proven, not presumed. Convergent — once root is closed, a re-run says "root already closed; nothing to do" and exits 0. -> **On `class=server`, root stays — so lock its key instead.** This is README +> **On `root-door=open`, root stays — so lock its key instead.** This is README > guidance, deliberately not automation: prefix Coolify's line in root's > `authorized_keys` with a `from=""` clause, so the > automation identity only opens from the one address supposed to use it. rig diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index 66ac4b5..e65ca87 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -20,7 +20,7 @@ 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 +. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of # shellcheck source=SCRIPTDIR/lib/sshd.sh . "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant) @@ -53,7 +53,7 @@ tenant on top, and re-runs converge an existing box to a new spec. Tenant roles are creds-free and non-interactive by contract — box auto-runs them at mint (`box exec … rig bootstrap claude-box`). They take none of the -machine-role traits (--hostname/--class/--host/--join): a tenant is a guest, +machine-role traits (--hostname/--root-door/--host/--join): a tenant is a guest, not a tailnet machine. Run as root, inside the box. EOF } @@ -74,7 +74,7 @@ while [ $# -gt 0 ]; do --user) [ $# -ge 2 ] || die "--user needs a value" 2 TENANT_USER="$2"; shift 2 ;; - --hostname|--class|--host|--join) + --hostname|--root-door|--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-box guest @@ -102,28 +102,41 @@ done # - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest. # Names the staging PAIR out loud, because whoever lands here has the two # halves confused: the metal is `staging-server`, the guest `staging-box`. -# - class= (agent tenants) → refuse: an agent box is never a tailnet machine. -# - class=server with host=no (staging-box only) → PROCEED, and leave the +# - a root-door policy (agent tenants) → refuse: an agent box is never a +# tailnet machine. +# - root-door=open with host=no (staging-box only) → PROCEED, and leave the # marker alone: that is the 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-box hardening it with server rules -# would die with server-specific messaging on a box that was never one. +# ONLY that shape — any other door policy (say root-door=closed, via +# `custom`) is a machine rig built on purpose, and staging-box hardening it +# with open-door rules would die with root-door=open-specific messaging on a +# box that was never one. +# +# "Names a root-door policy" IS this guard's "is this a machine marker?" test — +# a tenant marker deliberately carries none — so it must be asked through +# root_door_of, which reads the pre-#77 `class=` spelling as well as the current +# `root-door=` one. Pattern-matching the marker for one spelling is what this +# guard used to do, and after the rename that is a fail-OPEN bug in the +# dangerous direction: every box bootstrapped in the OTHER vocabulary stops +# looking like a machine, the refusals below never fire, and a tenant converge +# clobbers a real fleet box's marker. The resolver is the only reader. MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}" EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")" +EXISTING_ROOT_DOOR="$(root_door_of "$EXISTING_MARKER")" case "$EXISTING_MARKER" in *host=yes*) die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;; - *class=*) - if [ "$ROLE" != "staging-box" ]; 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-box tolerates only the workload-joined guest (class=server host=no). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." ;; - esac ;; esac +if [ -n "$EXISTING_ROOT_DOOR" ]; then + if [ "$ROLE" != "staging-box" ]; 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 + # A `conflict` marker lands here too, and refuses: a box whose two door + # claims disagree is emphatically not the one shape staging-box tolerates. + if [ "$EXISTING_ROOT_DOOR" != "open" ]; then + die "this box carries a machine role whose root door is not open (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (root-door=open host=no, or its pre-#77 spelling class=server). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." + fi +fi [ "$(id -u)" -eq 0 ] || die "must run as root" if [ -r /etc/os-release ]; then @@ -357,20 +370,27 @@ fi # box#69's posture, minus the join: docker (above) + sshd hardening, through # the SAME code the machine roles use (lib/sshd.sh) — the staging-box 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 +# before the operator joins it. root-door=open: root SSH stays the control # plane's future automation door. if [ "$ROLE" = "staging-box" ]; then - harden_sshd server + harden_sshd open 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-box 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 +# Same ground truth the machine roles write, tenant-shaped: no root-door trait +# at all (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-box 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. +# +# "Is a machine marker already here?" is the same question the guard above +# asks, and is asked the same way — through root_door_of, so both the current +# `root-door=` spelling and the pre-#77 `class=` one count (#77). Testing for +# one spelling would let this write CLOBBER a marker written in the other, which +# on a joined workload box means silently replacing its root-door policy with a +# tenant line that close-root then refuses on. +if [ -z "$EXISTING_ROOT_DOOR" ]; 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 diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 17c8bfc..316f7a1 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -13,7 +13,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" # 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 +# bad --root-door — 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' "$*"; } @@ -25,7 +25,7 @@ usage() { usage: rig bootstrap (--users | --no-users) - [--hostname ] [--class ] + [--hostname ] [--root-door ] [--host ] [--join ] rig bootstrap [--user ] (the box TENANT roles — see their own --help; they take @@ -39,13 +39,16 @@ usage: rig bootstrap "$MARKER_TMP" +printf 'role=%s root-door=%s host=%s join=%s\n' "$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" > "$MARKER_TMP" if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then mkdir -p /etc/rig install -m 0644 "$MARKER_TMP" "$MARKER" - log "role marker written: role=$ROLE class=$CLASS host=$HOST join=$JOIN" + log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN" else log "role marker already current" fi rm -f "$MARKER_TMP" -# --- box install (host-class only) ------------------------------------------- +# --- box install (host=yes only) ------------------------------------------- # A host=yes box exists to run guest boxes, so bootstrap finishes the job rather # than printing a to-do: it installs the box CLI globally and lets box's OWN # setup-host build the Incus stack. Placed AFTER the role marker write on @@ -695,7 +707,7 @@ 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 +# /etc/rig/role: root-door= 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 @@ -731,19 +743,19 @@ if [ "$ROLE" = "control-plane-server" ]; then elif [ "$ROLE" = "runner-server" ]; then log "next: rig runner install --repo --version " 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. With --users the accounts exist already, so +# Every box gets operators: humans always enter as themselves and elevate via +# sudo — a shared root login is unattributable. What differs, per the root-door +# trait, is root 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 + if [ "$ROOT_DOOR" = "closed" ]; 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 - if [ "$CLASS" = "human" ]; then + if [ "$ROOT_DOOR" = "closed" ]; 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 " diff --git a/commands/lib/sshd.sh b/commands/lib/sshd.sh index 518ddaa..d666250 100644 --- a/commands/lib/sshd.sh +++ b/commands/lib/sshd.sh @@ -6,12 +6,15 @@ # 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, +# 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. +# permitrootlogin acceptance gated on the ROOT-DOOR policy passed in (#77; this +# argument was before the trait was renamed for what it decides). +# Callers pass their own trait value, never a marker read: bootstrap knows its +# root-door from its flags, and the staging tenant is open by construction. harden_sshd() { - local class="$1" + local root_door="$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 @@ -59,22 +62,22 @@ EOF 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 + # The permitrootlogin acceptance is ROOT-DOOR-gated, because `no` means + # opposite things on the two policies. root-door=closed: `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. root-door=open: 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 + # a former root-door=closed 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 [ "$root_door" = "closed" ]; 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." + die "sshd resolves permitrootlogin=no, but this is a root-door=open 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 root-door=closed 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/" diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index ca62a17..c0b0904 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -32,8 +32,8 @@ # fix cycle, not one round-trip per line. # # Refusals: unknown role (the valid set is named), differing roles across one -# user's lines, root as username (root's keys are class policy's business, not -# this file's), malformed line (fewer than 3 fields, or a key field that does +# user's lines, root as username (root's keys are root-door policy's business, +# not this file's), malformed line (fewer than 3 fields, or a key field that does # not start with an SSH key type and is not exactly '@root'), '@root' with # trailing material (the token IS the whole field), invalid username (the # charset below — '|' would corrupt this parser's own delimited stream, a @@ -71,7 +71,7 @@ parse_users_file() { continue fi if [ "$u" = "root" ]; then - errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy") + errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is root-door policy") continue fi ok=1 @@ -103,21 +103,86 @@ parse_users_file() { } # read_role_marker — the marker line bootstrap wrote -# (`role=... class=... host=... join=...`), or nothing when absent. NO policy -# here: what an absent marker or a given class MEANS is each caller's call -# (apply notes it, close-root refuses on it) — this reader only reads. +# (`role=... root-door=... host=... join=...`), or nothing when absent. NO +# policy here: what an absent marker or a given trait MEANS is each caller's +# call (apply notes it, close-root refuses on it) — this reader only reads. read_role_marker() { [ -r "$1" ] || return 0 head -n1 "$1" } -# assert_marker_human — close-root's marker gate: return 0, -# silently, only when the marker says class=human; otherwise print the refusal -# reason on stdout and return 1 (the caller wraps it in its own die). The -# policy is a pure lib function on purpose: the CLI path sits behind the root -# check, so the harness proves every refusal HERE, against fixture markers, -# non-root (repo precedent: parse_users_file, assert_runner_repo). -assert_marker_human() { +# root_door_of — resolve the root-door trait from a marker LINE, +# reading both the current `root-door=` vocabulary and the `class=` one it +# replaced (#77). Prints exactly one of: +# +# closed the root SSH door is meant to shut once named operators exist +# (`rig users close-root`) — was class=human +# open root SSH stays as the control plane's automation door +# — was class=server +# conflict the marker names BOTH vocabularies and they DISAGREE +# (empty) the marker names neither, or names one with a value that is +# not in its value set +# +# Text->text and total, so the harness proves every arm off a literal string +# (repo precedent: deny_verdict, group_allow_verdict). Callers turn a verdict +# into policy; this function has none. +# +# WHY THE COMPAT READ IS NOT OPTIONAL, and why it is here rather than at each +# call site. #77 renamed the trait because `class=human|server` was named for +# who lives on the box while what it decides is whether root SSH stays open as +# the control plane's automation door — the axis that made `dev-server` a +# `class=human` box, a suffix and a trait that read as a contradiction. But +# unlike #76's role rename, this field is not informational: it is written into +# /etc/rig/role and read back on live machines, where it gates `rig users +# close-root`. Every box bootstrapped before this change carries `class=` and +# carries it FOREVER, until someone re-bootstraps it — there is no migration +# step that reaches a fleet. So dropping the old read breaks in both directions +# at once, and both are incidents: a machine whose door should close stops +# being able to close it (close-root refuses on a marker it no longer +# understands), and — through bootstrap-tenant's machine-marker guard, which +# used `class=` as its "this is a machine" detector — a real fleet box stops +# looking like a machine at all and a tenant converge will happily clobber it. +# One resolver, consulted everywhere the trait is read, is what keeps those +# two readings from drifting apart. +# +# BOTH FIELDS PRESENT is a state bootstrap never writes — it writes one line, +# fresh, in the new vocabulary only — so a marker carrying both was +# hand-edited, and the two answers are a question about intent that rig cannot +# settle. Agreement is taken (it says one thing twice); disagreement resolves +# to `conflict` and every caller fails CLOSED on it, because the alternative is +# picking a winner between two equally-authored claims about a root door. The +# repair is the same one the rest of the marker family names: re-run bootstrap, +# which rewrites the line whole. +# +# NEITHER FIELD PRESENT resolves empty and is likewise fail-closed everywhere, +# unchanged from before: a marker that names no door policy cannot authorize +# shutting a door. +root_door_of() { + local marker="$1" new="" old="" + case "$marker" in + *root-door=closed*) new=closed ;; + *root-door=open*) new=open ;; + esac + case "$marker" in + *class=human*) old=closed ;; + *class=server*) old=open ;; + esac + if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then + printf 'conflict'; return 0 + fi + # New wins where both are readable and agree; the old field answers alone on + # every marker written before #77, which is the whole point. + printf '%s' "${new:-$old}" +} + +# assert_marker_closes_root — close-root's marker gate: return 0, +# silently, only when the marker's root-door trait resolves to `closed`; +# otherwise print the refusal reason on stdout and return 1 (the caller wraps +# it in its own die). The policy is a pure lib function on purpose: the CLI +# path sits behind the root check, so the harness proves every refusal HERE, +# against fixture markers, non-root (repo precedent: parse_users_file, +# assert_runner_repo). +assert_marker_closes_root() { local marker marker="$(read_role_marker "$1")" if [ -z "$marker" ]; then @@ -126,22 +191,27 @@ assert_marker_human() { printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind" return 1 fi - case "$marker" in - *class=human*) return 0 ;; - *class=server*) - # Root SSH on a server IS the control plane's (Coolify's) automation + case "$(root_door_of "$marker")" in + closed) return 0 ;; + open) + # Root SSH on such a box IS the control plane's (Coolify's) automation # identity — closing it severs fleet management. No --force exists. - # Deliberately per-CLASS, not per-role: #17's original table let the - # runner role close root ("no Coolify involved"), but the class model - # (#26) supersedes that — every server-class box, runner included, is - # an automation identity whose management plane is root SSH, and rig - # itself converges through that door. A CI box someone administers - # like a human machine is class=human at bootstrap, not an exception - # carved out here. - printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management. Every server-class box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --class human at bootstrap, not an exception here" + # Deliberately keyed on the DOOR, not on the role: #17's original table + # let the runner role close root ("no Coolify involved"), but the trait + # model (#26) supersedes that — every root-door=open box, runner + # included, is an automation identity whose management plane is root + # SSH, and rig itself converges through that door. A CI box someone + # administers like a human machine is --root-door closed at bootstrap, + # not an exception carved out here. + printf '%s\n' "root-door=open: root here is the control plane's automation identity — closing it severs fleet management. Every root-door=open box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --root-door closed at bootstrap, not an exception here" + return 1 ;; + conflict) + # Hand-edited into naming both vocabularies, disagreeing. Fail closed: + # see root_door_of's header for why rig refuses to pick a winner. + printf '%s\n' "marker names both root-door= and the pre-#77 class= and they disagree (${marker}): rig will not pick a winner between two claims about a root door — re-run rig bootstrap to rewrite the marker, and refusing to shut the root door meanwhile" return 1 ;; *) - printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind" + printf '%s\n' "marker names no root-door policy (${marker}): re-run rig bootstrap; refusing to shut the root door blind" return 1 ;; esac } @@ -149,7 +219,7 @@ assert_marker_human() { # assert_marker_hosts_vms — the box role's gate: return 0, # silently, only when the marker says host=yes; otherwise print the reason on # stdout and return 1 (the caller decides whether that is a warn or a die). -# Same shape and same reason as assert_marker_human above: the policy is a +# Same shape and same reason as assert_marker_closes_root above: the policy is a # pure marker->verdict function so the harness can prove every arm against # fixture markers, non-root, while the CLI path sits behind the root check. # diff --git a/commands/users-apply.sh b/commands/users-apply.sh index 9042e1f..44d009b 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # rig users apply — converge named operator accounts from a declarative users -# file, on every class. Humans always enter as themselves and elevate via +# file, on every box. Humans always enter as themselves and elevate via # sudo: a shared root login is unattributable, so operators belong on servers -# too — class never gates this command, it only decides root SSH's fate AFTER -# users exist (close-root on human, kept as the control plane's automation -# door on server). Convergent: a second identical run changes nothing and -# says so. +# too — the root-door trait never gates this command, it only decides root +# SSH's fate AFTER users exist (close-root on root-door=closed, kept as the +# control plane's automation door on root-door=open). Convergent: a second +# identical run changes nothing and says so. set -euo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" @@ -153,13 +153,27 @@ if [ "$NEED_SEED" -eq 1 ]; then || die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead" fi -# Class is a note, never a refusal: #26's call is that operators belong on -# EVERY class — what differs is root SSH's fate once they exist. -case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in - *class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;; - *class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;; - "") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;; -esac +# The root-door trait is a note here, never a refusal: #26's call is that +# operators belong on EVERY box — what differs is root SSH's fate once they +# exist. Resolved through root_door_of so this note and close-root's gate read +# one marker the same way, pre-#77 class= spellings included (#77): a box whose +# note says the door will shut must be a box where close-root agrees it shuts. +APPLY_MARKER="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" +if [ -z "$APPLY_MARKER" ]; then + warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" +else + case "$(root_door_of "$APPLY_MARKER")" in + open) log "root-door=open: root SSH stays — it is the control plane's automation door" ;; + closed) log "root-door=closed: once your admin key works, 'rig users close-root' shuts the root door" ;; + # A marker naming both vocabularies in disagreement, or naming no door + # policy at all, is exactly where close-root will refuse. Apply still + # converges operators — that is the point of #26 — but it must not stay + # quiet about the refusal waiting at the end of the sequence it just + # pointed the operator at. + conflict) warn "marker names both root-door= and the pre-#77 class= and they disagree ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;; + *) warn "marker names no root-door policy ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;; + esac +fi CHANGED=0 diff --git a/commands/users-close-root.sh b/commands/users-close-root.sh index 4330f82..e3463c3 100755 --- a/commands/users-close-root.sh +++ b/commands/users-close-root.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# rig users close-root — shut the human-class root SSH door, once and only -# once a named admin can already get in. class decides root SSH's fate (#26): -# on class=human a root login is unattributable noise, so it goes; on -# class=server root IS the control plane's automation identity, so closing it +# rig users close-root — shut the root SSH door on the boxes whose door is +# meant to shut, once and only once a named admin can already get in. The +# root-door trait decides root SSH's fate (#26, renamed by #77): on +# root-door=closed a root login is unattributable noise, so it goes; on +# root-door=open root IS the control plane's automation identity, so closing it # would sever fleet management — this command refuses there, and no --force # exists. Convergent: a second run is a no-op and says so. set -euo pipefail @@ -23,9 +24,12 @@ Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by first-wins include order. -Human class ONLY. On class=server, root SSH is the control plane's (Coolify's) -automation identity — closing it severs fleet management — so close-root -refuses there, with no --force. It also refuses without a role marker (re-run +root-door=closed boxes ONLY. On root-door=open, root SSH is the control plane's +(Coolify's) automation identity — closing it severs fleet management — so +close-root refuses there, with no --force. Markers written before #77 name this +trait as class=human|server and are read as closed|open respectively, so a box +bootstrapped before the rename gates exactly as it always did. +It also refuses without a role marker (re-run rig bootstrap; never shut the root door blind) and refuses while no rig-admin member holds a login this box would actually honor. Per candidate, in order: the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys @@ -72,11 +76,13 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \ die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)" fi -# Marker gate — the policy lives in assert_marker_human (lib) so the harness -# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER -# exists for the same reason: it keeps the command's own gate pointable at -# fixtures instead of only at the real /etc/rig/role. -if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then +# Marker gate — the policy lives in assert_marker_closes_root (lib) so the +# harness can prove its refusals against fixture markers as non-root; +# RIG_ROLE_MARKER exists for the same reason: it keeps the command's own gate +# pointable at fixtures instead of only at the real /etc/rig/role. The gate +# reads the pre-#77 class= spelling too — see root_door_of for why that compat +# read is load-bearing rather than courteous. +if ! WHY="$(assert_marker_closes_root "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then die "$WHY" fi diff --git a/test/cli.sh b/test/cli.sh index a5f4729..70ad27b 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -92,7 +92,7 @@ done check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \ env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation check "bootstrap: custom keeps its bare name" 2 "--hostname" \ - "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey + "$ROOT/commands/bootstrap.sh" custom --root-door open --host no --join authkey # NOTHING may still TELL an operator to run a pre-#76 role. The rename is a # hard cut, so a next-step string, a usage line or a refusal that still recites # a bare role name is a command that fails when someone copy-pastes it — and it @@ -108,12 +108,12 @@ check "roles: no shipped script tells an operator to run a pre-#76 role name" 1 "$ROOT/bin/rig" "$ROOT/commands/" # --- traits: roles are presets, every trait individually settable (#26) ----- check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato -check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato +check "bootstrap: bad --root-door value exits 2" 2 "closed|open" "$ROOT/commands/bootstrap.sh" workload-server --root-door potato check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload-server --host maybe check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload-server --join carrier-pigeon check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \ - "$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey -check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 + "$ROOT/commands/bootstrap.sh" custom --root-door open --host no --join authkey +check "bootstrap: custom without traits exits 2" 2 "--root-door" "$ROOT/commands/bootstrap.sh" custom --hostname box1 # workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it # must die BEFORE the root check — provable non-root, which also proves the # preset actually landed. @@ -143,7 +143,15 @@ check "bootstrap: login verify fails closed on a stalled backend" 0 "" \ # The marker is the traits' ground truth for rig users; assert the write exists. check "bootstrap: role marker write is present" 0 "" \ grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" -# --- host-class box install (issues #12, #25) -------------------------------- +# ...and that it is written in the CURRENT vocabulary (#77). New markers say +# root-door=; the retired class= spelling is something rig READS forever and +# WRITES never, so a marker line that reintroduces it must not ship green. +check "bootstrap: the marker is written as root-door=, not class=" 0 "" \ + grep -qF "printf 'role=%s root-door=%s host=%s join=%s" "$ROOT/commands/bootstrap.sh" +# shellcheck disable=SC2016 +check "bootstrap: no shipped script WRITES the retired class= spelling" 0 "" \ + sh -c '! grep -n "printf .*class=" "$1"/commands/*.sh' _ "$ROOT" +# --- host=yes box install (issues #12, #25) -------------------------------- # A host=yes box finishes the job: bootstrap installs the box CLI globally and # lets box's own setup-host build the Incus stack. The install itself runs as # root, over the network, against a real host — none of which this harness can @@ -237,7 +245,7 @@ check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users "$TEN_FIX/host" -printf 'role=workload-server 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=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine" +printf 'role=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed" printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant" -check "tenant: staging-box refuses a non-server machine box" 1 "non-server machine role" \ - env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging-box +check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \ + env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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-box check "tenant: the host refusal sends you to the metal half of the pair" 1 "staging-server" \ env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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-box + +# The tenant guard's compat read (#77). This guard asks "does this marker name +# a root-door policy?" as its proxy for "is this a real fleet machine?", and it +# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling, +# same reason as the close-root fixtures below: a pre-#77 box that stops +# looking like a machine here is the fail-OPEN direction of this rename — the +# agent-tenant refusal never fires, and `rig bootstrap claude-box` converges a +# tenant straight over a live fleet box, clobbering the marker that holds its +# root-door policy. Do not modernize these two fixtures. +printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/pre77-machine" +printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/pre77-human" +check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \ + env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box +check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \ + env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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. @@ -539,6 +563,10 @@ if [ "$(id -u)" -ne 0 ]; then env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box check "tenant: staging-box 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-box + # ...and the same guest joined before #77: reaching the root check (rather + # than a marker refusal) is what proves the tolerance survived the rename. + check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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-box else @@ -586,7 +614,7 @@ check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \ # staging-box'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-box hardens through the shared sshd lib" 0 "" \ - grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh" + grep -qE '^[[:space:]]*harden_sshd open$' "$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 @@ -607,6 +635,15 @@ ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-ten 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}" +# The write's "is a machine marker already here?" test must go through the +# resolver, not through a pattern match on one spelling (#77). Pinned as a +# byte-grep because the failure it prevents is silent and expensive: a +# spelling-specific test would let a tenant converge CLOBBER a machine marker +# written in the other vocabulary — on a joined workload box that means +# replacing its root-door policy with a tenant line close-root then refuses on. +# shellcheck disable=SC2016 +check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \ + grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh" 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 @@ -645,8 +682,8 @@ marker_warns() { # marker_warns — how many warnings fir env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true } MARKER_FIX="$(mktemp -d)" -printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload" -printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARKER_FIX/workload" +printf 'role=control-plane-server root-door=open host=no join=authkey\n' > "$MARKER_FIX/control-plane" printf 'role=control-plane-server\n' > "$MARKER_FIX/bare-control-plane" # A PRE-#76 marker, verbatim as a real box bootstrapped before the rename # carries it. This is the one fixture that must keep its old spelling: the @@ -1048,10 +1085,23 @@ check "users apply: revoked keys are renamed, never deleted" 0 "" \ # admins included — still converges. check "users apply: box role skips on a host=no box" 0 "" \ grep -q "box role skipped" "$ROOT/commands/users-apply.sh" +# Apply's root-SSH note and close-root's gate must read ONE marker the same +# way, pre-#77 spellings included — a box told "close-root will shut this door" +# by apply and then refused by close-root is the worst of both (#77). Sharing +# the resolver is what guarantees it, so pin the call rather than the message. +# shellcheck disable=SC2016 +check "users apply: the root-SSH note resolves through root_door_of" 0 "" \ + grep -qF 'root_door_of "$APPLY_MARKER"' "$ROOT/commands/users-apply.sh" +# ...and it warns, rather than staying silent, on the two markers close-root +# will refuse: a note that only speaks on the happy paths is not a note. +check "users apply: a doorless marker warns that close-root will refuse" 0 "" \ + grep -q "names no root-door policy" "$ROOT/commands/users-apply.sh" +check "users apply: a contradictory marker warns that close-root will refuse" 0 "" \ + grep -q "they disagree" "$ROOT/commands/users-apply.sh" # --- the box role's host= gate (#58) ----------------------------------------- # The gate is a pure marker->verdict lib function for the same reason -# assert_marker_human is: apply's box arm sits behind the root check, so every +# assert_marker_closes_root is: apply's box arm sits behind the root check, so every # arm is proven HERE against fixture markers, non-root. # # What #58 fixed: the trait used to be consulted only when group incus was @@ -1068,11 +1118,11 @@ hostvm_gate() { # hostvm_gate assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" } HOSTVM_FIX="$(mktemp -d)" -printf 'role=dev-server class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" -printf 'role=workload-server class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" +printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$HOSTVM_FIX/yes" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$HOSTVM_FIX/no" # A marker that predates the host= trait (or was hand-edited): present, but it # names no host=. Distinct from an ABSENT marker and it must not read as yes. -printf 'role=workload-server class=server join=authkey\n' > "$HOSTVM_FIX/traitless" +printf 'role=workload-server root-door=open join=authkey\n' > "$HOSTVM_FIX/traitless" check "users apply: host=yes passes the box-role gate" \ 0 "" hostvm_gate "$HOSTVM_FIX/yes" check "users apply: host=no fails the box-role gate" \ @@ -1457,24 +1507,103 @@ check "users close-root: the gate consults deny_verdict" 0 "" \ marker_gate() { # marker_gate bash -c 'set -euo pipefail . "$1/commands/lib/users-config.sh" - assert_marker_human "$2"' _ "$ROOT" "$1" + assert_marker_closes_root "$2"' _ "$ROOT" "$1" } MARKER_DIR="$(mktemp -d)" -printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/server" -printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/human" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARKER_DIR/open" +printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$MARKER_DIR/closed" check "users close-root: absent marker refuses, names bootstrap as the repair" \ 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" -check "users close-root: class=server refuses, names the control plane" \ - 1 "control plane" marker_gate "$MARKER_DIR/server" -# #17's original table let the runner ROLE close root; the class model -# supersedes it — runner is class=server, an automation identity, and the +check "users close-root: root-door=open refuses, names the control plane" \ + 1 "control plane" marker_gate "$MARKER_DIR/open" +# #17's original table let the runner ROLE close root; the trait model +# supersedes it — runner is root-door=open, an automation identity, and the # refusal must SAY so or the divergence reads as a bug to anyone holding the # old table. -check "users close-root: the server refusal owns the runner row (#17)" \ - 1 "runner included" marker_gate "$MARKER_DIR/server" -check "users close-root: class=human passes the gate" \ - 0 "" marker_gate "$MARKER_DIR/human" +check "users close-root: the open-door refusal owns the runner row (#17)" \ + 1 "runner included" marker_gate "$MARKER_DIR/open" +check "users close-root: root-door=closed passes the gate" \ + 0 "" marker_gate "$MARKER_DIR/closed" + +# --- the pre-#77 vocabulary, on live markers (#77) --------------------------- +# THIS is the block that makes #77 safe to ship, and it is not a formality. +# Unlike #76's role rename, the root-door trait is written into /etc/rig/role +# and read BACK by this gate, so every box bootstrapped before the rename +# carries `class=human|server` and carries it until someone re-bootstraps it — +# which, for a fleet, is never. A gate that stopped understanding that spelling +# would fail in both directions and both are incidents: `class=human` boxes +# (whose whole point is that root closes) would lose the ability to close it, +# and `class=server` boxes would... also refuse, but for the wrong reason, +# which is luck rather than design and would evaporate the moment the fallthrough +# arm changed. +# +# So the fixtures below stay DELIBERATELY at the retired spelling, byte for +# byte as a real pre-#77 box's marker reads — the same reason #76's +# `pre-rename-cp` fixture keeps `role=control-plane`. Do not "modernize" them: +# updating these fixtures to the new vocabulary would delete the only evidence +# that the compat read works, and the suite would stay green while the field +# broke. The pairs assert the OLD spelling produces the SAME verdict as its new +# equivalent above, refusal text included. +printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/pre77-server" +printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/pre77-human" +check "users close-root: a PRE-#77 'class=human' marker still passes the gate" \ + 0 "" marker_gate "$MARKER_DIR/pre77-human" +check "users close-root: a PRE-#77 'class=server' marker still REFUSES" \ + 1 "control plane" marker_gate "$MARKER_DIR/pre77-server" +# The refusal an old box gets must be the CURRENT one, naming the current flag: +# an operator repairing a pre-#77 box is repairing it with today's rig, and +# being told to pass a flag that no longer exists is a dead end. +check "users close-root: the PRE-#77 refusal names today's flag, not --class" \ + 1 "root-door closed" marker_gate "$MARKER_DIR/pre77-server" + +# A marker naming NEITHER vocabulary refuses — unchanged from before #77, and +# distinct from an absent marker: the file exists and simply makes no claim +# about the door, which cannot authorize shutting one. +printf 'role=workload-server host=no join=authkey\n' > "$MARKER_DIR/doorless" +check "users close-root: a marker naming no door policy refuses" \ + 1 "names no root-door policy" marker_gate "$MARKER_DIR/doorless" +# A marker naming a root-door= value outside the value set is doorless too — +# fail closed rather than guessing which door 'potato' means. +printf 'role=custom root-door=potato host=no join=login\n' > "$MARKER_DIR/bogus" +check "users close-root: an unreadable root-door value refuses (fail closed)" \ + 1 "names no root-door policy" marker_gate "$MARKER_DIR/bogus" + +# BOTH vocabularies on one marker. Agreement is just the same claim twice and +# resolves normally; DISAGREEMENT is a hand-edited marker making two equally +# authored claims about a root door, and rig refuses to pick a winner — the +# fail-closed arm, since the alternative is guessing on the one field that +# decides whether a door welds shut. Both orders are checked so the verdict +# cannot depend on which field the editor happened to type first. +printf 'role=dev-server root-door=closed class=human host=yes join=authkey\n' > "$MARKER_DIR/both-agree" +check "users close-root: both vocabularies agreeing resolves normally" \ + 0 "" marker_gate "$MARKER_DIR/both-agree" +printf 'role=custom root-door=closed class=server host=no join=login\n' > "$MARKER_DIR/both-fight-a" +printf 'role=custom class=human root-door=open host=no join=login\n' > "$MARKER_DIR/both-fight-b" +check "users close-root: contradictory vocabularies refuse (new-first)" \ + 1 "will not pick a winner" marker_gate "$MARKER_DIR/both-fight-a" +check "users close-root: contradictory vocabularies refuse (old-first)" \ + 1 "will not pick a winner" marker_gate "$MARKER_DIR/both-fight-b" rm -rf "$MARKER_DIR" + +# root_door_of is the ONE reader of this trait — close-root's gate, apply's +# note and bootstrap-tenant's machine-marker detector all resolve through it, +# so the compat read cannot drift between them. Pin the resolver directly, +# text->text, the way deny_verdict and group_allow_verdict are pinned. +door_of() { # door_of + bash -c 'set -euo pipefail + . "$1/commands/lib/users-config.sh" + printf "[%s]" "$(root_door_of "$2")"' _ "$ROOT" "$1" +} +check "root_door_of: reads the current vocabulary" 0 "[closed]" \ + door_of 'role=dev-server root-door=closed host=yes join=authkey' +check "root_door_of: reads the pre-#77 class=human as closed" 0 "[closed]" \ + door_of 'role=dev-server class=human host=yes join=authkey' +check "root_door_of: reads the pre-#77 class=server as open" 0 "[open]" \ + door_of 'role=workload-server class=server host=no join=authkey' +check "root_door_of: a tenant marker names no door at all" 0 "[]" \ + door_of 'role=claude-box tenant=yes host=no' +check "root_door_of: disagreement is a conflict, not a coin flip" 0 "[conflict]" \ + door_of 'role=custom root-door=open class=human host=no join=login' if [ "$(id -u)" -ne 0 ]; then check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh" else @@ -1488,12 +1617,12 @@ fi # 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 +# ...but only for root-door=closed. On root-door=open 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 closed-door 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 "sshd lib: class=server refusal names the stale close-root drop-in" 0 "" \ +check "sshd lib: root-door=open 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). @@ -1503,7 +1632,7 @@ 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" + grep -qE '^harden_sshd "\$ROOT_DOOR"$' "$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 @@ -1625,7 +1754,7 @@ check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" -- # host itself — /etc/rig/role. The deliberate decision: warn and proceed. # Driven against a fixture marker; counting fires proves silence too. MARK="$WORK/role-marker" -printf 'role=workload-server class=server host=no join=authkey\n' > "$MARK" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARK" H2="$WORK/h2"; B2="$WORK/b2" check "flip gate: baseline install" 0 "done" inst "$H2" "$B2" check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \ -- 2.45.2 From 4bbf1babe03e5507b1b29329051c4ac84424dc48 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 11:18:37 +0000 Subject: [PATCH 5/5] fix(users): the root-door resolver matches whole fields, not substrings Caught in review. root_door_of matched unanchored substrings, so any value that EXTENDS a real one resolved as that value: `root-door=closedish` read as `closed` and PASSED close-root's gate -- the one arm in this repo that authorizes an irreversible act -- and `class=humanoid` did the same through the compat arm. Both contradicted the function's own header, which promises a value outside the set resolves empty and fails closed. Only reachable by hand-editing a marker, so it was never a live incident. It gets fixed anyway because this is the single function every consumer trusts -- close-root's gate, apply's root-SSH note, and bootstrap-tenant's machine guard all ask it -- and a resolver that is nearly right about a root door is the wrong kind of nearly. The marker is one line of space-separated key=value fields (bootstrap writes it with a single printf), so padding both ends and matching on field boundaries is exact rather than heuristic. Whitespace is normalised first so a hand-edit using tabs still reads correctly -- anchoring must not trade one silent misread for another. BOTH vocabularies are anchored. Fixing only the current spelling would have left the hole open on every box bootstrapped before #77, which is precisely the population the compat arm exists to serve. Tests pin the resolver and the end-to-end refusal, since the resolver returning "" is only safe because consumers treat it as one. Reverting the anchoring turns the suite red (447/4); restoring it returns 451/0. The original compat proof still holds: removing the class= arm gives 441/10. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 15 +++++++++++++++ commands/lib/users-config.sh | 27 ++++++++++++++++++++------- test/cli.sh | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a878a..b4a5fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,21 @@ on the way to cutting its first release, and this file starts there. told to re-run bootstrap — never a door welded shut on a machine whose only entrance it was. + The resolver matches **whole fields, not substrings** — the marker is one + line of space-separated `key=value` pairs, so it pads both ends and matches + on field boundaries. Review caught the first cut doing unanchored matching, + which resolved any value that *extended* a real one: `root-door=closedish` + read as `closed` and passed close-root's gate — the single arm that + authorizes an irreversible act — and `class=humanoid` did the same through + the compat arm, both contradicting the resolver's own promise that a value + outside the set resolves empty and fails closed. Only reachable by hand- + editing a marker, so never a live incident, but this is the one function + every consumer trusts and it owes them exactness rather than nearly. Both + vocabularies are anchored: fixing only the current spelling would have left + every pre-#77 box carrying the hole. Whitespace is normalised first, so a + hand-edit using tabs reads the same rather than trading one silent misread + for another. + **New markers are written in the new vocabulary only.** Writing both would keep an old rig reading a new marker, but it would entrench the retired spelling on every box rig ever converges and make the disagreement case diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index c0b0904..5e2da63 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -158,14 +158,27 @@ read_role_marker() { # unchanged from before: a marker that names no door policy cannot authorize # shutting a door. root_door_of() { - local marker="$1" new="" old="" - case "$marker" in - *root-door=closed*) new=closed ;; - *root-door=open*) new=open ;; + local marker="$1" new="" old="" padded + # FIELD-ANCHORED, not substring. The marker is one line of space-separated + # `key=value` fields (bootstrap writes it with a single printf), so padding + # both ends and matching whole fields is exact. Unanchored patterns matched + # any value that EXTENDS a real one: `root-door=closedish` resolved as + # `closed` and passed close-root's gate — the one arm that authorizes an + # irreversible act — and `class=humanoid` did the same through the compat + # arm. That contradicted this function's own promise above, that a value + # outside the set resolves empty and fails closed. Only reachable by hand + # editing, but this is the function every consumer trusts, so it owes them + # exactness rather than "close enough" (found in review on #77). + # Whitespace is normalised first so a hand-edit using tabs or double spaces + # is read the same way rather than silently failing to match. + padded=" ${marker//[[:space:]]/ } " + case "$padded" in + *" root-door=closed "*) new=closed ;; + *" root-door=open "*) new=open ;; esac - case "$marker" in - *class=human*) old=closed ;; - *class=server*) old=open ;; + case "$padded" in + *" class=human "*) old=closed ;; + *" class=server "*) old=open ;; esac if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then printf 'conflict'; return 0 diff --git a/test/cli.sh b/test/cli.sh index 70ad27b..75aea97 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -1604,6 +1604,31 @@ check "root_door_of: a tenant marker names no door at all" 0 "[]" \ door_of 'role=claude-box tenant=yes host=no' check "root_door_of: disagreement is a conflict, not a coin flip" 0 "[conflict]" \ door_of 'role=custom root-door=open class=human host=no join=login' +# FIELD-ANCHORED, not substring (found in review on #77). A value that EXTENDS +# a real one must resolve EMPTY and fail closed, exactly as the function's +# header promises — before anchoring, `closedish` read as `closed` and PERMITTED +# close-root, the one arm that authorizes an irreversible act. Both vocabularies +# are checked: the compat arm had the identical hole, and a fix that anchored +# only the current spelling would leave every pre-#77 box exposed to it. +check "root_door_of: a value EXTENDING the current spelling resolves empty" 0 "[]" \ + door_of 'role=x root-door=closedish host=no' +check "root_door_of: a value extending the pre-#77 spelling resolves empty too" 0 "[]" \ + door_of 'role=x class=humanoid host=no' +check "root_door_of: a value PREFIXED by junk does not match either" 0 "[]" \ + door_of 'role=x notroot-door=closed host=no' +# ...and the gate itself must refuse on those, not merely resolve empty: the +# resolver returning "" is only safe because every consumer treats it as a +# refusal, so the end-to-end behaviour is what gets pinned. +DOOR_FIX="$(mktemp -d)" +printf 'role=x root-door=closedish host=no\n' > "$DOOR_FIX/bogus" +# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours +check "close-root: refuses a marker whose door value merely LOOKS closed" 1 "names no root-door policy" \ + bash -c '. "$1/commands/lib/users-config.sh"; assert_marker_closes_root "$2"' _ "$ROOT" "$DOOR_FIX/bogus" +# Whitespace normalisation: a hand-edit using tabs is still a real marker and +# must read the same, or anchoring would trade one silent misread for another. +check "root_door_of: tab-separated fields read the same as space-separated" 0 "[closed]" \ + door_of "$(printf 'role=x\troot-door=closed\thost=no')" +rm -rf "$DOOR_FIX" if [ "$(id -u)" -ne 0 ]; then check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh" else -- 2.45.2