2026-07-10 20:40:07 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Dependency-free CLI assertions. Run: bash test/cli.sh
|
|
|
|
|
# Deliberately no `set -e` — the harness asserts on failing commands.
|
|
|
|
|
set -u
|
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
|
PASS=0 FAIL=0
|
|
|
|
|
|
|
|
|
|
# check <desc> <want_exit> <want_substr> <cmd...>
|
|
|
|
|
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
|
|
|
|
# contains want_substr.
|
|
|
|
|
check() {
|
|
|
|
|
local desc="$1" want="$2" substr="$3"; shift 3
|
|
|
|
|
local out rc
|
|
|
|
|
out="$("$@" 2>&1)"; rc=$?
|
|
|
|
|
if [ "$rc" -ne "$want" ]; then
|
|
|
|
|
echo "FAIL: $desc — exit $rc, wanted $want"
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
fi
|
2026-07-10 20:53:32 +00:00
|
|
|
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
2026-07-10 20:40:07 +00:00
|
|
|
echo "FAIL: $desc — output missing '$substr'"
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
fi
|
|
|
|
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 08:25:48 +00:00
|
|
|
check "no args shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig"
|
|
|
|
|
check "--help exits 0" 0 "usage:" "$ROOT/bin/rig" --help
|
|
|
|
|
check "help exits 0" 0 "usage:" "$ROOT/bin/rig" help
|
|
|
|
|
check "unknown command exits 2" 2 "unknown command" "$ROOT/bin/rig" frobnicate
|
|
|
|
|
check "bare coolify shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify
|
2026-07-10 20:40:07 +00:00
|
|
|
|
2026-07-10 20:43:24 +00:00
|
|
|
check "bootstrap: role required, exit 2" 2 "role required" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap.sh" --help
|
|
|
|
|
check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
|
|
|
|
check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope
|
|
|
|
|
check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname
|
bootstrap: infer the tailnet tag from the pre-auth key, verify the granted tag
rig used to pass --ts-tag to `tailscale up --advertise-tags`, stating the
tailnet tag a second time with no way to know whether its request and the
key's own tags agreed. It asserted the tag it REQUESTED, never the tag control
GRANTED — the sshd first-wins bug in a different hat, and the same scar (both
M900s joined tag:server, retagged by hand, unnoticed).
Collapse the two sources of truth onto one: the key.
- `tailscale up` drops --advertise-tags; the key's tags apply.
- After join, poll `tailscale status --json` for `.Self.Tags` (netmap ground
truth, not `debug prefs`) until tags appear or BackendState=Running, on BOTH
the fresh-join and already-joined paths.
- UNTAGGED -> hard refusal: `tailscale logout` to back the user-owned node out,
then die naming the fix (mint a tagged key).
- Role policy moves onto the effective tag: a runner must not have tag:server
among the tags the key actually granted. Strictly stronger than before.
- --ts-tag is removed, and dies exit 2 with a message pointing at the key
(consuming its value), not an "unknown flag".
- New array-aware reader json_string_array in lib/runner-config.sh (jq-free,
never fails under set -e), with its own unit tests; bootstrap sources the lib.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:27:09 +00:00
|
|
|
# --ts-tag is REMOVED, not demoted: the tag now comes from the pre-auth key and
|
|
|
|
|
# rig verifies the GRANTED tag after join. The old runner-refuses-tag:server test
|
|
|
|
|
# asserted the request-time refusal THROUGH this flag; that policy now lives on
|
|
|
|
|
# the EFFECTIVE tag and needs a real tailnet, so it belongs to the rehearsal, not
|
|
|
|
|
# here. What this harness CAN prove is that the flag dies with a message pointing
|
|
|
|
|
# at the key (exit 2, a usage error), rather than an "unknown flag" that would
|
|
|
|
|
# leave an operator guessing where the tag went — value present or absent.
|
|
|
|
|
check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pre-auth key" \
|
|
|
|
|
"$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
|
2026-07-17 15:51:36 +00:00
|
|
|
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
|
|
|
|
|
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
|
|
|
|
|
# The staging tag:server refusal rides the EFFECTIVE tag, inside
|
|
|
|
|
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
|
|
|
|
|
# rehearsal. What the harness CAN prove is that the refusal exists in the
|
|
|
|
|
# shipped script: grep the die message, so a deleted guard cannot ship green
|
|
|
|
|
# (the same reason the runner-install repo guard is grepped below).
|
|
|
|
|
check "bootstrap: staging effective-tag refusal is present" 0 "" \
|
|
|
|
|
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
|
2026-07-17 19:15:06 +00:00
|
|
|
# --- traits: roles are presets, every trait individually settable (#26) -----
|
|
|
|
|
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
|
|
|
|
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
|
|
|
|
|
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
|
|
|
|
|
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
|
|
|
|
|
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
|
|
|
|
|
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
|
|
|
|
|
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
|
|
|
|
|
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
|
|
|
|
|
# must die BEFORE the root check — provable non-root, which also proves the
|
|
|
|
|
# preset actually landed.
|
|
|
|
|
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
|
|
|
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
|
|
|
|
|
# A trait override changes derived behavior, provable non-root: dev is
|
|
|
|
|
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
|
|
|
|
|
# --join login flips it into the TS_AUTHKEY refusal.
|
|
|
|
|
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
|
|
|
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
|
|
|
|
|
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
|
|
|
|
|
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
|
|
|
|
|
check "bootstrap: login-path tagged refusal is present" 0 "" \
|
|
|
|
|
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
|
fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion
Three refusals, one doctrine: detect, refuse, name the repair — and never
back out state rig did not create.
- verify_effective_tag grows the same <back-out|keep> mode discipline as
verify_user_owned. First join keeps the logout-and-die on an untagged key;
the already-joined path now refuses WITHOUT logout — the untagged node may
be a login-joined workstation (untagged by design) that a join=authkey
re-run must not tear off the tailnet. The die names both ways out.
- verify_user_owned fails CLOSED on a stalled backend: empty tags is its
success signal, so a 30s poll that never saw Running waved a tagged node
on a slow tailscaled through as user-owned. state!=Running now dies in
both modes, logging nothing out — nothing was verified, so the repair is
to re-run and verify, not to undo a join that may be fine.
- The permitrootlogin acceptance is class-gated. class=human keeps
no|prohibit-password|without-password (`no` is the close-root state).
class=server accepts only prohibit-password|without-password: root SSH is
the control plane's automation door, and `no` there means a leftover
00-rig-users.conf from a former class=human life has fleet management
silently dead. Refused loudly, drop-in named, never auto-removed —
silently reopening a root door is worse than a loud stop.
Harness greps pin all three die messages so a deleted guard cannot ship
green (repo precedent: the tag-refusal greps).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:53 +00:00
|
|
|
# Re-running with join=authkey on a box that was legitimately login-joined
|
|
|
|
|
# (untagged BY DESIGN) lands in verify_effective_tag's untagged branch. Backing
|
|
|
|
|
# out a join this run did not perform would tear down a user-owned workstation;
|
|
|
|
|
# the already-joined path must refuse WITHOUT logout and name both repairs.
|
|
|
|
|
# Needs a real tailnet to exercise, so grep the keep-mode die instead.
|
|
|
|
|
check "bootstrap: already-joined untagged refusal keeps the join" 0 "" \
|
|
|
|
|
grep -q "joined but UNTAGGED" "$ROOT/commands/bootstrap.sh"
|
|
|
|
|
# verify_user_owned must fail CLOSED on a stalled backend: empty tags is its
|
|
|
|
|
# SUCCESS signal, so a 30s poll that never saw Running would wave a tagged node
|
|
|
|
|
# through as user-owned. Grep the timeout die (same real-tailnet excuse).
|
|
|
|
|
check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
|
|
|
|
|
grep -q "could not verify the join is user-owned" "$ROOT/commands/bootstrap.sh"
|
2026-07-17 19:15:06 +00:00
|
|
|
# The marker is the traits' ground truth for rig users; assert the write exists.
|
|
|
|
|
check "bootstrap: role marker write is present" 0 "" \
|
|
|
|
|
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
2026-07-10 20:43:24 +00:00
|
|
|
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
|
2026-07-11 18:25:47 +00:00
|
|
|
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
|
2026-07-17 15:51:36 +00:00
|
|
|
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
|
2026-07-17 19:15:06 +00:00
|
|
|
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
|
|
|
|
|
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
|
|
|
|
|
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
|
|
|
|
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
|
2026-07-10 20:43:24 +00:00
|
|
|
else
|
2026-07-11 18:25:47 +00:00
|
|
|
echo "skip: bootstrap non-root refusals (running as root)"
|
2026-07-10 20:43:24 +00:00
|
|
|
fi
|
|
|
|
|
|
2026-07-10 20:50:41 +00:00
|
|
|
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
|
|
|
|
|
check "coolify: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-install.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "coolify: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-install.sh" --version 4.1.2
|
|
|
|
|
else
|
|
|
|
|
echo "skip: coolify non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
feat(coolify): install the control-plane dump as a systemd timer
The Coolify control-plane database holds the GitHub App private key, every
registered server's SSH key, and every environment value for every environment
it manages. Backing it up was a manual runbook step, and the dump script lived
in cast — the off-box tool, whose src never references it. It runs on the box,
as root, under a scheduler: that is rig's job description.
It matters beyond tidiness. The dump is forensics, not a restore path — a lost
control plane is rebuilt fresh and reconciled from the manifest. So there will
be a next control-plane box, and as a runbook step it was born un-backed-up,
depending on someone remembering mid-incident. Now it is backed up from birth.
rig installs the machinery and templates /etc/coolify-dump.env empty at 0600,
never reading it back — no credential passes through rig. The script's own
guards make an unfilled file fail the unit loudly rather than ship plaintext.
systemd timer over cron: EnvironmentFile is the right idiom for 0600 secrets,
failures surface in systemctl status instead of being mailed into the void, and
Persistent=true catches a run missed while the box was down.
Two hazards the cast script missed, carried into the unit:
- aws-cli >= 2.23 enables default upload checksums that S3-compatible backends
reject; Debian 13 ships 2.23.6, so the unit defaults both checksum knobs to
when_required.
- A failed pg_dump piped into age still yields a valid, tiny, encrypted file
that uploads cleanly every night and looks exactly like a working backup. The
script now refuses to upload an empty artifact.
Closes #8
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:14:07 +00:00
|
|
|
check "bare coolify backup shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify backup
|
|
|
|
|
check "coolify backup: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" coolify backup frobnicate
|
|
|
|
|
check "coolify backup: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-backup-install.sh" --help
|
|
|
|
|
check "coolify backup: schedule needs value" 2 "needs a value" "$ROOT/commands/coolify-backup-install.sh" --schedule
|
|
|
|
|
check "coolify backup: pg-container needs value" 2 "needs a value" "$ROOT/commands/coolify-backup-install.sh" --pg-container
|
|
|
|
|
check "coolify backup: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-backup-install.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "coolify backup: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-backup-install.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: coolify backup non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-07-11 17:41:09 +00:00
|
|
|
check "bare runner shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" runner
|
|
|
|
|
check "runner: --help exits 0" 0 "usage:" "$ROOT/commands/runner-install.sh" --help
|
|
|
|
|
check "runner: repo required, exit 2" 2 "--repo" "$ROOT/commands/runner-install.sh" --version 2.335.1
|
2026-07-11 18:44:43 +00:00
|
|
|
check "runner: version needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version
|
2026-07-11 17:41:09 +00:00
|
|
|
check "runner: repo needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo
|
|
|
|
|
check "runner: rejects bad repo slug" 2 "owner/repo" "$ROOT/commands/runner-install.sh" --repo not-a-slug --version 2.335.1
|
|
|
|
|
check "runner: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1 --user root
|
|
|
|
|
check "runner: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-install.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "runner: refuses non-root" 1 "must run as root" env RUNNER_TOKEN=x "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1
|
|
|
|
|
else
|
|
|
|
|
echo "skip: runner non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
check "runner: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" runner frobnicate
|
|
|
|
|
|
fix(runner): install refuses a box registered to another repo
`rig runner install --repo <B>` on a box already registered to repo A
treated the mere existence of .runner as "already registered", skipped
configure, restarted the service still pointed at A, and reported success.
--repo was accepted, validated, and then ignored — leaving B with zero
runners and its `runs-on` jobs queued against one that will never come.
This is the natural next command after a partial `repoint`, and the failure
is worse than a no-op: moving a runner between repos is a trust-boundary
act, so quietly putting it back on the old one defeats the point of the move.
Gate install on the repo .runner actually names. Convergence — the property
worth keeping — is untouched: re-running against the repo the box is already
on still skips registration, never prompts for a token, and exits 0.
Skipping when the repo *differs* was never convergence, only a silently
ignored argument, so it now fails and names both repos, pointing at
`runner repoint` (move) or `runner remove` (start over). An unreadable
.runner is refused too — it is no licence to assume a match.
The .runner reader that `status` and `repoint` each carried is lifted into
commands/lib/runner-config.sh, which now also holds the guard. Its json_field
no longer dies bare under `set -o pipefail` when a key is missing, which is
what `status`'s own ${REPO_URL:-unknown} fallback always assumed.
Tests: the guard is exercised against a fixture .runner (refuses another repo
naming both, points at repoint, no-ops on the same repo, passes an
unregistered box, refuses an unreadable one) plus an ordering assertion that
it precedes svc.sh start — reaching it through the CLI would need root and a
really-registered runner, which the dependency-free harness cannot fabricate.
All three mutants (guard deleted, guard comparing nothing, guard moved below
the service start) go red.
Closes #13
2026-07-13 14:57:28 +00:00
|
|
|
# --- runner install: --repo must agree with what the box is already on -------
|
|
|
|
|
# The bug: `install --repo B` on a box registered to repo A skipped configure,
|
|
|
|
|
# restarted the service on A, and reported success — --repo accepted, validated,
|
|
|
|
|
# then ignored. The guard is exercised here through the shared lib, against a
|
|
|
|
|
# fixture .runner: reaching it via the CLI needs root AND a really-registered
|
|
|
|
|
# runner, neither of which this harness can fabricate.
|
|
|
|
|
guard() { # guard <runner_dir> <owner/repo>
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
assert_runner_repo "$2" "$3"' _ "$ROOT" "$1" "$2"
|
|
|
|
|
}
|
|
|
|
|
REG_DIR="$(mktemp -d)" # a box registered to acme/alpha
|
|
|
|
|
EMPTY_DIR="$(mktemp -d)" # a box with no runner at all
|
|
|
|
|
printf '%s\n' '{"agentId":7,"agentName":"ci-box","gitHubUrl":"https://github.com/acme/alpha","workFolder":"_work"}' \
|
|
|
|
|
> "$REG_DIR/.runner"
|
|
|
|
|
|
|
|
|
|
check "runner install: refuses a repo the box is not registered to" \
|
|
|
|
|
1 "already registered to https://github.com/acme/alpha" guard "$REG_DIR" acme/beta
|
|
|
|
|
check "runner install: the refusal names the repo that was asked for" \
|
|
|
|
|
1 "not https://github.com/acme/beta" guard "$REG_DIR" acme/beta
|
|
|
|
|
check "runner install: the refusal points at repoint" \
|
|
|
|
|
1 "rig runner repoint --repo acme/beta" guard "$REG_DIR" acme/beta
|
|
|
|
|
# Convergence is the property worth keeping: same repo stays a clean no-op.
|
|
|
|
|
check "runner install: the repo it is already on is a no-op" \
|
|
|
|
|
0 "" guard "$REG_DIR" acme/alpha
|
|
|
|
|
check "runner install: an unregistered box passes the guard" \
|
|
|
|
|
0 "" guard "$EMPTY_DIR" acme/beta
|
|
|
|
|
# A .runner rig cannot read is not a licence to assume it matches.
|
|
|
|
|
printf '%s\n' '{"agentName":"ci-box"}' > "$REG_DIR/.runner"
|
|
|
|
|
check "runner install: refuses an unreadable registration" \
|
|
|
|
|
1 "names no repository" guard "$REG_DIR" acme/alpha
|
|
|
|
|
rm -rf "$REG_DIR" "$EMPTY_DIR"
|
|
|
|
|
|
bootstrap: infer the tailnet tag from the pre-auth key, verify the granted tag
rig used to pass --ts-tag to `tailscale up --advertise-tags`, stating the
tailnet tag a second time with no way to know whether its request and the
key's own tags agreed. It asserted the tag it REQUESTED, never the tag control
GRANTED — the sshd first-wins bug in a different hat, and the same scar (both
M900s joined tag:server, retagged by hand, unnoticed).
Collapse the two sources of truth onto one: the key.
- `tailscale up` drops --advertise-tags; the key's tags apply.
- After join, poll `tailscale status --json` for `.Self.Tags` (netmap ground
truth, not `debug prefs`) until tags appear or BackendState=Running, on BOTH
the fresh-join and already-joined paths.
- UNTAGGED -> hard refusal: `tailscale logout` to back the user-owned node out,
then die naming the fix (mint a tagged key).
- Role policy moves onto the effective tag: a runner must not have tag:server
among the tags the key actually granted. Strictly stronger than before.
- --ts-tag is removed, and dies exit 2 with a message pointing at the key
(consuming its value), not an "unknown flag".
- New array-aware reader json_string_array in lib/runner-config.sh (jq-free,
never fails under set -e), with its own unit tests; bootstrap sources the lib.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:27:09 +00:00
|
|
|
# --- json_string_array: json_field's array-aware sibling ---------------------
|
|
|
|
|
# bootstrap reads `.Self.Tags` (a JSON array) out of `tailscale status --json` to
|
|
|
|
|
# assert the tag control GRANTED the node — and a rig box has no jq. Exercise the
|
|
|
|
|
# reader against fixture netmaps here, the same shared-lib way the guard above is:
|
|
|
|
|
# the bootstrap path that calls it needs a real tailnet this harness cannot fake.
|
|
|
|
|
tags() { # tags <file> — prints one tag per line, exactly like the reader
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
json_string_array "$2" Tags' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
tags_count() { # tags_count <file> — prints how many tags were read (0 if none)
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
json_string_array "$2" Tags | grep -c . || true' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
tags_empty() { # tags_empty <file> — exit 0 iff the reader prints NOTHING
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/runner-config.sh"
|
|
|
|
|
[ -z "$(json_string_array "$2" Tags)" ]' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
FIX_TAGGED="$(mktemp)" # Self carries two tags; a peer carries a third
|
|
|
|
|
FIX_UNTAGGED="$(mktemp)" # Self has no Tags key at all — the untagged hazard
|
|
|
|
|
cat > "$FIX_TAGGED" <<'JSON'
|
|
|
|
|
{
|
|
|
|
|
"BackendState": "Running",
|
|
|
|
|
"Self": {
|
|
|
|
|
"HostName": "ci-box",
|
|
|
|
|
"Tags": [
|
|
|
|
|
"tag:ci",
|
|
|
|
|
"tag:build"
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"Peer": {
|
|
|
|
|
"nodekey:abc": {
|
|
|
|
|
"HostName": "coolify-box",
|
|
|
|
|
"Tags": [
|
|
|
|
|
"tag:server"
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
JSON
|
|
|
|
|
cat > "$FIX_UNTAGGED" <<'JSON'
|
|
|
|
|
{
|
|
|
|
|
"BackendState": "Running",
|
|
|
|
|
"Self": {
|
|
|
|
|
"HostName": "user-owned-box"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
JSON
|
|
|
|
|
check "json_string_array: reads the first array element" 0 "tag:ci" tags "$FIX_TAGGED"
|
|
|
|
|
check "json_string_array: reads a later array element" 0 "tag:build" tags "$FIX_TAGGED"
|
|
|
|
|
# Self precedes Peer in the netmap, so the FIRST "Tags" is the node's own: exactly
|
|
|
|
|
# two elements read proves the peer's tag:server did not leak into Self's tags.
|
|
|
|
|
check "json_string_array: reads Self's array, not a peer's" 0 "2" tags_count "$FIX_TAGGED"
|
|
|
|
|
# An absent key omits itself (Go omitempty), never emits []: empty is the signal
|
|
|
|
|
# bootstrap turns into a hard untagged-key refusal, so it must read as empty here.
|
|
|
|
|
check "json_string_array: absent Tags key prints nothing" 0 "" tags_empty "$FIX_UNTAGGED"
|
|
|
|
|
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED"
|
|
|
|
|
|
fix(runner): install refuses a box registered to another repo
`rig runner install --repo <B>` on a box already registered to repo A
treated the mere existence of .runner as "already registered", skipped
configure, restarted the service still pointed at A, and reported success.
--repo was accepted, validated, and then ignored — leaving B with zero
runners and its `runs-on` jobs queued against one that will never come.
This is the natural next command after a partial `repoint`, and the failure
is worse than a no-op: moving a runner between repos is a trust-boundary
act, so quietly putting it back on the old one defeats the point of the move.
Gate install on the repo .runner actually names. Convergence — the property
worth keeping — is untouched: re-running against the repo the box is already
on still skips registration, never prompts for a token, and exits 0.
Skipping when the repo *differs* was never convergence, only a silently
ignored argument, so it now fails and names both repos, pointing at
`runner repoint` (move) or `runner remove` (start over). An unreadable
.runner is refused too — it is no licence to assume a match.
The .runner reader that `status` and `repoint` each carried is lifted into
commands/lib/runner-config.sh, which now also holds the guard. Its json_field
no longer dies bare under `set -o pipefail` when a key is missing, which is
what `status`'s own ${REPO_URL:-unknown} fallback always assumed.
Tests: the guard is exercised against a fixture .runner (refuses another repo
naming both, points at repoint, no-ops on the same repo, passes an
unregistered box, refuses an unreadable one) plus an ordering assertion that
it precedes svc.sh start — reaching it through the CLI would need root and a
really-registered runner, which the dependency-free harness cannot fabricate.
All three mutants (guard deleted, guard comparing nothing, guard moved below
the service start) go red.
Closes #13
2026-07-13 14:57:28 +00:00
|
|
|
# The guard is only worth something if it runs BEFORE the box is touched: the
|
|
|
|
|
# token prompt, the download, configure and svc.sh start all come after it.
|
|
|
|
|
# Ordering is the whole fix, so assert it rather than trust it.
|
|
|
|
|
# Matches the CALL, not the word: the comment above it mentions assert_runner_repo
|
|
|
|
|
# too, and a plain grep would keep finding that after the call itself was deleted.
|
|
|
|
|
# The defaults fail closed, so a guard that is gone cannot read as one that merely
|
|
|
|
|
# sits early in the file.
|
|
|
|
|
guard_at="$(grep -nE '^[[:space:]]*assert_runner_repo ' "$ROOT/commands/runner-install.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
start_at="$(grep -n 'svc.sh start' "$ROOT/commands/runner-install.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "runner install: the repo guard precedes svc.sh start" \
|
|
|
|
|
0 "" test "${guard_at:-999999}" -lt "${start_at:-0}"
|
|
|
|
|
|
feat(runner): status, remove, and repoint — the runner lifecycle verbs
runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.
- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
reusing the binary already on the box.
The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.
repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.
Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
2026-07-13 13:25:27 +00:00
|
|
|
check "runner status: --help exits 0" 0 "usage:" "$ROOT/commands/runner-status.sh" --help
|
|
|
|
|
check "runner status: user needs value" 2 "needs a value" "$ROOT/commands/runner-status.sh" --user
|
|
|
|
|
check "runner status: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-status.sh" --user root
|
|
|
|
|
check "runner status: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-status.sh" --nope
|
|
|
|
|
|
|
|
|
|
check "runner remove: --help exits 0" 0 "usage:" "$ROOT/commands/runner-remove.sh" --help
|
|
|
|
|
check "runner remove: user needs value" 2 "needs a value" "$ROOT/commands/runner-remove.sh" --user
|
|
|
|
|
check "runner remove: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-remove.sh" --user root
|
|
|
|
|
check "runner remove: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-remove.sh" --nope
|
|
|
|
|
|
|
|
|
|
check "runner repoint: --help exits 0" 0 "usage:" "$ROOT/commands/runner-repoint.sh" --help
|
|
|
|
|
check "runner repoint: repo required" 2 "--repo" "$ROOT/commands/runner-repoint.sh"
|
|
|
|
|
check "runner repoint: repo needs value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo
|
|
|
|
|
check "runner repoint: rejects bad slug" 2 "owner/repo" "$ROOT/commands/runner-repoint.sh" --repo not-a-slug
|
|
|
|
|
check "runner repoint: labels need value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --labels
|
|
|
|
|
check "runner repoint: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --user root
|
|
|
|
|
check "runner repoint: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-repoint.sh" --nope
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "runner status: refuses non-root" 1 "must run as root" "$ROOT/commands/runner-status.sh"
|
|
|
|
|
check "runner remove: refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RUNNER_REMOVE_TOKEN=x "$ROOT/commands/runner-remove.sh"
|
|
|
|
|
# --local too: the token-free path must still not be runnable by the runner user.
|
|
|
|
|
check "runner remove: --local refuses non-root" 1 "must run as root" \
|
|
|
|
|
"$ROOT/commands/runner-remove.sh" --local
|
|
|
|
|
check "runner repoint: refuses non-root" 1 "must run as root" \
|
|
|
|
|
env RUNNER_REMOVE_TOKEN=x RUNNER_TOKEN=y "$ROOT/commands/runner-repoint.sh" --repo acme/widgets
|
|
|
|
|
else
|
|
|
|
|
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
|
feat(users): declarative operators — apply/status over a users file, every class
Operators become a declared fact, not an accumulation of adduser runs: a
line-based, bash-parseable users file (no YAML, no jq — a rig box has
neither) names each user, their roles, and their keys, and apply converges
the box to exactly that. Roles map to groups (admin→rig-admin with full
NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no
sudo — box's setup-host owns Incus, rig only asserts the group). Every
password stays locked always; the SSH key at the door is the
authentication. A user dropped from the file is found via the /etc/rig/users
ledger and locked, never deleted — deleting frees the uid and rots
attribution. The sudoers drop-in lands only after visudo -c passes, because
a bad file under sudoers.d takes down all of sudo. Class never gates apply
(#26: a shared root login is unattributable, so operators belong on every
class); the marker only colors what root SSH does next. The whole file is
validated in one pass before the root check, every error named with its
line, so refusals are provable in the non-root harness through the sourced
parser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
|
|
|
check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users
|
|
|
|
|
check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate
|
|
|
|
|
|
|
|
|
|
check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help
|
|
|
|
|
check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file
|
|
|
|
|
check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users
|
|
|
|
|
check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope
|
|
|
|
|
check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help
|
|
|
|
|
|
|
|
|
|
# --- users file refusal matrix, through the sourced parser -------------------
|
|
|
|
|
# Reaching the parser via the CLI stops at the root check; it is pure and
|
|
|
|
|
# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array),
|
|
|
|
|
# so the refusals are proven here against fixtures, non-root and network-free.
|
|
|
|
|
parse() { # parse <file> — the users-file parser, exactly as apply runs it
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/users-config.sh"
|
|
|
|
|
parse_users_file "$2"' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line
|
|
|
|
|
FIX_BAD="$(mktemp)" # rewritten per refusal below
|
|
|
|
|
cat > "$FIX_OK" <<'USERS'
|
|
|
|
|
# fleet operators
|
|
|
|
|
dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop
|
|
|
|
|
dan admin,box ssh-ed25519 AAAAC3second dan@desk
|
|
|
|
|
|
|
|
|
|
maria rig ssh-ed25519 AAAAC3fixture maria@mac
|
|
|
|
|
USERS
|
|
|
|
|
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD"
|
|
|
|
|
check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD"
|
|
|
|
|
check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
|
|
|
|
|
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' 'dan admin' > "$FIX_BAD"
|
|
|
|
|
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
|
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:
- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
role rig silently root-equivalent through the very command that granted it.
Identity management now refuses any sudo invoker outside rig-admin; direct
root (bring-up, a root shell) proceeds.
- Offboarding revokes SSH, not just the password: a '!'-locked password is
not a closed door under UsePAM — Debian sshd still honors the pubkey. A
dropped user's account is now expired (usermod -L -e 1, the switch PAM
actually enforces) and authorized_keys is renamed to
authorized_keys.revoked-by-rig — access revoked, data kept, convergence
never destroys. Present users get their expiry cleared idempotently, so a
re-added user comes back to life.
- The ledger remembers: two-field lines ('name active' / 'name revoked',
legacy bare names read as active), so dropped users no longer vanish from
rig's memory on the next rewrite. status now reports the ledger state
corroborated by the account's real expiry — passwd -S read L for everyone
(apply locks all passwords always), so its locked/active was meaningless —
and flags a mismatch loudly as drift.
- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
and mode converge on every run, not only when content changes — StrictModes
treats them as load-bearing, so drifted perms were a broken login that
"already converged" lied about. Only the content write stays cmp-guarded.
- close-root's admin-door gate checks the StrictModes shape per candidate —
ownership, group/world-writability of home/.ssh/authorized_keys, a real
login shell, an unexpired account — and names which check failed. It proves
the door SHOULD open, not that it does; the separate-session advisory stays
load-bearing.
- Usernames are validated in the parser's one-pass refusal matrix
(^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
stream, and a leading '-' read as a useradd flag mid-convergence.
- The box role is trait-aware: on a host=no box an absent incus group skips
the role with a warning and converges everything else — one box-role user
in a fleet-wide file must not abort apply everywhere VMs don't live.
host=yes still dies pointing at box setup-host; a classless marker warns
toward a bootstrap re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
|
|
|
# Usernames are validated in the same one-pass refusal matrix: 'fo|o' would
|
|
|
|
|
# corrupt the parser's own '|'-delimited stream (user 'fo', garbage keys), and
|
|
|
|
|
# a leading '-' reads as a useradd flag mid-convergence. The refusal names the
|
|
|
|
|
# line and the rule, like every other parser refusal.
|
|
|
|
|
printf '%s\n' 'fo|o admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
|
|
|
|
check "users parser: '|' in a username is refused" 1 "invalid username" parse "$FIX_BAD"
|
|
|
|
|
check "users parser: the username refusal names the line" 1 "line 1" parse "$FIX_BAD"
|
|
|
|
|
printf '%s\n' '-dan admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
|
|
|
|
check "users parser: leading-dash username is refused" 1 "invalid username" parse "$FIX_BAD"
|
feat(users): declarative operators — apply/status over a users file, every class
Operators become a declared fact, not an accumulation of adduser runs: a
line-based, bash-parseable users file (no YAML, no jq — a rig box has
neither) names each user, their roles, and their keys, and apply converges
the box to exactly that. Roles map to groups (admin→rig-admin with full
NOPASSWD sudo, rig→rig sudo for the rig binary only, box→incus with no
sudo — box's setup-host owns Incus, rig only asserts the group). Every
password stays locked always; the SSH key at the door is the
authentication. A user dropped from the file is found via the /etc/rig/users
ledger and locked, never deleted — deleting frees the uid and rots
attribution. The sudoers drop-in lands only after visudo -c passes, because
a bad file under sudoers.d takes down all of sudo. Class never gates apply
(#26: a shared root login is unattributable, so operators belong on every
class); the marker only colors what root SSH does next. The whole file is
validated in one pass before the root check, every error named with its
line, so refusals are provable in the non-root harness through the sourced
parser.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:23:47 +00:00
|
|
|
check "users parser: valid file emits dan (both keys' roles agree)" \
|
|
|
|
|
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
|
|
|
|
|
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
|
|
|
|
|
# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error.
|
|
|
|
|
# A single invocation, both messages asserted from its one stderr.
|
|
|
|
|
printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD"
|
|
|
|
|
MULTI_ERRS="$(mktemp)"
|
|
|
|
|
parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$?
|
|
|
|
|
check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1
|
|
|
|
|
check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS"
|
|
|
|
|
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
|
|
|
|
|
rm -f "$MULTI_ERRS"
|
|
|
|
|
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
# A VALID fixture proves the whole file-validation pass sits before the
|
|
|
|
|
# root check — a parse failure here would exit 2, not 1.
|
|
|
|
|
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
|
|
|
|
|
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: users non-root refusals (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
rm -f "$FIX_OK" "$FIX_BAD"
|
|
|
|
|
|
|
|
|
|
# Validate-then-apply: `visudo -c` must pass before anything lands in
|
|
|
|
|
# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin
|
|
|
|
|
# out of the escalation path apply just granted. Assert the order in the file,
|
|
|
|
|
# matching the calls rather than comments (repo precedent: the runner-install
|
|
|
|
|
# repo-guard ordering check). Defaults fail closed.
|
|
|
|
|
visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "users apply: visudo -c precedes the sudoers install" \
|
|
|
|
|
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
|
|
|
|
|
|
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:
- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
role rig silently root-equivalent through the very command that granted it.
Identity management now refuses any sudo invoker outside rig-admin; direct
root (bring-up, a root shell) proceeds.
- Offboarding revokes SSH, not just the password: a '!'-locked password is
not a closed door under UsePAM — Debian sshd still honors the pubkey. A
dropped user's account is now expired (usermod -L -e 1, the switch PAM
actually enforces) and authorized_keys is renamed to
authorized_keys.revoked-by-rig — access revoked, data kept, convergence
never destroys. Present users get their expiry cleared idempotently, so a
re-added user comes back to life.
- The ledger remembers: two-field lines ('name active' / 'name revoked',
legacy bare names read as active), so dropped users no longer vanish from
rig's memory on the next rewrite. status now reports the ledger state
corroborated by the account's real expiry — passwd -S read L for everyone
(apply locks all passwords always), so its locked/active was meaningless —
and flags a mismatch loudly as drift.
- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
and mode converge on every run, not only when content changes — StrictModes
treats them as load-bearing, so drifted perms were a broken login that
"already converged" lied about. Only the content write stays cmp-guarded.
- close-root's admin-door gate checks the StrictModes shape per candidate —
ownership, group/world-writability of home/.ssh/authorized_keys, a real
login shell, an unexpired account — and names which check failed. It proves
the door SHOULD open, not that it does; the separate-session advisory stays
load-bearing.
- Usernames are validated in the parser's one-pass refusal matrix
(^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
stream, and a leading '-' read as a useradd flag mid-convergence.
- The box role is trait-aware: on a host=no box an absent incus group skips
the role with a warning and converges everything else — one box-role user
in a fleet-wide file must not abort apply everywhere VMs don't live.
host=yes still dies pointing at box setup-host; a classless marker warns
toward a bootstrap re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
|
|
|
# The invoker gate: %rig's sudoers rule is binary-scoped (NOPASSWD for
|
|
|
|
|
# /usr/local/bin/rig, any args), so without a gate `sudo rig users apply
|
|
|
|
|
# --file <me-as-admin>` turns role rig root-equivalent through this very
|
|
|
|
|
# command. Exercising it needs a real SUDO_USER and real groups, so grep the
|
|
|
|
|
# refusal in both identity-management commands (repo precedent: the
|
|
|
|
|
# staging/runner tag greps).
|
|
|
|
|
check "users apply: invoker gate refusal is present" 0 "" \
|
|
|
|
|
grep -q "changes who holds root" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
check "users close-root: invoker gate refusal is present" 0 "" \
|
|
|
|
|
grep -q "changes who holds root" "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
# Offboarding must revoke SSH, not just the password: a '!'-locked password is
|
|
|
|
|
# not a closed door under UsePAM — pubkey auth still works. Expiry is the
|
|
|
|
|
# switch PAM actually honors, and the keys are renamed, never deleted
|
|
|
|
|
# (convergence never destroys). Needs root + real accounts, so grep both moves.
|
|
|
|
|
check "users apply: a dropped user's account is expired, not just locked" 0 "" \
|
|
|
|
|
grep -qF -- "usermod -L -e 1" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
check "users apply: revoked keys are renamed, never deleted" 0 "" \
|
|
|
|
|
grep -q "revoked-by-rig" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
# A fleet-wide users file must not abort apply on a host=no box just because
|
|
|
|
|
# it names a box-role user somewhere in the fleet: the box role binds where
|
|
|
|
|
# VMs live, so on host=no it skips (with a warning) and everything else —
|
|
|
|
|
# admins included — still converges.
|
|
|
|
|
check "users apply: box role skips on a host=no box" 0 "" \
|
|
|
|
|
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
|
|
|
|
|
|
feat(users): close-root — shut the human-class root door once an admin key works
class decides root SSH's fate, and this is human's: install
/etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME
is the mechanism — sshd_config is first-wins, the Include glob expands
lexically, and '-' sorts before '.', so it is read before bootstrap's
00-rig.conf and wins. Gated three ways, no --force: a marker must exist
(never shut the root door blind), it must say class=human (on a server root
is the control plane's automation identity — closing it severs fleet
management), and some rig-admin member must already hold a non-empty
authorized_keys (never close the only door). The gate's policy lives in the
lib as assert_marker_human so the harness proves every refusal against
fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at
the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim —
cmp-guard, sshd -t on the merged config before the restart with rollback,
then the sshd -T effective assertion. Bootstrap's own permitrootlogin
assertion widens to accept 'no': the closed door is strictly harder, never
broken, and by first-wins bootstrap cannot reopen it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:28:25 +00:00
|
|
|
# --- users close-root: the human-class root-door shutter ---------------------
|
|
|
|
|
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
|
|
|
|
|
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
|
|
|
|
|
# The whole command rests on first-wins + lexical include order: '-' (0x2D)
|
|
|
|
|
# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's
|
|
|
|
|
# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the
|
|
|
|
|
# glob makes, so a renamed drop-in cannot silently lose the fight.
|
|
|
|
|
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
|
|
|
|
|
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
|
|
|
|
|
check "users close-root: drop-in name is the load-bearing one" 0 "" \
|
|
|
|
|
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
# Validate-then-apply: `sshd -t` on the merged config must precede the restart —
|
|
|
|
|
# on a box whose only door is SSH (exactly what this box is about to become),
|
|
|
|
|
# bouncing the daemon into a config it refuses to parse leaves no way back in.
|
|
|
|
|
# Match the call, not the word (repo precedent: the repo-guard ordering check);
|
|
|
|
|
# defaults fail closed.
|
|
|
|
|
sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
|
|
|
|
check "users close-root: sshd -t precedes the ssh restart" \
|
|
|
|
|
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
|
2026-07-17 20:49:12 +00:00
|
|
|
# Convergence is a claim about the DOOR, not the file. Matching bytes can hide
|
|
|
|
|
# an earlier-sorting override (first-wins) or a daemon that died between
|
|
|
|
|
# install and restart and never read the file — so the no-op message may only
|
|
|
|
|
# be spoken after the effective-config assertion (`sshd -T`), and the no-op
|
|
|
|
|
# branch may only be TAKEN when the daemon provably started after the last
|
|
|
|
|
# change to sshd's config inputs. Pin both: the assert-before-claim ordering,
|
|
|
|
|
# and the daemon-start-vs-config-mtime proof's presence.
|
|
|
|
|
efft_at="$(grep -n 'sshd -T' "$ROOT/commands/users-close-root.sh" | grep -v '^[0-9]*:#' | head -n1 | cut -d: -f1)"
|
|
|
|
|
noop_at="$(grep -n 'nothing to do' "$ROOT/commands/users-close-root.sh" | tail -n1 | cut -d: -f1)"
|
|
|
|
|
check "users close-root: no-op claim sits after the effective-config assert" \
|
|
|
|
|
0 "" test "${efft_at:-999999}" -lt "${noop_at:-0}"
|
|
|
|
|
check "users close-root: no-op needs a daemon start newer than the config" 0 "" \
|
|
|
|
|
grep -q "ExecMainStartTimestamp" "$ROOT/commands/users-close-root.sh"
|
fix(users): review findings — invoker gate, real SSH revocation, StrictModes-shaped close-root gate, trait-aware box role
Seven review findings on the users family, each with the harness check that
would have caught it:
- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
role rig silently root-equivalent through the very command that granted it.
Identity management now refuses any sudo invoker outside rig-admin; direct
root (bring-up, a root shell) proceeds.
- Offboarding revokes SSH, not just the password: a '!'-locked password is
not a closed door under UsePAM — Debian sshd still honors the pubkey. A
dropped user's account is now expired (usermod -L -e 1, the switch PAM
actually enforces) and authorized_keys is renamed to
authorized_keys.revoked-by-rig — access revoked, data kept, convergence
never destroys. Present users get their expiry cleared idempotently, so a
re-added user comes back to life.
- The ledger remembers: two-field lines ('name active' / 'name revoked',
legacy bare names read as active), so dropped users no longer vanish from
rig's memory on the next rewrite. status now reports the ledger state
corroborated by the account's real expiry — passwd -S read L for everyone
(apply locks all passwords always), so its locked/active was meaningless —
and flags a mismatch loudly as drift.
- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
and mode converge on every run, not only when content changes — StrictModes
treats them as load-bearing, so drifted perms were a broken login that
"already converged" lied about. Only the content write stays cmp-guarded.
- close-root's admin-door gate checks the StrictModes shape per candidate —
ownership, group/world-writability of home/.ssh/authorized_keys, a real
login shell, an unexpired account — and names which check failed. It proves
the door SHOULD open, not that it does; the separate-session advisory stays
load-bearing.
- Usernames are validated in the parser's one-pass refusal matrix
(^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
stream, and a leading '-' read as a useradd flag mid-convergence.
- The box role is trait-aware: on a host=no box an absent incus group skips
the role with a warning and converges everything else — one box-role user
in a fleet-wide file must not abort apply everywhere VMs don't live.
host=yes still dies pointing at box setup-host; a classless marker warns
toward a bootstrap re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:01:19 +00:00
|
|
|
# The admin-door gate must check the StrictModes SHAPE, not file existence: a
|
|
|
|
|
# non-empty authorized_keys behind group/world-writable perms is a key sshd
|
|
|
|
|
# rejects — closing root behind it welds the only door shut. The full gate
|
|
|
|
|
# needs root + real accounts, so grep the load-bearing check's wording.
|
|
|
|
|
check "users close-root: gate checks the StrictModes shape" 0 "" \
|
|
|
|
|
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
|
feat(users): close-root — shut the human-class root door once an admin key works
class decides root SSH's fate, and this is human's: install
/etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME
is the mechanism — sshd_config is first-wins, the Include glob expands
lexically, and '-' sorts before '.', so it is read before bootstrap's
00-rig.conf and wins. Gated three ways, no --force: a marker must exist
(never shut the root door blind), it must say class=human (on a server root
is the control plane's automation identity — closing it severs fleet
management), and some rig-admin member must already hold a non-empty
authorized_keys (never close the only door). The gate's policy lives in the
lib as assert_marker_human so the harness proves every refusal against
fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at
the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim —
cmp-guard, sshd -t on the merged config before the restart with rollback,
then the sshd -T effective assertion. Bootstrap's own permitrootlogin
assertion widens to accept 'no': the closed door is strictly harder, never
broken, and by first-wins bootstrap cannot reopen it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:28:25 +00:00
|
|
|
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
|
|
|
|
|
# path sits behind the root check, so the gate is a pure lib function on
|
|
|
|
|
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command
|
|
|
|
|
# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate
|
|
|
|
|
# stays pointable at fixtures.
|
|
|
|
|
marker_gate() { # marker_gate <marker_path>
|
|
|
|
|
bash -c 'set -euo pipefail
|
|
|
|
|
. "$1/commands/lib/users-config.sh"
|
|
|
|
|
assert_marker_human "$2"' _ "$ROOT" "$1"
|
|
|
|
|
}
|
|
|
|
|
MARKER_DIR="$(mktemp -d)"
|
|
|
|
|
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server"
|
|
|
|
|
printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
|
|
|
|
|
check "users close-root: absent marker refuses, names bootstrap as the repair" \
|
|
|
|
|
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
|
|
|
|
|
check "users close-root: class=server refuses, names the control plane" \
|
|
|
|
|
1 "control plane" marker_gate "$MARKER_DIR/server"
|
|
|
|
|
check "users close-root: class=human passes the gate" \
|
|
|
|
|
0 "" marker_gate "$MARKER_DIR/human"
|
|
|
|
|
rm -rf "$MARKER_DIR"
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
|
|
|
|
|
else
|
|
|
|
|
echo "skip: users close-root non-root refusal (running as root)"
|
|
|
|
|
fi
|
|
|
|
|
# Bootstrap must read the closed door as hardened, not broken: `no` is the
|
|
|
|
|
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
|
|
|
|
|
# the widened assertion so a revert cannot ship green.
|
|
|
|
|
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
|
|
|
|
|
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
|
fix(bootstrap): review findings — keep-mode for authkey re-runs, fail-closed login verify, class-gated root-door assertion
Three refusals, one doctrine: detect, refuse, name the repair — and never
back out state rig did not create.
- verify_effective_tag grows the same <back-out|keep> mode discipline as
verify_user_owned. First join keeps the logout-and-die on an untagged key;
the already-joined path now refuses WITHOUT logout — the untagged node may
be a login-joined workstation (untagged by design) that a join=authkey
re-run must not tear off the tailnet. The die names both ways out.
- verify_user_owned fails CLOSED on a stalled backend: empty tags is its
success signal, so a 30s poll that never saw Running waved a tagged node
on a slow tailscaled through as user-owned. state!=Running now dies in
both modes, logging nothing out — nothing was verified, so the repair is
to re-run and verify, not to undo a join that may be fine.
- The permitrootlogin acceptance is class-gated. class=human keeps
no|prohibit-password|without-password (`no` is the close-root state).
class=server accepts only prohibit-password|without-password: root SSH is
the control plane's automation door, and `no` there means a leftover
00-rig-users.conf from a former class=human life has fleet management
silently dead. Refused loudly, drop-in named, never auto-removed —
silently reopening a root door is worse than a loud stop.
Harness greps pin all three die messages so a deleted guard cannot ship
green (repo precedent: the tag-refusal greps).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:53 +00:00
|
|
|
# ...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"
|
feat(users): close-root — shut the human-class root door once an admin key works
class decides root SSH's fate, and this is human's: install
/etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no), where the NAME
is the mechanism — sshd_config is first-wins, the Include glob expands
lexically, and '-' sorts before '.', so it is read before bootstrap's
00-rig.conf and wins. Gated three ways, no --force: a marker must exist
(never shut the root door blind), it must say class=human (on a server root
is the control plane's automation identity — closing it severs fleet
management), and some rig-admin member must already hold a non-empty
authorized_keys (never close the only door). The gate's policy lives in the
lib as assert_marker_human so the harness proves every refusal against
fixture markers as non-root; RIG_ROLE_MARKER keeps the command pointable at
the same fixtures. Apply is bootstrap's validate-then-apply shape verbatim —
cmp-guard, sshd -t on the merged config before the restart with rollback,
then the sshd -T effective assertion. Bootstrap's own permitrootlogin
assertion widens to accept 'no': the closed door is strictly harder, never
broken, and by first-wins bootstrap cannot reopen it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:28:25 +00:00
|
|
|
|
fix(coolify): validate the dump bindings, and stop printing $EDITOR
Both found by the first run on a real control-plane box — neither was
reachable by the argument-parsing tests.
$EDITOR is unset on a freshly-bootstrapped server, which is precisely rig's
target environment. The printed next-step `$EDITOR /etc/coolify-dump.env`
expanded to nothing, so bash tried to EXECUTE the 0600 bindings file and said
"Permission denied" — an error that reads like a filesystem problem and is not
one. Print `nano`.
A bare bucket name in S3_BUCKET reads to `aws` as a LOCAL path, so the upload
died with "Invalid argument type" and a usage dump — after pg_dump had run and
age had encrypted 14MB, with nothing in the error pointing at the actual
mistake. The script now validates the bindings up front: S3_BUCKET must be an
s3:// URI, S3_ENDPOINT must carry a scheme. Both fail with the value quoted and
the reason stated, before a database is read.
Note what still cannot be validated, and now says so in the script: age's X25519
header does not reveal its recipient, so a valid-but-WRONG key (staging's
instead of prod's) yields a flawless backup nobody can open. Only decrypting an
artifact proves the recipient. The printed next-steps now walk through that
read-back explicitly, from a machine holding the private key — never the box.
The dump script ships as an embedded heredoc, so a typo in it would first
surface at 04:00 on a live control plane. test/cli.sh now extracts it and
asserts it is valid bash and that both new guards fire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:55:39 +00:00
|
|
|
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
|
|
|
|
# error in it would be invisible here and would first surface at 04:00 on a live
|
|
|
|
|
# control plane. Extract it and syntax-check what actually gets written.
|
|
|
|
|
DUMP_TMP="$(mktemp)"
|
|
|
|
|
sed -n "/<<'DUMP_SCRIPT'/,/^DUMP_SCRIPT\$/p" "$ROOT/commands/coolify-backup-install.sh" \
|
|
|
|
|
| sed '1d;$d' > "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script extracted (guards the sed above)" 0 "" grep -q "pg_dump" "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script is valid bash" 0 "" bash -n "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script rejects a bare bucket name" 1 "must be an s3:// URI" \
|
|
|
|
|
env AGE_RECIPIENT=age1x S3_BUCKET=my-bucket S3_ENDPOINT=https://s3.example.com bash "$DUMP_TMP"
|
|
|
|
|
check "embedded dump script rejects a schemeless endpoint" 1 "needs a scheme" \
|
|
|
|
|
env AGE_RECIPIENT=age1x S3_BUCKET=s3://b/k S3_ENDPOINT=s3.example.com bash "$DUMP_TMP"
|
|
|
|
|
rm -f "$DUMP_TMP"
|
|
|
|
|
|
2026-07-11 19:37:48 +00:00
|
|
|
# Regression: /etc/os-release defines VERSION (e.g. "13 (trixie)" on Debian);
|
|
|
|
|
# sourcing it in the main shell clobbers a script's $VERSION and splices the
|
|
|
|
|
# OS string into download URLs. It must only ever be sourced in a subshell.
|
|
|
|
|
check "no main-shell os-release sourcing" 1 "" \
|
|
|
|
|
grep -rnE '^[[:space:]]*\.[[:space:]]+/etc/os-release' "$ROOT/commands"
|
|
|
|
|
|
2026-07-10 20:40:07 +00:00
|
|
|
echo "---"
|
|
|
|
|
echo "$PASS passed, $FAIL failed"
|
|
|
|
|
[ "$FAIL" -eq 0 ]
|