Machine traits: roles as presets over class/host/join (supersedes #25) #26

Closed
opened 2026-07-17 18:04:29 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-17 18:04:29 +00:00 (Migrated from github.com)

Context

#25 made machine class bootstrap's organizing axis: server-class vs host-class. Working staging and workstations through that model showed the binary was bundling three independent questions that merely correlated across the first four roles:

  • The staging host is a host by what it runs, yet nobody works there — admins reach it as root exactly as they would a workload box. Under #25 it inherited operators and rig users by virtue of being host-class, which nobody needs there. Its guests are already root-only (heavy-duty/box#69); the corrected model makes host and guests both unattended, so the recursion gets cleaner.
  • A workstation is defined by the human at it, and joins the tailnet by interactive login — user-owned, untagged — which bootstrap today treats as a fleet-shaped mistake and backs out.

This issue supersedes #25: the class binary becomes a small set of orthogonal traits. Roles stay — they are the presets that make the usual shapes one command — but every trait is individually settable, so rig can also bootstrap a custom shape. A role is a template, not a straitjacket.

The traits

trait values what it drives
class human / server Root SSH's fate. Named operators with scoped sudo exist on every class via rig users (#24) — humans always enter as themselves; root is never a human door (see the hybrid access model under Decided). What class decides is what happens to root SSH after rig users apply: human — closed entirely (rig users close-rootPermitRootLogin no); server — stays open as the automation identity the control plane SSHes in as, and close-root refuses.
host yes / no Whether it hosts box-minted guest VMs. Drives the /dev/kvm advisory and the closing pointer at box setup-host. A host's traits say nothing about its guests' — staging guests are full machines converged as workload.
join authkey / login How it enters the tailnet. authkey: tagged pre-auth key, node is tag-owned; untagged is refused and backed out (today's behavior). login: interactive tailscale up, node is user-owned; here the assertion inverts — an effective tag is the refusal. Same mechanism either way: verify what control granted, never what was requested.

tag:server policy stays derived, not a trait: allowed only on the machines the control plane manages (control-plane, workload); every other shape refuses it on the effective tags. This generalizes today's runner and staging checks into one rule.

Roles as presets

role class host join tag guidance
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 (user-owned) — any tag refused

The correction vs #25: staging is class=server. It hosts VMs but is unattended — a VM appliance. "Hosts VMs" was never the load-bearing property for identity; it just correlated.

Any trait is overridable on any role — rig bootstrap workstation --host no for a machine that will never mint boxes — and rig bootstrap custom --class <c> --host <h> --join <j> --hostname <name> builds a shape with no preset at all (custom has no default hostname; --hostname is required). The marker (below) records effective traits, so an overridden role never lies about what the box is.

What changes

  1. role→traits map in commands/bootstrap.sh — the single place a role's shape is declared. All behavior that #25 keyed per-class keys off traits: kvm advisory (host=yes), tag:server refusal (derived policy), next-steps log (host=yes → box setup-host pointer; rig users pointer per the identity model).
  2. New roles dev and workstation. The join=login path: no pre-auth key demanded (a set TS_AUTHKEY is a loud refusal telling you to unset it or pass --join authkey), interactive tailscale up, then assert the node is user-owned and untagged.
  3. Marker /etc/rig/role, e.g. role=staging class=server host=yes join=authkey — convergent like the hostname. First consumer: #24rig users close-root refuses on class=server (root there is the control plane's automation identity, not a human door), while apply runs on every class.
  4. README identity model: the trait table, the roles-as-presets table, and the per-machine identity story (who installs what, who runs as what, elevation path), replacing the flat role list.

Non-goals

  • No behavior change for control-plane/workload/runner.
  • No rig users implementation (#24; the marker is its input — see the hybrid access model under Decided for what each class means there).
  • No box changes (root-only staging template, global install — tracked in heavy-duty/box).
  • No new tailnet tags; conventions documented, not invented.

Decided

  • Hybrid access model (supersedes the earlier "server is humanless — rig users refuses"; worked out in the comments below). Named operator accounts exist on every class: this tailnet is network-only — no Tailscale SSH — so there is no identity broker at the door, and multiple humans entering as root would be unattributable. Humans therefore always enter as themselves and elevate via sudo, on servers too. Root SSH is what class decides after rig users apply: human → closed entirely; server → stays, as the automation identity the control plane (Coolify) SSHes in as — key-only, and never a human door. Side benefit: once humans never use root, any root login that isn't the control plane is anomalous by definition — a cheap, high-signal alert that a shared root identity makes impossible. Caveat kept honest: on Docker-running boxes this buys attribution, not privilege reduction — an operator with sudo is root-equivalent; attribution is the goal.
  • PermitRootLogin no on class=human is a rig users follow-up, not a bootstrap concern. It cannot happen at bootstrap — dev bring-up is over root SSH before any user exists. Bootstrap keeps the fleet-wide prohibit-password; closing root SSH becomes possible only after rig users apply has put an admin user with a working key on the box (rig users close-root, specified in #24).

Open questions

  • Trait name bikeshed: host reclaims the word the fleet already uses ("the box that hosts staging boxes", box setup-host); alternative vm-host if the bare word reads too generic.

Acceptance

  • rig bootstrap dev and rig bootstrap workstation exist and behave per the preset table; test/cli.sh covers both as staging is covered, plus: a trait override changes the derived behavior, custom without --hostname exits 2, join=login with TS_AUTHKEY set exits 2.
  • The role→traits map is the only place a role's shape is declared; no per-role copies of trait behavior.
  • Bootstrap on any role writes the marker; a second run converges it silently.
  • README presents the trait model and identity table; shellcheck + bash test/cli.sh green.

Supersedes #25 (class-based model). Companion users issue: #24 — ships with this one as a single release. Prior related: #22 (staging role, PR #23), heavy-duty/box#68 (staging template, PR #69).

## Context #25 made **machine class** bootstrap's organizing axis: server-class vs host-class. Working staging and workstations through that model showed the binary was bundling three independent questions that merely *correlated* across the first four roles: - The **staging host** is a host by what it runs, yet nobody works there — admins reach it as root exactly as they would a workload box. Under #25 it inherited operators and `rig users` by virtue of being host-class, which nobody needs there. Its guests are already root-only (heavy-duty/box#69); the corrected model makes host and guests *both* unattended, so the recursion gets cleaner. - A **workstation** is defined by the human at it, and joins the tailnet by interactive login — user-owned, untagged — which bootstrap today treats as a fleet-shaped mistake and backs out. This issue supersedes #25: the class binary becomes a small set of **orthogonal traits**. **Roles stay** — they are the presets that make the usual shapes one command — but every trait is individually settable, so rig can also bootstrap a custom shape. A role is a template, not a straitjacket. ## The traits | trait | values | what it drives | |---|---|---| | `class` | `human` / `server` | **Root SSH's fate.** Named operators with scoped sudo exist on *every* class via `rig users` (#24) — humans always enter as themselves; root is never a human door (see the hybrid access model under Decided). What `class` decides is what happens to root SSH after `rig users apply`: `human` — closed entirely (`rig users close-root` → `PermitRootLogin no`); `server` — stays open as the **automation** identity the control plane SSHes in as, and `close-root` refuses. | | `host` | `yes` / `no` | **Whether it hosts box-minted guest VMs.** Drives the `/dev/kvm` advisory and the closing pointer at box `setup-host`. A host's traits say nothing about its guests' — staging guests are full machines converged as `workload`. | | `join` | `authkey` / `login` | **How it enters the tailnet.** `authkey`: tagged pre-auth key, node is tag-owned; untagged is refused and backed out (today's behavior). `login`: interactive `tailscale up`, node is user-owned; here the assertion *inverts* — an effective tag is the refusal. Same mechanism either way: verify what control **granted**, never what was requested. | **tag:server policy** stays derived, not a trait: allowed only on the machines the control plane manages (control-plane, workload); every other shape refuses it on the effective tags. This generalizes today's runner and staging checks into one rule. ## Roles as presets | role | class | host | join | tag guidance | |---|---|---|---|---| | `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 (user-owned) — any tag refused | The correction vs #25: **staging is class=server.** It hosts VMs but is unattended — a VM appliance. "Hosts VMs" was never the load-bearing property for identity; it just correlated. Any trait is overridable on any role — `rig bootstrap workstation --host no` for a machine that will never mint boxes — and `rig bootstrap custom --class <c> --host <h> --join <j> --hostname <name>` builds a shape with no preset at all (custom has no default hostname; `--hostname` is required). The marker (below) records **effective** traits, so an overridden role never lies about what the box is. ## What changes 1. **role→traits map** in `commands/bootstrap.sh` — the single place a role's shape is declared. All behavior that #25 keyed per-class keys off traits: kvm advisory (`host=yes`), tag:server refusal (derived policy), next-steps log (`host=yes` → box setup-host pointer; `rig users` pointer per the identity model). 2. **New roles `dev` and `workstation`.** The `join=login` path: no pre-auth key demanded (a set `TS_AUTHKEY` is a loud refusal telling you to unset it or pass `--join authkey`), interactive `tailscale up`, then assert the node is user-owned and untagged. 3. **Marker** `/etc/rig/role`, e.g. `role=staging class=server host=yes join=authkey` — convergent like the hostname. First consumer: #24 — `rig users close-root` refuses on `class=server` (root there is the control plane's automation identity, not a human door), while `apply` runs on every class. 4. **README identity model**: the trait table, the roles-as-presets table, and the per-machine identity story (who installs what, who runs as what, elevation path), replacing the flat role list. ## Non-goals - No behavior change for control-plane/workload/runner. - No `rig users` implementation (#24; the marker is its input — see the hybrid access model under Decided for what each class means there). - No box changes (root-only staging template, global install — tracked in heavy-duty/box). - No new tailnet tags; conventions documented, not invented. ## Decided - **Hybrid access model** (supersedes the earlier "server is humanless — `rig users` refuses"; worked out in the comments below). Named operator accounts exist on **every** class: this tailnet is network-only — no Tailscale SSH — so there is no identity broker at the door, and multiple humans entering as root would be unattributable. Humans therefore always enter as themselves and elevate via sudo, on servers too. Root SSH is what `class` decides *after* `rig users apply`: `human` → closed entirely; `server` → stays, as the **automation** identity the control plane (Coolify) SSHes in as — key-only, and never a human door. Side benefit: once humans never use root, any root login that isn't the control plane is anomalous by definition — a cheap, high-signal alert that a shared root identity makes impossible. Caveat kept honest: on Docker-running boxes this buys **attribution, not privilege reduction** — an operator with sudo is root-equivalent; attribution is the goal. - **`PermitRootLogin no` on `class=human` is a `rig users` follow-up, not a bootstrap concern.** It cannot happen at bootstrap — dev bring-up is over root SSH before any user exists. Bootstrap keeps the fleet-wide `prohibit-password`; closing root SSH becomes possible only after `rig users apply` has put an admin user with a working key on the box (`rig users close-root`, specified in #24). ## Open questions - Trait name bikeshed: `host` reclaims the word the fleet already uses ("the box that hosts staging boxes", `box setup-host`); alternative `vm-host` if the bare word reads too generic. ## Acceptance - `rig bootstrap dev` and `rig bootstrap workstation` exist and behave per the preset table; `test/cli.sh` covers both as `staging` is covered, plus: a trait override changes the derived behavior, `custom` without `--hostname` exits 2, `join=login` with `TS_AUTHKEY` set exits 2. - The role→traits map is the only place a role's shape is declared; no per-role copies of trait behavior. - Bootstrap on any role writes the marker; a second run converges it silently. - README presents the trait model and identity table; shellcheck + `bash test/cli.sh` green. --- Supersedes #25 (class-based model). Companion users issue: #24 — ships with this one as a single release. Prior related: #22 (staging role, PR #23), heavy-duty/box#68 (staging template, PR #69).
dan-claude-bot commented 2026-07-17 18:58:21 +00:00 (Migrated from github.com)

Revisiting one decision in this design after a closer look at the access model: operator (human) entry to server-class machines.

The current text says:

server: humanless — root is the management plane; rig users refuses.

and #25 adds "operators enter as root." That works for the automation half (the control plane SSHing as root with its own key is machine-identity access — attributable to the software, logged by it). But the human half inherits the classic shared-root problem: multiple operators entering as root means logins that can't be tied to a person. Tailscale here provides the private network only — not Tailscale SSH — so there's no identity broker to recover attribution at the door.

Proposed change: hybrid access model on server-class

Keep root as the automation identity; add named operator accounts for humans.

  • Automation (Coolify / control plane): root SSH stays, PermitRootLogin prohibit-password, and root's authorized_keys contains only the control-plane key — with a from="<control-plane-addr>" restriction on the key line (or an equivalent Match Address block) so it's unusable from anywhere else.
  • Humans: named operator accounts + sudo on every class, including server-class. Operators never log in as root.

What this changes in the trait model

  • rig users runs on server-class instead of refusing — presumably a minimal operator set with prod-appropriate sudo scope, vs. the fuller dev/workstation setup.
  • class no longer decides whether users exist; it decides what happens to root SSH after rig users apply:
    • humanPermitRootLogin no (unchanged from current plan)
    • server → root stays, key-locked to the control plane
  • Bootstrap sequencing is untouched: root fully open during bring-up, hardening remains a rig users follow-up (#24).

Side benefit

Once humans never use the root identity, any root login not originating from the control plane is anomalous by definition — a cheap, high-signal alert that's impossible when humans and automation share root.

Caveat for honesty: on Docker-running boxes this buys attribution, not privilege reduction (an operator with sudo is root-equivalent anyway). Attribution is the goal here.

🤖 Generated with Claude Code

Revisiting one decision in this design after a closer look at the access model: **operator (human) entry to server-class machines**. The current text says: > server: humanless — root *is* the management plane; `rig users` refuses. and #25 adds "operators enter as root." That works for the *automation* half (the control plane SSHing as root with its own key is machine-identity access — attributable to the software, logged by it). But the *human* half inherits the classic shared-root problem: multiple operators entering as root means logins that can't be tied to a person. Tailscale here provides the private network only — not Tailscale SSH — so there's no identity broker to recover attribution at the door. ## Proposed change: hybrid access model on server-class Keep root as the automation identity; add named operator accounts for humans. - **Automation (Coolify / control plane):** root SSH stays, `PermitRootLogin prohibit-password`, and root's `authorized_keys` contains only the control-plane key — with a `from="<control-plane-addr>"` restriction on the key line (or an equivalent `Match Address` block) so it's unusable from anywhere else. - **Humans:** named operator accounts + sudo on *every* class, including server-class. Operators never log in as root. ## What this changes in the trait model - `rig users` **runs on server-class instead of refusing** — presumably a minimal operator set with prod-appropriate sudo scope, vs. the fuller dev/workstation setup. - `class` no longer decides *whether* users exist; it decides **what happens to root SSH after `rig users apply`**: - `human` → `PermitRootLogin no` (unchanged from current plan) - `server` → root stays, key-locked to the control plane - Bootstrap sequencing is untouched: root fully open during bring-up, hardening remains a `rig users` follow-up (#24). ## Side benefit Once humans never use the root identity, any root login **not** originating from the control plane is anomalous by definition — a cheap, high-signal alert that's impossible when humans and automation share root. Caveat for honesty: on Docker-running boxes this buys **attribution, not privilege reduction** (an operator with sudo is root-equivalent anyway). Attribution is the goal here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#26
No description provided.