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.
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
#
|
|
|
|
|
# If THIS run granted the group and a later step fails, take it back on the
|
|
|
|
|
# way out: a half-granted user would otherwise hold live socket access to an
|
|
|
|
|
# UN-NARROWED project — the stock unhardened bridge attachable — until an
|
|
|
|
|
# admin re-runs. Backing out the group closes that window completely for a
|
|
|
|
|
# fresh grant (their existing sessions predate the membership, so no process
|
|
|
|
|
# holds it yet). A user who was already in the group keeps it: not ours to
|
|
|
|
|
# take on a re-run's failure.
|
|
|
|
|
added_group=0
|
|
|
|
|
backout() {
|
|
|
|
|
if [ "$added_group" -eq 1 ]; then
|
|
|
|
|
$SUDO gpasswd -d "$user" incus >/dev/null 2>&1 || true
|
|
|
|
|
echo "box grant: FAILED — removed $user from 'incus' again (no half-granted access left behind); fix the cause and re-run" >&2
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
trap backout EXIT
|
|
|
|
|
|
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
|
|
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
|
|
|
|
echo "group: $user already in 'incus'"
|
|
|
|
|
else
|
|
|
|
|
$SUDO usermod -aG incus "$user"
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
added_group=1
|
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
|
|
|
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
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
# The private bridge's name follows incus-user's own rule (revoke-user.sh
|
|
|
|
|
# mirrors it too): incusbr-<uid>, or user-<uid> when that would not fit an
|
|
|
|
|
# interface name — naming the wrong one here would be a true claim with the
|
|
|
|
|
# wrong noun on big-uid (SSSD/AD) hosts.
|
|
|
|
|
bridge="incusbr-$uid"; [ "${#bridge}" -gt 15 ] && bridge="user-$uid"
|
|
|
|
|
echo "network: $project restricted to boxnet (the private $bridge is unreferenced and unreachable)"
|
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
|
|
|
|
|
|
|
|
# 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; }
|
|
|
|
|
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
trap - EXIT # converged and verified: the grant stands
|
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
|
|
|
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)"
|