2026-07-10 20:43:24 +00:00
#!/usr/bin/env bash
2026-07-11 08:25:48 +00:00
# rig bootstrap — OS plumbing for a pristine Debian box.
2026-07-10 20:43:24 +00:00
# Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail
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
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
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. " $HERE /lib/sshd.sh " # harden_sshd — shared with the staging tenant
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. " $HERE /lib/users-config.sh " # parse_users_file — the --users PRE-FLIGHT only
feat: /etc/rig/manifest — which rig converged this machine, and when
A rig-managed machine recorded nothing about its own provenance. The entire
durable output of a bootstrap run was one line in /etc/rig/role, and that line
says what the box IS, never what built it. VERSION was read in exactly one
place (bin/rig:9, for --version) and reports the currently INSTALLED tree, not
the one that ran; there was no timestamp anywhere in the codebase.
bootstrap now stamps a second file beside the marker: schema=1, a birth pair
(bootstrapped_by/_at, pinned forever) and a latest pair (converged_by/_at).
key=value, one per line, 0644 — the one file that must stay readable on the
most broken machine in the fleet, where there is no YAML parser and no jq.
`rig manifest [<key>]` reads it back.
Only DECIDED facts go in, which is what keeps bootstrap.sh:3's convergence
contract intact: bootstrapped_* is first-write-wins, and converged_* updates
only when the version actually differs — it is the time the converging version
last changed, not the time of the last run. The renderer is pure, so a re-run
by the same rig is byte-identical no matter where the clock is, and the
cmp-guard stays silent. OBSERVED facts (cores, RAM, disk, kernel) stay out:
they go stale on their own and belong to `rig platform` (#64).
/etc/rig/role is untouched.
Closes #61
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:05:13 +00:00
# shellcheck source=SCRIPTDIR/lib/manifest.sh
. " $HERE /lib/manifest.sh " # manifest_stamp — provenance, written beside the marker
2026-07-25 10:57:52 +00:00
# shellcheck source=SCRIPTDIR/lib/templates.sh
. " $HERE /lib/templates.sh " # registry-backed machine-role definitions
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# The users lib is sourced for validation, never for convergence: `users apply`
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
# borrows the parser so a typo'd users file is caught in the same breath as a
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# bad --root-door — before apt, before the tailnet join, before a pre-auth key is
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# spent — instead of at the very end of a run the operator already paid for.
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
2026-07-11 08:25:48 +00:00
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 } " ; }
2026-07-10 20:43:24 +00:00
usage( ) {
cat <<'EOF'
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
usage: rig bootstrap <control-plane-server| workload-server| runner-server|
staging-server| dev-server| workstation| custom>
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
( --users <path> | --no-users)
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
[ --hostname <name>] [ --root-door <closed| open>]
2026-07-17 19:15:06 +00:00
[ --host <yes| no>] [ --join <authkey| login>]
2026-07-24 23:00:41 +00:00
rig bootstrap <role>-box [ --user <name>]
( the box TENANT roles — the agent tenants come from the
heavy-duty/rig-templates registry, staging-box from
rig' s own tree; see their own --help — they take no
--users, see below)
2026-07-24 01:29:58 +00:00
rig bootstrap --undo
leave the tailnet only when the role marker proves rig
performed the join, then remove the role marker
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
--users the users file this box' s operators come from — REQUIRED. It is
applied as bootstrap' s last phase, exactly as ` rig users apply
--file <path>` would, so one command leaves a box with its
people on it. Passed per invocation and never persisted.
--no-users the deliberate opt-out — bootstrap converges the OS and the
tailnet and leaves the box with root as its only door.
2026-07-17 19:15:06 +00:00
--hostname system + tailnet hostname ( default: the role name; custom has
no default and requires it)
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
--root-door what happens to root SSH after the users phase — closed| open.
closed: ` rig users close-root` shuts it once named operators can
get in. open: it stays, as the control plane' s automation door.
( Named --class human| server before #77 — same trait, renamed for
what it decides rather than for who lives on the box. Markers
written by the old flag are still read.)
2026-07-17 19:15:06 +00:00
--host does this box host VMs ( box/Incus) — yes| no
--join how it enters the tailnet — authkey| login
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
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
One of --users/--no-users is required on every role, root-door= open included:
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
a box nobody logs into routinely is exactly where shared-root access rots,
and per-human accounts keep attribution intact for the times someone does go
in. So the complete path is the default path and skipping it is a deliberate
--no-users, not an omission.
2026-07-24 23:00:41 +00:00
--users does NOT reach the box TENANT roles ( any '-box' name, e.g.
claude-box, staging-box) . A
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
non-interactively, with no file to hand it; the guest never joins the tailnet
and has no SSH door of its own — entry is ` box shell` , gated by the HOST' s
incus grants, which the host' s own users file already converged. A fleet-wide
operator file has nothing to converge in there.
2026-07-17 19:15:06 +00:00
Roles are presets over the three traits; any flag overrides its trait.
custom presets nothing and requires --hostname plus all three traits.
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
role root-door host join
control-plane-server open no authkey
workload-server open no authkey
runner-server open no authkey
staging-server open yes authkey
dev-server closed yes authkey
workstation closed yes login
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
THE SUFFIX NAMES THE FAMILY, not the door policy. '-server' means this role builds a
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
fleet MACHINE — a tailnet node rig converges; '-box' ( the tenant roles) means a
GUEST a box mints. Two families lived in one flat namespace and nothing in a
name said which you were asking for ; 'staging' made that concrete by naming
both the metal and the guests on it.
custom no suffix: it presets nothing and can be any shape, a guest
included, so a family claim would be one it cannot make.
workstation no suffix: somebody' s own device, not fleet infrastructure —
it joins by interactive login and comes up user-owned and
untagged, and the tailnet never manages it.
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
'dev-server --root-door closed' says what is true and says it once: the suffix
names the FAMILY ( a fleet machine) , the trait names the DOOR ( operators enter a
dev box as themselves, so 'users close-root' shuts its door) . Until #77 this
trait was '--class human|server' , which named the wrong axis — who lives on the
box — and left 'dev-server' reading as a class = human contradiction. Nobody
lives on a dev box; what makes it different is that its root door closes.
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
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
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
( tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
outright, and only control-plane-server and workload-server may carry
tag:server ( they are the only shapes the control plane manages) . Mint a
correctly-tagged key.
2026-07-17 19:15:06 +00:00
join = authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
env var, or enter it at the interactive prompt. Used once, never written to disk.
2026-07-10 20:43:24 +00:00
2026-07-17 19:15:06 +00:00
join = login: no pre-auth key — ` tailscale up` prints a login URL and the human
at the keyboard is the credential, so the node comes up user-owned and
UNTAGGED ( a tag here is refused and backed out) . A set TS_AUTHKEY is a usage
error: unset it, or pass --join authkey.
2026-07-10 20:43:24 +00:00
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE = " ${ 1 :- } "
2026-07-25 10:57:52 +00:00
MACHINE_TEMPLATE_DIR = ""
2026-07-10 20:43:24 +00:00
case " $ROLE " in
2026-07-24 01:29:58 +00:00
--undo)
shift
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
exec " $HERE /bootstrap-undo.sh " ; ;
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
control-plane-server| workload-server| runner-server| staging-server| dev-server| workstation| custom) shift ; ;
2026-07-24 23:00:41 +00:00
*-box)
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
# The box TENANT roles (#31) are a different family — guests a box mints,
# never tailnet machines — and live in their own mechanism, one script
2026-07-24 23:00:41 +00:00
# parameterized per DEFINITION fetched from the template registry (#110;
# staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not
# an enumerated list: which '-box' roles exist is the registry's fact, so
# a template added there is mintable with zero code changes here.
# `rig bootstrap <role>` stays the single entrypoint for both families.
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
exec " $HERE /bootstrap-tenant.sh " " $@ " ; ;
2026-07-10 20:43:24 +00:00
-h| --help) usage; exit 0 ; ;
2026-07-24 23:00:41 +00:00
"" ) usage >& 2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ; ;
2026-07-25 10:57:52 +00:00
*)
shift
templates_resolve || exit 2
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
MACHINE_TEMPLATE_DIR = " $REGISTRY_DIR / $ROLE "
2026-07-25 13:07:53 +00:00
if [ [ ! " $ROLE " = ~ ^[ a-z] [ a-z0-9-] *-server$ ] ] \
2026-07-25 11:00:21 +00:00
|| [ " $( template_family " $ROLE " 2>/dev/null || true ) " != "machine" ] \
2026-07-25 10:57:52 +00:00
|| [ ! -f " $MACHINE_TEMPLATE_DIR /template.env " ] ; then
MACHINE_ROLES = " $( templates_machine_roles " $REGISTRY_DIR " | paste -sd'|' -) "
[ -n " $MACHINE_ROLES " ] || MACHINE_ROLES = "none"
die " unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom; machine roles from $( templates_source_desc) : $MACHINE_ROLES ; or a '-box' tenant role) " 2
fi
machine_template_parse_env " $MACHINE_TEMPLATE_DIR /template.env " \
|| die " invalid machine role $ROLE from $( templates_source_desc) " 2 ; ;
2026-07-17 19:15:06 +00:00
esac
# Role→traits map — the single place a role's shape is declared (issue #26).
# Roles are presets, nothing more: every behavior below keys off the traits,
# so a flag override changes behavior without a new role, and custom exists
# for the shape nobody foresaw — it declares nothing and must state all three.
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
ROOT_DOOR = "" HOST = "" JOIN = ""
2026-07-17 19:15:06 +00:00
case " $ROLE " in
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
control-plane-server) ROOT_DOOR = open HOST = no JOIN = authkey ; ;
workload-server) ROOT_DOOR = open HOST = no JOIN = authkey ; ;
runner-server) ROOT_DOOR = open HOST = no JOIN = authkey ; ;
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
# The unattended VM host — the shape #31 retired when 'staging' moved to the
# tenant family, restored under a name that cannot be confused with its own
# guests. host=yes is the whole point: it is what installs the box CLI and
# runs box's setup-host further down, so this is a table row, not machinery.
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
staging-server) ROOT_DOOR = open HOST = yes JOIN = authkey ; ;
dev-server) ROOT_DOOR = closed HOST = yes JOIN = authkey ; ;
workstation) ROOT_DOOR = closed HOST = yes JOIN = login ; ;
2026-07-17 19:15:06 +00:00
custom) ; ;
2026-07-25 10:57:52 +00:00
*) ROOT_DOOR = " $TPL_ROOT_DOOR " HOST = " $TPL_HOST " JOIN = " $TPL_JOIN " ; ;
2026-07-10 20:43:24 +00:00
esac
2026-07-17 19:15:06 +00:00
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
2026-07-10 20:43:24 +00:00
TS_HOSTNAME = " $ROLE "
2026-07-17 19:15:06 +00:00
[ " $ROLE " = "custom" ] && TS_HOSTNAME = ""
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
USERS_FILE = ""
NO_USERS = 0
2026-07-10 20:43:24 +00:00
while [ $# -gt 0 ] ; do
case " $1 " in
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
--users)
[ $# -ge 2 ] || die "--users needs a value" 2
USERS_FILE = " $2 " ; shift 2 ; ;
--no-users)
NO_USERS = 1; shift ; ;
2026-07-10 20:43:24 +00:00
--hostname)
[ $# -ge 2 ] || die "--hostname needs a value" 2
TS_HOSTNAME = " $2 " ; shift 2 ; ;
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
--root-door)
[ $# -ge 2 ] || die "--root-door needs a value" 2
2026-07-17 19:15:06 +00:00
case " $2 " in
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
closed| open) ROOT_DOOR = " $2 " ; ;
*) die " bad --root-door: $2 (want closed|open) " 2 ; ;
2026-07-17 19:15:06 +00:00
esac
shift 2 ; ;
--host)
[ $# -ge 2 ] || die "--host needs a value" 2
case " $2 " in
yes| no) HOST = " $2 " ; ;
*) die " bad --host: $2 (want yes|no) " 2 ; ;
esac
shift 2 ; ;
--join)
[ $# -ge 2 ] || die "--join needs a value" 2
case " $2 " in
authkey| login) JOIN = " $2 " ; ;
*) die " bad --join: $2 (want authkey|login) " 2 ; ;
esac
shift 2 ; ;
2026-07-10 20:43:24 +00:00
--ts-tag)
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 GONE, but this is a deliberate death with a message, not an
# "unknown flag": the flag shipped for a month and scripts still pass it,
# so it must explain where the tag went rather than look like a typo. The
# tag is now the key's to state and rig's to verify after join (issue #16 —
# the tag was said twice and rig never checked the two agreed). Consume a
# following value if present so `--ts-tag tag:server` dies on the flag and
# its argument never lands in the *) arm as a mystery unknown flag.
[ $# -ge 2 ] && shift
die "--ts-tag is removed: the tailnet tag comes from the pre-auth key now, not rig. Mint a key with the tag you want; rig verifies the granted tag after join." 2 ; ;
2026-07-10 20:43:24 +00:00
*) die " unknown flag: $1 " 2 ; ;
esac
done
2026-07-17 19:15:06 +00:00
# custom must state its whole shape — collect every gap and report them at once,
# so the operator fixes the command line in one round trip, not four.
if [ " $ROLE " = "custom" ] ; then
MISSING = ""
[ -n " $TS_HOSTNAME " ] || MISSING = " $MISSING --hostname "
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
[ -n " $ROOT_DOOR " ] || MISSING = " $MISSING --root-door "
2026-07-17 19:15:06 +00:00
[ -n " $HOST " ] || MISSING = " $MISSING --host "
[ -n " $JOIN " ] || MISSING = " $MISSING --join "
[ -z " $MISSING " ] || die " role custom has no presets; missing: $MISSING " 2
fi
# A set TS_AUTHKEY on a login join is a usage error, caught before the root
# check: the operator plainly expected the key to be spent, and silently
# ignoring a credential is how the wrong join path ships unnoticed.
if [ " $JOIN " = "login" ] && [ -n " ${ TS_AUTHKEY :- } " ] ; then
die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2
fi
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# --- who lives here (#51) -----------------------------------------------------
# The users file is the last piece of "what this box is" that bootstrap did not
# take, and it is REQUIRED rather than optional: a bootstrapped box with no
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# users converges to a box only root can enter, and on root-door=closed that is a
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# half-built machine waiting for a second command the operator has to remember
# (`rig users close-root` is itself gated behind "once your admin key works" —
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# which needs an admin to exist). root-door=open gets the same requirement on
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# purpose: a server nobody logs into routinely is exactly where shared-root
# access rots, and per-human accounts keep attribution intact for the times
# someone does go in.
#
# The opt-out is a FLAG, not a default. Both states are then something the
# operator said out loud, which is the whole point — an omitted --users used to
# be indistinguishable from "I meant to and forgot", and the box that resulted
# looked identical either way. Contradicting yourself is a usage error too:
# --users and --no-users together is not a precedence puzzle rig should silently
# resolve, because whichever way it resolved would be the wrong one half the
# time.
if [ -n " $USERS_FILE " ] && [ " $NO_USERS " -eq 1 ] ; then
die "--users and --no-users are contradictory: pass the file, or say --no-users, not both" 2
fi
if [ -z " $USERS_FILE " ] && [ " $NO_USERS " -eq 0 ] ; then
die "one of --users <path> or --no-users is required: bootstrap converges this box's operators as its last phase, and a box with no named users is one only root can enter. Pass --users <path>, or --no-users to leave it root-only deliberately" 2
fi
# The users file is PRE-FLIGHTED here and applied at the very end: everything
# below this point costs the operator something — apt, a hostname change, a
# single-use pre-auth key — and a users file with a typo in it must not be
# discovered after all of that was already spent. Same reason every other flag
# is validated before the root check: errors belong at the top of the run.
USERS_HAS_BOX_ROLE = 0
if [ -n " $USERS_FILE " ] ; then
# '-' (stdin) is apply's own convenience and cannot survive the trip through
# bootstrap: stdin here belongs to the pre-auth key prompt, and a users file
# piped in would either eat that prompt or be eaten by it. Refuse the token
# rather than let the two credentials-shaped reads fight over one pipe.
[ " $USERS_FILE " != "-" ] \
|| die "--users needs a real path: bootstrap's stdin is the pre-auth key prompt's, so it cannot also carry the users file. Write it to a file, or run 'rig users apply --file -' separately after --no-users" 2
[ -r " $USERS_FILE " ] || die " cannot read users file: $USERS_FILE " 2
if ! USERS_PARSED = " $( parse_users_file " $USERS_FILE " ) " ; then
die " invalid users file: $USERS_FILE — every error is listed above; nothing was changed " 2
fi
fix(bootstrap): refuse a users file that names no users
An empty, comments-only or whitespace-only users file is not a parse error,
so it walked straight through the requirement #51 built: pre-flight passed,
apply converged nothing, and the box came up root-only — the exact outcome
--no-users exists to make explicit, reached by the flag added to guarantee
the opposite. `--users ./empty` and `--no-users` produced the identical box
and only one of them said so.
Catch the zero-user parse in bootstrap's pre-flight, where the file is
already parsed for validation and before apt, the hostname change, or a
spent pre-auth key. The refusal names --no-users: the root-only box is
reachable, it just has to be asked for out loud.
Deliberately narrow. This is bootstrap's contract, not the parser's and not
apply's: zero users is a legal file, and a standalone `rig users apply`
against an emptied file is a real de-provisioning operation that must stay
possible. Negative-grep tests pin both.
Closes #57
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:28:39 +00:00
# A file that parses to ZERO users is not a parse error — empty, comments-only
# and whitespace-only files are all perfectly valid input, and the parser is
# right to accept them. But they walk straight through the requirement #51
# built: `--users ./empty-file` and `--no-users` converge the identical
# root-only box, and only one of them says so. The ambiguity the required flag
# exists to kill would survive in a narrower form — an operator who pointed at
# the wrong path and one who meant root-only would again produce indis-
# tinguishable boxes, which is precisely the state the flag was made to end.
#
# Refuse, and name --no-users: the outcome is reachable, it just has to be
# said out loud. That is the whole shape of #51's contract — both answers are
# available, neither is a side effect of what you did not type.
#
# The sharper reason this belongs at pre-flight rather than nowhere: against a
# box that ALREADY has operators, a truncated file does not converge nothing,
# it revokes every one of them. That is apply's correct and documented
# drop-semantics and it warns per user, so it is loud rather than silent — but
# a stray '>' is all it takes to produce that file, and every other failure
# mode on this command was deliberately made to fail before apt, the hostname
# change, or a spent pre-auth key. This one should not be the exception that
# fails after them.
#
# Deliberately scoped to BOOTSTRAP, not to the parser and not to apply. The
# lib stays a parser — "zero users is not allowed here" is bootstrap's policy,
# not a property of the file format — and a standalone `rig users apply`
# against an emptied file remains a real de-provisioning operation that must
# keep working. Bootstrap is where the claim "this box's people are these" is
# being made, so bootstrap is where an empty answer is a contradiction.
if [ -z " $USERS_PARSED " ] ; then
die " users file names no users: $USERS_FILE parsed to zero operators (it is empty, or only comments and blank lines). Bootstrapping with it would converge a box only root can enter — the same outcome as --no-users, reached by the flag that exists to guarantee the opposite. Check the path, or pass --no-users to leave this box root-only deliberately " 2
fi
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# Does anyone in the file carry role box? That single fact decides whether the
# incus precondition below applies at all — a users file naming only admins
# converges perfectly well on a host that has never seen Incus, and refusing
# it there would be rig inventing a prerequisite its own apply does not have.
if printf '%s\n' " $USERS_PARSED " | cut -d'|' -f2 | grep -qE '(^|,)box(,|$)' ; then
USERS_HAS_BOX_ROLE = 1
fi
fi
# host=yes + a box-role user + no incus group = the refusal `users apply` already
# owns ("rig NEVER installs Incus: box's setup-host owns the daemon and its
# group"). rig does not build that stack here and does not call setup-host —
# whether rig should install box on a VM host is an open boundary question, and
# resolving it by accident inside a users change would be the worst way to
# answer it. What bootstrap CAN do is stop early instead of late.
#
# Early only where the outcome is already PROVEN, though. The ordinary host=yes
# path installs box further down, and box's own installer runs setup-host — so
# the group that is missing now will exist by the time the users phase runs, and
# an unconditional refusal here would reject the exact bring-up this issue is
# about. RIG_SKIP_BOX_INSTALL=1 is the one case with no such rescue: the
# operator has said this run will not touch box, so the group's absence is final
# and the run is doomed a hundred lines before it notices. The other failure
# shapes (no network, box's installer breaking) are not knowable this early and
# land in apply's own refusal at the end — the same message, one phase later.
#
# #49 (merged) added a SECOND host-level refusal to apply: the box CLI itself
# missing on host=yes, because the group is only the socket and the tier is
# `box grant`, which apply calls rather than reimplements. Under
# RIG_SKIP_BOX_INSTALL=1 that absence is just as final and just as knowable
# now as the group's, so the early check mirrors both rather than being a
# weaker proxy for one of them. Either one alone dooms the run.
if [ " $USERS_HAS_BOX_ROLE " -eq 1 ] && [ " $HOST " = "yes" ] \
&& [ " ${ RIG_SKIP_BOX_INSTALL :- } " = "1" ] ; then
if ! getent group incus >/dev/null 2>& 1; then
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent and RIG_SKIP_BOX_INSTALL=1 means this run will not install box — install the box CLI and run 'box setup-host' first; rig never installs Incus. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2
fi
if ! command -v box >/dev/null 2>& 1; then
die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH and RIG_SKIP_BOX_INSTALL=1 means this run will not install it — the incus group is only the socket; the restricted tier is 'box grant', which rig calls rather than reimplements. Install the box CLI first. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2
fi
fi
2026-07-10 20:43:24 +00:00
# --- guards ------------------------------------------------------------------
[ " $( id -u) " -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ] ; then
2026-07-11 19:37:48 +00:00
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.
2026-07-10 20:43:24 +00:00
# shellcheck source=/dev/null
2026-07-11 19:37:48 +00:00
OS_FAMILY = " $( . /etc/os-release && printf '%s %s' " ${ ID :- } " " ${ ID_LIKE :- } " ) "
case " $OS_FAMILY " in
2026-07-10 20:43:24 +00:00
*debian*) ; ;
2026-07-11 19:37:48 +00:00
*) warn " not a Debian-family system ( ${ OS_FAMILY :- unknown } ); proceeding anyway " ; ;
2026-07-10 20:43:24 +00:00
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
2026-07-17 19:15:06 +00:00
# A host=yes box exists to run VMs, so no /dev/kvm deserves a loud note — but
# only a note: the shape is rehearsed in containers, where /dev/kvm is
2026-07-17 15:51:36 +00:00
# legitimately absent, and rig cannot tell a rehearsal from a misconfigured box.
2026-07-17 19:15:06 +00:00
if [ " $HOST " = "yes" ] && [ ! -e /dev/kvm ] ; then
warn "/dev/kvm is absent — a host=yes box is expected to run VMs. Harmless in a container rehearsal; on real hardware, enable virtualization (VT-x/AMD-V) in firmware."
2026-07-17 15:51:36 +00:00
fi
2026-07-10 20:43:24 +00:00
2026-07-12 15:26:34 +00:00
# The pre-auth key is acquired LATER, in the tailscale block — and only if the
# box has not already joined. rig is convergent by contract, so re-running it to
# pick up a fix (e.g. the 2026-07-12 sshd first-wins fix) must not demand a
# credential it will never spend: prompting up front made the repair path cost a
# throwaway Tailscale key, which is exactly the friction that stops people from
# re-running it.
2026-07-10 20:43:24 +00:00
# --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND = noninteractive
log "installing base packages"
apt-get update -qq
2026-07-11 10:38:14 +00:00
# openssh-server: a rig box is managed over SSH (Coolify SSHes in as root),
# and the hardening drop-in below targets /etc/ssh/sshd_config.d/ — which
# only exists once the package is installed. Cloud images ship it; pristine
# container/VM images (the Incus rehearsal) do not.
apt-get install -y -qq curl ca-certificates unattended-upgrades openssh-server
2026-07-10 20:43:24 +00:00
# enable periodic unattended upgrades (canonical file; idempotent overwrite)
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1" ;
APT::Periodic::Unattended-Upgrade "1" ;
EOF
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
# --- 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,
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# validate-then-restart, assert sshd -T not the file, the root-door-gated
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
# permitrootlogin acceptance) moved with it, verbatim.
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
harden_sshd " $ROOT_DOOR "
2026-07-12 15:26:34 +00:00
# --- system hostname ----------------------------------------------------------
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
# passed --hostname only to `tailscale up`, so a box reached as `coolify-box`
# still greeted the operator with Hetzner's default (`root@internal-tooling`).
# The shell prompt is the operator's only "am I on the right box" signal before
# they run something destructive, and it was lying on every box rig built.
if [ " $( hostname) " != " $TS_HOSTNAME " ] ; then
log " setting system hostname to ${ TS_HOSTNAME } "
hostnamectl set-hostname " $TS_HOSTNAME "
# keep 127.0.1.1 in step, or sudo/sshd warn about an unresolvable host
if grep -qE '^127\.0\.1\.1[[:space:]]' /etc/hosts; then
sed -i -E " s/^127\.0\.1\.1[[:space:]].*/127.0.1.1\t ${ TS_HOSTNAME } / " /etc/hosts
else
printf '127.0.1.1\t%s\n' " $TS_HOSTNAME " >> /etc/hosts
fi
else
log " system hostname already ${ TS_HOSTNAME } "
fi
2026-07-10 20:43:24 +00:00
# --- tailscale ----------------------------------------------------------------
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
# verify_effective_tag — assert the tag control actually GRANTED this node, never
# the one rig requested. This is the sshd `sshd -T` lesson wearing a tailnet hat:
# rig used to advertise a tag and trust it took, exactly as it once trusted that
# a drop-in FILE existing meant sshd had read it. Both M900s joined carrying
# tag:server and had to be retagged by hand; nothing in rig noticed because
# nothing ever read the effective tag back.
#
# `.Self.Tags` from `tailscale status --json` is the netmap's ground truth.
# `tailscale debug prefs` would LIE here — it prints AdvertiseTags, i.e. what was
# REQUESTED — which is precisely the second source of truth issue #16 deletes.
# Tags ride in with the netmap, not synchronously out of `up`, so a single read
# right after join can legitimately come back empty; poll until tags appear OR
# the backend reaches Running (past which an empty Tags is real, not just early).
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
#
# verify_effective_tag <back-out|keep> — same mode discipline as
# verify_user_owned: back-out on first join (rig just spent the key, so an
# untagged result is rig's own mess to undo), keep on the already-joined path
# (never back out state rig did not create — the join there may be a
# legitimately login-joined, user-owned workstation that someone re-ran with
# join=authkey by mistake).
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
verify_effective_tag( ) {
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
local mode = " $1 " deadline = $(( SECONDS + 30 )) tags = "" state = "" json
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 = " $( mktemp) "
while :; do
if tailscale status --json > " $json " 2>/dev/null; then
tags = " $( json_string_array " $json " Tags) "
state = " $( json_field " $json " BackendState) "
if [ -n " $tags " ] || [ " $state " = "Running" ] ; then break; fi
fi
if [ " $SECONDS " -ge " $deadline " ] ; then break; fi
sleep 2
done
rm -f " $json "
# UNTAGGED is the real hazard and it is silent: with no tags the node joined
# owned by the KEY CREATOR's user identity — it inherits that human's ACL
# grants, expires with the key, and vanishes if the account is deleted.
# Dropping --advertise-tags removed the accidental net that used to tag such a
# node anyway, so rig must now catch this out loud. A wrong tag cannot be fixed
# in place (`tailscale set` has no tag flag; re-tagging needs a fresh key via
# `up --force-reauth`), so back the node out rather than leave a half-joined,
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
# user-owned device squatting a hostname. That back-out is EARNED only on the
# first-join path, where rig itself just performed the join; on the
# already-joined path an untagged node may be exactly what someone built on
# purpose — a login-joined workstation is untagged BY DESIGN — and tearing it
# off the tailnet because a re-run said join=authkey would destroy state rig
# did not create. keep mode refuses without touching the join and names both
# ways out, since rig cannot tell which one the operator meant.
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
if [ -z " $tags " ] ; then
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
if [ " $mode " = "back-out" ] ; then
tailscale logout >/dev/null 2>& 1 \
|| warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand"
die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run."
fi
die "this box is joined but UNTAGGED — possibly a login-joined (user-owned) machine re-run with join=authkey. It was joined before this run, so nothing was backed out. If it should be fleet-owned: run 'tailscale logout' and re-run with a TAGGED pre-auth key. If it is a workstation: re-run with --join login."
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
fi
2026-07-17 19:15:06 +00:00
# tag:server policy is DERIVED, not a trait: it means "the control plane
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
# manages this box", and only control-plane-server and workload-server are shapes the
2026-07-17 19:15:06 +00:00
# control plane manages. Everything else refuses it on the EFFECTIVE tag —
# strictly stronger than the old request-time check, which only guarded the
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
# tag rig HOPED for. The fleet has been bitten both ways: a runner-server carrying
2026-07-17 19:15:06 +00:00
# tag:server extends every server grant to repo-controlled code, and a
# staging host carrying it extends them to a box the control plane does not
# even know. Refused, never warned; rig can DETECT this but cannot FIX it,
# so each refusal names its repair.
if printf '%s\n' " $tags " | grep -qx 'tag:server' ; then
case " $ROLE " in
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
control-plane-server| workload-server) ; ;
runner-server)
die " role runner-server 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). " ; ;
2026-07-17 19:15:06 +00:00
*)
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
# This arm owns the VM-host shape too — 'staging-server' by name now,
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# plus custom/dev-server --root-door open: a host is never managed by the
feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
box templates collapse to thin, creds-free seeds (box#81); everything a
tenant machine BECOMES moves here, as convergent, re-runnable roles with
effective-state asserts. One mechanism (bootstrap-tenant.sh) parameterized
per tenant through a pure lib (tenant-config.sh) — never four copies —
dispatched from bootstrap.sh so 'rig bootstrap <role>' stays the single
entrypoint.
The agent tenants land the toolbelt (git, gh, tmux, …), docker, the agent's
CLI on the SYSTEM path (box exec shells read no rc files, #15), and the
agent-context file — rendered from ONE shared template that carries the
box#80 guard note once: never run box setup-host or the drill inside a box;
the box you are in is not a host you own. staging lands box#69's server
posture — docker + sshd hardening — through lib/sshd.sh, extracted verbatim
from bootstrap.sh so both families converge ONE drop-in with one converger;
its tailnet workload join stays operator-run, exactly the creds split #69
designed. Everything is asserted on effective state: the CLI must ANSWER as
the tenant user (the grok template's linked-but-cannot-run scar), docker
must answer, sshd -T must resolve.
'staging' therefore moves from the VM-host preset to the tenant role — the
thing box#81's seed will auto-run. The host shape lost nothing: it is
'dev --class server' (or custom with all three traits), the catch-all
effective-tag refusal still owns its tag policy, and a pre-#31 staging host
re-running its old command gets a loud refusal naming the new spelling —
tenants refuse host=yes boxes, agents refuse any machine-role box, staging
tolerates the workload-joined guest and leaves its marker alone.
Harness: the arg/refusal surface, the marker guards off fixture markers,
the pure parameter table, the rendered context file (guard included, all
three agents), creds-free-by-absence greps (no tailscale, no prompt), the
CLI-verified-not-trusted pin, marker-after-converge ordering, and the
re-pointed sshd-lib pins. 241 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:49:20 +00:00
# control plane — its guest VMs are — so tag:server is refused there
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
# like everywhere else outside the two control-plane-managed shapes.
# Mint the metal's key with tag:local.
die " role $ROLE joined with tag:server (effective tags: $( printf '%s' " $tags " | tr '\n' ' ' ) ). Only control-plane-server and workload-server 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). " ; ;
2026-07-17 19:15:06 +00:00
esac
2026-07-17 15:51:36 +00:00
fi
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
log " verified effective tailnet tag(s): $( printf '%s' " $tags " | tr '\n' ' ' ) "
}
2026-07-17 19:15:06 +00:00
# verify_user_owned <back-out|keep> — join=login INVERTS the tag assertion:
# the whole point of a login join is a user-owned, untagged node, so here a tag
# is the hazard (control granted this device fleet identity) and untagged is
# the success case. Same poll as verify_effective_tag — tags ride the netmap —
# but the empty read is what we WANT once the backend reaches Running.
# back-out: first join, so a refusal logs the node out (mirror of the
# untagged-key back-out on the authkey path). keep: the box was already joined
# before this run — never back out state rig did not create; detect, refuse,
# and name the by-hand repair instead.
verify_user_owned( ) {
local mode = " $1 " deadline = $(( SECONDS + 30 )) tags = "" state = "" json shown
json = " $( mktemp) "
while :; do
if tailscale status --json > " $json " 2>/dev/null; then
tags = " $( json_string_array " $json " Tags) "
state = " $( json_field " $json " BackendState) "
if [ -n " $tags " ] || [ " $state " = "Running" ] ; then break; fi
fi
if [ " $SECONDS " -ge " $deadline " ] ; then break; fi
sleep 2
done
rm -f " $json "
if [ -n " $tags " ] ; then
shown = " $( printf '%s' " $tags " | tr '\n' ' ' ) "
if [ " $mode " = "back-out" ] ; then
tailscale logout >/dev/null 2>& 1 \
|| warn "tailscale logout failed — this node is joined TAGGED; remove it from the tailnet by hand"
die " joined TAGGED ( ${ shown } ) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity; use a pre-auth key path (--join authkey) for fleet machines. Backed it out. "
fi
die " this node is TAGGED ( ${ shown } ) but join=login expects a user-owned, untagged node — a tag here means control granted this device fleet identity. It was joined before this run, so nothing was backed out: run 'tailscale logout' and re-run bootstrap, or re-run with --join authkey. "
fi
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
# Fail CLOSED on a poll that never reached Running: empty tags is this
# function's SUCCESS signal, which makes a timeout uniquely dangerous here —
# a tagged node on a slow tailscaled reads as empty and would be waved
# through as user-owned (verify_effective_tag has the mirror problem, but
# there timeout-empty already lands in a refusal). Nothing was verified
# either way, and the join may be perfectly fine, so neither mode logs out;
# the only honest move is to stop and have the operator re-run the verify.
if [ " $state " != "Running" ] ; then
die "tailscale backend never reached Running within 30s — could not verify the join is user-owned and untagged. Nothing was backed out; re-run bootstrap to verify once tailscaled settles."
fi
2026-07-17 19:15:06 +00:00
log "user-owned join verified (untagged)"
}
2026-07-10 20:43:24 +00:00
if ! command -v tailscale >/dev/null 2>& 1; then
log "installing tailscale"
curl -fsSL https://tailscale.com/install.sh | sh
fi
2026-07-24 01:29:58 +00:00
JOIN_BY = preexisting
2026-07-10 20:43:24 +00:00
if tailscale status >/dev/null 2>& 1; then
2026-07-12 15:26:34 +00:00
log "tailnet already joined; skipping tailscale up (no pre-auth key needed)"
2026-07-12 15:47:38 +00:00
# ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name
# never converged: a box that joined under the wrong name (e.g. --hostname
# omitted, so it defaulted to the ROLE) stayed misnamed forever, and re-running
# rig — the documented repair — could not fix it. rig is convergent by
# contract; this was the one field that wasn't. `tailscale set` converges it
# without a re-auth or a pre-auth key.
#
# Safe by construction here: Tailscale ACLs cannot bind a rule's dst to a
# hostname (it must be a tag, an IP, or a `hosts` alias — which is exactly why
# acl.hujson pins coolify-box to an IP), so a rename cannot silently void a
# grant. It also will NOT clobber a deliberate rename: a machine renamed in the
# admin console keeps that name, and the device hostname no longer overrides it.
current_ts_name = " $( tailscale status --peers= false 2>/dev/null | awk 'NR==1 {print $2}' ) "
if [ -n " $current_ts_name " ] && [ " $current_ts_name " != " $TS_HOSTNAME " ] ; then
log " tailnet hostname is ' ${ current_ts_name } ', want ' ${ TS_HOSTNAME } ' — converging "
tailscale set --hostname= " $TS_HOSTNAME " \
|| warn " tailscale set --hostname failed; rename ' ${ current_ts_name } ' -> ' ${ TS_HOSTNAME } ' in the admin console "
else
log " tailnet hostname already ${ TS_HOSTNAME } "
fi
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
# Verify the tag on the already-joined path too, not only on first join: this
# catches a box bootstrapped BEFORE rig looked at tags, or one retagged behind
# rig's back, on the very next ordinary re-run. Skipping `tailscale up` here is
# deliberate and stays — re-running an identical tagged-authkey `up` errors —
# but skipping the CHECK was how the M900s stayed mis-tagged unnoticed.
2026-07-17 19:15:06 +00:00
# The check the traits demand: authkey wants the granted tag, login wants none
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
# — both in `keep` mode: never back out a join this run did not perform.
2026-07-17 19:15:06 +00:00
if [ " $JOIN " = "login" ] ; then
verify_user_owned keep
else
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
verify_effective_tag keep
2026-07-17 19:15:06 +00:00
fi
elif [ " $JOIN " = "login" ] ; then
# No pre-auth key on this path — the human at the keyboard is the credential.
# `tailscale up` prints a login URL and blocks until the browser login lands.
log " joining tailnet as ${ TS_HOSTNAME } (interactive login; follow the URL tailscale prints) "
tailscale up --hostname= " $TS_HOSTNAME "
verify_user_owned back-out
2026-07-24 01:29:58 +00:00
JOIN_BY = rig
2026-07-10 20:43:24 +00:00
else
2026-07-19 12:15:08 +00:00
# env override, else prompt; never touches disk. The prompt only fires on a
# tty: with no terminal, a bare `read` exits non-zero and `set -e` would end
# the whole bootstrap with NO last word — the 2026-07-19 drill met exactly
# that, a log that stops mid-converge with exit 1 and nothing to grep. An
# unattended run gets the same refusal every other guard gives: loud, and
# naming the variable that unblocks it.
2026-07-12 15:26:34 +00:00
if [ -z " ${ TS_AUTHKEY :- } " ] ; then
2026-07-19 12:15:08 +00:00
[ -t 0 ] || die "TS_AUTHKEY is unset and stdin is not a tty — set TS_AUTHKEY to run unattended"
read -rsp "tailscale pre-auth key (single-use, tagged, <=1h expiry): " TS_AUTHKEY || { echo; die "no pre-auth key read (EOF) — set TS_AUTHKEY to run unattended" ; }
2026-07-12 15:26:34 +00:00
echo
fi
[ -n " ${ TS_AUTHKEY :- } " ] || die "empty pre-auth key"
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
# No --advertise-tags: the key's own tags apply (documented default for a
# tagged key), and rig verifies them below instead of stating a second tag it
# cannot reconcile with the key's. A tagged key needs no flag; an untagged one
# cannot be rescued by one (verify_effective_tag refuses it and logs out).
log " joining tailnet as ${ TS_HOSTNAME } (tag comes from the pre-auth key) "
tailscale up --authkey= " $TS_AUTHKEY " --hostname= " $TS_HOSTNAME "
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
verify_effective_tag back-out
2026-07-24 01:29:58 +00:00
JOIN_BY = rig
2026-07-10 20:43:24 +00:00
fi
2026-07-17 19:15:06 +00:00
# --- role marker --------------------------------------------------------------
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users`
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# reads root-door= from it to decide root SSH's fate). Written only AFTER the
# tag verification, so a marker never describes a box that failed to become what
# it claims — and cmp-guarded like every file rig converges.
#
# The line is written in the CURRENT vocabulary only — `root-door=`, never the
# `class=` it replaced (#77). Writing both would keep an old rig reading a new
# marker, but it would also entrench the retired spelling on every box rig ever
# converges, and make the both-fields-disagree case reachable from rig's own
# hand instead of only from a text editor. The compat obligation runs the other
# way and is discharged in root_door_of: NEW rig reads OLD markers, because
# those exist in the field by the thousand and nothing will rewrite them.
2026-07-17 19:15:06 +00:00
MARKER = /etc/rig/role
MARKER_TMP = " $( mktemp) "
2026-07-24 01:29:58 +00:00
printf 'role=%s root-door=%s host=%s join=%s join-by=%s\n' \
" $ROLE " " $ROOT_DOOR " " $HOST " " $JOIN " " $JOIN_BY " > " $MARKER_TMP "
2026-07-17 19:15:06 +00:00
if ! cmp -s " $MARKER_TMP " " $MARKER " 2>/dev/null; then
mkdir -p /etc/rig
install -m 0644 " $MARKER_TMP " " $MARKER "
2026-07-24 01:29:58 +00:00
log " role marker written: role= $ROLE root-door= $ROOT_DOOR host= $HOST join= $JOIN join-by= $JOIN_BY "
2026-07-17 19:15:06 +00:00
else
log "role marker already current"
fi
rm -f " $MARKER_TMP "
feat: /etc/rig/manifest — which rig converged this machine, and when
A rig-managed machine recorded nothing about its own provenance. The entire
durable output of a bootstrap run was one line in /etc/rig/role, and that line
says what the box IS, never what built it. VERSION was read in exactly one
place (bin/rig:9, for --version) and reports the currently INSTALLED tree, not
the one that ran; there was no timestamp anywhere in the codebase.
bootstrap now stamps a second file beside the marker: schema=1, a birth pair
(bootstrapped_by/_at, pinned forever) and a latest pair (converged_by/_at).
key=value, one per line, 0644 — the one file that must stay readable on the
most broken machine in the fleet, where there is no YAML parser and no jq.
`rig manifest [<key>]` reads it back.
Only DECIDED facts go in, which is what keeps bootstrap.sh:3's convergence
contract intact: bootstrapped_* is first-write-wins, and converged_* updates
only when the version actually differs — it is the time the converging version
last changed, not the time of the last run. The renderer is pure, so a re-run
by the same rig is byte-identical no matter where the clock is, and the
cmp-guard stays silent. OBSERVED facts (cores, RAM, disk, kernel) stay out:
they go stale on their own and belong to `rig platform` (#64).
/etc/rig/role is untouched.
Closes #61
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:05:13 +00:00
# --- provenance manifest ------------------------------------------------------
# /etc/rig/manifest records WHICH rig converged this machine and WHEN (#61).
# The marker above says what the box IS; this says what BUILT it — two files,
# two jobs, and the marker is deliberately untouched (it has six readers,
# install.sh:82-90 among them).
#
# Placed HERE, immediately after the marker, so the two agree by construction
# and both inherit the marker's discipline verbatim: written only AFTER the tag
# verification, so neither ever describes a box that failed to become what it
# claims. A manifest that survives a failed run is worse than no manifest — it
# is a confident wrong answer. It deliberately does NOT trail the box install
# and the users phase below: those are the host EXTRA and the operator phase,
# and a box whose people failed to converge was still converged BY this rig at
# this time. Stamping provenance is not a claim that everything after it
# succeeded — the marker beside it makes exactly the same claim, and the two
# landing together is what keeps them readable as one statement.
#
# The version stamped is the one that RAN, captured now — not what `rig
# --version` would answer after a later upgrade.
if manifest_stamp " $( manifest_running_version " $HERE /.. " ) " ; then
log " provenance manifest written: $( manifest_path) "
else
log "provenance manifest already current"
fi
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# --- box install (host=yes only) -------------------------------------------
feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.
rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.
- Convergent: box's installer is a no-op once box is installed, so re-running
bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
pointer) when curl or the network is missing — box is the host EXTRA, so a
failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
it claims never installs box on a half-built host.
The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.
Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).
Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
# A host=yes box exists to run guest boxes, so bootstrap finishes the job rather
# than printing a to-do: it installs the box CLI globally and lets box's OWN
# setup-host build the Incus stack. Placed AFTER the role marker write on
# purpose — a box that failed to become what it claims (tag refused, join backed
# out) dies above and never reaches here, so box is never installed on top of a
# half-built host.
#
# rig DELEGATES to box; it never touches Incus itself. This is the same design
# law `rig users apply` enforces — "rig NEVER installs Incus: box's setup-host
# owns the daemon and its group." rig does not apt-install incus, does not
# configure the daemon, does not create the incus group. It runs BOX'S global
# installer as root, and box installs Incus via its setup-host. Two tools
# converging one daemon is drift by construction; box is the single owner.
#
# CONVERGENT: box's installer is a no-op when box is already installed — it says
# so and changes nothing — so re-running bootstrap is safe and cheap.
#
# OPT-OUT: RIG_SKIP_BOX_INSTALL=1 skips the whole step (a container rehearsal
# with no /dev/kvm, an offline box, or a host whose box you manage by hand). The
# step ALSO skips gracefully — with a warning pointing at the manual command —
# when curl is missing or the network is down: bootstrap's core job is OS
# hardening + the tailnet, and box is the host EXTRA, so a failed box install
# must never abort a bootstrap that otherwise fully succeeded.
#
2026-07-23 23:56:54 +00:00
# PIN POINTS: BOX_REPO / BOX_REF override the source (default
# heavy-duty/box@0.9.0). BOX_RELEASE is bumped deliberately when rig releases,
# after the pinned combination has passed the release drill.
feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.
rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.
- Convergent: box's installer is a no-op once box is installed, so re-running
bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
pointer) when curl or the network is missing — box is the host EXTRA, so a
failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
it claims never installs box on a half-built host.
The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.
Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).
Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
# BOX_YES=1 makes box's installer non-interactive AND keeps setup-host (so the
# Incus stack is actually built, not just the CLI dropped on PATH).
#
# rig#12's hard constraints hold here: the HOST joined the tailnet above; the
# guest boxes never do (box does not join the tailnet — fine), and there are no
# credentials on the host (box is creds-free — fine).
#
# DEPENDENCY (box#71): the GLOBAL, world-readable install path — box under
# /opt/box with a /usr/local/bin shim that every non-root user can read — depends
# on box PR #71. Until that merges, box's root install lands in /root and non-root
# users cannot reach it, so this step is only fully correct once box#71 is merged.
if [ " $HOST " = "yes" ] ; then
2026-07-23 23:56:54 +00:00
BOX_RELEASE = 0.9.0
feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.
rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.
- Convergent: box's installer is a no-op once box is installed, so re-running
bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
pointer) when curl or the network is missing — box is the host EXTRA, so a
failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
it claims never installs box on a half-built host.
The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.
Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).
Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
BOX_REPO = " ${ BOX_REPO :- heavy -duty/box } "
2026-07-23 23:56:54 +00:00
BOX_REF = " ${ BOX_REF :- $BOX_RELEASE } "
feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.
rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.
- Convergent: box's installer is a no-op once box is installed, so re-running
bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
pointer) when curl or the network is missing — box is the host EXTRA, so a
failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
it claims never installs box on a half-built host.
The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.
Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).
Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
BOX_INSTALL_URL = " https://raw.githubusercontent.com/ ${ BOX_REPO } / ${ BOX_REF } /install.sh "
2026-07-24 00:39:24 +00:00
BOX_MANUAL = " curl -fsSL ${ BOX_INSTALL_URL } | BOX_YES=1 BOX_REF= ${ BOX_REF } bash "
feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.
rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.
- Convergent: box's installer is a no-op once box is installed, so re-running
bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
pointer) when curl or the network is missing — box is the host EXTRA, so a
failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
it claims never installs box on a half-built host.
The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.
Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).
Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
if [ " ${ RIG_SKIP_BOX_INSTALL :- } " = "1" ] ; then
log " RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${ BOX_MANUAL } "
elif ! command -v curl >/dev/null 2>& 1; then
warn " curl not found — skipping box install; once curl is present, prepare Incus with: ${ BOX_MANUAL } "
else
log " installing box ( ${ BOX_REPO } @ ${ BOX_REF } ) and running its host setup — box owns Incus, not rig "
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
# already installed, so re-running bootstrap converges instead of reinstalling.
# A curl failure (no network) fails the pipe under pipefail and lands in the
# else — a warning, never an abort: box is the host extra, the OS+tailnet core
# is already done.
2026-07-24 00:39:24 +00:00
if curl -fsSL " $BOX_INSTALL_URL " | BOX_YES = 1 BOX_REF = " $BOX_REF " bash; then
feat(bootstrap): prove box landed on PATH after a claimed install success — don't trust exit codes (#12)
Issue #12's review comment named the failure shape exactly: box's
setup-host is written for a sudo-capable user and one of its paths exits
0 after only adding a group, asking for a re-login — so an installer's
exit code can claim a success that never took effect. That is the sshd
first-wins bug's shape, and rig's doctrine is to assert effective state.
The check stays deliberately light: command -v box proves the one
artifact rig asked the installer for. Anything deeper — daemon, pool,
network — is box's domain; rig never interrogates Incus, so the success
log hands the operator 'box doctor' (box's own effective-state verdict)
instead of reimplementing it. A hollow success WARNS with the manual
pointer, never dies: box is the host extra, and the OS+tailnet core is
already done and asserted by the time this block runs.
Tests grep the shipped script (the check needs root + network to
exercise): the call, the warn wording, the delegation to box doctor,
and a fail-closed line-number assert that the check follows the
installer run. Rides along: the README rename greps (#12) — the stale
heavy-duty/claudebox slug is negative-grepped out for good.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:46 +00:00
# Don't trust the exit code — prove the effective state (issue #12). An
# installer can exit 0 having done less than it claims: box's setup-host
# is written for a sudo-capable user, and one of its paths exits 0 after
# only adding a group, asking for a re-login. That is the sshd first-wins
# bug's exact shape — asserting what was REQUESTED (here: the installer's
2026-07-18 17:16:29 +00:00
# claimed success) instead of what actually TOOK.
2026-07-18 16:59:43 +00:00
# Two proofs, one claim each. `command -v box` proves the CLI landed
# (box's root install symlinks into /usr/local/bin, already on this
# shell's PATH — no login shell needed). "Host set up" is a separate
# claim and gets box's OWN effective-state verdict, `box doctor` —
# the daemon, the pool, the network stay box's domain (the same
# delegation law as the install itself: box owns the daemon), rig
# just refuses to claim what that verdict does not answer. Both
# failures WARN, never die: box is the host EXTRA, and the OS+tailnet
# core above is already done and asserted.
feat(bootstrap): prove box landed on PATH after a claimed install success — don't trust exit codes (#12)
Issue #12's review comment named the failure shape exactly: box's
setup-host is written for a sudo-capable user and one of its paths exits
0 after only adding a group, asking for a re-login — so an installer's
exit code can claim a success that never took effect. That is the sshd
first-wins bug's shape, and rig's doctrine is to assert effective state.
The check stays deliberately light: command -v box proves the one
artifact rig asked the installer for. Anything deeper — daemon, pool,
network — is box's domain; rig never interrogates Incus, so the success
log hands the operator 'box doctor' (box's own effective-state verdict)
instead of reimplementing it. A hollow success WARNS with the manual
pointer, never dies: box is the host extra, and the OS+tailnet core is
already done and asserted by the time this block runs.
Tests grep the shipped script (the check needs root + network to
exercise): the call, the warn wording, the delegation to box doctor,
and a fail-closed line-number assert that the check follows the
installer run. Rides along: the README rename greps (#12) — the stale
heavy-duty/claudebox slug is negative-grepped out for good.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:46 +00:00
if command -v box >/dev/null 2>& 1; then
2026-07-18 16:59:43 +00:00
if box doctor >/dev/null 2>& 1; then
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
else
warn " box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${ BOX_MANUAL } ) "
fi
feat(bootstrap): prove box landed on PATH after a claimed install success — don't trust exit codes (#12)
Issue #12's review comment named the failure shape exactly: box's
setup-host is written for a sudo-capable user and one of its paths exits
0 after only adding a group, asking for a re-login — so an installer's
exit code can claim a success that never took effect. That is the sshd
first-wins bug's shape, and rig's doctrine is to assert effective state.
The check stays deliberately light: command -v box proves the one
artifact rig asked the installer for. Anything deeper — daemon, pool,
network — is box's domain; rig never interrogates Incus, so the success
log hands the operator 'box doctor' (box's own effective-state verdict)
instead of reimplementing it. A hollow success WARNS with the manual
pointer, never dies: box is the host extra, and the OS+tailnet core is
already done and asserted by the time this block runs.
Tests grep the shipped script (the check needs root + network to
exercise): the call, the warn wording, the delegation to box doctor,
and a fail-closed line-number assert that the check follows the
installer run. Rides along: the README rename greps (#12) — the stale
heavy-duty/claudebox slug is negative-grepped out for good.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:50:46 +00:00
else
warn " box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${ BOX_MANUAL } "
fi
feat(bootstrap): host-class installs box + runs setup-host
A host=yes box exists to run guest boxes, so bootstrap finishes the job
instead of printing "next: install the box CLI and run 'box setup-host'".
After the role marker is written, on host=yes it installs the box CLI
globally and lets box's OWN setup-host build the Incus stack.
rig DELEGATES to box; it never touches Incus itself — the same design law
`rig users apply` enforces ("rig NEVER installs Incus: box's setup-host
owns the daemon and its group"). rig does not apt-install incus, does not
configure the daemon, does not create the incus group. It runs box's global
installer as root with BOX_YES=1 (non-interactive AND keeps setup-host);
box installs Incus. Two tools converging one daemon is drift by construction.
- Convergent: box's installer is a no-op once box is installed, so re-running
bootstrap changes nothing.
- Opt-out: RIG_SKIP_BOX_INSTALL=1 skips; also skips gracefully (with a manual
pointer) when curl or the network is missing — box is the host EXTRA, so a
failed box install never aborts a bootstrap that otherwise succeeded.
- Pinnable: BOX_REPO / BOX_REF (default heavy-duty/box@main).
- Runs only AFTER the role marker write, so a box that failed to become what
it claims never installs box on a half-built host.
The world-readable global install path (box under /opt/box, readable by every
non-root user) depends on box PR #71; until it merges box's root install lands
in /root. Noted in a comment and the plan doc.
Completes rig#12 (the dev role — the Incus claudebox host) and rig#25
(machine classes: host-class installs box + rig users).
Tests: 8 new bootstrap checks (guard on host=yes, BOX_YES install, pin
defaults, RIG_SKIP_BOX_INSTALL opt-out, negative-grep that rig never
apt-installs incus, box-after-marker ordering, manual-pointer on skip).
154 passed, 0 failed; shellcheck -x clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:45:40 +00:00
else
warn " box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${ BOX_MANUAL } "
fi
fi
fi
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# --- users (the last phase, and it must be last) -------------------------------
# Ordering is a correctness property, not a preference. `users apply` READS
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# /etc/rig/role: root-door= decides which root-SSH note it prints, and host= decides
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# what an absent incus group means (refuse on yes, skip the box role with a
# warning on no). Run before the marker write, apply would see no marker at all
# and warn "re-run rig bootstrap so this box knows what it is" — in the middle
# of the very bootstrap that is teaching it. Run before the box install, a
# host=yes box would refuse its own box-role users for a group arriving twenty
# lines later. So: traits, then join, then marker, then box, then people.
#
# NOT persisted. rig takes the path, reads it once through apply, and keeps
# nothing — the users file lives in your private infra repo and is passed per
# invocation (README: "rig never persists it"). Taking it as a bootstrap flag
# must not quietly turn it into box state, so nothing here copies it anywhere,
# and /etc/rig keeps only the ledger of NAMES apply already wrote.
#
# Invoked as a child, not exec'd: bootstrap still owns the last word (the
# next-steps below), and a failing apply must fail the bootstrap — under
# `set -e` a non-zero apply ends the run right here, which is correct. The box
# is already hardened and joined at this point; what failed is one named phase,
# and it is re-runnable on its own with `rig users apply --file <path>`.
#
# A child also keeps apply's INVOKER gate intact, which is the point: SUDO_USER
# rides through, so `sudo rig bootstrap --users <file-naming-me-admin>` by a
# role-rig user refuses exactly as `sudo rig users apply` would. Bootstrap must
# not become a laundering path around the one gate that stops rig's scoped sudo
# from being root-equivalent. Bring-up runs as real root and is unaffected.
if [ -n " $USERS_FILE " ] ; then
log " converging operators from ${ USERS_FILE } (rig users apply) "
" $HERE /users-apply.sh " --file " $USERS_FILE "
fi
2026-07-25 10:57:52 +00:00
# A registry machine's optional install is the final convergence phase: after
# join, host setup, the marker prerequisites, and operators. It inherits the
# caller environment, adds only the selected role, and runs from its definition
# directory. Definitions own idempotence, like bootstrap itself.
if [ -n " $MACHINE_TEMPLATE_DIR " ] && [ -e " $MACHINE_TEMPLATE_DIR /install.sh " ] ; then
log " running install hook for ${ ROLE } from $( templates_source_desc) "
2026-07-25 13:07:53 +00:00
if ! ( cd " $MACHINE_TEMPLATE_DIR " && RIG_ROLE = " $ROLE " bash ./install.sh) ; then
2026-07-25 10:57:52 +00:00
die " install hook failed for role $ROLE from $( templates_source_desc) "
fi
fi
2026-07-10 20:43:24 +00:00
log " done — role ${ ROLE } , hostname ${ TS_HOSTNAME } "
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
if [ " $ROLE " = "control-plane-server" ] ; then
2026-07-11 08:25:48 +00:00
log "next: rig coolify install --version <pin>"
feat(bootstrap)!: machine roles carry a -server suffix; staging-server restored
rig builds two kinds of thing on opposite sides of a trust boundary --
tailnet machines it converges, and guests a box mints -- and both families
lived in one flat namespace with nothing in a role name saying which you
meant. `staging` is where that stopped being cosmetic: the word names the
metal that hosts guests and the guests on it, only one could have it, and
#31 gave it to the guests. The VM-host shape was left nameless, spelled
`custom --class server --host yes --join authkey`, which is what every
refusal recited at an operator who had confused the two.
The suffix now names the family: control-plane-server, workload-server,
runner-server, dev-server, plus the restored staging-server (class=server
host=yes join=authkey). host=yes already installs the box CLI and runs box's
setup-host, so staging-server is a table row, not new machinery. It stays
OUT of the tag:server allow-list deliberately -- a host is never managed by
the control plane, its guests are -- so its key is minted tag:local.
custom and workstation keep bare names as the rule, not an exception to it:
custom presets nothing and can be any shape including a guest, so a family
claim is one it cannot make; a workstation is somebody's own device, joined
by interactive login, user-owned and untagged, never tailnet-managed.
Hard cut, no aliases -- old names are refused as unknown. Two consequences
this reaches beyond the CLI surface. TS_HOSTNAME defaults to the role name,
so a box taking the default now comes up control-plane-server. And the two
coolify commands match the ROLE NAME in /etc/rig/role, not the traits, so
they now look for role=control-plane-server; a pre-rename control plane
takes their warning branch, which is advisory and never a gate, so the run
proceeds and the message names the repair.
dev-server is class=human, which reads like a contradiction and is not: the
suffix names the family, the class names the root-SSH door policy. The two
axes share the word "server", which is a real wart -- #77 renames the class
trait to what it controls, kept separate because it reaches markers on live
machines that guard root SSH.
Tests cover both directions of the cut: every new name resolves, every old
name is refused as unknown, and the two deliberately-bare roles are proven
NOT to have been swept up -- the inverse error, which would otherwise only
surface at somebody's laptop.
Closes #76 (machine-role half)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:16 +00:00
elif [ " $ROLE " = "runner-server" ] ; then
2026-07-11 18:25:47 +00:00
log "next: rig runner install --repo <owner/repo> --version <pin>"
2026-07-17 19:15:06 +00:00
fi
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
# Every box gets operators: humans always enter as themselves and elevate via
# sudo — a shared root login is unattributable. What differs, per the root-door
# trait, is root SSH's fate once named users exist. With --users the accounts exist already, so
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
# the note that used to point at the missing command now points at what is left
# to do; --no-users still owes the box its people, and says so.
if [ -n " $USERS_FILE " ] ; then
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
if [ " $ROOT_DOOR " = "closed" ] ; then
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST"
else
log "operators are converged; root SSH stays — it is the control plane's automation door"
fi
2026-07-17 19:15:06 +00:00
else
feat(users)!: --class human|server becomes --root-door closed|open
The trait was named for who lives on a box; what it decides is whether
root SSH stays open as the control plane's automation door. Those are
different questions, and `dev-server` proved it: an unattended VM-host
appliance nobody lives on, correctly class=human because its root door
must close. After #76 gave `-server` the job of naming the machine
family, that box carried a suffix saying server and a trait saying
human. `dev-server --root-door closed` says what is true, once.
Unlike #76's role rename this field is read back on live machines, so
the compat read is mandatory rather than courteous: one resolver,
root_door_of, reads both vocabularies and every consumer goes through
it — close-root's gate, apply's note, and bootstrap-tenant's
machine-marker guard, which used the presence of `class=` as its "is
this a real fleet machine?" test and would otherwise have let a tenant
converge clobber a live box. New markers are written as `root-door=`
only. Markers carrying both fields in disagreement, or neither, fail
closed with a re-run-bootstrap repair.
Fixture markers are kept deliberately at the retired spelling (the
convention #76's pre-rename-cp fixture established) and pinned at both
consumers; deleting the compat arm turns ten checks red.
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:50 +00:00
if [ " $ROOT_DOOR " = "closed" ] ; then
feat!: bootstrap takes the users file
`rig bootstrap` already knew everything else about what a box is — class,
host, join, hostname — and wrote /etc/rig/role to say so. The users file was
the last piece of that answer it did not take, so bring-up was two commands
and the second one was the forgettable one.
--users <path> now runs the `users apply` convergence as bootstrap's final
phase: after the traits, after the verified tailnet join, after the role
marker (apply reads that marker), and after the host=yes box install (so
box-role users find the incus group box's own setup-host built). One
command, and the box has its people on it.
BREAKING: --users is required on every machine role, with --no-users as the
explicit opt-out. Omitting both is a usage error naming both flags; passing
both is a usage error too. class=server is required as well: a machine
nobody logs into routinely is exactly where shared-root access rots, and
per-human accounts keep attribution intact for the times someone does go in.
The file is never persisted — passed per invocation, read once through
apply, copied nowhere. `--users -` is refused: bootstrap's stdin belongs to
the pre-auth key prompt. The box TENANT roles take neither flag; a guest is
minted non-interactively, never joins the tailnet, and has no SSH door of
its own.
rig still never installs Incus and never calls `box setup-host` itself. The
host=yes box-role precondition refuses early only where the outcome is
already proven (RIG_SKIP_BOX_INSTALL=1); every other way that step can fail
lands in `users apply`'s existing refusal, unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:17:49 +00:00
log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
else
log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file <users-file>"
fi
2026-07-10 20:43:24 +00:00
fi