diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 845da81..b36599d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,41 @@ jobs: - name: cli tests run: bash test/cli.sh - # NOT run here: the drill (drill/drill.sh) and the multi-user rehearsal - # (drill/multiuser.sh). Both stand up a real Incus, rearrange the host's - # network/firewall, and — for the rehearsal — create throwaway system users. - # None of that belongs in CI: it needs a disposable host and root, and it is - # the answer to #72 Task 0's substrate question, which a container runner - # cannot answer. CI stays static + dependency-free (shellcheck + cli.sh), - # exactly as this repo's design demands ("isolation claims are tested on a - # real host, never reasoned about" — docs/box-design.md). + # The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a + # disposable VM, which is exactly the substrate the rehearsal needs. It runs + # in container mode: the tier's mechanics (grant, confinement, the network + # contract, revoke) are identical for containers and VMs — the nft bridge + # drop, the ACL, dns.mode=none and port_isolation all bind to boxnet, not + # to the instance type. What container mode canNOT validate is the VM trust + # boundary itself; that stays a real-hardware ritual (drill/RUNS.md), same + # as the full drill. So: every PR proves the tier's semantics, and a + # release still proves the boundary. + rehearsal: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: install incus + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus + - name: global install, from this checkout (the #71 layout) + # Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove + # the code under review. Same tree, same layout, same a+rX. + run: | + sudo cp -r . /opt/box + sudo rm -rf /opt/box/.git + sudo chmod -R a+rX /opt/box + sudo ln -sf /opt/box/bin/box /usr/local/bin/box + - name: setup-host + run: sudo bash /opt/box/host/setup-host.sh + - name: doctor — the baseline is provable before anything is judged + run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh + - name: multi-user rehearsal (criteria a-l, container mode) + run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container + + # NOT run here: the full drill (drill/drill.sh). It rehearses the whole + # surface — cold template mints, expose, migration — and wants a real host + # and the better part of an hour. The rehearsal job above is the CI-shaped + # slice of the same discipline: isolation claims are still tested on a real + # daemon, never reasoned about (docs/box-design.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2eded..de1c3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ which records not just what changed but what each drill run proved. ### Added +- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin + runs `box grant ` and that user gets their own boxes on the same + hardened `boxnet`, seeing nobody else's; `box revoke ` takes it back + (`--purge` deletes their world, and asserts the absence). The tier rides + incus-user, whose defaults miss box's contract three measured ways (Debian + 13 / Incus 6.0.4): a private *unhardened* NAT bridge per user, snapshots + blocked, the `box-net` profile invisible — so grant is an idempotent + convergence: project narrowed to `boxnet` **and only boxnet** (listing the + private bridge too, the obvious fix, would keep an unhardened network one + `--network` flag away), snapshots allowed, the shipped profile installed + into their project. `box_tier()` (live credentials, argless `id -nG`) + drives the tier-aware surface: `expose` refuses honestly before any daemon + call, `setup-host` and `doctor` answer at the caller's tier. Rehearsed + end-to-end by `drill/multiuser.sh` (criteria a–n: confinement, lifecycle, + cross-user visibility, name collisions, the in-box isolation contract, + escape hatches, re-sync survival, revoke incl. the live-session case) — + 54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review. +- **CI runs the multi-user rehearsal on a real Incus** — a second `rehearsal` + job stands up the full stack on the runner (setup-host, doctor, then + `multiuser.sh --container`), so every PR proves the tier's semantics + against a live daemon, not a mock. The VM trust boundary itself remains a + real-hardware ritual, like the full drill. - **Global / root install** (#71) — run as root, box installs *once* to `/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so every operator on a shared host runs the same tree. Per-user installs are @@ -24,6 +46,12 @@ which records not just what changed but what each drill run proved. ### Fixed +- **`box restore` never worked against Incus 6** — the command table + dispatched `incus restore`, a subcommand that does not exist (Incus 6 + spells it `incus snapshot restore`), so every restore died on "unknown + command". Found by #74's rehearsal exercising the full lifecycle as a + restricted user; fixed for every tier, and the rehearsal + a grep-guard in + `test/cli.sh` now hold it. - **`box tmux` works on every template** (#65) — `box tmux` runs `tmux new-session` *inside* the box, but the templates did not install tmux, so it failed with `tmux: command not found`. `tmux` is now in each template's diff --git a/README.md b/README.md index 8317fe0..367a21d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,51 @@ re-homes each legacy box onto `boxnet` (authed state preserved), and `box migrate-host --retire-legacy` removes the old bridge and profile once no legacy box remains. +## Multi-user hosts: the restricted tier + +One host, several people, and not everyone should hold the daemon. Incus's +socket is all-or-nothing — `incus-admin` group members own every instance on +the machine — so box layers a second tier on +[incus-user](https://linuxcontainers.org/incus/docs/main/projects/): + +| tier | who | what they hold | +|---|---|---| +| **admin** | root, or the `incus-admin` group | everything: all boxes, the stack, `setup-host`, `expose`, `grant` | +| **restricted** | the `incus` group | their **own** boxes only, on the same hardened network | +| none | everyone else | no socket, nothing | + +An admin hands the tier out per user, and takes it back: + +```sh +box grant dev1 # dev1 can now: box new / list / shell / snapshot / rm — their boxes only +box revoke dev1 # tier removed; their boxes survive (grant again restores). + # a session they already hold keeps the socket until it + # ends — revoke warns and names the loginctl command +box revoke dev1 --purge # ...or end their sessions and delete everything they had +``` + +`grant` is an idempotent convergence, not a flag flip, because incus-user's +defaults miss box's contract three ways (measured on Debian 13 / Incus 6.0.4, +see [the plan doc](docs/plans/2026-07-18-restricted-tier.md)): it pins each +user to a private *unhardened* NAT bridge, it blocks snapshots, and it cannot +see the `box-net` profile. Granting rewires all three: the user's project is +restricted to `boxnet` **and only boxnet** — the hardened network is not their +default placement but the only one their certificate can express — snapshots +are allowed, and the shipped profile is installed into their project. Re-run +`box grant ` after upgrading box to refresh the profile, like +`setup-host` for the stack. + +What a restricted user gets is the full contract: same ACL, same DNS +isolation, same pinned resolver, same port isolation, same box↔box drop — +and their boxes cannot reach another user's box, which is the same +box↔box drop doing its one job. What they can't do stays honest: `box +expose` (daemon-global state) says to ask an admin, `box setup-host` and +`box doctor` answer at their tier instead of failing at it. + +`drill/multiuser.sh` rehearses all of it live — two users, real grants, real +boxes, probes from inside — and CI runs it on every PR (container mode; the +VM boundary itself is proven on real hardware, like the rest of the drill). + ## Quick start ```sh diff --git a/bin/box b/bin/box index 5613ded..a20f7e7 100755 --- a/bin/box +++ b/bin/box @@ -14,6 +14,24 @@ die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; } +# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG': +# what the kernel will present when incus opens the socket), never from the +# group database — the two disagree for exactly as long as a re-login is +# pending, and that window is where every wrong answer lives. +# UID 0 / incus-admin -> admin (the full daemon socket) +# incus (only) -> restricted (incus-user: your own project, nothing else) +# neither -> none (no socket at all) +# host/setup-host.sh carries a byte-identical copy (it runs before any install +# tree exists); test/cli.sh diffs the two so they cannot drift. +box_tier() { + [ "$(id -u)" -eq 0 ] && { printf 'admin\n'; return; } + local groups; groups="$(id -nG 2>/dev/null | tr ' ' '\n')" + if printf '%s\n' "$groups" | grep -qx incus-admin; then printf 'admin\n' + elif printf '%s\n' "$groups" | grep -qx incus; then printf 'restricted\n' + else printf 'none\n' + fi +} + # --------------------------------------------------------------------------- # The command table. # @@ -46,7 +64,7 @@ CMDS=( "exec^ -- ^box^Run a command inside a box^fn:cmd_exec^" "tmux^ []^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^" "snapshot^ [