Machine traits + fleet users: class/host/join presets and rig users apply/status/close-root (#26 + #24) #27

Merged
dan-claude-bot merged 12 commits from feat/users-and-traits into main 2026-07-17 21:24:51 +00:00
10 changed files with 2147 additions and 35 deletions

250
README.md
View file

@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade.
## Commands ## Commands
### `rig bootstrap <control-plane|workload|runner>` ### `rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>`
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
second run changes nothing. second run changes nothing.
@ -29,9 +29,62 @@ second run changes nothing.
rig bootstrap control-plane --hostname my-coolify-box rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-box rig bootstrap workload --hostname my-prod-box
rig bootstrap runner --hostname my-ci-box rig bootstrap runner --hostname my-ci-box
rig bootstrap staging --hostname my-vm-host
rig bootstrap dev --hostname my-dev-box
rig bootstrap workstation --hostname my-laptop
rig bootstrap custom --hostname odd-duck --class server --host yes --join authkey
``` ```
- `--hostname <name>` — tailnet hostname (default: the role name) - `--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
`rig users apply` (see *The identity model* below)
- `--host <yes|no>` — does this box host VMs (box/Incus)
- `--join <authkey|login>` — how it enters the tailnet
**Roles are presets over three orthogonal traits**, nothing more — every
per-role behavior keys off a trait, so any flag overrides its trait without
needing a new role (`rig bootstrap workstation --host no` for a laptop that
will never run VMs), and `custom` exists for the shape nobody foresaw: it
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 |
| `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and the `box setup-host` pointer |
| `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) |
| role | class | host | join | tailnet tag |
|-----------------|--------|------|---------|-------------|
| `control-plane` | server | no | authkey | `tag:server` |
| `workload` | server | no | authkey | `tag:server` |
| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` |
| `staging` | server | yes | authkey | `tag:local` — refuses `tag:server` |
| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` |
| `workstation` | human | yes | login | untagged — any tag refused |
The tag column is **derived policy, not a fourth trait**: `tag:server` means
"the control plane manages this box", and `control-plane` and `workload` are
the only shapes it manages — every other role refuses an effective
`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,
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
post-join and cmp-guarded, so a marker never describes a box that failed to
become what it claims.
**`join=login` inverts the tag assertion.** A workstation joins as a
user-owned device: there is no pre-auth key — a set `TS_AUTHKEY` is a loud
usage error (exit 2; unset it, or pass `--join authkey`) — `tailscale up`
prints a login URL, and the human at the keyboard is the credential. After
join the assertion flips: **untagged** is what rig asserts, and any effective
tag is the refusal — a tag here means control granted this device fleet
identity, and on a first join the half-joined node is backed out with
`tailscale logout` (a box that was already joined is refused without backout;
rig never unwinds state it did not create). Same principle as the authkey
path, mirrored: verify what control **granted**, never what was requested.
There is **no `--ts-tag` flag**. A pre-auth key is minted *with* its tags, so There is **no `--ts-tag` flag**. A pre-auth key is minted *with* its tags, so
the key is the single source of truth for the tailnet tag — rig no longer states the key is the single source of truth for the tailnet tag — rig no longer states
@ -67,7 +120,8 @@ admin console keeps that name; rig will not fight it.
> `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf` > `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf`
> on re-run, and refuses to claim success unless `sshd -T` agrees. > on re-run, and refuses to claim success unless `sshd -T` agrees.
**The pre-auth key:** provide it via the `TS_AUTHKEY` env var or type it at **The pre-auth key** (`join=authkey` roles — everything but `workstation`):
provide it via the `TS_AUTHKEY` env var or type it at
the interactive prompt. Use a **single-use, tagged, short-expiry** key — the the interactive prompt. Use a **single-use, tagged, short-expiry** key — the
**tagged** part is now load-bearing, not advice (see below). It lives in process **tagged** part is now load-bearing, not advice (see below). It lives in process
memory only — rig never writes a credential to disk. memory only — rig never writes a credential to disk.
@ -106,6 +160,50 @@ grant `tag:server` to repo-controlled code." A runner executes that code, and
the check turns the worst misconfiguration from a documentation warning into a the check turns the worst misconfiguration from a documentation warning into a
hard, post-join error. hard, post-join error.
`staging` is the box that *hosts* staging boxes — Incus VMs minted by the
[`box`](https://github.com/heavy-duty/box) CLI, each converged from inside with
`rig bootstrap workload` and registered in the control plane as its own server.
It is `class=server`: an unattended VM appliance — operators converge it and
leave; nobody lives there. Mint its key with `tag:local`: the host and its
guests sit on opposite sides of a trust boundary, and the *host* is never
managed by the control plane — so the role **refuses an effective
`tag:server`**, same mechanism as `runner`. rig deliberately installs no Incus
and no box here — box's own `setup-host` is the single owner of the Incus
daemon's configuration, and two tools converging one daemon is drift by
construction. The closing log points you at it: install box, run
`box setup-host`, then `box new --template staging`. If `/dev/kvm` is absent,
rig warns (a host that exists to run VMs should have it) but does not fail —
the shape is rehearsed in containers, which legitimately lack it.
`dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local`
shape with a person living on it — 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.
### The identity model
**Named operators exist on every class, 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
themself and elevates via sudo.
**`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 there is the **automation** identity the control plane (Coolify) SSHes
in as. It is a machine door, never a human one.
**The detection side benefit:** once humans never use root, any root login
that is not the control plane is anomalous *by definition* — a cheap,
high-signal alert that a shared root identity makes impossible to write.
**The honest caveat:** on a Docker-running box this buys attribution, not
privilege reduction — an operator with sudo is root-equivalent anyway.
Attribution is the goal: *who did what* survives, even where *what they could
do* is everything.
### `rig coolify install --version <pin>` ### `rig coolify install --version <pin>`
Control-plane box only. Installs Coolify at exactly the pinned version with Control-plane box only. Installs Coolify at exactly the pinned version with
@ -285,6 +383,141 @@ prints the exact `runner install` line that finishes the job.
Convergent — repointing to the repo it is already on changes nothing, exits 0, Convergent — repointing to the repo it is already on changes nothing, exits 0,
and never asks for a token. 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
run says "already converged; no changes".
```
# user roles ssh public key
dan admin,box ssh-ed25519 AAAA... dan@laptop
dan admin,box ssh-ed25519 AAAA... dan@desktop
maria rig,box ssh-ed25519 AAAA... maria@mac
```
One line per key — user, comma-joined roles, then the SSH public key (the rest
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.
`--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.
**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
holds nothing secret anyway: usernames, roles, and *public* keys.
| role | grants | via group |
|---------|----------------------------------------------|-------------|
| `admin` | full NOPASSWD sudo | `rig-admin` |
| `rig` | NOPASSWD sudo for `/usr/local/bin/rig` only | `rig` |
| `box` | Incus **restricted** tier, no sudo | `incus` |
**The honest limit of the `rig` role:** its sudo grant is binary-scoped, not
argument-scoped — it trusts its holder with every rig verb *except* identity
management. The `rig users` commands gate their **invoker**: run under sudo
by anyone outside `rig-admin`, they refuse. Without that gate, `sudo rig
users apply` against a file naming yourself admin would make the scoped grant
silently root-equivalent through the very tool it scopes. Direct root — a
bring-up shell, before any admin exists — proceeds.
`box` binds where VMs live, and a users file is fleet-wide — its box grants
are not. rig never installs Incus — box's `setup-host` owns the daemon — so
when the `incus` group is absent, the `host=` trait decides: on `host=yes`
apply dies pointing at `box setup-host` (a VM host missing Incus is a real
problem) rather than conjure a group the (nonexistent) daemon would never
consult; on `host=no` the box role is **skipped with a warning** and
everything else — admins included — still converges, because one box-role
user somewhere in the fleet must not stop apply everywhere VMs don't live.
`incus-admin` is deliberately **not** a role: that group is
host-root-equivalent, break-glass by hand only.
**All passwords stay locked, always** — created or found. The SSH key at the
door is the authentication, and NOPASSWD sudo does not weaken it: there was
never a password to guess or rotate.
Convergence is exact. Membership in the three rig-managed groups is made to
match the file — added *and* removed — while every other group is left alone:
not rig's to converge. `authorized_keys` becomes exactly the file's keys, and
its ownership and mode (and `.ssh`'s) are converged on **every** run, not
just when content changes — sshd's `StrictModes` treats them as
load-bearing, so drifted perms are a broken login that "already converged"
would lie about. A user dropped from the file is found via the
`/etc/rig/users` ledger and **revoked, never deleted**: the account is
expired — the switch PAM actually enforces; a locked password alone still
lets a pubkey in under Debian's `UsePAM` — and `authorized_keys` is renamed
to `authorized_keys.revoked-by-rig`. Access revoked, data kept: deletion
frees the uid for reuse and orphans file ownership, so attribution would rot;
home stays for the same reason, and re-adding the user to the file brings
them back, fresh keys and all. And the sudoers rules land in
`/etc/sudoers.d/rig-roles` only after `visudo -c` passes on the candidate — a
bad file under `/etc/sudoers.d` can take down *all* of sudo, locking every
admin out of the very escalation path apply just granted.
### `rig users status`
```sh
rig users status
```
Read-only truth: per rig-managed user, the roles derived from the groups the
user is **actually** in — not the ledger's memory of an apply — plus the
`authorized_keys` count (`revoked` when only the `.revoked-by-rig` rename
remains) and the user's state, **active** or **revoked**. The state is the
ledger's word corroborated by the account's real expiry — the switch that
actually revokes — and a mismatch is flagged loudly as drift: a box someone
changed behind rig's back must never read as healthy. Reads the box only; no
network, no writes. Run as root (shadow is read).
### `rig users close-root`
```sh
rig users close-root
```
Shuts the root SSH door — `class=human` 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`
refuses with no `--force`, because root there is the control plane's
automation identity and closing it severs fleet management. 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
user and not group/world-writable), a real login shell, and an unexpired
account, and its refusal names which check failed, per candidate. It proves
the door *should* open, not that it does — which is why the separate-session
verification below stays load-bearing. Never close the only door.
Before running it, prove the admin door in a **separate** session — `ssh
<admin>@<box>` while this one stays open. Root SSH is being welded shut; the
admin login must be proven, not presumed.
> **The drop-in's name is the entire mechanism.** close-root installs
> `/etc/ssh/sshd_config.d/00-rig-users.conf` carrying exactly
> `PermitRootLogin no`. sshd_config is first-wins, `Include` expands its glob
> lexically, and `-` (0x2D) sorts before `.` (0x2E) — so `00-rig-users.conf`
> is read *before* bootstrap's `00-rig.conf` and beats its
> `prohibit-password`. Bootstrap's effective-config assertion accepts the
> closed state (`no` is strictly harder than what it installs), and by the
> same first-wins order its own drop-in can never reopen it — a bootstrap
> re-run on a closed box leaves it closed. Validate-then-apply as everywhere:
> `sshd -t` before the restart, rollback on failure, and success is only
> claimed once `sshd -T` resolves `permitrootlogin no`.
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
> 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
> will not write that file — Coolify owns its key material on the servers it
> registers, and two tools converging one file is drift by construction (the
> same argument that keeps rig's hands off Incus).
## What rig deliberately does NOT do ## What rig deliberately does NOT do
- **Provider firewalls** — Docker publishes ports past host firewalls, so - **Provider firewalls** — Docker publishes ports past host firewalls, so
@ -299,7 +532,10 @@ and never asks for a token.
## Testing ## Testing
`bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The `bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The
end-to-end rehearsal is a throwaway VM/container: pristine Debian → install → `rig users` family is covered the same way: the harness drives its refusal
`bootstrap workload` with a real single-use key → assert the sshd drop-in, matrix — users-file parsing, the marker gates, the lexical drop-in-name
tailnet join, and a no-op second run → destroy, remove the node from the assertion, the validate-then-apply ordering — through the sourced lib
tailnet. functions, non-root and network-free. The end-to-end rehearsal is a throwaway
VM/container: pristine Debian → install → `bootstrap workload` with a real
single-use key → assert the sshd drop-in, tailnet join, and a no-op second
run → destroy, remove the node from the tailnet.

48
bin/rig
View file

@ -8,11 +8,17 @@ usage() {
usage: rig <command> [args] usage: rig <command> [args]
commands: commands:
bootstrap <control-plane|workload|runner> [--hostname <name>] [--ts-tag <tag>] bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>]
OS plumbing on a pristine Debian box: hardening, unattended-upgrades, OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
tailscale join. Prompts for a single-use tailnet pre-auth key tailscale join. Roles are presets over the three traits; any flag
(TS_AUTHKEY env overrides the prompt). Run as root. Role runner overrides its trait, and custom states all of them. Prompts for a
defaults to tag:ci and refuses tag:server. single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
prompt); the key's tags are the tailnet tag, verified after join —
only control-plane and workload may carry tag:server. join=login
(workstation) needs no key: interactive login, node must come up
untagged. Run as root.
coolify install --version <pin> coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
coolify backup install [options] coolify backup install [options]
@ -36,6 +42,18 @@ commands:
re-register, reusing the binary already on the box. Needs a removal re-register, reusing the binary already on the box. Needs a removal
token for the old repo and a registration token for the new one. token for the old repo and a registration token for the new one.
Run as root. Run as root.
users apply --file <path>
Converge named operator accounts from a declarative users file, on
every class: groups by role (admin/rig/box), passwords locked always,
authorized_keys made exact, visudo-gated sudoers rules. Users dropped
from the file are locked, never deleted. '-' reads stdin. Run as root.
users status
Roles (derived from actual group membership), key counts and lock
state for the rig-managed users. Reads the box only. Run as root.
users close-root
Shut root SSH on a class=human box once an admin key works. Refuses
on class=server — root there is the control plane's automation door —
and while no admin holds a key. Run as root.
install/upgrade: install/upgrade:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
@ -97,6 +115,28 @@ case "$cmd" in
;; ;;
esac esac
;; ;;
users)
shift
sub="${1:-}"
case "$sub" in
apply)
shift
exec "$ROOT/commands/users-apply.sh" "$@"
;;
status)
shift
exec "$ROOT/commands/users-status.sh" "$@"
;;
close-root)
shift
exec "$ROOT/commands/users-close-root.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
-h|--help|help) -h|--help|help)
usage usage
exit 0 exit 0

View file

@ -13,36 +13,99 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <control-plane|workload|runner> [--hostname <name>] usage: rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
[--hostname <name>] [--class <human|server>]
[--host <yes|no>] [--join <authkey|login>]
--hostname system + tailnet hostname (default: the role name) --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
`rig users apply`: human closes it, server keeps it as the
control plane's automation door.
--host does this box host VMs (box/Incus) — yes|no
--join how it enters the tailnet — authkey|login
Roles are presets over the three traits; any flag overrides its trait.
custom presets nothing and requires --hostname plus all three traits.
role class host join
control-plane server no authkey
workload server no authkey
runner server no authkey
staging server yes authkey
dev human yes authkey
workstation human yes login
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
so the key is the single source of truth: rig no longer requests a tag it might so the key is the single source of truth: rig no longer requests a tag it might
disagree with. After the box joins, rig reads the tag control actually GRANTED disagree with. After the box joins, rig reads the tag control actually GRANTED
(tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused (tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
outright, and a runner may not carry tag:server. Mint a correctly-tagged key. outright, and only control-plane and workload may carry tag:server (they are
the only shapes the control plane manages). Mint a correctly-tagged key.
Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
enter it at the interactive prompt. It is used once and never written to disk. env var, or enter it at the interactive prompt. Used once, never written to disk.
join=login: no pre-auth key — `tailscale up` prints a login URL and the human
at the keyboard is the credential, so the node comes up user-owned and
UNTAGGED (a tag here is refused and backed out). A set TS_AUTHKEY is a usage
error: unset it, or pass --join authkey.
EOF EOF
} }
# --- args (validated before the root check, so errors are testable) --------- # --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}" ROLE="${1:-}"
case "$ROLE" in case "$ROLE" in
control-plane|workload|runner) shift ;; control-plane|workload|runner|staging|dev|workstation|custom) shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
"") usage >&2; die "role required (control-plane|workload|runner)" 2 ;; "") usage >&2; die "role required (control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;;
*) die "unknown role: $ROLE (want control-plane|workload|runner)" 2 ;; *) die "unknown role: $ROLE (want control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;;
esac esac
# Role→traits map — the single place a role's shape is declared (issue #26).
# Roles are presets, nothing more: every behavior below keys off the traits,
# so a flag override changes behavior without a new role, and custom exists
# for the shape nobody foresaw — it declares nothing and must state all three.
CLASS="" HOST="" JOIN=""
case "$ROLE" in
control-plane) CLASS=server HOST=no JOIN=authkey ;;
workload) CLASS=server HOST=no JOIN=authkey ;;
runner) CLASS=server HOST=no JOIN=authkey ;;
staging) CLASS=server HOST=yes JOIN=authkey ;;
dev) CLASS=human HOST=yes JOIN=authkey ;;
workstation) CLASS=human HOST=yes JOIN=login ;;
custom) ;;
esac
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
TS_HOSTNAME="$ROLE" TS_HOSTNAME="$ROLE"
[ "$ROLE" = "custom" ] && TS_HOSTNAME=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--hostname) --hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 2 [ $# -ge 2 ] || die "--hostname needs a value" 2
TS_HOSTNAME="$2"; shift 2 ;; TS_HOSTNAME="$2"; shift 2 ;;
--class)
[ $# -ge 2 ] || die "--class needs a value" 2
case "$2" in
human|server) CLASS="$2" ;;
*) die "bad --class: $2 (want human|server)" 2 ;;
esac
shift 2 ;;
--host)
[ $# -ge 2 ] || die "--host needs a value" 2
case "$2" in
yes|no) HOST="$2" ;;
*) die "bad --host: $2 (want yes|no)" 2 ;;
esac
shift 2 ;;
--join)
[ $# -ge 2 ] || die "--join needs a value" 2
case "$2" in
authkey|login) JOIN="$2" ;;
*) die "bad --join: $2 (want authkey|login)" 2 ;;
esac
shift 2 ;;
--ts-tag) --ts-tag)
# --ts-tag is GONE, but this is a deliberate death with a message, not an # --ts-tag is GONE, but this is a deliberate death with a message, not an
# "unknown flag": the flag shipped for a month and scripts still pass it, # "unknown flag": the flag shipped for a month and scripts still pass it,
@ -57,6 +120,24 @@ while [ $# -gt 0 ]; do
esac esac
done done
# custom must state its whole shape — collect every gap and report them at once,
# so the operator fixes the command line in one round trip, not four.
if [ "$ROLE" = "custom" ]; then
MISSING=""
[ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname"
[ -n "$CLASS" ] || MISSING="$MISSING --class"
[ -n "$HOST" ] || MISSING="$MISSING --host"
[ -n "$JOIN" ] || MISSING="$MISSING --join"
[ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2
fi
# A set TS_AUTHKEY on a login join is a usage error, caught before the root
# check: the operator plainly expected the key to be spent, and silently
# ignoring a credential is how the wrong join path ships unnoticed.
if [ "$JOIN" = "login" ] && [ -n "${TS_AUTHKEY:-}" ]; then
die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2
fi
# --- guards ------------------------------------------------------------------ # --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
@ -71,6 +152,12 @@ if [ -r /etc/os-release ]; then
else else
warn "cannot read /etc/os-release; proceeding anyway" warn "cannot read /etc/os-release; proceeding anyway"
fi fi
# A host=yes box exists to run VMs, so no /dev/kvm deserves a loud note — but
# only a note: the shape is rehearsed in containers, where /dev/kvm is
# legitimately absent, and rig cannot tell a rehearsal from a misconfigured box.
if [ "$HOST" = "yes" ] && [ ! -e /dev/kvm ]; then
warn "/dev/kvm is absent — a host=yes box is expected to run VMs. Harmless in a container rehearsal; on real hardware, enable virtualization (VT-x/AMD-V) in firmware."
fi
# The pre-auth key is acquired LATER, in the tailscale block — and only if the # The pre-auth key is acquired LATER, in the tailscale block — and only if the
# box has not already joined. rig is convergent by contract, so re-running it to # box has not already joined. rig is convergent by contract, so re-running it to
@ -142,8 +229,26 @@ rm -f "$TMP"
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box" eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \ echo "$eff" | grep -qx 'passwordauthentication no' \
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/" || die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \ # The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" # things on the two classes. class=human: `no` is the post-`rig users
# close-root` state — strictly harder than the prohibit-password this script
# installs. Bootstrap must never read a closed door as a broken one, and it
# cannot reopen one either: by first-wins its own drop-in loses to
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
# management silently dead, and the likely culprit is a drop-in left over from
# a former class=human life on a repurposed box. rig can DETECT that but must
# not FIX it: silently reopening a root door is worse than a loud stop, so —
# same doctrine as the tag checks — detect, refuse, and name the repair.
if [ "$CLASS" = "human" ]; then
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
else
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
fi
log "sshd hardening verified (sshd -T: passwordauthentication no)" log "sshd hardening verified (sshd -T: passwordauthentication no)"
# --- system hostname ---------------------------------------------------------- # --- system hostname ----------------------------------------------------------
@ -179,8 +284,15 @@ fi
# Tags ride in with the netmap, not synchronously out of `up`, so a single read # Tags ride in with the netmap, not synchronously out of `up`, so a single read
# right after join can legitimately come back empty; poll until tags appear OR # right after join can legitimately come back empty; poll until tags appear OR
# the backend reaches Running (past which an empty Tags is real, not just early). # the backend reaches Running (past which an empty Tags is real, not just early).
#
# verify_effective_tag <back-out|keep> — same mode discipline as
# verify_user_owned: back-out on first join (rig just spent the key, so an
# untagged result is rig's own mess to undo), keep on the already-joined path
# (never back out state rig did not create — the join there may be a
# legitimately login-joined, user-owned workstation that someone re-ran with
# join=authkey by mistake).
verify_effective_tag() { verify_effective_tag() {
local deadline=$((SECONDS + 30)) tags="" state="" json local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json
json="$(mktemp)" json="$(mktemp)"
while :; do while :; do
if tailscale status --json > "$json" 2>/dev/null; then if tailscale status --json > "$json" 2>/dev/null; then
@ -200,25 +312,92 @@ verify_effective_tag() {
# node anyway, so rig must now catch this out loud. A wrong tag cannot be fixed # node anyway, so rig must now catch this out loud. A wrong tag cannot be fixed
# in place (`tailscale set` has no tag flag; re-tagging needs a fresh key via # in place (`tailscale set` has no tag flag; re-tagging needs a fresh key via
# `up --force-reauth`), so back the node out rather than leave a half-joined, # `up --force-reauth`), so back the node out rather than leave a half-joined,
# user-owned device squatting a hostname. # user-owned device squatting a hostname. That back-out is EARNED only on the
# first-join path, where rig itself just performed the join; on the
# already-joined path an untagged node may be exactly what someone built on
# purpose — a login-joined workstation is untagged BY DESIGN — and tearing it
# off the tailnet because a re-run said join=authkey would destroy state rig
# did not create. keep mode refuses without touching the join and names both
# ways out, since rig cannot tell which one the operator meant.
if [ -z "$tags" ]; then if [ -z "$tags" ]; then
tailscale logout >/dev/null 2>&1 \ if [ "$mode" = "back-out" ]; then
|| warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand" tailscale logout >/dev/null 2>&1 \
die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run." || warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand"
die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run."
fi
die "this box is joined but UNTAGGED — possibly a login-joined (user-owned) machine re-run with join=authkey. It was joined before this run, so nothing was backed out. If it should be fleet-owned: run 'tailscale logout' and re-run with a TAGGED pre-auth key. If it is a workstation: re-run with --join login."
fi fi
# Role policy now rides the EFFECTIVE tag — strictly stronger than the old # tag:server policy is DERIVED, not a trait: it means "the control plane
# request-time check, which only guarded the tag rig HOPED for. This guards the # manages this box", and only control-plane and workload are shapes the
# tag the key ACTUALLY granted to repo-controlled code: a runner carrying # control plane manages. Everything else refuses it on the EFFECTIVE tag —
# tag:server would extend every grant your servers hold to CI code. Refused, # strictly stronger than the old request-time check, which only guarded the
# never warned. rig can DETECT this but cannot FIX it, so name the repair. # tag rig HOPED for. The fleet has been bitten both ways: a runner carrying
if [ "$ROLE" = "runner" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then # tag:server extends every server grant to repo-controlled code, and a
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." # staging host carrying it extends them to a box the control plane does not
# even know. Refused, never warned; rig can DETECT this but cannot FIX it,
# so each refusal names its repair.
if printf '%s\n' "$tags" | grep -qx 'tag:server'; then
case "$ROLE" in
control-plane|workload) ;;
runner)
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
staging)
die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." ;;
*)
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;;
esac
fi fi
log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')" log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')"
} }
# verify_user_owned <back-out|keep> — join=login INVERTS the tag assertion:
# the whole point of a login join is a user-owned, untagged node, so here a tag
# is the hazard (control granted this device fleet identity) and untagged is
# the success case. Same poll as verify_effective_tag — tags ride the netmap —
# but the empty read is what we WANT once the backend reaches Running.
# back-out: first join, so a refusal logs the node out (mirror of the
# untagged-key back-out on the authkey path). keep: the box was already joined
# before this run — never back out state rig did not create; detect, refuse,
# and name the by-hand repair instead.
verify_user_owned() {
local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json shown
json="$(mktemp)"
while :; do
if tailscale status --json > "$json" 2>/dev/null; then
tags="$(json_string_array "$json" Tags)"
state="$(json_field "$json" BackendState)"
if [ -n "$tags" ] || [ "$state" = "Running" ]; then break; fi
fi
if [ "$SECONDS" -ge "$deadline" ]; then break; fi
sleep 2
done
rm -f "$json"
if [ -n "$tags" ]; then
shown="$(printf '%s' "$tags" | tr '\n' ' ')"
if [ "$mode" = "back-out" ]; then
tailscale logout >/dev/null 2>&1 \
|| warn "tailscale logout failed — this node is joined TAGGED; remove it from the tailnet by hand"
die "joined TAGGED (${shown}) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity; use a pre-auth key path (--join authkey) for fleet machines. Backed it out."
fi
die "this node is TAGGED (${shown}) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity. It was joined before this run, so nothing was backed out: run 'tailscale logout' and re-run bootstrap, or re-run with --join authkey."
fi
# Fail CLOSED on a poll that never reached Running: empty tags is this
# function's SUCCESS signal, which makes a timeout uniquely dangerous here —
# a tagged node on a slow tailscaled reads as empty and would be waved
# through as user-owned (verify_effective_tag has the mirror problem, but
# there timeout-empty already lands in a refusal). Nothing was verified
# either way, and the join may be perfectly fine, so neither mode logs out;
# the only honest move is to stop and have the operator re-run the verify.
if [ "$state" != "Running" ]; then
die "tailscale backend never reached Running within 30s — could not verify the join is user-owned and untagged. Nothing was backed out; re-run bootstrap to verify once tailscaled settles."
fi
log "user-owned join verified (untagged)"
}
if ! command -v tailscale >/dev/null 2>&1; then if ! command -v tailscale >/dev/null 2>&1; then
log "installing tailscale" log "installing tailscale"
curl -fsSL https://tailscale.com/install.sh | sh curl -fsSL https://tailscale.com/install.sh | sh
@ -250,7 +429,19 @@ if tailscale status >/dev/null 2>&1; then
# rig's back, on the very next ordinary re-run. Skipping `tailscale up` here is # rig's back, on the very next ordinary re-run. Skipping `tailscale up` here is
# deliberate and stays — re-running an identical tagged-authkey `up` errors — # deliberate and stays — re-running an identical tagged-authkey `up` errors —
# but skipping the CHECK was how the M900s stayed mis-tagged unnoticed. # but skipping the CHECK was how the M900s stayed mis-tagged unnoticed.
verify_effective_tag # The check the traits demand: authkey wants the granted tag, login wants none
# — both in `keep` mode: never back out a join this run did not perform.
if [ "$JOIN" = "login" ]; then
verify_user_owned keep
else
verify_effective_tag keep
fi
elif [ "$JOIN" = "login" ]; then
# No pre-auth key on this path — the human at the keyboard is the credential.
# `tailscale up` prints a login URL and blocks until the browser login lands.
log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)"
tailscale up --hostname="$TS_HOSTNAME"
verify_user_owned back-out
else else
# env override, else prompt; never touches disk # env override, else prompt; never touches disk
if [ -z "${TS_AUTHKEY:-}" ]; then if [ -z "${TS_AUTHKEY:-}" ]; then
@ -264,12 +455,40 @@ else
# cannot be rescued by one (verify_effective_tag refuses it and logs out). # cannot be rescued by one (verify_effective_tag refuses it and logs out).
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)" log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
verify_effective_tag verify_effective_tag back-out
fi 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.
MARKER=/etc/rig/role
MARKER_TMP="$(mktemp)"
printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$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"
else
log "role marker already current"
fi
rm -f "$MARKER_TMP"
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
if [ "$ROLE" = "control-plane" ]; then if [ "$ROLE" = "control-plane" ]; then
log "next: rig coolify install --version <pin>" log "next: rig coolify install --version <pin>"
elif [ "$ROLE" = "runner" ]; then elif [ "$ROLE" = "runner" ]; then
log "next: rig runner install --repo <owner/repo> --version <pin>" log "next: rig runner install --repo <owner/repo> --version <pin>"
fi fi
if [ "$HOST" = "yes" ]; then
log "next: install the box CLI and run 'box setup-host' to prepare Incus for guest boxes"
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.
if [ "$CLASS" = "human" ]; then
log "next: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
else
log "next: rig users apply --file <users-file> for named operator logins; root SSH stays — it is the control plane's automation door"
fi

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Shared parsing for the rig users family. Sourced by the users-* commands and
# by the test harness against fixture files; never executed on its own.
# The users file is line-based and whitespace-separated on purpose: a
# rig-bootstrapped box has no YAML parser and no jq, and `read` parses this
# shape for free — same jq-free reason runner-config.sh greps JSON. One line
# per key:
#
# # user roles ssh public key
# dan admin,box ssh-ed25519 AAAA... dan@laptop
#
# Repeated username lines are additional authorized keys; the roles field must
# be IDENTICAL on each — a repeated line means "another key", never a quiet
# role edit hiding mid-file. '#' comments and blank lines are skipped.
# parse_users_file <path>
#
# Emits one normalized 'user|roles|key' line per key line on stdout. On ANY
# validation error: EVERY error goes to stderr, each with its line number, no
# stdout, return 1. All errors in one pass because a bad file should cost one
# 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
# not start with an SSH key type), invalid username (the charset below —
# '|' would corrupt this parser's own delimited stream, a leading '-' reads
# as a useradd flag), duplicate identical key line.
parse_users_file() {
local path="$1"
local -a errs=() out=() rlist=()
local -A first_roles=() seen=()
local line u r k role ok n=0
while IFS= read -r line || [ -n "$line" ]; do
n=$((n + 1))
if [[ "$line" =~ ^[[:space:]]*(#|$) ]]; then continue; fi
read -r u r k <<< "$line"
if [ -z "${k:-}" ]; then
errs+=("line $n: malformed — expected 'user roles ssh-public-key' (3+ whitespace-separated fields)")
continue
fi
case "$k" in
ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;;
*)
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)")
continue ;;
esac
# The username feeds this parser's own '|'-delimited stream and then
# useradd: 'fo|o' silently becomes user 'fo' with garbage keys, and a
# leading '-' reads as a useradd flag mid-convergence. One safe charset
# refuses both by construction (and ':', which would corrupt passwd).
if ! [[ "$u" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
errs+=("line $n: invalid username '$u' — must match ^[a-z_][a-z0-9_-]{0,31}\$ (lowercase letter or '_' first, then lowercase, digits, '_', '-'; max 32)")
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")
continue
fi
ok=1
IFS=',' read -ra rlist <<< "$r"
for role in "${rlist[@]}"; do
case "$role" in
admin|rig|box) ;;
*) errs+=("line $n: unknown role '$role' for $u (valid roles: admin rig box)"); ok=0 ;;
esac
done
if [ -n "${first_roles[$u]:-}" ] && [ "${first_roles[$u]}" != "$r" ]; then
errs+=("line $n: $u has roles '$r' here but '${first_roles[$u]}' earlier — repeated lines add keys, roles must be identical")
ok=0
fi
if [ -z "${first_roles[$u]:-}" ]; then first_roles[$u]="$r"; fi
if [ -n "${seen[$u|$k]:-}" ]; then
errs+=("line $n: duplicate key line for $u (same key already on line ${seen[$u|$k]})")
continue
fi
seen[$u|$k]="$n"
if [ "$ok" -eq 1 ]; then out+=("$u|$r|$k"); fi
done < "$path"
if [ "${#errs[@]}" -gt 0 ]; then
printf '%s\n' "${errs[@]}" >&2
return 1
fi
if [ "${#out[@]}" -gt 0 ]; then printf '%s\n' "${out[@]}"; fi
return 0
}
# 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.
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() {
local marker
marker="$(read_role_marker "$1")"
if [ -z "$marker" ]; then
# No marker means rig cannot know whether root here is a human's bad habit
# or the control plane's automation door — refuse to shut it blind.
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
return 1
fi
case "$marker" in
*class=human*) return 0 ;;
*class=server*)
# Root SSH on a server IS the control plane's (Coolify's) automation
# identity — closing it severs fleet management. No --force exists.
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management"
return 1 ;;
*)
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
return 1 ;;
esac
}

294
commands/users-apply.sh Executable file
View file

@ -0,0 +1,294 @@
#!/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
# 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.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh"
log() { printf 'rig-users: %s\n' "$*"; }
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig users apply --file <path>
--file <path> users file (required; '-' reads it from stdin)
The file is line-based and bash-parseable on purpose — a rig box has no YAML
parser and no jq, and gets neither for this. Whitespace-separated: user,
comma-joined roles, then the SSH public key (the rest of the line). '#'
comments and blank lines are fine. Repeated username lines add authorized
keys; the roles must be identical on every line of one user.
# user roles ssh public key
dan admin,box ssh-ed25519 AAAA... dan@laptop
maria rig,box ssh-ed25519 AAAA... maria@mac
roles:
admin group rig-admin — full NOPASSWD sudo
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
box group incus — Incus restricted tier, no sudo (box's setup-host
owns the Incus install; rig only asserts it)
All passwords stay locked, always — the SSH key at the door is the
authentication, and NOPASSWD sudo does not weaken it. Convergent: membership
in the three rig-managed groups is made exact (other groups are never
touched), authorized_keys becomes exactly the file's keys, and a user dropped
from the file is REVOKED: account expired (which blocks SSH keys too, not
just the password), authorized_keys renamed to authorized_keys.revoked-by-rig,
rig groups stripped — home kept, nothing deleted, and re-adding the user
brings them back. Run as root; under sudo, only rig-admin members may — the
users family changes who holds root, so role rig's scoped sudo does not reach
it.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--file)
[ $# -ge 2 ] || die "--file needs a value" 2
FILE="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
[ -n "$FILE" ] || die "--file <path> is required" 2
# stdin is read ONCE into a temp: the file is parsed for validation and then
# walked again to converge, and a pipe only plays once.
if [ "$FILE" = "-" ]; then
STDIN_TMP="$(mktemp)"
cat > "$STDIN_TMP"
FILE="$STDIN_TMP"
fi
[ -r "$FILE" ] || die "cannot read users file: $FILE" 2
# File parsing is argument validation: every error in the file is reported in
# one pass, exit 2, still before the root check.
PARSED="$(parse_users_file "$FILE")" \
|| die "invalid users file: $FILE — every error is listed above; nothing was changed" 2
declare -A USER_ROLES=() USER_KEYS=()
USERS=()
BOX_USERS=()
NEED_SUDO=0
NEED_INCUS=0
while IFS='|' read -r u r k; do
[ -n "$u" ] || continue
if [ -z "${USER_ROLES[$u]:-}" ]; then
USERS+=("$u")
USER_ROLES[$u]="$r"
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
fi
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac
case ",$r," in *,box,*) NEED_INCUS=1 ;; esac
done <<< "$PARSED"
# --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
# is binary-scoped but not argument-scoped, so without this gate a rig-role
# user could run `sudo rig users apply --file <me-as-admin>` — the scoped
# grant silently root-equivalent through this very command. Direct root (no
# SUDO_USER: bring-up, a root shell) proceeds.
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
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
# 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
CHANGED=0
# --- sudo (only when some role actually grants through it) -------------------
if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
log "installing sudo (admin/rig grants go through it)"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
CHANGED=1
fi
# --- groups ------------------------------------------------------------------
groupadd -f rig-admin
groupadd -f rig
# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An
# absent incus group means that never ran — but what that MEANS is the host=
# trait's call. The box role binds where VMs live; a users file is fleet-wide,
# its box grants are not. So on host=yes an absent group is a broken VM host
# (refuse, point at setup-host), while on host=no it is simply not this box's
# role to converge — skip it, never abort the admins the file also carries.
INCUS_OK=0
if getent group incus >/dev/null; then INCUS_OK=1; fi
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
*host=yes*)
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;;
*host=no*)
warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;;
*)
warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;;
esac
fi
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
# --- converge each user ------------------------------------------------------
for u in "${USERS[@]}"; do
if ! id -u "$u" >/dev/null 2>&1; then
useradd -m -s /bin/bash "$u"
log "created user $u"
CHANGED=1
fi
# Locked always, created or found: no password ever exists to guess or
# rotate — the SSH key at the door is the authentication. The expiry is
# cleared just as idempotently: revocation below IS an expiry date, so a
# user dropped once and re-added comes back to life on this line.
usermod -L -e '' "$u"
# Membership in the three rig-managed groups is made EXACT — added and
# removed to match the file. Other groups are never touched: they are not
# rig's to converge.
roles="${USER_ROLES[$u]}"
want=""
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
case ",$roles," in *,rig,*) want="$want rig" ;; esac
# incus joins the wanted set only when the group exists (host=no boxes
# skipped it above): converging membership in a conjured group would hand
# the daemon's arrival an audience it never granted.
case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
for g in rig-admin rig incus; do
case " $want " in
*" $g "*)
if ! in_group "$u" "$g"; then
usermod -aG "$g" "$u"
log "added $u to $g"
CHANGED=1
fi ;;
*)
if in_group "$u" "$g"; then
gpasswd -d "$u" "$g" >/dev/null
log "removed $u from $g"
CHANGED=1
fi ;;
esac
done
# authorized_keys becomes exactly the file's keys — only the content WRITE
# is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode
# converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing
# (a group-writable .ssh is a rejected key), so drifted perms behind
# matching content would otherwise stay broken while apply logs "already
# converged". Perms are part of the converged state.
home="$(getent passwd "$u" | cut -d: -f6)"
ugroup="$(id -gn "$u")"
mkdir -p "$home/.ssh"
AK_TMP="$(mktemp)"
printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP"
if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then
install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys"
log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)"
CHANGED=1
fi
rm -f "$AK_TMP"
chmod 0700 "$home/.ssh"
chown "$u:$ugroup" "$home/.ssh"
chmod 0600 "$home/.ssh/authorized_keys"
chown "$u:$ugroup" "$home/.ssh/authorized_keys"
done
# --- previously managed users no longer in the file --------------------------
# The ledger is what lets a REMOVED user be found at all — so it must REMEMBER
# them: two-field lines, 'name active' / 'name revoked' (a legacy bare name
# reads as active). Revoked, not deleted: deleting frees the uid for reuse and
# orphans file ownership — attribution would rot. Home stays for the same
# reason. But revoked must actually mean revoked: a '!'-locked password is not
# a closed door under UsePAM — Debian sshd still honors the pubkey — so the
# lock alone left a dropped operator with working SSH. Account expiry (a date
# in the past) is the switch PAM actually enforces, against every auth method
# including keys; the keys themselves are renamed, never deleted — access
# revoked, data kept, convergence never destroys.
LEDGER=/etc/rig/users
REVOKED=()
if [ -r "$LEDGER" ]; then
while read -r prev pstate _; do
[ -n "$prev" ] || continue
case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac
id -u "$prev" >/dev/null 2>&1 || continue
usermod -L -e 1 "$prev"
prevhome="$(getent passwd "$prev" | cut -d: -f6)"
if [ -f "$prevhome/.ssh/authorized_keys" ]; then
mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig"
fi
for g in rig-admin rig incus; do
if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi
done
REVOKED+=("$prev")
# Warn on the TRANSITION only: an already-revoked user is converged above
# (quietly — repairing drift, not announcing news) so a second identical
# run stays a clean no-op.
if [ "${pstate:-active}" != "revoked" ]; then
warn "$prev is no longer in the file: account expired (blocks SSH keys too, not just the password), authorized_keys renamed to authorized_keys.revoked-by-rig, rig groups stripped (home kept — rig never deletes a user)"
CHANGED=1
fi
done < "$LEDGER"
fi
LEDGER_TMP="$(mktemp)"
if [ "${#USERS[@]}" -gt 0 ]; then printf '%s active\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
if [ "${#REVOKED[@]}" -gt 0 ]; then printf '%s revoked\n' "${REVOKED[@]}" >> "$LEDGER_TMP"; fi
if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then
mkdir -p /etc/rig
install -m 0644 "$LEDGER_TMP" "$LEDGER"
CHANGED=1
fi
rm -f "$LEDGER_TMP"
# --- sudoers -----------------------------------------------------------------
# Both group rules ship in one drop-in whether or not both roles are in use:
# the groups exist and the rules are inert without members. visudo gates the
# install because a bad file under /etc/sudoers.d can take down ALL of sudo —
# locking every admin out of the very escalation path apply just granted.
SUDOERS_TMP="$(mktemp)"
cat > "$SUDOERS_TMP" <<'EOF'
# Managed by `rig users apply` — do not edit; the next apply converges it.
%rig-admin ALL=(ALL:ALL) NOPASSWD: ALL
%rig ALL=(root) NOPASSWD: /usr/local/bin/rig
EOF
if command -v visudo >/dev/null 2>&1; then
visudo -c -f "$SUDOERS_TMP" >/dev/null \
|| die "sudoers candidate failed validation — /etc/sudoers.d untouched; candidate kept at $SUDOERS_TMP for inspection"
if ! cmp -s "$SUDOERS_TMP" /etc/sudoers.d/rig-roles 2>/dev/null; then
install -m 0440 "$SUDOERS_TMP" /etc/sudoers.d/rig-roles
log "sudoers role rules installed (/etc/sudoers.d/rig-roles)"
CHANGED=1
fi
rm -f "$SUDOERS_TMP"
else
# No sudo on the box means no role needed it (the install above would have
# run otherwise): rules for a binary that is not there can wait for the
# apply that brings a sudo-bearing role.
rm -f "$SUDOERS_TMP"
log "sudo not installed and no role needs it; skipping the sudoers drop-in"
fi
if [ "$CHANGED" -eq 0 ]; then
log "already converged; no changes"
else
log "converged ${#USERS[@]} user(s)"
fi

206
commands/users-close-root.sh Executable file
View file

@ -0,0 +1,206 @@
#!/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
# 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
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh"
log() { printf 'rig-users: %s\n' "$*"; }
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig users close-root
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
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
member holds a login sshd would plausibly accept — authorized_keys present
and non-empty, home/.ssh/keys owned by the user and not group/world-writable
(sshd's StrictModes rejects the key otherwise), a real login shell, account
not expired. The refusal names which check failed, per candidate. Run rig
users apply first; never close the only door.
Before running, verify your admin login in a SEPARATE session — `ssh
<admin>@<box>` while this one stays open. Root SSH is the door being welded
shut; the admin door must be proven, not presumed.
Run as root. Convergent: once root is closed, a re-run is a clean no-op.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
# is binary-scoped but not argument-scoped, so without this gate a rig-role
# user could reshape who enters this box as whom — the scoped grant silently
# root-equivalent through the users family. Direct root (no SUDO_USER:
# bring-up, a root shell) proceeds.
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
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
die "$WHY"
fi
# Admin-door gate — never close the only door. Root SSH goes away below, so
# at least one rig-admin member must hold a login sshd would plausibly ACCEPT
# — a non-empty authorized_keys alone proves a file exists, not a door:
# StrictModes rejects keys behind wrongly-owned or group/world-writable
# paths, a nologin shell never logs in, and an expired account fails PAM
# before the key is read. So every candidate is checked for the StrictModes
# shape, and the refusal names, per candidate, WHICH check failed — an
# operator staring at a refusal must see the repair. Honestly: this proves
# the door SHOULD open per StrictModes, not that it does — the
# verify-in-a-separate-session advisory in --help stays load-bearing.
today=$(( $(date +%s) / 86400 ))
# path_strict <path> <uid> <label> — flag the two StrictModes complaints
flag() { bad="${bad:+$bad, }$1"; }
path_strict() {
local p="$1" uid="$2" l="$3" o m
o="$(stat -c '%u' "$p" 2>/dev/null)" || return 0
m="$(stat -c '%a' "$p" 2>/dev/null)"
if [ "$o" != "$uid" ]; then flag "$l not owned by the user"; fi
if [ $(( 8#$m & 8#022 )) -ne 0 ]; then flag "$l is group/world-writable (mode $m)"; fi
}
ADMIN_OK=0
CANDIDATES=0
DETAIL=""
while IFS= read -r a; do
[ -n "$a" ] || continue
CANDIDATES=$((CANDIDATES + 1))
bad=""
uid="$(id -u "$a" 2>/dev/null)" || { DETAIL="$DETAIL; $a: no such user"; continue; }
ent="$(getent passwd "$a")"
h="$(printf '%s' "$ent" | cut -d: -f6)"
shell="$(printf '%s' "$ent" | cut -d: -f7)"
if [ ! -d "$h" ]; then
flag "home directory missing"
else
path_strict "$h" "$uid" "home"
if [ ! -d "$h/.ssh" ]; then
flag ".ssh directory missing"
else
path_strict "$h/.ssh" "$uid" ".ssh directory"
if [ ! -s "$h/.ssh/authorized_keys" ]; then
flag "authorized_keys missing or empty"
else
path_strict "$h/.ssh/authorized_keys" "$uid" "authorized_keys"
fi
fi
fi
case "$shell" in
*/nologin|*/false) flag "login shell $shell never logs in" ;;
esac
# shadow field 8 is the expiry in days-since-epoch; empty means never.
exp="$(getent shadow "$a" 2>/dev/null | cut -d: -f8)"
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
flag "account expired"
fi
if [ -z "$bad" ]; then ADMIN_OK=1; break; fi
DETAIL="$DETAIL; $a: $bad"
done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n')
if [ "$ADMIN_OK" -ne 1 ]; then
if [ "$CANDIDATES" -eq 0 ]; then
die "no admin user with a key on this box — run rig users apply first; never close the only door"
fi
die "no rig-admin member would pass sshd's door${DETAIL} — repair, prove the login in a separate session, then re-run; never close the only door"
fi
# --- the drop-in --------------------------------------------------------------
# The NAME is the entire mechanism: sshd_config is FIRST-wins ("for each
# keyword, the first obtained value will be used" — sshd_config(5)), Include
# expands its glob in lexical order, and '-' (0x2D) sorts before '.' (0x2E),
# so 00-rig-users.conf is read BEFORE bootstrap's 00-rig.conf and this
# PermitRootLogin beats its prohibit-password. Rename the file and it silently
# loses that fight — the harness asserts the comparison the glob makes.
DROPIN=/etc/ssh/sshd_config.d/00-rig-users.conf
TMP="$(mktemp)"
printf 'PermitRootLogin no\n' > "$TMP"
# Convergence needs two proofs, and matching bytes are only half of one. The
# file can be right while the DOOR is still open: a prior run that died
# between install and restart leaves a daemon that never read this file, and
# `sshd -T` cannot tell — it re-parses disk, it does not interrogate the
# running daemon. The only proof the running sshd carries this config is a
# (re)start AFTER the last change to anything sshd reads: the main config,
# the drop-in dir itself (creates/deletes/renames inside touch its mtime, so
# a since-removed override is caught), and every drop-in. So the no-op is
# taken only when the bytes match AND systemd says sshd started strictly
# after the newest of those mtimes; anything less restarts, and the
# effective-config assertion at the bottom runs on EVERY path — claiming
# "already closed" from file bytes is exactly what let bootstrap's
# first-wins bug ship green.
RESTART=1
BACKUP=""
INSTALLED=0
if cmp -s "$TMP" "$DROPIN" 2>/dev/null; then
newest="$(stat -c '%Y' /etc/ssh/sshd_config /etc/ssh/sshd_config.d /etc/ssh/sshd_config.d/*.conf 2>/dev/null | sort -rn | head -n1)"
started="$(systemctl show ssh -p ExecMainStartTimestamp --value 2>/dev/null)" || started=""
if [ -n "$started" ] && started_s="$(date -d "$started" +%s 2>/dev/null)" \
&& [ -n "$newest" ] && [ "$started_s" -gt "$newest" ]; then
RESTART=0
fi
else
[ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; }
install -m 0644 "$TMP" "$DROPIN"
INSTALLED=1
fi
rm -f "$TMP"
if [ "$RESTART" -eq 1 ]; then
# Validate the MERGED config BEFORE bouncing the daemon (the bootstrap
# shape): on a box whose only door is SSH — exactly what this box is about
# to become — restarting into a config the daemon refuses to parse leaves
# no listener and no way back in. Roll back (when we installed anything)
# and stop rather than shut the door on a maybe.
if ! sshd -t 2>/dev/null; then
if [ "$INSTALLED" -eq 1 ]; then
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
rm -f "$BACKUP"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
fi
die "sshd rejects the current config; daemon untouched. Run 'sshd -t' to see which file is bad."
fi
rm -f "$BACKUP"
systemctl restart ssh
fi
# Assert the EFFECTIVE config, not the file's existence — a drop-in sorting
# even earlier would win the first-wins fight silently. `sshd -T` is what the
# daemon actually resolved, and it gates the no-op claim too: "already
# closed" is a statement about the door, never about the file.
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim root is closed"
echo "$eff" | grep -qx 'permitrootlogin no' \
|| die "sshd still resolves permitrootlogin != no — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
if [ "$RESTART" -eq 0 ]; then
log "root already closed (sshd -T resolves permitrootlogin no); nothing to do"
else
log "root door closed (sshd -T resolves permitrootlogin no); humans enter as themselves now"
fi

88
commands/users-status.sh Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# rig users status — what this box's operator accounts actually are, read from
# the machine itself: roles derived from REAL group membership (not the
# ledger's memory of an apply), key counts from authorized_keys, and the
# active/revoked state from the ledger CORROBORATED by the account's actual
# expiry — apply locks every password always, so the lock flag says nothing;
# expiry is the switch that actually revokes, and a mismatch between ledger
# and expiry is drift worth shouting about. Reads only — no network, no
# writes.
set -euo pipefail
log() { printf 'rig-users: %s\n' "$*"; }
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig users status
Per rig-managed user (the /etc/rig/users ledger): roles derived from the
groups the user is ACTUALLY in (rig-admin -> admin, rig -> rig, incus -> box),
the authorized_keys count ('revoked' when only the .revoked-by-rig rename
remains), and whether the user is active or revoked — the ledger's word,
checked against the account's real expiry, with a loud warning when the two
disagree (a drifted box must never read as healthy). Reads the box only — no
network, no writes. Run as root (shadow is read).
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
[ "$(id -u)" -eq 0 ] || die "must run as root"
LEDGER=/etc/rig/users
if [ ! -r "$LEDGER" ]; then
log "no rig-managed users (no $LEDGER yet — rig users apply creates it)"
exit 0
fi
today=$(( $(date +%s) / 86400 ))
while read -r u lstate _; do
[ -n "$u" ] || continue
# Ledger lines are 'name active' / 'name revoked'; a legacy bare name (a
# ledger written before states existed) reads as active.
[ -n "${lstate:-}" ] || lstate=active
if ! id -u "$u" >/dev/null 2>&1; then
# In the ledger but off the box: someone deleted by hand what rig only
# ever revokes. Say so rather than crash or silently skip.
warn "$u: in the ledger but not on the box (rig never deletes — removed by hand?)"
continue
fi
groups=" $(id -nG "$u") "
roles=""
case "$groups" in *" rig-admin "*) roles="admin" ;; esac
case "$groups" in *" rig "*) roles="${roles:+$roles,}rig" ;; esac
case "$groups" in *" incus "*) roles="${roles:+$roles,}box" ;; esac
[ -n "$roles" ] || roles="none"
home="$(getent passwd "$u" | cut -d: -f6)"
keys=0
if [ -r "$home/.ssh/authorized_keys" ]; then
keys="$(grep -c . "$home/.ssh/authorized_keys" || true)"
elif [ -e "$home/.ssh/authorized_keys.revoked-by-rig" ]; then
# Only the rename apply's revocation performed remains: access revoked,
# data kept.
keys=revoked
fi
# Corroborate the ledger against the switch that actually revokes: shadow
# field 8 is the expiry (days since epoch, empty = never). The ledger is
# apply's memory; the expiry is the machine's present tense — when they
# disagree, someone changed the account behind rig's back.
exp="$(getent shadow "$u" 2>/dev/null | cut -d: -f8)"
actual=active
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
actual=revoked
fi
if [ "$actual" != "$lstate" ]; then
warn "$u: DRIFT — ledger says $lstate but the account's expiry says $actual; re-run rig users apply"
log "$u roles=$roles keys=$keys $lstate (DRIFT: expiry says $actual)"
else
log "$u roles=$roles keys=$keys $lstate"
fi
done < "$LEDGER"

View file

@ -0,0 +1,216 @@
# rig `bootstrap staging` Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `staging` bootstrap role alongside `control-plane` / `workload` /
`runner` — the host archetype for a machine whose job is to **host staging
VMs**: Incus VMs minted by the `box` CLI (heavy-duty/box) from its `staging`
template, each later converged from *inside* with `rig bootstrap workload` and
registered in the control plane as its own server.
**Why this shape:** the host and its guests sit on opposite sides of a trust
boundary. The guests are servers the control plane manages; the **host is
not** — it must never carry `tag:server`, and the fleet has already been bitten
once by a host that wrongly did. The role is deliberately minimal host
plumbing: hardening, tailnet join (key minted with `tag:local`), hostname —
and **nothing about Incus or box**. box's own `setup-host` owns Incus
configuration; two tools converging the same daemon is drift by construction,
so rig only *points* at box's installer in its closing log. The guest side
needs zero rig changes — `rig bootstrap workload` already is the staging-box
role.
**Architecture:** `staging` joins the existing role case in
`commands/bootstrap.sh`; no new files, no new flags. Since issue #16 / PR #20,
the tailnet tag is **not a rig argument** — the pre-auth key carries its tags
and rig asserts on the tag control actually *granted* (`.Self.Tags`), post-join
and on every re-run. So the role's tag policy lands in `verify_effective_tag`,
exactly where the `runner` policy lives: role `staging` **refuses an effective
`tag:server`** (die, exit 1 — a runtime refusal, not a usage error). The
`/dev/kvm` advisory and the box next-step pointer live in the execution path;
argument validation stays pure and root-free.
**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck +
`bash test/cli.sh`) — no workflow change needed.
## Non-Goals
- **No Incus, no box install** — box's `setup-host` is the single owner of the
Incus daemon's configuration. rig prints a pointer, nothing more.
- **No VM provisioning** — minting boxes is box's job (`box new --template
staging`, companion issue heavy-duty/box#68).
- **No control-plane/Coolify API usage** — guests register themselves via the
existing workload flow.
- **No `dev` role** — a dev-box host role is anticipated (same plumbing, honest
name) but explicitly out of scope here.
## Global Constraints
- `#!/usr/bin/env bash` + `set -euo pipefail`; log prefix `rig-bootstrap:`
via the existing `log`/`warn`/`die` helpers.
- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All argument
validation runs BEFORE the root check** so error paths are testable as
non-root.
- The tag policy asserts the **effective** tag, never a requested one — there
is no `--ts-tag` to refuse anymore (it died in PR #20; passing it exits 2
with a pointer at the key). The issue's original "refuse `--ts-tag
tag:server`" acceptance is therefore satisfied at the stronger, post-join
layer, same as `runner`.
- `/dev/kvm` absence is a **warning, not a failure** — the role is rehearsed in
containers where `/dev/kvm` legitimately isn't there.
- Convergent: a second run changes nothing and exits 0.
- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x
bin/* **/*.sh`); `bash test/cli.sh` green as non-root.
- Keep the diff minimal — no drive-by refactors. (One deliberate exception:
`bin/rig`'s bootstrap usage line still advertises the removed `--ts-tag`
flag and the old `tag:ci` default — stale since PR #20. It gets corrected in
the same breath as adding `staging` to the role list, because shipping a new
role into a help text that lies about the flag surface would be worse than
the drive-by.)
---
### Task 1: role wiring in `commands/bootstrap.sh` + dispatcher usage + tests
**Files:**
- Modify: `commands/bootstrap.sh` (role case, effective-tag refusal, `/dev/kvm`
advisory, closing next-step log, usage heredoc)
- Modify: `bin/rig` (bootstrap usage line: role list + stale-flag correction)
- Modify: `test/cli.sh` (bootstrap section additions)
**Behavior contract, in file order:**
1. Usage heredoc: role list becomes `<control-plane|workload|runner|staging>`;
one added sentence: staging hosts box-minted staging VMs, its key should be
minted with `tag:local`, and it refuses `tag:server` — the host is never
managed by the control plane; its guest VMs are.
2. Role case arm: `control-plane|workload|runner|staging) shift ;;` and both
error messages (`role required`, `unknown role`) name the four roles.
3. `verify_effective_tag`: after the `runner` refusal, the `staging` one — same
shape (`grep -qx 'tag:server'` against the effective tags), message
`role staging joined with tag:server ...` naming the repair (mint a
`tag:local` key), rationale comment: hosts are never managed by the control
plane, their guest VMs are; the fleet has been bitten by a host wrongly
carrying `tag:server`. `die` with default status → exit 1.
4. Guards section (execution path, after the root check): when role is
`staging` and `/dev/kvm` is absent, `warn` — the host exists to run VMs, but
a container rehearsal legitimately has no `/dev/kvm`, so this must not fail.
5. Closing log: `staging` branch pointing at the box CLI — install box, run
`box setup-host` to prepare Incus, then `box new --template staging`.
6. `bin/rig` usage: `bootstrap <control-plane|workload|runner|staging>
[--hostname <name>]`; drop the stale `[--ts-tag <tag>]` and
`tag:ci`-default sentence; say the tag comes from the pre-auth key and that
roles `runner` and `staging` refuse `tag:server`.
- [ ] **Step 1: Append failing tests**
In `test/cli.sh`, bootstrap section:
```bash
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
# The staging tag:server refusal rides the EFFECTIVE tag, inside
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
# rehearsal. What the harness CAN prove is that the refusal exists in the shipped
# script: grep the die message, so a deleted guard cannot ship green (the same
# reason the runner-install repo guard is grepped below).
check "bootstrap: staging effective-tag refusal is present" 0 "" \
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
```
and in the existing non-root block:
```bash
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
```
The existing `unknown role exits 2` (potato) check already covers the
still-fails-usage path and stays untouched.
- [ ] **Step 2: Run tests to verify the new ones fail**
Run: `bash test/cli.sh`
Expected: the staging checks FAIL (`unknown role: staging` → wrong exit/output
for the first and third; missing die message for the grep); everything existing
stays green; harness exits 1.
- [ ] **Step 3: Implement the role**
Per the behavior contract above.
- [ ] **Step 4: Run tests to verify they pass**
Run: `bash test/cli.sh`
Expected: all checks pass, exit 0.
- [ ] **Step 5: shellcheck + syntax**
Run: `shopt -s globstar; shellcheck -x bin/* **/*.sh` (exactly CI's
invocation) and `bash -n` on each edited script.
Expected: exit 0, no findings.
- [ ] **Step 6: Commit**
```bash
git add commands/bootstrap.sh bin/rig test/cli.sh
git commit -m "feat(bootstrap): staging role — the host archetype for box-minted staging VMs"
```
---
### Task 2: README roles documentation
**Files:**
- Modify: `README.md` (bootstrap section: heading role list, example block, the
roles paragraph)
- [ ] **Step 1: Write it**
Content requirements (in the README's existing voice):
- Heading/example gain `staging` (`rig bootstrap staging --hostname my-vm-host`).
- One honest paragraph in the roles discussion: `staging` is the box that
*hosts* staging boxes — Incus VMs minted by the `box` CLI, each converged
from inside with `rig bootstrap workload` and registered in the control plane
as its own server. Mint its key with `tag:local`; the role **refuses an
effective `tag:server`** — the host is never managed by the control plane,
its guests are. rig deliberately installs no Incus and no box (box's
`setup-host` owns that); it points there when done.
- [ ] **Step 2: Full local gate**
Run: CI's shellcheck invocation + `bash test/cli.sh`.
Expected: silent shellcheck; all tests pass.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: README section for the staging bootstrap role"
```
---
## Test Plan
- **Harness (`bash test/cli.sh`, non-root, network-free):** staging parses and
reaches the root check (exit 1 `must run as root`); staging + the removed
`--ts-tag` dies at arg validation (exit 2, message points at the key —
proving validation precedes the root check); the effective-tag refusal
message is present in the script; unknown roles still exit 2.
- **CI:** unchanged `ci.yml` covers the edits (globstar shellcheck + harness).
- **Rehearsal (manual, out of harness):** pristine Debian box → `rig bootstrap
staging` with a real single-use `tag:local` key → hardened sshd drop-in,
tailnet join, hostname `staging`, `/dev/kvm` warning absent on real hardware,
closing log points at box; second run is a no-op. A `tag:server` key must
die post-join with the staging refusal.
## Addendum (2026-07-17, written before implementation)
Issue #22 predates the merge of PR #20 (issue #16: the tag comes from the key).
Its acceptance criterion "`rig bootstrap staging --ts-tag tag:server` exits 1
with a refusal, before the root check" names a flag that no longer exists —
`--ts-tag` now dies (exit 2) for every role, before the root check, pointing at
the key. The staging `tag:server` policy therefore lands where the runner's
did: on the **effective** tag in `verify_effective_tag`, exit 1, which is the
strictly stronger check (it guards the tag the key actually granted, not the
one rig hoped for). This plan is the up-to-date statement of the work.

View file

@ -0,0 +1,458 @@
# Machine Traits + `rig users` Implementation Plan (issues #26 + #24, one release)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the trait model (#26) and declarative fleet users (#24) as a
single release. Bootstrap's role list becomes presets over three orthogonal
traits (`class`, `host`, `join`), recorded in a convergent marker
`/etc/rig/role`. A new `rig users` command family makes operator accounts a
first-class, declarative concern on **every** class — the hybrid access model:
humans always enter as themselves and elevate via sudo; root SSH is what
`class` decides *after* users exist. `class=human` machines close it
(`rig users close-root`); `class=server` machines keep it as the **automation**
identity the control plane (Coolify) SSHes in as. The tailnet is network-only
(no Tailscale SSH), so named users are the only attribution at the door.
**Why this shape:** #25's server/host binary bundled three questions that
merely correlated (who lives there / hosts VMs / how it joins). #26 unbundles
them; roles stay as presets so the usual shapes remain one command. The
earlier "server is humanless — `rig users` refuses" was superseded in #26's
comments: a shared root login is unattributable, so operators belong
everywhere; what stays class-specific is the *fate of root SSH*. Root's key
hygiene on servers (`from=`-locking Coolify's key line) is README guidance,
not automation — Coolify owns its key material on the servers it registers,
and two tools converging one file is drift by construction.
**Architecture:**
- `commands/bootstrap.sh` gains a **role→traits map** — the single place a
role's shape is declared — plus `--class/--host/--join` overrides and roles
`dev`, `workstation`, `custom`. All per-class behavior keys off traits:
`/dev/kvm` advisory (`host=yes`), tag policy (derived), next-steps log.
The marker is written post-join, convergently.
- `join=login` (workstation): a set `TS_AUTHKEY` is a usage error (exit 2,
before the root check — testable); interactive `tailscale up`; the tag
assertion **inverts** — any effective tag is a refusal, backed out with
`tailscale logout`, mirroring the untagged-key refusal on the authkey path.
- **tag:server policy is derived, not a trait**: only `control-plane` and
`workload` may carry it; every other role refuses it on the effective tags
(generalizes today's runner + staging checks into one rule).
- New `commands/users-apply.sh`, `commands/users-status.sh`,
`commands/users-close-root.sh`, with parsing/marker helpers in
`commands/lib/users-config.sh` so the harness can exercise refusals via
sourced functions against fixtures (repo precedent: `assert_runner_repo`,
`json_string_array`).
- `close-root` installs `/etc/ssh/sshd_config.d/00-rig-users.conf`
(`PermitRootLogin no`). The **name is load-bearing**: sshd_config is
first-wins, the Include glob expands lexically, and `-` (0x2D) < `.`
(0x2E), so `00-rig-users.conf` is read before bootstrap's `00-rig.conf`
and wins. Validate-then-apply exactly like bootstrap (`sshd -t`, rollback,
`sshd -T` assertion). Bootstrap's effective-config assertion learns to
accept `permitrootlogin no` (strictly harder) so a re-run never reopens
root.
- Managed-user bookkeeping: `apply` maintains `/etc/rig/users` (one username
per line) so a user removed from the input file is found and **locked**
(never deleted) on the next run.
**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck
+ `bash test/cli.sh`) — no workflow change needed.
## Non-Goals
- No user deletion; no passwords (all locked, always); no LDAP/SSO/PAM; no
per-user quotas (Incus project limits are a box concern).
- No management of root's `authorized_keys` (Coolify owns its key on the
servers it manages; the `from=` lock is README guidance, not code).
- No Coolify application-account (web UI) management.
- No Incus / box installation (box's `setup-host` owns the daemon; the `box`
role *asserts* the `incus` group exists and refuses with a pointer
otherwise).
- No behavior change for control-plane/workload/runner beyond the marker
write and the generalized (identical-in-effect) tag policy.
- Cross-repo box work (restricted-tier verification, project-awareness,
global install) is referenced in #24 and tracked in heavy-duty/box.
## Global Constraints
- `#!/usr/bin/env bash` + `set -euo pipefail`; per-command log prefix via
`log`/`warn`/`die` helpers (`rig-users:` for the new family).
- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All
argument validation runs BEFORE the root check** so error paths are
testable as non-root. File parsing counts as validation: a bad users file
exits 2 with **all** errors reported at once.
- Convergent everywhere: a second identical run is a no-op, says so, exits 0.
- Validate-then-apply for anything that can lock the door or break sudo:
`sshd -t` before restart (rollback on failure), `visudo -c` before the
sudoers drop-in lands.
- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x
bin/* **/*.sh`); `bash test/cli.sh` green as non-root.
- Keep the diff minimal — no drive-by refactors.
---
### Task 1: role→traits map, trait flags, marker, login join path (bootstrap)
**Files:**
- Modify: `commands/bootstrap.sh` (usage heredoc, role case + trait map +
override flags, TS_AUTHKEY/login validation, kvm advisory keyed on traits,
generalized tag policy, login-path inverted assertion, marker write,
next-steps log)
- Modify: `bin/rig` (bootstrap usage lines: roles + trait flags)
- Modify: `test/cli.sh` (bootstrap section additions)
**Behavior contract, in file order:**
1. Usage heredoc: roles `<control-plane|workload|runner|staging|dev|workstation|custom>`;
flags `--hostname <name>`, `--class <human|server>`, `--host <yes|no>`,
`--join <authkey|login>`. Document: roles are presets over traits, any
trait overridable; `custom` requires `--hostname` and all three traits;
`join=login` needs no pre-auth key and refuses a set `TS_AUTHKEY`
(unset it or pass `--join authkey`); the preset table in one compact
block.
2. Role case: the seven roles shift; `role required` / `unknown role`
messages name them. Then the **role→traits map** — one `case "$ROLE"`
assigning `CLASS`/`HOST`/`JOIN` per the preset table in issue #26;
`custom` leaves all three empty.
3. Flag loop gains `--class/--host/--join`, each validating its value set
(bad value → exit 2 naming the valid values). After the loop:
`custom` missing `--hostname` → exit 2; `custom` missing any trait →
exit 2. `TS_HOSTNAME` default stays the role name (custom has none).
4. Post-parse validation (still pre-root-check): `JOIN=login` with a set
`TS_AUTHKEY``die` exit 2: "join=login is interactive: unset TS_AUTHKEY
or pass --join authkey".
5. Guards: the `/dev/kvm` advisory keys on `HOST=yes` (message unchanged in
spirit; drops the staging-only wording).
6. `verify_effective_tag` generalizes the runner/staging refusals into the
derived policy: if role is not `control-plane`/`workload` and the
effective tags contain `tag:server` → die (message keeps the
role-specific repair pointers for runner/staging; a generic message for
other shapes). Keep the existing two die-message strings greppable —
the harness greps them (`role staging joined with tag:server`,
runner equivalent).
7. **login path**: when `JOIN=login`, skip the pre-auth key acquisition;
run `tailscale up --hostname="$TS_HOSTNAME"` (interactive, no
`--authkey`); then the **inverted** assertion — poll as
`verify_effective_tag` does, but *any* non-empty effective tag →
`tailscale logout` + die: "joined TAGGED (…) but join=login expects a
user-owned, untagged node — a tag here means control granted this device
fleet identity; use a pre-auth key path (--join authkey) for fleet
machines." Empty tags + `Running` → OK, log "user-owned join verified".
The already-joined path runs the same class of check (tags present →
refusal; no logout on a box that was already joined — detect, refuse,
name the repair by hand).
8. **Marker**: after tag verification, write `/etc/rig/role` (mkdir -p
`/etc/rig`) with exactly one line:
`role=$ROLE class=$CLASS host=$HOST join=$JOIN` — cmp-guarded
(write+log only on change; "marker already current" otherwise).
9. Next-steps log keys off traits + role: `control-plane` → coolify install
pointer (as today); `runner` → runner install pointer (as today);
`HOST=yes` → box setup-host pointer (replaces the staging-only branch);
all classes → `rig users apply` pointer, with the class-specific tail:
`class=human` adds "then `rig users close-root` once your admin key
works"; `class=server` adds "root SSH stays — it is the control plane's
automation door".
10. `bin/rig` usage: bootstrap line gains the three roles and trait flags,
one sentence for the trait model.
- [ ] **Step 1: Append failing tests** (`test/cli.sh`, bootstrap section)
```bash
# --- traits: roles are presets, every trait individually settable (#26) -----
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
check "bootstrap: 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
# 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.
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
# A trait override changes derived behavior, provable non-root: dev is
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
# --join login flips it into the TS_AUTHKEY refusal.
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
check "bootstrap: login-path tagged refusal is present" 0 "" \
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
# 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"
```
and in the existing non-root block:
```bash
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
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
```
- [ ] **Step 2: Run `bash test/cli.sh`** — new checks FAIL (unknown role /
unknown flag / missing messages), existing stay green, harness exits 1.
- [ ] **Step 3: Implement** per the behavior contract.
- [ ] **Step 4: `bash test/cli.sh`** — all green, exit 0.
- [ ] **Step 5: shellcheck + syntax** — `shopt -s globstar; shellcheck -x
bin/* **/*.sh`; `bash -n` each edited script.
- [ ] **Step 6: Commit**
```bash
git add commands/bootstrap.sh bin/rig test/cli.sh
git commit -m "feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker"
```
---
### Task 2: `rig users apply` + `rig users status`
**Files:**
- Create: `commands/lib/users-config.sh` (users-file parser, marker reader,
role→group mapping — pure functions, no side effects, sourceable by the
harness)
- Create: `commands/users-apply.sh`, `commands/users-status.sh`
- Modify: `bin/rig` (users subcommand dispatch + usage)
- Modify: `test/cli.sh` (users section)
**Behavior contract:**
1. `commands/lib/users-config.sh`:
- `parse_users_file <path>`: emits normalized `user|roles|key` lines on
stdout, or **all** validation errors on stderr and returns 1. Refusals:
unknown role (naming the valid set `admin rig box`), differing roles
across one user's lines, `root` as username, malformed line (fewer than
3 fields / key not starting `ssh-` or `ecdsa-`), duplicate identical
key line. `#` comments and blanks skipped.
- `read_role_marker <path>`: prints `class=<v>` etc. from the marker;
empty output when absent. No policy here — callers decide.
2. `users-apply.sh` (`rig-users:` prefix):
- Args: `--file <path>` required (`-` = stdin, read once into a temp);
`--help`; unknown flag exit 2. Parse+validate the whole file (exit 2 on
any error, all reported) **before** the root check.
- Root check. Then marker note (never a refusal): `class=server` or no
marker → log that root SSH stays the automation door / advise
bootstrapping a marker, respectively.
- Install `sudo` if missing and any parsed role needs it (`admin`, `rig`).
- `groupadd -f rig-admin rig`; if any user carries `box`, assert group
`incus` exists else die 1 pointing at box `setup-host`.
- Converge each user: `useradd -m -s /bin/bash` if absent, then always
`usermod -L`; membership in the three rig-managed groups exactly
(add and remove; other groups untouched); `~/.ssh/authorized_keys`
written 0700/0600, user-owned, to exactly the file's keys (cmp-guarded).
- Previously managed users absent from the file (diff against
`/etc/rig/users`): `usermod -L`, strip the three rig groups, keep home,
`warn` each. Then rewrite `/etc/rig/users` to the file's users.
- Sudoers: write the two `%rig-admin`/`%rig` rules to a temp, `visudo -c`
against it, then install atomically to `/etc/sudoers.d/rig-roles` 0440
(cmp-guarded; die 1 with the temp preserved for inspection on a
`visudo` failure, sudoers untouched).
- Converged-no-change run says "already converged; no changes".
3. `users-status.sh`: `--help`; root check; reads `/etc/rig/users`, prints
per user: roles (derived from actual group membership), key count from
`authorized_keys`, locked/active. Exits 0 with "no rig-managed users"
when the ledger is absent. Reads only — no network, no writes.
4. `bin/rig`: `users` dispatch (`apply`/`status`/`close-root` → their
scripts; bare or unknown sub → usage exit 2); usage block documents the
three subcommands in the existing voice.
- [ ] **Step 1: Append failing tests** (`test/cli.sh`; fixtures via mktemp,
parser exercised through the sourced lib — precedent: `guard()`/`tags()`):
bare `users` exit 2; `users frobnicate` exit 2; apply `--help` 0;
`--file` required 2; `--file` needs value 2; missing file 2; unknown flag
2; parser fixtures: unknown role (message lists valid set), role mismatch
across lines, `root` refused, malformed line, a valid two-user file parses
(and a multi-error file reports **both** errors in one run); status
`--help` 0; non-root refusals for apply (valid fixture file) and status;
ordering grep: `visudo -c` line precedes the `sudoers.d/rig-roles`
install line in `users-apply.sh`.
- [ ] **Step 2: `bash test/cli.sh`** — new checks fail.
- [ ] **Step 3: Implement** per contract.
- [ ] **Step 4: `bash test/cli.sh`** — green.
- [ ] **Step 5: shellcheck + syntax** (CI invocation).
- [ ] **Step 6: Commit**
```bash
git add commands/lib/users-config.sh commands/users-apply.sh commands/users-status.sh bin/rig test/cli.sh
git commit -m "feat(users): declarative operators — apply/status over a users file, every class"
```
---
### Task 3: `rig users close-root` + bootstrap accepts the closed state
**Files:**
- Create: `commands/users-close-root.sh`
- Modify: `commands/bootstrap.sh` (effective-config assertion accepts
`permitrootlogin no`)
- Modify: `test/cli.sh`
**Behavior contract:**
1. `users-close-root.sh`: `--help` documents the model (human-class only;
verify your admin login in a separate session first) ; unknown flag 2;
root check; then, in order:
- Marker gate via `read_role_marker` (path overridable for tests via
`RIG_ROLE_MARKER`, default `/etc/rig/role`): absent marker → die 1
"no /etc/rig/role marker: re-run rig bootstrap so this box knows what
it is; refusing to shut the root door blind"; `class=server` → die 1
"class=server: root here is the control plane's automation identity —
closing it severs fleet management"; only `class=human` proceeds.
- Admin-door gate: at least one member of `rig-admin` with a non-empty
`~/.ssh/authorized_keys` → else die 1 "no admin user with a key on this
box — run rig users apply first; never close the only door".
- Install `/etc/ssh/sshd_config.d/00-rig-users.conf` containing exactly
`PermitRootLogin no`, cmp-guarded; `sshd -t` on the merged config
BEFORE `systemctl restart ssh`, rollback (remove/restore) on failure —
the bootstrap shape verbatim. Then assert `sshd -T` resolves
`permitrootlogin no` or die.
- Second run: "root already closed; nothing to do", exit 0.
2. `bootstrap.sh`: the `permitrootlogin` assertion regex becomes
`(no|prohibit-password|without-password)` with a comment: `no` is the
post-`close-root` state, strictly harder; bootstrap must never read a
closed door as a broken one (or reopen it — its drop-in loses to
`00-rig-users.conf` by first-wins, which is the point).
- [ ] **Step 1: Append failing tests**
```bash
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
# The whole command rests on first-wins + lexical include order: '-' < '.', so
# 00-rig-users.conf is read before 00-rig.conf. Assert the actual comparison the
# glob makes, so a renamed drop-in cannot silently lose the fight.
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
check "users close-root: drop-in name is the load-bearing one" 0 "" \
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
# Validate-then-apply ordering, greppable (repo precedent: repo-guard ordering).
# sshd -t must precede the restart in file order.
# marker refusals via the sourced lib against fixture markers:
# class=server marker → refusal names the control plane
# absent marker → refusal names bootstrap as the repair
# class=human marker → gate passes (function returns 0)
# non-root: close-root refuses non-root (exit 1) after arg validation.
```
(Exact harness lines mirror the `guard()` fixture pattern; ordering check
mirrors the `guard_at`/`start_at` line-number comparison.)
- [ ] **Step 2: `bash test/cli.sh`** — new checks fail.
- [ ] **Step 3: Implement** per contract.
- [ ] **Step 4: `bash test/cli.sh`** — green.
- [ ] **Step 5: shellcheck + syntax** (CI invocation).
- [ ] **Step 6: Commit**
```bash
git add commands/users-close-root.sh commands/bootstrap.sh test/cli.sh
git commit -m "feat(users): close-root — shut the human-class root door once an admin key works"
```
---
### Task 4: README — identity model, trait tables, users commands
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Write it** (in the README's existing voice):
- Bootstrap section: role list/examples gain `dev`, `workstation`,
`custom`; the trait table and roles-as-presets table from #26; the
marker; the `join=login` story (untagged/user-owned is the *assertion*,
a tag is the refusal).
- New **identity model** subsection: the hybrid access model — operators
on every class, humans never enter as root, `class` decides root SSH's
fate after `rig users apply`; the attribution rationale (network-only
tailnet, no identity broker at the door); the detection side benefit
(any root login that isn't the control plane is anomalous by
definition); the honest caveat (attribution, not privilege reduction —
sudo on a Docker box is root-equivalent).
- `rig users` section: file format, roles table (admin/rig/box,
incus-admin deliberately not a role), apply/status/close-root, the
locked-not-deleted convergence rule, close-root's gates, and the
**README-only** guidance: on `class=server`, lock root's
`authorized_keys` to the control plane with a
`from="<control-plane-addr>"` clause on Coolify's key line (rig will
not write that file — Coolify owns it).
- [ ] **Step 2: Full local gate** — CI shellcheck invocation +
`bash test/cli.sh`.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: README identity model — traits, presets, fleet users, root's two fates"
```
---
## Test Plan
- **Harness (`bash test/cli.sh`, non-root, network-free):** everything in the
per-task steps — trait/flag validation, preset-driven TS_AUTHKEY refusals
(proving both presets and overrides), users-file refusal matrix through the
sourced parser, marker-gate refusals through fixtures, the lexical
drop-in-name assertion, and the two validate-then-apply ordering greps
(`visudo -c` before sudoers install, `sshd -t` before restart).
- **CI:** unchanged `ci.yml` (globstar shellcheck + harness) covers all new
files.
- **Rehearsal (manual, out of harness):** Incus container pair —
1. human-class: `bootstrap dev` (real `tag:local` key) → marker says
`class=human host=yes join=authkey`; `users apply` a two-user file →
users/groups/keys/sudoers as specified; re-apply no-ops; remove a user
→ locked, home intact; `status` truthful; `close-root``sshd -T`
resolves `permitrootlogin no`; re-run bootstrap → green, root stays
closed; drop a user's key, `ssh` as them fails, as the other succeeds.
2. server-class: `bootstrap workload` → marker `class=server`; `users
apply` proceeds (operators exist); `close-root` refuses naming the
control plane.
3. workstation: `bootstrap workstation` with no key → interactive login
join, untagged asserted; with a tagged key's identity → refused and
backed out.
## Addendum (2026-07-17, post-implementation review)
A high-effort adversarial review of the branch diff confirmed ten findings;
all are fixed in the two `fix(...)` commits following the four task commits.
The load-bearing corrections, recorded here because they refine the contracts
above:
- **The `rig` role is not an identity boundary.** `%rig`'s binary-scoped
NOPASSWD was root-equivalent through `sudo rig users apply` (write yourself
into `admin`, escalate). Identity management now gates its *invoker*:
`users apply`/`close-root` refuse a `SUDO_USER` who is not a `rig-admin`
member; direct root proceeds. Documented honestly in the README.
- **Offboarding must out-revoke PAM.** A `!`-locked password does not block
pubkey auth under Debian's `UsePAM`; dropped users are now `usermod -L -e 1`
(account expiry — the switch PAM honors) and their `authorized_keys` is
renamed `.revoked-by-rig` (access revoked, data kept). Present users get
expiry cleared idempotently; the ledger keeps dropped users as
`name revoked` so `status` can tell the truth (`passwd -S` reporting is
gone — every rig password is locked by design, so it said nothing).
- **Perms are converged state.** `.ssh`/`authorized_keys` ownership and mode
converge unconditionally every run; only the content write is cmp-guarded —
StrictModes treats perms as load-bearing, so must apply.
- **`close-root`'s gate is StrictModes-shaped**: per-candidate checks (owner,
group/world-writable bits on home/`.ssh`/`authorized_keys`, real shell,
unexpired account), naming each failed check. It proves the admin door
*should* open, not that it does — the verify-in-a-separate-session advisory
stays load-bearing.
- **`verify_effective_tag` grows the same back-out|keep discipline as
`verify_user_owned`**: an already-joined untagged box (possibly a
legitimate `join=login` machine) is refused *without* `tailscale logout`
never back out state this run did not create. `verify_user_owned` now fails
closed on a poll timeout.
- **The widened `permitrootlogin` acceptance is class-gated**: `no` is
hardened on `class=human`, but on `class=server` it is a refusal naming the
likely leftover `00-rig-users.conf` — a repurposed box must not silently
strand the control plane, and rig will not silently reopen a root door
either.
- **`box` role is trait-aware**: on `host=no` boxes (incus absent by design)
it warns and skips rather than aborting the whole apply — a users file is
fleet-wide; its box grants bind only where VMs live. `host=yes` without
incus still dies at `box setup-host`.
- **Parser hardening**: usernames validate against `^[a-z_][a-z0-9_-]{0,31}$`
in the all-errors-at-once pass (a `|` or useradd-hostile name can no longer
corrupt the stream or die mid-convergence).

View file

@ -47,9 +47,60 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \ check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" runner --ts-tag "$ROOT/commands/bootstrap.sh" runner --ts-tag
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
# The staging tag:server refusal rides the EFFECTIVE tag, inside
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
# rehearsal. What the harness CAN prove is that the refusal exists in the
# shipped script: grep the die message, so a deleted guard cannot ship green
# (the same reason the runner-install repo guard is grepped below).
check "bootstrap: staging effective-tag refusal is present" 0 "" \
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
# --- traits: roles are presets, every trait individually settable (#26) -----
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
check "bootstrap: 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
# 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.
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
# A trait override changes derived behavior, provable non-root: dev is
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
# --join login flips it into the TS_AUTHKEY refusal.
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
check "bootstrap: login-path tagged refusal is present" 0 "" \
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
# Re-running with join=authkey on a box that was legitimately login-joined
# (untagged BY DESIGN) lands in verify_effective_tag's untagged branch. Backing
# out a join this run did not perform would tear down a user-owned workstation;
# the already-joined path must refuse WITHOUT logout and name both repairs.
# Needs a real tailnet to exercise, so grep the keep-mode die instead.
check "bootstrap: already-joined untagged refusal keeps the join" 0 "" \
grep -q "joined but UNTAGGED" "$ROOT/commands/bootstrap.sh"
# verify_user_owned must fail CLOSED on a stalled backend: empty tags is its
# SUCCESS signal, so a 30s poll that never saw Running would wave a tagged node
# through as user-owned. Grep the timeout die (same real-tailnet excuse).
check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
grep -q "could not verify the join is user-owned" "$ROOT/commands/bootstrap.sh"
# 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"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
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
else else
echo "skip: bootstrap non-root refusals (running as root)" echo "skip: bootstrap non-root refusals (running as root)"
fi fi
@ -227,6 +278,186 @@ else
echo "skip: runner status/remove/repoint non-root refusals (running as root)" echo "skip: runner status/remove/repoint non-root refusals (running as root)"
fi fi
check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users
check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate
check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help
check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh"
check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file
check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users
check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope
check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help
# --- users file refusal matrix, through the sourced parser -------------------
# Reaching the parser via the CLI stops at the root check; it is pure and
# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array),
# so the refusals are proven here against fixtures, non-root and network-free.
parse() { # parse <file> — the users-file parser, exactly as apply runs it
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"
parse_users_file "$2"' _ "$ROOT" "$1"
}
FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line
FIX_BAD="$(mktemp)" # rewritten per refusal below
cat > "$FIX_OK" <<'USERS'
# fleet operators
dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop
dan admin,box ssh-ed25519 AAAAC3second dan@desk
maria rig ssh-ed25519 AAAAC3fixture maria@mac
USERS
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD"
check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD"
printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD"
check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD"
printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
printf '%s\n' 'dan admin' > "$FIX_BAD"
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
# Usernames are validated in the same one-pass refusal matrix: 'fo|o' would
# corrupt the parser's own '|'-delimited stream (user 'fo', garbage keys), and
# a leading '-' reads as a useradd flag mid-convergence. The refusal names the
# line and the rule, like every other parser refusal.
printf '%s\n' 'fo|o admin ssh-ed25519 AAAA x' > "$FIX_BAD"
check "users parser: '|' in a username is refused" 1 "invalid username" parse "$FIX_BAD"
check "users parser: the username refusal names the line" 1 "line 1" parse "$FIX_BAD"
printf '%s\n' '-dan admin ssh-ed25519 AAAA x' > "$FIX_BAD"
check "users parser: leading-dash username is refused" 1 "invalid username" parse "$FIX_BAD"
check "users parser: valid file emits dan (both keys' roles agree)" \
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error.
# A single invocation, both messages asserted from its one stderr.
printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD"
MULTI_ERRS="$(mktemp)"
parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$?
check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1
check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS"
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
rm -f "$MULTI_ERRS"
if [ "$(id -u)" -ne 0 ]; then
# A VALID fixture proves the whole file-validation pass sits before the
# root check — a parse failure here would exit 2, not 1.
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
else
echo "skip: users non-root refusals (running as root)"
fi
rm -f "$FIX_OK" "$FIX_BAD"
# Validate-then-apply: `visudo -c` must pass before anything lands in
# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin
# out of the escalation path apply just granted. Assert the order in the file,
# matching the calls rather than comments (repo precedent: the runner-install
# repo-guard ordering check). Defaults fail closed.
visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
check "users apply: visudo -c precedes the sudoers install" \
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
# The invoker gate: %rig's sudoers rule is binary-scoped (NOPASSWD for
# /usr/local/bin/rig, any args), so without a gate `sudo rig users apply
# --file <me-as-admin>` turns role rig root-equivalent through this very
# command. Exercising it needs a real SUDO_USER and real groups, so grep the
# refusal in both identity-management commands (repo precedent: the
# staging/runner tag greps).
check "users apply: invoker gate refusal is present" 0 "" \
grep -q "changes who holds root" "$ROOT/commands/users-apply.sh"
check "users close-root: invoker gate refusal is present" 0 "" \
grep -q "changes who holds root" "$ROOT/commands/users-close-root.sh"
# Offboarding must revoke SSH, not just the password: a '!'-locked password is
# not a closed door under UsePAM — pubkey auth still works. Expiry is the
# switch PAM actually honors, and the keys are renamed, never deleted
# (convergence never destroys). Needs root + real accounts, so grep both moves.
check "users apply: a dropped user's account is expired, not just locked" 0 "" \
grep -qF -- "usermod -L -e 1" "$ROOT/commands/users-apply.sh"
check "users apply: revoked keys are renamed, never deleted" 0 "" \
grep -q "revoked-by-rig" "$ROOT/commands/users-apply.sh"
# A fleet-wide users file must not abort apply on a host=no box just because
# it names a box-role user somewhere in the fleet: the box role binds where
# VMs live, so on host=no it skips (with a warning) and everything else —
# 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"
# --- users close-root: the human-class root-door shutter ---------------------
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
# The whole command rests on first-wins + lexical include order: '-' (0x2D)
# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's
# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the
# glob makes, so a renamed drop-in cannot silently lose the fight.
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
check "users close-root: drop-in name is the load-bearing one" 0 "" \
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
# Validate-then-apply: `sshd -t` on the merged config must precede the restart —
# on a box whose only door is SSH (exactly what this box is about to become),
# bouncing the daemon into a config it refuses to parse leaves no way back in.
# Match the call, not the word (repo precedent: the repo-guard ordering check);
# defaults fail closed.
sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
check "users close-root: sshd -t precedes the ssh restart" \
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
# Convergence is a claim about the DOOR, not the file. Matching bytes can hide
# an earlier-sorting override (first-wins) or a daemon that died between
# install and restart and never read the file — so the no-op message may only
# be spoken after the effective-config assertion (`sshd -T`), and the no-op
# branch may only be TAKEN when the daemon provably started after the last
# change to sshd's config inputs. Pin both: the assert-before-claim ordering,
# and the daemon-start-vs-config-mtime proof's presence.
efft_at="$(grep -n 'sshd -T' "$ROOT/commands/users-close-root.sh" | grep -v '^[0-9]*:#' | head -n1 | cut -d: -f1)"
noop_at="$(grep -n 'nothing to do' "$ROOT/commands/users-close-root.sh" | tail -n1 | cut -d: -f1)"
check "users close-root: no-op claim sits after the effective-config assert" \
0 "" test "${efft_at:-999999}" -lt "${noop_at:-0}"
check "users close-root: no-op needs a daemon start newer than the config" 0 "" \
grep -q "ExecMainStartTimestamp" "$ROOT/commands/users-close-root.sh"
# The admin-door gate must check the StrictModes SHAPE, not file existence: a
# non-empty authorized_keys behind group/world-writable perms is a key sshd
# rejects — closing root behind it welds the only door shut. The full gate
# needs root + real accounts, so grep the load-bearing check's wording.
check "users close-root: gate checks the StrictModes shape" 0 "" \
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
# path sits behind the root check, so the gate is a pure lib function on
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command
# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate
# stays pointable at fixtures.
marker_gate() { # marker_gate <marker_path>
bash -c 'set -euo pipefail
. "$1/commands/lib/users-config.sh"
assert_marker_human "$2"' _ "$ROOT" "$1"
}
MARKER_DIR="$(mktemp -d)"
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server"
printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
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"
check "users close-root: class=human passes the gate" \
0 "" marker_gate "$MARKER_DIR/human"
rm -rf "$MARKER_DIR"
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
echo "skip: users close-root non-root refusal (running as root)"
fi
# Bootstrap must read the closed door as hardened, not broken: `no` is the
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
# the widened assertion so a revert cannot ship green.
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
# ...but only for class=human. On class=server a closed root door is a BROKEN
# box — root SSH is the control plane's automation door — and the usual cause
# is a 00-rig-users.conf left over from a former class=human life. The refusal
# must name that drop-in or the operator greps sshd configs blind; the path
# needs root + a doctored sshd, so grep the die message (repo precedent above).
check "bootstrap: class=server refusal names the stale close-root drop-in" 0 "" \
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/bootstrap.sh"
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax # The dump script ships to control-plane boxes as an embedded heredoc. A syntax
# error in it would be invisible here and would first surface at 04:00 on a live # error in it would be invisible here and would first surface at 04:00 on a live
# control plane. Extract it and syntax-check what actually gets written. # control plane. Extract it and syntax-check what actually gets written.