Seven review findings on the users family, each with the harness check that
would have caught it:
- Invoker gate (apply + close-root): %rig's sudoers rule is binary-scoped but
not argument-scoped, so `sudo rig users apply --file <me-as-admin>` made
role rig silently root-equivalent through the very command that granted it.
Identity management now refuses any sudo invoker outside rig-admin; direct
root (bring-up, a root shell) proceeds.
- Offboarding revokes SSH, not just the password: a '!'-locked password is
not a closed door under UsePAM — Debian sshd still honors the pubkey. A
dropped user's account is now expired (usermod -L -e 1, the switch PAM
actually enforces) and authorized_keys is renamed to
authorized_keys.revoked-by-rig — access revoked, data kept, convergence
never destroys. Present users get their expiry cleared idempotently, so a
re-added user comes back to life.
- The ledger remembers: two-field lines ('name active' / 'name revoked',
legacy bare names read as active), so dropped users no longer vanish from
rig's memory on the next rewrite. status now reports the ledger state
corroborated by the account's real expiry — passwd -S read L for everyone
(apply locks all passwords always), so its locked/active was meaningless —
and flags a mismatch loudly as drift.
- Perms are part of the converged state: ~/.ssh and authorized_keys ownership
and mode converge on every run, not only when content changes — StrictModes
treats them as load-bearing, so drifted perms were a broken login that
"already converged" lied about. Only the content write stays cmp-guarded.
- close-root's admin-door gate checks the StrictModes shape per candidate —
ownership, group/world-writability of home/.ssh/authorized_keys, a real
login shell, an unexpired account — and names which check failed. It proves
the door SHOULD open, not that it does; the separate-session advisory stays
load-bearing.
- Usernames are validated in the parser's one-pass refusal matrix
(^[a-z_][a-z0-9_-]{0,31}$): 'fo|o' corrupted the parser's own '|'-delimited
stream, and a leading '-' read as a useradd flag mid-convergence.
- The box role is trait-aware: on a host=no box an absent incus group skips
the role with a warning and converges everything else — one box-role user
in a fleet-wide file must not abort apply everywhere VMs don't live.
host=yes still dies pointing at box setup-host; a classless marker warns
toward a bootstrap re-run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .github/workflows | ||
| bin | ||
| commands | ||
| docs/plans | ||
| test | ||
| install.sh | ||
| README.md | ||
rig
A CLI that turns a pristine Debian server into a hardened, tailnet-joined node — one curl, one command. A second command installs a version-pinned Coolify on a control-plane box.
Philosophy (shared with claudebox): public tool, private state. rig carries plumbing logic only — no hostnames, no bindings, no secrets, nothing about your infrastructure. It takes arguments, does its work, and stores no credential, ever.
Install
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
Installs the tree to ~/.local/share/rig and links rig onto your
PATH (/usr/local/bin when root). Re-run any time to upgrade.
Commands
rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing.
rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-box
rig bootstrap runner --hostname my-ci-box
rig bootstrap staging --hostname my-vm-host
rig bootstrap dev --hostname my-dev-box
rig bootstrap workstation --hostname my-laptop
rig bootstrap custom --hostname odd-duck --class server --host yes --join authkey
--hostname <name>— system + tailnet hostname (default: the role name;customhas no default and requires it)--class <human|server>— who lives here; decides root SSH's fate afterrig users apply(see The identity model below)--host <yes|no>— does this box host VMs (box/Incus)--join <authkey|login>— how it enters the tailnet
Roles are presets over three orthogonal traits, nothing more — every
per-role behavior keys off a trait, so any flag overrides its trait without
needing a new role (rig bootstrap workstation --host no for a laptop that
will never run VMs), and custom exists for the shape nobody foresaw: it
presets nothing and requires --hostname plus all three traits.
| trait | values | what it drives |
|---|---|---|
class |
human, server |
root SSH's fate once operators exist — human closes it, server keeps it as the control plane's automation door |
host |
yes, no |
whether the box exists to run VMs — the /dev/kvm advisory and the box setup-host pointer |
join |
authkey, login |
tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) |
| role | class | host | join | tailnet tag |
|---|---|---|---|---|
control-plane |
server | no | authkey | tag:server |
workload |
server | no | authkey | tag:server |
runner |
server | no | authkey | tag:ci — refuses tag:server |
staging |
server | yes | authkey | tag:local — refuses tag:server |
dev |
human | yes | authkey | tag:local — refuses tag:server |
workstation |
human | yes | login | untagged — any tag refused |
The tag column is derived policy, not a fourth trait: tag:server means
"the control plane manages this box", and control-plane and workload are
the only shapes it manages — every other role refuses an effective
tag:server after join, one rule instead of per-role exceptions.
After the tag verification passes, bootstrap writes /etc/rig/role — one
line, role=… class=… host=… join=… — recording the effective traits,
overrides and all, so an overridden role never lies to the commands that read
the marker later (rig users keys root policy off class=). Written
post-join and cmp-guarded, so a marker never describes a box that failed to
become what it claims.
join=login inverts the tag assertion. A workstation joins as a
user-owned device: there is no pre-auth key — a set TS_AUTHKEY is a loud
usage error (exit 2; unset it, or pass --join authkey) — tailscale up
prints a login URL, and the human at the keyboard is the credential. After
join the assertion flips: untagged is what rig asserts, and any effective
tag is the refusal — a tag here means control granted this device fleet
identity, and on a first join the half-joined node is backed out with
tailscale logout (a box that was already joined is refused without backout;
rig never unwinds state it did not create). Same principle as the authkey
path, mirrored: verify what control granted, never what was requested.
There is no --ts-tag flag. A pre-auth key is minted with its tags, so
the key is the single source of truth for the tailnet tag — rig no longer states
a second one it might disagree with. It verifies the tag control actually
granted after join instead (see below). Passing --ts-tag now exits 2 with a
message pointing you at the key.
What it does: installs curl ca-certificates unattended-upgrades (and
enables periodic unattended upgrades); writes an sshd hardening drop-in
(PermitRootLogin prohibit-password, PasswordAuthentication no) and
verifies it took effect via sshd -T; sets the system hostname; installs
tailscale and joins your tailnet — then verifies the tag the key granted
(see The tag comes from the key below).
--hostname converges both names. On a box that has already joined,
bootstrap skips tailscale up (so a re-run needs no pre-auth key) — but it
still reconciles the tailnet hostname via tailscale set --hostname. Without
that, a box which joined under the wrong name — say --hostname was omitted, so
it defaulted to the role — stayed misnamed forever, and re-running rig, the
documented repair, could not fix it. A machine you deliberately renamed in the
admin console keeps that name; rig will not fight it.
Why the drop-in is
00-rig.confand not99-.sshd_configis first-wins — "for each keyword, the first obtained value will be used" (sshd_config(5)) — andIncludeexpands its glob in lexical order. Cloud images ship/etc/ssh/sshd_config.d/50-cloud-init.confcarryingPasswordAuthentication yes, so a99-drop-in is read second and every keyword in it is silently discarded. This is the opposite of the last-wins convention most config systems use, and it shipped green here for a month: rig asserted the file existed rather than whatsshdactually resolved, and the Incus rehearsal container has no cloud-init drop-in to lose to. Every Hetzner box rig had bootstrapped was still servingpasswordauthentication yes.bootstrapnow sweeps a stale99-rig.confon re-run, and refuses to claim success unlesssshd -Tagrees.
The pre-auth key (join=authkey roles — everything but workstation):
provide it via the TS_AUTHKEY env var or type it at
the interactive prompt. Use a single-use, tagged, short-expiry key — the
tagged part is now load-bearing, not advice (see below). It lives in process
memory only — rig never writes a credential to disk.
The tag comes from the key, and rig verifies the one control granted. rig
used to pass --ts-tag to tailscale up --advertise-tags, stating the 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;
this is the same shape as the sshd first-wins bug above, and it left the same
scar (both M900s joined carrying tag:server and had to be retagged by hand,
because nothing in rig ever read the effective tag back). So rig stops
overriding the key: tailscale up carries no --advertise-tags, the key's tags
apply, and after join rig polls tailscale status --json for .Self.Tags — the
netmap's ground truth, not tailscale debug prefs, which prints what was
requested — and asserts on that, on first join and on every re-run (which
catches a box bootstrapped before this change, or retagged behind rig's back).
An untagged key is a hard refusal. Drop
--advertise-tagsand you also drop the accidental net that used to tag an untagged key's node anyway. An untagged node joins 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. That is a fleet-shaped mistake, not a warning: rig runstailscale logoutto back the half-joined node out and dies telling you to mint a tagged key. A wrong tag cannot be fixed in place either —tailscale sethas no tag flag, re-tagging needs a fresh key viaup --force-reauth— so rig detects and refuses, and never claims a convergence it cannot perform.
control-plane and workload are identical today except the default
hostname; they exist because the boxes diverge over time, and because each
follow-up command applies to exactly one role. runner is the box a CI agent
will live on, and it differs behaviorally: it refuses tag:server. That
refusal moved onto the effective tag and is strictly stronger for it — it is
no longer "don't advertise tag:server" but "the key you actually used must not
grant tag:server to repo-controlled code." A runner executes that code, and
tag:server's grants (SSH between your servers, say) must never extend to it;
the check turns the worst misconfiguration from a documentation warning into a
hard, post-join error.
staging is the box that hosts staging boxes — Incus VMs minted by the
box CLI, each converged from inside with
rig bootstrap workload and registered in the control plane as its own server.
It is class=server: an unattended VM appliance — operators converge it and
leave; nobody lives there. Mint its key with tag:local: the host and its
guests sit on opposite sides of a trust boundary, and the host is never
managed by the control plane — so the role refuses an effective
tag:server, same mechanism as runner. rig deliberately installs no Incus
and no box here — box's own setup-host is the single owner of the Incus
daemon's configuration, and two tools converging one daemon is drift by
construction. The closing log points you at it: install box, run
box setup-host, then box new --template staging. If /dev/kvm is absent,
rig warns (a host that exists to run VMs should have it) but does not fail —
the shape is rehearsed in containers, which legitimately lack it.
dev is staging's human-class sibling — the same VM-hosting, tag:local
shape with a person living on it — and workstation is the machine at the
keyboard end of all the SSH connections: human-class, join=login, entering
the tailnet as your device rather than the fleet's.
The identity model
Named operators exist on every class, and humans never enter as root. The
tailnet is network-only — no Tailscale SSH — so there is no identity broker at
the door: whoever holds a key to an account is that account, and a shared
root login is unattributable by construction. rig users apply puts named
operators on every box, server-class included; a human always enters as
themself and elevates via sudo.
class decides root SSH's fate — after rig users apply, never before.
On class=human, root SSH closes entirely (rig users close-root, below).
On class=server it stays open — key-only, as bootstrap left it — because
root there is the automation identity the control plane (Coolify) SSHes
in as. It is a machine door, never a human one.
The detection side benefit: once humans never use root, any root login that is not the control plane is anomalous by definition — a cheap, high-signal alert that a shared root identity makes impossible to write.
The honest caveat: on a Docker-running box this buys attribution, not privilege reduction — an operator with sudo is root-equivalent anyway. Attribution is the goal: who did what survives, even where what they could do is everything.
rig coolify install --version <pin>
Control-plane box only. Installs Coolify at exactly the pinned version with
AUTOUPDATE=false — your deploy tooling is verified against an API surface;
the platform must never move underneath it on its own. Upgrading is an
explicit re-run with a new pin. The pin is required; there is no default.
rig coolify backup install
Control-plane box only. Installs a nightly age-encrypted dump of Coolify's own database as a systemd timer.
rig coolify backup install
rig coolify backup install --schedule '*-*-* 02:30:00 UTC' --pg-container coolify-db
--schedule <OnCalendar>— systemd calendar expression (default:*-*-* 04:00:00 UTC)--pg-container/--pg-user/--pg-db— Coolify's postgres (defaults:coolify-db,coolify,coolify)
That database holds the GitHub App private key, every registered server's SSH key,
and every environment value for every environment the control plane manages. It is
pg_dumped straight into age — encrypted client-side, on the box — and only
then shipped to S3. The bucket is never trusted with plaintext.
It is forensics, not a restore path. A lost control plane is rebuilt fresh and reconciled from your manifest, never restored from this artifact. Which is exactly why the plumbing belongs in rig: there will be a next control-plane box, and it should be backed up from birth rather than depending on someone remembering a runbook step mid-incident.
rig installs the machinery; you supply the bindings. rig writes
/etc/coolify-dump.env empty, 0600, and never reads it back — no credential
ever passes through rig. You fill in the age recipient (a public key), the S3
bucket + endpoint, and the S3 credentials. Until you do, the unit fails loudly on
every run: a silent backup is worse than a missing one.
rig cannot verify that the upload works — that needs your credentials. So prove it by hand once, rather than letting the timer discover it at 04:00:
systemctl start coolify-dump.service
journalctl -u coolify-dump.service -n 20 --no-pager
A backup you have never read back is not yet a backup.
rig runner install --repo <owner/repo>
Runner box only, run after rig bootstrap runner (the same two-step rhythm
as bootstrap control-plane → coolify install):
rig bootstrap runner --hostname my-ci-box
rig runner install --repo acme/widgets
Installs GitHub's official actions/runner as a systemd service under an
unprivileged user (default github-runner, created if absent, never root, no
supplementary groups). The runner is an agent, not a server: it long-polls
GitHub outbound and receives jobs down that already-established connection,
so it needs zero inbound ports and works fine behind a deny-all
firewall — it can even trigger deploys on hosts only it can reach, like a
tailnet-only control plane.
No Docker, deliberately: the Docker socket is a root API and docker group
membership is root-equivalent, which is a gratuitous path to root on a box
whose whole point is a narrow blast radius. Add Docker only once a job
genuinely needs it, and rethink the isolation model then.
--version <pin>— actions/runner release to install (default: the latest release, resolved at install time; e.g.--version 2.335.1— the latest as of this writing). Pin it when you need a deterministic, auditable install.--name <name>— runner name (default: this host's hostname)--labels <csv>— runner labels, replacing theci-runnerdefault — keep any label your workflows'runs-onneeds (GitHub addsself-hosteditself)--user <name>— the unprivileged service user (default:github-runner)
The registration token: provide it via the RUNNER_TOKEN env var or type
it at the interactive prompt. It's short-lived, consumed at registration, and
never written to disk by rig.
Why latest-by-default here when coolify install demands a pin: the two
tools age differently. Coolify never self-updates (AUTOUPDATE=false), so
its version is a contract your deploy tooling is verified against — stating
it is the point. The runner self-updates regardless: GitHub refuses jobs
from stale runners, so freezing it would just make it silently stop taking
work. The install-time version is a starting point either way; --version
exists for when you want that starting point deterministic and auditable.
Convergent toward --repo — re-running against the repo this box is
already on re-uses the binary, skips registration, and never asks for a token.
Pointed at a different repo it refuses, and names both: skipping there
would not be convergence, it would be ignoring the argument — restarting the
runner on the old repo while reporting success, leaving the repo you asked
for with no runner and its runs-on jobs queued forever. Moving a runner
between repos is a trust-boundary act; that verb is
rig runner repoint.
rig runner status
rig runner status
What this box's runner is registered to — repo, runner name, labels, install dir, systemd unit and its state. Reads the runner's own on-disk config; no token, no network call. Exits 1 when no runner is installed.
The answer to "wait, which repo is this box wired to?" should not require knowing that the config lives in a dotfile under an unprivileged user's home.
rig runner remove
rig runner remove
rig runner remove --local # no token; leaves a stale entry to delete by hand
Stops and uninstalls the systemd service, then deregisters the runner from
GitHub. The binary and its user stay put, so a later rig runner install
re-registers without downloading anything.
The token here is a removal token, not a registration token — a different endpoint, and mixing them up is the easy mistake:
gh api -X POST repos/<owner/repo>/actions/runners/remove-token
Supply it via RUNNER_REMOVE_TOKEN or the prompt; it never touches disk.
--local is the escape hatch for when the registration is already gone
server-side (or you can't mint a token): the box is cleaned, but a stale
offline runner stays listed in the repo, for you to delete from
Settings → Actions → Runners.
The service always comes down first, in both paths. GitHub's own removal
refuses to run while the service is installed ("Uninstall service first"),
and --local skips that check entirely — which would otherwise leave a
running service pointed at config that no longer exists.
Convergent — a box with no runner installed exits 0.
rig runner repoint --repo <owner/repo>
rig runner repoint --repo acme/widgets
Moves an installed runner from one repository to another: deregister,
re-register, reusing the binary already on the box. It keeps the runner's
existing name unless you pass --name.
This is the verb that was missing. runner install can create a runner but
never move one — pointed at a repo the box is not on, it fails and sends you
here — and re-pointing a box otherwise meant hand-rolled config.sh/svc.sh
incantations against an install path only rig knew.
Two short-lived tokens, each minted from its own repo — RUNNER_REMOVE_TOKEN
for the one it's leaving, RUNNER_TOKEN for the one it's joining. Both are
collected before anything is torn down: a token you turn out not to have
should fail while the runner is still registered and working, not halfway
through the move. If re-registration fails anyway, rig says so plainly and
prints the exact runner install line that finishes the job.
Labels do not survive a move on their own. GitHub holds them; the runner does not persist them locally. rig now records what it registered with, so
repointandstatuscan read it back — but a runner installed before rig did that has nothing to read, andrepointfalls back to theci-runnerdefault and warns loudly before it touches anything. Labels are whatruns-onmatches, so a silent change there is a workflow that simply stops finding its runner. Pass--labelsif yours differ.
Convergent — repointing to the repo it is already on changes nothing, exits 0, and never asks for a token.
rig users apply --file <path>
Converges named operator accounts from a declarative users file — on every class (see The identity model). Run as root. Convergent: a second identical run says "already converged; no changes".
# user roles ssh public key
dan admin,box ssh-ed25519 AAAA... dan@laptop
dan admin,box ssh-ed25519 AAAA... dan@desktop
maria rig,box ssh-ed25519 AAAA... maria@mac
One line per key — user, comma-joined roles, then the SSH public key (the rest
of the line). The format is bash-parseable on purpose: a rig box has no YAML
parser and no jq, and gets neither for this. Repeated username lines add
authorized keys, and the roles must be identical on each — a repeated line
always means "another key", never a quiet role edit hiding mid-file. root is
refused as a username: this file names operators; root's fate is class policy.
--file - reads stdin. A bad file exits 2 with every error listed at
once, before anything changes — one fix cycle, not one round-trip per line.
Public tool, private state, here too. The users file lives in your private infra repo and is passed per invocation — rig never persists it. It holds nothing secret anyway: usernames, roles, and public keys.
| role | grants | via group |
|---|---|---|
admin |
full NOPASSWD sudo | rig-admin |
rig |
NOPASSWD sudo for /usr/local/bin/rig only |
rig |
box |
Incus restricted tier, no sudo | incus |
The honest limit of the rig role: its sudo grant is binary-scoped, not
argument-scoped — it trusts its holder with every rig verb except identity
management. The rig users commands gate their invoker: run under sudo
by anyone outside rig-admin, they refuse. Without that gate, sudo rig users apply against a file naming yourself admin would make the scoped grant
silently root-equivalent through the very tool it scopes. Direct root — a
bring-up shell, before any admin exists — proceeds.
box binds where VMs live, and a users file is fleet-wide — its box grants
are not. rig never installs Incus — box's setup-host owns the daemon — so
when the incus group is absent, the host= trait decides: on host=yes
apply dies pointing at box setup-host (a VM host missing Incus is a real
problem) rather than conjure a group the (nonexistent) daemon would never
consult; on host=no the box role is skipped with a warning and
everything else — admins included — still converges, because one box-role
user somewhere in the fleet must not stop apply everywhere VMs don't live.
incus-admin is deliberately not a role: that group is
host-root-equivalent, break-glass by hand only.
All passwords stay locked, always — created or found. The SSH key at the door is the authentication, and NOPASSWD sudo does not weaken it: there was never a password to guess or rotate.
Convergence is exact. Membership in the three rig-managed groups is made to
match the file — added and removed — while every other group is left alone:
not rig's to converge. authorized_keys becomes exactly the file's keys, and
its ownership and mode (and .ssh's) are converged on every run, not
just when content changes — sshd's StrictModes treats them as
load-bearing, so drifted perms are a broken login that "already converged"
would lie about. A user dropped from the file is found via the
/etc/rig/users ledger and revoked, never deleted: the account is
expired — the switch PAM actually enforces; a locked password alone still
lets a pubkey in under Debian's UsePAM — and authorized_keys is renamed
to authorized_keys.revoked-by-rig. Access revoked, data kept: deletion
frees the uid for reuse and orphans file ownership, so attribution would rot;
home stays for the same reason, and re-adding the user to the file brings
them back, fresh keys and all. And the sudoers rules land in
/etc/sudoers.d/rig-roles only after visudo -c passes on the candidate — a
bad file under /etc/sudoers.d can take down all of sudo, locking every
admin out of the very escalation path apply just granted.
rig users status
rig users status
Read-only truth: per rig-managed user, the roles derived from the groups the
user is actually in — not the ledger's memory of an apply — plus the
authorized_keys count (revoked when only the .revoked-by-rig rename
remains) and the user's state, active or revoked. The state is the
ledger's word corroborated by the account's real expiry — the switch that
actually revokes — and a mismatch is flagged loudly as drift: a box someone
changed behind rig's back must never read as healthy. Reads the box only; no
network, no writes. Run as root (shadow is read).
rig users close-root
rig users close-root
Shuts the root SSH door — class=human boxes only, and only once a named
admin can already get in. The gates run in order: the /etc/rig/role marker
must say class=human — an absent marker refuses (never shut the root door
blind; re-run bootstrap so the box knows what it is), and class=server
refuses with no --force, because root there is the control plane's
automation identity and closing it severs fleet management. Then at least one
rig-admin member must hold a login sshd would plausibly accept — a
non-empty authorized_keys alone proves a file, not a door: the gate checks
the StrictModes shape (home, .ssh, and authorized_keys owned by the
user and not group/world-writable), a real login shell, and an unexpired
account, and its refusal names which check failed, per candidate. It proves
the door should open, not that it does — which is why the separate-session
verification below stays load-bearing. Never close the only door.
Before running it, prove the admin door in a separate session — ssh <admin>@<box> while this one stays open. Root SSH is being welded shut; the
admin login must be proven, not presumed.
The drop-in's name is the entire mechanism. close-root installs
/etc/ssh/sshd_config.d/00-rig-users.confcarrying exactlyPermitRootLogin no. sshd_config is first-wins,Includeexpands its glob lexically, and-(0x2D) sorts before.(0x2E) — so00-rig-users.confis read before bootstrap's00-rig.confand beats itsprohibit-password. Bootstrap's effective-config assertion accepts the closed state (nois strictly harder than what it installs), and by the same first-wins order its own drop-in can never reopen it — a bootstrap re-run on a closed box leaves it closed. Validate-then-apply as everywhere:sshd -tbefore the restart, rollback on failure, and success is only claimed oncesshd -Tresolvespermitrootlogin no.
Convergent — once root is closed, a re-run says "root already closed; nothing to do" and exits 0.
On
class=server, root stays — so lock its key instead. This is README guidance, deliberately not automation: prefix Coolify's line in root'sauthorized_keyswith afrom="<control-plane-addr>"clause, so the automation identity only opens from the one address supposed to use it. rig will not write that file — Coolify owns its key material on the servers it registers, and two tools converging one file is drift by construction (the same argument that keeps rig's hands off Incus).
What rig deliberately does NOT do
- Provider firewalls — Docker publishes ports past host firewalls, so the real boundary is your cloud provider's firewall, configured outside this tool.
- Fetch your config — boxes never receive repo credentials. Everything rig needs arrives as arguments or an interactive prompt.
- Manage deployments — deploy manifests/executors are separate concerns.
(Planned: the
apply/diffexecutor half joins rig as commands that run on operator machines, never on boxes.)
Testing
bash test/cli.sh (dependency-free assertions) + shellcheck run in CI. The
rig users family is covered the same way: the harness drives its refusal
matrix — users-file parsing, the marker gates, the lexical drop-in-name
assertion, the validate-then-apply ordering — through the sourced lib
functions, non-root and network-free. The end-to-end rehearsal is a throwaway
VM/container: pristine Debian → install → bootstrap workload with a real
single-use key → assert the sshd drop-in, tailnet join, and a no-op second
run → destroy, remove the node from the tailnet.