#!/bin/bash #=============================================================================== # felhom-pbs-apply — the ONLY storage-entry path the felhom-agent sudoers permits for the PBS DR # tier (slice 2; SPIKE-pbs-tier-provisioning-2026-07-10 §2b). The guarded-mkfs shape: the agent's # in-process validation is the PRIMARY gate (descriptor-field charset + equality checks BEFORE # exec); this wrapper is the minimal, auditable second gate as root. # # THE SET-ONLY LAW (spike §4, data-loss class): `pvesm` entry deletion DESTROYS the client # encryption key file (.enc = K) — un-decryptable backups. This wrapper therefore contains # NO deletion path of any kind, and a re-apply is `pvesm set`-only. Grep-assertable; do not add # a "cleanup" verb here, ever. Deprovision is a deliberate future operator op, not this tool. # # SECRET DISCIPLINE (spike §2b): sudo logs its full argv to auth.log → the PBS token secret # arrives on STDIN, never as an argument to this wrapper. Inside, it is passed to pvesm's # --password (root-local, transient ps exposure — the accepted spike posture); it is never # echoed, never written anywhere except by pvesm itself (the 0600 .pw store). # # Ops (non-secret args on argv): # create # secret on stdin (required). Creates the pbs entry with --encryption-key autogen # (K born at /etc/pve/priv/storage/.enc), then places .pw/.enc copies in # when it differs (the §4b WARN-fix dir; escrow-create's PBSEncKeyPath # must find K there — spike §4 flag). # reconcile # secret on stdin (optional; empty = no credential change). `pvesm set` ONLY: # server/fingerprint (+ --password when a secret is fed, e.g. after a hub re-issue). # NOTE datastore is deliberately NOT settable, and namespace/token-id are accepted # for validation parity but NOT applied — tenancy identity is adopt-only (the # demo's live entry must never be repointed at a different namespace). # read # R-39 leg (b): print the storage's token secret to STDOUT and nothing else. # The non-root agent WRITES this file through this wrapper but could never read it # back (/etc/pve/priv is 0700 root:www-data and there is no read verb), so its # 15-minute PBS verify loop was permanently blind to the one failure it exists to # catch — an `applied` tier authenticating 401. This verb is that missing read. # It is deliberately the narrowest thing that works: no network, no mutation, no # logging of the value, one file, prefix-asserted under the given secret dir. # # grant # The Part-0-evidenced dual-grant: FelhomAgentStore on /storage/ to the agent # user AND token (privsep intersection). Datastore.Audit reads ride the base role. #=============================================================================== set -euo pipefail die() { echo "felhom-pbs-apply: REFUSED: $*" >&2; exit 1; } op="${1:-}"; id="${2:-}" [[ -n "$op" && -n "$id" ]] || die "usage: felhom-pbs-apply ..." # Storage id: PVE grammar, conservative. Also the ACL path component — no slashes possible. [[ "$id" =~ ^[A-Za-z][A-Za-z0-9_.-]{0,27}$ ]] || die "bad storage id ($id)" STORECFG=/etc/pve/storage.cfg PRIVDIR=/etc/pve/priv/storage entry_exists() { grep -Eq "^pbs: ${id}\$" "$STORECFG"; } val_server() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$ ]] || die "bad server ($1)"; } val_datastore() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$ ]] || die "bad datastore ($1)"; } val_ns() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$ ]] || die "bad namespace ($1)"; } val_tok() { [[ "$1" =~ ^[A-Za-z0-9_.-]+@[A-Za-z0-9]+![A-Za-z0-9_.-]+$ ]] || die "bad token id ($1)"; } val_fp() { [[ "$1" =~ ^([A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2}$ ]] || die "bad fingerprint"; } val_sdir() { case "$1" in /etc/pve/priv/storage) : ;; /var/lib/felhom-agent/*) [[ "$1" != *..* ]] || die "secret-dir traversal" ;; *) die "secret-dir must be $PRIVDIR or under /var/lib/felhom-agent ($1)" ;; esac } read_secret() { # → SECRET (may be empty when optional) local s s=$(head -c 256 || true) s="${s%$'\n'}"; s="${s%$'\r'}" printf '%s' "$s" } place_copies() { # secret-dir — the §4b WARN-fix placement (non-root agent can't read /etc/pve/priv) local sdir="$1" [[ "$sdir" == "$PRIVDIR" ]] && return 0 install -d -o felhom-agent -g felhom-agent -m 0700 "$sdir" [[ -f "$PRIVDIR/$id.pw" ]] && install -o felhom-agent -g felhom-agent -m 0600 "$PRIVDIR/$id.pw" "$sdir/$id.pw" # K's copy: escrow-create stats PBSEncKeyPath(/.enc) — the spike §4 flag. [[ -f "$PRIVDIR/$id.enc" ]] && install -o root -g felhom-agent -m 0640 "$PRIVDIR/$id.enc" "$sdir/$id.enc" return 0 } case "$op" in create) [[ $# -eq 8 ]] || die "create needs 7 args: " server="$3"; datastore="$4"; ns="$5"; tok="$6"; fp="$7"; sdir="$8" val_server "$server"; val_datastore "$datastore"; val_ns "$ns"; val_tok "$tok"; val_fp "$fp"; val_sdir "$sdir" entry_exists && die "entry $id already exists (reconcile is the re-apply path — set-only law)" SECRET=$(read_secret) [[ -n "$SECRET" ]] || die "create requires the token secret on stdin" pvesm add pbs "$id" \ --server "$server" --datastore "$datastore" --namespace "$ns" \ --username "$tok" --password "$SECRET" --fingerprint "$fp" \ --content backup --encryption-key autogen >&2 [[ -f "$PRIVDIR/$id.enc" ]] || die "pvesm add succeeded but K ($PRIVDIR/$id.enc) was not born" place_copies "$sdir" echo "felhom-pbs-apply: created $id (K born; encryption-key autogen)" >&2 ;; reconcile) [[ $# -eq 7 ]] || die "reconcile needs 6 args: " server="$3"; ns="$4"; tok="$5"; fp="$6"; sdir="$7" val_server "$server"; val_ns "$ns"; val_tok "$tok"; val_fp "$fp"; val_sdir "$sdir" entry_exists || die "entry $id does not exist (create is the fresh path)" SECRET=$(read_secret) # R-39 (2026-07-18): NEVER pass --server to `pvesm set`. PVE treats `server` as a CREATE-ONLY # parameter and rejects the ENTIRE call — "can't change value of fixed parameter 'server'" — # even when the value is byte-identical to the stored one. That made every reconcile exit 255, # so each hub-re-issued one-time secret was consumed-then-burned and the tier stayed pinned to # a revoked credential (401 forever). Proven live on the N100 demo host: `pvesm set # --server --fingerprint ` -> rejected; the same call without --server -> rc 0. # The server address is immutable by construction (relocating a PBS endpoint needs a fresh # create), so there is nothing here to reconcile. Guarded by # TestReconcileNeverPassesServerToPvesmSet. args=(--fingerprint "$fp") [[ -n "$SECRET" ]] && args+=(--password "$SECRET") pvesm set "$id" "${args[@]}" >&2 place_copies "$sdir" echo "felhom-pbs-apply: reconciled $id (set-only; tenancy identity untouched)" >&2 ;; read) # R-39(b): the missing read path. Prints the secret to STDOUT, nothing else — no stderr note (it # would be the only verb whose success line could be confused with the value), no mutation. # # Traversal is refused three times over, because this is the one verb that EXFILTRATES a file and # its argv is attacker-shaped if the agent is ever compromised: # 1. `id` already matched ^[A-Za-z][A-Za-z0-9_.-]{0,27}$ at the top — it cannot start with a dot # and cannot contain a slash, so "../../etc/shadow" never reaches here; # 2. val_sdir pins the directory to PRIVDIR or under /var/lib/felhom-agent, rejecting ".."; # 3. the RESOLVED path is prefix-asserted under that directory below, so even a future change to # either grammar cannot walk out. [[ $# -eq 3 ]] || die "read needs 2 args: " sdir="$3" val_sdir "$sdir" target="$sdir/$id.pw" # Belt: resolve and re-check the prefix (guards a symlinked .pw pointing outside the dir). resolved=$(readlink -f -- "$target" 2>/dev/null || true) [[ -n "$resolved" ]] || die "secret file not found ($target)" case "$resolved" in "$sdir"/*) : ;; *) die "resolved secret path escapes the secret dir" ;; esac [[ -f "$resolved" ]] || die "secret file not found ($target)" cat -- "$resolved" ;; grant) [[ $# -eq 2 ]] || die "grant takes only " pveum acl modify "/storage/$id" --users felhom-agent@pve --roles FelhomAgentStore >&2 pveum acl modify "/storage/$id" --tokens 'felhom-agent@pve!agent' --roles FelhomAgentStore >&2 echo "felhom-pbs-apply: granted FelhomAgentStore on /storage/$id (user + token)" >&2 ;; *) die "unknown op ($op)" ;; esac