forked from heavy-duty/box
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`, `awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the writer, and yield the same 141 under pipefail. The pin guarded the instance spelling of the very thing it existed to generalise. Both halves of the matcher are alternations now. Readers are deliberately not narrowed to the early-exit spellings: telling `grep -q` from `grep -c` by regex is precision that rots, and all six `ufw status` sites in the tree already capture first, so banning the pipe outright costs nothing real. Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into `grep -q` as the --purge leftover assert, under `set -euo pipefail` — so unlike drill/wipe.sh nothing but the writer's size was holding it, and left of `&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a host that still trusts the revoked user's certificate and called the purge complete: fail-open, on the path whose job is to prove access is gone. Now captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so the first CSV row anchors like the `^` it replaces. Writers are enumerated rather than generalised — ~150 legitimate `| grep` sites exist under host/ and drill/, nearly all re-reading a captured string — so the sweep claims only what it can check. The `id -nG | tr | grep -qx` shapes in grant/revoke/setup are left alone: single tiny writes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
12 KiB
Bash
237 lines
12 KiB
Bash
#!/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"
|
|
# EOF (Ctrl-D) is an answer too, and it is a refusal. Unguarded, 'read'
|
|
# returns non-zero and errexit ends the run right here — before the
|
|
# 'case' below can name the abort, so the most destructive prompt box
|
|
# has would go silent at the moment it asked (#111).
|
|
read -r reply || { echo "box revoke: aborted." >&2; exit 1; }
|
|
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
|
|
|
|
# An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES
|
|
# put them in 'incus' — not for privilege, but because incus-user's socket is
|
|
# a file owned by that group (#101) — so revoke has a real membership to take
|
|
# back here. What it does not have is a lockout: 'incus-admin' opens the
|
|
# daemon and is not this script's to remove, so "revoked" would still be the
|
|
# script claiming something it did not perform. Recorded here, spoken below.
|
|
admin_member=0
|
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
|
admin_member=1
|
|
fi
|
|
|
|
# The group, first — access ends even if a purge step below trips.
|
|
dropped_group=0
|
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
|
$SUDO gpasswd -d "$user" incus >/dev/null
|
|
dropped_group=1
|
|
if [ "$admin_member" -eq 1 ]; then
|
|
echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which"
|
|
echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here"
|
|
else
|
|
echo "group: removed $user from 'incus'"
|
|
fi
|
|
elif [ "$admin_member" -eq 1 ]; then
|
|
echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch"
|
|
else
|
|
echo "group: $user was not in 'incus'"
|
|
fi
|
|
|
|
# Say it once, plainly, before either path's summary: for an admin member the
|
|
# group step above closed nothing, so nothing this script does — including
|
|
# --purge — ends their access to the daemon. Only the admin group itself does.
|
|
if [ "$admin_member" -eq 1 ]; then
|
|
echo "NOTE: $user is in 'incus-admin' and keeps FULL access to every project on this host"
|
|
echo " regardless of what follows. To actually lock them out: gpasswd -d $user incus-admin"
|
|
fi
|
|
|
|
# Supplementary groups are fixed AT LOGIN: the database change above does
|
|
# nothing to a session the user already holds — a leftover tmux keeps the
|
|
# socket until it dies. For a bare revoke that is an honest warning. For
|
|
# --purge it is a hole: a stale-group process can touch incus-user AFTER the
|
|
# purge and lazily recreate the project with incus-user's stock defaults —
|
|
# the unhardened NAT bridge, un-narrowed — which is strictly worse than the
|
|
# granted state this script is unwinding. So --purge terminates the user's
|
|
# sessions first (it is already the destructive, confirmed path), and a bare
|
|
# revoke says out loud what it did not do.
|
|
if pgrep -u "$user" >/dev/null 2>&1; then
|
|
if [ "$purge" -eq 1 ]; then
|
|
echo "sessions: $user has live processes — terminating them (a stale session could recreate the project, unhardened, after the purge)"
|
|
$SUDO loginctl terminate-user "$user" 2>/dev/null || true
|
|
$SUDO pkill -u "$user" 2>/dev/null || true
|
|
sleep 1
|
|
$SUDO pkill -9 -u "$user" 2>/dev/null || true
|
|
if pgrep -u "$user" >/dev/null 2>&1; then
|
|
echo "box revoke: could not terminate $user's processes — refusing to purge under them" >&2
|
|
echo " (they retain the socket until those sessions end, and could recreate the project)" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "WARNING: $user has live sessions, and group membership is read at login —"
|
|
echo " those sessions keep the socket until they end. To end them now:"
|
|
echo " sudo loginctl terminate-user $user"
|
|
fi
|
|
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)"
|
|
if [ "$admin_member" -eq 1 ]; then
|
|
# "restores access" would be the wrong promise here: no access was lost.
|
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project and the membership"
|
|
else
|
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
|
fi
|
|
fi
|
|
if [ "$admin_member" -eq 1 ]; then
|
|
# Not "revoked", still: what came back is the 'incus' membership grant
|
|
# added for incus-user's socket, and their access to this host was never
|
|
# riding on it. The project is kept. So say exactly what was taken and
|
|
# name the two real options, instead of a summary that reads as a lockout.
|
|
if [ "$dropped_group" -eq 1 ]; then
|
|
echo "partial: took $user out of 'incus' — incus-user's socket key, which 'box grant' added."
|
|
echo " $user is NOT locked out: 'incus-admin' still opens every project on this host."
|
|
else
|
|
echo "no-op: $user was not in 'incus' and holds the admin tier via 'incus-admin' — nothing was taken."
|
|
fi
|
|
echo " to remove their access: gpasswd -d $user incus-admin"
|
|
echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge"
|
|
exit 0
|
|
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.
|
|
# $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a
|
|
# non-root admin, so an unprivileged stat answers "absent" for a directory
|
|
# that is very much there — the same lie the absence assert below must dodge.
|
|
if $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null; 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.
|
|
# The certificate included: its removal above is set -e-exempt (left of &&),
|
|
# and a promise the header makes is a promise this block checks. The
|
|
# incus-user state directory too — it was purged for releases without being
|
|
# re-checked, which is exactly the gap this block exists to close.
|
|
# The trust store is read into a capture rather than piped into a reader that
|
|
# stops at its first match — #102's shape, and this file is `set -euo pipefail`
|
|
# already, so unlike drill/wipe.sh (#107) nothing but the writer's size is
|
|
# holding it. A reader that exits early SIGPIPEs incus mid-table and the
|
|
# pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it
|
|
# would read as "no leftover cert" on a host that still trusts the revoked
|
|
# user's certificate and the purge would report success. Fail-open, on the
|
|
# cleanup path whose entire job is to prove access is gone.
|
|
# Un-racy in practice today — the trust store is small and likely one write —
|
|
# so this is defensive, not a live defect. Captured so it cannot become one.
|
|
trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)"
|
|
|
|
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"
|
|
# Leading newline so the first CSV row anchors like the `^` this replaces.
|
|
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
|
|
&& leftover="$leftover cert:incus-user-$uid"
|
|
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
|
&& leftover="$leftover /var/lib/incus/users/$uid"
|
|
if [ -n "$leftover" ]; then
|
|
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$admin_member" -eq 1 ]; then
|
|
# "is out" would be false: the purge unmade the provisioning, and their
|
|
# incus-admin membership — untouched by design, it is not this script's to
|
|
# take — still opens every project on the host, including a fresh one they
|
|
# can build by hand a second from now.
|
|
echo "purged: everything 'box grant' created for $user is gone — but $user is NOT out."
|
|
echo " 'incus-admin' still gives them the whole daemon: gpasswd -d $user incus-admin"
|
|
else
|
|
echo "revoked: $user is out, and everything the tier created is gone."
|
|
fi
|