10 changed files with 2147 additions and 35 deletions
250
README.md
250
README.md
|
|
@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### `rig bootstrap <control-plane|workload|runner>`
|
### `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
|
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
|
||||||
second run changes nothing.
|
second run changes nothing.
|
||||||
|
|
@ -29,9 +29,62 @@ second run changes nothing.
|
||||||
rig bootstrap control-plane --hostname my-coolify-box
|
rig bootstrap control-plane --hostname my-coolify-box
|
||||||
rig bootstrap workload --hostname my-prod-box
|
rig bootstrap workload --hostname my-prod-box
|
||||||
rig bootstrap runner --hostname my-ci-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>` — tailnet hostname (default: the role name)
|
- `--hostname <name>` — system + tailnet hostname (default: the role name;
|
||||||
|
`custom` has no default and requires it)
|
||||||
|
- `--class <human|server>` — who lives here; decides root SSH's fate after
|
||||||
|
`rig 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
|
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
|
the key is the single source of truth for the tailnet tag — rig no longer states
|
||||||
|
|
@ -67,7 +120,8 @@ admin console keeps that name; rig will not fight it.
|
||||||
> `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf`
|
> `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf`
|
||||||
> on re-run, and refuses to claim success unless `sshd -T` agrees.
|
> on re-run, and refuses to claim success unless `sshd -T` agrees.
|
||||||
|
|
||||||
**The pre-auth key:** provide it via the `TS_AUTHKEY` env var or type it at
|
**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
|
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
|
**tagged** part is now load-bearing, not advice (see below). It lives in process
|
||||||
memory only — rig never writes a credential to disk.
|
memory only — rig never writes a credential to disk.
|
||||||
|
|
@ -106,6 +160,50 @@ grant `tag:server` to repo-controlled code." A runner executes that code, and
|
||||||
the check turns the worst misconfiguration from a documentation warning into a
|
the check turns the worst misconfiguration from a documentation warning into a
|
||||||
hard, post-join error.
|
hard, post-join error.
|
||||||
|
|
||||||
|
`staging` is the box that *hosts* staging boxes — Incus VMs minted by the
|
||||||
|
[`box`](https://github.com/heavy-duty/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>`
|
### `rig coolify install --version <pin>`
|
||||||
|
|
||||||
Control-plane box only. Installs Coolify at exactly the pinned version with
|
Control-plane box only. Installs Coolify at exactly the pinned version with
|
||||||
|
|
@ -285,6 +383,141 @@ prints the exact `runner install` line that finishes the job.
|
||||||
Convergent — repointing to the repo it is already on changes nothing, exits 0,
|
Convergent — repointing to the repo it is already on changes nothing, exits 0,
|
||||||
and never asks for a token.
|
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`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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.conf` carrying exactly
|
||||||
|
> `PermitRootLogin no`. sshd_config is first-wins, `Include` expands its glob
|
||||||
|
> lexically, and `-` (0x2D) sorts before `.` (0x2E) — so `00-rig-users.conf`
|
||||||
|
> is read *before* bootstrap's `00-rig.conf` and beats its
|
||||||
|
> `prohibit-password`. Bootstrap's effective-config assertion accepts the
|
||||||
|
> closed state (`no` is 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 -t` before the restart, rollback on failure, and success is only
|
||||||
|
> claimed once `sshd -T` resolves `permitrootlogin 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's
|
||||||
|
> `authorized_keys` with a `from="<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
|
## What rig deliberately does NOT do
|
||||||
|
|
||||||
- **Provider firewalls** — Docker publishes ports past host firewalls, so
|
- **Provider firewalls** — Docker publishes ports past host firewalls, so
|
||||||
|
|
@ -299,7 +532,10 @@ and never asks for a token.
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
`bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The
|
`bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The
|
||||||
end-to-end rehearsal is a throwaway VM/container: pristine Debian → install →
|
`rig users` family is covered the same way: the harness drives its refusal
|
||||||
`bootstrap workload` with a real single-use key → assert the sshd drop-in,
|
matrix — users-file parsing, the marker gates, the lexical drop-in-name
|
||||||
tailnet join, and a no-op second run → destroy, remove the node from the
|
assertion, the validate-then-apply ordering — through the sourced lib
|
||||||
tailnet.
|
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.
|
||||||
|
|
|
||||||
48
bin/rig
48
bin/rig
|
|
@ -8,11 +8,17 @@ usage() {
|
||||||
usage: rig <command> [args]
|
usage: rig <command> [args]
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
bootstrap <control-plane|workload|runner> [--hostname <name>] [--ts-tag <tag>]
|
bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
|
||||||
|
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
|
||||||
|
[--join <authkey|login>]
|
||||||
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
|
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
|
||||||
tailscale join. Prompts for a single-use tailnet pre-auth key
|
tailscale join. Roles are presets over the three traits; any flag
|
||||||
(TS_AUTHKEY env overrides the prompt). Run as root. Role runner
|
overrides its trait, and custom states all of them. Prompts for a
|
||||||
defaults to tag:ci and refuses tag:server.
|
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
|
||||||
|
prompt); the key's tags are the tailnet tag, verified after join —
|
||||||
|
only control-plane and workload may carry tag:server. join=login
|
||||||
|
(workstation) needs no key: interactive login, node must come up
|
||||||
|
untagged. Run as root.
|
||||||
coolify install --version <pin>
|
coolify install --version <pin>
|
||||||
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
||||||
coolify backup install [options]
|
coolify backup install [options]
|
||||||
|
|
@ -36,6 +42,18 @@ commands:
|
||||||
re-register, reusing the binary already on the box. Needs a removal
|
re-register, reusing the binary already on the box. Needs a removal
|
||||||
token for the old repo and a registration token for the new one.
|
token for the old repo and a registration token for the new one.
|
||||||
Run as root.
|
Run as root.
|
||||||
|
users apply --file <path>
|
||||||
|
Converge named operator accounts from a declarative users file, on
|
||||||
|
every class: groups by role (admin/rig/box), passwords locked always,
|
||||||
|
authorized_keys made exact, visudo-gated sudoers rules. Users dropped
|
||||||
|
from the file are locked, never deleted. '-' reads stdin. Run as root.
|
||||||
|
users status
|
||||||
|
Roles (derived from actual group membership), key counts and lock
|
||||||
|
state for the rig-managed users. Reads the box only. Run as root.
|
||||||
|
users close-root
|
||||||
|
Shut root SSH on a class=human box once an admin key works. Refuses
|
||||||
|
on class=server — root there is the control plane's automation door —
|
||||||
|
and while no admin holds a key. Run as root.
|
||||||
|
|
||||||
install/upgrade:
|
install/upgrade:
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||||
|
|
@ -97,6 +115,28 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
users)
|
||||||
|
shift
|
||||||
|
sub="${1:-}"
|
||||||
|
case "$sub" in
|
||||||
|
apply)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/users-apply.sh" "$@"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/users-status.sh" "$@"
|
||||||
|
;;
|
||||||
|
close-root)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/users-close-root.sh" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
-h|--help|help)
|
-h|--help|help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -13,36 +13,99 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: rig bootstrap <control-plane|workload|runner> [--hostname <name>]
|
usage: rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
|
||||||
|
[--hostname <name>] [--class <human|server>]
|
||||||
|
[--host <yes|no>] [--join <authkey|login>]
|
||||||
|
|
||||||
--hostname system + tailnet hostname (default: the role name)
|
--hostname system + tailnet hostname (default: the role name; custom has
|
||||||
|
no default and requires it)
|
||||||
|
--class who lives here — human|server. Decides root SSH's fate after
|
||||||
|
`rig users apply`: human closes it, server keeps it as the
|
||||||
|
control plane's automation door.
|
||||||
|
--host does this box host VMs (box/Incus) — yes|no
|
||||||
|
--join how it enters the tailnet — authkey|login
|
||||||
|
|
||||||
|
Roles are presets over the three traits; any flag overrides its trait.
|
||||||
|
custom presets nothing and requires --hostname plus all three traits.
|
||||||
|
|
||||||
|
role class host join
|
||||||
|
control-plane server no authkey
|
||||||
|
workload server no authkey
|
||||||
|
runner server no authkey
|
||||||
|
staging server yes authkey
|
||||||
|
dev human yes authkey
|
||||||
|
workstation human yes login
|
||||||
|
|
||||||
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
|
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
|
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
|
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
|
(tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
|
||||||
outright, and a runner may not carry tag:server. Mint a correctly-tagged key.
|
outright, and only control-plane and workload may carry tag:server (they are
|
||||||
|
the only shapes the control plane manages). Mint a correctly-tagged key.
|
||||||
|
|
||||||
Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or
|
join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
|
||||||
enter it at the interactive prompt. It is used once and never written to disk.
|
env var, or enter it at the interactive prompt. Used once, never written to disk.
|
||||||
|
|
||||||
|
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.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
ROLE="${1:-}"
|
ROLE="${1:-}"
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
control-plane|workload|runner) shift ;;
|
control-plane|workload|runner|staging|dev|workstation|custom) shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
"") usage >&2; die "role required (control-plane|workload|runner)" 2 ;;
|
"") usage >&2; die "role required (control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;;
|
||||||
*) die "unknown role: $ROLE (want control-plane|workload|runner)" 2 ;;
|
*) die "unknown role: $ROLE (want control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;;
|
||||||
esac
|
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.
|
||||||
|
CLASS="" HOST="" JOIN=""
|
||||||
|
case "$ROLE" in
|
||||||
|
control-plane) CLASS=server HOST=no JOIN=authkey ;;
|
||||||
|
workload) CLASS=server HOST=no JOIN=authkey ;;
|
||||||
|
runner) CLASS=server HOST=no JOIN=authkey ;;
|
||||||
|
staging) CLASS=server HOST=yes JOIN=authkey ;;
|
||||||
|
dev) CLASS=human HOST=yes JOIN=authkey ;;
|
||||||
|
workstation) CLASS=human HOST=yes JOIN=login ;;
|
||||||
|
custom) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
|
||||||
TS_HOSTNAME="$ROLE"
|
TS_HOSTNAME="$ROLE"
|
||||||
|
[ "$ROLE" = "custom" ] && TS_HOSTNAME=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--hostname)
|
--hostname)
|
||||||
[ $# -ge 2 ] || die "--hostname needs a value" 2
|
[ $# -ge 2 ] || die "--hostname needs a value" 2
|
||||||
TS_HOSTNAME="$2"; shift 2 ;;
|
TS_HOSTNAME="$2"; shift 2 ;;
|
||||||
|
--class)
|
||||||
|
[ $# -ge 2 ] || die "--class needs a value" 2
|
||||||
|
case "$2" in
|
||||||
|
human|server) CLASS="$2" ;;
|
||||||
|
*) die "bad --class: $2 (want human|server)" 2 ;;
|
||||||
|
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 ;;
|
||||||
--ts-tag)
|
--ts-tag)
|
||||||
# --ts-tag is GONE, but this is a deliberate death with a message, not an
|
# --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,
|
# "unknown flag": the flag shipped for a month and scripts still pass it,
|
||||||
|
|
@ -57,6 +120,24 @@ while [ $# -gt 0 ]; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
[ -n "$CLASS" ] || MISSING="$MISSING --class"
|
||||||
|
[ -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
|
||||||
|
|
||||||
# --- guards ------------------------------------------------------------------
|
# --- guards ------------------------------------------------------------------
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
|
|
@ -71,6 +152,12 @@ if [ -r /etc/os-release ]; then
|
||||||
else
|
else
|
||||||
warn "cannot read /etc/os-release; proceeding anyway"
|
warn "cannot read /etc/os-release; proceeding anyway"
|
||||||
fi
|
fi
|
||||||
|
# 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
|
||||||
|
# legitimately absent, and rig cannot tell a rehearsal from a misconfigured box.
|
||||||
|
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."
|
||||||
|
fi
|
||||||
|
|
||||||
# The pre-auth key is acquired LATER, in the tailscale block — and only if the
|
# 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
|
# box has not already joined. rig is convergent by contract, so re-running it to
|
||||||
|
|
@ -142,8 +229,26 @@ rm -f "$TMP"
|
||||||
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
||||||
echo "$eff" | grep -qx 'passwordauthentication no' \
|
echo "$eff" | grep -qx 'passwordauthentication no' \
|
||||||
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
||||||
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
|
||||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
# things on the two classes. class=human: `no` is the post-`rig users
|
||||||
|
# close-root` state — strictly harder than the prohibit-password this script
|
||||||
|
# installs. Bootstrap must never read a closed door as a broken one, and it
|
||||||
|
# cannot reopen one either: by first-wins its own drop-in loses to
|
||||||
|
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
|
||||||
|
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
|
||||||
|
# management silently dead, and the likely culprit is a drop-in left over from
|
||||||
|
# a former class=human life on a repurposed box. rig can DETECT that but must
|
||||||
|
# not FIX it: silently reopening a root door is worse than a loud stop, so —
|
||||||
|
# same doctrine as the tag checks — detect, refuse, and name the repair.
|
||||||
|
if [ "$CLASS" = "human" ]; then
|
||||||
|
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
||||||
|
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||||
|
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
|
||||||
|
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
|
||||||
|
else
|
||||||
|
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
||||||
|
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||||
|
fi
|
||||||
log "sshd hardening verified (sshd -T: passwordauthentication no)"
|
log "sshd hardening verified (sshd -T: passwordauthentication no)"
|
||||||
|
|
||||||
# --- system hostname ----------------------------------------------------------
|
# --- system hostname ----------------------------------------------------------
|
||||||
|
|
@ -179,8 +284,15 @@ fi
|
||||||
# Tags ride in with the netmap, not synchronously out of `up`, so a single read
|
# 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
|
# 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).
|
# the backend reaches Running (past which an empty Tags is real, not just early).
|
||||||
|
#
|
||||||
|
# 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).
|
||||||
verify_effective_tag() {
|
verify_effective_tag() {
|
||||||
local deadline=$((SECONDS + 30)) tags="" state="" json
|
local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json
|
||||||
json="$(mktemp)"
|
json="$(mktemp)"
|
||||||
while :; do
|
while :; do
|
||||||
if tailscale status --json > "$json" 2>/dev/null; then
|
if tailscale status --json > "$json" 2>/dev/null; then
|
||||||
|
|
@ -200,25 +312,92 @@ verify_effective_tag() {
|
||||||
# node anyway, so rig must now catch this out loud. A wrong tag cannot be fixed
|
# 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
|
# 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,
|
# `up --force-reauth`), so back the node out rather than leave a half-joined,
|
||||||
# user-owned device squatting a hostname.
|
# 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.
|
||||||
if [ -z "$tags" ]; then
|
if [ -z "$tags" ]; then
|
||||||
tailscale logout >/dev/null 2>&1 \
|
if [ "$mode" = "back-out" ]; then
|
||||||
|| warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand"
|
tailscale logout >/dev/null 2>&1 \
|
||||||
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."
|
|| 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."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Role policy now rides the EFFECTIVE tag — strictly stronger than the old
|
# tag:server policy is DERIVED, not a trait: it means "the control plane
|
||||||
# request-time check, which only guarded the tag rig HOPED for. This guards the
|
# manages this box", and only control-plane and workload are shapes the
|
||||||
# tag the key ACTUALLY granted to repo-controlled code: a runner carrying
|
# control plane manages. Everything else refuses it on the EFFECTIVE tag —
|
||||||
# tag:server would extend every grant your servers hold to CI code. Refused,
|
# strictly stronger than the old request-time check, which only guarded the
|
||||||
# never warned. rig can DETECT this but cannot FIX it, so name the repair.
|
# tag rig HOPED for. The fleet has been bitten both ways: a runner carrying
|
||||||
if [ "$ROLE" = "runner" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then
|
# tag:server extends every server grant to repo-controlled code, and a
|
||||||
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)."
|
# staging 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
|
||||||
|
control-plane|workload) ;;
|
||||||
|
runner)
|
||||||
|
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
|
||||||
|
staging)
|
||||||
|
die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." ;;
|
||||||
|
*)
|
||||||
|
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')"
|
log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
log "user-owned join verified (untagged)"
|
||||||
|
}
|
||||||
|
|
||||||
if ! command -v tailscale >/dev/null 2>&1; then
|
if ! command -v tailscale >/dev/null 2>&1; then
|
||||||
log "installing tailscale"
|
log "installing tailscale"
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
|
@ -250,7 +429,19 @@ if tailscale status >/dev/null 2>&1; then
|
||||||
# rig's back, on the very next ordinary re-run. Skipping `tailscale up` here is
|
# 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 —
|
# deliberate and stays — re-running an identical tagged-authkey `up` errors —
|
||||||
# but skipping the CHECK was how the M900s stayed mis-tagged unnoticed.
|
# but skipping the CHECK was how the M900s stayed mis-tagged unnoticed.
|
||||||
verify_effective_tag
|
# The check the traits demand: authkey wants the granted tag, login wants none
|
||||||
|
# — both in `keep` mode: never back out a join this run did not perform.
|
||||||
|
if [ "$JOIN" = "login" ]; then
|
||||||
|
verify_user_owned keep
|
||||||
|
else
|
||||||
|
verify_effective_tag keep
|
||||||
|
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
|
||||||
else
|
else
|
||||||
# env override, else prompt; never touches disk
|
# env override, else prompt; never touches disk
|
||||||
if [ -z "${TS_AUTHKEY:-}" ]; then
|
if [ -z "${TS_AUTHKEY:-}" ]; then
|
||||||
|
|
@ -264,12 +455,40 @@ else
|
||||||
# cannot be rescued by one (verify_effective_tag refuses it and logs out).
|
# 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)"
|
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
|
||||||
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
||||||
verify_effective_tag
|
verify_effective_tag back-out
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- role marker --------------------------------------------------------------
|
||||||
|
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users`
|
||||||
|
# reads class 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.
|
||||||
|
MARKER=/etc/rig/role
|
||||||
|
MARKER_TMP="$(mktemp)"
|
||||||
|
printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$HOST" "$JOIN" > "$MARKER_TMP"
|
||||||
|
if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
|
||||||
|
mkdir -p /etc/rig
|
||||||
|
install -m 0644 "$MARKER_TMP" "$MARKER"
|
||||||
|
log "role marker written: role=$ROLE class=$CLASS host=$HOST join=$JOIN"
|
||||||
|
else
|
||||||
|
log "role marker already current"
|
||||||
|
fi
|
||||||
|
rm -f "$MARKER_TMP"
|
||||||
|
|
||||||
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
||||||
if [ "$ROLE" = "control-plane" ]; then
|
if [ "$ROLE" = "control-plane" ]; then
|
||||||
log "next: rig coolify install --version <pin>"
|
log "next: rig coolify install --version <pin>"
|
||||||
elif [ "$ROLE" = "runner" ]; then
|
elif [ "$ROLE" = "runner" ]; then
|
||||||
log "next: rig runner install --repo <owner/repo> --version <pin>"
|
log "next: rig runner install --repo <owner/repo> --version <pin>"
|
||||||
fi
|
fi
|
||||||
|
if [ "$HOST" = "yes" ]; then
|
||||||
|
log "next: install the box CLI and run 'box setup-host' to prepare Incus for guest boxes"
|
||||||
|
fi
|
||||||
|
# Every class gets operators: humans always enter as themselves and elevate via
|
||||||
|
# sudo — a shared root login is unattributable. What differs by class is root
|
||||||
|
# SSH's fate once named users exist.
|
||||||
|
if [ "$CLASS" = "human" ]; then
|
||||||
|
log "next: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
|
||||||
|
else
|
||||||
|
log "next: rig users apply --file <users-file> for named operator logins; root SSH stays — it is the control plane's automation door"
|
||||||
|
fi
|
||||||
|
|
|
||||||
124
commands/lib/users-config.sh
Normal file
124
commands/lib/users-config.sh
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared parsing for the rig users family. Sourced by the users-* commands and
|
||||||
|
# by the test harness against fixture files; never executed on its own.
|
||||||
|
|
||||||
|
# The users file is line-based and whitespace-separated on purpose: a
|
||||||
|
# rig-bootstrapped box has no YAML parser and no jq, and `read` parses this
|
||||||
|
# shape for free — same jq-free reason runner-config.sh greps JSON. One line
|
||||||
|
# per key:
|
||||||
|
#
|
||||||
|
# # user roles ssh public key
|
||||||
|
# dan admin,box ssh-ed25519 AAAA... dan@laptop
|
||||||
|
#
|
||||||
|
# Repeated username lines are additional authorized keys; the roles field must
|
||||||
|
# be IDENTICAL on each — a repeated line means "another key", never a quiet
|
||||||
|
# role edit hiding mid-file. '#' comments and blank lines are skipped.
|
||||||
|
|
||||||
|
# parse_users_file <path>
|
||||||
|
#
|
||||||
|
# Emits one normalized 'user|roles|key' line per key line on stdout. On ANY
|
||||||
|
# validation error: EVERY error goes to stderr, each with its line number, no
|
||||||
|
# stdout, return 1. All errors in one pass because a bad file should cost one
|
||||||
|
# fix cycle, not one round-trip per line.
|
||||||
|
#
|
||||||
|
# Refusals: unknown role (the valid set is named), differing roles across one
|
||||||
|
# user's lines, root as username (root's keys are class policy's business, not
|
||||||
|
# this file's), malformed line (fewer than 3 fields, or a key field that does
|
||||||
|
# not start with an SSH key type), invalid username (the charset below —
|
||||||
|
# '|' would corrupt this parser's own delimited stream, a leading '-' reads
|
||||||
|
# as a useradd flag), duplicate identical key line.
|
||||||
|
parse_users_file() {
|
||||||
|
local path="$1"
|
||||||
|
local -a errs=() out=() rlist=()
|
||||||
|
local -A first_roles=() seen=()
|
||||||
|
local line u r k role ok n=0
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
n=$((n + 1))
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*(#|$) ]]; then continue; fi
|
||||||
|
read -r u r k <<< "$line"
|
||||||
|
if [ -z "${k:-}" ]; then
|
||||||
|
errs+=("line $n: malformed — expected 'user roles ssh-public-key' (3+ whitespace-separated fields)")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$k" in
|
||||||
|
ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;;
|
||||||
|
*)
|
||||||
|
errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)")
|
||||||
|
continue ;;
|
||||||
|
esac
|
||||||
|
# The username feeds this parser's own '|'-delimited stream and then
|
||||||
|
# useradd: 'fo|o' silently becomes user 'fo' with garbage keys, and a
|
||||||
|
# leading '-' reads as a useradd flag mid-convergence. One safe charset
|
||||||
|
# refuses both by construction (and ':', which would corrupt passwd).
|
||||||
|
if ! [[ "$u" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
|
||||||
|
errs+=("line $n: invalid username '$u' — must match ^[a-z_][a-z0-9_-]{0,31}\$ (lowercase letter or '_' first, then lowercase, digits, '_', '-'; max 32)")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$u" = "root" ]; then
|
||||||
|
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
ok=1
|
||||||
|
IFS=',' read -ra rlist <<< "$r"
|
||||||
|
for role in "${rlist[@]}"; do
|
||||||
|
case "$role" in
|
||||||
|
admin|rig|box) ;;
|
||||||
|
*) errs+=("line $n: unknown role '$role' for $u (valid roles: admin rig box)"); ok=0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -n "${first_roles[$u]:-}" ] && [ "${first_roles[$u]}" != "$r" ]; then
|
||||||
|
errs+=("line $n: $u has roles '$r' here but '${first_roles[$u]}' earlier — repeated lines add keys, roles must be identical")
|
||||||
|
ok=0
|
||||||
|
fi
|
||||||
|
if [ -z "${first_roles[$u]:-}" ]; then first_roles[$u]="$r"; fi
|
||||||
|
if [ -n "${seen[$u|$k]:-}" ]; then
|
||||||
|
errs+=("line $n: duplicate key line for $u (same key already on line ${seen[$u|$k]})")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
seen[$u|$k]="$n"
|
||||||
|
if [ "$ok" -eq 1 ]; then out+=("$u|$r|$k"); fi
|
||||||
|
done < "$path"
|
||||||
|
if [ "${#errs[@]}" -gt 0 ]; then
|
||||||
|
printf '%s\n' "${errs[@]}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "${#out[@]}" -gt 0 ]; then printf '%s\n' "${out[@]}"; fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# read_role_marker <path> — the marker line bootstrap wrote
|
||||||
|
# (`role=... class=... host=... join=...`), or nothing when absent. NO policy
|
||||||
|
# here: what an absent marker or a given class MEANS is each caller's call
|
||||||
|
# (apply notes it, close-root refuses on it) — this reader only reads.
|
||||||
|
read_role_marker() {
|
||||||
|
[ -r "$1" ] || return 0
|
||||||
|
head -n1 "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# assert_marker_human <marker_path> — close-root's marker gate: return 0,
|
||||||
|
# silently, only when the marker says class=human; otherwise print the refusal
|
||||||
|
# reason on stdout and return 1 (the caller wraps it in its own die). The
|
||||||
|
# policy is a pure lib function on purpose: the CLI path sits behind the root
|
||||||
|
# check, so the harness proves every refusal HERE, against fixture markers,
|
||||||
|
# non-root (repo precedent: parse_users_file, assert_runner_repo).
|
||||||
|
assert_marker_human() {
|
||||||
|
local marker
|
||||||
|
marker="$(read_role_marker "$1")"
|
||||||
|
if [ -z "$marker" ]; then
|
||||||
|
# No marker means rig cannot know whether root here is a human's bad habit
|
||||||
|
# or the control plane's automation door — refuse to shut it blind.
|
||||||
|
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
case "$marker" in
|
||||||
|
*class=human*) return 0 ;;
|
||||||
|
*class=server*)
|
||||||
|
# Root SSH on a server IS the control plane's (Coolify's) automation
|
||||||
|
# identity — closing it severs fleet management. No --force exists.
|
||||||
|
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management"
|
||||||
|
return 1 ;;
|
||||||
|
*)
|
||||||
|
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
||||||
|
return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
294
commands/users-apply.sh
Executable file
294
commands/users-apply.sh
Executable file
|
|
@ -0,0 +1,294 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig users apply — converge named operator accounts from a declarative users
|
||||||
|
# file, on every class. Humans always enter as themselves and elevate via
|
||||||
|
# sudo: a shared root login is unattributable, so operators belong on servers
|
||||||
|
# too — class never gates this command, it only decides root SSH's fate AFTER
|
||||||
|
# users exist (close-root on human, kept as the control plane's automation
|
||||||
|
# door on server). Convergent: a second identical run changes nothing and
|
||||||
|
# says so.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
|
. "$HERE/lib/users-config.sh"
|
||||||
|
|
||||||
|
log() { printf 'rig-users: %s\n' "$*"; }
|
||||||
|
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig users apply --file <path>
|
||||||
|
|
||||||
|
--file <path> users file (required; '-' reads it from stdin)
|
||||||
|
|
||||||
|
The file is line-based and bash-parseable on purpose — a rig box has no YAML
|
||||||
|
parser and no jq, and gets neither for this. Whitespace-separated: user,
|
||||||
|
comma-joined roles, then the SSH public key (the rest of the line). '#'
|
||||||
|
comments and blank lines are fine. Repeated username lines add authorized
|
||||||
|
keys; the roles must be identical on every line of one user.
|
||||||
|
|
||||||
|
# user roles ssh public key
|
||||||
|
dan admin,box ssh-ed25519 AAAA... dan@laptop
|
||||||
|
maria rig,box ssh-ed25519 AAAA... maria@mac
|
||||||
|
|
||||||
|
roles:
|
||||||
|
admin group rig-admin — full NOPASSWD sudo
|
||||||
|
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
|
||||||
|
box group incus — Incus restricted tier, no sudo (box's setup-host
|
||||||
|
owns the Incus install; rig only asserts it)
|
||||||
|
|
||||||
|
All passwords stay locked, always — the SSH key at the door is the
|
||||||
|
authentication, and NOPASSWD sudo does not weaken it. Convergent: membership
|
||||||
|
in the three rig-managed groups is made exact (other groups are never
|
||||||
|
touched), authorized_keys becomes exactly the file's keys, and a user dropped
|
||||||
|
from the file is REVOKED: account expired (which blocks SSH keys too, not
|
||||||
|
just the password), authorized_keys renamed to authorized_keys.revoked-by-rig,
|
||||||
|
rig groups stripped — home kept, nothing deleted, and re-adding the user
|
||||||
|
brings them back. Run as root; under sudo, only rig-admin members may — the
|
||||||
|
users family changes who holds root, so role rig's scoped sudo does not reach
|
||||||
|
it.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
|
FILE=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--file)
|
||||||
|
[ $# -ge 2 ] || die "--file needs a value" 2
|
||||||
|
FILE="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$FILE" ] || die "--file <path> is required" 2
|
||||||
|
|
||||||
|
# stdin is read ONCE into a temp: the file is parsed for validation and then
|
||||||
|
# walked again to converge, and a pipe only plays once.
|
||||||
|
if [ "$FILE" = "-" ]; then
|
||||||
|
STDIN_TMP="$(mktemp)"
|
||||||
|
cat > "$STDIN_TMP"
|
||||||
|
FILE="$STDIN_TMP"
|
||||||
|
fi
|
||||||
|
[ -r "$FILE" ] || die "cannot read users file: $FILE" 2
|
||||||
|
|
||||||
|
# File parsing is argument validation: every error in the file is reported in
|
||||||
|
# one pass, exit 2, still before the root check.
|
||||||
|
PARSED="$(parse_users_file "$FILE")" \
|
||||||
|
|| die "invalid users file: $FILE — every error is listed above; nothing was changed" 2
|
||||||
|
|
||||||
|
declare -A USER_ROLES=() USER_KEYS=()
|
||||||
|
USERS=()
|
||||||
|
BOX_USERS=()
|
||||||
|
NEED_SUDO=0
|
||||||
|
NEED_INCUS=0
|
||||||
|
while IFS='|' read -r u r k; do
|
||||||
|
[ -n "$u" ] || continue
|
||||||
|
if [ -z "${USER_ROLES[$u]:-}" ]; then
|
||||||
|
USERS+=("$u")
|
||||||
|
USER_ROLES[$u]="$r"
|
||||||
|
case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac
|
||||||
|
fi
|
||||||
|
USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n'
|
||||||
|
case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac
|
||||||
|
case ",$r," in *,box,*) NEED_INCUS=1 ;; esac
|
||||||
|
done <<< "$PARSED"
|
||||||
|
|
||||||
|
# --- guards ------------------------------------------------------------------
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
|
||||||
|
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
|
||||||
|
# is binary-scoped but not argument-scoped, so without this gate a rig-role
|
||||||
|
# user could run `sudo rig users apply --file <me-as-admin>` — the scoped
|
||||||
|
# grant silently root-equivalent through this very command. Direct root (no
|
||||||
|
# SUDO_USER: bring-up, a root shell) proceeds.
|
||||||
|
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
||||||
|
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
|
||||||
|
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Class is a note, never a refusal: #26's call is that operators belong on
|
||||||
|
# EVERY class — what differs is root SSH's fate once they exist.
|
||||||
|
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
||||||
|
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;;
|
||||||
|
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;;
|
||||||
|
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CHANGED=0
|
||||||
|
|
||||||
|
# --- sudo (only when some role actually grants through it) -------------------
|
||||||
|
if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
|
log "installing sudo (admin/rig grants go through it)"
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- groups ------------------------------------------------------------------
|
||||||
|
groupadd -f rig-admin
|
||||||
|
groupadd -f rig
|
||||||
|
# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An
|
||||||
|
# absent incus group means that never ran — but what that MEANS is the host=
|
||||||
|
# trait's call. The box role binds where VMs live; a users file is fleet-wide,
|
||||||
|
# its box grants are not. So on host=yes an absent group is a broken VM host
|
||||||
|
# (refuse, point at setup-host), while on host=no it is simply not this box's
|
||||||
|
# role to converge — skip it, never abort the admins the file also carries.
|
||||||
|
INCUS_OK=0
|
||||||
|
if getent group incus >/dev/null; then INCUS_OK=1; fi
|
||||||
|
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
|
||||||
|
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
||||||
|
*host=yes*)
|
||||||
|
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;;
|
||||||
|
*host=no*)
|
||||||
|
warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;;
|
||||||
|
*)
|
||||||
|
warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
||||||
|
|
||||||
|
# --- converge each user ------------------------------------------------------
|
||||||
|
for u in "${USERS[@]}"; do
|
||||||
|
if ! id -u "$u" >/dev/null 2>&1; then
|
||||||
|
useradd -m -s /bin/bash "$u"
|
||||||
|
log "created user $u"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
# Locked always, created or found: no password ever exists to guess or
|
||||||
|
# rotate — the SSH key at the door is the authentication. The expiry is
|
||||||
|
# cleared just as idempotently: revocation below IS an expiry date, so a
|
||||||
|
# user dropped once and re-added comes back to life on this line.
|
||||||
|
usermod -L -e '' "$u"
|
||||||
|
|
||||||
|
# Membership in the three rig-managed groups is made EXACT — added and
|
||||||
|
# removed to match the file. Other groups are never touched: they are not
|
||||||
|
# rig's to converge.
|
||||||
|
roles="${USER_ROLES[$u]}"
|
||||||
|
want=""
|
||||||
|
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
|
||||||
|
case ",$roles," in *,rig,*) want="$want rig" ;; esac
|
||||||
|
# incus joins the wanted set only when the group exists (host=no boxes
|
||||||
|
# skipped it above): converging membership in a conjured group would hand
|
||||||
|
# the daemon's arrival an audience it never granted.
|
||||||
|
case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
|
||||||
|
for g in rig-admin rig incus; do
|
||||||
|
case " $want " in
|
||||||
|
*" $g "*)
|
||||||
|
if ! in_group "$u" "$g"; then
|
||||||
|
usermod -aG "$g" "$u"
|
||||||
|
log "added $u to $g"
|
||||||
|
CHANGED=1
|
||||||
|
fi ;;
|
||||||
|
*)
|
||||||
|
if in_group "$u" "$g"; then
|
||||||
|
gpasswd -d "$u" "$g" >/dev/null
|
||||||
|
log "removed $u from $g"
|
||||||
|
CHANGED=1
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# authorized_keys becomes exactly the file's keys — only the content WRITE
|
||||||
|
# is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode
|
||||||
|
# converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing
|
||||||
|
# (a group-writable .ssh is a rejected key), so drifted perms behind
|
||||||
|
# matching content would otherwise stay broken while apply logs "already
|
||||||
|
# converged". Perms are part of the converged state.
|
||||||
|
home="$(getent passwd "$u" | cut -d: -f6)"
|
||||||
|
ugroup="$(id -gn "$u")"
|
||||||
|
mkdir -p "$home/.ssh"
|
||||||
|
AK_TMP="$(mktemp)"
|
||||||
|
printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP"
|
||||||
|
if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then
|
||||||
|
install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys"
|
||||||
|
log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
rm -f "$AK_TMP"
|
||||||
|
chmod 0700 "$home/.ssh"
|
||||||
|
chown "$u:$ugroup" "$home/.ssh"
|
||||||
|
chmod 0600 "$home/.ssh/authorized_keys"
|
||||||
|
chown "$u:$ugroup" "$home/.ssh/authorized_keys"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- previously managed users no longer in the file --------------------------
|
||||||
|
# The ledger is what lets a REMOVED user be found at all — so it must REMEMBER
|
||||||
|
# them: two-field lines, 'name active' / 'name revoked' (a legacy bare name
|
||||||
|
# reads as active). Revoked, not deleted: deleting frees the uid for reuse and
|
||||||
|
# orphans file ownership — attribution would rot. Home stays for the same
|
||||||
|
# reason. But revoked must actually mean revoked: a '!'-locked password is not
|
||||||
|
# a closed door under UsePAM — Debian sshd still honors the pubkey — so the
|
||||||
|
# lock alone left a dropped operator with working SSH. Account expiry (a date
|
||||||
|
# in the past) is the switch PAM actually enforces, against every auth method
|
||||||
|
# including keys; the keys themselves are renamed, never deleted — access
|
||||||
|
# revoked, data kept, convergence never destroys.
|
||||||
|
LEDGER=/etc/rig/users
|
||||||
|
REVOKED=()
|
||||||
|
if [ -r "$LEDGER" ]; then
|
||||||
|
while read -r prev pstate _; do
|
||||||
|
[ -n "$prev" ] || continue
|
||||||
|
case " ${USERS[*]:-} " in *" $prev "*) continue ;; esac
|
||||||
|
id -u "$prev" >/dev/null 2>&1 || continue
|
||||||
|
usermod -L -e 1 "$prev"
|
||||||
|
prevhome="$(getent passwd "$prev" | cut -d: -f6)"
|
||||||
|
if [ -f "$prevhome/.ssh/authorized_keys" ]; then
|
||||||
|
mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig"
|
||||||
|
fi
|
||||||
|
for g in rig-admin rig incus; do
|
||||||
|
if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi
|
||||||
|
done
|
||||||
|
REVOKED+=("$prev")
|
||||||
|
# Warn on the TRANSITION only: an already-revoked user is converged above
|
||||||
|
# (quietly — repairing drift, not announcing news) so a second identical
|
||||||
|
# run stays a clean no-op.
|
||||||
|
if [ "${pstate:-active}" != "revoked" ]; then
|
||||||
|
warn "$prev is no longer in the file: account expired (blocks SSH keys too, not just the password), authorized_keys renamed to authorized_keys.revoked-by-rig, rig groups stripped (home kept — rig never deletes a user)"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
done < "$LEDGER"
|
||||||
|
fi
|
||||||
|
LEDGER_TMP="$(mktemp)"
|
||||||
|
if [ "${#USERS[@]}" -gt 0 ]; then printf '%s active\n' "${USERS[@]}" > "$LEDGER_TMP"; fi
|
||||||
|
if [ "${#REVOKED[@]}" -gt 0 ]; then printf '%s revoked\n' "${REVOKED[@]}" >> "$LEDGER_TMP"; fi
|
||||||
|
if ! cmp -s "$LEDGER_TMP" "$LEDGER" 2>/dev/null; then
|
||||||
|
mkdir -p /etc/rig
|
||||||
|
install -m 0644 "$LEDGER_TMP" "$LEDGER"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
rm -f "$LEDGER_TMP"
|
||||||
|
|
||||||
|
# --- sudoers -----------------------------------------------------------------
|
||||||
|
# Both group rules ship in one drop-in whether or not both roles are in use:
|
||||||
|
# the groups exist and the rules are inert without members. visudo gates the
|
||||||
|
# install because 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.
|
||||||
|
SUDOERS_TMP="$(mktemp)"
|
||||||
|
cat > "$SUDOERS_TMP" <<'EOF'
|
||||||
|
# Managed by `rig users apply` — do not edit; the next apply converges it.
|
||||||
|
%rig-admin ALL=(ALL:ALL) NOPASSWD: ALL
|
||||||
|
%rig ALL=(root) NOPASSWD: /usr/local/bin/rig
|
||||||
|
EOF
|
||||||
|
if command -v visudo >/dev/null 2>&1; then
|
||||||
|
visudo -c -f "$SUDOERS_TMP" >/dev/null \
|
||||||
|
|| die "sudoers candidate failed validation — /etc/sudoers.d untouched; candidate kept at $SUDOERS_TMP for inspection"
|
||||||
|
if ! cmp -s "$SUDOERS_TMP" /etc/sudoers.d/rig-roles 2>/dev/null; then
|
||||||
|
install -m 0440 "$SUDOERS_TMP" /etc/sudoers.d/rig-roles
|
||||||
|
log "sudoers role rules installed (/etc/sudoers.d/rig-roles)"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
rm -f "$SUDOERS_TMP"
|
||||||
|
else
|
||||||
|
# No sudo on the box means no role needed it (the install above would have
|
||||||
|
# run otherwise): rules for a binary that is not there can wait for the
|
||||||
|
# apply that brings a sudo-bearing role.
|
||||||
|
rm -f "$SUDOERS_TMP"
|
||||||
|
log "sudo not installed and no role needs it; skipping the sudoers drop-in"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CHANGED" -eq 0 ]; then
|
||||||
|
log "already converged; no changes"
|
||||||
|
else
|
||||||
|
log "converged ${#USERS[@]} user(s)"
|
||||||
|
fi
|
||||||
206
commands/users-close-root.sh
Executable file
206
commands/users-close-root.sh
Executable file
|
|
@ -0,0 +1,206 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig users close-root — shut the human-class root SSH door, once and only
|
||||||
|
# once a named admin can already get in. class decides root SSH's fate (#26):
|
||||||
|
# on class=human a root login is unattributable noise, so it goes; on
|
||||||
|
# class=server root IS the control plane's automation identity, so closing it
|
||||||
|
# would sever fleet management — this command refuses there, and no --force
|
||||||
|
# exists. Convergent: a second run is a no-op and says so.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
|
. "$HERE/lib/users-config.sh"
|
||||||
|
|
||||||
|
log() { printf 'rig-users: %s\n' "$*"; }
|
||||||
|
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig users close-root
|
||||||
|
|
||||||
|
Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf
|
||||||
|
carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
|
||||||
|
first-wins include order.
|
||||||
|
|
||||||
|
Human class ONLY. On class=server, root SSH is the control plane's (Coolify's)
|
||||||
|
automation identity — closing it severs fleet management — so close-root
|
||||||
|
refuses there, with no --force. It also refuses without a role marker (re-run
|
||||||
|
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
|
||||||
|
member holds a login sshd would plausibly accept — authorized_keys present
|
||||||
|
and non-empty, home/.ssh/keys owned by the user and not group/world-writable
|
||||||
|
(sshd's StrictModes rejects the key otherwise), a real login shell, account
|
||||||
|
not expired. The refusal names which check failed, per candidate. Run rig
|
||||||
|
users apply first; never close the only door.
|
||||||
|
|
||||||
|
Before running, verify your admin login in a SEPARATE session — `ssh
|
||||||
|
<admin>@<box>` while this one stays open. Root SSH is the door being welded
|
||||||
|
shut; the admin door must be proven, not presumed.
|
||||||
|
|
||||||
|
Run as root. Convergent: once root is closed, a re-run is a clean no-op.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- guards ------------------------------------------------------------------
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
|
||||||
|
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
|
||||||
|
# is binary-scoped but not argument-scoped, so without this gate a rig-role
|
||||||
|
# user could reshape who enters this box as whom — the scoped grant silently
|
||||||
|
# root-equivalent through the users family. Direct root (no SUDO_USER:
|
||||||
|
# bring-up, a root shell) proceeds.
|
||||||
|
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
||||||
|
&& ! id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx rig-admin; then
|
||||||
|
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Marker gate — the policy lives in assert_marker_human (lib) so the harness
|
||||||
|
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER
|
||||||
|
# exists for the same reason: it keeps the command's own gate pointable at
|
||||||
|
# fixtures instead of only at the real /etc/rig/role.
|
||||||
|
if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
||||||
|
die "$WHY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Admin-door gate — never close the only door. Root SSH goes away below, so
|
||||||
|
# at least one rig-admin member must hold a login sshd would plausibly ACCEPT
|
||||||
|
# — a non-empty authorized_keys alone proves a file exists, not a door:
|
||||||
|
# StrictModes rejects keys behind wrongly-owned or group/world-writable
|
||||||
|
# paths, a nologin shell never logs in, and an expired account fails PAM
|
||||||
|
# before the key is read. So every candidate is checked for the StrictModes
|
||||||
|
# shape, and the refusal names, per candidate, WHICH check failed — an
|
||||||
|
# operator staring at a refusal must see the repair. Honestly: this proves
|
||||||
|
# the door SHOULD open per StrictModes, not that it does — the
|
||||||
|
# verify-in-a-separate-session advisory in --help stays load-bearing.
|
||||||
|
today=$(( $(date +%s) / 86400 ))
|
||||||
|
# path_strict <path> <uid> <label> — flag the two StrictModes complaints
|
||||||
|
flag() { bad="${bad:+$bad, }$1"; }
|
||||||
|
path_strict() {
|
||||||
|
local p="$1" uid="$2" l="$3" o m
|
||||||
|
o="$(stat -c '%u' "$p" 2>/dev/null)" || return 0
|
||||||
|
m="$(stat -c '%a' "$p" 2>/dev/null)"
|
||||||
|
if [ "$o" != "$uid" ]; then flag "$l not owned by the user"; fi
|
||||||
|
if [ $(( 8#$m & 8#022 )) -ne 0 ]; then flag "$l is group/world-writable (mode $m)"; fi
|
||||||
|
}
|
||||||
|
ADMIN_OK=0
|
||||||
|
CANDIDATES=0
|
||||||
|
DETAIL=""
|
||||||
|
while IFS= read -r a; do
|
||||||
|
[ -n "$a" ] || continue
|
||||||
|
CANDIDATES=$((CANDIDATES + 1))
|
||||||
|
bad=""
|
||||||
|
uid="$(id -u "$a" 2>/dev/null)" || { DETAIL="$DETAIL; $a: no such user"; continue; }
|
||||||
|
ent="$(getent passwd "$a")"
|
||||||
|
h="$(printf '%s' "$ent" | cut -d: -f6)"
|
||||||
|
shell="$(printf '%s' "$ent" | cut -d: -f7)"
|
||||||
|
if [ ! -d "$h" ]; then
|
||||||
|
flag "home directory missing"
|
||||||
|
else
|
||||||
|
path_strict "$h" "$uid" "home"
|
||||||
|
if [ ! -d "$h/.ssh" ]; then
|
||||||
|
flag ".ssh directory missing"
|
||||||
|
else
|
||||||
|
path_strict "$h/.ssh" "$uid" ".ssh directory"
|
||||||
|
if [ ! -s "$h/.ssh/authorized_keys" ]; then
|
||||||
|
flag "authorized_keys missing or empty"
|
||||||
|
else
|
||||||
|
path_strict "$h/.ssh/authorized_keys" "$uid" "authorized_keys"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
case "$shell" in
|
||||||
|
*/nologin|*/false) flag "login shell $shell never logs in" ;;
|
||||||
|
esac
|
||||||
|
# shadow field 8 is the expiry in days-since-epoch; empty means never.
|
||||||
|
exp="$(getent shadow "$a" 2>/dev/null | cut -d: -f8)"
|
||||||
|
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
|
||||||
|
flag "account expired"
|
||||||
|
fi
|
||||||
|
if [ -z "$bad" ]; then ADMIN_OK=1; break; fi
|
||||||
|
DETAIL="$DETAIL; $a: $bad"
|
||||||
|
done < <(getent group rig-admin | cut -d: -f4 | tr ',' '\n')
|
||||||
|
if [ "$ADMIN_OK" -ne 1 ]; then
|
||||||
|
if [ "$CANDIDATES" -eq 0 ]; then
|
||||||
|
die "no admin user with a key on this box — run rig users apply first; never close the only door"
|
||||||
|
fi
|
||||||
|
die "no rig-admin member would pass sshd's door${DETAIL} — repair, prove the login in a separate session, then re-run; never close the only door"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- the drop-in --------------------------------------------------------------
|
||||||
|
# The NAME is the entire mechanism: sshd_config is FIRST-wins ("for each
|
||||||
|
# keyword, the first obtained value will be used" — sshd_config(5)), Include
|
||||||
|
# expands its glob in lexical order, and '-' (0x2D) sorts before '.' (0x2E),
|
||||||
|
# so 00-rig-users.conf is read BEFORE bootstrap's 00-rig.conf and this
|
||||||
|
# PermitRootLogin beats its prohibit-password. Rename the file and it silently
|
||||||
|
# loses that fight — the harness asserts the comparison the glob makes.
|
||||||
|
DROPIN=/etc/ssh/sshd_config.d/00-rig-users.conf
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
printf 'PermitRootLogin no\n' > "$TMP"
|
||||||
|
|
||||||
|
# Convergence needs two proofs, and matching bytes are only half of one. The
|
||||||
|
# file can be right while the DOOR is still open: a prior run that died
|
||||||
|
# between install and restart leaves a daemon that never read this file, and
|
||||||
|
# `sshd -T` cannot tell — it re-parses disk, it does not interrogate the
|
||||||
|
# running daemon. The only proof the running sshd carries this config is a
|
||||||
|
# (re)start AFTER the last change to anything sshd reads: the main config,
|
||||||
|
# the drop-in dir itself (creates/deletes/renames inside touch its mtime, so
|
||||||
|
# a since-removed override is caught), and every drop-in. So the no-op is
|
||||||
|
# taken only when the bytes match AND systemd says sshd started strictly
|
||||||
|
# after the newest of those mtimes; anything less restarts, and the
|
||||||
|
# effective-config assertion at the bottom runs on EVERY path — claiming
|
||||||
|
# "already closed" from file bytes is exactly what let bootstrap's
|
||||||
|
# first-wins bug ship green.
|
||||||
|
RESTART=1
|
||||||
|
BACKUP=""
|
||||||
|
INSTALLED=0
|
||||||
|
if cmp -s "$TMP" "$DROPIN" 2>/dev/null; then
|
||||||
|
newest="$(stat -c '%Y' /etc/ssh/sshd_config /etc/ssh/sshd_config.d /etc/ssh/sshd_config.d/*.conf 2>/dev/null | sort -rn | head -n1)"
|
||||||
|
started="$(systemctl show ssh -p ExecMainStartTimestamp --value 2>/dev/null)" || started=""
|
||||||
|
if [ -n "$started" ] && started_s="$(date -d "$started" +%s 2>/dev/null)" \
|
||||||
|
&& [ -n "$newest" ] && [ "$started_s" -gt "$newest" ]; then
|
||||||
|
RESTART=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; }
|
||||||
|
install -m 0644 "$TMP" "$DROPIN"
|
||||||
|
INSTALLED=1
|
||||||
|
fi
|
||||||
|
rm -f "$TMP"
|
||||||
|
|
||||||
|
if [ "$RESTART" -eq 1 ]; then
|
||||||
|
# Validate the MERGED config BEFORE bouncing the daemon (the bootstrap
|
||||||
|
# shape): on a box whose only door is SSH — exactly what this box is about
|
||||||
|
# to become — restarting into a config the daemon refuses to parse leaves
|
||||||
|
# no listener and no way back in. Roll back (when we installed anything)
|
||||||
|
# and stop rather than shut the door on a maybe.
|
||||||
|
if ! sshd -t 2>/dev/null; then
|
||||||
|
if [ "$INSTALLED" -eq 1 ]; then
|
||||||
|
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
|
||||||
|
rm -f "$BACKUP"
|
||||||
|
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
|
||||||
|
fi
|
||||||
|
die "sshd rejects the current config; daemon untouched. Run 'sshd -t' to see which file is bad."
|
||||||
|
fi
|
||||||
|
rm -f "$BACKUP"
|
||||||
|
systemctl restart ssh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Assert the EFFECTIVE config, not the file's existence — a drop-in sorting
|
||||||
|
# even earlier would win the first-wins fight silently. `sshd -T` is what the
|
||||||
|
# daemon actually resolved, and it gates the no-op claim too: "already
|
||||||
|
# closed" is a statement about the door, never about the file.
|
||||||
|
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim root is closed"
|
||||||
|
echo "$eff" | grep -qx 'permitrootlogin no' \
|
||||||
|
|| die "sshd still resolves permitrootlogin != no — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
|
||||||
|
if [ "$RESTART" -eq 0 ]; then
|
||||||
|
log "root already closed (sshd -T resolves permitrootlogin no); nothing to do"
|
||||||
|
else
|
||||||
|
log "root door closed (sshd -T resolves permitrootlogin no); humans enter as themselves now"
|
||||||
|
fi
|
||||||
88
commands/users-status.sh
Executable file
88
commands/users-status.sh
Executable file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig users status — what this box's operator accounts actually are, read from
|
||||||
|
# the machine itself: roles derived from REAL group membership (not the
|
||||||
|
# ledger's memory of an apply), key counts from authorized_keys, and the
|
||||||
|
# active/revoked state from the ledger CORROBORATED by the account's actual
|
||||||
|
# expiry — apply locks every password always, so the lock flag says nothing;
|
||||||
|
# expiry is the switch that actually revokes, and a mismatch between ledger
|
||||||
|
# and expiry is drift worth shouting about. Reads only — no network, no
|
||||||
|
# writes.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { printf 'rig-users: %s\n' "$*"; }
|
||||||
|
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig users status
|
||||||
|
|
||||||
|
Per rig-managed user (the /etc/rig/users ledger): roles derived from the
|
||||||
|
groups the user is ACTUALLY in (rig-admin -> admin, rig -> rig, incus -> box),
|
||||||
|
the authorized_keys count ('revoked' when only the .revoked-by-rig rename
|
||||||
|
remains), and whether the user is active or revoked — the ledger's word,
|
||||||
|
checked against the account's real expiry, with a loud warning when the two
|
||||||
|
disagree (a drifted box must never read as healthy). Reads the box only — no
|
||||||
|
network, no writes. Run as root (shadow is read).
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
|
||||||
|
LEDGER=/etc/rig/users
|
||||||
|
if [ ! -r "$LEDGER" ]; then
|
||||||
|
log "no rig-managed users (no $LEDGER yet — rig users apply creates it)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
today=$(( $(date +%s) / 86400 ))
|
||||||
|
while read -r u lstate _; do
|
||||||
|
[ -n "$u" ] || continue
|
||||||
|
# Ledger lines are 'name active' / 'name revoked'; a legacy bare name (a
|
||||||
|
# ledger written before states existed) reads as active.
|
||||||
|
[ -n "${lstate:-}" ] || lstate=active
|
||||||
|
if ! id -u "$u" >/dev/null 2>&1; then
|
||||||
|
# In the ledger but off the box: someone deleted by hand what rig only
|
||||||
|
# ever revokes. Say so rather than crash or silently skip.
|
||||||
|
warn "$u: in the ledger but not on the box (rig never deletes — removed by hand?)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
groups=" $(id -nG "$u") "
|
||||||
|
roles=""
|
||||||
|
case "$groups" in *" rig-admin "*) roles="admin" ;; esac
|
||||||
|
case "$groups" in *" rig "*) roles="${roles:+$roles,}rig" ;; esac
|
||||||
|
case "$groups" in *" incus "*) roles="${roles:+$roles,}box" ;; esac
|
||||||
|
[ -n "$roles" ] || roles="none"
|
||||||
|
home="$(getent passwd "$u" | cut -d: -f6)"
|
||||||
|
keys=0
|
||||||
|
if [ -r "$home/.ssh/authorized_keys" ]; then
|
||||||
|
keys="$(grep -c . "$home/.ssh/authorized_keys" || true)"
|
||||||
|
elif [ -e "$home/.ssh/authorized_keys.revoked-by-rig" ]; then
|
||||||
|
# Only the rename apply's revocation performed remains: access revoked,
|
||||||
|
# data kept.
|
||||||
|
keys=revoked
|
||||||
|
fi
|
||||||
|
# Corroborate the ledger against the switch that actually revokes: shadow
|
||||||
|
# field 8 is the expiry (days since epoch, empty = never). The ledger is
|
||||||
|
# apply's memory; the expiry is the machine's present tense — when they
|
||||||
|
# disagree, someone changed the account behind rig's back.
|
||||||
|
exp="$(getent shadow "$u" 2>/dev/null | cut -d: -f8)"
|
||||||
|
actual=active
|
||||||
|
if [ -n "$exp" ] && [ "$exp" -le "$today" ] 2>/dev/null; then
|
||||||
|
actual=revoked
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$lstate" ]; then
|
||||||
|
warn "$u: DRIFT — ledger says $lstate but the account's expiry says $actual; re-run rig users apply"
|
||||||
|
log "$u roles=$roles keys=$keys $lstate (DRIFT: expiry says $actual)"
|
||||||
|
else
|
||||||
|
log "$u roles=$roles keys=$keys $lstate"
|
||||||
|
fi
|
||||||
|
done < "$LEDGER"
|
||||||
216
docs/plans/2026-07-17-staging-role.md
Normal file
216
docs/plans/2026-07-17-staging-role.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# rig `bootstrap staging` Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a `staging` bootstrap role alongside `control-plane` / `workload` /
|
||||||
|
`runner` — the host archetype for a machine whose job is to **host staging
|
||||||
|
VMs**: Incus VMs minted by the `box` CLI (heavy-duty/box) from its `staging`
|
||||||
|
template, each later converged from *inside* with `rig bootstrap workload` and
|
||||||
|
registered in the control plane as its own server.
|
||||||
|
|
||||||
|
**Why this shape:** the host and its guests sit on opposite sides of a trust
|
||||||
|
boundary. The guests are servers the control plane manages; the **host is
|
||||||
|
not** — it must never carry `tag:server`, and the fleet has already been bitten
|
||||||
|
once by a host that wrongly did. The role is deliberately minimal host
|
||||||
|
plumbing: hardening, tailnet join (key minted with `tag:local`), hostname —
|
||||||
|
and **nothing about Incus or box**. box's own `setup-host` owns Incus
|
||||||
|
configuration; two tools converging the same daemon is drift by construction,
|
||||||
|
so rig only *points* at box's installer in its closing log. The guest side
|
||||||
|
needs zero rig changes — `rig bootstrap workload` already is the staging-box
|
||||||
|
role.
|
||||||
|
|
||||||
|
**Architecture:** `staging` joins the existing role case in
|
||||||
|
`commands/bootstrap.sh`; no new files, no new flags. Since issue #16 / PR #20,
|
||||||
|
the tailnet tag is **not a rig argument** — the pre-auth key carries its tags
|
||||||
|
and rig asserts on the tag control actually *granted* (`.Self.Tags`), post-join
|
||||||
|
and on every re-run. So the role's tag policy lands in `verify_effective_tag`,
|
||||||
|
exactly where the `runner` policy lives: role `staging` **refuses an effective
|
||||||
|
`tag:server`** (die, exit 1 — a runtime refusal, not a usage error). The
|
||||||
|
`/dev/kvm` advisory and the box next-step pointer live in the execution path;
|
||||||
|
argument validation stays pure and root-free.
|
||||||
|
|
||||||
|
**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck +
|
||||||
|
`bash test/cli.sh`) — no workflow change needed.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- **No Incus, no box install** — box's `setup-host` is the single owner of the
|
||||||
|
Incus daemon's configuration. rig prints a pointer, nothing more.
|
||||||
|
- **No VM provisioning** — minting boxes is box's job (`box new --template
|
||||||
|
staging`, companion issue heavy-duty/box#68).
|
||||||
|
- **No control-plane/Coolify API usage** — guests register themselves via the
|
||||||
|
existing workload flow.
|
||||||
|
- **No `dev` role** — a dev-box host role is anticipated (same plumbing, honest
|
||||||
|
name) but explicitly out of scope here.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `#!/usr/bin/env bash` + `set -euo pipefail`; log prefix `rig-bootstrap:`
|
||||||
|
via the existing `log`/`warn`/`die` helpers.
|
||||||
|
- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All argument
|
||||||
|
validation runs BEFORE the root check** so error paths are testable as
|
||||||
|
non-root.
|
||||||
|
- The tag policy asserts the **effective** tag, never a requested one — there
|
||||||
|
is no `--ts-tag` to refuse anymore (it died in PR #20; passing it exits 2
|
||||||
|
with a pointer at the key). The issue's original "refuse `--ts-tag
|
||||||
|
tag:server`" acceptance is therefore satisfied at the stronger, post-join
|
||||||
|
layer, same as `runner`.
|
||||||
|
- `/dev/kvm` absence is a **warning, not a failure** — the role is rehearsed in
|
||||||
|
containers where `/dev/kvm` legitimately isn't there.
|
||||||
|
- Convergent: a second run changes nothing and exits 0.
|
||||||
|
- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x
|
||||||
|
bin/* **/*.sh`); `bash test/cli.sh` green as non-root.
|
||||||
|
- Keep the diff minimal — no drive-by refactors. (One deliberate exception:
|
||||||
|
`bin/rig`'s bootstrap usage line still advertises the removed `--ts-tag`
|
||||||
|
flag and the old `tag:ci` default — stale since PR #20. It gets corrected in
|
||||||
|
the same breath as adding `staging` to the role list, because shipping a new
|
||||||
|
role into a help text that lies about the flag surface would be worse than
|
||||||
|
the drive-by.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: role wiring in `commands/bootstrap.sh` + dispatcher usage + tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `commands/bootstrap.sh` (role case, effective-tag refusal, `/dev/kvm`
|
||||||
|
advisory, closing next-step log, usage heredoc)
|
||||||
|
- Modify: `bin/rig` (bootstrap usage line: role list + stale-flag correction)
|
||||||
|
- Modify: `test/cli.sh` (bootstrap section additions)
|
||||||
|
|
||||||
|
**Behavior contract, in file order:**
|
||||||
|
|
||||||
|
1. Usage heredoc: role list becomes `<control-plane|workload|runner|staging>`;
|
||||||
|
one added sentence: staging hosts box-minted staging VMs, its key should be
|
||||||
|
minted with `tag:local`, and it refuses `tag:server` — the host is never
|
||||||
|
managed by the control plane; its guest VMs are.
|
||||||
|
2. Role case arm: `control-plane|workload|runner|staging) shift ;;` and both
|
||||||
|
error messages (`role required`, `unknown role`) name the four roles.
|
||||||
|
3. `verify_effective_tag`: after the `runner` refusal, the `staging` one — same
|
||||||
|
shape (`grep -qx 'tag:server'` against the effective tags), message
|
||||||
|
`role staging joined with tag:server ...` naming the repair (mint a
|
||||||
|
`tag:local` key), rationale comment: hosts are never managed by the control
|
||||||
|
plane, their guest VMs are; the fleet has been bitten by a host wrongly
|
||||||
|
carrying `tag:server`. `die` with default status → exit 1.
|
||||||
|
4. Guards section (execution path, after the root check): when role is
|
||||||
|
`staging` and `/dev/kvm` is absent, `warn` — the host exists to run VMs, but
|
||||||
|
a container rehearsal legitimately has no `/dev/kvm`, so this must not fail.
|
||||||
|
5. Closing log: `staging` branch pointing at the box CLI — install box, run
|
||||||
|
`box setup-host` to prepare Incus, then `box new --template staging`.
|
||||||
|
6. `bin/rig` usage: `bootstrap <control-plane|workload|runner|staging>
|
||||||
|
[--hostname <name>]`; drop the stale `[--ts-tag <tag>]` and
|
||||||
|
`tag:ci`-default sentence; say the tag comes from the pre-auth key and that
|
||||||
|
roles `runner` and `staging` refuse `tag:server`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append failing tests**
|
||||||
|
|
||||||
|
In `test/cli.sh`, bootstrap section:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
|
||||||
|
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
|
||||||
|
# The staging tag:server refusal rides the EFFECTIVE tag, inside
|
||||||
|
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
|
||||||
|
# rehearsal. What the harness CAN prove is that the refusal exists in the shipped
|
||||||
|
# script: grep the die message, so a deleted guard cannot ship green (the same
|
||||||
|
# reason the runner-install repo guard is grepped below).
|
||||||
|
check "bootstrap: staging effective-tag refusal is present" 0 "" \
|
||||||
|
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
and in the existing non-root block:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `unknown role exits 2` (potato) check already covers the
|
||||||
|
still-fails-usage path and stays untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify the new ones fail**
|
||||||
|
|
||||||
|
Run: `bash test/cli.sh`
|
||||||
|
Expected: the staging checks FAIL (`unknown role: staging` → wrong exit/output
|
||||||
|
for the first and third; missing die message for the grep); everything existing
|
||||||
|
stays green; harness exits 1.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the role**
|
||||||
|
|
||||||
|
Per the behavior contract above.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `bash test/cli.sh`
|
||||||
|
Expected: all checks pass, exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 5: shellcheck + syntax**
|
||||||
|
|
||||||
|
Run: `shopt -s globstar; shellcheck -x bin/* **/*.sh` (exactly CI's
|
||||||
|
invocation) and `bash -n` on each edited script.
|
||||||
|
Expected: exit 0, no findings.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add commands/bootstrap.sh bin/rig test/cli.sh
|
||||||
|
git commit -m "feat(bootstrap): staging role — the host archetype for box-minted staging VMs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: README roles documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md` (bootstrap section: heading role list, example block, the
|
||||||
|
roles paragraph)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write it**
|
||||||
|
|
||||||
|
Content requirements (in the README's existing voice):
|
||||||
|
|
||||||
|
- Heading/example gain `staging` (`rig bootstrap staging --hostname my-vm-host`).
|
||||||
|
- One honest paragraph in the roles discussion: `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. Mint its key with `tag:local`; the role **refuses an
|
||||||
|
effective `tag:server`** — the host is never managed by the control plane,
|
||||||
|
its guests are. rig deliberately installs no Incus and no box (box's
|
||||||
|
`setup-host` owns that); it points there when done.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full local gate**
|
||||||
|
|
||||||
|
Run: CI's shellcheck invocation + `bash test/cli.sh`.
|
||||||
|
Expected: silent shellcheck; all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: README section for the staging bootstrap role"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- **Harness (`bash test/cli.sh`, non-root, network-free):** staging parses and
|
||||||
|
reaches the root check (exit 1 `must run as root`); staging + the removed
|
||||||
|
`--ts-tag` dies at arg validation (exit 2, message points at the key —
|
||||||
|
proving validation precedes the root check); the effective-tag refusal
|
||||||
|
message is present in the script; unknown roles still exit 2.
|
||||||
|
- **CI:** unchanged `ci.yml` covers the edits (globstar shellcheck + harness).
|
||||||
|
- **Rehearsal (manual, out of harness):** pristine Debian box → `rig bootstrap
|
||||||
|
staging` with a real single-use `tag:local` key → hardened sshd drop-in,
|
||||||
|
tailnet join, hostname `staging`, `/dev/kvm` warning absent on real hardware,
|
||||||
|
closing log points at box; second run is a no-op. A `tag:server` key must
|
||||||
|
die post-join with the staging refusal.
|
||||||
|
|
||||||
|
## Addendum (2026-07-17, written before implementation)
|
||||||
|
|
||||||
|
Issue #22 predates the merge of PR #20 (issue #16: the tag comes from the key).
|
||||||
|
Its acceptance criterion "`rig bootstrap staging --ts-tag tag:server` exits 1
|
||||||
|
with a refusal, before the root check" names a flag that no longer exists —
|
||||||
|
`--ts-tag` now dies (exit 2) for every role, before the root check, pointing at
|
||||||
|
the key. The staging `tag:server` policy therefore lands where the runner's
|
||||||
|
did: on the **effective** tag in `verify_effective_tag`, exit 1, which is the
|
||||||
|
strictly stronger check (it guards the tag the key actually granted, not the
|
||||||
|
one rig hoped for). This plan is the up-to-date statement of the work.
|
||||||
458
docs/plans/2026-07-17-traits-and-users.md
Normal file
458
docs/plans/2026-07-17-traits-and-users.md
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
# Machine Traits + `rig users` Implementation Plan (issues #26 + #24, one release)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ship the trait model (#26) and declarative fleet users (#24) as a
|
||||||
|
single release. Bootstrap's role list becomes presets over three orthogonal
|
||||||
|
traits (`class`, `host`, `join`), recorded in a convergent marker
|
||||||
|
`/etc/rig/role`. A new `rig users` command family makes operator accounts a
|
||||||
|
first-class, declarative concern on **every** class — the hybrid access model:
|
||||||
|
humans always enter as themselves and elevate via sudo; root SSH is what
|
||||||
|
`class` decides *after* users exist. `class=human` machines close it
|
||||||
|
(`rig users close-root`); `class=server` machines keep it as the **automation**
|
||||||
|
identity the control plane (Coolify) SSHes in as. The tailnet is network-only
|
||||||
|
(no Tailscale SSH), so named users are the only attribution at the door.
|
||||||
|
|
||||||
|
**Why this shape:** #25's server/host binary bundled three questions that
|
||||||
|
merely correlated (who lives there / hosts VMs / how it joins). #26 unbundles
|
||||||
|
them; roles stay as presets so the usual shapes remain one command. The
|
||||||
|
earlier "server is humanless — `rig users` refuses" was superseded in #26's
|
||||||
|
comments: a shared root login is unattributable, so operators belong
|
||||||
|
everywhere; what stays class-specific is the *fate of root SSH*. Root's key
|
||||||
|
hygiene on servers (`from=`-locking Coolify's key line) is README guidance,
|
||||||
|
not automation — Coolify owns its key material on the servers it registers,
|
||||||
|
and two tools converging one file is drift by construction.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
- `commands/bootstrap.sh` gains a **role→traits map** — the single place a
|
||||||
|
role's shape is declared — plus `--class/--host/--join` overrides and roles
|
||||||
|
`dev`, `workstation`, `custom`. All per-class behavior keys off traits:
|
||||||
|
`/dev/kvm` advisory (`host=yes`), tag policy (derived), next-steps log.
|
||||||
|
The marker is written post-join, convergently.
|
||||||
|
- `join=login` (workstation): a set `TS_AUTHKEY` is a usage error (exit 2,
|
||||||
|
before the root check — testable); interactive `tailscale up`; the tag
|
||||||
|
assertion **inverts** — any effective tag is a refusal, backed out with
|
||||||
|
`tailscale logout`, mirroring the untagged-key refusal on the authkey path.
|
||||||
|
- **tag:server policy is derived, not a trait**: only `control-plane` and
|
||||||
|
`workload` may carry it; every other role refuses it on the effective tags
|
||||||
|
(generalizes today's runner + staging checks into one rule).
|
||||||
|
- New `commands/users-apply.sh`, `commands/users-status.sh`,
|
||||||
|
`commands/users-close-root.sh`, with parsing/marker helpers in
|
||||||
|
`commands/lib/users-config.sh` so the harness can exercise refusals via
|
||||||
|
sourced functions against fixtures (repo precedent: `assert_runner_repo`,
|
||||||
|
`json_string_array`).
|
||||||
|
- `close-root` installs `/etc/ssh/sshd_config.d/00-rig-users.conf`
|
||||||
|
(`PermitRootLogin no`). The **name is load-bearing**: sshd_config is
|
||||||
|
first-wins, the Include glob expands lexically, and `-` (0x2D) < `.`
|
||||||
|
(0x2E), so `00-rig-users.conf` is read before bootstrap's `00-rig.conf`
|
||||||
|
and wins. Validate-then-apply exactly like bootstrap (`sshd -t`, rollback,
|
||||||
|
`sshd -T` assertion). Bootstrap's effective-config assertion learns to
|
||||||
|
accept `permitrootlogin no` (strictly harder) so a re-run never reopens
|
||||||
|
root.
|
||||||
|
- Managed-user bookkeeping: `apply` maintains `/etc/rig/users` (one username
|
||||||
|
per line) so a user removed from the input file is found and **locked**
|
||||||
|
(never deleted) on the next run.
|
||||||
|
|
||||||
|
**Tech Stack:** bash only, shellcheck, existing `ci.yml` (globstar shellcheck
|
||||||
|
+ `bash test/cli.sh`) — no workflow change needed.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No user deletion; no passwords (all locked, always); no LDAP/SSO/PAM; no
|
||||||
|
per-user quotas (Incus project limits are a box concern).
|
||||||
|
- No management of root's `authorized_keys` (Coolify owns its key on the
|
||||||
|
servers it manages; the `from=` lock is README guidance, not code).
|
||||||
|
- No Coolify application-account (web UI) management.
|
||||||
|
- No Incus / box installation (box's `setup-host` owns the daemon; the `box`
|
||||||
|
role *asserts* the `incus` group exists and refuses with a pointer
|
||||||
|
otherwise).
|
||||||
|
- No behavior change for control-plane/workload/runner beyond the marker
|
||||||
|
write and the generalized (identical-in-effect) tag policy.
|
||||||
|
- Cross-repo box work (restricted-tier verification, project-awareness,
|
||||||
|
global install) is referenced in #24 and tracked in heavy-duty/box.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `#!/usr/bin/env bash` + `set -euo pipefail`; per-command log prefix via
|
||||||
|
`log`/`warn`/`die` helpers (`rig-users:` for the new family).
|
||||||
|
- Exit codes: `2` = usage/argument error, `1` = runtime refusal. **All
|
||||||
|
argument validation runs BEFORE the root check** so error paths are
|
||||||
|
testable as non-root. File parsing counts as validation: a bad users file
|
||||||
|
exits 2 with **all** errors reported at once.
|
||||||
|
- Convergent everywhere: a second identical run is a no-op, says so, exits 0.
|
||||||
|
- Validate-then-apply for anything that can lock the door or break sudo:
|
||||||
|
`sshd -t` before restart (rollback on failure), `visudo -c` before the
|
||||||
|
sudoers drop-in lands.
|
||||||
|
- shellcheck-clean exactly as CI runs it (`shopt -s globstar; shellcheck -x
|
||||||
|
bin/* **/*.sh`); `bash test/cli.sh` green as non-root.
|
||||||
|
- Keep the diff minimal — no drive-by refactors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: role→traits map, trait flags, marker, login join path (bootstrap)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `commands/bootstrap.sh` (usage heredoc, role case + trait map +
|
||||||
|
override flags, TS_AUTHKEY/login validation, kvm advisory keyed on traits,
|
||||||
|
generalized tag policy, login-path inverted assertion, marker write,
|
||||||
|
next-steps log)
|
||||||
|
- Modify: `bin/rig` (bootstrap usage lines: roles + trait flags)
|
||||||
|
- Modify: `test/cli.sh` (bootstrap section additions)
|
||||||
|
|
||||||
|
**Behavior contract, in file order:**
|
||||||
|
|
||||||
|
1. Usage heredoc: roles `<control-plane|workload|runner|staging|dev|workstation|custom>`;
|
||||||
|
flags `--hostname <name>`, `--class <human|server>`, `--host <yes|no>`,
|
||||||
|
`--join <authkey|login>`. Document: roles are presets over traits, any
|
||||||
|
trait overridable; `custom` requires `--hostname` and all three traits;
|
||||||
|
`join=login` needs no pre-auth key and refuses a set `TS_AUTHKEY`
|
||||||
|
(unset it or pass `--join authkey`); the preset table in one compact
|
||||||
|
block.
|
||||||
|
2. Role case: the seven roles shift; `role required` / `unknown role`
|
||||||
|
messages name them. Then the **role→traits map** — one `case "$ROLE"`
|
||||||
|
assigning `CLASS`/`HOST`/`JOIN` per the preset table in issue #26;
|
||||||
|
`custom` leaves all three empty.
|
||||||
|
3. Flag loop gains `--class/--host/--join`, each validating its value set
|
||||||
|
(bad value → exit 2 naming the valid values). After the loop:
|
||||||
|
`custom` missing `--hostname` → exit 2; `custom` missing any trait →
|
||||||
|
exit 2. `TS_HOSTNAME` default stays the role name (custom has none).
|
||||||
|
4. Post-parse validation (still pre-root-check): `JOIN=login` with a set
|
||||||
|
`TS_AUTHKEY` → `die` exit 2: "join=login is interactive: unset TS_AUTHKEY
|
||||||
|
or pass --join authkey".
|
||||||
|
5. Guards: the `/dev/kvm` advisory keys on `HOST=yes` (message unchanged in
|
||||||
|
spirit; drops the staging-only wording).
|
||||||
|
6. `verify_effective_tag` generalizes the runner/staging refusals into the
|
||||||
|
derived policy: if role is not `control-plane`/`workload` and the
|
||||||
|
effective tags contain `tag:server` → die (message keeps the
|
||||||
|
role-specific repair pointers for runner/staging; a generic message for
|
||||||
|
other shapes). Keep the existing two die-message strings greppable —
|
||||||
|
the harness greps them (`role staging joined with tag:server`,
|
||||||
|
runner equivalent).
|
||||||
|
7. **login path**: when `JOIN=login`, skip the pre-auth key acquisition;
|
||||||
|
run `tailscale up --hostname="$TS_HOSTNAME"` (interactive, no
|
||||||
|
`--authkey`); then the **inverted** assertion — poll as
|
||||||
|
`verify_effective_tag` does, but *any* non-empty effective tag →
|
||||||
|
`tailscale logout` + die: "joined TAGGED (…) 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." Empty tags + `Running` → OK, log "user-owned join verified".
|
||||||
|
The already-joined path runs the same class of check (tags present →
|
||||||
|
refusal; no logout on a box that was already joined — detect, refuse,
|
||||||
|
name the repair by hand).
|
||||||
|
8. **Marker**: after tag verification, write `/etc/rig/role` (mkdir -p
|
||||||
|
`/etc/rig`) with exactly one line:
|
||||||
|
`role=$ROLE class=$CLASS host=$HOST join=$JOIN` — cmp-guarded
|
||||||
|
(write+log only on change; "marker already current" otherwise).
|
||||||
|
9. Next-steps log keys off traits + role: `control-plane` → coolify install
|
||||||
|
pointer (as today); `runner` → runner install pointer (as today);
|
||||||
|
`HOST=yes` → box setup-host pointer (replaces the staging-only branch);
|
||||||
|
all classes → `rig users apply` pointer, with the class-specific tail:
|
||||||
|
`class=human` adds "then `rig users close-root` once your admin key
|
||||||
|
works"; `class=server` adds "root SSH stays — it is the control plane's
|
||||||
|
automation door".
|
||||||
|
10. `bin/rig` usage: bootstrap line gains the three roles and trait flags,
|
||||||
|
one sentence for the trait model.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append failing tests** (`test/cli.sh`, bootstrap section)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# --- traits: roles are presets, every trait individually settable (#26) -----
|
||||||
|
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
||||||
|
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
|
||||||
|
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
|
||||||
|
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
|
||||||
|
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
|
||||||
|
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
|
||||||
|
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
|
||||||
|
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
|
||||||
|
# must die BEFORE the root check — provable non-root, which also proves the
|
||||||
|
# preset actually landed.
|
||||||
|
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
||||||
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
|
||||||
|
# A trait override changes derived behavior, provable non-root: dev is
|
||||||
|
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
|
||||||
|
# --join login flips it into the TS_AUTHKEY refusal.
|
||||||
|
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
||||||
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
|
||||||
|
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
|
||||||
|
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
|
||||||
|
check "bootstrap: login-path tagged refusal is present" 0 "" \
|
||||||
|
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# The marker is the traits' ground truth for rig users; assert the write exists.
|
||||||
|
check "bootstrap: role marker write is present" 0 "" \
|
||||||
|
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
and in the existing non-root block:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
|
||||||
|
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
|
||||||
|
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
|
||||||
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run `bash test/cli.sh`** — new checks FAIL (unknown role /
|
||||||
|
unknown flag / missing messages), existing stay green, harness exits 1.
|
||||||
|
- [ ] **Step 3: Implement** per the behavior contract.
|
||||||
|
- [ ] **Step 4: `bash test/cli.sh`** — all green, exit 0.
|
||||||
|
- [ ] **Step 5: shellcheck + syntax** — `shopt -s globstar; shellcheck -x
|
||||||
|
bin/* **/*.sh`; `bash -n` each edited script.
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add commands/bootstrap.sh bin/rig test/cli.sh
|
||||||
|
git commit -m "feat(bootstrap): traits under the roles — class/host/join, dev/workstation/custom, /etc/rig/role marker"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `rig users apply` + `rig users status`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `commands/lib/users-config.sh` (users-file parser, marker reader,
|
||||||
|
role→group mapping — pure functions, no side effects, sourceable by the
|
||||||
|
harness)
|
||||||
|
- Create: `commands/users-apply.sh`, `commands/users-status.sh`
|
||||||
|
- Modify: `bin/rig` (users subcommand dispatch + usage)
|
||||||
|
- Modify: `test/cli.sh` (users section)
|
||||||
|
|
||||||
|
**Behavior contract:**
|
||||||
|
|
||||||
|
1. `commands/lib/users-config.sh`:
|
||||||
|
- `parse_users_file <path>`: emits normalized `user|roles|key` lines on
|
||||||
|
stdout, or **all** validation errors on stderr and returns 1. Refusals:
|
||||||
|
unknown role (naming the valid set `admin rig box`), differing roles
|
||||||
|
across one user's lines, `root` as username, malformed line (fewer than
|
||||||
|
3 fields / key not starting `ssh-` or `ecdsa-`), duplicate identical
|
||||||
|
key line. `#` comments and blanks skipped.
|
||||||
|
- `read_role_marker <path>`: prints `class=<v>` etc. from the marker;
|
||||||
|
empty output when absent. No policy here — callers decide.
|
||||||
|
2. `users-apply.sh` (`rig-users:` prefix):
|
||||||
|
- Args: `--file <path>` required (`-` = stdin, read once into a temp);
|
||||||
|
`--help`; unknown flag exit 2. Parse+validate the whole file (exit 2 on
|
||||||
|
any error, all reported) **before** the root check.
|
||||||
|
- Root check. Then marker note (never a refusal): `class=server` or no
|
||||||
|
marker → log that root SSH stays the automation door / advise
|
||||||
|
bootstrapping a marker, respectively.
|
||||||
|
- Install `sudo` if missing and any parsed role needs it (`admin`, `rig`).
|
||||||
|
- `groupadd -f rig-admin rig`; if any user carries `box`, assert group
|
||||||
|
`incus` exists else die 1 pointing at box `setup-host`.
|
||||||
|
- Converge each user: `useradd -m -s /bin/bash` if absent, then always
|
||||||
|
`usermod -L`; membership in the three rig-managed groups exactly
|
||||||
|
(add and remove; other groups untouched); `~/.ssh/authorized_keys`
|
||||||
|
written 0700/0600, user-owned, to exactly the file's keys (cmp-guarded).
|
||||||
|
- Previously managed users absent from the file (diff against
|
||||||
|
`/etc/rig/users`): `usermod -L`, strip the three rig groups, keep home,
|
||||||
|
`warn` each. Then rewrite `/etc/rig/users` to the file's users.
|
||||||
|
- Sudoers: write the two `%rig-admin`/`%rig` rules to a temp, `visudo -c`
|
||||||
|
against it, then install atomically to `/etc/sudoers.d/rig-roles` 0440
|
||||||
|
(cmp-guarded; die 1 with the temp preserved for inspection on a
|
||||||
|
`visudo` failure, sudoers untouched).
|
||||||
|
- Converged-no-change run says "already converged; no changes".
|
||||||
|
3. `users-status.sh`: `--help`; root check; reads `/etc/rig/users`, prints
|
||||||
|
per user: roles (derived from actual group membership), key count from
|
||||||
|
`authorized_keys`, locked/active. Exits 0 with "no rig-managed users"
|
||||||
|
when the ledger is absent. Reads only — no network, no writes.
|
||||||
|
4. `bin/rig`: `users` dispatch (`apply`/`status`/`close-root` → their
|
||||||
|
scripts; bare or unknown sub → usage exit 2); usage block documents the
|
||||||
|
three subcommands in the existing voice.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append failing tests** (`test/cli.sh`; fixtures via mktemp,
|
||||||
|
parser exercised through the sourced lib — precedent: `guard()`/`tags()`):
|
||||||
|
bare `users` exit 2; `users frobnicate` exit 2; apply `--help` 0;
|
||||||
|
`--file` required 2; `--file` needs value 2; missing file 2; unknown flag
|
||||||
|
2; parser fixtures: unknown role (message lists valid set), role mismatch
|
||||||
|
across lines, `root` refused, malformed line, a valid two-user file parses
|
||||||
|
(and a multi-error file reports **both** errors in one run); status
|
||||||
|
`--help` 0; non-root refusals for apply (valid fixture file) and status;
|
||||||
|
ordering grep: `visudo -c` line precedes the `sudoers.d/rig-roles`
|
||||||
|
install line in `users-apply.sh`.
|
||||||
|
- [ ] **Step 2: `bash test/cli.sh`** — new checks fail.
|
||||||
|
- [ ] **Step 3: Implement** per contract.
|
||||||
|
- [ ] **Step 4: `bash test/cli.sh`** — green.
|
||||||
|
- [ ] **Step 5: shellcheck + syntax** (CI invocation).
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add commands/lib/users-config.sh commands/users-apply.sh commands/users-status.sh bin/rig test/cli.sh
|
||||||
|
git commit -m "feat(users): declarative operators — apply/status over a users file, every class"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `rig users close-root` + bootstrap accepts the closed state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `commands/users-close-root.sh`
|
||||||
|
- Modify: `commands/bootstrap.sh` (effective-config assertion accepts
|
||||||
|
`permitrootlogin no`)
|
||||||
|
- Modify: `test/cli.sh`
|
||||||
|
|
||||||
|
**Behavior contract:**
|
||||||
|
|
||||||
|
1. `users-close-root.sh`: `--help` documents the model (human-class only;
|
||||||
|
verify your admin login in a separate session first) ; unknown flag 2;
|
||||||
|
root check; then, in order:
|
||||||
|
- Marker gate via `read_role_marker` (path overridable for tests via
|
||||||
|
`RIG_ROLE_MARKER`, default `/etc/rig/role`): absent marker → die 1
|
||||||
|
"no /etc/rig/role marker: re-run rig bootstrap so this box knows what
|
||||||
|
it is; refusing to shut the root door blind"; `class=server` → die 1
|
||||||
|
"class=server: root here is the control plane's automation identity —
|
||||||
|
closing it severs fleet management"; only `class=human` proceeds.
|
||||||
|
- Admin-door gate: at least one member of `rig-admin` with a non-empty
|
||||||
|
`~/.ssh/authorized_keys` → else die 1 "no admin user with a key on this
|
||||||
|
box — run rig users apply first; never close the only door".
|
||||||
|
- Install `/etc/ssh/sshd_config.d/00-rig-users.conf` containing exactly
|
||||||
|
`PermitRootLogin no`, cmp-guarded; `sshd -t` on the merged config
|
||||||
|
BEFORE `systemctl restart ssh`, rollback (remove/restore) on failure —
|
||||||
|
the bootstrap shape verbatim. Then assert `sshd -T` resolves
|
||||||
|
`permitrootlogin no` or die.
|
||||||
|
- Second run: "root already closed; nothing to do", exit 0.
|
||||||
|
2. `bootstrap.sh`: the `permitrootlogin` assertion regex becomes
|
||||||
|
`(no|prohibit-password|without-password)` with a comment: `no` is the
|
||||||
|
post-`close-root` state, strictly harder; bootstrap must never read a
|
||||||
|
closed door as a broken one (or reopen it — its drop-in loses to
|
||||||
|
`00-rig-users.conf` by first-wins, which is the point).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append failing tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
|
||||||
|
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
|
||||||
|
# The whole command rests on first-wins + lexical include order: '-' < '.', so
|
||||||
|
# 00-rig-users.conf is read before 00-rig.conf. Assert the actual comparison the
|
||||||
|
# glob makes, so a renamed drop-in cannot silently lose the fight.
|
||||||
|
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
|
||||||
|
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
|
||||||
|
check "users close-root: drop-in name is the load-bearing one" 0 "" \
|
||||||
|
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
|
||||||
|
# Validate-then-apply ordering, greppable (repo precedent: repo-guard ordering).
|
||||||
|
# sshd -t must precede the restart in file order.
|
||||||
|
# marker refusals via the sourced lib against fixture markers:
|
||||||
|
# class=server marker → refusal names the control plane
|
||||||
|
# absent marker → refusal names bootstrap as the repair
|
||||||
|
# class=human marker → gate passes (function returns 0)
|
||||||
|
# non-root: close-root refuses non-root (exit 1) after arg validation.
|
||||||
|
```
|
||||||
|
|
||||||
|
(Exact harness lines mirror the `guard()` fixture pattern; ordering check
|
||||||
|
mirrors the `guard_at`/`start_at` line-number comparison.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: `bash test/cli.sh`** — new checks fail.
|
||||||
|
- [ ] **Step 3: Implement** per contract.
|
||||||
|
- [ ] **Step 4: `bash test/cli.sh`** — green.
|
||||||
|
- [ ] **Step 5: shellcheck + syntax** (CI invocation).
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add commands/users-close-root.sh commands/bootstrap.sh test/cli.sh
|
||||||
|
git commit -m "feat(users): close-root — shut the human-class root door once an admin key works"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: README — identity model, trait tables, users commands
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write it** (in the README's existing voice):
|
||||||
|
- Bootstrap section: role list/examples gain `dev`, `workstation`,
|
||||||
|
`custom`; the trait table and roles-as-presets table from #26; the
|
||||||
|
marker; the `join=login` story (untagged/user-owned is the *assertion*,
|
||||||
|
a tag is the refusal).
|
||||||
|
- New **identity model** subsection: the hybrid access model — operators
|
||||||
|
on every class, humans never enter as root, `class` decides root SSH's
|
||||||
|
fate after `rig users apply`; the attribution rationale (network-only
|
||||||
|
tailnet, no identity broker at the door); the detection side benefit
|
||||||
|
(any root login that isn't the control plane is anomalous by
|
||||||
|
definition); the honest caveat (attribution, not privilege reduction —
|
||||||
|
sudo on a Docker box is root-equivalent).
|
||||||
|
- `rig users` section: file format, roles table (admin/rig/box,
|
||||||
|
incus-admin deliberately not a role), apply/status/close-root, the
|
||||||
|
locked-not-deleted convergence rule, close-root's gates, and the
|
||||||
|
**README-only** guidance: on `class=server`, lock root's
|
||||||
|
`authorized_keys` to the control plane with a
|
||||||
|
`from="<control-plane-addr>"` clause on Coolify's key line (rig will
|
||||||
|
not write that file — Coolify owns it).
|
||||||
|
- [ ] **Step 2: Full local gate** — CI shellcheck invocation +
|
||||||
|
`bash test/cli.sh`.
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: README identity model — traits, presets, fleet users, root's two fates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- **Harness (`bash test/cli.sh`, non-root, network-free):** everything in the
|
||||||
|
per-task steps — trait/flag validation, preset-driven TS_AUTHKEY refusals
|
||||||
|
(proving both presets and overrides), users-file refusal matrix through the
|
||||||
|
sourced parser, marker-gate refusals through fixtures, the lexical
|
||||||
|
drop-in-name assertion, and the two validate-then-apply ordering greps
|
||||||
|
(`visudo -c` before sudoers install, `sshd -t` before restart).
|
||||||
|
- **CI:** unchanged `ci.yml` (globstar shellcheck + harness) covers all new
|
||||||
|
files.
|
||||||
|
- **Rehearsal (manual, out of harness):** Incus container pair —
|
||||||
|
1. human-class: `bootstrap dev` (real `tag:local` key) → marker says
|
||||||
|
`class=human host=yes join=authkey`; `users apply` a two-user file →
|
||||||
|
users/groups/keys/sudoers as specified; re-apply no-ops; remove a user
|
||||||
|
→ locked, home intact; `status` truthful; `close-root` → `sshd -T`
|
||||||
|
resolves `permitrootlogin no`; re-run bootstrap → green, root stays
|
||||||
|
closed; drop a user's key, `ssh` as them fails, as the other succeeds.
|
||||||
|
2. server-class: `bootstrap workload` → marker `class=server`; `users
|
||||||
|
apply` proceeds (operators exist); `close-root` refuses naming the
|
||||||
|
control plane.
|
||||||
|
3. workstation: `bootstrap workstation` with no key → interactive login
|
||||||
|
join, untagged asserted; with a tagged key's identity → refused and
|
||||||
|
backed out.
|
||||||
|
|
||||||
|
## Addendum (2026-07-17, post-implementation review)
|
||||||
|
|
||||||
|
A high-effort adversarial review of the branch diff confirmed ten findings;
|
||||||
|
all are fixed in the two `fix(...)` commits following the four task commits.
|
||||||
|
The load-bearing corrections, recorded here because they refine the contracts
|
||||||
|
above:
|
||||||
|
|
||||||
|
- **The `rig` role is not an identity boundary.** `%rig`'s binary-scoped
|
||||||
|
NOPASSWD was root-equivalent through `sudo rig users apply` (write yourself
|
||||||
|
into `admin`, escalate). Identity management now gates its *invoker*:
|
||||||
|
`users apply`/`close-root` refuse a `SUDO_USER` who is not a `rig-admin`
|
||||||
|
member; direct root proceeds. Documented honestly in the README.
|
||||||
|
- **Offboarding must out-revoke PAM.** A `!`-locked password does not block
|
||||||
|
pubkey auth under Debian's `UsePAM`; dropped users are now `usermod -L -e 1`
|
||||||
|
(account expiry — the switch PAM honors) and their `authorized_keys` is
|
||||||
|
renamed `.revoked-by-rig` (access revoked, data kept). Present users get
|
||||||
|
expiry cleared idempotently; the ledger keeps dropped users as
|
||||||
|
`name revoked` so `status` can tell the truth (`passwd -S` reporting is
|
||||||
|
gone — every rig password is locked by design, so it said nothing).
|
||||||
|
- **Perms are converged state.** `.ssh`/`authorized_keys` ownership and mode
|
||||||
|
converge unconditionally every run; only the content write is cmp-guarded —
|
||||||
|
StrictModes treats perms as load-bearing, so must apply.
|
||||||
|
- **`close-root`'s gate is StrictModes-shaped**: per-candidate checks (owner,
|
||||||
|
group/world-writable bits on home/`.ssh`/`authorized_keys`, real shell,
|
||||||
|
unexpired account), naming each failed check. It proves the admin door
|
||||||
|
*should* open, not that it does — the verify-in-a-separate-session advisory
|
||||||
|
stays load-bearing.
|
||||||
|
- **`verify_effective_tag` grows the same back-out|keep discipline as
|
||||||
|
`verify_user_owned`**: an already-joined untagged box (possibly a
|
||||||
|
legitimate `join=login` machine) is refused *without* `tailscale logout` —
|
||||||
|
never back out state this run did not create. `verify_user_owned` now fails
|
||||||
|
closed on a poll timeout.
|
||||||
|
- **The widened `permitrootlogin` acceptance is class-gated**: `no` is
|
||||||
|
hardened on `class=human`, but on `class=server` it is a refusal naming the
|
||||||
|
likely leftover `00-rig-users.conf` — a repurposed box must not silently
|
||||||
|
strand the control plane, and rig will not silently reopen a root door
|
||||||
|
either.
|
||||||
|
- **`box` role is trait-aware**: on `host=no` boxes (incus absent by design)
|
||||||
|
it warns and skips rather than aborting the whole apply — a users file is
|
||||||
|
fleet-wide; its box grants bind only where VMs live. `host=yes` without
|
||||||
|
incus still dies at `box setup-host`.
|
||||||
|
- **Parser hardening**: usernames validate against `^[a-z_][a-z0-9_-]{0,31}$`
|
||||||
|
in the all-errors-at-once pass (a `|` or useradd-hostile name can no longer
|
||||||
|
corrupt the stream or die mid-convergence).
|
||||||
231
test/cli.sh
231
test/cli.sh
|
|
@ -47,9 +47,60 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr
|
||||||
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
|
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
|
||||||
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
|
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
|
||||||
"$ROOT/commands/bootstrap.sh" runner --ts-tag
|
"$ROOT/commands/bootstrap.sh" runner --ts-tag
|
||||||
|
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
|
||||||
|
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
|
||||||
|
# The staging tag:server refusal rides the EFFECTIVE tag, inside
|
||||||
|
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
|
||||||
|
# rehearsal. What the harness CAN prove is that the refusal exists in the
|
||||||
|
# shipped script: grep the die message, so a deleted guard cannot ship green
|
||||||
|
# (the same reason the runner-install repo guard is grepped below).
|
||||||
|
check "bootstrap: staging effective-tag refusal is present" 0 "" \
|
||||||
|
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# --- traits: roles are presets, every trait individually settable (#26) -----
|
||||||
|
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
|
||||||
|
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
|
||||||
|
check "bootstrap: bad --host value exits 2" 2 "yes|no" "$ROOT/commands/bootstrap.sh" workload --host maybe
|
||||||
|
check "bootstrap: bad --join value exits 2" 2 "authkey|login" "$ROOT/commands/bootstrap.sh" workload --join carrier-pigeon
|
||||||
|
check "bootstrap: custom without --hostname exits 2" 2 "--hostname" \
|
||||||
|
"$ROOT/commands/bootstrap.sh" custom --class server --host no --join authkey
|
||||||
|
check "bootstrap: custom without traits exits 2" 2 "--class" "$ROOT/commands/bootstrap.sh" custom --hostname box1
|
||||||
|
# workstation is join=login by preset: a set TS_AUTHKEY is a usage error, and it
|
||||||
|
# must die BEFORE the root check — provable non-root, which also proves the
|
||||||
|
# preset actually landed.
|
||||||
|
check "bootstrap: workstation + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
||||||
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workstation
|
||||||
|
# A trait override changes derived behavior, provable non-root: dev is
|
||||||
|
# join=authkey (TS_AUTHKEY fine → falls through to the root check), but
|
||||||
|
# --join login flips it into the TS_AUTHKEY refusal.
|
||||||
|
check "bootstrap: dev --join login + TS_AUTHKEY exits 2" 2 "unset TS_AUTHKEY" \
|
||||||
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev --join login
|
||||||
|
# The login-path inverted assertion needs a real tailnet; grep the refusal so a
|
||||||
|
# deleted guard cannot ship green (repo precedent: staging/runner tag greps).
|
||||||
|
check "bootstrap: login-path tagged refusal is present" 0 "" \
|
||||||
|
grep -q "join=login expects a user-owned, untagged node" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# Re-running with join=authkey on a box that was legitimately login-joined
|
||||||
|
# (untagged BY DESIGN) lands in verify_effective_tag's untagged branch. Backing
|
||||||
|
# out a join this run did not perform would tear down a user-owned workstation;
|
||||||
|
# the already-joined path must refuse WITHOUT logout and name both repairs.
|
||||||
|
# Needs a real tailnet to exercise, so grep the keep-mode die instead.
|
||||||
|
check "bootstrap: already-joined untagged refusal keeps the join" 0 "" \
|
||||||
|
grep -q "joined but UNTAGGED" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# verify_user_owned must fail CLOSED on a stalled backend: empty tags is its
|
||||||
|
# SUCCESS signal, so a 30s poll that never saw Running would wave a tagged node
|
||||||
|
# through as user-owned. Grep the timeout die (same real-tailnet excuse).
|
||||||
|
check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
|
||||||
|
grep -q "could not verify the join is user-owned" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# The marker is the traits' ground truth for rig users; assert the write exists.
|
||||||
|
check "bootstrap: role marker write is present" 0 "" \
|
||||||
|
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
|
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
|
||||||
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
|
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
|
||||||
|
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
|
||||||
|
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
|
||||||
|
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
|
||||||
|
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
|
||||||
|
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" custom --hostname b --class server --host no --join authkey
|
||||||
else
|
else
|
||||||
echo "skip: bootstrap non-root refusals (running as root)"
|
echo "skip: bootstrap non-root refusals (running as root)"
|
||||||
fi
|
fi
|
||||||
|
|
@ -227,6 +278,186 @@ else
|
||||||
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
|
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
check "bare users shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" users
|
||||||
|
check "users: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" users frobnicate
|
||||||
|
|
||||||
|
check "users apply: --help exits 0" 0 "usage:" "$ROOT/commands/users-apply.sh" --help
|
||||||
|
check "users apply: --file required" 2 "--file" "$ROOT/commands/users-apply.sh"
|
||||||
|
check "users apply: --file needs value" 2 "needs a value" "$ROOT/commands/users-apply.sh" --file
|
||||||
|
check "users apply: missing file exits 2" 2 "cannot read" "$ROOT/commands/users-apply.sh" --file /nonexistent/users
|
||||||
|
check "users apply: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-apply.sh" --nope
|
||||||
|
check "users status: --help exits 0" 0 "usage:" "$ROOT/commands/users-status.sh" --help
|
||||||
|
|
||||||
|
# --- users file refusal matrix, through the sourced parser -------------------
|
||||||
|
# Reaching the parser via the CLI stops at the root check; it is pure and
|
||||||
|
# sourceable on purpose (repo precedent: assert_runner_repo, json_string_array),
|
||||||
|
# so the refusals are proven here against fixtures, non-root and network-free.
|
||||||
|
parse() { # parse <file> — the users-file parser, exactly as apply runs it
|
||||||
|
bash -c 'set -euo pipefail
|
||||||
|
. "$1/commands/lib/users-config.sh"
|
||||||
|
parse_users_file "$2"' _ "$ROOT" "$1"
|
||||||
|
}
|
||||||
|
FIX_OK="$(mktemp)" # two operators; dan carries a second key on a repeat line
|
||||||
|
FIX_BAD="$(mktemp)" # rewritten per refusal below
|
||||||
|
cat > "$FIX_OK" <<'USERS'
|
||||||
|
# fleet operators
|
||||||
|
dan admin,box ssh-ed25519 AAAAC3fixture dan@laptop
|
||||||
|
dan admin,box ssh-ed25519 AAAAC3second dan@desk
|
||||||
|
|
||||||
|
maria rig ssh-ed25519 AAAAC3fixture maria@mac
|
||||||
|
USERS
|
||||||
|
printf '%s\n' 'maria ops ssh-ed25519 AAAA maria@mac' > "$FIX_BAD"
|
||||||
|
check "users parser: unknown role names the valid set" 1 "valid roles: admin rig box" parse "$FIX_BAD"
|
||||||
|
printf '%s\n' 'dan admin ssh-ed25519 AAAA a' 'dan admin,box ssh-ed25519 BBBB b' > "$FIX_BAD"
|
||||||
|
check "users parser: differing roles across one user's lines" 1 "roles must be identical" parse "$FIX_BAD"
|
||||||
|
printf '%s\n' 'root admin ssh-ed25519 AAAA r' > "$FIX_BAD"
|
||||||
|
check "users parser: root is refused" 1 "not a rig-managed user" parse "$FIX_BAD"
|
||||||
|
printf '%s\n' 'dan admin' > "$FIX_BAD"
|
||||||
|
check "users parser: malformed line is refused" 1 "malformed" parse "$FIX_BAD"
|
||||||
|
# Usernames are validated in the same one-pass refusal matrix: 'fo|o' would
|
||||||
|
# corrupt the parser's own '|'-delimited stream (user 'fo', garbage keys), and
|
||||||
|
# a leading '-' reads as a useradd flag mid-convergence. The refusal names the
|
||||||
|
# line and the rule, like every other parser refusal.
|
||||||
|
printf '%s\n' 'fo|o admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
||||||
|
check "users parser: '|' in a username is refused" 1 "invalid username" parse "$FIX_BAD"
|
||||||
|
check "users parser: the username refusal names the line" 1 "line 1" parse "$FIX_BAD"
|
||||||
|
printf '%s\n' '-dan admin ssh-ed25519 AAAA x' > "$FIX_BAD"
|
||||||
|
check "users parser: leading-dash username is refused" 1 "invalid username" parse "$FIX_BAD"
|
||||||
|
check "users parser: valid file emits dan (both keys' roles agree)" \
|
||||||
|
0 "dan|admin,box|ssh-ed25519 AAAAC3second dan@desk" parse "$FIX_OK"
|
||||||
|
check "users parser: valid file emits maria too" 0 "maria|rig|ssh-ed25519" parse "$FIX_OK"
|
||||||
|
# ALL errors in ONE pass: a bad file costs one fix cycle, not one per error.
|
||||||
|
# A single invocation, both messages asserted from its one stderr.
|
||||||
|
printf '%s\n' 'root admin ssh-ed25519 AAAA r' 'maria ops ssh-ed25519 AAAA m' > "$FIX_BAD"
|
||||||
|
MULTI_ERRS="$(mktemp)"
|
||||||
|
parse "$FIX_BAD" 2> "$MULTI_ERRS"; multi_rc=$?
|
||||||
|
check "users parser: multi-error file exits 1" 0 "" test "$multi_rc" -eq 1
|
||||||
|
check "users parser: one run reports the root line" 0 "" grep -q "not a rig-managed user" "$MULTI_ERRS"
|
||||||
|
check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS"
|
||||||
|
rm -f "$MULTI_ERRS"
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
# A VALID fixture proves the whole file-validation pass sits before the
|
||||||
|
# root check — a parse failure here would exit 2, not 1.
|
||||||
|
check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK"
|
||||||
|
check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh"
|
||||||
|
else
|
||||||
|
echo "skip: users non-root refusals (running as root)"
|
||||||
|
fi
|
||||||
|
rm -f "$FIX_OK" "$FIX_BAD"
|
||||||
|
|
||||||
|
# Validate-then-apply: `visudo -c` must pass before anything lands in
|
||||||
|
# /etc/sudoers.d — a bad drop-in takes down ALL of sudo, locking every admin
|
||||||
|
# out of the escalation path apply just granted. Assert the order in the file,
|
||||||
|
# matching the calls rather than comments (repo precedent: the runner-install
|
||||||
|
# repo-guard ordering check). Defaults fail closed.
|
||||||
|
visudo_at="$(grep -n 'visudo -c' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
||||||
|
sudoers_at="$(grep -nE 'install .*sudoers\.d/rig-roles' "$ROOT/commands/users-apply.sh" | head -n1 | cut -d: -f1)"
|
||||||
|
check "users apply: visudo -c precedes the sudoers install" \
|
||||||
|
0 "" test "${visudo_at:-999999}" -lt "${sudoers_at:-0}"
|
||||||
|
|
||||||
|
# The invoker gate: %rig's sudoers rule is binary-scoped (NOPASSWD for
|
||||||
|
# /usr/local/bin/rig, any args), so without a gate `sudo rig users apply
|
||||||
|
# --file <me-as-admin>` turns role rig root-equivalent through this very
|
||||||
|
# command. Exercising it needs a real SUDO_USER and real groups, so grep the
|
||||||
|
# refusal in both identity-management commands (repo precedent: the
|
||||||
|
# staging/runner tag greps).
|
||||||
|
check "users apply: invoker gate refusal is present" 0 "" \
|
||||||
|
grep -q "changes who holds root" "$ROOT/commands/users-apply.sh"
|
||||||
|
check "users close-root: invoker gate refusal is present" 0 "" \
|
||||||
|
grep -q "changes who holds root" "$ROOT/commands/users-close-root.sh"
|
||||||
|
# Offboarding must revoke SSH, not just the password: a '!'-locked password is
|
||||||
|
# not a closed door under UsePAM — pubkey auth still works. Expiry is the
|
||||||
|
# switch PAM actually honors, and the keys are renamed, never deleted
|
||||||
|
# (convergence never destroys). Needs root + real accounts, so grep both moves.
|
||||||
|
check "users apply: a dropped user's account is expired, not just locked" 0 "" \
|
||||||
|
grep -qF -- "usermod -L -e 1" "$ROOT/commands/users-apply.sh"
|
||||||
|
check "users apply: revoked keys are renamed, never deleted" 0 "" \
|
||||||
|
grep -q "revoked-by-rig" "$ROOT/commands/users-apply.sh"
|
||||||
|
# A fleet-wide users file must not abort apply on a host=no box just because
|
||||||
|
# it names a box-role user somewhere in the fleet: the box role binds where
|
||||||
|
# VMs live, so on host=no it skips (with a warning) and everything else —
|
||||||
|
# admins included — still converges.
|
||||||
|
check "users apply: box role skips on a host=no box" 0 "" \
|
||||||
|
grep -q "box role skipped" "$ROOT/commands/users-apply.sh"
|
||||||
|
|
||||||
|
# --- users close-root: the human-class root-door shutter ---------------------
|
||||||
|
check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help
|
||||||
|
check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope
|
||||||
|
# The whole command rests on first-wins + lexical include order: '-' (0x2D)
|
||||||
|
# sorts before '.' (0x2E), so 00-rig-users.conf is read before bootstrap's
|
||||||
|
# 00-rig.conf and its PermitRootLogin wins. Assert the actual comparison the
|
||||||
|
# glob makes, so a renamed drop-in cannot silently lose the fight.
|
||||||
|
check "users close-root: drop-in name sorts before bootstrap's" 0 "" \
|
||||||
|
bash -c '[ "00-rig-users.conf" \< "00-rig.conf" ]'
|
||||||
|
check "users close-root: drop-in name is the load-bearing one" 0 "" \
|
||||||
|
grep -q "00-rig-users.conf" "$ROOT/commands/users-close-root.sh"
|
||||||
|
# Validate-then-apply: `sshd -t` on the merged config must precede the restart —
|
||||||
|
# on a box whose only door is SSH (exactly what this box is about to become),
|
||||||
|
# bouncing the daemon into a config it refuses to parse leaves no way back in.
|
||||||
|
# Match the call, not the word (repo precedent: the repo-guard ordering check);
|
||||||
|
# defaults fail closed.
|
||||||
|
sshdt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||||
|
restart_at="$(grep -n 'systemctl restart ssh' "$ROOT/commands/users-close-root.sh" | head -n1 | cut -d: -f1)"
|
||||||
|
check "users close-root: sshd -t precedes the ssh restart" \
|
||||||
|
0 "" test "${sshdt_at:-999999}" -lt "${restart_at:-0}"
|
||||||
|
# Convergence is a claim about the DOOR, not the file. Matching bytes can hide
|
||||||
|
# an earlier-sorting override (first-wins) or a daemon that died between
|
||||||
|
# install and restart and never read the file — so the no-op message may only
|
||||||
|
# be spoken after the effective-config assertion (`sshd -T`), and the no-op
|
||||||
|
# branch may only be TAKEN when the daemon provably started after the last
|
||||||
|
# change to sshd's config inputs. Pin both: the assert-before-claim ordering,
|
||||||
|
# and the daemon-start-vs-config-mtime proof's presence.
|
||||||
|
efft_at="$(grep -n 'sshd -T' "$ROOT/commands/users-close-root.sh" | grep -v '^[0-9]*:#' | head -n1 | cut -d: -f1)"
|
||||||
|
noop_at="$(grep -n 'nothing to do' "$ROOT/commands/users-close-root.sh" | tail -n1 | cut -d: -f1)"
|
||||||
|
check "users close-root: no-op claim sits after the effective-config assert" \
|
||||||
|
0 "" test "${efft_at:-999999}" -lt "${noop_at:-0}"
|
||||||
|
check "users close-root: no-op needs a daemon start newer than the config" 0 "" \
|
||||||
|
grep -q "ExecMainStartTimestamp" "$ROOT/commands/users-close-root.sh"
|
||||||
|
# The admin-door gate must check the StrictModes SHAPE, not file existence: a
|
||||||
|
# non-empty authorized_keys behind group/world-writable perms is a key sshd
|
||||||
|
# rejects — closing root behind it welds the only door shut. The full gate
|
||||||
|
# needs root + real accounts, so grep the load-bearing check's wording.
|
||||||
|
check "users close-root: gate checks the StrictModes shape" 0 "" \
|
||||||
|
grep -q "group/world-writable" "$ROOT/commands/users-close-root.sh"
|
||||||
|
# Marker-gate refusals through the sourced lib against fixture markers: the CLI
|
||||||
|
# path sits behind the root check, so the gate is a pure lib function on
|
||||||
|
# purpose (repo precedent: parse_users_file, assert_runner_repo). The command
|
||||||
|
# reads the marker path from RIG_ROLE_MARKER for the same reason — so the gate
|
||||||
|
# stays pointable at fixtures.
|
||||||
|
marker_gate() { # marker_gate <marker_path>
|
||||||
|
bash -c 'set -euo pipefail
|
||||||
|
. "$1/commands/lib/users-config.sh"
|
||||||
|
assert_marker_human "$2"' _ "$ROOT" "$1"
|
||||||
|
}
|
||||||
|
MARKER_DIR="$(mktemp -d)"
|
||||||
|
printf 'role=workload class=server host=no join=authkey\n' > "$MARKER_DIR/server"
|
||||||
|
printf 'role=dev class=human host=yes join=authkey\n' > "$MARKER_DIR/human"
|
||||||
|
check "users close-root: absent marker refuses, names bootstrap as the repair" \
|
||||||
|
1 "no /etc/rig/role marker" marker_gate "$MARKER_DIR/absent"
|
||||||
|
check "users close-root: class=server refuses, names the control plane" \
|
||||||
|
1 "control plane" marker_gate "$MARKER_DIR/server"
|
||||||
|
check "users close-root: class=human passes the gate" \
|
||||||
|
0 "" marker_gate "$MARKER_DIR/human"
|
||||||
|
rm -rf "$MARKER_DIR"
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
check "users close-root: refuses non-root" 1 "must run as root" "$ROOT/commands/users-close-root.sh"
|
||||||
|
else
|
||||||
|
echo "skip: users close-root non-root refusal (running as root)"
|
||||||
|
fi
|
||||||
|
# Bootstrap must read the closed door as hardened, not broken: `no` is the
|
||||||
|
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
|
||||||
|
# the widened assertion so a revert cannot ship green.
|
||||||
|
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
|
||||||
|
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
|
||||||
|
# ...but only for class=human. On class=server a closed root door is a BROKEN
|
||||||
|
# box — root SSH is the control plane's automation door — and the usual cause
|
||||||
|
# is a 00-rig-users.conf left over from a former class=human life. The refusal
|
||||||
|
# must name that drop-in or the operator greps sshd configs blind; the path
|
||||||
|
# needs root + a doctored sshd, so grep the die message (repo precedent above).
|
||||||
|
check "bootstrap: class=server refusal names the stale close-root drop-in" 0 "" \
|
||||||
|
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/bootstrap.sh"
|
||||||
|
|
||||||
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
|
||||||
# error in it would be invisible here and would first surface at 04:00 on a live
|
# error in it would be invisible here and would first surface at 04:00 on a live
|
||||||
# control plane. Extract it and syntax-check what actually gets written.
|
# control plane. Extract it and syntax-check what actually gets written.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue