forked from heavy-duty/rig
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a tenant machine BECOMES moves here, as convergent, re-runnable roles with effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized per tenant through a pure lib (tenant-config.sh) — never four copies — dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single entrypoint. The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's CLI on the SYSTEM path (box exec shells read no rc files, #15), and the agent-context file — rendered from ONE shared template that carries the box#80 guard note once: never run box setup-host or the drill inside a box; the box you are in is not a host you own. staging lands box#69's server posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim from bootstrap.sh so both families converge ONE drop-in with one converger; its tailnet workload join stays operator-run, exactly the creds split #69 designed. Everything is asserted on effective state: the CLI must ANSWER as the tenant user (the grok template's linked-but-cannot-run scar), docker must answer, sshd -T must resolve. 'staging' therefore moves from the VM-host preset to the tenant role — the thing box#81's seed will auto-run. The host shape lost nothing: it is 'dev --class server' (or custom with all three traits), the catch-all effective-tag refusal still owns its tag policy, and a pre-#31 staging host re-running its old command gets a loud refusal naming the new spelling — tenants refuse host=yes boxes, agents refuse any machine-role box, staging tolerates the workload-joined guest and leaves its marker alone. Harness: the arg/refusal surface, the marker guards off fixture markers, the pure parameter table, the rendered context file (guard included, all three agents), creds-free-by-absence greps (no tailscale, no prompt), the CLI-verified-not-trusted pin, marker-after-converge ordering, and the re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7646346311
commit
739db6e633
6 changed files with 733 additions and 91 deletions
10
bin/rig
10
bin/rig
|
|
@ -8,7 +8,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,
|
||||
|
|
@ -19,6 +19,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]
|
||||
|
|
|
|||
370
commands/bootstrap-tenant.sh
Executable file
370
commands/bootstrap-tenant.sh
Executable file
|
|
@ -0,0 +1,370 @@
|
|||
#!/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= 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.
|
||||
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 ;;
|
||||
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"
|
||||
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"
|
||||
runuser -l "$TENANT_USER" -c "$CLI --version" >/dev/null 2>&1 \
|
||||
|| 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 ($(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1))"
|
||||
|
||||
# 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
|
||||
}
|
||||
168
test/cli.sh
168
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,126 @@ 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 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
|
||||
# a class= marker with host=no — that is the staging guest after its
|
||||
# operator-run workload join, and re-converging it is what convergence is for.
|
||||
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=claude tenant=yes host=no\n' > "$TEN_FIX/tenant"
|
||||
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: 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 +871,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