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:
commit
e6b630b9f2
7 changed files with 892 additions and 116 deletions
153
README.md
153
README.md
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
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
|
||||
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)):
|
||||
**public tool, private state**. rig carries plumbing logic only — no
|
||||
|
|
@ -68,19 +70,20 @@ itself is untouched — what bootstrap converged stays converged.
|
|||
|
||||
## 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
|
||||
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
|
||||
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
|
||||
rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey
|
||||
```
|
||||
|
||||
- `--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` |
|
||||
| `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 |
|
||||
|
||||
> **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 control plane manages this box", and `control-plane` and `workload` are
|
||||
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
|
||||
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`.
|
||||
The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by
|
||||
the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside
|
||||
with the tenant roles and (for staging guests) `rig bootstrap workload` —
|
||||
rides the traits since #31 (`--class server --host yes --join authkey`; see
|
||||
the note above). 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 an effective **`tag:server` is
|
||||
refused**, same mechanism as `runner`.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -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
|
||||
> manage by hand.
|
||||
|
||||
`dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local`
|
||||
shape with a person living on it, box CLI installed the same way — and
|
||||
`workstation` is the machine at the keyboard end of all the SSH connections:
|
||||
human-class, `join=login`, entering the tailnet as *your* device rather than the
|
||||
fleet's.
|
||||
`dev` is the human-class VM-hosting shape — `tag:local`, box CLI installed as
|
||||
above, a person living on it (`--class server` turns it into the unattended
|
||||
VM-host appliance) — and `workstation` is the machine at the keyboard end of
|
||||
all the SSH connections: human-class, `join=login`, entering the tailnet as
|
||||
*your* device rather than the fleet's.
|
||||
|
||||
### `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
|
||||
|
||||
|
|
@ -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 |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `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
|
||||
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**:
|
||||
|
|
@ -774,7 +870,14 @@ uninstalls with their absence asserts. The
|
|||
`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.
|
||||
functions, non-root and network-free. The tenant family follows the same
|
||||
split: the harness proves the arg/refusal surface, the marker guards (off
|
||||
fixture markers), the pure parameter table, and the rendered agent-context
|
||||
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
10
bin/rig
|
|
@ -13,7 +13,7 @@ usage() {
|
|||
usage: rig <command> [args]
|
||||
|
||||
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>]
|
||||
[--join <authkey|login>]
|
||||
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
|
||||
(workstation) needs no key: interactive login, node must come up
|
||||
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>
|
||||
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
||||
coolify backup install [options]
|
||||
|
|
|
|||
390
commands/bootstrap-tenant.sh
Executable file
390
commands/bootstrap-tenant.sh
Executable 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
|
||||
|
|
@ -6,6 +6,8 @@ set -euo pipefail
|
|||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
|
||||
. "$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' "$*"; }
|
||||
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() {
|
||||
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>]
|
||||
[--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
|
||||
no default and requires it)
|
||||
|
|
@ -32,10 +36,13 @@ custom presets nothing and requires --hostname plus all three traits.
|
|||
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 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,
|
||||
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
|
||||
|
|
@ -56,10 +63,16 @@ EOF
|
|||
# --- args (validated before the root check, so errors are testable) ---------
|
||||
ROLE="${1:-}"
|
||||
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 ;;
|
||||
"") 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 ;;
|
||||
"") 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|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
|
||||
esac
|
||||
|
||||
# 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 ;;
|
||||
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) ;;
|
||||
|
|
@ -182,74 +194,13 @@ APT::Periodic::Update-Package-Lists "1";
|
|||
APT::Periodic::Unattended-Upgrade "1";
|
||||
EOF
|
||||
|
||||
# --- sshd hardening (restart only when the drop-in actually changed) ---------
|
||||
# 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.)
|
||||
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)"
|
||||
# --- sshd hardening ----------------------------------------------------------
|
||||
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
|
||||
# one drop-in, one converger, never two copies drifting apart. Everything the
|
||||
# block learned the hard way (00- beats cloud-init's 50- under first-wins,
|
||||
# validate-then-restart, assert sshd -T not the file, the class-gated
|
||||
# permitrootlogin acceptance) moved with it, verbatim.
|
||||
harden_sshd "$CLASS"
|
||||
|
||||
# --- system hostname ----------------------------------------------------------
|
||||
# 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) ;;
|
||||
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." ;;
|
||||
*)
|
||||
# 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)." ;;
|
||||
esac
|
||||
fi
|
||||
|
|
|
|||
83
commands/lib/sshd.sh
Normal file
83
commands/lib/sshd.sh
Normal 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)"
|
||||
}
|
||||
88
commands/lib/tenant-config.sh
Normal file
88
commands/lib/tenant-config.sh
Normal 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
|
||||
}
|
||||
179
test/cli.sh
179
test/cli.sh
|
|
@ -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
|
||||
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" \
|
||||
# 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
|
||||
# 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"
|
||||
# The old staging effective-tag refusal guarded the VM-HOST shape, which now
|
||||
# rides the traits (custom/dev --class server) — the catch-all tag:server
|
||||
# refusal must still own that shape, so grep the general die instead.
|
||||
check "bootstrap: the catch-all tag:server refusal is present" 0 "" \
|
||||
grep -q "Only control-plane and workload are managed by the control plane" "$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
|
||||
|
|
@ -178,7 +180,13 @@ check "README: points at heavy-duty/box" 0 "" \
|
|||
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
|
||||
# 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: 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" \
|
||||
|
|
@ -187,6 +195,137 @@ else
|
|||
echo "skip: bootstrap non-root refusals (running as root)"
|
||||
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: --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
|
||||
|
|
@ -743,16 +882,28 @@ else
|
|||
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"
|
||||
# the widened assertion so a revert cannot ship green. The hardening block
|
||||
# lives in lib/sshd.sh since #31 — ONE converger shared by the machine roles
|
||||
# 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
|
||||
# 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"
|
||||
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/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
|
||||
# error in it would be invisible here and would first surface at 04:00 on a live
|
||||
|
|
|
|||
Loading…
Reference in a new issue