#!/usr/bin/env bash # box grant — give a host user the restricted tier (#74). # # The tier rides incus-user: the user lands in an auto-created project # user- 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- (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-" 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 " >&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 || { 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 &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 2>&1 || true incus project show "$project" >/dev/null 2>&1 &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-; 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 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 &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 2>&1 /dev/null /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)"