Merge pull request #37 from dan-claude-bot/feat/bootstrap-roles

feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
This commit is contained in:
Daniel Marin 2026-07-18 21:32:13 +01:00 committed by GitHub
commit e6b630b9f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 892 additions and 116 deletions

153
README.md
View file

@ -2,7 +2,9 @@
A CLI that turns a **pristine Debian server into a hardened, tailnet-joined A CLI that turns a **pristine Debian server into a hardened, tailnet-joined
node** — one curl, one command. A second command installs a version-pinned node** — one curl, one command. A second command installs a version-pinned
Coolify on a control-plane box. Coolify on a control-plane box. And inside a [box](https://github.com/heavy-duty/box)-minted
guest, the same verb converges the **box tenants** — claude, codex, grok,
staging — from thin, creds-free seeds (see *the box tenants* below).
Philosophy (shared with [box](https://github.com/heavy-duty/box)): Philosophy (shared with [box](https://github.com/heavy-duty/box)):
**public tool, private state**. rig carries plumbing logic only — no **public tool, private state**. rig carries plumbing logic only — no
@ -68,19 +70,20 @@ itself is untouched — what bootstrap converged stays converged.
## Commands ## Commands
### `rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>` ### `rig bootstrap <control-plane|workload|runner|dev|workstation|custom>`
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
second run changes nothing. second run changes nothing. (The box TENANT roles — `claude`, `codex`,
`grok`, `staging` — share the verb but are their own family; see *the box
tenants* below.)
```sh ```sh
rig bootstrap control-plane --hostname my-coolify-box rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-box rig bootstrap workload --hostname my-prod-box
rig bootstrap runner --hostname my-ci-box rig bootstrap runner --hostname my-ci-box
rig bootstrap staging --hostname my-vm-host
rig bootstrap dev --hostname my-dev-box rig bootstrap dev --hostname my-dev-box
rig bootstrap workstation --hostname my-laptop rig bootstrap workstation --hostname my-laptop
rig bootstrap custom --hostname odd-duck --class server --host yes --join authkey rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey
``` ```
- `--hostname <name>` — system + tailnet hostname (default: the role name; - `--hostname <name>` — system + tailnet hostname (default: the role name;
@ -107,10 +110,20 @@ presets nothing and requires `--hostname` plus all three traits.
| `control-plane` | server | no | authkey | `tag:server` | | `control-plane` | server | no | authkey | `tag:server` |
| `workload` | server | no | authkey | `tag:server` | | `workload` | server | no | authkey | `tag:server` |
| `runner` | server | no | authkey | `tag:ci` — refuses `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` | | `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` |
| `workstation` | human | yes | login | untagged — any tag refused | | `workstation` | human | yes | login | untagged — any tag refused |
> **Where the `staging` preset went.** Before #31, `staging` was the VM-host
> preset (`class=server host=yes join=authkey`). The name now belongs to the
> box TENANT family — the *guest*, not the host under it — because that is
> what "staging" mostly names in practice (the box the control plane will
> manage), and two meanings on one word was the worse bug. The host shape
> lost nothing: it is one flag away — `rig bootstrap dev --class server
> --hostname my-vm-host` — or fully spelled as `custom --class server --host
> yes --join authkey`. Tag policy is unchanged: mint the host's key with
> `tag:local`; an effective `tag:server` is refused on every role but
> `control-plane` and `workload`.
The tag column is **derived policy, not a fourth trait**: `tag:server` means The tag column is **derived policy, not a fourth trait**: `tag:server` means
"the control plane manages this box", and `control-plane` and `workload` are "the control plane manages this box", and `control-plane` and `workload` are
the only shapes it manages — every other role refuses an effective the only shapes it manages — every other role refuses an effective
@ -208,19 +221,20 @@ grant `tag:server` to repo-controlled code." A runner executes that code, and
the check turns the worst misconfiguration from a documentation warning into a the check turns the worst misconfiguration from a documentation warning into a
hard, post-join error. hard, post-join error.
`staging` is the box that *hosts* staging boxes — Incus VMs minted by the The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by
[`box`](https://github.com/heavy-duty/box) CLI, each converged from inside with the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside
`rig bootstrap workload` and registered in the control plane as its own server. with the tenant roles and (for staging guests) `rig bootstrap workload`
It is `class=server`: an unattended VM appliance — operators converge it and rides the traits since #31 (`--class server --host yes --join authkey`; see
leave; nobody lives there. Mint its key with `tag:local`: the host and its the note above). It is `class=server`: an unattended VM appliance — operators
guests sit on opposite sides of a trust boundary, and the *host* is never converge it and leave; nobody lives there. Mint its key with `tag:local`: the
managed by the control plane — so the role **refuses an effective host and its guests sit on opposite sides of a trust boundary, and the *host*
`tag:server`**, same mechanism as `runner`. is never managed by the control plane — so an effective **`tag:server` is
refused**, same mechanism as `runner`.
On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving
a to-do: after the role marker is written it **installs the `box` CLI globally a to-do: after the role marker is written it **installs the `box` CLI globally
and runs box's own `setup-host`**, so the Incus stack is ready for and runs box's own `setup-host`**, so the Incus stack is ready for
`box new --template staging` when bootstrap returns. rig **delegates to box; it `box new` when bootstrap returns. rig **delegates to box; it
never touches Incus itself** — it does not `apt-get install incus`, does not never touches Incus itself** — it does not `apt-get install incus`, does not
configure the daemon, does not create the `incus` group. It runs box's global configure the daemon, does not create the `incus` group. It runs box's global
installer (`curl … | BOX_YES=1 bash`) as root, and box installs Incus via its installer (`curl … | BOX_YES=1 bash`) as root, and box installs Incus via its
@ -248,11 +262,88 @@ merges box's root install lands in `/root`.)
> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you > fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
> manage by hand. > manage by hand.
`dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local` `dev` is the human-class VM-hosting shape — `tag:local`, box CLI installed as
shape with a person living on it, box CLI installed the same way — and above, a person living on it (`--class server` turns it into the unattended
`workstation` is the machine at the keyboard end of all the SSH connections: VM-host appliance) — and `workstation` is the machine at the keyboard end of
human-class, `join=login`, entering the tailnet as *your* device rather than the all the SSH connections: human-class, `join=login`, entering the tailnet as
fleet's. *your* device rather than the fleet's.
### `rig bootstrap <claude|codex|grok|staging>` — the box tenants
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
guest. Convergent — safe to re-run; a second run changes nothing.
```sh
rig bootstrap claude # or codex, grok — the agent tenants
rig bootstrap staging # the server tenant (docker + sshd hardening)
rig bootstrap claude --user dev # when the seed's BOX_USER differs
```
**The layering** (rig#31 ↔ box#81): a box template stops being where tenant
content lives. box mints a **thin, creds-free seed** — base image, the
`BOX_USER`, rig (+ tmux) preinstalled, and nothing that joins or admits — and
everything the guest *becomes* is a rig tenant role. cloud-init is a first-boot
one-shot: not convergent, not re-runnable, and only parse-and-grep testable.
rig roles are idempotent scripts with effective-state asserts, driven by the
same harness as everything else — and re-runnable on an *existing* box to
converge it to a new spec instead of re-minting it. One convergence engine;
the guests were the hole.
It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh`
holds the whole per-tenant table), not four hand-maintained scripts:
| tenant | user | what lands |
|-----------|----------|------------|
| `claude` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` |
| `codex` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
| `grok` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
| `staging` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) |
Every install is **asserted on effective state**, not exit codes: the CLI must
*answer* (`--version`, run as the tenant user — a CLI that exists but cannot
run has already cost a drill), docker must answer, `sshd -T` must resolve the
hardening. The CLI also lands on the **system** PATH (`/usr/local/bin`):
`box exec <box> -- claude …` runs a non-interactive shell that reads no rc
files, so a PATH export alone is invisible to it.
**Creds-free and non-interactive, by contract.** box auto-runs these at mint
(`box exec … rig bootstrap claude`), so nothing here prompts, joins, or admits
— no tailnet, no keys (the harness pins this by *absence*: no `tailscale`, no
prompt, in the shipped script). The one creds-holding step a staging guest
eventually needs — the tailnet workload join — stays **operator-run**, exactly
as box#69 designed it: `box shell` → `sudo rig bootstrap workload --hostname
<name>` with a single-use tagged pre-auth key. After that join, re-running
`rig bootstrap staging` still converges docker + hardening and leaves the
workload marker alone — the machine role is the truer statement of what the
box became.
**The agent-context file carries the box#80 guard, once.** Every agent tenant
writes its agent's instructions file (`CLAUDE.md` / `AGENTS.md`), rendered
from one shared template: the creds-free contract, the isolation and
disposability facts, and the guard note — **never run `box setup-host`,
`box teardown-host`, or the drill inside a box; the box you are in is not a
host you own**. A nested box stack claims the guest's own uplink subnet and
silently breaks its networking (box#80). The note lives in
`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was
the point of moving it here.
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
host=no` — no `class=`, because a guest has no root-door policy of its own
(`rig users close-root` fails closed on it, by design). The guard runs the
other way too: a box already carrying a **machine** role refuses the agent
tenants outright, and *any* tenant refuses a `host=yes` box — a VM host is
the opposite of a guest, and a pre-#31 staging *host* re-running its old
command is exactly who that refusal catches (it names the new spelling).
> **The rig install in the seed is unpinned — same honesty as the box note
> above.** The seed preinstalls rig via its curl installer, which resolves
> `RIG_REPO`/`RIG_REF` (default `heavy-duty/rig@main`, branches only — rig
> cuts no tags yet). That inverts the install edge on this page: rig installs
> box on host-class machines, and box guests now install rig — both tracking
> a moving `main` until a release flow exists (rig#32). `RIG_REPO`/`RIG_REF`
> are the pin points the day there is something to pin to, or point them at a
> frozen branch of your own fork. The seed side of this edge is box#81's to
> document.
### The identity model ### The identity model
@ -271,10 +362,15 @@ comparison, translated onto the traits that replaced the class binary:
| `control-plane` | server | no | authkey | nobody — Coolify runs here | open — the automation door | | `control-plane` | server | no | authkey | nobody — Coolify runs here | open — the automation door |
| `workload` | server | no | authkey | nobody — deployed services run here | open — the automation door | | `workload` | server | no | authkey | nobody — deployed services run here | open — the automation door |
| `runner` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door | | `runner` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door |
| `staging` | server | yes | authkey | nobody — an unattended VM appliance | open — the automation door |
| `dev` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` | | `dev` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` |
| `workstation` | human | yes | login | its owner | closed by `rig users close-root` | | `workstation` | human | yes | login | its owner | closed by `rig users close-root` |
(The unattended VM-host appliance — formerly the `staging` preset — is the
`class=server host=yes join=authkey` shape: nobody lives there, root SSH stays
open as the automation door. The box TENANT roles sit outside this table on
purpose: a guest is not a tailnet machine, and its marker carries no `class=`,
so `rig users close-root` fails closed on it.)
Who installs what, and who runs as what: **bootstrap is always root** and Who installs what, and who runs as what: **bootstrap is always root** and
installs everything a role needs — on `host=yes` that includes the box CLI installs everything a role needs — on `host=yes` that includes the box CLI
(globally) and box's own `setup-host`. **Humans always run as themselves**: (globally) and box's own `setup-host`. **Humans always run as themselves**:
@ -774,7 +870,14 @@ uninstalls with their absence asserts. The
`rig users` family is covered the same way: the harness drives its refusal `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 matrix — users-file parsing, the marker gates, the lexical drop-in-name
assertion, the validate-then-apply ordering — through the sourced lib assertion, the validate-then-apply ordering — through the sourced lib
functions, non-root and network-free. The end-to-end rehearsal is a throwaway functions, non-root and network-free. The tenant family follows the same
VM/container: pristine Debian → install → `bootstrap workload` with a real split: the harness proves the arg/refusal surface, the marker guards (off
single-use key → assert the sshd drop-in, tailnet join, and a no-op second fixture markers), the pure parameter table, and the rendered agent-context
run → destroy, remove the node from the tailnet. file — guard note included — plus absence-greps for the creds-free contract;
the real converge belongs to the rehearsal. The end-to-end rehearsal is a
throwaway VM/container: pristine Debian → install → `bootstrap workload` with
a real single-use key → assert the sshd drop-in, tailnet join, and a no-op
second run → destroy, remove the node from the tailnet. The tenant rehearsal
is the same shape, creds-free: container + seed user → `rig bootstrap claude`
/ `staging` → assert the CLI answers, docker answers, `sshd -T`, the context
file — then re-run and watch it no-op.

10
bin/rig
View file

@ -13,7 +13,7 @@ usage() {
usage: rig <command> [args] usage: rig <command> [args]
commands: commands:
bootstrap <control-plane|workload|runner|staging|dev|workstation|custom> bootstrap <control-plane|workload|runner|dev|workstation|custom>
[--hostname <name>] [--class <human|server>] [--host <yes|no>] [--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>] [--join <authkey|login>]
OS plumbing on a pristine Debian box: hardening, unattended-upgrades, OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
@ -24,6 +24,14 @@ commands:
only control-plane and workload may carry tag:server. join=login only control-plane and workload may carry tag:server. join=login
(workstation) needs no key: interactive login, node must come up (workstation) needs no key: interactive login, node must come up
untagged. Run as root. untagged. Run as root.
bootstrap <claude|codex|grok|staging> [--user <name>]
The box TENANT roles: converge a box-minted guest. claude|codex|grok
land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
the system PATH, and the agent-context file (with the box#80 guard:
never run box setup-host inside a box). staging lands box#69's server
posture — docker + sshd hardening; its tailnet join stays operator-run
via 'rig bootstrap workload'. Creds-free and non-interactive: box
auto-runs these at mint. Run as root, inside the box.
coolify install --version <pin> coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
coolify backup install [options] coolify backup install [options]

390
commands/bootstrap-tenant.sh Executable file
View file

@ -0,0 +1,390 @@
#!/usr/bin/env bash
# rig bootstrap <claude|codex|grok|staging> — the box TENANT roles: what a
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the
# tenant content that used to live in the templates' cloud-init, idempotent and
# effective-state asserted, so an EXISTING box can be re-run to a new spec
# instead of re-minted. One mechanism, parameterized per tenant through
# lib/tenant-config.sh — never four hand-maintained copies.
#
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
# bootstrap claude'), so every path here is non-interactive and nothing joins
# or admits — no tailnet, no keys, no prompts. staging's tailnet join stays
# operator-run ('rig bootstrap workload' through 'box shell'), exactly the
# creds split box#69 designed.
# Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging tenant)
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <claude|codex|grok|staging> [--user <name>]
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
creds-free seed (base image, user, rig preinstalled); this converges the
tenant on top, and re-runs converge an existing box to a new spec.
claude|codex|grok the agent tenants: base tooling (git, gh, tmux, …),
docker, the agent's CLI on the system PATH, and the
agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box.
staging the server tenant (box#69's posture): docker + sshd
hardening. The tailnet workload join is deliberately
NOT here — it holds a credential, so it stays
operator-run: `box shell``sudo rig bootstrap
workload` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the
role's name; staging defaults to `ops`)
Tenant roles are creds-free and non-interactive by contract — box auto-runs
them at mint (`box exec … rig bootstrap claude`). They take none of the
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest,
not a tailnet machine. Run as root, inside the box.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
claude|codex|grok|staging) shift ;;
-h|--help) usage; exit 0 ;;
"") usage >&2; die "tenant role required (claude|codex|grok|staging)" 2 ;;
*) die "unknown tenant role: $ROLE (want claude|codex|grok|staging)" 2 ;;
esac
TENANT_USER="$(tenant_user "$ROLE")"
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 2 ;;
--hostname|--class|--host|--join)
# The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes
# from the box seed, and the one trait-shaped thing a staging guest
# eventually does (join the tailnet as a workload) is deliberately not
# here: it holds a credential, so it stays operator-run.
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;;
--ts-tag)
[ $# -ge 2 ] && shift
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', where the tag comes from the pre-auth key." 2 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# Same charset the users file enforces, for the same reasons (a leading '-'
# reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
# --- guards ------------------------------------------------------------------
# A tenant role converges a box GUEST. A box already carrying a machine-role
# marker is a tailnet machine rig built on purpose, and quietly turning it into
# a tenant (or clobbering its marker) is how a fleet box gets poisoned. Checked
# BEFORE the root check so the refusals are testable non-root, off fixture
# markers (repo precedent: the coolify marker warning). Two refusals, one
# tolerance:
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
# Names the staging rename out loud — before #31, `staging` was the VM-host
# PRESET; that shape is now spelled through the traits.
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine.
# - class=server with host=no (staging only) → PROCEED, and leave the marker
# alone: that is the staging guest AFTER its operator-run workload join, and
# re-converging docker+hardening on it is exactly what convergence is for.
# ONLY that shape — any other class (say class=human, via `custom`) is a
# machine rig built on purpose, and staging hardening it with server rules
# would die with server-specific messaging on a box that was never one.
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
case "$EXISTING_MARKER" in
*host=yes*)
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;;
*class=*)
if [ "$ROLE" != "staging" ]; then
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
fi
case "$EXISTING_MARKER" in
*class=server*) ;;
*)
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging tolerates only the workload-joined guest (class=server host=no). If this really is a staging guest, remove ${MARKER_PATH} and re-run." ;;
esac ;;
esac
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
# The tenant user is the SEED's to create (box.env BOX_USER + cloud-init), not
# rig's to conjure: a missing user means the seed and the role disagree, and
# inventing an account here would paper over exactly that mismatch.
id -u "$TENANT_USER" >/dev/null 2>&1 \
|| die "user '$TENANT_USER' does not exist — the box seed creates it (BOX_USER); pass --user <name> if this box's user differs"
TENANT_HOME="$(getent passwd "$TENANT_USER" | cut -d: -f6)"
TENANT_GROUP="$(id -gn "$TENANT_USER")"
[ -d "$TENANT_HOME" ] || die "user '$TENANT_USER' has no home directory ($TENANT_HOME)"
# append_line_once <file> <line> — converge a literal rc line: present exactly
# once, appended only when missing, ownership converged to the tenant user.
append_line_once() {
local file="$1" line="$2"
if [ ! -e "$file" ] || ! grep -qxF "$line" "$file"; then
printf '%s\n' "$line" >> "$file"
log "appended to ${file}: ${line}"
fi
chown "$TENANT_USER:$TENANT_GROUP" "$file"
}
# --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})"
apt-get update -qq
case "$ROLE" in
claude)
# The claude tenant keeps zsh (its shell UX ships with the box); the
# remaining list is the shared agent toolbelt the templates carried.
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;;
codex|grok)
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
staging)
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
esac
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
# the operator's git credential lands.
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
if [ "$ROLE" != "staging" ]; then
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
command -v git >/dev/null 2>&1 || die "git missing after package install"
fi
# --- docker ------------------------------------------------------------------
# Every tenant gets docker (the templates all carried it; staging's guests run
# their workloads in it). Docker's own installer, convergence-guarded — its
# script is not a no-op when docker exists, so rig supplies the guard.
if ! command -v docker >/dev/null 2>&1; then
log "installing docker (get.docker.com)"
curl -fsSL https://get.docker.com | sh
else
log "docker already installed"
fi
docker --version >/dev/null 2>&1 || die "docker installed but 'docker --version' does not answer"
# The client answering is not the effective state — a dead dockerd would still
# pass it. Ask the daemon, with a bounded settle for the freshly-installed case
# (get.docker.com starts it, but not instantaneously on a slow guest).
docker_up=""
for _ in 1 2 3 4 5 6; do
if docker info >/dev/null 2>&1; then docker_up=1; break; fi
sleep 5
done
[ -n "$docker_up" ] || die "dockerd does not answer 'docker info' after 30s — the daemon is not running; check 'systemctl status docker' (or the container's init) before re-running"
log "dockerd answering"
if getent group docker >/dev/null 2>&1; then
if id -nG "$TENANT_USER" | tr ' ' '\n' | grep -qx docker; then
log "${TENANT_USER} already in the docker group"
else
usermod -aG docker "$TENANT_USER"
log "added ${TENANT_USER} to the docker group"
fi
else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi
# --- node (claude, codex) ----------------------------------------------------
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified
# upstream when the template was written); the claude tenant ships node as part
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
node_ok() {
command -v node >/dev/null 2>&1 || return 1
local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null
}
if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then
if node_ok; then
log "node $(node --version) already present"
else
log "installing node 22 (nodesource)"
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
node_ok || die "node >= 22 still missing after install — check the nodesource setup"
fi
# --- the agent CLI -----------------------------------------------------------
# Per-agent install, shared discipline: install only when the CLI is absent
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
# what cost the last drill (the grok template's scar).
CLI="" CLI_SRC=""
case "$ROLE" in
claude)
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Claude Code CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
else
log "claude CLI already installed"
fi ;;
codex)
CLI=codex
if ! command -v codex >/dev/null 2>&1; then
log "installing the Codex CLI (npm global)"
npm install -g @openai/codex
else
log "codex CLI already installed"
fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
grok)
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
# Run it AS the tenant user, never root: a symlink into root's 0700 home
# would be a CLI that exists and cannot run.
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Grok CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
else
log "grok CLI already installed"
fi ;;
staging) ;; # no agent lives on the staging tenant
esac
if [ -n "$CLI" ]; then
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# One capture serves both the assert and the log line; emptiness IS the
# failure signal (head exits 0 regardless, so a pipeline status can't be).
CLI_VER="$(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1)"
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
# The interactive-shell PATH exports the templates carried, converged as
# literal rc lines (written once, never duplicated). Single quotes are the
# point: the line must expand in the USER's shell, not here.
# shellcheck disable=SC2016
case "$ROLE" in
claude)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
codex)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
grok)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
esac
fi
# --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. Rendered from
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
# the drill inside a box; the box you are in is not a host you own") lives
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
# like every file rig converges.
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
CTX_DIR="$(dirname "$CTX_PATH")"
if [ ! -d "$CTX_DIR" ]; then
mkdir -p "$CTX_DIR"
log "created ${CTX_DIR}"
fi
# The dotdir is the AGENT's (it writes state next to its instructions), so
# its ownership is converged on every run, not only on creation.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
CTX_TMP="$(mktemp)"
render_tenant_context "$ROLE" > "$CTX_TMP"
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
log "agent-context file written: ${CTX_PATH}"
else
log "agent-context file already current"
fi
rm -f "$CTX_TMP"
fi
# --- claude shell niceties ---------------------------------------------------
# The claude template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a
# bootstrap whose real work (CLI, context, docker) already converged.
if [ "$ROLE" = "claude" ]; then
if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then
chsh -s /usr/bin/zsh "$TENANT_USER"
log "login shell set to zsh for ${TENANT_USER}"
else
log "login shell already zsh for ${TENANT_USER}"
fi
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
log "installing oh-my-zsh for ${TENANT_USER}"
# Single quotes on purpose: the $(...) must run in the USER's shell.
# shellcheck disable=SC2016
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
else
log "oh-my-zsh already installed"
fi
# After oh-my-zsh (it rewrites .zshrc on first install).
# shellcheck disable=SC2016
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
fi
# --- staging server posture --------------------------------------------------
# box#69's posture, minus the join: docker (above) + sshd hardening, through
# the SAME code the machine roles use (lib/sshd.sh) — the staging guest is a
# workload server in waiting, and its door must never be password-open even
# before the operator joins it. class=server: root SSH stays the control
# plane's future automation door.
if [ "$ROLE" = "staging" ]; then
harden_sshd server
fi
# --- role marker --------------------------------------------------------------
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant
# has no root-door policy of its own — close-root fails closed on it), and
# host=no so `rig users apply` box-role gating keeps working. staging SKIPS the
# write when a machine marker is already present: after the operator-run
# workload join, the workload marker is the truer statement and rig never
# clobbers state a joined box earned.
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then
MARKER_TMP="$(mktemp)"
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
mkdir -p "$(dirname "$MARKER_PATH")"
install -m 0644 "$MARKER_TMP" "$MARKER_PATH"
log "role marker written: role=$ROLE tenant=yes host=no"
else
log "role marker already current"
fi
rm -f "$MARKER_TMP"
else
log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
fi
log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname <name> with a tagged pre-auth key"
else
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
fi

View file

@ -6,6 +6,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/runner-config.sh # shellcheck source=SCRIPTDIR/lib/runner-config.sh
. "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap . "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant
log() { printf 'rig-bootstrap: %s\n' "$*"; } log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
@ -13,9 +15,11 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom> usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom>
[--hostname <name>] [--class <human|server>] [--hostname <name>] [--class <human|server>]
[--host <yes|no>] [--join <authkey|login>] [--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude|codex|grok|staging> [--user <name>]
(the box TENANT roles — see their own --help)
--hostname system + tailnet hostname (default: the role name; custom has --hostname system + tailnet hostname (default: the role name; custom has
no default and requires it) no default and requires it)
@ -32,10 +36,13 @@ custom presets nothing and requires --hostname plus all three traits.
control-plane server no authkey control-plane server no authkey
workload server no authkey workload server no authkey
runner server no authkey runner server no authkey
staging server yes authkey
dev human yes authkey dev human yes authkey
workstation human yes login workstation human yes login
The former staging VM-host preset is now spelled through the traits:
'custom --class server --host yes --join authkey' (or 'dev --class server').
'staging' names the box TENANT role today — the guest, not the host.
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags, The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
so the key is the single source of truth: rig no longer requests a tag it might so the key is the single source of truth: rig no longer requests a tag it might
disagree with. After the box joins, rig reads the tag control actually GRANTED disagree with. After the box joins, rig reads the tag control actually GRANTED
@ -56,10 +63,16 @@ EOF
# --- args (validated before the root check, so errors are testable) --------- # --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}" ROLE="${1:-}"
case "$ROLE" in case "$ROLE" in
control-plane|workload|runner|staging|dev|workstation|custom) shift ;; control-plane|workload|runner|dev|workstation|custom) shift ;;
claude|codex|grok|staging)
# The box TENANT roles (#31) are a different family — guests a box mints,
# never tailnet machines — and live in their own mechanism, one script
# parameterized per tenant. Dispatched here so `rig bootstrap <role>`
# stays the single entrypoint for both families.
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
"") usage >&2; die "role required (control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;; "") usage >&2; die "role required (control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
*) die "unknown role: $ROLE (want control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;; *) die "unknown role: $ROLE (want control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
esac esac
# Role→traits map — the single place a role's shape is declared (issue #26). # Role→traits map — the single place a role's shape is declared (issue #26).
@ -71,7 +84,6 @@ case "$ROLE" in
control-plane) CLASS=server HOST=no JOIN=authkey ;; control-plane) CLASS=server HOST=no JOIN=authkey ;;
workload) CLASS=server HOST=no JOIN=authkey ;; workload) CLASS=server HOST=no JOIN=authkey ;;
runner) 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 ;; dev) CLASS=human HOST=yes JOIN=authkey ;;
workstation) CLASS=human HOST=yes JOIN=login ;; workstation) CLASS=human HOST=yes JOIN=login ;;
custom) ;; custom) ;;
@ -182,74 +194,13 @@ APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::Unattended-Upgrade "1";
EOF EOF
# --- sshd hardening (restart only when the drop-in actually changed) --------- # --- sshd hardening ----------------------------------------------------------
# The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins # The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
# ("for each keyword, the first obtained value will be used" — sshd_config(5)), # one drop-in, one converger, never two copies drifting apart. Everything the
# and Include expands the glob in lexical order. Cloud images ship # block learned the hard way (00- beats cloud-init's 50- under first-wins,
# /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication # validate-then-restart, assert sshd -T not the file, the class-gated
# yes`, so the old 99-rig.conf was read second and silently lost every keyword # permitrootlogin acceptance) moved with it, verbatim.
# it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped harden_sshd "$CLASS"
# was still serving `passwordauthentication yes`. The Incus rehearsal never
# caught it — a pristine Debian container has no cloud-init drop-in.)
DROPIN=/etc/ssh/sshd_config.d/00-rig.conf
LEGACY_DROPIN=/etc/ssh/sshd_config.d/99-rig.conf
TMP="$(mktemp)"
cat > "$TMP" <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
EOF
if ! cmp -s "$TMP" "$DROPIN" 2>/dev/null || [ -e "$LEGACY_DROPIN" ]; then
BACKUP=""
[ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; }
install -m 0644 "$TMP" "$DROPIN"
rm -f "$LEGACY_DROPIN" # sweep the losing file from already-bootstrapped boxes
# Validate the MERGED config BEFORE bouncing the daemon. On a box whose only
# door is SSH, `systemctl restart ssh` against a config sshd refuses to parse
# leaves no listener and no way back in. `sshd -t` parses everything sshd
# would parse — our drop-in, cloud-init's, and any third-party file — so a
# broken neighbour is caught here rather than after the door has shut.
if ! sshd -t 2>/dev/null; then
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
rm -f "$TMP" "$BACKUP"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
fi
rm -f "$BACKUP"
systemctl restart ssh
log "sshd hardening drop-in installed"
else
log "sshd hardening drop-in already in place"
fi
rm -f "$TMP"
# Assert the EFFECTIVE config, not the file's existence — asserting the file is
# what let the first-wins bug ship green. `sshd -T` is what the daemon actually
# resolved, cloud-init and all.
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
# things on the two classes. class=human: `no` is the post-`rig users
# close-root` state — strictly harder than the prohibit-password this 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 ---------------------------------------------------------- # --- system hostname ----------------------------------------------------------
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig # Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
@ -342,9 +293,11 @@ verify_effective_tag() {
control-plane|workload) ;; control-plane|workload) ;;
runner) 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)." ;; 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." ;;
*) *)
# This arm now also owns the VM-host shape the old staging preset
# covered (custom/dev --class server): a host is never managed by the
# control plane — its guest VMs are — so tag:server is refused there
# like everywhere else outside control-plane|workload.
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;; 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 esac
fi fi

83
commands/lib/sshd.sh Normal file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Shared sshd hardening — sourced by bootstrap.sh (machine roles) and by
# bootstrap-tenant.sh (the staging tenant). Root-requiring, unlike the pure
# parsing libs: it converges /etc/ssh and bounces the daemon. Extracted so the
# two roles converging ONE drop-in stay literally the same code — two copies of
# a hardening block is drift by construction, the same law that keeps rig's
# hands off Incus. Callers provide log/warn/die.
# harden_sshd <human|server> — install the 00-rig.conf hardening drop-in,
# validate the merged config before touching the daemon, restart only when the
# drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the
# permitrootlogin acceptance gated on the class passed in.
harden_sshd() {
local class="$1"
local dropin=/etc/ssh/sshd_config.d/00-rig.conf
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
local tmp backup eff
# The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins
# ("for each keyword, the first obtained value will be used" — sshd_config(5)),
# and Include expands the glob in lexical order. Cloud images ship
# /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication
# yes`, so the old 99-rig.conf was read second and silently lost every keyword
# it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped
# was still serving `passwordauthentication yes`. The Incus rehearsal never
# caught it — a pristine Debian container has no cloud-init drop-in.)
tmp="$(mktemp)"
cat > "$tmp" <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
EOF
if ! cmp -s "$tmp" "$dropin" 2>/dev/null || [ -e "$legacy_dropin" ]; then
backup=""
[ -e "$dropin" ] && { backup="$(mktemp)"; cp -a "$dropin" "$backup"; }
install -m 0644 "$tmp" "$dropin"
rm -f "$legacy_dropin" # sweep the losing file from already-bootstrapped boxes
# Validate the MERGED config BEFORE bouncing the daemon. On a box whose only
# door is SSH, `systemctl restart ssh` against a config sshd refuses to parse
# leaves no listener and no way back in. `sshd -t` parses everything sshd
# would parse — our drop-in, cloud-init's, and any third-party file — so a
# broken neighbour is caught here rather than after the door has shut.
if ! sshd -t 2>/dev/null; then
if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi
rm -f "$tmp" "$backup"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
fi
rm -f "$backup"
systemctl restart ssh
log "sshd hardening drop-in installed"
else
log "sshd hardening drop-in already in place"
fi
rm -f "$tmp"
# Assert the EFFECTIVE config, not the file's existence — asserting the file is
# what let the first-wins bug ship green. `sshd -T` is what the daemon actually
# resolved, cloud-init and all.
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/"
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
# things on the two classes. class=human: `no` is the post-`rig users
# close-root` state — strictly harder than the prohibit-password this function
# installs. Hardening must never read a closed door as a broken one, and it
# cannot reopen one either: by first-wins its own drop-in loses to
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
# 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)"
}

View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Shared parameters for the box TENANT roles (claude, codex, grok, staging) —
# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no
# side effects: the per-tenant differences live HERE, in one table, so the
# mechanism stays one script parameterized per tenant instead of four
# hand-maintained copies (repo precedent: parse_users_file, runner-config).
# tenant_user <role> — the user the box seed creates (box.env BOX_USER). The
# agent tenants are named after their agent; staging keeps box#69's `ops`.
tenant_user() {
case "$1" in
claude) printf 'claude' ;;
codex) printf 'codex' ;;
grok) printf 'grok' ;;
staging) printf 'ops' ;;
*) return 1 ;;
esac
}
# tenant_context_path <role> <home> — where the agent-context file lands. Each
# agent CLI reads its own instructions file from its own dotdir; staging has no
# agent and no context file (return 1).
tenant_context_path() {
case "$1" in
claude) printf '%s/.claude/CLAUDE.md' "$2" ;;
codex) printf '%s/.codex/AGENTS.md' "$2" ;;
grok) printf '%s/.grok/AGENTS.md' "$2" ;;
*) return 1 ;;
esac
}
# render_tenant_context <role> — the agent-context file's content, on stdout.
# One renderer for all three agents: only the creds paragraph is per-vendor,
# and the box#80 guard note lives HERE once — never copy-pasted per template.
# staging renders nothing (return 1): no agent lives there.
render_tenant_context() {
local role="$1" creds
# The single-quoted markdown below carries literal `$`-free backtick prose;
# single quotes are deliberate — nothing in it may expand here.
# shellcheck disable=SC2016
case "$role" in
claude)
creds='- **Creds-free by default.** The box starts with no Claude and no git
credentials. If you need to authenticate Claude, the operator runs `/login`
interactively. For git, the operator adds their own credentials (a PAT or
`gh auth login`). Never assume credentials are present; never ask for or
store secrets on disk beyond what the operator sets up.' ;;
codex)
creds='- **Creds-free by default.** The box starts with no OpenAI and no git
credentials. If you need to authenticate Codex, the operator runs the
login flow (`codex`) interactively. For git, the operator adds their own
credentials (a PAT or `gh auth login`). Never assume credentials are
present; never ask for or store secrets on disk beyond what the operator
sets up.' ;;
grok)
creds='- **Creds-free by default.** The box starts with no xAI and no git
credentials. If you need to authenticate, the operator runs
`grok login` interactively (SuperGrok / X Premium+). For git, the
operator adds their own credentials (a PAT or `gh auth login`). Never
assume credentials are present; never ask for or store secrets on disk
beyond what the operator sets up.' ;;
*) return 1 ;;
esac
cat <<EOF
# You are running inside a box (tenant: ${role})
A box is a trust-less, network-isolated, ephemeral VM created by the
\`box\` CLI. Keep this context in mind:
${creds}
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via \`box snapshot\`.
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
or the drill inside a box. The box you are in is not a host you own: a
nested box stack claims the guest's own uplink subnet and gateway, and
silently breaks this box's networking with intermittent egress blackouts
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
and testing never needs the host stack; host setup belongs to the operator's
machine, never this one.
- **Bootstrap runbook.** If the repository you are working in contains a
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
runbook — how to install dependencies, start services, template environment
files, seed data, and smoke-test — and follow it. It is documentation for
you, not a script the host runs.
EOF
}

View file

@ -47,15 +47,17 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \ check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" runner --ts-tag "$ROOT/commands/bootstrap.sh" runner --ts-tag
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \ # staging is a box TENANT role since #31 (the guest, not the VM host), and it
# never joins the tailnet — but --ts-tag on it must still die with a story,
# not an "unknown flag": scripts from its trait-preset life may pass it, and
# the message must say where both the tag AND the join went.
check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server "$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
# The staging tag:server refusal rides the EFFECTIVE tag, inside # The old staging effective-tag refusal guarded the VM-HOST shape, which now
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the # rides the traits (custom/dev --class server) — the catch-all tag:server
# rehearsal. What the harness CAN prove is that the refusal exists in the # refusal must still own that shape, so grep the general die instead.
# shipped script: grep the die message, so a deleted guard cannot ship green check "bootstrap: the catch-all tag:server refusal is present" 0 "" \
# (the same reason the runner-install repo guard is grepped below). grep -q "Only control-plane and workload are managed by the control plane" "$ROOT/commands/bootstrap.sh"
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) ----- # --- traits: roles are presets, every trait individually settable (#26) -----
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
@ -178,7 +180,13 @@ check "README: points at heavy-duty/box" 0 "" \
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging # staging dispatches to the tenant mechanism now; reaching ITS root check
# through bootstrap.sh proves the dispatch and the tenant arg pass in one go.
# RIG_ROLE_MARKER points at an absent fixture: the tenant marker guard runs
# before the root check, and the machine running this harness may well have
# a real /etc/rig/role of its own.
check "bootstrap: staging dispatches to the tenant mechanism, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER=/nonexistent/rig-role "$ROOT/commands/bootstrap.sh" staging
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev 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: 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" \ check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
@ -187,6 +195,137 @@ else
echo "skip: bootstrap non-root refusals (running as root)" echo "skip: bootstrap non-root refusals (running as root)"
fi fi
# --- box tenant roles (#31): claude|codex|grok|staging ------------------------
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
# parameterized per tenant through lib/tenant-config.sh, dispatched from
# bootstrap.sh so `rig bootstrap <role>` stays the single entrypoint. The real
# converge needs root, a tenant user, and the network — the container
# rehearsal's job — so the harness proves what it can non-root: the whole
# arg/refusal surface, the pure parameter table, the rendered agent-context
# file (guard note included), and grep-pins on the shipped script.
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help
check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude --nope
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude --user
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude --user 'fo|o'
# The docker converge asserts the DAEMON answers, not just the client binary —
# a dead dockerd passing `docker --version` is the "linked but cannot run"
# scar in daemon form. Grep-pinned so the assert cannot ship deleted.
check "tenant: dockerd effective-state assert is present" 0 "" \
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
# The machine-role traits die with the tenant story, never "unknown flag" — an
# operator reaching for --hostname must learn where the trait family went.
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" claude --class human
check "tenant: --hostname dies the same way" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" staging --hostname my-guest
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant
# mechanism with args intact (--help reaching the TENANT usage proves both).
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude|codex|grok|staging" \
"$ROOT/commands/bootstrap.sh" claude --help
# The marker guard fires BEFORE the root check (repo precedent: the coolify
# marker warning), so the refusals are provable here off fixture markers. A
# VM host (host=yes) refuses for every tenant — and names the staging rename,
# because a pre-#31 staging HOST re-running its old command is exactly who
# lands here. An agent tenant refuses ANY machine-role box; staging tolerates
# ONLY class=server with host=no — that is the staging guest after its
# operator-run workload join, and re-converging it is what convergence is for.
# A non-server machine (class=human via custom) is NOT that guest, and server
# hardening would die at it with server-specific messaging — refuse instead.
TEN_FIX="$(mktemp -d)"
printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host"
printf 'role=workload class=server host=no join=authkey\n' > "$TEN_FIX/machine"
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human"
printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant"
check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \
env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging
check "tenant: refuses a host=yes box (a VM host is never a guest)" 1 "hosts VMs" \
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude
check "tenant: the host refusal names the old staging preset's new spelling" 1 "custom --class server --host yes" \
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude
if [ "$(id -u)" -ne 0 ]; then
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
# the root check, and the harness machine may carry a real /etc/rig/role.
check "tenant: claude parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude
check "tenant: codex parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex
check "tenant: grok parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok
check "tenant: staging tolerates a workload-joined guest's marker" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude
else
echo "skip: tenant non-root refusals (running as root)"
fi
rm -rf "$TEN_FIX"
# The per-tenant parameter table and the agent-context renderer are pure lib
# functions on purpose (repo precedent: parse_users_file, json_string_array):
# the CLI path to them sits behind root + a real tenant user, so the harness
# proves them here, sourced, non-root and network-free.
tuser() { bash -c 'set -euo pipefail
. "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; }
tpath() { bash -c 'set -euo pipefail
. "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; }
tctx() { bash -c 'set -euo pipefail
. "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; }
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude /home/claude
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex /home/codex
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok /home/grok
check "tenant params: staging has no context file" 1 "" tpath staging /home/ops
# The box#80 guard note lives ONCE, in the renderer, and every agent's file
# carries it — the layering decision's whole point: never per-template again.
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex
check "tenant context: grok names its login flow" 0 "grok login" tctx grok
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
# apt-installs incus. A grep that finds nothing (exit 1) is the pass.
check "tenant: never touches the tailnet" 1 "" \
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: non-interactive — nothing prompts" 1 "" \
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
# staging's posture rides the SAME hardening code as the machine roles — the
# shared lib call is the anti-drift property, so pin the call, not the words.
check "tenant: staging hardens through the shared sshd lib" 0 "" \
grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: docker lands via docker's own installer" 0 "" \
grep -q "get.docker.com" "$ROOT/commands/bootstrap-tenant.sh"
# The #15 lesson pinned: 'box exec' shells read no rc files, so the CLI must
# land on the SYSTEM path — and a claimed install is verified, not trusted:
# it must ANSWER as the tenant user (the grok template's scar: linked but
# cannot run). The $CLI/$TENANT_USER are literals we grep for in the script.
# shellcheck disable=SC2016
check "tenant: the agent CLI lands on the system PATH" 0 "" \
grep -qF '/usr/local/bin/$CLI' "$ROOT/commands/bootstrap-tenant.sh"
# shellcheck disable=SC2016
check "tenant: the CLI install is verified as the tenant user" 0 "" \
grep -qF 'runuser -l "$TENANT_USER" -c "$CLI --version"' "$ROOT/commands/bootstrap-tenant.sh"
# Ordering is the safety property, as with bootstrap's marker-then-box assert:
# the tenant marker may only describe converges that already happened, so the
# write sits after the context-file converge. Defaults fail closed.
ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
# shellcheck disable=SC2016
ten_marker_at="$(grep -nF 'install -m 0644 "$MARKER_TMP" "$MARKER_PATH"' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
check "tenant: the marker write follows the context-file converge" \
0 "" test "${ten_ctx_at:-999999}" -lt "${ten_marker_at:-0}"
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
@ -743,16 +882,28 @@ else
fi fi
# Bootstrap must read the closed door as hardened, not broken: `no` is the # 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 # post-close-root state, strictly harder than what bootstrap installs. Byte-grep
# the widened assertion so a revert cannot ship green. # the widened assertion so a revert cannot ship green. The hardening block
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \ # lives in lib/sshd.sh since #31 — ONE converger shared by the machine roles
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh" # and the staging tenant — so the greps pin the lib, and a call-site grep pins
# that bootstrap actually runs it (a function nobody calls is not hardening).
check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/lib/sshd.sh"
# ...but only for class=human. On class=server a closed root door is a BROKEN # ...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 # 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 # 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 # must name that drop-in or the operator greps sshd configs blind; the path
# needs root + a doctored sshd, so grep the die message (repo precedent above). # needs root + a doctored sshd, so grep the die message (repo precedent above).
check "bootstrap: class=server refusal names the stale close-root drop-in" 0 "" \ check "sshd lib: 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" grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/lib/sshd.sh"
# Validate-then-apply survived the extraction: sshd -t on the merged config
# must still precede the restart (same idiom as the close-root ordering check).
libt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)"
librestart_at="$(grep -nE '^[[:space:]]*systemctl restart ssh$' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)"
check "sshd lib: sshd -t precedes the ssh restart" \
0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}"
# shellcheck disable=SC2016
check "bootstrap: hardening runs through the shared lib" 0 "" \
grep -qE '^harden_sshd "\$CLASS"$' "$ROOT/commands/bootstrap.sh"
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax # The dump script ships to control-plane boxes as an embedded heredoc. A syntax
# error in it would be invisible here and would first surface at 04:00 on a live # error in it would be invisible here and would first surface at 04:00 on a live