feat(users)!: --class human|server becomes --root-door closed|open

The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.

Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.

Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.

Closes #77

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-20 10:02:50 +00:00
parent 94d9628766
commit b1c1357f2b
10 changed files with 646 additions and 261 deletions

View file

@ -8,6 +8,83 @@ on the way to cutting its first release, and this file starts there.
### Changed ### Changed
- **BREAKING: `--class human|server` is now `--root-door closed|open`** (#77) —
the trait was named for who *lives on* a box; what it decides is one thing,
and it is not occupancy: whether root SSH stays open as the control plane's
automation door, or `rig users close-root` shuts it once named operators can
get in. The roles had been saying so for a while. `dev-server` is an
unattended VM-host appliance — nobody lives there, operators visit it to mint
boxes and leave — and by occupancy it is plainly a server. It was
`class=human` anyway, and correctly so, because operators enter it *as
themselves* and its root door must close. The trait was right; its name
described the wrong axis.
That stayed cheap until a second thing wanted the word "server". After #76
the `-server` suffix names the machine *family*, so `dev-server` carried a
suffix saying server and a trait saying human, and nothing in the name told a
reader that the two words were answering unrelated questions. `dev-server
--root-door closed` says exactly what is true, and `-server` means one thing
everywhere. The values moved with the name: `human``closed`, `server`
`open`, and the marker field follows as `root-door=`.
Other names were considered. `--root-door open|closed` describes a
*destination* rather than the state at bootstrap time — bootstrap leaves root
SSH open on every box, and the door only shuts later, when `close-root` runs
— so `--root-door closes|stays` was on the table for naming the fate as a
verb, as was `--automation-door yes|no` for naming the thing itself. Both were
rejected in favour of the plainer pair: the marker is already a declaration of
*intent* rather than a report of observed state everywhere else in this repo
(`host=yes` claims a box hosts VMs; #58 settled that the marker's claim wins
over probing the machine), so a trait that states the door's designed end
state is consistent with how every other field is read. Every string that
prints the trait says "once operators exist" or names `close-root` explicitly,
so the tense never has to be inferred.
**Old markers still resolve, permanently, and that is the substance of this
change.** Unlike #76's role rename — role names are informational, nothing
reads them back — this field is written into `/etc/rig/role` and read *from*
there on live machines, where it gates `rig users close-root`. Every box
bootstrapped before this carries `class=human` or `class=server` and carries
it until someone re-bootstraps it, which for a fleet is never. Dropping the
old read would have broken in both directions at once and both are incidents:
a machine whose door is supposed to close loses the ability to close it, and
— through `bootstrap-tenant`'s machine-marker guard, which used the presence
of `class=` as its "is this a real fleet machine?" test — a live box stops
looking like a machine at all, so a tenant converge sails past the refusal
that exists to protect it and clobbers its marker. That second one is the
fail-*open* direction and was the least obvious part of the change.
So one resolver, `root_door_of`, reads both vocabularies, and every consumer
goes through it — close-root's gate, apply's root-SSH note, and the tenant
guard — because a compat read that lives at three call sites is three chances
to drift. `root-door=` wins where both fields are present and agree;
`class=` answers alone on every pre-#77 marker. A marker carrying **both and
disagreeing** resolves to a refusal rather than a winner: bootstrap writes one
line fresh and never produces that state, so a marker in it was hand-edited,
and rig declines to arbitrate between two equally-authored claims about a root
door. A marker naming **neither** refuses too, unchanged from before. Both
refusals fail closed, which here means the door stays open and the operator is
told to re-run bootstrap — never a door welded shut on a machine whose only
entrance it was.
**New markers are written in the new vocabulary only.** Writing both would
keep an old rig reading a new marker, but it would entrench the retired
spelling on every box rig ever converges and make the disagreement case
reachable from rig's own hand instead of only from a text editor. The compat
obligation runs the other way and only the other way: new rig reads old
markers. The bounded consequence to know about is downgrade — flipping a
box back to a pre-#77 rig with `rig use` leaves that older code unable to
recognize the new marker; the flip already WARNS on a bootstrapped host (#35),
and re-running bootstrap under whichever rig you settle on rewrites the line.
The suite proves the compat read rather than asserting it. Fixture markers are
kept **deliberately** at the retired spelling — byte for byte as a real
pre-#77 box reads, the same convention #76's `pre-rename-cp` fixture
established — and pinned at both consumers: `close-root` still passes on
`class=human` and still *refuses* on `class=server`, with today's refusal text
naming today's flag, and the tenant guard still recognizes a pre-#77 machine
marker as a machine. Deleting the compat arm turns ten of them red.
- **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other - **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other
half of the rename below. `claude``claude-box`, `codex``codex-box`, half of the rename below. `claude``claude-box`, `codex``codex-box`,
`grok``grok-box`, `staging``staging-box`, so a role name always says `grok``grok-box`, `staging``staging-box`, so a role name always says
@ -81,12 +158,13 @@ on the way to cutting its first release, and this file starts there.
every shipped script for pre-rename role names rather than pinning the known every shipped script for pre-rename role names rather than pinning the known
sites, because the next instance of this will be somewhere else. sites, because the next instance of this will be somewhere else.
`dev-server` is `class=human`, which reads like a contradiction and is not: `dev-server` was `class=human` when this landed, which read like a
the suffix names the family, the class names the root-SSH door policy, and contradiction and was not: the suffix names the family, the class named the
operators enter a dev box as themselves so `close-root` shuts its door. The root-SSH door policy, and operators enter a dev box as themselves so
two axes genuinely share the word "server", which is a wart — #77 renames the `close-root` shuts its door. The two axes genuinely shared the word "server",
class trait to what it actually controls, and is kept separate because it which was a wart — #77, above, renames the trait to what it actually controls
reaches markers on live machines that guard root SSH. 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 ## 0.2.0 — 2026-07-19

View file

@ -38,7 +38,7 @@ All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
| Label | Covers | | Label | Covers |
|---|---| |---|---|
| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node | | `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:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status |
| `scope:coolify` | `commands/coolify-*` — Coolify and its backup install | | `scope:coolify` | `commands/coolify-*` — Coolify and its backup install |
| `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof | | `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 "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 "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: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: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:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force
gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force

199
README.md
View file

@ -104,7 +104,7 @@ rig bootstrap workload-server --hostname my-prod-box --users ./users
rig bootstrap runner-server --hostname my-ci-box --users ./users rig bootstrap runner-server --hostname my-ci-box --users ./users
rig bootstrap dev-server --hostname my-dev-box --users ./users rig bootstrap dev-server --hostname my-dev-box --users ./users
rig bootstrap workstation --hostname my-laptop --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 <path>` / `--no-users`**required**, one or the other: the users - `--users <path>` / `--no-users`**required**, one or the other: the users
@ -112,17 +112,19 @@ rig bootstrap custom --hostname my-vm-host --class server --host yes --join auth
(see *One command, box ready* below) (see *One command, box ready* below)
- `--hostname <name>` — system + tailnet hostname (default: the role name; - `--hostname <name>` — system + tailnet hostname (default: the role name;
`custom` has no default and requires it) `custom` has no default and requires it)
- `--class <human|server>` — who lives here; decides root SSH's fate after - `--root-door <closed|open>` — what happens to root SSH after the users
the users phase (see *The identity model* below) 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 <yes|no>` — does this box host VMs (box/Incus) - `--host <yes|no>` — does this box host VMs (box/Incus)
- `--join <authkey|login>` — how it enters the tailnet - `--join <authkey|login>` — how it enters the tailnet
#### One command, box ready — `--users` is required #### One command, box ready — `--users` is required
`rig bootstrap` already knows everything else about what a box *is*class, `rig bootstrap` already knows everything else about what a box *is*the
host, join, hostname — and writes `/etc/rig/role` to say so. The users file root-door policy, host, join, hostname — and writes `/etc/rig/role` to say so.
was the last piece of that answer it did not take, so bring-up was two The users file was the last piece of that answer it did not take, so bring-up
commands and the second one was easy to forget. Now it takes it, and was two commands and the second one was easy to forget. Now it takes it, and
**requires** it: **requires** it:
```sh ```sh
@ -133,16 +135,16 @@ rig bootstrap dev-server --hostname my-dev-box --no-users # deliberately
`--users <path>` runs exactly what `rig users apply --file <path>` runs, as `--users <path>` runs exactly what `rig users apply --file <path>` runs, as
bootstrap's **final phase** — after the traits are set, after the tailnet 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* 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` `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. 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 The file is passed per invocation and **never persisted** — bootstrap reads
it through apply and keeps nothing; `--users -` is refused, because it through apply and keeps nothing; `--users -` is refused, because
bootstrap's stdin belongs to the pre-auth key prompt. bootstrap's stdin belongs to the pre-auth key prompt.
Required on **every** role, `class=server` included. A bootstrapped box with Required on **every** role, `root-door=open` included. A bootstrapped box with
no users converges to a box only root can enter — on `class=human` a no users converges to a box only root can enter — on `root-door=closed` a
half-built machine, and on `class=server` something worse than half-built: 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, 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 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 in. So the complete path is the default path, and skipping it is a deliberate
@ -151,7 +153,7 @@ Omitting both is a usage error naming both flags; passing both is a usage
error too — rig will not silently pick a winner. error too — rig will not silently pick a winner.
A bad users file is caught **up front**, in the same breath as a bad 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 `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, spent. A file that names **no users** is refused there too — empty,
comments-only and whitespace-only files all parse fine, but bootstrapping comments-only and whitespace-only files all parse fine, but bootstrapping
@ -184,20 +186,20 @@ presets nothing and requires `--hostname` plus all three traits.
| trait | values | what it drives | | 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` | | `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) | | `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) |
| role | class | host | join | tailnet tag | | role | root-door | host | join | tailnet tag |
|------------------------|--------|------|---------|-------------| |------------------------|-----------|------|---------|-------------|
| `control-plane-server` | server | no | authkey | `tag:server` | | `control-plane-server` | open | no | authkey | `tag:server` |
| `workload-server` | server | no | authkey | `tag:server` | | `workload-server` | open | no | authkey | `tag:server` |
| `runner-server` | server | no | authkey | `tag:ci` — refuses `tag:server` | | `runner-server` | open | no | authkey | `tag:ci` — refuses `tag:server` |
| `staging-server` | server | yes | authkey | `tag:local` — refuses `tag:server` | | `staging-server` | open | yes | authkey | `tag:local` — refuses `tag:server` |
| `dev-server` | human | yes | authkey | `tag:local` — refuses `tag:server` | | `dev-server` | closed | yes | authkey | `tag:local` — refuses `tag:server` |
| `workstation` | human | yes | login | untagged — any tag refused | | `workstation` | closed | yes | login | untagged — any tag refused |
> **The suffix names the family, not the class** (#76). rig builds two kinds > **The suffix names the family, not the door policy** (#76). rig builds two kinds
> of thing on opposite sides of a trust boundary — tailnet **machines** it > 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 > 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 > name said which you were asking for. `staging` made that concrete: the word
@ -213,13 +215,16 @@ presets nothing and requires `--hostname` plus all three traits.
> joins by interactive login, comes up user-owned and untagged, and the > joins by interactive login, comes up user-owned and untagged, and the
> tailnet never manages it. > tailnet never manages it.
> >
> **`dev-server` is `class=human`, and that is not a contradiction** — though > **`dev-server --root-door closed` says what is true, and says it once**
> it is a wart. The suffix names the *family*; the class names the *root-SSH > (#77). The suffix names the *family* — a fleet machine — and the trait names
> door policy*, and operators enter a dev box as themselves, so `close-root` > the *door*: operators enter a dev box as themselves, so `close-root` shuts
> shuts its door. Two orthogonal axes that happen to share the word "server". > its door. Until #77 this trait was `--class human|server`, which named the
> [#77](https://github.com/heavy-duty/rig/issues/77) renames the class trait > wrong axis (who lives on the box) and made `dev-server` read as a
> to what it actually controls, which is the real fix; it touches markers on > `class=human` contradiction: one word, "server", doing duty on two unrelated
> live machines, so it is deliberately not folded in here. > 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 > **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 > box bootstrapped under one is re-bootstrapped rather than migrated. Two
@ -236,9 +241,9 @@ the only shapes it manages — every other role refuses an effective
`tag:server` after join, one rule instead of per-role exceptions. `tag:server` after join, one rule instead of per-role exceptions.
After the tag verification passes, bootstrap writes `/etc/rig/role` — one 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 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 post-join and cmp-guarded, so a marker never describes a box that failed to
become what it claims. become what it claims.
@ -330,14 +335,14 @@ hard, post-join error.
The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by The 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 the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside
with the tenant roles and (for staging guests) `rig bootstrap workload-server` with the tenant roles and (for staging guests) `rig bootstrap workload-server`
is the `staging-server` role (`--class server --host yes --join authkey`; see is the `staging-server` role (`--root-door open --host yes --join authkey`; see
the note above). It is `class=server`: an unattended VM appliance — operators 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 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* 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 is never managed by the control plane — so an effective **`tag:server` is
refused**, same mechanism as `runner-server`. 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 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 and runs box's own `setup-host`**, so the Incus stack is ready for
`box new` when bootstrap returns. rig **delegates to box; it `box new` when bootstrap returns. rig **delegates to box; it
@ -368,11 +373,11 @@ merges box's root install lands in `/root`.)
> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you > fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
> manage by hand. > manage by hand.
`dev-server` is the human-class VM-hosting shape — `tag:local`, box CLI installed as `dev-server` is the closed-door VM-hosting shape — `tag:local`, box CLI installed as
above, a person living on it (`--class server` turns it into the unattended above, operators entering as themselves (`--root-door open` turns it into the
VM-host appliance) — and `workstation` is the machine at the keyboard end of unattended VM-host appliance) — and `workstation` is the machine at the keyboard
all the SSH connections: human-class, `join=login`, entering the tailnet as end of all the SSH connections: `root-door=closed`, `join=login`, entering the
*your* device rather than the fleet's. tailnet as *your* device rather than the fleet's.
### `rig bootstrap <claude-box|codex-box|grok-box|staging-box>` — the box tenants ### `rig bootstrap <claude-box|codex-box|grok-box|staging-box>` — the box tenants
@ -403,7 +408,7 @@ holds the whole per-tenant table), not four hand-maintained scripts:
| `claude-box` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` | | `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` | | `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` | | `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) | | `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) |
**The role carries the suffix; the user does not.** A tenant user is the **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 account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
@ -441,7 +446,7 @@ silently breaks its networking (box#80). The note lives in
the point of moving it here. the point of moving it here.
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes **Tenants and the role marker.** A tenant run writes `role=<tenant> 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 (`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 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 tenants outright, and *any* tenant refuses a `host=yes` box — a VM host is
@ -455,61 +460,106 @@ command is exactly who that refusal catches (it names the new spelling).
> dev channel. Until rig cuts 0.1.0 there is no release to resolve, so the > 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 > seed must set `RIG_REF=main` explicitly (the default channel fails loudly
> rather than falling back). That inverts the install edge on this page: > 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 > `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. > of your own fork. The seed side of this edge is box#81's to document.
### The identity model ### 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 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 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 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. themself and elevates via sudo.
Per role, the whole identity picture at a glance — issue #25's class 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` | | role | root-door | host | join | who lives here | root SSH after `rig users apply` |
|------------------------|--------|------|---------|--------------------------------------|----------------------------------| |------------------------|-----------|------|---------|--------------------------------------|----------------------------------|
| `control-plane-server` | server | no | authkey | nobody — Coolify runs here | open — the automation door | | `control-plane-server` | open | no | authkey | nobody — Coolify runs here | open — the automation door |
| `workload-server` | server | no | authkey | nobody — deployed services run here | open — the automation door | | `workload-server` | open | no | authkey | nobody — deployed services run here | open — the automation door |
| `runner-server` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | | `runner-server` | open | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door |
| `staging-server` | server | yes | authkey | nobody — it mints and hosts guests | open — the automation door | | `staging-server` | open | yes | authkey | nobody — it mints and hosts guests | open — the automation door |
| `dev-server` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` | | `dev-server` | closed | yes | authkey | operators, minting boxes | closed by `rig users close-root` |
| `workstation` | human | yes | login | its owner | closed by `rig users close-root` | | `workstation` | closed | yes | login | its owner | closed by `rig users close-root` |
(`staging-server` is that unattended VM-host appliance, and the row above is (`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 the whole of it: nobody lives there, root SSH stays open as the automation
door. The box TENANT roles sit outside this table on door. The box TENANT roles sit outside this table on
purpose: a guest is not a tailnet machine, and its marker carries no `class=`, purpose: a guest is not a tailnet machine, and its marker carries no `root-door=`,
so `rig users close-root` fails closed on it.) so `rig users close-root` fails closed on it.)
Who installs what, and who runs as what: **bootstrap is always root** and 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 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**: (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 (roles `admin`/`rig`) or the `incus` group (role `box`) — never by logging in
as root. **Machine identities stay machine-shaped**: Coolify's automation 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 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 server-class boxes, unprivileged `github-runner`, and guest VMs are their own open-door boxes,
converged from inside by `rig bootstrap workload-server`. converged from inside by `rig bootstrap workload-server`.
**`class` decides root SSH's fate — after `rig users apply`, never before.** **`root-door` decides root SSH's fate — after `rig users apply`, never before.**
On `class=human`, root SSH closes entirely (`rig users close-root`, below). On `root-door=closed`, root SSH closes entirely (`rig users close-root`, below).
On `class=server` it stays open — key-only, as bootstrap left it — because 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 root there is the **automation** identity the control plane (Coolify) SSHes
in as. It is a machine door, never a human one. 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 **Where this diverges from #17's original table:** that table let the
`runner-server` role close root ("no Coolify involved"). The class model supersedes `runner-server` role close root ("no Coolify involved"). The trait model supersedes
the per-role call: runner is `class=server` — an automation identity, not a the per-role call: runner is `root-door=open` — an automation identity, not a
person's box — and on every server-class machine root SSH is the management person's box — and on every open-door machine root SSH is the management
plane rig itself converges through, so `close-root` refuses there plane rig itself converges through, so `close-root` refuses there
deliberately, runner included. A CI box you mean to administer like a human 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 machine is `--root-door closed` at bootstrap, not an exception carved out of
gate. the gate.
**The detection side benefit:** once humans never use root, any root login **The detection side benefit:** once humans never use root, any root login
that is not the control plane is anomalous *by definition* — a cheap, that is not the control plane is anomalous *by definition* — a cheap,
@ -803,7 +853,7 @@ and never asks for a token.
### `rig users apply --file <path>` ### `rig users apply --file <path>`
Converges named operator accounts from a declarative users file — on **every** 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". run says "already converged; no changes".
This is also what `rig bootstrap --users <path>` runs as its last phase, so on This is also what `rig bootstrap --users <path>` runs as its last phase, so on
@ -822,7 +872,7 @@ of the line). The format is bash-parseable on purpose: a rig box has no YAML
parser and no jq, and gets neither for this. Repeated username lines add 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 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 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 `--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. once, before anything changes — one fix cycle, not one round-trip per line.
@ -838,7 +888,7 @@ the admin returns until you switch the line to literal keys — and apply dies
if root has no `authorized_keys` to seed. Root's key lines are copied if root has no `authorized_keys` to seed. Root's key lines are copied
verbatim, options included: a `from=`/`command=` restriction follows the key, 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 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* **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 private infra repo and is passed per invocation — rig never persists it. It
@ -955,12 +1005,15 @@ network, no writes. Run as root (shadow is read).
rig users close-root 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 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 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 `class=server` 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 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 `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 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 the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the
@ -1001,7 +1054,7 @@ admin login must be proven, not presumed.
Convergent — once root is closed, a re-run says "root already closed; nothing Convergent — once root is closed, a re-run says "root already closed; nothing
to do" and exits 0. 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 > guidance, deliberately not automation: prefix Coolify's line in root's
> `authorized_keys` with a `from="<control-plane-addr>"` clause, so the > `authorized_keys` with a `from="<control-plane-addr>"` clause, so the
> automation identity only opens from the one address supposed to use it. rig > automation identity only opens from the one address supposed to use it. rig

View file

@ -20,7 +20,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh # shellcheck source=SCRIPTDIR/lib/tenant-config.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context . "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh # 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 # shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant) . "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
@ -53,7 +53,7 @@ tenant on top, and re-runs converge an existing box to a new spec.
Tenant roles are creds-free and non-interactive by contract — box auto-runs Tenant roles are creds-free and non-interactive by contract — box auto-runs
them at mint (`box exec … rig bootstrap claude-box`). They take none of the them at mint (`box exec … rig bootstrap claude-box`). They take none of the
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest, machine-role traits (--hostname/--root-door/--host/--join): a tenant is a guest,
not a tailnet machine. Run as root, inside the box. not a tailnet machine. Run as root, inside the box.
EOF EOF
} }
@ -74,7 +74,7 @@ while [ $# -gt 0 ]; do
--user) --user)
[ $# -ge 2 ] || die "--user needs a value" 2 [ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 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 # The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes # flag": a tenant is a guest, not a tailnet machine — its shape comes
# from the box seed, and the one trait-shaped thing a staging-box guest # from the box seed, and the one trait-shaped thing a staging-box guest
@ -102,28 +102,41 @@ done
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest. # - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
# Names the staging PAIR out loud, because whoever lands here has the two # Names the staging PAIR out loud, because whoever lands here has the two
# halves confused: the metal is `staging-server`, the guest `staging-box`. # halves confused: the metal is `staging-server`, the guest `staging-box`.
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine. # - a root-door policy (agent tenants) → refuse: an agent box is never a
# - class=server with host=no (staging-box only) → PROCEED, and leave the # 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 # 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. # 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 # ONLY that shape — any other door policy (say root-door=closed, via
# machine rig built on purpose, and staging-box hardening it with server rules # `custom`) is a machine rig built on purpose, and staging-box hardening it
# would die with server-specific messaging on a box that was never one. # 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}" MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")" EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
EXISTING_ROOT_DOOR="$(root_door_of "$EXISTING_MARKER")"
case "$EXISTING_MARKER" in case "$EXISTING_MARKER" in
*host=yes*) *host=yes*)
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;; die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
*class=*)
if [ "$ROLE" != "staging-box" ]; then
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
fi
case "$EXISTING_MARKER" in
*class=server*) ;;
*)
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (class=server host=no). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." ;;
esac ;;
esac 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" [ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
@ -357,20 +370,27 @@ fi
# box#69's posture, minus the join: docker (above) + sshd hardening, through # box#69's posture, minus the join: docker (above) + sshd hardening, through
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a # 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 # 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. # plane's future automation door.
if [ "$ROLE" = "staging-box" ]; then if [ "$ROLE" = "staging-box" ]; then
harden_sshd server harden_sshd open
fi fi
# --- role marker -------------------------------------------------------------- # --- role marker --------------------------------------------------------------
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant # Same ground truth the machine roles write, tenant-shaped: no root-door trait
# has no root-door policy of its own — close-root fails closed on it), and # at all (a tenant has no root-door policy of its own — close-root fails closed
# host=no so `rig users apply` box-role gating keeps working. staging-box SKIPS the # on it), and host=no so `rig users apply` box-role gating keeps working.
# write when a machine marker is already present: after the operator-run # staging-box SKIPS the write when a machine marker is already present: after
# workload join, the workload marker is the truer statement and rig never # the operator-run workload join, the workload marker is the truer statement and
# clobbers state a joined box earned. # rig never clobbers state a joined box earned.
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then #
# "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)" MARKER_TMP="$(mktemp)"
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP" printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then

View file

@ -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` # 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 # 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 # 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. # spent — instead of at the very end of a run the operator already paid for.
log() { printf 'rig-bootstrap: %s\n' "$*"; } log() { printf 'rig-bootstrap: %s\n' "$*"; }
@ -25,7 +25,7 @@ usage() {
usage: rig bootstrap <control-plane-server|workload-server|runner-server| usage: rig bootstrap <control-plane-server|workload-server|runner-server|
staging-server|dev-server|workstation|custom> staging-server|dev-server|workstation|custom>
(--users <path> | --no-users) (--users <path> | --no-users)
[--hostname <name>] [--class <human|server>] [--hostname <name>] [--root-door <closed|open>]
[--host <yes|no>] [--join <authkey|login>] [--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>] rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
(the box TENANT roles — see their own --help; they take (the box TENANT roles — see their own --help; they take
@ -39,13 +39,16 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
tailnet and leaves the box with root as its only door. tailnet and leaves the box with root as its only door.
--hostname system + tailnet hostname (default: the role name; custom has --hostname system + tailnet hostname (default: the role name; custom has
no default and requires it) no default and requires it)
--class who lives here — human|server. Decides root SSH's fate after --root-door what happens to root SSH after the users phase — closed|open.
the users phase: human closes it (`rig users close-root`), closed: `rig users close-root` shuts it once named operators can
server keeps it as the control plane's automation door. 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 --host does this box host VMs (box/Incus) — yes|no
--join how it enters the tailnet — authkey|login --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, 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 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 in. So the complete path is the default path and skipping it is a deliberate
@ -61,15 +64,15 @@ operator file has nothing to converge in there.
Roles are presets over the three traits; any flag overrides its trait. Roles are presets over the three traits; any flag overrides its trait.
custom presets nothing and requires --hostname plus all three traits. custom presets nothing and requires --hostname plus all three traits.
role class host join role root-door host join
control-plane-server server no authkey control-plane-server open no authkey
workload-server server no authkey workload-server open no authkey
runner-server server no authkey runner-server open no authkey
staging-server server yes authkey staging-server open yes authkey
dev-server human yes authkey dev-server closed yes authkey
workstation human yes login workstation closed yes login
THE SUFFIX NAMES THE FAMILY, not the class. '-server' means this role builds a 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 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 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 name said which you were asking for; 'staging' made that concrete by naming
@ -81,11 +84,12 @@ both the metal and the guests on it.
it joins by interactive login and comes up user-owned and it joins by interactive login and comes up user-owned and
untagged, and the tailnet never manages it. untagged, and the tailnet never manages it.
'dev-server' is class=human, and that is not a contradiction: the suffix names 'dev-server --root-door closed' says what is true and says it once: the suffix
the family, the CLASS names the root-SSH door policy (operators enter a dev box names the FAMILY (a fleet machine), the trait names the DOOR (operators enter a
as themselves, so 'users close-root' shuts its door). The two axes share the dev box as themselves, so 'users close-root' shuts its door). Until #77 this
word 'server' and that is a genuine wart — tracked in #77, which renames the trait was '--class human|server', which named the wrong axis — who lives on the
class trait to what it actually controls. 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, 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 so the key is the single source of truth: rig no longer requests a tag it might
@ -124,18 +128,18 @@ esac
# Roles are presets, nothing more: every behavior below keys off the traits, # 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 # 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. # for the shape nobody foresaw — it declares nothing and must state all three.
CLASS="" HOST="" JOIN="" ROOT_DOOR="" HOST="" JOIN=""
case "$ROLE" in case "$ROLE" in
control-plane-server) CLASS=server HOST=no JOIN=authkey ;; control-plane-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
workload-server) CLASS=server HOST=no JOIN=authkey ;; workload-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
runner-server) CLASS=server 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 # 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 # 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 # 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. # runs box's setup-host further down, so this is a table row, not machinery.
staging-server) CLASS=server HOST=yes JOIN=authkey ;; staging-server) ROOT_DOOR=open HOST=yes JOIN=authkey ;;
dev-server) CLASS=human HOST=yes JOIN=authkey ;; dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
workstation) CLASS=human HOST=yes JOIN=login ;; workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
custom) ;; custom) ;;
esac esac
@ -154,11 +158,11 @@ while [ $# -gt 0 ]; do
--hostname) --hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 2 [ $# -ge 2 ] || die "--hostname needs a value" 2
TS_HOSTNAME="$2"; shift 2 ;; TS_HOSTNAME="$2"; shift 2 ;;
--class) --root-door)
[ $# -ge 2 ] || die "--class needs a value" 2 [ $# -ge 2 ] || die "--root-door needs a value" 2
case "$2" in case "$2" in
human|server) CLASS="$2" ;; closed|open) ROOT_DOOR="$2" ;;
*) die "bad --class: $2 (want human|server)" 2 ;; *) die "bad --root-door: $2 (want closed|open)" 2 ;;
esac esac
shift 2 ;; shift 2 ;;
--host) --host)
@ -194,7 +198,7 @@ done
if [ "$ROLE" = "custom" ]; then if [ "$ROLE" = "custom" ]; then
MISSING="" MISSING=""
[ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname" [ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname"
[ -n "$CLASS" ] || MISSING="$MISSING --class" [ -n "$ROOT_DOOR" ] || MISSING="$MISSING --root-door"
[ -n "$HOST" ] || MISSING="$MISSING --host" [ -n "$HOST" ] || MISSING="$MISSING --host"
[ -n "$JOIN" ] || MISSING="$MISSING --join" [ -n "$JOIN" ] || MISSING="$MISSING --join"
[ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2 [ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2
@ -210,10 +214,10 @@ fi
# --- who lives here (#51) ----------------------------------------------------- # --- who lives here (#51) -----------------------------------------------------
# The users file is the last piece of "what this box is" that bootstrap did not # 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 # 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 # 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" — # (`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 # purpose: a server nobody logs into routinely is exactly where shared-root
# access rots, and per-human accounts keep attribution intact for the times # access rots, and per-human accounts keep attribution intact for the times
# someone does go in. # someone does go in.
@ -370,9 +374,9 @@ EOF
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role — # 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 # 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, # 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. # permitrootlogin acceptance) moved with it, verbatim.
harden_sshd "$CLASS" harden_sshd "$ROOT_DOOR"
# --- system hostname ---------------------------------------------------------- # --- system hostname ----------------------------------------------------------
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig # Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
@ -467,7 +471,7 @@ verify_effective_tag() {
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)." ;; 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 owns the VM-host shape too — 'staging-server' by name now, # This arm owns the VM-host shape too — 'staging-server' by name now,
# plus custom/dev-server --class server: a host is never managed by the # 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 # control plane — its guest VMs are — so tag:server is refused there
# like everywhere else outside the two control-plane-managed shapes. # like everywhere else outside the two control-plane-managed shapes.
# Mint the metal's key with tag:local. # Mint the metal's key with tag:local.
@ -592,22 +596,30 @@ fi
# --- role marker -------------------------------------------------------------- # --- role marker --------------------------------------------------------------
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users` # /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 # reads root-door= from it to decide root SSH's fate). Written only AFTER the
# verification, so a marker never describes a box that failed to become what it # tag verification, so a marker never describes a box that failed to become what
# claims — and cmp-guarded like every file rig converges. # 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=/etc/rig/role
MARKER_TMP="$(mktemp)" 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 if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
mkdir -p /etc/rig mkdir -p /etc/rig
install -m 0644 "$MARKER_TMP" "$MARKER" 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 else
log "role marker already current" log "role marker already current"
fi fi
rm -f "$MARKER_TMP" 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 # 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 # 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 # setup-host build the Incus stack. Placed AFTER the role marker write on
@ -695,7 +707,7 @@ fi
# --- users (the last phase, and it must be last) ------------------------------- # --- users (the last phase, and it must be last) -------------------------------
# Ordering is a correctness property, not a preference. `users apply` READS # 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 # 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 # 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 # and warn "re-run rig bootstrap so this box knows what it is" — in the middle
@ -731,19 +743,19 @@ if [ "$ROLE" = "control-plane-server" ]; then
elif [ "$ROLE" = "runner-server" ]; then elif [ "$ROLE" = "runner-server" ]; then
log "next: rig runner install --repo <owner/repo> --version <pin>" log "next: rig runner install --repo <owner/repo> --version <pin>"
fi fi
# Every class gets operators: humans always enter as themselves and elevate via # Every box gets operators: humans always enter as themselves and elevate via
# sudo — a shared root login is unattributable. What differs by class is root # sudo — a shared root login is unattributable. What differs, per the root-door
# SSH's fate once named users exist. With --users the accounts exist already, so # 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 # 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. # to do; --no-users still owes the box its people, and says so.
if [ -n "$USERS_FILE" ]; then 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" log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST"
else else
log "operators are converged; root SSH stays — it is the control plane's automation door" log "operators are converged; root SSH stays — it is the control plane's automation door"
fi fi
else 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 <users-file>, then 'rig users close-root' once your admin key works" log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
else 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 <users-file>" 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 <users-file>"

View file

@ -6,12 +6,15 @@
# a hardening block is drift by construction, the same law that keeps rig's # a hardening block is drift by construction, the same law that keeps rig's
# hands off Incus. Callers provide log/warn/die. # hands off Incus. Callers provide log/warn/die.
# harden_sshd <human|server> — install the 00-rig.conf hardening drop-in, # harden_sshd <closed|open> — install the 00-rig.conf hardening drop-in,
# validate the merged config before touching the daemon, restart only when the # 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 # 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 <human|server> 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() { harden_sshd() {
local class="$1" local root_door="$1"
local dropin=/etc/ssh/sshd_config.d/00-rig.conf local dropin=/etc/ssh/sshd_config.d/00-rig.conf
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
local tmp backup eff 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" eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \ 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/" || 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 # The permitrootlogin acceptance is ROOT-DOOR-gated, because `no` means
# things on the two classes. class=human: `no` is the post-`rig users # opposite things on the two policies. root-door=closed: `no` is the post-`rig
# close-root` state — strictly harder than the prohibit-password this function # users close-root` state — strictly harder than the prohibit-password this
# installs. Hardening must never read a closed door as a broken one, and it # function installs. Hardening must never read a closed door as a broken one,
# cannot reopen one either: by first-wins its own drop-in loses to # 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 # 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 # 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 # 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 # a former root-door=closed life on a repurposed box. rig can DETECT that but
# not FIX it: silently reopening a root door is worse than a loud stop, so # 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. # same doctrine as the tag checks — detect, refuse, and name the repair.
if [ "$class" = "human" ]; then if [ "$root_door" = "closed" ]; then
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \ echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
elif echo "$eff" | grep -qx 'permitrootlogin no'; then 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 else
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \ echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"

View file

@ -32,8 +32,8 @@
# fix cycle, not one round-trip per line. # fix cycle, not one round-trip per line.
# #
# Refusals: unknown role (the valid set is named), differing roles across one # 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 # user's lines, root as username (root's keys are root-door policy's business,
# this file's), malformed line (fewer than 3 fields, or a key field that does # 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 # 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 # trailing material (the token IS the whole field), invalid username (the
# charset below — '|' would corrupt this parser's own delimited stream, a # charset below — '|' would corrupt this parser's own delimited stream, a
@ -71,7 +71,7 @@ parse_users_file() {
continue continue
fi fi
if [ "$u" = "root" ]; then 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 continue
fi fi
ok=1 ok=1
@ -103,21 +103,86 @@ parse_users_file() {
} }
# read_role_marker <path> — the marker line bootstrap wrote # read_role_marker <path> — the marker line bootstrap wrote
# (`role=... class=... host=... join=...`), or nothing when absent. NO policy # (`role=... root-door=... host=... join=...`), or nothing when absent. NO
# here: what an absent marker or a given class MEANS is each caller's call # policy here: what an absent marker or a given trait MEANS is each caller's
# (apply notes it, close-root refuses on it) — this reader only reads. # call (apply notes it, close-root refuses on it) — this reader only reads.
read_role_marker() { read_role_marker() {
[ -r "$1" ] || return 0 [ -r "$1" ] || return 0
head -n1 "$1" head -n1 "$1"
} }
# assert_marker_human <marker_path> — close-root's marker gate: return 0, # root_door_of <marker line> — resolve the root-door trait from a marker LINE,
# silently, only when the marker says class=human; otherwise print the refusal # reading both the current `root-door=` vocabulary and the `class=` one it
# reason on stdout and return 1 (the caller wraps it in its own die). The # replaced (#77). Prints exactly one of:
# 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, # closed the root SSH door is meant to shut once named operators exist
# non-root (repo precedent: parse_users_file, assert_runner_repo). # (`rig users close-root`) — was class=human
assert_marker_human() { # open root SSH stays as the control plane's automation door
# — was class=server
# conflict the marker names BOTH vocabularies and they DISAGREE
# (empty) the marker names neither, or names one with a value that is
# not in its value set
#
# Text->text and total, so the harness proves every arm off a literal string
# (repo precedent: deny_verdict, group_allow_verdict). Callers turn a verdict
# into policy; this function has none.
#
# WHY THE COMPAT READ IS NOT OPTIONAL, and why it is here rather than at each
# call site. #77 renamed the trait because `class=human|server` was named for
# who lives on the box while what it decides is whether root SSH stays open as
# the control plane's automation door — the axis that made `dev-server` a
# `class=human` box, a suffix and a trait that read as a contradiction. But
# unlike #76's role rename, this field is not informational: it is written into
# /etc/rig/role and read back on live machines, where it gates `rig users
# close-root`. Every box bootstrapped before this change carries `class=` and
# carries it FOREVER, until someone re-bootstraps it — there is no migration
# step that reaches a fleet. So dropping the old read breaks in both directions
# at once, and both are incidents: a machine whose door should close stops
# being able to close it (close-root refuses on a marker it no longer
# understands), and — through bootstrap-tenant's machine-marker guard, which
# used `class=` as its "this is a machine" detector — a real fleet box stops
# looking like a machine at all and a tenant converge will happily clobber it.
# One resolver, consulted everywhere the trait is read, is what keeps those
# two readings from drifting apart.
#
# BOTH FIELDS PRESENT is a state bootstrap never writes — it writes one line,
# fresh, in the new vocabulary only — so a marker carrying both was
# hand-edited, and the two answers are a question about intent that rig cannot
# settle. Agreement is taken (it says one thing twice); disagreement resolves
# to `conflict` and every caller fails CLOSED on it, because the alternative is
# picking a winner between two equally-authored claims about a root door. The
# repair is the same one the rest of the marker family names: re-run bootstrap,
# which rewrites the line whole.
#
# NEITHER FIELD PRESENT resolves empty and is likewise fail-closed everywhere,
# unchanged from before: a marker that names no door policy cannot authorize
# shutting a door.
root_door_of() {
local marker="$1" new="" old=""
case "$marker" in
*root-door=closed*) new=closed ;;
*root-door=open*) new=open ;;
esac
case "$marker" in
*class=human*) old=closed ;;
*class=server*) old=open ;;
esac
if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then
printf 'conflict'; return 0
fi
# New wins where both are readable and agree; the old field answers alone on
# every marker written before #77, which is the whole point.
printf '%s' "${new:-$old}"
}
# assert_marker_closes_root <marker_path> — 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 local marker
marker="$(read_role_marker "$1")" marker="$(read_role_marker "$1")"
if [ -z "$marker" ]; then if [ -z "$marker" ]; then
@ -126,22 +191,27 @@ assert_marker_human() {
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind" 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 return 1
fi fi
case "$marker" in case "$(root_door_of "$marker")" in
*class=human*) return 0 ;; closed) return 0 ;;
*class=server*) open)
# Root SSH on a server IS the control plane's (Coolify's) automation # Root SSH on such a box IS the control plane's (Coolify's) automation
# identity — closing it severs fleet management. No --force exists. # identity — closing it severs fleet management. No --force exists.
# Deliberately per-CLASS, not per-role: #17's original table let the # Deliberately keyed on the DOOR, not on the role: #17's original table
# runner role close root ("no Coolify involved"), but the class model # let the runner role close root ("no Coolify involved"), but the trait
# (#26) supersedes that — every server-class box, runner included, is # model (#26) supersedes that — every root-door=open box, runner
# an automation identity whose management plane is root SSH, and rig # included, is an automation identity whose management plane is root
# itself converges through that door. A CI box someone administers # SSH, and rig itself converges through that door. A CI box someone
# like a human machine is class=human at bootstrap, not an exception # administers like a human machine is --root-door closed at bootstrap,
# carved out here. # 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" 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 ;; 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 ;; return 1 ;;
esac esac
} }
@ -149,7 +219,7 @@ assert_marker_human() {
# assert_marker_hosts_vms <marker_path> — the box role's gate: return 0, # assert_marker_hosts_vms <marker_path> — the box role's gate: return 0,
# silently, only when the marker says host=yes; otherwise print the reason on # 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). # 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 # 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. # fixture markers, non-root, while the CLI path sits behind the root check.
# #

View file

@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig users apply — converge named operator accounts from a declarative users # 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 # 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 # too — the root-door trait never gates this command, it only decides root
# users exist (close-root on human, kept as the control plane's automation # SSH's fate AFTER users exist (close-root on root-door=closed, kept as the
# door on server). Convergent: a second identical run changes nothing and # control plane's automation door on root-door=open). Convergent: a second
# says so. # identical run changes nothing and says so.
set -euo pipefail set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" 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" || 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 fi
# Class is a note, never a refusal: #26's call is that operators belong on # The root-door trait is a note here, never a refusal: #26's call is that
# EVERY class — what differs is root SSH's fate once they exist. # operators belong on EVERY box — what differs is root SSH's fate once they
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in # exist. Resolved through root_door_of so this note and close-root's gate read
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;; # one marker the same way, pre-#77 class= spellings included (#77): a box whose
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;; # note says the door will shut must be a box where close-root agrees it shuts.
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;; APPLY_MARKER="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
esac 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 CHANGED=0

View file

@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig users close-root — shut the human-class root SSH door, once and only # rig users close-root — shut the root SSH door on the boxes whose door is
# once a named admin can already get in. class decides root SSH's fate (#26): # meant to shut, once and only once a named admin can already get in. The
# on class=human a root login is unattributable noise, so it goes; on # root-door trait decides root SSH's fate (#26, renamed by #77): on
# class=server root IS the control plane's automation identity, so closing it # 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 # would sever fleet management — this command refuses there, and no --force
# exists. Convergent: a second run is a no-op and says so. # exists. Convergent: a second run is a no-op and says so.
set -euo pipefail 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 carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
first-wins include order. first-wins include order.
Human class ONLY. On class=server, root SSH is the control plane's (Coolify's) root-door=closed boxes ONLY. On root-door=open, root SSH is the control plane's
automation identity — closing it severs fleet management — so close-root (Coolify's) automation identity — closing it severs fleet management — so
refuses there, with no --force. It also refuses without a role marker (re-run 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 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: 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 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)" 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 fi
# Marker gate — the policy lives in assert_marker_human (lib) so the harness # Marker gate — the policy lives in assert_marker_closes_root (lib) so the
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER # harness can prove its refusals against fixture markers as non-root;
# exists for the same reason: it keeps the command's own gate pointable at # RIG_ROLE_MARKER exists for the same reason: it keeps the command's own gate
# fixtures instead of only at the real /etc/rig/role. # pointable at fixtures instead of only at the real /etc/rig/role. The gate
if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then # 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" die "$WHY"
fi fi

View file

@ -92,7 +92,7 @@ done
check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \ check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
check "bootstrap: custom keeps its bare name" 2 "--hostname" \ check "bootstrap: custom keeps its bare name" 2 "--hostname" \
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey "$ROOT/commands/bootstrap.sh" custom --root-door open --host no --join authkey
# NOTHING may still TELL an operator to run a pre-#76 role. The rename is a # 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 # 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 # a bare role name is a command that fails when someone copy-pastes it — and it
@ -108,12 +108,12 @@ check "roles: no shipped script tells an operator to run a pre-#76 role name" 1
"$ROOT/bin/rig" "$ROOT/commands/" "$ROOT/bin/rig" "$ROOT/commands/"
# --- traits: roles are presets, every trait individually settable (#26) ----- # --- 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: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato check "bootstrap: bad --root-door value exits 2" 2 "closed|open" "$ROOT/commands/bootstrap.sh" workload-server --root-door potato
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload-server --host maybe check "bootstrap: bad --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: 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" \ check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey "$ROOT/commands/bootstrap.sh" custom --root-door open --host no --join authkey
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1 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 # 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 # must die BEFORE the root check — provable non-root, which also proves the
# preset actually landed. # preset actually landed.
@ -143,7 +143,15 @@ check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
# The marker is the traits' ground truth for rig users; assert the write exists. # The marker is the traits' ground truth for rig users; assert the write exists.
check "bootstrap: role marker write is present" 0 "" \ check "bootstrap: role marker write is present" 0 "" \
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" 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 # 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 # 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 # root, over the network, against a real host — none of which this harness can
@ -237,7 +245,7 @@ check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users <pa
"$ROOT/commands/bootstrap.sh" workload-server "$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" \ check "bootstrap: the requirement names --no-users as the way out" 2 "--no-users to leave it root-only" \
"$ROOT/commands/bootstrap.sh" dev-server --hostname b "$ROOT/commands/bootstrap.sh" dev-server --hostname b
check "bootstrap: the requirement holds on class=server too" 2 "one of --users" \ check "bootstrap: the requirement holds on root-door=open too" 2 "one of --users" \
"$ROOT/commands/bootstrap.sh" control-plane-server --hostname cp "$ROOT/commands/bootstrap.sh" control-plane-server --hostname cp
check "bootstrap: --users needs a value" 2 "needs a value" \ check "bootstrap: --users needs a value" 2 "needs a value" \
"$ROOT/commands/bootstrap.sh" workload-server --users "$ROOT/commands/bootstrap.sh" workload-server --users
@ -385,7 +393,7 @@ fi
check "bootstrap: the users phase never runs box setup-host itself" 1 "" \ check "bootstrap: the users phase never runs box setup-host itself" 1 "" \
grep -nE '^[[:space:]]*box setup-host' "$ROOT/commands/bootstrap.sh" grep -nE '^[[:space:]]*box setup-host' "$ROOT/commands/bootstrap.sh"
# ORDERING is a correctness property, not taste: apply READS /etc/rig/role # 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 # 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 # 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; # numbers, same idiom as the marker/box-install ordering asserts above;
@ -461,7 +469,7 @@ if [ "$(id -u)" -ne 0 ]; then
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: 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: 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" \ 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 else
echo "skip: bootstrap non-root refusals (running as root)" echo "skip: bootstrap non-root refusals (running as root)"
fi fi
@ -499,7 +507,7 @@ check "tenant: dockerd effective-state assert is present" 0 "" \
# The machine-role traits die with the tenant story, never "unknown flag" — an # The machine-role traits die with the tenant story, never "unknown flag" — an
# operator reaching for --hostname must learn where the trait family went. # operator reaching for --hostname must learn where the trait family went.
check "tenant: trait flags die with the tenant story" 2 "have no traits" \ check "tenant: trait flags die with the tenant story" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" claude-box --class human "$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed
check "tenant: --hostname dies the same way" 2 "have no traits" \ check "tenant: --hostname dies the same way" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest "$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant # Dispatch: the machine-role entrypoint hands tenant roles to the tenant
@ -511,23 +519,39 @@ check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|code
# VM host (host=yes) refuses for every tenant — and names the staging PAIR, # 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 # 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 # (staging-server). An agent tenant refuses ANY machine-role box; staging-box
# tolerates ONLY class=server with host=no — that is the guest after its # 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. # 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 # A closed-door machine (root-door=closed via custom) is NOT that guest, and
# hardening would die at it with server-specific messaging — refuse instead. # open-door hardening would die at it with root-door=open-specific messaging —
# refuse instead.
TEN_FIX="$(mktemp -d)" TEN_FIX="$(mktemp -d)"
printf 'role=dev-server class=human host=yes join=authkey\n' > "$TEN_FIX/host" printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host"
printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/machine" printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human" 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" printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
check "tenant: staging-box refuses a non-server machine box" 1 "non-server machine role" \ check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \
env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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" \ check "tenant: refuses a host=yes box (a VM host is never a guest)" 1 "hosts VMs" \
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude-box 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" \ 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 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" \ check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box 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 if [ "$(id -u)" -ne 0 ]; then
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before # 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. # the root check, and the harness machine may carry a real /etc/rig/role.
@ -539,6 +563,10 @@ if [ "$(id -u)" -ne 0 ]; then
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box 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" \ 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 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" \ check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box
else else
@ -586,7 +614,7 @@ check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
# staging-box's posture rides the SAME hardening code as the machine roles — the # 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. # shared lib call is the anti-drift property, so pin the call, not the words.
check "tenant: staging-box hardens through the shared sshd lib" 0 "" \ check "tenant: staging-box hardens through the shared sshd lib" 0 "" \
grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh" grep -qE '^[[:space:]]*harden_sshd open$' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: docker lands via docker's own installer" 0 "" \ check "tenant: docker lands via docker's own installer" 0 "" \
grep -q "get.docker.com" "$ROOT/commands/bootstrap-tenant.sh" 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 # The #15 lesson pinned: 'box exec' shells read no rc files, so the CLI must
@ -607,6 +635,15 @@ ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-ten
ten_marker_at="$(grep -nF 'install -m 0644 "$MARKER_TMP" "$MARKER_PATH"' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)" 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" \ check "tenant: the marker write follows the context-file converge" \
0 "" test "${ten_ctx_at:-999999}" -lt "${ten_marker_at:-0}" 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: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
@ -645,8 +682,8 @@ marker_warns() { # marker_warns <marker_path> <cmd...> — how many warnings fir
env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true
} }
MARKER_FIX="$(mktemp -d)" MARKER_FIX="$(mktemp -d)"
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload" printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARKER_FIX/workload"
printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane" printf 'role=control-plane-server root-door=open host=no join=authkey\n' > "$MARKER_FIX/control-plane"
printf 'role=control-plane-server\n' > "$MARKER_FIX/bare-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 # 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 # carries it. This is the one fixture that must keep its old spelling: the
@ -1048,10 +1085,23 @@ check "users apply: revoked keys are renamed, never deleted" 0 "" \
# admins included — still converges. # admins included — still converges.
check "users apply: box role skips on a host=no box" 0 "" \ check "users apply: box role skips on a host=no box" 0 "" \
grep -q "box role skipped" "$ROOT/commands/users-apply.sh" 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 box role's host= gate (#58) -----------------------------------------
# The gate is a pure marker->verdict lib function for the same reason # 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. # arm is proven HERE against fixture markers, non-root.
# #
# What #58 fixed: the trait used to be consulted only when group incus was # What #58 fixed: the trait used to be consulted only when group incus was
@ -1068,11 +1118,11 @@ hostvm_gate() { # hostvm_gate <marker_path>
assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" assert_marker_hosts_vms "$2"' _ "$ROOT" "$1"
} }
HOSTVM_FIX="$(mktemp -d)" HOSTVM_FIX="$(mktemp -d)"
printf 'role=dev-server class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$HOSTVM_FIX/yes"
printf 'role=workload-server class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" 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 # 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. # names no host=. Distinct from an ABSENT marker and it must not read as yes.
printf 'role=workload-server class=server join=authkey\n' > "$HOSTVM_FIX/traitless" printf 'role=workload-server root-door=open join=authkey\n' > "$HOSTVM_FIX/traitless"
check "users apply: host=yes passes the box-role gate" \ check "users apply: host=yes passes the box-role gate" \
0 "" hostvm_gate "$HOSTVM_FIX/yes" 0 "" hostvm_gate "$HOSTVM_FIX/yes"
check "users apply: host=no fails the box-role gate" \ check "users apply: host=no fails the box-role gate" \
@ -1457,24 +1507,103 @@ check "users close-root: the gate consults deny_verdict" 0 "" \
marker_gate() { # marker_gate <marker_path> marker_gate() { # marker_gate <marker_path>
bash -c 'set -euo pipefail bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh" . "$1/commands/lib/users-config.sh"
assert_marker_human "$2"' _ "$ROOT" "$1" assert_marker_closes_root "$2"' _ "$ROOT" "$1"
} }
MARKER_DIR="$(mktemp -d)" MARKER_DIR="$(mktemp -d)"
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/server" printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARKER_DIR/open"
printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/human" 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" \ check "users close-root: absent marker refuses, names bootstrap as the repair" \
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent" 1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
check "users close-root: class=server refuses, names the control plane" \ check "users close-root: root-door=open refuses, names the control plane" \
1 "control plane" marker_gate "$MARKER_DIR/server" 1 "control plane" marker_gate "$MARKER_DIR/open"
# #17's original table let the runner ROLE close root; the class model # #17's original table let the runner ROLE close root; the trait model
# supersedes it — runner is class=server, an automation identity, and the # 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 # refusal must SAY so or the divergence reads as a bug to anyone holding the
# old table. # old table.
check "users close-root: the server refusal owns the runner row (#17)" \ check "users close-root: the open-door refusal owns the runner row (#17)" \
1 "runner included" marker_gate "$MARKER_DIR/server" 1 "runner included" marker_gate "$MARKER_DIR/open"
check "users close-root: class=human passes the gate" \ check "users close-root: root-door=closed passes the gate" \
0 "" marker_gate "$MARKER_DIR/human" 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" 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 <marker line>
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"
printf "[%s]" "$(root_door_of "$2")"' _ "$ROOT" "$1"
}
check "root_door_of: reads the current vocabulary" 0 "[closed]" \
door_of 'role=dev-server root-door=closed host=yes join=authkey'
check "root_door_of: reads the pre-#77 class=human as closed" 0 "[closed]" \
door_of 'role=dev-server class=human host=yes join=authkey'
check "root_door_of: reads the pre-#77 class=server as open" 0 "[open]" \
door_of 'role=workload-server class=server host=no join=authkey'
check "root_door_of: a tenant marker names no door at all" 0 "[]" \
door_of 'role=claude-box tenant=yes host=no'
check "root_door_of: disagreement is a conflict, not a coin flip" 0 "[conflict]" \
door_of 'role=custom root-door=open class=human host=no join=login'
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh" check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
else else
@ -1488,12 +1617,12 @@ fi
# that bootstrap actually runs it (a function nobody calls is not hardening). # that bootstrap actually runs it (a function nobody calls is not hardening).
check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \ check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/lib/sshd.sh" 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 # ...but only for root-door=closed. On root-door=open a closed root door is a
# box — root SSH is the control plane's automation door — and the usual cause # BROKEN box — root SSH is the control plane's automation door — and the usual
# is a 00-rig-users.conf left over from a former class=human life. The refusal # 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 # 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). # 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" 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 # 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). # must still precede the restart (same idiom as the close-root ordering check).
@ -1503,7 +1632,7 @@ check "sshd lib: sshd -t precedes the ssh restart" \
0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}" 0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}"
# shellcheck disable=SC2016 # shellcheck disable=SC2016
check "bootstrap: hardening runs through the shared lib" 0 "" \ 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 # 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 # error in it would be invisible here and would first surface at 04:00 on a live
@ -1625,7 +1754,7 @@ check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" --
# host itself — /etc/rig/role. The deliberate decision: warn and proceed. # host itself — /etc/rig/role. The deliberate decision: warn and proceed.
# Driven against a fixture marker; counting fires proves silence too. # Driven against a fixture marker; counting fires proves silence too.
MARK="$WORK/role-marker" MARK="$WORK/role-marker"
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARK" printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARK"
H2="$WORK/h2"; B2="$WORK/b2" H2="$WORK/h2"; B2="$WORK/b2"
check "flip gate: baseline install" 0 "done" inst "$H2" "$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" \ check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \