From 739db6e633a32278a22d4079c2aff00bec3a2fc6 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 19:49:20 +0000 Subject: [PATCH] =?UTF-8?q?feat(bootstrap):=20box=20tenant=20roles=20?= =?UTF-8?q?=E2=80=94=20claude,=20codex,=20grok,=20staging=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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 --- bin/rig | 10 +- commands/bootstrap-tenant.sh | 370 ++++++++++++++++++++++++++++++++++ commands/bootstrap.sh | 105 +++------- commands/lib/sshd.sh | 83 ++++++++ commands/lib/tenant-config.sh | 88 ++++++++ test/cli.sh | 168 +++++++++++++-- 6 files changed, 733 insertions(+), 91 deletions(-) create mode 100755 commands/bootstrap-tenant.sh create mode 100644 commands/lib/sshd.sh create mode 100644 commands/lib/tenant-config.sh diff --git a/bin/rig b/bin/rig index 56ffc5a..4ee24a3 100755 --- a/bin/rig +++ b/bin/rig @@ -8,7 +8,7 @@ usage() { usage: rig [args] commands: - bootstrap + bootstrap [--hostname ] [--class ] [--host ] [--join ] 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 [--user ] + 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 Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh new file mode 100755 index 0000000..b28878a --- /dev/null +++ b/commands/bootstrap-tenant.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash +# rig bootstrap — 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 [--user ] + +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 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 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 — 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 -- …' 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 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 diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 0146f04..4c47062 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -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 +usage: rig bootstrap [--hostname ] [--class ] [--host ] [--join ] + rig bootstrap [--user ] + (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 ` + # 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 diff --git a/commands/lib/sshd.sh b/commands/lib/sshd.sh new file mode 100644 index 0000000..518ddaa --- /dev/null +++ b/commands/lib/sshd.sh @@ -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 — 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)" +} diff --git a/commands/lib/tenant-config.sh b/commands/lib/tenant-config.sh new file mode 100644 index 0000000..cd6950e --- /dev/null +++ b/commands/lib/tenant-config.sh @@ -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 — 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 — 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 — 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 <` 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