feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74)

incus-user confines an incus-group user to their own project, but its
defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4):
a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS
isolation), snapshots blocked, and the box-net profile invisible to their
project. So the tier is an admin-run idempotent convergence:

  box grant <user>   # incus group; touch incus-user (the project is lazy);
                     # drop the private-bridge eth0 from their default
                     # profile; restricted.networks.access=boxnet — and ONLY
                     # boxnet, or the unhardened bridge stays one --network
                     # flag away; restricted.snapshots=allow; install the
                     # shipped box-net profile into their project
  box revoke <user>  # group removal closes the socket, boxes keep running
         --purge     # ...or delete their world, and assert the absence

box_tier() (live credentials, argless id -nG; byte-identical copy in
setup-host.sh) drives the tier-aware surface: new pre-flights the profile
and names the right fix per tier, expose refuses before any daemon call
(without the guard the failure is a lie — restricted certs cannot read
boxnet's redacted config, so box_net_ip claims a running box has no
address), setup-host exits 0 with the honest note, doctor judges only what
the caller can see.

Also fixed while the rehearsal exercised the lifecycle: box restore
dispatched 'incus restore', which does not exist in Incus 6 (it is
'incus snapshot restore') — the verb had never worked. Fixed for every tier.

Convergence survives incus-user restarts by that tool's own design (it
configures a project only at creation) — read in its source, then measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 04:09:13 +00:00
parent f7b93a91cd
commit 0429a11020
5 changed files with 458 additions and 2 deletions

91
bin/box
View file

@ -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> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:restore^restored {} to {1}"
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}"
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
"start^<box>^box^Start a stopped box^incus:start^started {}"
@ -55,6 +73,8 @@ CMDS=(
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
"grant^<user>^^Admin: give a host user the restricted tier — their own boxes, on the hardened boxnet^fn:cmd_grant^"
"revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
@ -180,6 +200,10 @@ THE MODEL
user.box=1 boundary, the isolation stack, or the creds-free snapshot
workflow. Everything else is Incus's job — and 'box incus' is the door.
On a shared host, an admin hands out the restricted tier per user
('box grant <user>'): their own boxes, the same hardened network, and
no view of anyone else's. 'box help grant' has the contract.
Docs: https://github.com/heavy-duty/box
EOF
}
@ -394,6 +418,44 @@ One run is enough. If it has to add you to the incus-admin group it re-runs
itself under that group — no re-login, no second invocation.
box setup-host
Multi-user hosts: setup-host builds the stack once, for everyone. An admin
then hands individual users the restricted tier with 'box grant <user>' —
their own boxes, on this same hardened network, seeing nobody else's.
EOF
;;
grant) cat <<'EOF'
Give a host user the restricted tier. They get their own Incus project (via
incus-user), and every box they mint lands on the SAME hardened boxnet as an
admin's — the full isolation contract (ACL, DNS isolation, resolver pin,
port isolation, the box-to-box drop), with no view of anyone else's boxes.
What it converges, idempotently (safe to re-run, and re-run after upgrades):
· puts the user in the 'incus' group (not incus-admin — that is the point)
· creates their user-<uid> project by touching incus-user for them
· points the project at boxnet and ONLY boxnet — the private incusbr-<uid>
bridge incus-user auto-creates carries none of box's hardening, so it is
unreferenced and unreachable, not just unused
· allows snapshots (incus-user blocks them; box's clone workflow needs them)
· installs the box-net profile into their project
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/rm on
their own boxes. Not theirs: expose (edits daemon-global state), setup-host,
grant. Admin boxes and other users' boxes are invisible to them, and the
existing box-to-box drop means even their instances cannot reach a sibling.
box grant dev1
EOF
;;
revoke) cat <<'EOF'
Take the restricted tier back from a user. Without --purge, this only removes
them from the 'incus' group: they lose the socket, but their project and
boxes stay (still running!) and 'box grant' restores access untouched. With
--purge, their boxes, images, project, private bridge and trust-store
certificate are removed too — irreversible, so it asks first.
box revoke dev1 # lock them out, keep their boxes
box revoke dev1 --purge # ...and delete everything they had
EOF
;;
teardown-host) cat <<'EOF'
@ -471,7 +533,7 @@ while [ $# -gt 0 ]; do
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# the host verbs delegate their flags to the scripts they wrap
case "$cmd" in setup-host|teardown-host|migrate-host) args+=("$1"); shift; continue ;; esac
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke) args+=("$1"); shift; continue ;; esac
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
@ -688,6 +750,16 @@ cmd_templates() {
cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
# The placement contract must exist before any mint — in the DEFAULT project
# for an admin (setup-host builds it), in YOUR project for a restricted user
# (box grant converges it). Its absence has a different fix per tier, and
# incus's own "Profile not found" at launch time names neither.
if [ -z "$remote" ] && ! incus profile show box-net >/dev/null 2>&1 </dev/null; then
if [ "$(box_tier)" = restricted ]; then
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
fi
die "no box-net profile — the host stack is missing. Build it: box setup-host"
fi
local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
@ -942,6 +1014,10 @@ cmd_status() {
cmd_doctor() {
local script="$root/drill/doctor.sh"
[ -f "$script" ] || die "doctor script not found at $script — re-run install.sh"
# The doctor's verdict depends on who is asking: a restricted user cannot
# see the nft tables or the kernel's bridge state, and telling them the
# host is broken because THEY cannot read it would be a wrong diagnosis.
export BOX_TIER; BOX_TIER="$(box_tier)"
exec bash "$script" "${args[@]}"
}
@ -957,6 +1033,8 @@ host_script() { # $1 = script basename under host/
cmd_setup_host() { host_script setup-host.sh; }
cmd_teardown_host() { host_script teardown-host.sh; }
cmd_migrate_host() { host_script migrate-host.sh; }
cmd_grant() { host_script grant-user.sh; }
cmd_revoke() { host_script revoke-user.sh; }
cmd_help() { show_help "${args[0]:-}"; }
@ -1015,6 +1093,15 @@ exposure_dev() { echo "expose-$1"; } # device name for a port
cmd_expose() {
local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"
# Before ANY incus call: the door's plumbing (the box-isolate ACL, the
# host firewall's route_localnet + masquerade) is daemon-global state a
# restricted certificate cannot touch. Without this guard the failure is
# a lie — box_net_ip cannot read boxnet's (redacted) config, so the
# restricted user is told their running box "has no boxnet address yet".
if [ "$(box_tier)" = restricted ]; then
die "box expose edits the daemon-global ACL and firewall, which the restricted tier cannot modify — ask an incus-admin. (see #74)"
fi
# --list
if [ "$a2" = "--list" ]; then
local found=0 d listen connect

View file

@ -45,6 +45,65 @@ timeout 10 incus list >/dev/null 2>&1 || {
exit 1
}
# The tier changes what this doctor can SEE, so it changes what it may JUDGE.
# bin/box exports BOX_TIER; unset means a hand-run, which was always admin.
# A restricted (incus-group) user cannot read the nft tables, the kernel's
# bridge ports, or boxnet's (redacted) config — reporting those as DIRTY
# would blame the host for the reader's own, correct, confinement. They get
# the checks that are theirs: is the tier granted, is the contract in their
# project, do their boxes actually resolve and route.
TIER="${BOX_TIER:-admin}"
if [ "$TIER" = restricted ]; then
head_ "Access tier — restricted (the incus group: your own boxes, nothing else)"
inf "the host stack (network, ACL, firewall, kernel state) is admin-owned;"
inf "this doctor judges only what is yours to see"
if [ "$FIX" = 1 ] || [ "$PIN" = 1 ]; then
inf "--fix / --pin-dns are admin levers — ignored on this tier"
FIX=0; PIN=0
fi
head_ "Your project — is the tier granted?"
if incus profile show box-net >/dev/null 2>&1 </dev/null; then
ok "the box-net profile is in your project — 'box new' lands on the hardened boxnet"
iso="$(incus profile device get box-net eth0 security.port_isolation </dev/null 2>/dev/null)"
[ "$iso" = "true" ] \
&& ok "security.port_isolation = true (as shipped)" \
|| no "security.port_isolation is NOT set in your box-net profile — re-grant refreshes it: ask an admin to re-run 'box grant $(id -un)'"
else
no "no box-net profile in your project — the restricted tier is granted per user"
inf "fix: an admin runs: box grant $(id -un)"
fi
if incus network show boxnet >/dev/null 2>&1 </dev/null; then
ok "boxnet is reachable from your project"
else
no "boxnet is not visible from your project — ask an admin to re-run 'box grant $(id -un)'"
fi
head_ "Can one of your boxes actually resolve DNS?"
probe="$({ incus list "user.box=1" --format csv --columns ns 2>/dev/null
incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null; } \
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
if [ -n "$probe" ]; then
inf "probing inside '$probe':"
timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null \
&& ok "reaches 1.1.1.1 by address — egress routing is fine" \
|| no "cannot reach 1.1.1.1 by address — egress routing is broken (an admin problem: box doctor as admin)"
timeout -k 5 25 incus exec "$probe" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1 \
&& ok "resolves deb.debian.org — DNS works" \
|| no "CANNOT resolve deb.debian.org — an admin problem (the resolver pin lives on the host): box doctor as admin"
else
inf "no running box to probe with (mint one: box new --name work)"
fi
head_ "Verdict"
if [ "$bad" -eq 0 ]; then
printf ' \033[32mclean\033[0m — your tier is granted and your boxes are fit.\n\n'
exit 0
fi
printf ' \033[31m%s problem(s)\033[0m — see the fixes above (most need an admin).\n\n' "$bad"
exit 1
fi
# A FRESH host (no boxnet) is not a DIRTY one. Everything below that would
# scream about a missing piece must first ask: missing from a stack, or never
# set up? setup-host creates all of it, and the drill runs setup-host itself —

154
host/grant-user.sh Normal file
View file

@ -0,0 +1,154 @@
#!/usr/bin/env bash
# box grant <user> — give a host user the restricted tier (#74).
#
# The tier rides incus-user: the user lands in an auto-created project
# user-<uid> and can only ever see their own instances. What incus-user does
# NOT do is put them on box's hardened network — it auto-creates a private
# bridge incusbr-<uid> (a plain NAT bridge: no ACL, no DNS isolation, IPv6 on,
# none of box's contract) and pins the project to it. Measured on Debian 13 /
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
#
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
# 1. put the user in the 'incus' group (not incus-admin — that is the tier)
# 2. touch incus-user AS the user, so the lazy project exists to converge
# 3. unpin the private bridge (drop eth0 from the project's default profile)
# 4. restrict the project's network access to boxnet and ONLY boxnet —
# "boxnet,incusbr-<uid>" would leave an unhardened NAT bridge one
# '--network' flag away from any box they mint
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
# on them)
# 6. install the shipped box-net profile into their project
#
# Idempotent: every step converges, so re-running (including after a box
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
# project it already created (verified against its source: setup is skipped
# once the project exists and the user's certificate is trusted), so nothing
# here is fighting a re-sync.
set -euo pipefail
self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
here="$(dirname "$(dirname "$self")")"
usage() { echo "usage: box grant <user>" >&2; exit 2; }
[ $# -eq 1 ] || usage
user="$1"
case "$user" in -*) usage ;; esac
# Root, or sudo — same decision, same reasons as setup-host.sh: granting
# needs usermod and a run-as-the-user touch, both root's to give.
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "ERROR: box grant needs root and 'sudo' was not found." >&2
echo " re-run as root: $self $user" >&2
exit 1
fi
# Run a command as the granted user. 'runuser' when we are root (sudo may not
# exist there), sudo -u otherwise. -H so incus's client state lands in THEIR
# home, not the admin's. stdin pinned: an incus client with a terminal on
# stdin can go interactive and wedge a script that will never answer it.
run_as() {
local u="$1"; shift
if [ -n "$SUDO" ]; then $SUDO -u "$u" -H -- "$@" </dev/null
else runuser -u "$u" -- "$@" </dev/null
fi
}
getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2; exit 1; }
uid="$(id -u "$user")"
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
# An incus-admin member already holds the full socket; "granting" them the
# restricted tier would not restrict anything (admin membership wins at the
# socket), it would only mislead whoever reads the group list later.
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2
exit 1
fi
# The stack the tier converges ONTO must exist first. Checked via the daemon,
# not config files: setup-host is the only thing that builds boxnet.
incus network show boxnet >/dev/null 2>&1 </dev/null \
|| { echo "box grant: no boxnet on this host — build the stack first: box setup-host" >&2; exit 1; }
# incus-user is the mechanism under the whole tier. Debian 13 and Ubuntu 24.04
# ship it in the incus package; a host without it cannot hold this tier at all.
if ! systemctl is-active --quiet incus-user.socket; then
$SUDO systemctl enable --now incus-user.socket 2>/dev/null \
|| { echo "box grant: incus-user.socket is not available — this Incus cannot serve the restricted tier (see #74)." >&2; exit 1; }
fi
# 1. The group. 'incus' is the restricted socket; membership takes effect at
# the user's next login, but run_as below starts a fresh process with the
# database's groups, so the grant itself never waits on a re-login.
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
echo "group: $user already in 'incus'"
else
$SUDO usermod -aG incus "$user"
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
fi
project="user-$uid"
# 2. The project is created LAZILY, on the user's first contact with
# incus-user — an admin cannot pre-create it (incus-user would fight over
# it), so make that first contact happen now, as the user.
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
echo "project: touching incus-user as $user to create $project..."
run_as "$user" timeout 60 incus project list >/dev/null 2>&1 || true
incus project show "$project" >/dev/null 2>&1 </dev/null \
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
echo "project: $project created"
else
echo "project: $project already exists"
fi
# 3. Unpin the private bridge. incus-user's default profile carries an eth0
# on incusbr-<uid>; while ANY profile references that bridge, the narrowing
# below is rejected by incus's own validation. Removing the device is also
# what it looks like: the default profile in this project places no network —
# box-net is the only door, which is the placement contract working.
if incus --project "$project" profile device get default eth0 type >/dev/null 2>&1 </dev/null; then
incus --project "$project" profile device remove default eth0 >/dev/null </dev/null
echo "profile: removed the private-bridge eth0 from $project's default profile"
fi
# 4. boxnet, and ONLY boxnet. The auto-created incusbr-<uid> is a stock NAT
# bridge with none of box's hardening — listing it here would keep a
# one-flag escape from the isolation contract open forever. Narrowed, the
# hardened network is not the default placement but the only one possible.
# This can fail honestly: an instance the user already parked on the private
# bridge blocks the narrowing, and incus's error names it.
if ! err="$(incus project set "$project" restricted.networks.access boxnet 2>&1 </dev/null)"; then
echo "box grant: could not restrict $project to boxnet:" >&2
echo " $err" >&2
echo " (an instance still on the private bridge blocks this — move or delete it, then re-run)" >&2
exit 1
fi
echo "network: $project restricted to boxnet (the private incusbr-$uid is unreferenced and unreachable)"
# 5. Snapshots. incus-user projects block them by default, and box's whole
# reuse story — log in once, snapshot, clone forever — is snapshots.
incus project set "$project" restricted.snapshots allow </dev/null
echo "snapshots: allowed"
# 6. The placement contract itself, installed into their project. Created if
# missing, refreshed unconditionally — same convergence discipline as
# setup-host's own profile handling, so a box upgrade propagates by re-run.
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \
|| incus --project "$project" profile create box-net >/dev/null </dev/null
incus --project "$project" profile edit box-net < "$here/profiles/box-net.yaml"
echo "profile: box-net installed in $project"
# Prove the grant from the USER's side of the socket — the only side that
# matters. This catches the failure the steps above cannot see one at a time:
# a converged project the user still cannot reach.
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"

124
host/revoke-user.sh Normal file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# box revoke <user> [--purge] — take the restricted tier back (#74).
#
# Two strengths, deliberately:
# · bare revoke removes the user from the 'incus' group. That closes the
# socket — the only path their certificate can travel — so access ends at
# their next login, while their project and boxes stay intact (and their
# boxes stay RUNNING: revoking a person does not kill their workloads).
# 'box grant' restores everything untouched.
# · --purge also deletes what the tier created: their boxes, their images,
# their project, the private bridge, the trust-store certificate, the
# incus-user state. Irreversible, so it asks first.
set -euo pipefail
usage() { echo "usage: box revoke <user> [--purge]" >&2; exit 2; }
user=""; purge=0
for a in "$@"; do
case "$a" in
--purge) purge=1 ;;
-*) usage ;;
*) [ -z "$user" ] || usage; user="$a" ;;
esac
done
[ -n "$user" ] || usage
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "ERROR: box revoke needs root and 'sudo' was not found." >&2
exit 1
fi
getent passwd "$user" >/dev/null || { echo "box revoke: no such user: $user" >&2; exit 1; }
uid="$(id -u "$user")"
project="user-$uid"
# incus-user's own naming rule, mirrored exactly: the bridge is incusbr-<uid>
# unless that would not fit in an interface name (15 chars), then user-<uid>.
bridge="incusbr-$uid"
[ "${#bridge}" -gt 15 ] && bridge="user-$uid"
if [ "$purge" -eq 1 ]; then
# Destructive and irreversible: a TTY to ask on, or BOX_YES=1, or refuse —
# the same non-interactive contract as install.sh.
if [ -z "${BOX_YES:-}" ]; then
if [ -t 0 ]; then
printf 'box revoke: delete ALL of %s'\''s boxes, images and their project %s? this cannot be undone. [y/N] ' "$user" "$project"
read -r reply
case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "box revoke: aborted." >&2; exit 1 ;; esac
else
echo "box revoke: refusing to --purge without a terminal to confirm on. BOX_YES=1 means yes." >&2
exit 2
fi
fi
fi
# The group, first — access ends even if a purge step below trips.
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
$SUDO gpasswd -d "$user" incus >/dev/null
echo "group: removed $user from 'incus' — the socket closes with their next login"
else
echo "group: $user was not in 'incus'"
fi
if [ "$purge" -eq 0 ]; then
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
fi
echo "revoked: $user no longer has the restricted tier."
exit 0
fi
# --purge: unmake what the tier made. Instances one at a time — a wildcard
# delete that half-fails leaves a state nobody can name; a loop that fails
# names the box it failed on (the wipe.sh discipline).
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
while IFS=, read -r inst _; do
[ -n "$inst" ] || continue
echo "purge: deleting instance $inst"
incus --project "$project" delete -f "$inst" </dev/null
done < <(incus --project "$project" list --format csv --columns n 2>/dev/null)
while IFS=, read -r fp _; do
[ -n "$fp" ] || continue
incus --project "$project" image delete "$fp" </dev/null
done < <(incus --project "$project" image list --format csv --columns f 2>/dev/null)
incus --project "$project" profile delete box-net >/dev/null 2>&1 </dev/null || true
incus project delete "$project" </dev/null \
|| { echo "box revoke: could not delete $project — something is still in it (incus --project $project list / image list / storage volume list)" >&2; exit 1; }
echo "purge: project $project removed"
fi
if incus network delete "$bridge" >/dev/null 2>&1 </dev/null; then
echo "purge: private bridge $bridge removed"
fi
# The trust-store certificate incus-user minted for them. Named, not guessed:
# incus-user calls it incus-user-<uid>.
while IFS=, read -r name fp _; do
[ "$name" = "incus-user-$uid" ] || continue
incus config trust remove "$fp" </dev/null && echo "purge: trust-store certificate $name removed"
done < <(incus config trust list --format csv --columns nf 2>/dev/null)
# incus-user's per-user client state (their key pair). Removed so a future
# re-grant starts clean instead of trusting a key the purge revoked.
if [ -d "/var/lib/incus/users/$uid" ]; then
$SUDO rm -rf "/var/lib/incus/users/$uid"
echo "purge: incus-user state for uid $uid removed"
fi
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
leftover=""
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
if [ -n "$leftover" ]; then
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
exit 1
fi
echo "revoked: $user is out, and everything the tier created is gone."

View file

@ -6,6 +6,31 @@ set -euo pipefail
self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
here="$(dirname "$(dirname "$self")")"
# Byte-identical copy of bin/box's box_tier() — this script must know the
# tier before any install tree exists, and test/cli.sh diffs the two copies
# 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
}
# A restricted (incus-group) user cannot build daemon-global state, and
# telling them to escalate would be wrong twice: the stack is the admin's to
# own, and if 'box new' works for them it already exists. Say so and succeed —
# this must sit BEFORE the sudo resolution below, which would otherwise bury
# the honest answer under a privilege error. Gated on the tier, not on
# 'command -v sudo': having the sudo binary is not the same as holding a grant.
if [ "$(id -u)" -ne 0 ] && [ "$(box_tier)" = restricted ]; then
echo "You are in the 'incus' group (restricted tier): you manage your own boxes," >&2
echo "but the host's daemon-global stack is built by an admin. It is already set" >&2
echo "up if 'box new' works. Nothing for you to do here." >&2
exit 0
fi
# How we reach root, decided once. 'sudo' cannot be hardcoded: at UID 0 it is
# unnecessary, and on a minimal root image it is not installed at all — this
# script died on 'sudo: command not found' before doing anything, which made
@ -195,6 +220,13 @@ $SUDO systemctl enable box-firewall.service
# claimed to close it. Restart re-runs the script, which is idempotent by design.
$SUDO systemctl restart box-firewall.service
# incus-user is what serves the restricted tier (box grant). Debian 13 and
# Ubuntu 24.04 ship it inside the incus package; enabling it here makes the
# host tier-ready, and costs a host that never grants anyone nothing. Failure
# is a NOTE, not an error: the admin tier does not depend on it.
$SUDO systemctl enable --now incus-user.socket 2>/dev/null \
|| echo "NOTE: could not enable incus-user.socket — 'box grant' (the restricted tier) needs it; this Incus may not ship incus-user (#74)." >&2
# Profile — box-net, the placement contract: the isolated NIC and the root
# disk, nothing a template controls (resources are stamped per-instance from
# the template at mint time). A legacy claude-dev profile is left alone: