diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40454c5..aa2ef1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,16 +20,3 @@ jobs: shellcheck -x "${files[@]}" - name: cli tests run: bash test/cli.sh - - # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and - # stands up throwaway containers, and a slow image pull must never delay the - # fast shellcheck + cli.sh feedback above. ubuntu-latest ships Docker running - # and passwordless sudo, so test/db-integration.sh EXECUTES here (it only - # skips where Docker is absent). It is the automated proof that dump/restore - # actually round-trips, not just that the args parse. - db-integration: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: db dump/restore round-trip - run: bash test/db-integration.sh diff --git a/README.md b/README.md index 0c15b82..47fbc06 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. @@ -29,9 +29,62 @@ second run changes nothing. rig bootstrap control-plane --hostname my-coolify-box rig bootstrap workload --hostname my-prod-box rig bootstrap runner --hostname my-ci-box +rig bootstrap staging --hostname my-vm-host +rig bootstrap dev --hostname my-dev-box +rig bootstrap workstation --hostname my-laptop +rig bootstrap custom --hostname odd-duck --class server --host yes --join authkey ``` -- `--hostname ` — 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; decides root SSH's fate after + `rig users apply` (see *The identity model* below) +- `--host ` — does this box host VMs (box/Incus) +- `--join ` — how it enters the tailnet + +**Roles are presets over three orthogonal traits**, nothing more — every +per-role behavior keys off a trait, so any flag overrides its trait without +needing a new role (`rig bootstrap workstation --host no` for a laptop that +will never run VMs), and `custom` exists for the shape nobody foresaw: it +presets nothing and requires `--hostname` plus all three traits. + +| trait | values | what it drives | +|---------|--------------------|----------------| +| `class` | `human`, `server` | root SSH's fate once operators exist — human closes it, server keeps it as the control plane's automation door | +| `host` | `yes`, `no` | whether the box exists to run VMs — the `/dev/kvm` advisory and the `box setup-host` pointer | +| `join` | `authkey`, `login` | tagged pre-auth key (fleet identity) vs interactive browser login (user-owned device) | + +| role | class | host | join | tailnet tag | +|-----------------|--------|------|---------|-------------| +| `control-plane` | server | no | authkey | `tag:server` | +| `workload` | server | no | authkey | `tag:server` | +| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` | +| `staging` | server | yes | authkey | `tag:local` — refuses `tag:server` | +| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` | +| `workstation` | human | yes | login | untagged — any tag refused | + +The tag column is **derived policy, not a fourth trait**: `tag:server` means +"the control plane manages this box", and `control-plane` and `workload` are +the only shapes it manages — every other role refuses an effective +`tag:server` after join, one rule instead of per-role exceptions. + +After the tag verification passes, bootstrap writes `/etc/rig/role` — one +line, `role=… class=… host=… join=…` — recording the **effective** traits, +overrides and all, so an overridden role never lies to the commands that read +the marker later (`rig users` keys root policy off `class=`). Written +post-join and cmp-guarded, so a marker never describes a box that failed to +become what it claims. + +**`join=login` inverts the tag assertion.** A workstation joins as a +user-owned device: there is no pre-auth key — a set `TS_AUTHKEY` is a loud +usage error (exit 2; unset it, or pass `--join authkey`) — `tailscale up` +prints a login URL, and the human at the keyboard is the credential. After +join the assertion flips: **untagged** is what rig asserts, and any effective +tag is the refusal — a tag here means control granted this device fleet +identity, and on a first join the half-joined node is backed out with +`tailscale logout` (a box that was already joined is refused without backout; +rig never unwinds state it did not create). Same principle as the authkey +path, mirrored: verify what control **granted**, never what was requested. There is **no `--ts-tag` flag**. A pre-auth key is minted *with* its tags, so the key is the single source of truth for the tailnet tag — rig no longer states @@ -67,7 +120,8 @@ admin console keeps that name; rig will not fight it. > `passwordauthentication yes`. `bootstrap` now sweeps a stale `99-rig.conf` > 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 **tagged** part is now load-bearing, not advice (see below). It lives in process 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 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 ` Control-plane box only. Installs Coolify at exactly the pinned version with @@ -154,107 +252,6 @@ journalctl -u coolify-dump.service -n 20 --no-pager A backup you have never read back is not yet a backup. -### `rig db ` - -Ad-hoc PostgreSQL dump/restore for a container running on this box. Run as -root. - -```sh -rig db dump coolify-db # -> coolify-db-20260717T041500Z.sql.gz -rig db dump my-app-db /srv/snapshots/pre-migrate.sql.gz -rig db restore pre-migrate.sql.gz my-app-db # prompts before overwriting -rig db restore umami.sql.gz shared-pg umami --yes -``` - -This is **imperative on-box tooling** — the "give me a copy of that database -right now", "put this artifact back" verbs you reach for by hand. It is the -counterpart to [`rig coolify backup install`](#rig-coolify-backup-install), -which is the *scheduled, declarative, forensics-only* path; `db` is -interactive, targets any container, and (on restore) overwrites live data -behind a confirm gate. Declarative convergence lives elsewhere by design — this -verb exists precisely for the moments that are not convergent. - -**`dump`** pipes `pg_dump` straight into `gzip`: - -```sh -docker exec sh -c \ - 'pg_dump -U "$POSTGRES_USER" --clean --if-exists --no-owner --no-acl "$POSTGRES_DB"' | gzip -``` - -- **`--no-owner --no-acl` is mandatory, not cosmetic.** A cross-instance restore - runs as the *target's* superuser, and Coolify randomizes that role per - database — so the source's `ALTER OWNER`/`GRANT` statements name a role that - does not exist on the target and, under `ON_ERROR_STOP=1`, abort the whole - restore on the first one. Stripping ownership and ACLs makes the dump describe - *data and schema*, portable onto any instance. -- **`$POSTGRES_USER` / `$POSTGRES_DB` are read inside the container** — that is - why the command is a *single-quoted* `sh -c`: it evaluates the container's own - environment, never the host's. The host never hardcodes `postgres`; on the - next container that role name is simply wrong. -- With no `[outfile]`, it writes `-.sql.gz` in the - current directory (same timestamp shape as the nightly dump). `pipefail` is - load-bearing: without it a failing `pg_dump` still exits 0 through the pipe and - `gzip` compresses the truncated output into a valid `.gz` that looks exactly - like a good backup. rig dumps to a sibling temp, promotes it only on success, - and refuses to keep an empty artifact. - -**`restore [db]`** streams the artifact back in: - -```sh -gunzip -c | docker exec -i sh -c \ - 'psql -U "$POSTGRES_USER" -d "${db:-$POSTGRES_DB}" -v ON_ERROR_STOP=1' -``` - -- It connects as the container's **own superuser** (`$POSTGRES_USER`), again - never a hardcoded role, and runs with `ON_ERROR_STOP=1` so a bad restore fails - loudly instead of limping to a half-applied state and reporting success. -- **`[db]` targets a named database in a shared container** — e.g. a `umami` - database living in a Postgres that also hosts other apps. Omit it to restore - into the container's default `$POSTGRES_DB`. rig passes the name *into* the - container as an env var rather than splicing it into the command string. -- **Restore overwrites the target**, so it prompts `y/N` first. `--yes` (or - `--force`) is the automation bypass. The artifact is checked for existence and - non-emptiness *before* the prompt and before anything touches the database, so - a fat-fingered path fails cheaply. - -#### Verifying a dump/restore actually works - -A `.gz` that opens without error is not proof of a good backup: a `pg_dump` -truncated mid-stream still compresses into a perfectly valid gzip file that -*looks* exactly like a complete one. The same ethos as the nightly dump applies -here — **a backup you have never read back is not yet a backup.** The only -fully-trustworthy proof is to restore the artifact and read the rows back out. - -On a real Coolify box you can do that **without touching prod data** by -restoring into a fresh *scratch* database rather than over the live one: - -```sh -# 1. dump the live database (read-only; harmless) -rig db dump coolify-db /srv/snapshots/verify.sql.gz - -# 2. create a throwaway database as the container's OWN superuser -docker exec coolify-db sh -c 'createdb -U "$POSTGRES_USER" rig_verify' - -# 3. restore the artifact INTO the scratch db (not the live one) -rig db restore /srv/snapshots/verify.sql.gz coolify-db rig_verify --yes - -# 4. spot-check a table you expect to see -docker exec coolify-db sh -c \ - 'psql -U "$POSTGRES_USER" -d rig_verify -c "\dt"' -docker exec coolify-db sh -c \ - 'psql -U "$POSTGRES_USER" -d rig_verify -c "SELECT count(*) FROM "' - -# 5. drop the scratch db — live data was never touched -docker exec coolify-db sh -c 'dropdb -U "$POSTGRES_USER" rig_verify' -``` - -If the counts and tables are there, the artifact is real. This is exactly the -round-trip `test/db-integration.sh` automates in CI (the `db-integration` job): -it seeds a known table in a source container whose superuser is *not* the -default, dumps it, restores into a second container whose superuser differs, and -asserts the rows and an ordered checksum survived — the same proof, done against -throwaway containers on every push. - ### `rig runner install --repo ` Runner box only, run after `rig bootstrap runner` (the same two-step rhythm @@ -386,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, and never asks for a token. +### `rig users apply --file ` + +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 +@` 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=""` clause, so the +> automation identity only opens from the one address supposed to use it. rig +> will not write that file — Coolify owns its key material on the servers it +> registers, and two tools converging one file is drift by construction (the +> same argument that keeps rig's hands off Incus). + ## What rig deliberately does NOT do - **Provider firewalls** — Docker publishes ports past host firewalls, so @@ -400,7 +532,10 @@ and never asks for a token. ## Testing `bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. 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. +`rig users` family is covered the same way: the harness drives its refusal +matrix — users-file parsing, the marker gates, the lexical drop-in-name +assertion, the validate-then-apply ordering — through the sourced lib +functions, non-root and network-free. The end-to-end rehearsal is a throwaway +VM/container: pristine Debian → install → `bootstrap workload` with a real +single-use key → assert the sshd drop-in, tailnet join, and a no-op second +run → destroy, remove the node from the tailnet. diff --git a/bin/rig b/bin/rig index b425fec..1fe8f95 100755 --- a/bin/rig +++ b/bin/rig @@ -8,11 +8,17 @@ usage() { usage: rig [args] commands: - bootstrap [--hostname ] [--ts-tag ] + bootstrap + [--hostname ] [--class ] [--host ] + [--join ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, - tailscale join. Prompts for a single-use tailnet pre-auth key - (TS_AUTHKEY env overrides the prompt). Run as root. Role runner - defaults to tag:ci and refuses tag:server. + tailscale join. Roles are presets over the three traits; any flag + overrides its trait, and custom states all of them. Prompts for a + 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 Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] @@ -20,11 +26,6 @@ commands: systemd timer. rig installs the machinery and templates an empty 0600 bindings file; you fill in the age recipient and S3 details. Control-plane box only. Run as root. - db ... - Ad-hoc PostgreSQL dump/restore for a container on this box. `dump` - writes a gzipped SQL artifact (--no-owner --no-acl, so it restores - onto a different instance); `restore` loads one back, connecting as - the container's own superuser, behind a confirm gate. Run as root. runner install --repo [options] GitHub Actions runner as a systemd service under an unprivileged user — outbound-only, no Docker. Prompts for the short-lived @@ -41,6 +42,18 @@ commands: 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. Run as root. + users apply --file + 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: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash @@ -76,18 +89,6 @@ case "$cmd" in ;; esac ;; - db) - shift - case "${1:-}" in - dump|restore|-h|--help) - exec "$ROOT/commands/db.sh" "$@" - ;; - *) - usage >&2 - exit 2 - ;; - esac - ;; runner) shift sub="${1:-}" @@ -114,6 +115,28 @@ case "$cmd" in ;; 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) usage exit 0 diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 45f8092..a47bec1 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -13,36 +13,99 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--hostname ] +usage: rig bootstrap + [--hostname ] [--class ] + [--host ] [--join ] - --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, so the key is the single source of truth: rig no longer requests a tag it might disagree with. After the box joins, rig reads the tag control actually GRANTED (tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused -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 -enter it at the interactive prompt. It is used once and never written to disk. +join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY +env var, or enter it at the interactive prompt. Used once, never written to disk. + +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 } # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload|runner) shift ;; + control-plane|workload|runner|staging|dev|workstation|custom) shift ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane|workload|runner)" 2 ;; - *) die "unknown role: $ROLE (want 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|staging|dev|workstation|custom)" 2 ;; 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" +[ "$ROLE" = "custom" ] && TS_HOSTNAME="" while [ $# -gt 0 ]; do case "$1" in --hostname) [ $# -ge 2 ] || die "--hostname needs a value" 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 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, @@ -57,6 +120,24 @@ while [ $# -gt 0 ]; do esac 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 ------------------------------------------------------------------ [ "$(id -u)" -eq 0 ] || die "must run as root" if [ -r /etc/os-release ]; then @@ -71,6 +152,12 @@ if [ -r /etc/os-release ]; then else warn "cannot read /etc/os-release; proceeding anyway" 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 # 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" 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/" -echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \ - || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" +# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite +# things on the two classes. class=human: `no` is the post-`rig users +# close-root` state — strictly harder than the prohibit-password this script +# installs. Bootstrap must never read a closed door as a broken one, and it +# cannot reopen one either: by first-wins its own drop-in loses to +# 00-rig-users.conf. class=server: root SSH is the control plane's automation +# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet +# management silently dead, and the likely culprit is a drop-in left over from +# a former class=human life on a repurposed box. rig can DETECT that but must +# not FIX it: silently reopening a root door is worse than a loud stop, so — +# same doctrine as the tag checks — detect, refuse, and name the repair. +if [ "$CLASS" = "human" ]; then + echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \ + || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" +elif echo "$eff" | grep -qx 'permitrootlogin no'; then + die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap." +else + echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \ + || die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/" +fi log "sshd hardening verified (sshd -T: passwordauthentication no)" # --- system hostname ---------------------------------------------------------- @@ -179,8 +284,15 @@ fi # Tags ride in with the netmap, not synchronously out of `up`, so a single read # right after join can legitimately come back empty; poll until tags appear OR # the backend reaches Running (past which an empty Tags is real, not just early). +# +# verify_effective_tag — 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() { - local deadline=$((SECONDS + 30)) tags="" state="" json + local mode="$1" deadline=$((SECONDS + 30)) tags="" state="" json json="$(mktemp)" while :; do 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 # 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, - # 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 - tailscale logout >/dev/null 2>&1 \ - || warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand" - die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run." + if [ "$mode" = "back-out" ]; then + tailscale logout >/dev/null 2>&1 \ + || warn "tailscale logout failed — this node is joined UNTAGGED and user-owned; remove it from the tailnet by hand" + die "joined with NO tag: the pre-auth key was untagged, so this node is owned by the key creator's user identity, not a tag. Backed it out. Fix: mint a TAGGED pre-auth key and re-run." + fi + die "this box is joined but UNTAGGED — possibly a login-joined (user-owned) machine re-run with join=authkey. It was joined before this run, so nothing was backed out. If it should be fleet-owned: run 'tailscale logout' and re-run with a TAGGED pre-auth key. If it is a workstation: re-run with --join login." fi - # Role policy now rides the EFFECTIVE tag — strictly stronger than the old - # request-time check, which only guarded the tag rig HOPED for. This guards the - # tag the key ACTUALLY granted to repo-controlled code: a runner carrying - # tag:server would extend every grant your servers hold to CI code. Refused, - # never warned. rig can DETECT this but cannot FIX it, so name the repair. - if [ "$ROLE" = "runner" ] && printf '%s\n' "$tags" | grep -qx 'tag:server'; then - 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)." + # tag:server policy is DERIVED, not a trait: it means "the control plane + # manages this box", and only control-plane and workload are shapes the + # control plane manages. Everything else refuses it on the EFFECTIVE tag — + # strictly stronger than the old request-time check, which only guarded the + # tag rig HOPED for. The fleet has been bitten both ways: a runner carrying + # tag:server extends every server grant to repo-controlled code, and a + # staging host carrying it extends them to a box the control plane does not + # even know. Refused, never warned; rig can DETECT this but cannot FIX it, + # so each refusal names its repair. + if printf '%s\n' "$tags" | grep -qx 'tag:server'; then + case "$ROLE" in + 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 log "verified effective tailnet tag(s): $(printf '%s' "$tags" | tr '\n' ' ')" } +# verify_user_owned — 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 log "installing tailscale" 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 # deliberate and stays — re-running an identical tagged-authkey `up` errors — # 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 # env override, else prompt; never touches disk if [ -z "${TS_AUTHKEY:-}" ]; then @@ -264,12 +455,40 @@ else # cannot be rescued by one (verify_effective_tag refuses it and logs out). log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)" tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" - verify_effective_tag + verify_effective_tag back-out 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}" if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " elif [ "$ROLE" = "runner" ]; then log "next: rig runner install --repo --version " 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 , then 'rig users close-root' once your admin key works" +else + log "next: rig users apply --file for named operator logins; root SSH stays — it is the control plane's automation door" +fi diff --git a/commands/db.sh b/commands/db.sh deleted file mode 100755 index 8b094f7..0000000 --- a/commands/db.sh +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env bash -# rig db — ad-hoc PostgreSQL dump/restore for the containers running on THIS box. -# -# Imperative on-box tooling, deliberately: this is the "I need a copy of that -# database right now" / "put this artifact back" verb an operator reaches for by -# hand. It is the counterpart to `coolify backup install`, which is the -# scheduled, declarative, forensics-only path — this one is interactive, targets -# any container, and (for restore) overwrites live data behind a confirm gate. -# -# Two rules run through everything below and are non-negotiable: -# * $POSTGRES_USER / $POSTGRES_DB are read INSIDE the container (that is why -# every pg_dump/psql lives in a SINGLE-quoted `sh -c '...'` — the container's -# own environment, not the host's). Coolify randomizes the superuser per -# database, so the host must never hardcode `postgres`; a hardcoded role is -# simply wrong on the next container. -# * dumps carry `--no-owner --no-acl`. Without them a cross-instance restore -# aborts under ON_ERROR_STOP=1 the moment psql hits a GRANT/ALTER OWNER for -# a role that does not exist on the target (source and target superusers -# differ by construction). The dump must describe *data and schema*, not the -# source box's role graph. -set -euo pipefail - -log() { printf 'rig-db: %s\n' "$*"; } -warn() { printf 'rig-db: WARNING: %s\n' "$*" >&2; } -die() { printf 'rig-db: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } - -usage() { - cat <<'EOF' -usage: rig db ... - - dump [outfile] - Dump the PostgreSQL database inside to a gzipped SQL file. - When [outfile] is omitted, writes -.sql.gz in - the current directory. The dump is taken with --clean --if-exists - --no-owner --no-acl, so it restores cleanly onto a different instance - whose superuser differs from this one's. - - restore [db] [--yes] - Restore a gzipped SQL artifact into , connecting as the - container's OWN superuser. [db] targets a NAMED database in a shared - container (e.g. a `umami` database in a shared Postgres); omit it to - restore into the container's default database. Restore OVERWRITES the - target and prompts before doing so — pass --yes (or --force) to run - non-interactively. -EOF -} - -# --- common guards ---------------------------------------------------------- -# Called AFTER arg validation (arg errors must stay testable without root). -require_common_guards() { - [ "$(id -u)" -eq 0 ] || die "must run as root" - if [ -r /etc/os-release ]; then - # Sourced in a subshell — /etc/os-release defines VERSION and would clobber - # a caller's variables (see test/cli.sh's regression check). - local OS_FAMILY - # shellcheck source=/dev/null - OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")" - case "$OS_FAMILY" in - *debian*) ;; - *) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;; - esac - else - warn "cannot read /etc/os-release; proceeding anyway" - fi - # dump/restore only make sense on a box actually running the containers. - command -v docker >/dev/null \ - || die "docker not found — rig db operates on containers running on this box" -} - -# --- db dump ---------------------------------------------------------------- -cmd_dump() { - local container="" outfile="" tmp - local -a pos=() - while [ $# -gt 0 ]; do - case "$1" in - -h|--help) usage; exit 0 ;; - --*|-*) die "unknown flag: $1" 2 ;; - *) pos+=("$1"); shift ;; - esac - done - - # Args first, so these errors are reachable without docker or root. - [ "${#pos[@]}" -le 2 ] || die "dump takes at most [outfile]" 2 - container="${pos[0]:-}" - outfile="${pos[1]:-}" - [ -n "$container" ] || die "dump needs a container name" 2 - # Mirror coolify-dump.sh's timestamp style for the default name. - [ -n "$outfile" ] || outfile="${container}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz" - - require_common_guards - command -v gzip >/dev/null || die "gzip not found (part of coreutils on Debian)" - - log "dumping database in container ${container} -> ${outfile}" - # Write to a sibling temp and promote on success, so a failed dump never - # leaves a plausible-looking artifact in place of a real one. Same directory - # as the target keeps the final mv atomic. - tmp="$(mktemp "${outfile}.XXXXXX")" || die "could not create a temp file next to ${outfile}" - trap 'rm -f "$tmp"' EXIT - - # pipefail (set above) is load-bearing: without it a failing pg_dump still - # exits 0 through the pipe and gzip faithfully compresses the truncated output - # into a valid .gz that looks exactly like a good backup. The `sh -c` is - # single-quoted on purpose — $POSTGRES_USER/$POSTGRES_DB are the CONTAINER's. - # shellcheck disable=SC2016 - if ! docker exec "$container" \ - sh -c 'pg_dump -U "$POSTGRES_USER" --clean --if-exists --no-owner --no-acl "$POSTGRES_DB"' \ - | gzip > "$tmp"; then - die "pg_dump failed — no artifact written" - fi - # Even an empty dump gzips to a ~20-byte VALID file; refuse to keep one. - [ -s "$tmp" ] || die "refusing to keep an empty dump artifact" - mv "$tmp" "$outfile" - trap - EXIT - log "wrote ${outfile} ($(stat -c %s "$outfile") bytes)" -} - -# --- db restore ------------------------------------------------------------- -cmd_restore() { - local artifact="" container="" target_db="" assume_yes=0 reply="" - local -a pos=() - while [ $# -gt 0 ]; do - case "$1" in - --yes|--force) assume_yes=1; shift ;; - -h|--help) usage; exit 0 ;; - --*|-*) die "unknown flag: $1" 2 ;; - *) pos+=("$1"); shift ;; - esac - done - - # Args first: required-arg errors are exit 2 and reachable without root. - [ "${#pos[@]}" -le 3 ] || die "restore takes at most [db]" 2 - artifact="${pos[0]:-}" - container="${pos[1]:-}" - target_db="${pos[2]:-}" - [ -n "$artifact" ] || die "restore needs an artifact file" 2 - [ -n "$container" ] || die "restore needs a target container" 2 - # Artifact existence/non-emptiness is checkable without docker or root, so we - # check it HERE — a fat-fingered path fails clearly and cheaply, before the - # confirm gate and before anything touches the database. - [ -e "$artifact" ] || die "artifact not found: ${artifact}" - [ -f "$artifact" ] || die "artifact is not a regular file: ${artifact}" - [ -s "$artifact" ] || die "artifact is empty: ${artifact}" - - require_common_guards - command -v gunzip >/dev/null || die "gunzip not found (part of coreutils on Debian)" - - # Destructive: restore overwrites the target database in place. Default to - # prompting; --yes/--force is the automation bypass. - if [ "$assume_yes" -eq 0 ]; then - printf 'rig-db: restore OVERWRITES the database in container %s%s. Continue? [y/N] ' \ - "$container" "${target_db:+ (database ${target_db})}" >&2 - read -r reply || reply="" - case "$reply" in - y|Y|yes|YES|Yes) ;; - *) die "aborted — no changes made" ;; - esac - fi - - log "restoring ${artifact} into container ${container}${target_db:+ (database ${target_db})}" - # Connect as the container's OWN superuser ($POSTGRES_USER); never hardcode - # postgres. RIG_TARGET_DB carries the optional [db] arg INTO the container so - # a shared Postgres can be restored into a NAMED database; empty falls back to - # the container's own $POSTGRES_DB. ON_ERROR_STOP=1 aborts on the first error - # rather than limping to a half-applied restore and reporting success. - # shellcheck disable=SC2016 - if ! gunzip -c "$artifact" \ - | docker exec -i -e RIG_TARGET_DB="$target_db" "$container" \ - sh -c 'psql -U "$POSTGRES_USER" -d "${RIG_TARGET_DB:-$POSTGRES_DB}" -v ON_ERROR_STOP=1'; then - die "restore failed — the database may be partially applied; inspect container ${container}" - fi - log "restore complete" -} - -# --- dispatch --------------------------------------------------------------- -sub="${1:-}" -case "$sub" in - dump) shift; cmd_dump "$@" ;; - restore) shift; cmd_restore "$@" ;; - -h|--help) usage; exit 0 ;; - *) usage >&2; exit 2 ;; -esac diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh new file mode 100644 index 0000000..69251ee --- /dev/null +++ b/commands/lib/users-config.sh @@ -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 +# +# 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 — 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 — 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 +} diff --git a/commands/users-apply.sh b/commands/users-apply.sh new file mode 100755 index 0000000..39021bd --- /dev/null +++ b/commands/users-apply.sh @@ -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 + + --file 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 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 ` — 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 diff --git a/commands/users-close-root.sh b/commands/users-close-root.sh new file mode 100755 index 0000000..26d8d74 --- /dev/null +++ b/commands/users-close-root.sh @@ -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 +@` 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