box/host/grant-user.sh

155 lines
7.8 KiB
Bash
Raw Normal View History

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>
2026-07-18 04:09:13 +00:00
#!/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)"