Merge pull request #27 from dan-claude-bot/feat/users-and-traits

Machine traits + fleet users: class/host/join presets and rig users apply/status/close-root (#26 + #24)
This commit is contained in:
Daniel Marin 2026-07-17 22:24:50 +01:00 committed by GitHub
commit be3761a5df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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
### `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
second run changes nothing.
@ -29,9 +29,62 @@ second run changes nothing.
rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-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
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`
> 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
**tagged** part is now load-bearing, not advice (see below). It lives in process
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
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>`
Control-plane box only. Installs Coolify at exactly the pinned version with
@ -386,6 +484,141 @@ prints the exact `runner install` line that finishes the job.
Convergent — repointing to the repo it is already on changes nothing, exits 0,
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
- **Provider firewalls** — Docker publishes ports past host firewalls, so
@ -400,7 +633,10 @@ and never asks for a token.
## Testing
`bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. 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.
`rig users` family is covered the same way: the harness drives its refusal
matrix — users-file parsing, the marker gates, the lexical drop-in-name
assertion, the validate-then-apply ordering — through the sourced lib
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]
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,
tailscale join. Prompts for a single-use tailnet pre-auth key
(TS_AUTHKEY env overrides the prompt). Run as root. Role runner
defaults to tag:ci and refuses tag:server.
tailscale join. Roles are presets over the three traits; any flag
overrides its trait, and custom states all of them. Prompts for a
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
prompt); the key's tags are the tailnet tag, verified after join —
only control-plane and workload may carry tag:server. join=login
(workstation) needs no key: interactive login, node must come up
untagged. Run as root.
coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
coolify backup install [options]
@ -41,6 +47,18 @@ commands:
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.
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:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
@ -114,6 +132,28 @@ case "$cmd" in
;;
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)
usage
exit 0

View file

@ -13,36 +13,99 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
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,
so the key is the single source of truth: rig no longer requests a tag it might
disagree with. After the box joins, rig reads the tag control actually GRANTED
(tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
outright, and 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
enter it at the interactive prompt. It is used once and never written to disk.
join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
env var, or enter it at the interactive prompt. Used once, never written to disk.
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
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
control-plane|workload|runner) shift ;;
control-plane|workload|runner|staging|dev|workstation|custom) shift ;;
-h|--help) usage; exit 0 ;;
"") usage >&2; die "role required (control-plane|workload|runner)" 2 ;;
*) die "unknown role: $ROLE (want 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|staging|dev|workstation|custom)" 2 ;;
esac
# Role→traits map — the single place a role's shape is declared (issue #26).
# Roles are presets, nothing more: every behavior below keys off the traits,
# so a flag override changes behavior without a new role, and custom exists
# for the shape nobody foresaw — it declares nothing and must state all three.
CLASS="" HOST="" JOIN=""
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"
[ "$ROLE" = "custom" ] && TS_HOSTNAME=""
while [ $# -gt 0 ]; do
case "$1" in
--hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 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 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,
@ -57,6 +120,24 @@ while [ $# -gt 0 ]; do
esac
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 ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
@ -71,6 +152,12 @@ if [ -r /etc/os-release ]; then
else
warn "cannot read /etc/os-release; proceeding anyway"
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
# 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"
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/"
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
# things on the two classes. class=human: `no` is the post-`rig users
# close-root` state — strictly harder than the prohibit-password this 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)"
# --- system hostname ----------------------------------------------------------
@ -179,8 +284,15 @@ fi
# 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
# 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() {
local deadline=$((SECONDS + 30)) tags="" state="" json
local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json
json="$(mktemp)"
while :; do
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
# 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,
# 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 [ "$mode" = "back-out" ]; then
tailscale logout >/dev/null 2>&1 \
|| 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
# Role policy now rides the EFFECTIVE tag — strictly stronger than the old
# request-time check, which only guarded the tag rig HOPED for. This guards the
# tag the key ACTUALLY granted to repo-controlled code: a runner carrying
# tag:server would extend every grant your servers hold to CI code. Refused,
# never warned. rig can DETECT this but cannot FIX it, so name the repair.
if [ "$ROLE" = "runner" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then
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)."
# tag:server policy is DERIVED, not a trait: it means "the control plane
# manages this box", and only control-plane and workload are shapes the
# control plane manages. Everything else refuses it on the EFFECTIVE tag —
# strictly stronger than the old request-time check, which only guarded the
# tag rig HOPED for. The fleet has been bitten both ways: a runner carrying
# tag:server extends every server grant to repo-controlled code, and a
# staging host carrying it extends them to a box the control plane does not
# even know. Refused, never warned; rig can DETECT this but cannot FIX it,
# so each refusal names its repair.
if printf '%s\n' "$tags" | grep -qx 'tag:server'; then
case "$ROLE" in
control-plane|workload) ;;
runner)
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
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
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
log "installing tailscale"
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
# deliberate and stays — re-running an identical tagged-authkey `up` errors —
# 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
# env override, else prompt; never touches disk
if [ -z "${TS_AUTHKEY:-}" ]; then
@ -264,12 +455,40 @@ else
# 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)"
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
verify_effective_tag
verify_effective_tag back-out
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}"
if [ "$ROLE" = "control-plane" ]; then
log "next: rig coolify install --version <pin>"
elif [ "$ROLE" = "runner" ]; then
log "next: rig runner install --repo <owner/repo> --version <pin>"
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
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
"$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
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: 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
echo "skip: bootstrap non-root refusals (running as root)"
fi
@ -273,6 +324,186 @@ else
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
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
# 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.