diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e4d22..65dc1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ which records not just what changed but what each drill run proved. ## Unreleased +### Added + +- **Server-class boxes: the `staging` template** (#68) — Debian 13 VM with + docker + rig (+ tmux, the #65 contract) preinstalled and *nothing* that + joins or admits: tailscale, sshd and every credential are rig's to install + at bootstrap time (`box shell` → `sudo rig bootstrap workload`), inside + the guest — box stays creds-free. Two new optional `box.env` keys carry + the posture: `BOX_REQUIRE_VM=1` (no container fallback, and `--container` + is refused — the VM is the trust boundary and the guest runs docker) and + `BOX_AUTOSTART=1` (`boot.autostart=true`, so the box returns from a host + reboot without an operator; clones inherit it via `incus copy`). Still no + key for a network or a `security.*` flag. The design doc gains the + overlay-join clarification and the snapshot-before-join rule. +- **Template test suite** — `test/cli.sh`'s template coverage is dynamic + over `templates/*/`, so a new template cannot ship unseen: `box.env` is + driven through the real, extracted `load_template` (unknown keys and + missing `BOX_IMAGE`/`BOX_USER` fail), `user-data.yaml` must exist, declare + `#cloud-config`, parse as YAML and install tmux; staging additionally + proves its boot demands, docker + rig, and the creds-free refusal (no + tailscale/authkey/ssh in effective cloud-init lines). + ## 0.6.0 — 2026-07-18 ### Added diff --git a/bin/box b/bin/box index a20f7e7..2f84881 100755 --- a/bin/box +++ b/bin/box @@ -224,8 +224,10 @@ being told. --name Required. The box's name. --template Template to mint from; 'box templates' lists them. - A template sets image, user and resources — never - the network: every template gets the same isolation. + A template sets image, user, resources and boot + demands (a server-class one insists on VM mode and + autostart) — never the network: every template gets + the same isolation. --from [/] Clone src's live state, or its snapshot . --cpu CPUs for this mint (limits.cpu, verbatim to Incus). --memory RAM for this mint, e.g. 3GiB (limits.memory). @@ -235,6 +237,8 @@ being told. default wherever /dev/kvm exists; container mode (security.nesting=true) is the fallback for hosts without nested virt — weaker isolation, dev/test only. + A template that requires VM mode refuses both the + fallback and --container. Resources resolve most-specific-first: these flags, then BOX_CPU / BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the @@ -250,11 +254,11 @@ EOF ;; templates) cat <<'EOF' List the templates this install can mint, with their descriptions. A template -is a directory under templates/: a box.env (image, user, resources — parsed -against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed -to Incus verbatim). Templates cannot touch the network or security flags — -the shared box-net profile is the placement contract, so every template gets -the same isolation. +is a directory under templates/: a box.env (image, user, resources, boot +demands — parsed against an allowlist, never sourced) and a user-data.yaml +(cloud-init, passed to Incus verbatim). Templates cannot touch the network +or security flags — the shared box-net profile is the placement contract, so +every template gets the same isolation. box templates box new --name scratch --template blank @@ -688,18 +692,23 @@ reset_identity() { wait_agent "$i" } -# Templates set image, user, resources and cloud-init — NOTHING else. The -# box.env file is parsed against this allowlist, never sourced: sourcing would -# hand every template arbitrary bash execution on the HOST at mint time. And -# there is deliberately no key for a network or a security flag — the shared -# box-net profile is the placement contract, so no template can weaken -# isolation. 'blank' is a box with nobody home, not a box with the safety off. +# Templates set image, user, resources, boot demands and cloud-init — NOTHING +# else. The box.env file is parsed against this allowlist, never sourced: +# sourcing would hand every template arbitrary bash execution on the HOST at +# mint time. And there is deliberately no key for a network or a security flag +# — the shared box-net profile is the placement contract, so no template can +# weaken isolation. 'blank' is a box with nobody home, not a box with the +# safety off. The two boot demands are for server-class templates (#68): +# BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust +# boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps +# boot.autostart so the box survives a host reboot without an operator. load_template() { local t="$1" dir line key val dir="$root/templates/$t" [ -d "$dir" ] || die "no such template: $t (see 'box templates')" [ -f "$dir/box.env" ] || die "template '$t' has no box.env" T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK="" + T_REQUIRE_VM=""; T_AUTOSTART="" while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|\#*) continue ;; esac case "$line" in @@ -721,7 +730,9 @@ load_template() { BOX_CPU) T_CPU="$val" ;; BOX_MEMORY) T_MEMORY="$val" ;; BOX_DISK) T_DISK="$val" ;; - *) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;; + BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;; + BOX_AUTOSTART) T_AUTOSTART="$val" ;; + *) die "template '$t': unknown key '$key' — a template sets image, user, resources and boot demands, nothing else (there is no key for a network, on purpose)" ;; esac done <"$dir/box.env" # Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still @@ -785,6 +796,17 @@ cmd_new() { local t="${template:-blank}" m extra=() load_template "$t" m="$(pick_mode)" + # A server-class template (BOX_REQUIRE_VM, #68) has no weaker mode: the VM + # is its trust boundary, and its guest runs docker. Refuse the container + # fallback AND an explicit --container — never silently mint something + # lesser than what the template promises. The message holds for both + # tiers: /dev/kvm is a fact about the HOST, and an admin's default-project + # mint and a restricted user's incus-user mint go through the same daemon, + # so the fix is the same for both — a KVM-capable host, not a grant. + if [ "$T_REQUIRE_VM" = 1 ] && [ "$m" != vm ]; then + [ "$mode" != container ] || usage_error "template '$t' requires VM mode — it will not mint as a container (drop --container)" + die "template '$t' requires VM mode and this host has no /dev/kvm — mint it on a KVM-capable host (or via --remote)" + fi # shellcheck disable=SC2054 # "root,size=..." is a single incus argument # security.secureboot=false: Incus defaults VMs to secureboot ON, and a # Debian cloud image whose shim is signed with a key the host's OVMF does @@ -794,6 +816,11 @@ cmd_new() { # turning it off boots reliably across image rebuilds. Container mode has # no firmware, so it does not apply there. if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi + # BOX_AUTOSTART (#68): a server-class box must come back after a host + # reboot without an operator. Stamped per-instance like limits.*; a --from clone + # needs no code — 'incus copy' keeps every non-volatile config key, the + # same ride the user.* stamps take (audit B2). + [ "$T_AUTOSTART" != 1 ] || extra+=(--config boot.autostart=true) # Root size is a VM launch concern; a container's root rides the pool. Say # so instead of silently dropping an explicit --disk. [ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2 diff --git a/docs/box-design.md b/docs/box-design.md index 037e929..7f863bd 100644 --- a/docs/box-design.md +++ b/docs/box-design.md @@ -188,6 +188,39 @@ certificate) and asserts the absence afterwards. A failed `grant` backs its own group-add out on exit for the same reason: no half-granted user holding an un-narrowed socket. +## Server-class boxes: joining an overlay from inside + +"No inbound path" is a claim about the **host's network position**: box builds +no route in — no SSH, no forwarded port, no LAN listener beyond `expose`'s +loopback door. It was never a claim about what a guest may do with its own +outbound reach. A guest can deliberately join an overlay network (a tailnet) +from inside and invite management in over the tunnel it established — the +host-side stack only ever sees allowed outbound UDP, and every rule above +still holds: the box still cannot reach the host, the LAN, or a sibling box. +It is reachable *only* over the overlay. For a staging server that is not a +hole in the posture, it **is** the posture: tailnet-only, never LAN or public. + +The `staging` template is the sanctioned server-class use of that, and the +layering keeps each tool honest: + +- **box** mints the VM — docker + rig preinstalled, still strictly creds-free. +- **rig**, run *inside* the box (`box shell` → `sudo rig bootstrap workload`), + hardens sshd and joins the tailnet. rig holds the auth key in process + memory per its own contract; box never sees it. +- **cast** registers the box in the control plane by its tailnet address. + +Two template keys carry the server posture, and only those: `BOX_REQUIRE_VM` +(no container fallback — the VM is the trust boundary, and the guest runs +docker) and `BOX_AUTOSTART` (`boot.autostart`, so the box returns from a host +reboot without an operator). There is still no key for a network or a +`security.*` flag. + +**Snapshot before the join.** Clone from a snapshot taken *before* +`rig bootstrap` runs: a post-join clone carries its source's tailnet identity, +and two machines answering as one node is the overlay's version of the DHCP +collision `reset_identity` exists for. Mint → snapshot → bootstrap, in that +order. + ## Non-goals - Interactive-first: install and setup prompt by default (`BOX_YES=1` and the diff --git a/docs/plans/2026-07-17-staging-template.md b/docs/plans/2026-07-17-staging-template.md new file mode 100644 index 0000000..b7fbbee --- /dev/null +++ b/docs/plans/2026-07-17-staging-template.md @@ -0,0 +1,166 @@ +# `staging` template Implementation Plan + +> Spec: issue #68. Companion: heavy-duty/rig#22 (`rig bootstrap workload`, +> implemented separately in the rig repo). Steps use checkbox (`- [ ]`) +> syntax for tracking. + +**Goal:** A server-class `staging` template so staging VMs (registered in the +control plane as servers, reachable tailnet-only) are minted by box instead of +by a hand-rolled `incus launch` in a private infra repo. The template ships +docker + rig preinstalled and nothing else; the operator runs +`box shell` → `sudo rig bootstrap workload` inside, and rig — not box — hardens +sshd, joins the tailnet, and holds the auth key in process memory. + +**Why box's isolation stack needs zero changes:** the guest joins the tailnet +*itself*, outbound. The host-side stack (boxnet ACL, port isolation, +box-firewall, loopback-only `expose`) only ever sees allowed outbound UDP. +Isolation becomes the feature: the staging box still cannot reach the LAN, the +host, or a sibling box — it is reachable only over the tailnet, which is +exactly the staging posture. + +**Architecture:** one new data-only template directory (`templates/staging/`), +two new keys in the `box.env` allowlist (`BOX_REQUIRE_VM`, `BOX_AUTOSTART`) +honored by `cmd_new` in `bin/box`, and a design-doc section. No new commands, +no new flags, no dependency changes. + +## Non-goals + +- No changes to boxnet, the `box-isolate` ACL, `box-firewall`, or `expose` — + the whole point is that none are needed. +- No credential injection — no `TS_AUTHKEY`, no ssh keys, nothing. The + creds-free contract is untouched; rig owns the join, inside the guest. +- No tailscale and no openssh-server in the template — rig installs both at + bootstrap time, so box ships neither. +- No control-plane/Coolify awareness (that is cast's job, out of scope). +- No agent tooling in the box: no `~/.claude`, no agent-context file — this + is a server, not an agent devbox. + +## Global constraints + +- `templates/staging/` is data-only: a `box.env` (parsed against the + allowlist, never sourced) and a `user-data.yaml` (passed to Incus verbatim). + Templates still cannot touch the network or a `security.*` key. +- The two new keys are optional and absent from every other template; + existing templates mint exactly as before, and the allowlist still rejects + unknown keys. +- `BOX_REQUIRE_VM` refuses *both* the silent container fallback (no + `/dev/kvm` → exit 1, it went wrong) and an explicit `--container` (exit 2, + you asked wrong), per the existing exit-code contract. +- `boot.autostart=true` is stamped only when `BOX_AUTOSTART` is set — absent + on every other template's boxes. Clones inherit it via `incus copy`, which + keeps all non-volatile config keys (verified in Incus's + `InstanceIncludeWhenCopying`; same mechanism the `user.*` stamps already + rely on — audit B2), so no clone-path code is needed. +- Match the repo's disciplined-bash style; minimal diff; conventional + commits (`type(scope): subject`). + +--- + +### Task 1: `BOX_REQUIRE_VM` + `BOX_AUTOSTART` in `bin/box` + +**Files:** modify `bin/box` only. + +- [ ] `load_template()`: initialize `T_REQUIRE_VM=""; T_AUTOSTART=""` and + accept `BOX_REQUIRE_VM` / `BOX_AUTOSTART` in the key allowlist. Update the + "image, user and resources, nothing else" phrasing where it enumerates the + allowlist (the function's header comment, the unknown-key error, and the + `new`/`templates` help prose) — there is still no key for a network or a + `security.*` flag, on purpose. +- [ ] `cmd_new` (fresh-mint path): after `m="$(pick_mode)"`, if the template + set `BOX_REQUIRE_VM` and the effective mode is not `vm`: `usage_error` when + `--container` was asked for explicitly, `die` (naming `/dev/kvm`) when the + host fell back. No silent container fallback for a server-class template. +- [ ] `cmd_new` (fresh-mint path): when the template set `BOX_AUTOSTART`, + append `--config boot.autostart=true` to the launch arguments — the same + per-instance mechanism as `limits.cpu`. The `--from` clone path needs + nothing: `incus copy` carries the key (see Global constraints). +- [ ] Commit: `feat(new): BOX_REQUIRE_VM and BOX_AUTOSTART template keys` + +### Task 2: the `staging` template + +**Files:** create `templates/staging/box.env`, +`templates/staging/user-data.yaml`. + +- [ ] `box.env`, following the existing templates' format and header-comment + voice: `BOX_DESCRIPTION` (server-class staging VM: docker + rig + preinstalled; converge with `rig bootstrap workload` inside, then register + in the control plane), `BOX_IMAGE="images:debian/13/cloud"`, + `BOX_USER="ops"`, `BOX_CPU="4"`, `BOX_MEMORY="8GiB"`, `BOX_DISK="100GiB"` + (build-sized: the control plane builds on the target), + `BOX_REQUIRE_VM="1"`, `BOX_AUTOSTART="1"`. +- [ ] `user-data.yaml`, modeled on the claude template but server-minimal: + user `ops` (NOPASSWD sudo, `lock_passwd: true`), `curl` + + `ca-certificates`, docker via `get.docker.com` (the mechanism the claude + template uses), and rig preinstalled via its installer + (`curl -fsSL …/rig/main/install.sh | bash`, with `HOME=/root` pinned — + cloud-init's runcmd does not guarantee `HOME`, and rig's installer derives + its install dir from it: as root it lands in `/root/.local/share/rig` with + a `/usr/local/bin/rig` symlink, which is what we want since rig runs as + root). A comment states that tailscale, openssh-server and every credential + are deliberately absent — rig installs those at bootstrap time. No + `~/.claude`, no agent-context file. +- [ ] Commit: `feat(templates): staging — server-class VM, docker + rig, creds-free` + +### Task 3: design-doc amendment + +**Files:** modify `docs/box-design.md`. + +- [ ] New section (after Isolation, matching the doc's voice): the isolation + guarantee is "no inbound *via the host's network position*"; a guest can + deliberately join an overlay network (tailnet) from inside and invite + management in over its own outbound tunnel; the `staging` template is the + sanctioned server-class use of that, layered as box mints / rig converges + (inside the guest) / cast registers; and the **snapshot-before-join** rule — + clone from a pre-`rig bootstrap` snapshot, because a post-join clone + duplicates the source's tailnet identity. +- [ ] Commit: `docs(design): server-class boxes — overlay joins and snapshot-before-join` + +--- + +## Test plan + +No Incus in the implementation environment, so runtime minting is **not** +exercised here — the acceptance criteria in #68 (`box new --template staging` +mints a VM on a KVM host, refuses on a non-KVM host, `rig bootstrap workload` +succeeds inside) are exercised on a real host. Static gate, all of which must +pass before merge: + +- [ ] `bash -n bin/box` — parses. +- [ ] `shellcheck bin/box` — no new findings against main. +- [ ] Both `templates/staging/*.yaml` / all templates' `user-data.yaml` parse + as YAML (pyyaml). +- [ ] Grep assertions: `BOX_REQUIRE_VM`/`BOX_AUTOSTART` appear in the + allowlist; `boot.autostart` is stamped only under the `BOX_AUTOSTART` + guard; no template other than `staging` sets either key. + +--- + +## Addendum (2026-07-18): rebased onto main; the template test suite + +The branch was rebased onto main, which had since gained the restricted tier +(#74), a CI workflow, and `test/cli.sh`. What that changed here: + +- **`load_template` conflicts** — main replaced the `[ -n … ] && [ -n … ] || + die` required-keys idiom with the spelled-out `if [ -z … ]` form (SC2015) + and grew the SC2034 directive block; the two new key arms were re-applied + onto that version, both intact. +- **`cmd_new`** — main added a tier-aware box-net pre-flight at the top of + the function; the `BOX_REQUIRE_VM` refusal stays in the fresh-mint branch, + after `pick_mode` (it must read the *effective* mode). Its message holds + for both tiers: `/dev/kvm` is a host fact, and admin and restricted mints + go through the same daemon, so the fix is the same — a KVM host, not a + grant. +- **tmux** — `box tmux` is a contract every template honors (#65, asserted + by `test/cli.sh`), so the staging package list carries tmux; the operator + babysits `rig bootstrap workload` through it. +- **The template test suite** (maintainer request): `test/cli.sh`'s template + coverage is now *dynamic* over `templates/*/` — a new template cannot ship + unseen. Per template: `box.env` driven through the real, extracted + `load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER` fail); + `user-data.yaml` exists, declares `#cloud-config`, parses as YAML + (python3+pyyaml, loudly skipped where absent), installs tmux. + Staging-specific: both boot demands proven through the parser, docker + + rig present, and a creds-free grep-refusal (no tailscale/authkey/ssh in + effective cloud-init lines). Grep guards pin the `cmd_new` half: the + refusal orders after `pick_mode`; `boot.autostart` is stamped only under + the `T_AUTOSTART` guard. diff --git a/templates/staging/box.env b/templates/staging/box.env new file mode 100644 index 0000000..fb97219 --- /dev/null +++ b/templates/staging/box.env @@ -0,0 +1,18 @@ +# The staging template — a server-class box: docker + rig, no agent, no creds. +# KEY="value" only. Parsed against an allowlist, never sourced; there is no +# key for a network or a security flag, on purpose — the shared box-net +# profile is the placement contract and no template can weaken it. +# BOX_USER must match the user user-data.yaml creates. +# +# BOX_REQUIRE_VM: no container fallback — the VM is the staging trust +# boundary, and the guest runs docker. BOX_AUTOSTART: a staging server must +# come back on its own after a host reboot. Resources are build-sized: the +# control plane builds on the target. +BOX_DESCRIPTION="Server-class staging VM: docker + rig preinstalled; converge with 'rig bootstrap workload' inside, then register in the control plane" +BOX_IMAGE="images:debian/13/cloud" +BOX_USER="ops" +BOX_CPU="4" +BOX_MEMORY="8GiB" +BOX_DISK="100GiB" +BOX_REQUIRE_VM="1" +BOX_AUTOSTART="1" diff --git a/templates/staging/user-data.yaml b/templates/staging/user-data.yaml new file mode 100644 index 0000000..60f37f3 --- /dev/null +++ b/templates/staging/user-data.yaml @@ -0,0 +1,31 @@ +#cloud-config +# The staging template — a server, not an agent devbox: no agent, no +# agent-context file, and (as everywhere in box) no credentials. Docker and +# rig are preinstalled and that is all. Tailscale, openssh-server and every +# credential are deliberately ABSENT: rig installs and hardens sshd and joins +# the tailnet at bootstrap time ('box shell' → 'sudo rig bootstrap workload'), +# holding the auth key in process memory — box never sees it. +# BOX_USER in box.env must match the user created here — the file is passed +# to Incus verbatim. +users: + - name: ops + shell: /bin/bash + sudo: "ALL=(ALL) NOPASSWD:ALL" + lock_passwd: true +package_update: true +packages: + - curl + - ca-certificates + # tmux is the one agent-adjacent tool a server box still carries: 'box tmux' + # runs 'tmux new-session' INSIDE the box on EVERY template (#65) — the + # operator babysits 'rig bootstrap workload' through it — and test/cli.sh + # asserts it for every template directory, this one included. + - tmux +runcmd: + - curl -fsSL https://get.docker.com | sh + - usermod -aG docker ops + # rig runs as root, so install it as root: its installer lands the tree in + # $HOME/.local/share/rig and symlinks /usr/local/bin/rig. HOME is pinned + # because cloud-init's runcmd does not guarantee one, and the installer + # derives its install dir from it. + - HOME=/root bash -c 'curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash' diff --git a/test/cli.sh b/test/cli.sh index 12e113b..edebf57 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -130,15 +130,110 @@ check "install.sh: still no-ops on an existing install (#66)" 0 "" \ grep -qF 'already installed' "$ROOT/install.sh" # --------------------------------------------------------------------------- -# Templates — #65 tmux. `box tmux` runs `tmux new-session` INSIDE the box, so a -# template that never installs tmux fails with "tmux: command not found". Every -# template must carry it in its cloud-init package list. +# Templates — DYNAMIC over templates/*/ (#68): the loop discovers every +# template directory, so a new template cannot ship without passing these (the +# old hardcoded blank/claude/codex/grok list let exactly that happen). The +# box.env parse is proven against the REAL allowlist: load_template is +# extracted from bin/box and DRIVEN against each template — the same +# source-the-pure-function trick install.sh's DEST block and box_tier get +# below — so an unknown key, a missing BOX_IMAGE/BOX_USER, or a line that is +# not KEY="value" fails HERE, not at mint time on a host. # --------------------------------------------------------------------------- -for t in blank claude codex grok; do +TPLFN="$(mktemp)" +awk '/^load_template\(\) \{/,/^\}/' "$ROOT/bin/box" > "$TPLFN" +check "load_template: extracted from bin/box (guards the awk)" 0 "unknown key" cat "$TPLFN" +check "load_template: the extracted function is valid bash" 0 "" bash -n "$TPLFN" + +# tpl