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:
parent
f7b93a91cd
commit
0429a11020
5 changed files with 458 additions and 2 deletions
91
bin/box
91
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> -- <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
|
||||
|
|
|
|||
|
|
@ -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
154
host/grant-user.sh
Normal 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
124
host/revoke-user.sh
Normal 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."
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue