diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d7fb0..b4a5fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,181 @@ on the way to cutting its first release, and this file starts there. ## Unreleased +### 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. + + 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 + 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 + 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 + 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. + + 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` 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 ### Added 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 cb9d443..806c0ab 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 @@ -90,20 +91,20 @@ 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`, -`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 --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 +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 @@ -111,37 +112,39 @@ 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 -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 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 @@ -150,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 @@ -167,8 +170,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 @@ -183,38 +186,64 @@ 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 | 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 | 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 | -> **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 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 +> 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 --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 +> 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. 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. @@ -292,9 +321,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,15 +334,15 @@ 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 -the note above). It is `class=server`: an unattended VM appliance — operators +with the tenant roles and (for staging guests) `rig bootstrap workload-server` — +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`. +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 @@ -344,21 +373,21 @@ 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 -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 +### `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 @@ -374,12 +403,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`, `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 +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 @@ -389,14 +424,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 --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 +`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. @@ -411,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 @@ -425,60 +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 | 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 | 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` | -(The unattended VM-host appliance — formerly the `staging` preset — is the -`class=server host=yes join=authkey` shape: nobody lives there, root SSH stays -open as the automation door. The box TENANT roles sit outside this table on -purpose: a guest is not a tailnet machine, and its marker carries no `class=`, +(`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 `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, -converged from inside by `rig bootstrap workload`. +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` 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, @@ -640,11 +721,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 ``` @@ -772,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 @@ -791,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. @@ -807,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 @@ -924,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 @@ -970,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 @@ -1006,9 +1090,9 @@ 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` -/ `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 0af2016..3de7315 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,16 +28,19 @@ 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 ] - 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'. 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 a09f23b..e65ca87 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,9 +9,9 @@ # lib/tenant-config.sh — never four hand-maintained copies. # # Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig -# bootstrap claude'), so every path here is non-interactive and nothing joins -# or admits — no tailnet, no keys, no prompts. staging's tailnet join stays -# operator-run ('rig bootstrap workload' through 'box shell'), exactly the +# 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. set -euo pipefail @@ -19,9 +20,9 @@ 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 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,28 +30,30 @@ 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` 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`) + 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 -machine-role traits (--hostname/--class/--host/--join): a tenant is a guest, +them at mint (`box exec … rig bootstrap claude-box`). They take none of the +machine-role traits (--hostname/--root-door/--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")" @@ -71,16 +74,16 @@ 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 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|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-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', 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,30 +100,43 @@ 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. -# - 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 +# Names the staging PAIR out loud, because whoever lands here has the two +# halves confused: the metal is `staging-server`, the guest `staging-box`. +# - 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 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. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;; - *class=*) - if [ "$ROLE" != "staging" ]; then - die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run." - fi - case "$EXISTING_MARKER" in - *class=server*) ;; - *) - die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging tolerates only the workload-joined guest (class=server host=no). If this really is a staging guest, remove ${MARKER_PATH} and re-run." ;; - esac ;; + 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'." ;; 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 @@ -161,13 +177,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 +193,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 +230,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 +240,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 +257,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 +268,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 +277,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 +289,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 +305,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 +340,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,24 +366,31 @@ 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 +# before the operator joins it. root-door=open: root SSH stays the control # plane's future automation door. -if [ "$ROLE" = "staging" ]; then - harden_sshd server +if [ "$ROLE" = "staging-box" ]; then + 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 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 @@ -383,8 +406,8 @@ else 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" +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" fi diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 2374dfe..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' "$*"; } @@ -22,11 +22,12 @@ 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 ] + [--hostname ] [--root-door ] [--host ] [--join ] - rig bootstrap [--user ] + rig bootstrap [--user ] (the box TENANT roles — see their own --help; they take no --users, see below) @@ -38,19 +39,22 @@ usage: rig bootstrap tailnet and leaves the box with root as its only door. --hostname system + tailnet hostname (default: the role name; custom has no default and requires it) - --class who lives here — human|server. Decides root SSH's fate after - the users phase: human closes it (`rig users close-root`), - server keeps it as the control plane's automation door. + --root-door what happens to root SSH after the users phase — closed|open. + closed: `rig users close-root` shuts it once named operators can + get in. open: it stays, as the control plane's automation door. + (Named --class human|server before #77 — same trait, renamed for + what it decides rather than for who lives on the box. Markers + written by the old flag are still read.) --host does this box host VMs (box/Incus) — yes|no --join how it enters the tailnet — authkey|login -One of --users/--no-users is required on every role, class=server included: +One of --users/--no-users is required on every role, root-door=open included: a box nobody logs into routinely is exactly where shared-root access rots, and per-human accounts keep attribution intact for the times someone does go in. So the complete path is the default path and skipping it is a deliberate --no-users, not an omission. ---users does NOT reach the box TENANT roles (claude|codex|grok|staging). A +--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 @@ -60,23 +64,40 @@ 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 root-door host join + control-plane-server open no authkey + workload-server open no authkey + runner-server open no authkey + staging-server open yes authkey + dev-server closed yes authkey + workstation closed 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 door policy. '-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 --root-door closed' says what is true and says it once: the suffix +names the FAMILY (a fleet machine), the trait names the DOOR (operators enter a +dev box as themselves, so 'users close-root' shuts its door). Until #77 this +trait was '--class human|server', which named the wrong axis — who lives on the +box — and left 'dev-server' reading as a class=human contradiction. Nobody +lives on a dev box; what makes it different is that its root door closes. 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,29 +112,34 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner|dev|workstation|custom) shift ;; - claude|codex|grok|staging) + control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;; + 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|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-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). # Roles are presets, nothing more: every behavior below keys off the traits, # so a flag override changes behavior without a new role, and custom exists # for the shape nobody foresaw — it declares nothing and must state all three. -CLASS="" HOST="" JOIN="" +ROOT_DOOR="" 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) ROOT_DOOR=open HOST=no JOIN=authkey ;; + workload-server) ROOT_DOOR=open HOST=no JOIN=authkey ;; + runner-server) ROOT_DOOR=open 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) ROOT_DOOR=open HOST=yes JOIN=authkey ;; + dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;; + workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;; custom) ;; esac @@ -132,11 +158,11 @@ while [ $# -gt 0 ]; do --hostname) [ $# -ge 2 ] || die "--hostname needs a value" 2 TS_HOSTNAME="$2"; shift 2 ;; - --class) - [ $# -ge 2 ] || die "--class needs a value" 2 + --root-door) + [ $# -ge 2 ] || die "--root-door needs a value" 2 case "$2" in - human|server) CLASS="$2" ;; - *) die "bad --class: $2 (want human|server)" 2 ;; + closed|open) ROOT_DOOR="$2" ;; + *) die "bad --root-door: $2 (want closed|open)" 2 ;; esac shift 2 ;; --host) @@ -172,7 +198,7 @@ done if [ "$ROLE" = "custom" ]; then MISSING="" [ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname" - [ -n "$CLASS" ] || MISSING="$MISSING --class" + [ -n "$ROOT_DOOR" ] || MISSING="$MISSING --root-door" [ -n "$HOST" ] || MISSING="$MISSING --host" [ -n "$JOIN" ] || MISSING="$MISSING --join" [ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2 @@ -188,10 +214,10 @@ fi # --- who lives here (#51) ----------------------------------------------------- # The users file is the last piece of "what this box is" that bootstrap did not # take, and it is REQUIRED rather than optional: a bootstrapped box with no -# users converges to a box only root can enter, and on class=human that is a +# users converges to a box only root can enter, and on root-door=closed that is a # half-built machine waiting for a second command the operator has to remember # (`rig users close-root` is itself gated behind "once your admin key works" — -# which needs an admin to exist). class=server gets the same requirement on +# which needs an admin to exist). root-door=open gets the same requirement on # purpose: a server nobody logs into routinely is exactly where shared-root # access rots, and per-human accounts keep attribution intact for the times # someone does go in. @@ -348,9 +374,9 @@ EOF # The whole block lives in lib/sshd.sh, shared with the staging TENANT role — # one drop-in, one converger, never two copies drifting apart. Everything the # block learned the hard way (00- beats cloud-init's 50- under first-wins, -# validate-then-restart, assert sshd -T not the file, the class-gated +# validate-then-restart, assert sshd -T not the file, the root-door-gated # permitrootlogin acceptance) moved with it, verbatim. -harden_sshd "$CLASS" +harden_sshd "$ROOT_DOOR" # --- system hostname ---------------------------------------------------------- # Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig @@ -430,25 +456,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 --root-door open: 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 @@ -569,22 +596,30 @@ fi # --- role marker -------------------------------------------------------------- # /etc/rig/role is the traits' ground truth for later rig commands (`rig users` -# reads class from it to decide root SSH's fate). Written only AFTER the tag -# verification, so a marker never describes a box that failed to become what it -# claims — and cmp-guarded like every file rig converges. +# reads root-door= from it to decide root SSH's fate). Written only AFTER the +# tag verification, so a marker never describes a box that failed to become what +# it claims — and cmp-guarded like every file rig converges. +# +# The line is written in the CURRENT vocabulary only — `root-door=`, never the +# `class=` it replaced (#77). 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 both-fields-disagree case reachable from rig's own +# hand instead of only from a text editor. The compat obligation runs the other +# way and is discharged in root_door_of: NEW rig reads OLD markers, because +# those exist in the field by the thousand and nothing will rewrite them. MARKER=/etc/rig/role MARKER_TMP="$(mktemp)" -printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$HOST" "$JOIN" > "$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 @@ -672,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 @@ -703,24 +738,24 @@ 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 -# 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/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/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/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/commands/lib/users-config.sh b/commands/lib/users-config.sh index ca62a17..5e2da63 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,99 @@ 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="" 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 "$padded" 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 +204,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 +232,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 70e2e8e..75aea97 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,28 +44,76 @@ 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 -# staging is a box TENANT role since #31 (the guest, not the VM host), and it + "$ROOT/commands/bootstrap.sh" runner-server --ts-tag +# 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 -# 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. +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 +# 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 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" +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 --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 +# 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 --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 --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. @@ -75,7 +123,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 "" \ @@ -95,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 @@ -186,33 +242,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 -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" dev-server --hostname b +check "bootstrap: the requirement holds on root-door=open too" 2 "one of --users" \ + "$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 +283,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 +351,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 +374,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 @@ -337,7 +393,7 @@ fi check "bootstrap: the users phase never runs box setup-host itself" 1 "" \ grep -nE '^[[:space:]]*box setup-host' "$ROOT/commands/bootstrap.sh" # ORDERING is a correctness property, not taste: apply READS /etc/rig/role -# (class= picks its root-SSH note, host= decides what a missing incus group +# (root-door= picks its root-SSH note, host= decides what a missing incus group # means), and on host=yes it needs the group box's installer built. So the # users phase must sit after BOTH the marker write and the box install. Line # numbers, same idiom as the marker/box-install ordering asserts above; @@ -368,7 +424,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) -------------------------------------------- @@ -399,26 +455,26 @@ 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 - # staging dispatches to the tenant mechanism now; reaching ITS root check + 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-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: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --no-users + 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" \ - env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey --no-users + env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --root-door open --host no --join authkey --no-users 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 @@ -426,12 +482,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. @@ -440,48 +507,68 @@ 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 --root-door closed 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 root-door=open 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. +# A closed-door machine (root-door=closed via custom) is NOT that guest, and +# open-door hardening would die at it with root-door=open-specific messaging — +# refuse instead. TEN_FIX="$(mktemp -d)" -printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host" -printf 'role=workload class=server host=no join=authkey\n' > "$TEN_FIX/machine" -printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human" -printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant" -check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \ - env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging +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 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 -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 + +# 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. - 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 + # ...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 + 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 @@ -497,24 +584,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. @@ -524,15 +611,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 "" \ - grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: staging-box hardens through the shared sshd lib" 0 "" \ + 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 # 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 "" \ @@ -548,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 @@ -586,9 +682,15 @@ 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 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 +# 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 @@ -596,10 +698,21 @@ 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 + # 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" \ @@ -972,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 @@ -992,11 +1118,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 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 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" \ @@ -1381,24 +1507,128 @@ 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 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 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' +# 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 @@ -1408,16 +1638,16 @@ 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" -# ...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). @@ -1427,7 +1657,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 @@ -1549,7 +1779,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 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" \