forked from heavy-duty/rig
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:
parent
94d9628766
commit
b1c1357f2b
10 changed files with 646 additions and 261 deletions
90
CHANGELOG.md
90
CHANGELOG.md
|
|
@ -8,6 +8,83 @@ on the way to cutting its first release, and this file starts there.
|
|||
|
||||
### Changed
|
||||
|
||||
- **BREAKING: `--class human|server` is now `--root-door closed|open`** (#77) —
|
||||
the trait was named for who *lives on* a box; what it decides is one thing,
|
||||
and it is not occupancy: whether root SSH stays open as the control plane's
|
||||
automation door, or `rig users close-root` shuts it once named operators can
|
||||
get in. The roles had been saying so for a while. `dev-server` is an
|
||||
unattended VM-host appliance — nobody lives there, operators visit it to mint
|
||||
boxes and leave — and by occupancy it is plainly a server. It was
|
||||
`class=human` anyway, and correctly so, because operators enter it *as
|
||||
themselves* and its root door must close. The trait was right; its name
|
||||
described the wrong axis.
|
||||
|
||||
That stayed cheap until a second thing wanted the word "server". After #76
|
||||
the `-server` suffix names the machine *family*, so `dev-server` carried a
|
||||
suffix saying server and a trait saying human, and nothing in the name told a
|
||||
reader that the two words were answering unrelated questions. `dev-server
|
||||
--root-door closed` says exactly what is true, and `-server` means one thing
|
||||
everywhere. The values moved with the name: `human` → `closed`, `server` →
|
||||
`open`, and the marker field follows as `root-door=`.
|
||||
|
||||
Other names were considered. `--root-door open|closed` describes a
|
||||
*destination* rather than the state at bootstrap time — bootstrap leaves root
|
||||
SSH open on every box, and the door only shuts later, when `close-root` runs
|
||||
— so `--root-door closes|stays` was on the table for naming the fate as a
|
||||
verb, as was `--automation-door yes|no` for naming the thing itself. Both were
|
||||
rejected in favour of the plainer pair: the marker is already a declaration of
|
||||
*intent* rather than a report of observed state everywhere else in this repo
|
||||
(`host=yes` claims a box hosts VMs; #58 settled that the marker's claim wins
|
||||
over probing the machine), so a trait that states the door's designed end
|
||||
state is consistent with how every other field is read. Every string that
|
||||
prints the trait says "once operators exist" or names `close-root` explicitly,
|
||||
so the tense never has to be inferred.
|
||||
|
||||
**Old markers still resolve, permanently, and that is the substance of this
|
||||
change.** Unlike #76's role rename — role names are informational, nothing
|
||||
reads them back — this field is written into `/etc/rig/role` and read *from*
|
||||
there on live machines, where it gates `rig users close-root`. Every box
|
||||
bootstrapped before this carries `class=human` or `class=server` and carries
|
||||
it until someone re-bootstraps it, which for a fleet is never. Dropping the
|
||||
old read would have broken in both directions at once and both are incidents:
|
||||
a machine whose door is supposed to close loses the ability to close it, and
|
||||
— through `bootstrap-tenant`'s machine-marker guard, which used the presence
|
||||
of `class=` as its "is this a real fleet machine?" test — a live box stops
|
||||
looking like a machine at all, so a tenant converge sails past the refusal
|
||||
that exists to protect it and clobbers its marker. That second one is the
|
||||
fail-*open* direction and was the least obvious part of the change.
|
||||
|
||||
So one resolver, `root_door_of`, reads both vocabularies, and every consumer
|
||||
goes through it — close-root's gate, apply's root-SSH note, and the tenant
|
||||
guard — because a compat read that lives at three call sites is three chances
|
||||
to drift. `root-door=` wins where both fields are present and agree;
|
||||
`class=` answers alone on every pre-#77 marker. A marker carrying **both and
|
||||
disagreeing** resolves to a refusal rather than a winner: bootstrap writes one
|
||||
line fresh and never produces that state, so a marker in it was hand-edited,
|
||||
and rig declines to arbitrate between two equally-authored claims about a root
|
||||
door. A marker naming **neither** refuses too, unchanged from before. Both
|
||||
refusals fail closed, which here means the door stays open and the operator is
|
||||
told to re-run bootstrap — never a door welded shut on a machine whose only
|
||||
entrance it was.
|
||||
|
||||
**New markers are written in the new vocabulary only.** Writing both would
|
||||
keep an old rig reading a new marker, but it would entrench the retired
|
||||
spelling on every box rig ever converges and make the disagreement case
|
||||
reachable from rig's own hand instead of only from a text editor. The compat
|
||||
obligation runs the other way and only the other way: new rig reads old
|
||||
markers. The bounded consequence to know about is downgrade — flipping a
|
||||
box back to a pre-#77 rig with `rig use` leaves that older code unable to
|
||||
recognize the new marker; the flip already WARNS on a bootstrapped host (#35),
|
||||
and re-running bootstrap under whichever rig you settle on rewrites the line.
|
||||
|
||||
The suite proves the compat read rather than asserting it. Fixture markers are
|
||||
kept **deliberately** at the retired spelling — byte for byte as a real
|
||||
pre-#77 box reads, the same convention #76's `pre-rename-cp` fixture
|
||||
established — and pinned at both consumers: `close-root` still passes on
|
||||
`class=human` and still *refuses* on `class=server`, with today's refusal text
|
||||
naming today's flag, and the tenant guard still recognizes a pre-#77 machine
|
||||
marker as a machine. Deleting the compat arm turns ten of them red.
|
||||
|
||||
- **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other
|
||||
half of the rename below. `claude` → `claude-box`, `codex` → `codex-box`,
|
||||
`grok` → `grok-box`, `staging` → `staging-box`, so a role name always says
|
||||
|
|
@ -81,12 +158,13 @@ on the way to cutting its first release, and this file starts there.
|
|||
every shipped script for pre-rename role names rather than pinning the known
|
||||
sites, because the next instance of this will be somewhere else.
|
||||
|
||||
`dev-server` is `class=human`, which reads like a contradiction and is not:
|
||||
the suffix names the family, the class names the root-SSH door policy, and
|
||||
operators enter a dev box as themselves so `close-root` shuts its door. The
|
||||
two axes genuinely share the word "server", which is a wart — #77 renames the
|
||||
class trait to what it actually controls, and is kept separate because it
|
||||
reaches markers on live machines that guard root SSH.
|
||||
`dev-server` was `class=human` when this landed, which read like a
|
||||
contradiction and was not: the suffix names the family, the class named the
|
||||
root-SSH door policy, and operators enter a dev box as themselves so
|
||||
`close-root` shuts its door. The two axes genuinely shared the word "server",
|
||||
which was a wart — #77, above, renames the trait to what it actually controls
|
||||
and retires it. It stayed a separate change because it reaches markers on live
|
||||
machines that guard root SSH, and so needed a compat read this rename did not.
|
||||
|
||||
## 0.2.0 — 2026-07-19
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
|
|||
| Label | Covers |
|
||||
|---|---|
|
||||
| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node |
|
||||
| `scope:users` | `commands/users-*` — the class model, apply/status, close-root |
|
||||
| `scope:users` | `commands/users-*` — the root-door model, apply/status, close-root |
|
||||
| `scope:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status |
|
||||
| `scope:coolify` | `commands/coolify-*` — Coolify and its backup install |
|
||||
| `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof |
|
||||
|
|
@ -74,7 +74,7 @@ gh label create "stale" --color B60205 --description "No activity
|
|||
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
|
||||
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
|
||||
gh label create "scope:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force
|
||||
gh label create "scope:users" --color C5DEF5 --description "users-* — class model, apply/status, close-root" --force
|
||||
gh label create "scope:users" --color C5DEF5 --description "users-* — root-door model, apply/status, close-root" --force
|
||||
gh label create "scope:runner" --color C5DEF5 --description "runner-* — GitHub runner lifecycle" --force
|
||||
gh label create "scope:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force
|
||||
gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force
|
||||
|
|
|
|||
199
README.md
199
README.md
|
|
@ -104,7 +104,7 @@ rig bootstrap workload-server --hostname my-prod-box --users ./users
|
|||
rig bootstrap runner-server --hostname my-ci-box --users ./users
|
||||
rig bootstrap dev-server --hostname my-dev-box --users ./users
|
||||
rig bootstrap workstation --hostname my-laptop --users ./users
|
||||
rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey --users ./users
|
||||
rig bootstrap custom --hostname my-vm-host --root-door open --host yes --join authkey --users ./users
|
||||
```
|
||||
|
||||
- `--users <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)
|
||||
- `--hostname <name>` — system + tailnet hostname (default: the role name;
|
||||
`custom` has no default and requires it)
|
||||
- `--class <human|server>` — who lives here; decides root SSH's fate after
|
||||
the users phase (see *The identity model* below)
|
||||
- `--root-door <closed|open>` — what happens to root SSH after the users
|
||||
phase: `closed` means `rig users close-root` shuts it once named operators
|
||||
can get in, `open` means it stays as the control plane's automation door
|
||||
(see *The identity model* below)
|
||||
- `--host <yes|no>` — does this box host VMs (box/Incus)
|
||||
- `--join <authkey|login>` — how it enters the tailnet
|
||||
|
||||
#### One command, box ready — `--users` is required
|
||||
|
||||
`rig bootstrap` already knows everything else about what a box *is* — class,
|
||||
host, join, hostname — and writes `/etc/rig/role` to say so. The users file
|
||||
was the last piece of that answer it did not take, so bring-up was two
|
||||
commands and the second one was easy to forget. Now it takes it, and
|
||||
`rig bootstrap` already knows everything else about what a box *is* — the
|
||||
root-door policy, host, join, hostname — and writes `/etc/rig/role` to say so.
|
||||
The users file was the last piece of that answer it did not take, so bring-up
|
||||
was two commands and the second one was easy to forget. Now it takes it, and
|
||||
**requires** it:
|
||||
|
||||
```sh
|
||||
|
|
@ -133,16 +135,16 @@ rig bootstrap dev-server --hostname my-dev-box --no-users # deliberately
|
|||
`--users <path>` runs exactly what `rig users apply --file <path>` runs, as
|
||||
bootstrap's **final phase** — after the traits are set, after the tailnet
|
||||
join is verified, and after `/etc/rig/role` is written, because apply *reads*
|
||||
that marker (`class=` picks its root-SSH note, `host=` decides what a missing
|
||||
that marker (`root-door=` picks its root-SSH note, `host=` decides what a missing
|
||||
`incus` group means). On a `host=yes` box it also lands after the `box`
|
||||
install, so box-role users find the `incus` group box's `setup-host` built.
|
||||
The file is passed per invocation and **never persisted** — bootstrap reads
|
||||
it through apply and keeps nothing; `--users -` is refused, because
|
||||
bootstrap's stdin belongs to the pre-auth key prompt.
|
||||
|
||||
Required on **every** role, `class=server` included. A bootstrapped box with
|
||||
no users converges to a box only root can enter — on `class=human` a
|
||||
half-built machine, and on `class=server` something worse than half-built: a
|
||||
Required on **every** role, `root-door=open` included. A bootstrapped box with
|
||||
no users converges to a box only root can enter — on `root-door=closed` a
|
||||
half-built machine, and on `root-door=open` something worse than half-built: a
|
||||
machine nobody logs into routinely is exactly where shared-root access rots,
|
||||
and per-human accounts keep attribution intact for the times someone does go
|
||||
in. So the complete path is the default path, and skipping it is a deliberate
|
||||
|
|
@ -151,7 +153,7 @@ Omitting both is a usage error naming both flags; passing both is a usage
|
|||
error too — rig will not silently pick a winner.
|
||||
|
||||
A bad users file is caught **up front**, in the same breath as a bad
|
||||
`--class`: bootstrap pre-flights it with the same parser apply uses, before
|
||||
`--root-door`: bootstrap pre-flights it with the same parser apply uses, before
|
||||
`apt`, before the hostname change, and before a single-use pre-auth key is
|
||||
spent. A file that names **no users** is refused there too — empty,
|
||||
comments-only and whitespace-only files all parse fine, but bootstrapping
|
||||
|
|
@ -184,20 +186,20 @@ presets nothing and requires `--hostname` plus all three traits.
|
|||
|
||||
| trait | values | what it drives |
|
||||
|---------|--------------------|----------------|
|
||||
| `class` | `human`, `server` | root SSH's fate once operators exist — human closes it, server keeps it as the control plane's automation door |
|
||||
| `root-door` | `closed`, `open` | root SSH's fate once operators exist — `closed` shuts it via `rig users close-root`, `open` keeps it as the control plane's automation door |
|
||||
| `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and, on `yes`, installing the `box` CLI + running box's `setup-host` |
|
||||
| `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) |
|
||||
|
||||
| role | class | host | join | tailnet tag |
|
||||
|------------------------|--------|------|---------|-------------|
|
||||
| `control-plane-server` | server | no | authkey | `tag:server` |
|
||||
| `workload-server` | server | no | authkey | `tag:server` |
|
||||
| `runner-server` | server | no | authkey | `tag:ci` — refuses `tag:server` |
|
||||
| `staging-server` | server | yes | authkey | `tag:local` — refuses `tag:server` |
|
||||
| `dev-server` | human | yes | authkey | `tag:local` — refuses `tag:server` |
|
||||
| `workstation` | human | yes | login | untagged — any tag refused |
|
||||
| role | root-door | host | join | tailnet tag |
|
||||
|------------------------|-----------|------|---------|-------------|
|
||||
| `control-plane-server` | open | no | authkey | `tag:server` |
|
||||
| `workload-server` | open | no | authkey | `tag:server` |
|
||||
| `runner-server` | open | no | authkey | `tag:ci` — refuses `tag:server` |
|
||||
| `staging-server` | open | yes | authkey | `tag:local` — refuses `tag:server` |
|
||||
| `dev-server` | closed | yes | authkey | `tag:local` — refuses `tag:server` |
|
||||
| `workstation` | closed | yes | login | untagged — any tag refused |
|
||||
|
||||
> **The suffix names the family, not the class** (#76). rig builds two kinds
|
||||
> **The suffix names the family, not the door policy** (#76). rig builds two kinds
|
||||
> of thing on opposite sides of a trust boundary — tailnet **machines** it
|
||||
> converges, and **guests** a box mints — and for a while nothing in a role
|
||||
> name said which you were asking for. `staging` made that concrete: the word
|
||||
|
|
@ -213,13 +215,16 @@ presets nothing and requires `--hostname` plus all three traits.
|
|||
> joins by interactive login, comes up user-owned and untagged, and the
|
||||
> tailnet never manages it.
|
||||
>
|
||||
> **`dev-server` is `class=human`, and that is not a contradiction** — though
|
||||
> it is a wart. The suffix names the *family*; the class names the *root-SSH
|
||||
> door policy*, and operators enter a dev box as themselves, so `close-root`
|
||||
> shuts its door. Two orthogonal axes that happen to share the word "server".
|
||||
> [#77](https://github.com/heavy-duty/rig/issues/77) renames the class trait
|
||||
> to what it actually controls, which is the real fix; it touches markers on
|
||||
> live machines, so it is deliberately not folded in here.
|
||||
> **`dev-server --root-door closed` says what is true, and says it once**
|
||||
> (#77). The suffix names the *family* — a fleet machine — and the trait names
|
||||
> the *door*: operators enter a dev box as themselves, so `close-root` shuts
|
||||
> its door. Until #77 this trait was `--class human|server`, which named the
|
||||
> wrong axis (who lives on the box) and made `dev-server` read as a
|
||||
> `class=human` contradiction: one word, "server", doing duty on two unrelated
|
||||
> questions. Nobody *lives* on a dev box; what distinguishes it is that its
|
||||
> root door closes. Markers written before the rename still say
|
||||
> `class=human|server` and are still read — see *The root-door trait was
|
||||
> renamed* below.
|
||||
>
|
||||
> **This was a hard cut — no aliases.** Old role names stop working, and a
|
||||
> box bootstrapped under one is re-bootstrapped rather than migrated. Two
|
||||
|
|
@ -236,9 +241,9 @@ the only shapes it manages — every other role refuses an effective
|
|||
`tag:server` after join, one rule instead of per-role exceptions.
|
||||
|
||||
After the tag verification passes, bootstrap writes `/etc/rig/role` — one
|
||||
line, `role=… class=… host=… join=…` — recording the **effective** traits,
|
||||
line, `role=… root-door=… host=… join=…` — recording the **effective** traits,
|
||||
overrides and all, so an overridden role never lies to the commands that read
|
||||
the marker later (`rig users` keys root policy off `class=`). Written
|
||||
the marker later (`rig users` keys root policy off `root-door=`). Written
|
||||
post-join and cmp-guarded, so a marker never describes a box that failed to
|
||||
become what it claims.
|
||||
|
||||
|
|
@ -330,14 +335,14 @@ hard, post-join error.
|
|||
The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by
|
||||
the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside
|
||||
with the tenant roles and (for staging guests) `rig bootstrap workload-server` —
|
||||
is the `staging-server` role (`--class server --host yes --join authkey`; see
|
||||
the note above). It is `class=server`: an unattended VM appliance — operators
|
||||
is the `staging-server` role (`--root-door open --host yes --join authkey`; see
|
||||
the note above). It is `root-door=open`: an unattended VM appliance — operators
|
||||
converge it and leave; nobody lives there. Mint its key with `tag:local`: the
|
||||
host and its guests sit on opposite sides of a trust boundary, and the *host*
|
||||
is never managed by the control plane — so an effective **`tag:server` is
|
||||
refused**, same mechanism as `runner-server`.
|
||||
|
||||
On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving
|
||||
On a VM-hosting box (`host=yes`), bootstrap finishes the job instead of leaving
|
||||
a to-do: after the role marker is written it **installs the `box` CLI globally
|
||||
and runs box's own `setup-host`**, so the Incus stack is ready for
|
||||
`box new` when bootstrap returns. rig **delegates to box; it
|
||||
|
|
@ -368,11 +373,11 @@ merges box's root install lands in `/root`.)
|
|||
> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
|
||||
> manage by hand.
|
||||
|
||||
`dev-server` is the human-class VM-hosting shape — `tag:local`, box CLI installed as
|
||||
above, a person living on it (`--class server` turns it into the unattended
|
||||
VM-host appliance) — and `workstation` is the machine at the keyboard end of
|
||||
all the SSH connections: human-class, `join=login`, entering the tailnet as
|
||||
*your* device rather than the fleet's.
|
||||
`dev-server` is the closed-door VM-hosting shape — `tag:local`, box CLI installed as
|
||||
above, operators entering as themselves (`--root-door open` turns it into the
|
||||
unattended VM-host appliance) — and `workstation` is the machine at the keyboard
|
||||
end of all the SSH connections: `root-door=closed`, `join=login`, entering the
|
||||
tailnet as *your* device rather than the fleet's.
|
||||
|
||||
### `rig bootstrap <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` |
|
||||
| `codex-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
|
||||
| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
|
||||
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) |
|
||||
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) |
|
||||
|
||||
**The role carries the suffix; the user does not.** A tenant user is the
|
||||
account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
|
||||
|
|
@ -441,7 +446,7 @@ silently breaks its networking (box#80). The note lives in
|
|||
the point of moving it here.
|
||||
|
||||
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
|
||||
host=no` — no `class=`, because a guest has no root-door policy of its own
|
||||
host=no` — no `root-door=`, because a guest has no root-door policy of its own
|
||||
(`rig users close-root` fails closed on it, by design). The guard runs the
|
||||
other way too: a box already carrying a **machine** role refuses the agent
|
||||
tenants outright, and *any* tenant refuses a `host=yes` box — a VM host is
|
||||
|
|
@ -455,61 +460,106 @@ command is exactly who that refusal catches (it names the new spelling).
|
|||
> dev channel. Until rig cuts 0.1.0 there is no release to resolve, so the
|
||||
> seed must set `RIG_REF=main` explicitly (the default channel fails loudly
|
||||
> rather than falling back). That inverts the install edge on this page:
|
||||
> rig installs box on host-class machines, and box guests now install rig.
|
||||
> rig installs box on VM-hosting machines, and box guests now install rig.
|
||||
> `RIG_REPO`/`RIG_REF` are the pin points, or point them at a frozen branch
|
||||
> of your own fork. The seed side of this edge is box#81's to document.
|
||||
|
||||
### The identity model
|
||||
|
||||
**Named operators exist on every class, and humans never enter as root.** The
|
||||
**Named operators exist on every box, and humans never enter as root.** The
|
||||
tailnet is network-only — no Tailscale SSH — so there is no identity broker at
|
||||
the door: whoever holds a key to an account *is* that account, and a shared
|
||||
root login is unattributable by construction. `rig users apply` puts named
|
||||
operators on every box, server-class included; a human always enters as
|
||||
operators on every box, `root-door=open` included; a human always enters as
|
||||
themself and elevates via sudo.
|
||||
|
||||
Per role, the whole identity picture at a glance — issue #25's class
|
||||
comparison, translated onto the traits that replaced the class binary:
|
||||
comparison, translated onto the traits that replaced the class binary. Note
|
||||
that "who lives here" and the root-door trait are **different columns**: that
|
||||
they were ever one word is exactly what #77 fixed.
|
||||
|
||||
| role | class | host | join | who lives here | root SSH after `rig users apply` |
|
||||
|------------------------|--------|------|---------|--------------------------------------|----------------------------------|
|
||||
| `control-plane-server` | server | no | authkey | nobody — Coolify runs here | open — the automation door |
|
||||
| `workload-server` | server | no | authkey | nobody — deployed services run here | open — the automation door |
|
||||
| `runner-server` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door |
|
||||
| `staging-server` | server | yes | authkey | nobody — it mints and hosts guests | open — the automation door |
|
||||
| `dev-server` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` |
|
||||
| `workstation` | human | yes | login | its owner | closed by `rig users close-root` |
|
||||
| role | root-door | host | join | who lives here | root SSH after `rig users apply` |
|
||||
|------------------------|-----------|------|---------|--------------------------------------|----------------------------------|
|
||||
| `control-plane-server` | open | no | authkey | nobody — Coolify runs here | open — the automation door |
|
||||
| `workload-server` | open | no | authkey | nobody — deployed services run here | open — the automation door |
|
||||
| `runner-server` | open | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door |
|
||||
| `staging-server` | open | yes | authkey | nobody — it mints and hosts guests | open — the automation door |
|
||||
| `dev-server` | closed | yes | authkey | operators, minting boxes | closed by `rig users close-root` |
|
||||
| `workstation` | closed | yes | login | its owner | closed by `rig users close-root` |
|
||||
|
||||
(`staging-server` is that unattended VM-host appliance, and the row above is
|
||||
the whole of it: nobody lives there, root SSH stays open as the automation
|
||||
door. The box TENANT roles sit outside this table on
|
||||
purpose: a guest is not a tailnet machine, and its marker carries no `class=`,
|
||||
purpose: a guest is not a tailnet machine, and its marker carries no `root-door=`,
|
||||
so `rig users close-root` fails closed on it.)
|
||||
|
||||
Who installs what, and who runs as what: **bootstrap is always root** and
|
||||
installs everything a role needs — on `host=yes` that includes the box CLI
|
||||
(globally) and box's own `setup-host`. **Humans always run as themselves**:
|
||||
operators land via `rig users apply` on every class and elevate through sudo
|
||||
operators land via `rig users apply` on every box and elevate through sudo
|
||||
(roles `admin`/`rig`) or the `incus` group (role `box`) — never by logging in
|
||||
as root. **Machine identities stay machine-shaped**: Coolify's automation
|
||||
SSHes in as root (that is what server-class root *is*), CI jobs run as the
|
||||
unprivileged `github-runner`, and guest VMs are their own server-class boxes,
|
||||
SSHes in as root (that is what `root-door=open` root *is*), CI jobs run as the
|
||||
unprivileged `github-runner`, and guest VMs are their own open-door boxes,
|
||||
converged from inside by `rig bootstrap workload-server`.
|
||||
|
||||
**`class` decides root SSH's fate — after `rig users apply`, never before.**
|
||||
On `class=human`, root SSH closes entirely (`rig users close-root`, below).
|
||||
On `class=server` it stays open — key-only, as bootstrap left it — because
|
||||
**`root-door` decides root SSH's fate — after `rig users apply`, never before.**
|
||||
On `root-door=closed`, root SSH closes entirely (`rig users close-root`, below).
|
||||
On `root-door=open` it stays open — key-only, as bootstrap left it — because
|
||||
root there is the **automation** identity the control plane (Coolify) SSHes
|
||||
in as. It is a machine door, never a human one.
|
||||
|
||||
#### The root-door trait was renamed, and old markers still resolve
|
||||
|
||||
This trait was `--class human|server` until
|
||||
[#77](https://github.com/heavy-duty/rig/issues/77). `class` named who *lived
|
||||
on* the box; what it actually decides is whether root SSH stays open as the
|
||||
control plane's automation door. Those are different questions, and the roles
|
||||
proved it: `dev-server` is an unattended VM-host appliance — nobody lives
|
||||
there — yet it was `class=human`, correctly, because operators enter it as
|
||||
themselves and its root door must close. After #76 gave `-server` a second
|
||||
job (naming the machine *family*), `dev-server` carried a suffix saying server
|
||||
and a trait saying human, and nothing in the name said which axis was which.
|
||||
`--root-door closed|open` names the axis rig actually branches on.
|
||||
|
||||
**This is not a cosmetic rename, and it is not a hard cut.** Unlike role
|
||||
names — which nothing reads back — this trait is written into `/etc/rig/role`
|
||||
and read *from* there on live machines, where it gates `rig users close-root`.
|
||||
Every box bootstrapped before #77 carries `class=human` or `class=server` and
|
||||
carries it **forever**, until someone re-bootstraps it; nothing migrates a
|
||||
fleet. So rig **reads both spellings, permanently**:
|
||||
|
||||
| marker says | resolves to | means |
|
||||
|---|---|---|
|
||||
| `root-door=closed` | closed | the current spelling |
|
||||
| `root-door=open` | open | the current spelling |
|
||||
| `class=human` | closed | pre-#77, still honored |
|
||||
| `class=server` | open | pre-#77, still honored |
|
||||
| both, agreeing | that value | one claim said twice |
|
||||
| both, **disagreeing** | refusal | rig will not pick a winner — re-run bootstrap |
|
||||
| neither | refusal | no door policy to act on — re-run bootstrap |
|
||||
|
||||
New markers are written in the **new vocabulary only**. Writing both would
|
||||
keep an old rig reading a new marker, but it would also entrench the retired
|
||||
spelling on every box rig ever converges and make the disagreement row
|
||||
reachable from rig's own hand rather than only from a text editor. The compat
|
||||
obligation runs the other way: new rig reads old markers, because those exist
|
||||
in the field and nothing will rewrite them.
|
||||
|
||||
The two refusal rows are **fail-closed** on purpose. A marker that names no
|
||||
door policy, or names two that disagree, leaves rig unable to say whether root
|
||||
here is a human's bad habit or the fleet's management plane — and the safe
|
||||
error is a door that stays open and a loud instruction to re-run bootstrap,
|
||||
never a door welded shut on a machine whose only entrance it was.
|
||||
|
||||
**Where this diverges from #17's original table:** that table let the
|
||||
`runner-server` role close root ("no Coolify involved"). The class model supersedes
|
||||
the per-role call: runner is `class=server` — an automation identity, not a
|
||||
person's box — and on every server-class machine root SSH is the management
|
||||
`runner-server` role close root ("no Coolify involved"). The trait model supersedes
|
||||
the per-role call: runner is `root-door=open` — an automation identity, not a
|
||||
person's box — and on every open-door machine root SSH is the management
|
||||
plane rig itself converges through, so `close-root` refuses there
|
||||
deliberately, runner included. A CI box you mean to administer like a human
|
||||
machine is `--class human` at bootstrap, not an exception carved out of the
|
||||
gate.
|
||||
machine is `--root-door closed` at bootstrap, not an exception carved out of
|
||||
the gate.
|
||||
|
||||
**The detection side benefit:** once humans never use root, any root login
|
||||
that is not the control plane is anomalous *by definition* — a cheap,
|
||||
|
|
@ -803,7 +853,7 @@ and never asks for a token.
|
|||
### `rig users apply --file <path>`
|
||||
|
||||
Converges named operator accounts from a declarative users file — on **every**
|
||||
class (see *The identity model*). Run as root. Convergent: a second identical
|
||||
box, whatever its root-door policy (see *The identity model*). Run as root. Convergent: a second identical
|
||||
run says "already converged; no changes".
|
||||
|
||||
This is also what `rig bootstrap --users <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
|
||||
authorized keys, and the roles must be identical on each — a repeated line
|
||||
always means "another key", never a quiet role edit hiding mid-file. `root` is
|
||||
refused as a username: this file names operators; root's fate is class policy.
|
||||
refused as a username: this file names operators; root's fate is root-door policy.
|
||||
`--file -` reads stdin. A bad file exits 2 with **every** error listed at
|
||||
once, before anything changes — one fix cycle, not one round-trip per line.
|
||||
|
||||
|
|
@ -838,7 +888,7 @@ the admin returns until you switch the line to literal keys — and apply dies
|
|||
if root has no `authorized_keys` to seed. Root's key lines are copied
|
||||
verbatim, options included: a `from=`/`command=` restriction follows the key,
|
||||
and on a Coolify-managed box root's file also carries *Coolify's* key — on
|
||||
`class=server`, prefer literal keys.
|
||||
`root-door=open`, prefer literal keys.
|
||||
|
||||
**Public tool, private state, here too.** The users file lives in *your*
|
||||
private infra repo and is passed per invocation — rig never persists it. It
|
||||
|
|
@ -955,12 +1005,15 @@ network, no writes. Run as root (shadow is read).
|
|||
rig users close-root
|
||||
```
|
||||
|
||||
Shuts the root SSH door — `class=human` boxes only, and only once a named
|
||||
Shuts the root SSH door — `root-door=closed` boxes only, and only once a named
|
||||
admin can already get in. The gates run in order: the `/etc/rig/role` marker
|
||||
must say `class=human` — an absent marker refuses (never shut the root door
|
||||
blind; re-run bootstrap so the box knows what it is), and `class=server`
|
||||
must resolve to `closed` — an absent marker refuses (never shut the root door
|
||||
blind; re-run bootstrap so the box knows what it is), and `root-door=open`
|
||||
refuses with no `--force`, because root there is the control plane's
|
||||
automation identity and closing it severs fleet management. Then at least one
|
||||
automation identity and closing it severs fleet management. A marker written
|
||||
before #77 says `class=human|server` and resolves to `closed|open`
|
||||
respectively, so a box bootstrapped before the rename gates exactly as it
|
||||
always did. Then at least one
|
||||
`rig-admin` member must hold a login sshd would plausibly **accept** — a
|
||||
non-empty `authorized_keys` alone proves a file, not a door: the gate checks
|
||||
the `StrictModes` shape (home, `.ssh`, and `authorized_keys` owned by the
|
||||
|
|
@ -1001,7 +1054,7 @@ admin login must be proven, not presumed.
|
|||
Convergent — once root is closed, a re-run says "root already closed; nothing
|
||||
to do" and exits 0.
|
||||
|
||||
> **On `class=server`, root stays — so lock its key instead.** This is README
|
||||
> **On `root-door=open`, root stays — so lock its key instead.** This is README
|
||||
> guidance, deliberately not automation: prefix Coolify's line in root's
|
||||
> `authorized_keys` with a `from="<control-plane-addr>"` clause, so the
|
||||
> automation identity only opens from the one address supposed to use it. rig
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|||
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
|
||||
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
|
||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||
. "$HERE/lib/users-config.sh" # read_role_marker
|
||||
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ tenant on top, and re-runs converge an existing box to a new spec.
|
|||
|
||||
Tenant roles are creds-free and non-interactive by contract — box auto-runs
|
||||
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
|
||||
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest,
|
||||
machine-role traits (--hostname/--root-door/--host/--join): a tenant is a guest,
|
||||
not a tailnet machine. Run as root, inside the box.
|
||||
EOF
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ while [ $# -gt 0 ]; do
|
|||
--user)
|
||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||
TENANT_USER="$2"; shift 2 ;;
|
||||
--hostname|--class|--host|--join)
|
||||
--hostname|--root-door|--host|--join)
|
||||
# The machine-role traits, refused with a story rather than "unknown
|
||||
# flag": a tenant is a guest, not a tailnet machine — its shape comes
|
||||
# from the box seed, and the one trait-shaped thing a staging-box guest
|
||||
|
|
@ -102,28 +102,41 @@ done
|
|||
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
|
||||
# Names the staging PAIR out loud, because whoever lands here has the two
|
||||
# halves confused: the metal is `staging-server`, the guest `staging-box`.
|
||||
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine.
|
||||
# - class=server with host=no (staging-box only) → PROCEED, and leave the
|
||||
# - a root-door policy (agent tenants) → refuse: an agent box is never a
|
||||
# tailnet machine.
|
||||
# - root-door=open with host=no (staging-box only) → PROCEED, and leave the
|
||||
# marker alone: that is the guest AFTER its operator-run workload join, and
|
||||
# re-converging docker+hardening on it is exactly what convergence is for.
|
||||
# ONLY that shape — any other class (say class=human, via `custom`) is a
|
||||
# machine rig built on purpose, and staging-box hardening it with server rules
|
||||
# would die with server-specific messaging on a box that was never one.
|
||||
# ONLY that shape — any other door policy (say root-door=closed, via
|
||||
# `custom`) is a machine rig built on purpose, and staging-box hardening it
|
||||
# with open-door rules would die with root-door=open-specific messaging on a
|
||||
# box that was never one.
|
||||
#
|
||||
# "Names a root-door policy" IS this guard's "is this a machine marker?" test —
|
||||
# a tenant marker deliberately carries none — so it must be asked through
|
||||
# root_door_of, which reads the pre-#77 `class=` spelling as well as the current
|
||||
# `root-door=` one. Pattern-matching the marker for one spelling is what this
|
||||
# guard used to do, and after the rename that is a fail-OPEN bug in the
|
||||
# dangerous direction: every box bootstrapped in the OTHER vocabulary stops
|
||||
# looking like a machine, the refusals below never fire, and a tenant converge
|
||||
# clobbers a real fleet box's marker. The resolver is the only reader.
|
||||
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
|
||||
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
|
||||
EXISTING_ROOT_DOOR="$(root_door_of "$EXISTING_MARKER")"
|
||||
case "$EXISTING_MARKER" in
|
||||
*host=yes*)
|
||||
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
|
||||
*class=*)
|
||||
if [ "$ROLE" != "staging-box" ]; then
|
||||
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
|
||||
fi
|
||||
case "$EXISTING_MARKER" in
|
||||
*class=server*) ;;
|
||||
*)
|
||||
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (class=server host=no). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." ;;
|
||||
esac ;;
|
||||
esac
|
||||
if [ -n "$EXISTING_ROOT_DOOR" ]; then
|
||||
if [ "$ROLE" != "staging-box" ]; then
|
||||
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
|
||||
fi
|
||||
# A `conflict` marker lands here too, and refuses: a box whose two door
|
||||
# claims disagree is emphatically not the one shape staging-box tolerates.
|
||||
if [ "$EXISTING_ROOT_DOOR" != "open" ]; then
|
||||
die "this box carries a machine role whose root door is not open (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (root-door=open host=no, or its pre-#77 spelling class=server). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run."
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
if [ -r /etc/os-release ]; then
|
||||
|
|
@ -357,20 +370,27 @@ fi
|
|||
# box#69's posture, minus the join: docker (above) + sshd hardening, through
|
||||
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
|
||||
# workload server in waiting, and its door must never be password-open even
|
||||
# before the operator joins it. class=server: root SSH stays the control
|
||||
# before the operator joins it. root-door=open: root SSH stays the control
|
||||
# plane's future automation door.
|
||||
if [ "$ROLE" = "staging-box" ]; then
|
||||
harden_sshd server
|
||||
harden_sshd open
|
||||
fi
|
||||
|
||||
# --- role marker --------------------------------------------------------------
|
||||
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant
|
||||
# has no root-door policy of its own — close-root fails closed on it), and
|
||||
# host=no so `rig users apply` box-role gating keeps working. staging-box SKIPS the
|
||||
# write when a machine marker is already present: after the operator-run
|
||||
# workload join, the workload marker is the truer statement and rig never
|
||||
# clobbers state a joined box earned.
|
||||
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then
|
||||
# Same ground truth the machine roles write, tenant-shaped: no root-door trait
|
||||
# at all (a tenant has no root-door policy of its own — close-root fails closed
|
||||
# on it), and host=no so `rig users apply` box-role gating keeps working.
|
||||
# staging-box SKIPS the write when a machine marker is already present: after
|
||||
# the operator-run workload join, the workload marker is the truer statement and
|
||||
# rig never clobbers state a joined box earned.
|
||||
#
|
||||
# "Is a machine marker already here?" is the same question the guard above
|
||||
# asks, and is asked the same way — through root_door_of, so both the current
|
||||
# `root-door=` spelling and the pre-#77 `class=` one count (#77). Testing for
|
||||
# one spelling would let this write CLOBBER a marker written in the other, which
|
||||
# on a joined workload box means silently replacing its root-door policy with a
|
||||
# tenant line that close-root then refuses on.
|
||||
if [ -z "$EXISTING_ROOT_DOOR" ]; then
|
||||
MARKER_TMP="$(mktemp)"
|
||||
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
|
||||
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|||
# The users lib is sourced for validation, never for convergence: `users apply`
|
||||
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
|
||||
# borrows the parser so a typo'd users file is caught in the same breath as a
|
||||
# bad --class — before apt, before the tailnet join, before a pre-auth key is
|
||||
# bad --root-door — before apt, before the tailnet join, before a pre-auth key is
|
||||
# spent — instead of at the very end of a run the operator already paid for.
|
||||
|
||||
log() { printf 'rig-bootstrap: %s\n' "$*"; }
|
||||
|
|
@ -25,7 +25,7 @@ usage() {
|
|||
usage: rig bootstrap <control-plane-server|workload-server|runner-server|
|
||||
staging-server|dev-server|workstation|custom>
|
||||
(--users <path> | --no-users)
|
||||
[--hostname <name>] [--class <human|server>]
|
||||
[--hostname <name>] [--root-door <closed|open>]
|
||||
[--host <yes|no>] [--join <authkey|login>]
|
||||
rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
|
||||
(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.
|
||||
--hostname system + tailnet hostname (default: the role name; custom has
|
||||
no default and requires it)
|
||||
--class who lives here — human|server. Decides root SSH's fate after
|
||||
the users phase: human closes it (`rig users close-root`),
|
||||
server keeps it as the control plane's automation door.
|
||||
--root-door what happens to root SSH after the users phase — closed|open.
|
||||
closed: `rig users close-root` shuts it once named operators can
|
||||
get in. open: it stays, as the control plane's automation door.
|
||||
(Named --class human|server before #77 — same trait, renamed for
|
||||
what it decides rather than for who lives on the box. Markers
|
||||
written by the old flag are still read.)
|
||||
--host does this box host VMs (box/Incus) — yes|no
|
||||
--join how it enters the tailnet — authkey|login
|
||||
|
||||
One of --users/--no-users is required on every role, class=server included:
|
||||
One of --users/--no-users is required on every role, root-door=open included:
|
||||
a box nobody logs into routinely is exactly where shared-root access rots,
|
||||
and per-human accounts keep attribution intact for the times someone does go
|
||||
in. So the complete path is the default path and skipping it is a deliberate
|
||||
|
|
@ -61,15 +64,15 @@ operator file has nothing to converge in there.
|
|||
Roles are presets over the three traits; any flag overrides its trait.
|
||||
custom presets nothing and requires --hostname plus all three traits.
|
||||
|
||||
role class host join
|
||||
control-plane-server server no authkey
|
||||
workload-server server no authkey
|
||||
runner-server server no authkey
|
||||
staging-server server yes authkey
|
||||
dev-server human yes authkey
|
||||
workstation human yes login
|
||||
role root-door host join
|
||||
control-plane-server open no authkey
|
||||
workload-server open no authkey
|
||||
runner-server open no authkey
|
||||
staging-server open yes authkey
|
||||
dev-server closed yes authkey
|
||||
workstation closed yes login
|
||||
|
||||
THE 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
|
||||
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
|
||||
|
|
@ -81,11 +84,12 @@ both the metal and the guests on it.
|
|||
it joins by interactive login and comes up user-owned and
|
||||
untagged, and the tailnet never manages it.
|
||||
|
||||
'dev-server' is class=human, and that is not a contradiction: the suffix names
|
||||
the family, the CLASS names the root-SSH door policy (operators enter a dev box
|
||||
as themselves, so 'users close-root' shuts its door). The two axes share the
|
||||
word 'server' and that is a genuine wart — tracked in #77, which renames the
|
||||
class trait to what it actually controls.
|
||||
'dev-server --root-door closed' says what is true and says it once: the suffix
|
||||
names the FAMILY (a fleet machine), the trait names the DOOR (operators enter a
|
||||
dev box as themselves, so 'users close-root' shuts its door). Until #77 this
|
||||
trait was '--class human|server', which named the wrong axis — who lives on the
|
||||
box — and left 'dev-server' reading as a class=human contradiction. Nobody
|
||||
lives on a dev box; what makes it different is that its root door closes.
|
||||
|
||||
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
|
||||
so the key is the single source of truth: rig no longer requests a tag it might
|
||||
|
|
@ -124,18 +128,18 @@ esac
|
|||
# Roles are presets, nothing more: every behavior below keys off the traits,
|
||||
# so a flag override changes behavior without a new role, and custom exists
|
||||
# for the shape nobody foresaw — it declares nothing and must state all three.
|
||||
CLASS="" HOST="" JOIN=""
|
||||
ROOT_DOOR="" HOST="" JOIN=""
|
||||
case "$ROLE" in
|
||||
control-plane-server) CLASS=server HOST=no JOIN=authkey ;;
|
||||
workload-server) CLASS=server HOST=no JOIN=authkey ;;
|
||||
runner-server) CLASS=server HOST=no JOIN=authkey ;;
|
||||
control-plane-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
|
||||
workload-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
|
||||
runner-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
|
||||
# The unattended VM host — the shape #31 retired when 'staging' moved to the
|
||||
# tenant family, restored under a name that cannot be confused with its own
|
||||
# guests. host=yes is the whole point: it is what installs the box CLI and
|
||||
# runs box's setup-host further down, so this is a table row, not machinery.
|
||||
staging-server) CLASS=server HOST=yes JOIN=authkey ;;
|
||||
dev-server) CLASS=human HOST=yes JOIN=authkey ;;
|
||||
workstation) CLASS=human HOST=yes JOIN=login ;;
|
||||
staging-server) ROOT_DOOR=open HOST=yes JOIN=authkey ;;
|
||||
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
|
||||
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
|
||||
custom) ;;
|
||||
esac
|
||||
|
||||
|
|
@ -154,11 +158,11 @@ while [ $# -gt 0 ]; do
|
|||
--hostname)
|
||||
[ $# -ge 2 ] || die "--hostname needs a value" 2
|
||||
TS_HOSTNAME="$2"; shift 2 ;;
|
||||
--class)
|
||||
[ $# -ge 2 ] || die "--class needs a value" 2
|
||||
--root-door)
|
||||
[ $# -ge 2 ] || die "--root-door needs a value" 2
|
||||
case "$2" in
|
||||
human|server) CLASS="$2" ;;
|
||||
*) die "bad --class: $2 (want human|server)" 2 ;;
|
||||
closed|open) ROOT_DOOR="$2" ;;
|
||||
*) die "bad --root-door: $2 (want closed|open)" 2 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--host)
|
||||
|
|
@ -194,7 +198,7 @@ done
|
|||
if [ "$ROLE" = "custom" ]; then
|
||||
MISSING=""
|
||||
[ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname"
|
||||
[ -n "$CLASS" ] || MISSING="$MISSING --class"
|
||||
[ -n "$ROOT_DOOR" ] || MISSING="$MISSING --root-door"
|
||||
[ -n "$HOST" ] || MISSING="$MISSING --host"
|
||||
[ -n "$JOIN" ] || MISSING="$MISSING --join"
|
||||
[ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2
|
||||
|
|
@ -210,10 +214,10 @@ fi
|
|||
# --- who lives here (#51) -----------------------------------------------------
|
||||
# The users file is the last piece of "what this box is" that bootstrap did not
|
||||
# take, and it is REQUIRED rather than optional: a bootstrapped box with no
|
||||
# users converges to a box only root can enter, and on class=human that is a
|
||||
# users converges to a box only root can enter, and on root-door=closed that is a
|
||||
# half-built machine waiting for a second command the operator has to remember
|
||||
# (`rig users close-root` is itself gated behind "once your admin key works" —
|
||||
# which needs an admin to exist). class=server gets the same requirement on
|
||||
# which needs an admin to exist). root-door=open gets the same requirement on
|
||||
# purpose: a server nobody logs into routinely is exactly where shared-root
|
||||
# access rots, and per-human accounts keep attribution intact for the times
|
||||
# someone does go in.
|
||||
|
|
@ -370,9 +374,9 @@ EOF
|
|||
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
|
||||
# one drop-in, one converger, never two copies drifting apart. Everything the
|
||||
# block learned the hard way (00- beats cloud-init's 50- under first-wins,
|
||||
# validate-then-restart, assert sshd -T not the file, the class-gated
|
||||
# validate-then-restart, assert sshd -T not the file, the root-door-gated
|
||||
# permitrootlogin acceptance) moved with it, verbatim.
|
||||
harden_sshd "$CLASS"
|
||||
harden_sshd "$ROOT_DOOR"
|
||||
|
||||
# --- system hostname ----------------------------------------------------------
|
||||
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
|
||||
|
|
@ -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)." ;;
|
||||
*)
|
||||
# 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
|
||||
# like everywhere else outside the two control-plane-managed shapes.
|
||||
# Mint the metal's key with tag:local.
|
||||
|
|
@ -592,22 +596,30 @@ fi
|
|||
|
||||
# --- role marker --------------------------------------------------------------
|
||||
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users`
|
||||
# reads class from it to decide root SSH's fate). Written only AFTER the tag
|
||||
# verification, so a marker never describes a box that failed to become what it
|
||||
# claims — and cmp-guarded like every file rig converges.
|
||||
# reads root-door= from it to decide root SSH's fate). Written only AFTER the
|
||||
# tag verification, so a marker never describes a box that failed to become what
|
||||
# it claims — and cmp-guarded like every file rig converges.
|
||||
#
|
||||
# The line is written in the CURRENT vocabulary only — `root-door=`, never the
|
||||
# `class=` it replaced (#77). Writing both would keep an old rig reading a new
|
||||
# marker, but it would also entrench the retired spelling on every box rig ever
|
||||
# converges, and make the both-fields-disagree case reachable from rig's own
|
||||
# hand instead of only from a text editor. The compat obligation runs the other
|
||||
# way and is discharged in root_door_of: NEW rig reads OLD markers, because
|
||||
# those exist in the field by the thousand and nothing will rewrite them.
|
||||
MARKER=/etc/rig/role
|
||||
MARKER_TMP="$(mktemp)"
|
||||
printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$HOST" "$JOIN" > "$MARKER_TMP"
|
||||
printf 'role=%s root-door=%s host=%s join=%s\n' "$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" > "$MARKER_TMP"
|
||||
if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
|
||||
mkdir -p /etc/rig
|
||||
install -m 0644 "$MARKER_TMP" "$MARKER"
|
||||
log "role marker written: role=$ROLE class=$CLASS host=$HOST join=$JOIN"
|
||||
log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN"
|
||||
else
|
||||
log "role marker already current"
|
||||
fi
|
||||
rm -f "$MARKER_TMP"
|
||||
|
||||
# --- box install (host-class only) -------------------------------------------
|
||||
# --- box install (host=yes only) -------------------------------------------
|
||||
# A host=yes box exists to run guest boxes, so bootstrap finishes the job rather
|
||||
# than printing a to-do: it installs the box CLI globally and lets box's OWN
|
||||
# setup-host build the Incus stack. Placed AFTER the role marker write on
|
||||
|
|
@ -695,7 +707,7 @@ fi
|
|||
|
||||
# --- users (the last phase, and it must be last) -------------------------------
|
||||
# Ordering is a correctness property, not a preference. `users apply` READS
|
||||
# /etc/rig/role: class= decides which root-SSH note it prints, and host= decides
|
||||
# /etc/rig/role: root-door= decides which root-SSH note it prints, and host= decides
|
||||
# what an absent incus group means (refuse on yes, skip the box role with a
|
||||
# warning on no). Run before the marker write, apply would see no marker at all
|
||||
# and warn "re-run rig bootstrap so this box knows what it is" — in the middle
|
||||
|
|
@ -731,19 +743,19 @@ if [ "$ROLE" = "control-plane-server" ]; then
|
|||
elif [ "$ROLE" = "runner-server" ]; then
|
||||
log "next: rig runner install --repo <owner/repo> --version <pin>"
|
||||
fi
|
||||
# Every class gets operators: humans always enter as themselves and elevate via
|
||||
# sudo — a shared root login is unattributable. What differs by class is root
|
||||
# SSH's fate once named users exist. With --users the accounts exist already, so
|
||||
# Every box gets operators: humans always enter as themselves and elevate via
|
||||
# sudo — a shared root login is unattributable. What differs, per the root-door
|
||||
# trait, is root SSH's fate once named users exist. With --users the accounts exist already, so
|
||||
# the note that used to point at the missing command now points at what is left
|
||||
# to do; --no-users still owes the box its people, and says so.
|
||||
if [ -n "$USERS_FILE" ]; then
|
||||
if [ "$CLASS" = "human" ]; then
|
||||
if [ "$ROOT_DOOR" = "closed" ]; then
|
||||
log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST"
|
||||
else
|
||||
log "operators are converged; root SSH stays — it is the control plane's automation door"
|
||||
fi
|
||||
else
|
||||
if [ "$CLASS" = "human" ]; then
|
||||
if [ "$ROOT_DOOR" = "closed" ]; then
|
||||
log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
|
||||
else
|
||||
log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file <users-file>"
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
# a hardening block is drift by construction, the same law that keeps rig's
|
||||
# hands off Incus. Callers provide log/warn/die.
|
||||
|
||||
# harden_sshd <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
|
||||
# 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() {
|
||||
local class="$1"
|
||||
local root_door="$1"
|
||||
local dropin=/etc/ssh/sshd_config.d/00-rig.conf
|
||||
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
|
||||
local tmp backup eff
|
||||
|
|
@ -59,22 +62,22 @@ EOF
|
|||
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
||||
echo "$eff" | grep -qx 'passwordauthentication no' \
|
||||
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/"
|
||||
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
|
||||
# things on the two classes. class=human: `no` is the post-`rig users
|
||||
# close-root` state — strictly harder than the prohibit-password this function
|
||||
# installs. Hardening must never read a closed door as a broken one, and it
|
||||
# cannot reopen one either: by first-wins its own drop-in loses to
|
||||
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
|
||||
# The permitrootlogin acceptance is ROOT-DOOR-gated, because `no` means
|
||||
# opposite things on the two policies. root-door=closed: `no` is the post-`rig
|
||||
# users close-root` state — strictly harder than the prohibit-password this
|
||||
# function installs. Hardening must never read a closed door as a broken one,
|
||||
# and it cannot reopen one either: by first-wins its own drop-in loses to
|
||||
# 00-rig-users.conf. root-door=open: root SSH is the control plane's automation
|
||||
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
|
||||
# management silently dead, and the likely culprit is a drop-in left over from
|
||||
# a former class=human life on a repurposed box. rig can DETECT that but must
|
||||
# not FIX it: silently reopening a root door is worse than a loud stop, so —
|
||||
# same doctrine as the tag checks — detect, refuse, and name the repair.
|
||||
if [ "$class" = "human" ]; then
|
||||
# a former root-door=closed life on a repurposed box. rig can DETECT that but
|
||||
# must not FIX it: silently reopening a root door is worse than a loud stop, so
|
||||
# — same doctrine as the tag checks — detect, refuse, and name the repair.
|
||||
if [ "$root_door" = "closed" ]; then
|
||||
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
|
||||
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
|
||||
die "sshd resolves permitrootlogin=no, but this is a root-door=open box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former root-door=closed life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
|
||||
else
|
||||
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@
|
|||
# fix cycle, not one round-trip per line.
|
||||
#
|
||||
# Refusals: unknown role (the valid set is named), differing roles across one
|
||||
# user's lines, root as username (root's keys are class policy's business, not
|
||||
# this file's), malformed line (fewer than 3 fields, or a key field that does
|
||||
# user's lines, root as username (root's keys are root-door policy's business,
|
||||
# not this file's), malformed line (fewer than 3 fields, or a key field that does
|
||||
# not start with an SSH key type and is not exactly '@root'), '@root' with
|
||||
# trailing material (the token IS the whole field), invalid username (the
|
||||
# charset below — '|' would corrupt this parser's own delimited stream, a
|
||||
|
|
@ -71,7 +71,7 @@ parse_users_file() {
|
|||
continue
|
||||
fi
|
||||
if [ "$u" = "root" ]; then
|
||||
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy")
|
||||
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is root-door policy")
|
||||
continue
|
||||
fi
|
||||
ok=1
|
||||
|
|
@ -103,21 +103,86 @@ parse_users_file() {
|
|||
}
|
||||
|
||||
# read_role_marker <path> — the marker line bootstrap wrote
|
||||
# (`role=... class=... host=... join=...`), or nothing when absent. NO policy
|
||||
# here: what an absent marker or a given class MEANS is each caller's call
|
||||
# (apply notes it, close-root refuses on it) — this reader only reads.
|
||||
# (`role=... root-door=... host=... join=...`), or nothing when absent. NO
|
||||
# policy here: what an absent marker or a given trait MEANS is each caller's
|
||||
# call (apply notes it, close-root refuses on it) — this reader only reads.
|
||||
read_role_marker() {
|
||||
[ -r "$1" ] || return 0
|
||||
head -n1 "$1"
|
||||
}
|
||||
|
||||
# assert_marker_human <marker_path> — close-root's marker gate: return 0,
|
||||
# silently, only when the marker says class=human; otherwise print the refusal
|
||||
# reason on stdout and return 1 (the caller wraps it in its own die). The
|
||||
# policy is a pure lib function on purpose: the CLI path sits behind the root
|
||||
# check, so the harness proves every refusal HERE, against fixture markers,
|
||||
# non-root (repo precedent: parse_users_file, assert_runner_repo).
|
||||
assert_marker_human() {
|
||||
# root_door_of <marker line> — resolve the root-door trait from a marker LINE,
|
||||
# reading both the current `root-door=` vocabulary and the `class=` one it
|
||||
# replaced (#77). Prints exactly one of:
|
||||
#
|
||||
# closed the root SSH door is meant to shut once named operators exist
|
||||
# (`rig users close-root`) — was class=human
|
||||
# open root SSH stays as the control plane's automation door
|
||||
# — was class=server
|
||||
# conflict the marker names BOTH vocabularies and they DISAGREE
|
||||
# (empty) the marker names neither, or names one with a value that is
|
||||
# not in its value set
|
||||
#
|
||||
# Text->text and total, so the harness proves every arm off a literal string
|
||||
# (repo precedent: deny_verdict, group_allow_verdict). Callers turn a verdict
|
||||
# into policy; this function has none.
|
||||
#
|
||||
# WHY THE COMPAT READ IS NOT OPTIONAL, and why it is here rather than at each
|
||||
# call site. #77 renamed the trait because `class=human|server` was named for
|
||||
# who lives on the box while what it decides is whether root SSH stays open as
|
||||
# the control plane's automation door — the axis that made `dev-server` a
|
||||
# `class=human` box, a suffix and a trait that read as a contradiction. But
|
||||
# unlike #76's role rename, this field is not informational: it is written into
|
||||
# /etc/rig/role and read back on live machines, where it gates `rig users
|
||||
# close-root`. Every box bootstrapped before this change carries `class=` and
|
||||
# carries it FOREVER, until someone re-bootstraps it — there is no migration
|
||||
# step that reaches a fleet. So dropping the old read breaks in both directions
|
||||
# at once, and both are incidents: a machine whose door should close stops
|
||||
# being able to close it (close-root refuses on a marker it no longer
|
||||
# understands), and — through bootstrap-tenant's machine-marker guard, which
|
||||
# used `class=` as its "this is a machine" detector — a real fleet box stops
|
||||
# looking like a machine at all and a tenant converge will happily clobber it.
|
||||
# One resolver, consulted everywhere the trait is read, is what keeps those
|
||||
# two readings from drifting apart.
|
||||
#
|
||||
# BOTH FIELDS PRESENT is a state bootstrap never writes — it writes one line,
|
||||
# fresh, in the new vocabulary only — so a marker carrying both was
|
||||
# hand-edited, and the two answers are a question about intent that rig cannot
|
||||
# settle. Agreement is taken (it says one thing twice); disagreement resolves
|
||||
# to `conflict` and every caller fails CLOSED on it, because the alternative is
|
||||
# picking a winner between two equally-authored claims about a root door. The
|
||||
# repair is the same one the rest of the marker family names: re-run bootstrap,
|
||||
# which rewrites the line whole.
|
||||
#
|
||||
# NEITHER FIELD PRESENT resolves empty and is likewise fail-closed everywhere,
|
||||
# unchanged from before: a marker that names no door policy cannot authorize
|
||||
# shutting a door.
|
||||
root_door_of() {
|
||||
local marker="$1" new="" old=""
|
||||
case "$marker" in
|
||||
*root-door=closed*) new=closed ;;
|
||||
*root-door=open*) new=open ;;
|
||||
esac
|
||||
case "$marker" in
|
||||
*class=human*) old=closed ;;
|
||||
*class=server*) old=open ;;
|
||||
esac
|
||||
if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then
|
||||
printf 'conflict'; return 0
|
||||
fi
|
||||
# New wins where both are readable and agree; the old field answers alone on
|
||||
# every marker written before #77, which is the whole point.
|
||||
printf '%s' "${new:-$old}"
|
||||
}
|
||||
|
||||
# assert_marker_closes_root <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
|
||||
marker="$(read_role_marker "$1")"
|
||||
if [ -z "$marker" ]; then
|
||||
|
|
@ -126,22 +191,27 @@ assert_marker_human() {
|
|||
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
|
||||
return 1
|
||||
fi
|
||||
case "$marker" in
|
||||
*class=human*) return 0 ;;
|
||||
*class=server*)
|
||||
# Root SSH on a server IS the control plane's (Coolify's) automation
|
||||
case "$(root_door_of "$marker")" in
|
||||
closed) return 0 ;;
|
||||
open)
|
||||
# Root SSH on such a box IS the control plane's (Coolify's) automation
|
||||
# identity — closing it severs fleet management. No --force exists.
|
||||
# Deliberately per-CLASS, not per-role: #17's original table let the
|
||||
# runner role close root ("no Coolify involved"), but the class model
|
||||
# (#26) supersedes that — every server-class box, runner included, is
|
||||
# an automation identity whose management plane is root SSH, and rig
|
||||
# itself converges through that door. A CI box someone administers
|
||||
# like a human machine is class=human at bootstrap, not an exception
|
||||
# carved out here.
|
||||
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management. Every server-class box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --class human at bootstrap, not an exception here"
|
||||
# Deliberately keyed on the DOOR, not on the role: #17's original table
|
||||
# let the runner role close root ("no Coolify involved"), but the trait
|
||||
# model (#26) supersedes that — every root-door=open box, runner
|
||||
# included, is an automation identity whose management plane is root
|
||||
# SSH, and rig itself converges through that door. A CI box someone
|
||||
# administers like a human machine is --root-door closed at bootstrap,
|
||||
# not an exception carved out here.
|
||||
printf '%s\n' "root-door=open: root here is the control plane's automation identity — closing it severs fleet management. Every root-door=open box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --root-door closed at bootstrap, not an exception here"
|
||||
return 1 ;;
|
||||
conflict)
|
||||
# Hand-edited into naming both vocabularies, disagreeing. Fail closed:
|
||||
# see root_door_of's header for why rig refuses to pick a winner.
|
||||
printf '%s\n' "marker names both root-door= and the pre-#77 class= and they disagree (${marker}): rig will not pick a winner between two claims about a root door — re-run rig bootstrap to rewrite the marker, and refusing to shut the root door meanwhile"
|
||||
return 1 ;;
|
||||
*)
|
||||
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
||||
printf '%s\n' "marker names no root-door policy (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
||||
return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -149,7 +219,7 @@ assert_marker_human() {
|
|||
# assert_marker_hosts_vms <marker_path> — the box role's gate: return 0,
|
||||
# silently, only when the marker says host=yes; otherwise print the reason on
|
||||
# stdout and return 1 (the caller decides whether that is a warn or a die).
|
||||
# Same shape and same reason as assert_marker_human above: the policy is a
|
||||
# Same shape and same reason as assert_marker_closes_root above: the policy is a
|
||||
# pure marker->verdict function so the harness can prove every arm against
|
||||
# fixture markers, non-root, while the CLI path sits behind the root check.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig users apply — converge named operator accounts from a declarative users
|
||||
# file, on every class. Humans always enter as themselves and elevate via
|
||||
# file, on every box. Humans always enter as themselves and elevate via
|
||||
# sudo: a shared root login is unattributable, so operators belong on servers
|
||||
# too — class never gates this command, it only decides root SSH's fate AFTER
|
||||
# users exist (close-root on human, kept as the control plane's automation
|
||||
# door on server). Convergent: a second identical run changes nothing and
|
||||
# says so.
|
||||
# too — the root-door trait never gates this command, it only decides root
|
||||
# SSH's fate AFTER users exist (close-root on root-door=closed, kept as the
|
||||
# control plane's automation door on root-door=open). Convergent: a second
|
||||
# identical run changes nothing and says so.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
|
|
@ -153,13 +153,27 @@ if [ "$NEED_SEED" -eq 1 ]; then
|
|||
|| die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead"
|
||||
fi
|
||||
|
||||
# Class is a note, never a refusal: #26's call is that operators belong on
|
||||
# EVERY class — what differs is root SSH's fate once they exist.
|
||||
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
||||
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;;
|
||||
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;;
|
||||
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;;
|
||||
esac
|
||||
# The root-door trait is a note here, never a refusal: #26's call is that
|
||||
# operators belong on EVERY box — what differs is root SSH's fate once they
|
||||
# exist. Resolved through root_door_of so this note and close-root's gate read
|
||||
# one marker the same way, pre-#77 class= spellings included (#77): a box whose
|
||||
# note says the door will shut must be a box where close-root agrees it shuts.
|
||||
APPLY_MARKER="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
||||
if [ -z "$APPLY_MARKER" ]; then
|
||||
warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is"
|
||||
else
|
||||
case "$(root_door_of "$APPLY_MARKER")" in
|
||||
open) log "root-door=open: root SSH stays — it is the control plane's automation door" ;;
|
||||
closed) log "root-door=closed: once your admin key works, 'rig users close-root' shuts the root door" ;;
|
||||
# A marker naming both vocabularies in disagreement, or naming no door
|
||||
# policy at all, is exactly where close-root will refuse. Apply still
|
||||
# converges operators — that is the point of #26 — but it must not stay
|
||||
# quiet about the refusal waiting at the end of the sequence it just
|
||||
# pointed the operator at.
|
||||
conflict) warn "marker names both root-door= and the pre-#77 class= and they disagree ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;;
|
||||
*) warn "marker names no root-door policy ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
CHANGED=0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig users close-root — shut the human-class root SSH door, once and only
|
||||
# once a named admin can already get in. class decides root SSH's fate (#26):
|
||||
# on class=human a root login is unattributable noise, so it goes; on
|
||||
# class=server root IS the control plane's automation identity, so closing it
|
||||
# rig users close-root — shut the root SSH door on the boxes whose door is
|
||||
# meant to shut, once and only once a named admin can already get in. The
|
||||
# root-door trait decides root SSH's fate (#26, renamed by #77): on
|
||||
# root-door=closed a root login is unattributable noise, so it goes; on
|
||||
# root-door=open root IS the control plane's automation identity, so closing it
|
||||
# would sever fleet management — this command refuses there, and no --force
|
||||
# exists. Convergent: a second run is a no-op and says so.
|
||||
set -euo pipefail
|
||||
|
|
@ -23,9 +24,12 @@ Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf
|
|||
carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
|
||||
first-wins include order.
|
||||
|
||||
Human class ONLY. On class=server, root SSH is the control plane's (Coolify's)
|
||||
automation identity — closing it severs fleet management — so close-root
|
||||
refuses there, with no --force. It also refuses without a role marker (re-run
|
||||
root-door=closed boxes ONLY. On root-door=open, root SSH is the control plane's
|
||||
(Coolify's) automation identity — closing it severs fleet management — so
|
||||
close-root refuses there, with no --force. Markers written before #77 name this
|
||||
trait as class=human|server and are read as closed|open respectively, so a box
|
||||
bootstrapped before the rename gates exactly as it always did.
|
||||
It also refuses without a role marker (re-run
|
||||
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
|
||||
member holds a login this box would actually honor. Per candidate, in order:
|
||||
the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys
|
||||
|
|
@ -72,11 +76,13 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
|||
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
||||
fi
|
||||
|
||||
# Marker gate — the policy lives in assert_marker_human (lib) so the harness
|
||||
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER
|
||||
# exists for the same reason: it keeps the command's own gate pointable at
|
||||
# fixtures instead of only at the real /etc/rig/role.
|
||||
if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
||||
# Marker gate — the policy lives in assert_marker_closes_root (lib) so the
|
||||
# harness can prove its refusals against fixture markers as non-root;
|
||||
# RIG_ROLE_MARKER exists for the same reason: it keeps the command's own gate
|
||||
# pointable at fixtures instead of only at the real /etc/rig/role. The gate
|
||||
# reads the pre-#77 class= spelling too — see root_door_of for why that compat
|
||||
# read is load-bearing rather than courteous.
|
||||
if ! WHY="$(assert_marker_closes_root "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
||||
die "$WHY"
|
||||
fi
|
||||
|
||||
|
|
|
|||
211
test/cli.sh
211
test/cli.sh
|
|
@ -92,7 +92,7 @@ done
|
|||
check "bootstrap: workstation keeps its bare name" 2 "unset TS_AUTHKEY" \
|
||||
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
|
||||
check "bootstrap: custom keeps its bare name" 2 "--hostname" \
|
||||
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
|
||||
"$ROOT/commands/bootstrap.sh" custom --root-door open --host no --join authkey
|
||||
# NOTHING may still TELL an operator to run a pre-#76 role. The rename is a
|
||||
# hard cut, so a next-step string, a usage line or a refusal that still recites
|
||||
# a bare role name is a command that fails when someone copy-pastes it — and it
|
||||
|
|
@ -108,12 +108,12 @@ check "roles: no shipped script tells an operator to run a pre-#76 role name" 1
|
|||
"$ROOT/bin/rig" "$ROOT/commands/"
|
||||
# --- traits: roles are presets, every trait individually settable (#26) -----
|
||||
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
||||
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload-server --class potato
|
||||
check "bootstrap: bad --root-door value exits 2" 2 "closed|open" "$ROOT/commands/bootstrap.sh" workload-server --root-door potato
|
||||
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload-server --host maybe
|
||||
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload-server --join carrier-pigeon
|
||||
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
|
||||
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
|
||||
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
|
||||
"$ROOT/commands/bootstrap.sh" custom --root-door open --host no --join authkey
|
||||
check "bootstrap: custom without traits exits 2" 2 "--root-door" "$ROOT/commands/bootstrap.sh" custom --hostname box1
|
||||
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
|
||||
# must die BEFORE the root check — provable non-root, which also proves the
|
||||
# preset actually landed.
|
||||
|
|
@ -143,7 +143,15 @@ check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
|
|||
# The marker is the traits' ground truth for rig users; assert the write exists.
|
||||
check "bootstrap: role marker write is present" 0 "" \
|
||||
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
||||
# --- host-class box install (issues #12, #25) --------------------------------
|
||||
# ...and that it is written in the CURRENT vocabulary (#77). New markers say
|
||||
# root-door=; the retired class= spelling is something rig READS forever and
|
||||
# WRITES never, so a marker line that reintroduces it must not ship green.
|
||||
check "bootstrap: the marker is written as root-door=, not class=" 0 "" \
|
||||
grep -qF "printf 'role=%s root-door=%s host=%s join=%s" "$ROOT/commands/bootstrap.sh"
|
||||
# shellcheck disable=SC2016
|
||||
check "bootstrap: no shipped script WRITES the retired class= spelling" 0 "" \
|
||||
sh -c '! grep -n "printf .*class=" "$1"/commands/*.sh' _ "$ROOT"
|
||||
# --- host=yes box install (issues #12, #25) --------------------------------
|
||||
# A host=yes box finishes the job: bootstrap installs the box CLI globally and
|
||||
# lets box's own setup-host build the Incus stack. The install itself runs as
|
||||
# root, over the network, against a real host — none of which this harness can
|
||||
|
|
@ -237,7 +245,7 @@ check "bootstrap: omitting --users and --no-users exits 2" 2 "one of --users <pa
|
|||
"$ROOT/commands/bootstrap.sh" workload-server
|
||||
check "bootstrap: the requirement names --no-users as the way out" 2 "--no-users to leave it root-only" \
|
||||
"$ROOT/commands/bootstrap.sh" dev-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
|
||||
check "bootstrap: --users needs a value" 2 "needs a value" \
|
||||
"$ROOT/commands/bootstrap.sh" workload-server --users
|
||||
|
|
@ -385,7 +393,7 @@ fi
|
|||
check "bootstrap: the users phase never runs box setup-host itself" 1 "" \
|
||||
grep -nE '^[[:space:]]*box setup-host' "$ROOT/commands/bootstrap.sh"
|
||||
# ORDERING is a correctness property, not taste: apply READS /etc/rig/role
|
||||
# (class= picks its root-SSH note, host= decides what a missing incus group
|
||||
# (root-door= picks its root-SSH note, host= decides what a missing incus group
|
||||
# means), and on host=yes it needs the group box's installer built. So the
|
||||
# users phase must sit after BOTH the marker write and the box install. Line
|
||||
# numbers, same idiom as the marker/box-install ordering asserts above;
|
||||
|
|
@ -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: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation --no-users
|
||||
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
|
||||
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey --no-users
|
||||
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --root-door open --host no --join authkey --no-users
|
||||
else
|
||||
echo "skip: bootstrap non-root refusals (running as root)"
|
||||
fi
|
||||
|
|
@ -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
|
||||
# operator reaching for --hostname must learn where the trait family went.
|
||||
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" claude-box --class human
|
||||
"$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed
|
||||
check "tenant: --hostname dies the same way" 2 "have no traits" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest
|
||||
# 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,
|
||||
# because whoever lands here has the two halves confused and wants the metal
|
||||
# (staging-server). An agent tenant refuses ANY machine-role box; staging-box
|
||||
# tolerates ONLY class=server with host=no — that is the guest after its
|
||||
# tolerates ONLY root-door=open with host=no — that is the guest after its
|
||||
# operator-run workload join, and re-converging it is what convergence is for.
|
||||
# A non-server machine (class=human via custom) is NOT that guest, and server
|
||||
# hardening would die at it with server-specific messaging — refuse instead.
|
||||
# A closed-door machine (root-door=closed via custom) is NOT that guest, and
|
||||
# open-door hardening would die at it with root-door=open-specific messaging —
|
||||
# refuse instead.
|
||||
TEN_FIX="$(mktemp -d)"
|
||||
printf 'role=dev-server class=human host=yes join=authkey\n' > "$TEN_FIX/host"
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/machine"
|
||||
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human"
|
||||
printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
|
||||
printf 'role=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed"
|
||||
printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
|
||||
check "tenant: staging-box refuses a non-server machine box" 1 "non-server machine role" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: refuses a host=yes box (a VM host is never a guest)" 1 "hosts VMs" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
check "tenant: the host refusal sends you to the metal half of the pair" 1 "staging-server" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
|
||||
# The tenant guard's compat read (#77). This guard asks "does this marker name
|
||||
# a root-door policy?" as its proxy for "is this a real fleet machine?", and it
|
||||
# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling,
|
||||
# same reason as the close-root fixtures below: a pre-#77 box that stops
|
||||
# looking like a machine here is the fail-OPEN direction of this rename — the
|
||||
# agent-tenant refusal never fires, and `rig bootstrap claude-box` converges a
|
||||
# tenant straight over a live fleet box, clobbering the marker that holds its
|
||||
# root-door policy. Do not modernize these two fixtures.
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/pre77-machine"
|
||||
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/pre77-human"
|
||||
check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
|
||||
# the root check, and the harness machine may carry a real /etc/rig/role.
|
||||
|
|
@ -539,6 +563,10 @@ if [ "$(id -u)" -ne 0 ]; then
|
|||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box
|
||||
check "tenant: staging-box tolerates a workload-joined guest's marker" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
# ...and the same guest joined before #77: reaching the root check (rather
|
||||
# than a marker refusal) is what proves the tolerance survived the rename.
|
||||
check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
else
|
||||
|
|
@ -586,7 +614,7 @@ check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
|
|||
# staging-box's posture rides the SAME hardening code as the machine roles — the
|
||||
# shared lib call is the anti-drift property, so pin the call, not the words.
|
||||
check "tenant: staging-box hardens through the shared sshd lib" 0 "" \
|
||||
grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
grep -qE '^[[:space:]]*harden_sshd open$' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: docker lands via docker's own installer" 0 "" \
|
||||
grep -q "get.docker.com" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
# The #15 lesson pinned: 'box exec' shells read no rc files, so the CLI must
|
||||
|
|
@ -607,6 +635,15 @@ ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-ten
|
|||
ten_marker_at="$(grep -nF 'install -m 0644 "$MARKER_TMP" "$MARKER_PATH"' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
|
||||
check "tenant: the marker write follows the context-file converge" \
|
||||
0 "" test "${ten_ctx_at:-999999}" -lt "${ten_marker_at:-0}"
|
||||
# The write's "is a machine marker already here?" test must go through the
|
||||
# resolver, not through a pattern match on one spelling (#77). Pinned as a
|
||||
# byte-grep because the failure it prevents is silent and expensive: a
|
||||
# spelling-specific test would let a tenant converge CLOBBER a machine marker
|
||||
# written in the other vocabulary — on a joined workload box that means
|
||||
# replacing its root-door policy with a tenant line close-root then refuses on.
|
||||
# shellcheck disable=SC2016
|
||||
check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \
|
||||
grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
|
||||
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
|
||||
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
|
||||
|
|
@ -645,8 +682,8 @@ marker_warns() { # marker_warns <marker_path> <cmd...> — how many warnings fir
|
|||
env RIG_ROLE_MARKER="$marker" "$@" 2>&1 | grep -c "not a control-plane box" || true
|
||||
}
|
||||
MARKER_FIX="$(mktemp -d)"
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_FIX/workload"
|
||||
printf 'role=control-plane-server class=server host=no join=authkey\n' > "$MARKER_FIX/control-plane"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARKER_FIX/workload"
|
||||
printf 'role=control-plane-server root-door=open host=no join=authkey\n' > "$MARKER_FIX/control-plane"
|
||||
printf 'role=control-plane-server\n' > "$MARKER_FIX/bare-control-plane"
|
||||
# A PRE-#76 marker, verbatim as a real box bootstrapped before the rename
|
||||
# carries it. This is the one fixture that must keep its old spelling: the
|
||||
|
|
@ -1048,10 +1085,23 @@ check "users apply: revoked keys are renamed, never deleted" 0 "" \
|
|||
# admins included — still converges.
|
||||
check "users apply: box role skips on a host=no box" 0 "" \
|
||||
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
|
||||
# Apply's root-SSH note and close-root's gate must read ONE marker the same
|
||||
# way, pre-#77 spellings included — a box told "close-root will shut this door"
|
||||
# by apply and then refused by close-root is the worst of both (#77). Sharing
|
||||
# the resolver is what guarantees it, so pin the call rather than the message.
|
||||
# shellcheck disable=SC2016
|
||||
check "users apply: the root-SSH note resolves through root_door_of" 0 "" \
|
||||
grep -qF 'root_door_of "$APPLY_MARKER"' "$ROOT/commands/users-apply.sh"
|
||||
# ...and it warns, rather than staying silent, on the two markers close-root
|
||||
# will refuse: a note that only speaks on the happy paths is not a note.
|
||||
check "users apply: a doorless marker warns that close-root will refuse" 0 "" \
|
||||
grep -q "names no root-door policy" "$ROOT/commands/users-apply.sh"
|
||||
check "users apply: a contradictory marker warns that close-root will refuse" 0 "" \
|
||||
grep -q "they disagree" "$ROOT/commands/users-apply.sh"
|
||||
|
||||
# --- the box role's host= gate (#58) -----------------------------------------
|
||||
# The gate is a pure marker->verdict lib function for the same reason
|
||||
# assert_marker_human is: apply's box arm sits behind the root check, so every
|
||||
# assert_marker_closes_root is: apply's box arm sits behind the root check, so every
|
||||
# arm is proven HERE against fixture markers, non-root.
|
||||
#
|
||||
# What #58 fixed: the trait used to be consulted only when group incus was
|
||||
|
|
@ -1068,11 +1118,11 @@ hostvm_gate() { # hostvm_gate <marker_path>
|
|||
assert_marker_hosts_vms "$2"' _ "$ROOT" "$1"
|
||||
}
|
||||
HOSTVM_FIX="$(mktemp -d)"
|
||||
printf 'role=dev-server class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes"
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$HOSTVM_FIX/no"
|
||||
printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$HOSTVM_FIX/yes"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$HOSTVM_FIX/no"
|
||||
# A marker that predates the host= trait (or was hand-edited): present, but it
|
||||
# names no host=. Distinct from an ABSENT marker and it must not read as yes.
|
||||
printf 'role=workload-server class=server join=authkey\n' > "$HOSTVM_FIX/traitless"
|
||||
printf 'role=workload-server root-door=open join=authkey\n' > "$HOSTVM_FIX/traitless"
|
||||
check "users apply: host=yes passes the box-role gate" \
|
||||
0 "" hostvm_gate "$HOSTVM_FIX/yes"
|
||||
check "users apply: host=no fails the box-role gate" \
|
||||
|
|
@ -1457,24 +1507,103 @@ check "users close-root: the gate consults deny_verdict" 0 "" \
|
|||
marker_gate() { # marker_gate <marker_path>
|
||||
bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/users-config.sh"
|
||||
assert_marker_human "$2"' _ "$ROOT" "$1"
|
||||
assert_marker_closes_root "$2"' _ "$ROOT" "$1"
|
||||
}
|
||||
MARKER_DIR="$(mktemp -d)"
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/server"
|
||||
printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARKER_DIR/open"
|
||||
printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$MARKER_DIR/closed"
|
||||
check "users close-root: absent marker refuses, names bootstrap as the repair" \
|
||||
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
|
||||
check "users close-root: class=server refuses, names the control plane" \
|
||||
1 "control plane" marker_gate "$MARKER_DIR/server"
|
||||
# #17's original table let the runner ROLE close root; the class model
|
||||
# supersedes it — runner is class=server, an automation identity, and the
|
||||
check "users close-root: root-door=open refuses, names the control plane" \
|
||||
1 "control plane" marker_gate "$MARKER_DIR/open"
|
||||
# #17's original table let the runner ROLE close root; the trait model
|
||||
# supersedes it — runner is root-door=open, an automation identity, and the
|
||||
# refusal must SAY so or the divergence reads as a bug to anyone holding the
|
||||
# old table.
|
||||
check "users close-root: the server refusal owns the runner row (#17)" \
|
||||
1 "runner included" marker_gate "$MARKER_DIR/server"
|
||||
check "users close-root: class=human passes the gate" \
|
||||
0 "" marker_gate "$MARKER_DIR/human"
|
||||
check "users close-root: the open-door refusal owns the runner row (#17)" \
|
||||
1 "runner included" marker_gate "$MARKER_DIR/open"
|
||||
check "users close-root: root-door=closed passes the gate" \
|
||||
0 "" marker_gate "$MARKER_DIR/closed"
|
||||
|
||||
# --- the pre-#77 vocabulary, on live markers (#77) ---------------------------
|
||||
# THIS is the block that makes #77 safe to ship, and it is not a formality.
|
||||
# Unlike #76's role rename, the root-door trait is written into /etc/rig/role
|
||||
# and read BACK by this gate, so every box bootstrapped before the rename
|
||||
# carries `class=human|server` and carries it until someone re-bootstraps it —
|
||||
# which, for a fleet, is never. A gate that stopped understanding that spelling
|
||||
# would fail in both directions and both are incidents: `class=human` boxes
|
||||
# (whose whole point is that root closes) would lose the ability to close it,
|
||||
# and `class=server` boxes would... also refuse, but for the wrong reason,
|
||||
# which is luck rather than design and would evaporate the moment the fallthrough
|
||||
# arm changed.
|
||||
#
|
||||
# So the fixtures below stay DELIBERATELY at the retired spelling, byte for
|
||||
# byte as a real pre-#77 box's marker reads — the same reason #76's
|
||||
# `pre-rename-cp` fixture keeps `role=control-plane`. Do not "modernize" them:
|
||||
# updating these fixtures to the new vocabulary would delete the only evidence
|
||||
# that the compat read works, and the suite would stay green while the field
|
||||
# broke. The pairs assert the OLD spelling produces the SAME verdict as its new
|
||||
# equivalent above, refusal text included.
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARKER_DIR/pre77-server"
|
||||
printf 'role=dev-server class=human host=yes join=authkey\n' > "$MARKER_DIR/pre77-human"
|
||||
check "users close-root: a PRE-#77 'class=human' marker still passes the gate" \
|
||||
0 "" marker_gate "$MARKER_DIR/pre77-human"
|
||||
check "users close-root: a PRE-#77 'class=server' marker still REFUSES" \
|
||||
1 "control plane" marker_gate "$MARKER_DIR/pre77-server"
|
||||
# The refusal an old box gets must be the CURRENT one, naming the current flag:
|
||||
# an operator repairing a pre-#77 box is repairing it with today's rig, and
|
||||
# being told to pass a flag that no longer exists is a dead end.
|
||||
check "users close-root: the PRE-#77 refusal names today's flag, not --class" \
|
||||
1 "root-door closed" marker_gate "$MARKER_DIR/pre77-server"
|
||||
|
||||
# A marker naming NEITHER vocabulary refuses — unchanged from before #77, and
|
||||
# distinct from an absent marker: the file exists and simply makes no claim
|
||||
# about the door, which cannot authorize shutting one.
|
||||
printf 'role=workload-server host=no join=authkey\n' > "$MARKER_DIR/doorless"
|
||||
check "users close-root: a marker naming no door policy refuses" \
|
||||
1 "names no root-door policy" marker_gate "$MARKER_DIR/doorless"
|
||||
# A marker naming a root-door= value outside the value set is doorless too —
|
||||
# fail closed rather than guessing which door 'potato' means.
|
||||
printf 'role=custom root-door=potato host=no join=login\n' > "$MARKER_DIR/bogus"
|
||||
check "users close-root: an unreadable root-door value refuses (fail closed)" \
|
||||
1 "names no root-door policy" marker_gate "$MARKER_DIR/bogus"
|
||||
|
||||
# BOTH vocabularies on one marker. Agreement is just the same claim twice and
|
||||
# resolves normally; DISAGREEMENT is a hand-edited marker making two equally
|
||||
# authored claims about a root door, and rig refuses to pick a winner — the
|
||||
# fail-closed arm, since the alternative is guessing on the one field that
|
||||
# decides whether a door welds shut. Both orders are checked so the verdict
|
||||
# cannot depend on which field the editor happened to type first.
|
||||
printf 'role=dev-server root-door=closed class=human host=yes join=authkey\n' > "$MARKER_DIR/both-agree"
|
||||
check "users close-root: both vocabularies agreeing resolves normally" \
|
||||
0 "" marker_gate "$MARKER_DIR/both-agree"
|
||||
printf 'role=custom root-door=closed class=server host=no join=login\n' > "$MARKER_DIR/both-fight-a"
|
||||
printf 'role=custom class=human root-door=open host=no join=login\n' > "$MARKER_DIR/both-fight-b"
|
||||
check "users close-root: contradictory vocabularies refuse (new-first)" \
|
||||
1 "will not pick a winner" marker_gate "$MARKER_DIR/both-fight-a"
|
||||
check "users close-root: contradictory vocabularies refuse (old-first)" \
|
||||
1 "will not pick a winner" marker_gate "$MARKER_DIR/both-fight-b"
|
||||
rm -rf "$MARKER_DIR"
|
||||
|
||||
# root_door_of is the ONE reader of this trait — close-root's gate, apply's
|
||||
# note and bootstrap-tenant's machine-marker detector all resolve through it,
|
||||
# so the compat read cannot drift between them. Pin the resolver directly,
|
||||
# text->text, the way deny_verdict and group_allow_verdict are pinned.
|
||||
door_of() { # door_of <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
|
||||
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
|
||||
else
|
||||
|
|
@ -1488,12 +1617,12 @@ fi
|
|||
# that bootstrap actually runs it (a function nobody calls is not hardening).
|
||||
check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \
|
||||
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/lib/sshd.sh"
|
||||
# ...but only for class=human. On class=server a closed root door is a BROKEN
|
||||
# box — root SSH is the control plane's automation door — and the usual cause
|
||||
# is a 00-rig-users.conf left over from a former class=human life. The refusal
|
||||
# ...but only for root-door=closed. On root-door=open a closed root door is a
|
||||
# BROKEN box — root SSH is the control plane's automation door — and the usual
|
||||
# cause is a 00-rig-users.conf left over from a former closed-door life. The refusal
|
||||
# must name that drop-in or the operator greps sshd configs blind; the path
|
||||
# needs root + a doctored sshd, so grep the die message (repo precedent above).
|
||||
check "sshd lib: class=server refusal names the stale close-root drop-in" 0 "" \
|
||||
check "sshd lib: root-door=open refusal names the stale close-root drop-in" 0 "" \
|
||||
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/lib/sshd.sh"
|
||||
# Validate-then-apply survived the extraction: sshd -t on the merged config
|
||||
# must still precede the restart (same idiom as the close-root ordering check).
|
||||
|
|
@ -1503,7 +1632,7 @@ check "sshd lib: sshd -t precedes the ssh restart" \
|
|||
0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}"
|
||||
# shellcheck disable=SC2016
|
||||
check "bootstrap: hardening runs through the shared lib" 0 "" \
|
||||
grep -qE '^harden_sshd "\$CLASS"$' "$ROOT/commands/bootstrap.sh"
|
||||
grep -qE '^harden_sshd "\$ROOT_DOOR"$' "$ROOT/commands/bootstrap.sh"
|
||||
|
||||
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
||||
# error in it would be invisible here and would first surface at 04:00 on a live
|
||||
|
|
@ -1625,7 +1754,7 @@ check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" --
|
|||
# host itself — /etc/rig/role. The deliberate decision: warn and proceed.
|
||||
# Driven against a fixture marker; counting fires proves silence too.
|
||||
MARK="$WORK/role-marker"
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$MARK"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$MARK"
|
||||
H2="$WORK/h2"; B2="$WORK/b2"
|
||||
check "flip gate: baseline install" 0 "done" inst "$H2" "$B2"
|
||||
check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \
|
||||
|
|
|
|||
Loading…
Reference in a new issue