58b598b697
The agent cannot create a PVE storage (Datastore.Allocate at /storage) or grant an ACL (Permissions.Modify) -- it holds neither by design, and widening the role would trade the whole blast-radius containment model for one feature. The privileged half therefore lives in a new fenced shim behind a literal FELHOM_BACKUPTARGET sudoers alias, following the mkfs/pbs-apply pattern. The wrapper enforces the two laws E-1 paid for on live hardware so no caller can forget them: F-1 the path must BE the drive's own mountpoint, F-2 is_mountpoint 1 is hardcoded rather than a caller flag. It refuses a root-device target, has NO storage-removal path of any kind (the pbs-apply no-delete law, grep-assertable), is idempotent for the same path, and REFUSES to repoint an existing id. POST /backup/target drives it in a fixed order: create -> grant -> config. Reversed, a config pointing at an ungranted storage 403s every backup on first run -- exactly E-1 finding F-3. A failed grant leaves the config untouched. It deliberately does NOT restart the agent: restarting with a backup in flight cancels the wait and records a spurious tier failure for a backup that actually succeeded (E-1 did this to a real felhom-pbs run). It returns restart_required and the caller restarts behind its own immediate in-flight check. Config rewrite preserves unknown keys verbatim and writes in place, since /etc/felhom-agent is root-owned while agent.json is agent-owned 0600. Green gate: build + vet + test rc=0 (29 packages), run separately from this commit.
112 lines
6.5 KiB
Bash
Executable File
112 lines
6.5 KiB
Bash
Executable File
#!/bin/bash
|
|
#===============================================================================
|
|
# felhom-backup-target-apply — the ONLY path the felhom-agent sudoers permits for creating the
|
|
# whole-guest backup TARGET storage and granting the agent access to it (E-2a).
|
|
#
|
|
# WHY A WRAPPER AT ALL. Creating a PVE storage needs `Datastore.Allocate` at `/storage`, and the ACL
|
|
# grant needs `Permissions.Modify`. The agent holds NEITHER by design — its token is scoped per
|
|
# storage path for blast-radius containment, and `Permissions.Modify` would let it rewrite its own
|
|
# authority. Widening the PVE role to make the move possible would trade the entire containment model
|
|
# for one feature. So the privileged half lives here: a minimal, auditable root shim with a fixed
|
|
# vocabulary, exactly like felhom-mkfs-guarded and felhom-pbs-apply.
|
|
#
|
|
# THE NO-DELETE LAW (inherited from felhom-pbs-apply, same reasoning class). This wrapper contains NO
|
|
# storage-removal path of any kind. `pvesm remove` on a dir storage does not delete the archives, but
|
|
# it DOES silently orphan a configured backup tier, and a "cleanup" verb here would be reachable by
|
|
# any bug in the agent. Retiring a target is a deliberate operator op, not this tool. Grep-assertable;
|
|
# do not add one.
|
|
#
|
|
# THE TWO LAWS E-1 PAID FOR ON LIVE HARDWARE, both enforced here rather than trusted to the caller:
|
|
#
|
|
# F-1 the storage path must BE the drive's own mountpoint. A subdirectory fails the agent's
|
|
# exactMount check, so the target reports `disconnected` FOREVER and its durable id degrades
|
|
# off the filesystem UUID. Enforced: `mountpoint -q` must pass on the exact path given.
|
|
#
|
|
# F-2 --is_mountpoint 1 is not optional. Without it, an unplugged or late-mounting drive leaves a
|
|
# bare directory on the ROOT filesystem and vzdump writes the whole-guest backup onto the
|
|
# system drive — the exact device the whole change exists to escape — while PVE reports the
|
|
# storage `active` and advertises the root filesystem's free space. Proven live: the unguarded
|
|
# form had already created dump/ on pve-root. Hardcoded below; not a caller-supplied flag.
|
|
#
|
|
# Ops (all non-secret; nothing here touches a credential, so nothing arrives on stdin):
|
|
# create <id> <mountpoint>
|
|
# Create a `dir` storage with content=backup at <mountpoint>, is_mountpoint 1.
|
|
# IDEMPOTENT: an existing entry with the SAME path is accepted (re-run safe, and the
|
|
# installer re-run path depends on it). An existing entry with a DIFFERENT path is REFUSED
|
|
# — silently repointing a live backup target is the failure this whole arc closes.
|
|
# grant <id>
|
|
# The dual grant: FelhomAgentStore on /storage/<id> to the agent user AND token (privsep
|
|
# intersection — a token's rights are the intersection, so granting one is granting neither).
|
|
# Without it every backup 403s on first run (E-1 finding F-3, found by the first real backup).
|
|
#===============================================================================
|
|
set -euo pipefail
|
|
|
|
die() { echo "felhom-backup-target-apply: REFUSED: $*" >&2; exit 1; }
|
|
|
|
op="${1:-}"; id="${2:-}"
|
|
[[ -n "$op" && -n "$id" ]] || die "usage: felhom-backup-target-apply <create|grant> <storage-id> [mountpoint]"
|
|
|
|
# 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
|
|
|
|
# current_path_of <id> — the configured `path` of dir storage <id>, or "" when absent/not-a-dir.
|
|
current_path_of() {
|
|
awk -v want="dir: $1" '
|
|
$0 == want { found=1; next }
|
|
found && /^[a-z]+: / { exit }
|
|
found && $1 == "path" { print $2; exit }
|
|
' "$STORECFG" 2>/dev/null || true
|
|
}
|
|
|
|
case "$op" in
|
|
create)
|
|
[[ $# -eq 3 ]] || die "create takes <id> <mountpoint>"
|
|
mp="$3"
|
|
# Absolute, normalized, no traversal, no shell metacharacters. The value reaches pvesm and the
|
|
# filesystem, so it is validated here rather than assumed well-formed.
|
|
[[ "$mp" = /* ]] || die "mountpoint must be absolute ($mp)"
|
|
[[ "$mp" != *".."* ]] || die "mountpoint must not contain .. ($mp)"
|
|
[[ "$mp" =~ ^[A-Za-z0-9/_.-]+$ ]] || die "mountpoint has unexpected characters ($mp)"
|
|
[[ "$mp" != "/" ]] || die "refusing / as a backup target"
|
|
|
|
# F-1 + F-2, checked as one: the path must BE a mountpoint right now. A bare directory here is
|
|
# precisely the silent-retarget shape, and is_mountpoint would make PVE refuse it later anyway —
|
|
# better to refuse now, with a reason, than to create a storage that can never activate.
|
|
mountpoint -q "$mp" || die "$mp is not a mountpoint — the backup target must be the drive's OWN mountpoint (F-1), and an unmounted path would silently retarget onto the system drive (F-2)"
|
|
|
|
# Never the system disk: a target on the root filesystem is not drive-loss protection, it is the
|
|
# thing we are escaping. The root device and the candidate's device are compared, not their paths.
|
|
root_dev="$(findmnt -no SOURCE / 2>/dev/null || true)"
|
|
mp_dev="$(findmnt -no SOURCE "$mp" 2>/dev/null || true)"
|
|
[[ -n "$mp_dev" ]] || die "could not resolve the backing device of $mp"
|
|
[[ "$mp_dev" != "$root_dev" ]] || die "$mp is backed by the ROOT device ($root_dev) — a backup target there protects against corruption only, never drive loss"
|
|
|
|
existing="$(current_path_of "$id")"
|
|
if [[ -n "$existing" ]]; then
|
|
if [[ "$existing" == "$mp" ]]; then
|
|
echo "felhom-backup-target-apply: storage $id already exists at $mp — nothing to do (idempotent)" >&2
|
|
exit 0
|
|
fi
|
|
die "storage $id already exists at $existing — refusing to repoint it at $mp (a live backup target is never silently moved)"
|
|
fi
|
|
|
|
# is_mountpoint 1 is HARDCODED (F-2). content=backup only: this storage exists for vzdump archives
|
|
# and must never become a place guests are allocated on.
|
|
pvesm add dir "$id" --path "$mp" --content backup --is_mountpoint 1 >&2
|
|
echo "felhom-backup-target-apply: created dir storage $id at $mp (content=backup, is_mountpoint 1)" >&2
|
|
;;
|
|
grant)
|
|
[[ $# -eq 2 ]] || die "grant takes only <id>"
|
|
# BOTH, always. A privsep token's rights are the intersection of the user's and the token's ACLs,
|
|
# so granting one of the two grants nothing usable.
|
|
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-backup-target-apply: granted FelhomAgentStore on /storage/$id (user + token)" >&2
|
|
;;
|
|
*)
|
|
die "unknown op ($op)"
|
|
;;
|
|
esac
|