R-275, R-276, R-881: the uninstall removes every copy of the agent config, the WireGuard tunnel and felhom-priv-apply

- R-275: the agent's own config dir is purged as a directory (the .bak* glob missed all five
  demo-hp copies); a custom config path loses agent.json + every agent.json.* sibling only.
  Sudoers dotted copies go too. At install, a freshly created service user cannot read what an old
  install left in /etc/felhom-agent (sealed root 0600, named, never deleted). vmbr9 and the ISO
  first-boot files are now NAMED under KEPT.
- R-276: wg-quick@wg-felhom is disabled --now and its conf removed, then observed down; the hub-side
  peer is named under KEPT (removing it is the hub's job).
- R-881: /usr/local/sbin/felhom-priv-apply is removed; the disclosure says it and the guest hook
  come from the config bundle.
- scripts/test_hostinstall.py: lifts the functions verbatim and runs them with PATH stubs (13 tests,
  BusyBox-safe); harness GL4-D here-string (SIGPIPE false miss), GL8-F1 follows the new purge.
- SCRIPT_VERSION 1.32.0 (not published; no tag).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:14:19 +02:00
parent c8da8e0439
commit 85de3f9b87
3 changed files with 487 additions and 28 deletions
+128 -8
View File
@@ -184,7 +184,7 @@
set -euo pipefail
SCRIPT_VERSION="1.31.0" # the SINGLE version source (F-1): -h and the run banners follow it.
SCRIPT_VERSION="1.32.0" # the SINGLE version source (F-1): -h and the run banners follow it.
# The hub used to carry a copy for its Setup tab; R-94 DELETED it
# (2026-08-02) because the hub cannot know which version a box runs —
# the Setup command fetches this script at run time. scripts/
@@ -811,9 +811,18 @@ _uninstall_statement() {
echo " WIPED (this run):"
echo " - guest $vmid (container + its OS/Docker/user-data volumes)"
if [[ "$scope" == "full" ]]; then
echo " - the felhom-agent: binary, unit, sudoers, config (+ its .bak backups), state dir, service user"
echo " - the felhom-agent: binary, unit, sudoers (+ its copies), state dir, service user, and its config"
echo " directory with every copy of the config in it"
if $_WG_PRESENT; then
if [[ -n "$_WG_TEARDOWN_NOTE" ]]; then
echo " - the WireGuard tunnel to the Felhom off-site endpoint: NOT shown down — $_WG_TEARDOWN_NOTE"
else
echo " - the WireGuard tunnel to the Felhom off-site endpoint (${WG_UNIT} disabled, ${WG_CONF} removed)"
fi
fi
echo " - self-update artifacts: guarded wrapper, A/B slots (.prev/.new.*), rollback unit, start-limit drop-in"
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets; the mkfs + pbs-apply wrappers"
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets; the mkfs, pbs-apply,"
echo " backup-target-apply, os-apply and priv-apply wrappers; the crash guard; the config-bundle record"
echo " - pveum: the Felhom roles/user/token/scoped ACL$( $pool_removed && printf '; the emptied %s pool' "$PVE_POOL")"
echo " - the install state file"
if $REMOVE_GOLDEN; then echo " - the golden vzdump (--remove-golden)"; fi
@@ -835,6 +844,21 @@ _uninstall_statement() {
echo " - the PBS backups + this customer's namespace on the PBS side — delete there if wanted"
fi
echo " - the hub host/customer record + report history (operator UI / DB)"
if [[ "$scope" == "full" ]] && $_WG_PRESENT; then
echo " - this host's WireGuard PEER on the hub and the off-site endpoint (its /32 and public key) — the"
echo " tunnel is down from this side; delete the peer in the hub (operator) if the customer is leaving"
fi
if [[ "$scope" == "full" ]]; then
# R-275: named, not removed — a host network change and the ISO's own first-boot files.
if grep -qE "^[[:space:]]*iface[[:space:]]+${ISLAND_BRIDGE}[[:space:]]" /etc/network/interfaces 2>/dev/null; then
echo " - the ${ISLAND_BRIDGE} island bridge stanza in /etc/network/interfaces (host-internal, no port; a reinstall"
echo " reuses it) — to remove it: delete the stanza, then ifreload -a"
fi
if [[ -e /etc/felhom/.bootstrap-done || -e /etc/felhom/appliance-pairing-code ]]; then
echo " - the appliance ISO's first-boot files: /etc/felhom/.bootstrap-done, /etc/felhom/appliance-pairing-code,"
echo " felhom-bootstrap.service (disabled, fired once) — not this script's; remove by hand if wanted"
fi
fi
echo " - the escrow blob in the hub, if one exists (operator UI)"
if $_had_break_glass; then
echo " - the hub-vaulted root@pam recovery credential — the box KEEPS the password step 4b set; rotate it if the box leaves Felhom management"
@@ -931,6 +955,90 @@ _dnsmasq_purge_owned() {
return 0
}
# _purge_agent_config CFG — remove the agent config AND every copy of it (R-275).
#
# The config holds the per-host hub api_key and the Proxmox token. Copies of it are made by hand and
# by tools, under names nobody can predict (`agent.json.campaign9-before`, `agent.json.pre-prunegate.bak`
# were measured on demo-hp 2026-08-09). The old `${cfg}.bak*` glob missed all five of them, and the
# reinstall then handed them to the new service account (same uid). So: the agent's OWN directory is
# purged as a directory. A config at a custom path (operator-chosen, maybe a shared dir) is never
# purged by directory — there the config and every `${cfg}.*` sibling go, then an empty dir.
# Pinned by scripts/test_hostinstall.py (test_purge_*).
AGENT_CFG_DIR_DEFAULT="/etc/felhom-agent"
_purge_agent_config() {
local cfg="$1" dir f
dir=$(dirname "$cfg")
if [[ "$dir" == "$AGENT_CFG_DIR_DEFAULT" ]]; then
if [[ -d "$dir" ]]; then
run rm -rf "$dir"
log_success " removed $dir (the agent config and every copy of it)"
else
log_skip " $dir already absent"
fi
return 0
fi
if [[ -f "$cfg" ]]; then run rm -f "$cfg"; else log_skip " $cfg already absent"; fi
for f in "${cfg}".*; do [[ -e "$f" ]] && run rm -f "$f"; done
run rmdir "$dir" 2>/dev/null || true
return 0
}
# _seal_old_agent_config DIR — at install, when the service user was JUST created (R-275, second half).
# A new system account can get the uid the deleted one had, so files a previous install left in the
# config dir would become readable by the new account. They are made root-only (0600 root:root) and
# named — never deleted (they may be an operator's own backup). Step 6 rewrites agent.json and gives
# it to the agent again, so only the old copies stay sealed.
_seal_old_agent_config() {
local dir="$1" f found=false
[[ -d "$dir" ]] || return 0
for f in "$dir"/* "$dir"/.[!.]*; do
[[ -f "$f" ]] || continue
found=true
run chown root:root "$f"
run chmod 0600 "$f"
log_warn " left by a previous install, now root-only: $f"
done
$found && log_warn " remove these by hand if you do not need them (they may hold an old hub key and Proxmox token)"
return 0
}
# _teardown_wg_tunnel — stop the WireGuard tunnel to the Felhom off-site endpoint (R-276).
#
# The agent creates it at run time (wg_tunnel.enabled is the default, decision 5) and the uninstall
# used to leave it enabled and handshaking: a box told to leave Felhom kept a live network path into
# Felhom's endpoint. Stop + disable the unit, remove its conf, then OBSERVE that it is down. The
# peer on the hub/endpoint side is not this script's to remove — the closing statement names it.
# Sets _WG_TEARDOWN_NOTE when the tunnel could not be shown down. Pinned by
# scripts/test_hostinstall.py (test_wg_*).
WG_UNIT="wg-quick@wg-felhom"
WG_CONF="/etc/wireguard/wg-felhom.conf"
_WG_TEARDOWN_NOTE=""
_WG_PRESENT=false
_teardown_wg_tunnel() {
local active=false enabled=false
systemctl is-active --quiet "$WG_UNIT" 2>/dev/null && active=true
systemctl is-enabled --quiet "$WG_UNIT" 2>/dev/null && enabled=true
if $active || $enabled || [[ -e "$WG_CONF" ]]; then _WG_PRESENT=true; fi
if ! $_WG_PRESENT; then
log_skip " WireGuard tunnel ($WG_UNIT) not present"
return 0
fi
if $active || $enabled; then
run systemctl disable --now "$WG_UNIT" || log_warn " systemctl disable --now $WG_UNIT returned non-zero"
fi
run systemctl reset-failed "$WG_UNIT" 2>/dev/null || true
if [[ -e "$WG_CONF" ]]; then run rm -f "$WG_CONF"; fi
$DRY_RUN && return 0
# Positive observable: the unit must now read inactive (an exit code is not an observation).
if systemctl is-active --quiet "$WG_UNIT" 2>/dev/null; then
_WG_TEARDOWN_NOTE="$WG_UNIT is STILL active after disable --now"
log_warn " $_WG_TEARDOWN_NOTE — stop it by hand: systemctl disable --now $WG_UNIT"
else
log_success " WireGuard tunnel to the Felhom off-site endpoint is down ($WG_UNIT disabled, conf removed)"
fi
return 0
}
run_uninstall() {
log_step "UNINSTALL — local host teardown"
@@ -1045,6 +1153,8 @@ run_uninstall() {
for bak in "${AGENT_UNIT}".bak-*; do [[ -e "$bak" ]] && run rm -f "$bak"; done
run systemctl daemon-reload
if [[ -f "$AGENT_SUDOERS" ]]; then run rm -f "$AGENT_SUDOERS"; else log_skip " $AGENT_SUDOERS already absent"; fi
# R-275: dotted copies (`felhom-agent.bak-pre-e2a`) are inert for sudo but are still a copy of it.
for bak in "${AGENT_SUDOERS}".*; do [[ -e "$bak" ]] && run rm -f "$bak"; done
if [[ -f "$AGENT_BIN" ]]; then run rm -f "$AGENT_BIN"; else log_skip " $AGENT_BIN already absent"; fi
for bak in "${AGENT_BIN}".bak-*; do [[ -e "$bak" ]] && run rm -f "$bak"; done
if [[ -d "$AGENT_STATE_DIR" ]]; then run rm -rf "$AGENT_STATE_DIR"; else log_skip " $AGENT_STATE_DIR already absent"; fi
@@ -1054,10 +1164,13 @@ run_uninstall() {
# drill R1 / GL-6 F1). The config write leaves `${agent_cfg}.bak*` siblings (e.g. .bak-<ver>,
# .bak-ceremony-*, .bak-pre064) — one GL-6 residue still held a LIVE hub api_key. Remove the
# config AND every `.bak*` sibling, then the (now-empty) dir. Paths logged, contents never.
if [[ -f "$agent_cfg" ]]; then run rm -f "$agent_cfg"; else log_skip " $agent_cfg already absent"; fi
local _cfgbak
for _cfgbak in "${agent_cfg}".bak*; do [[ -e "$_cfgbak" ]] && run rm -f "$_cfgbak"; done
run rmdir "$(dirname "$agent_cfg")" 2>/dev/null || true
# R-275: the `.bak*` glob missed every hand-made copy (`agent.json.campaign9-before` …), so the
# agent's own directory is now purged as a directory — see _purge_agent_config.
_purge_agent_config "$agent_cfg"
# 4b0. The WireGuard tunnel to the Felhom off-site endpoint (R-276). The agent is stopped above, so
# nothing re-enables it while it goes.
_teardown_wg_tunnel
# 4b2. Management-plane break-glass (TASK G1): timer+oneshot+script+tmpfiles. Stop/disable the
# timer, remove all four artifacts + the runtime heal-marker. We do NOT `rmdir /run/sshd` —
@@ -1161,6 +1274,8 @@ run_uninstall() {
if [[ -f /usr/local/sbin/felhom-pbs-apply ]]; then run rm -f /usr/local/sbin/felhom-pbs-apply; else log_skip " felhom-pbs-apply already absent"; fi
if [[ -f /usr/local/sbin/felhom-backup-target-apply ]]; then run rm -f /usr/local/sbin/felhom-backup-target-apply; else log_skip " felhom-backup-target-apply already absent"; fi
if [[ -f /usr/local/sbin/felhom-os-apply ]]; then run rm -f /usr/local/sbin/felhom-os-apply; else log_skip " felhom-os-apply already absent"; fi
# R-881: the content checker the config bundle installs since agent v0.146.1 (R-861).
if [[ -f /usr/local/sbin/felhom-priv-apply ]]; then run rm -f /usr/local/sbin/felhom-priv-apply; else log_skip " felhom-priv-apply already absent"; fi
# 1.30.0: the crash guard (kernel.panic goes back to the kernel default 0 at the next boot) and the root-owned
# slow-lane trust files.
if systemctl list-unit-files felhom-crash-guard.service >/dev/null 2>&1; then
@@ -1676,7 +1791,8 @@ _byo_disclosure_ack() {
+ /usr/local/sbin/felhom-pbs-apply (PBS-DR apply wrapper — DR capability is baked
on every install; ACTIVATION stays a hub flag, off = zero effect on this host)
+ felhom-mgmt-watchdog service+timer+script + /etc/tmpfiles.d/felhom-privsep.conf
+ guest-hook snippet under /var/lib/vz/snippets/ (agent-installed at runtime)
+ /usr/local/sbin/felhom-priv-apply + the guest-hook snippet under /var/lib/vz/snippets/
(both from the agent's config bundle)
+ the 'sudo' and 'age' packages if absent + install state dir ${STATE_DIR}
wg: an OUTBOUND WireGuard tunnel to the Felhom hub (wg_tunnel.enabled=true — base
infrastructure like the cloudflared tunnel; hands-free peer registration; the
@@ -2270,6 +2386,10 @@ step_agent_install() {
else
useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER"
log_success " created service user $AGENT_USER"
# R-275: the new account may get the uid of a deleted one — make what an old install left
# in the agent's own config dir root-only. Only that dir: a custom config path may share a
# directory with files that are not ours.
_seal_old_agent_config "$AGENT_CFG_DIR_DEFAULT"
fi
# systemd-journal group: the NAS verify pipeline (agent v0.81.0) classifies mount failures from
+11 -20
View File
@@ -256,8 +256,11 @@ if [[ -n "$ustart" && -n "$uend" && "$ustart" -lt "$uend" ]]; then
for tok in 'felhom-selfupdate-guarded' 'felhom-agent-rollback.service' 'felhom-agent-limits.conf' \
'.prev' 'felhom-mgmt-watchdog' 'felhom-privsep.conf' 'felhom-mkfs-guarded' \
'felhom-guest-hook' '/mnt/felhom-drives' 'AGENT_SUDOERS' 'AGENT_STATE_DIR' \
'remove_scoped_acl' 'pveum user token remove' 'pveum pool delete' 'STATE_FILE'; do
echo "$usect" | grep -qF "$tok" || d_missing+="$tok "
'remove_scoped_acl' 'pveum user token remove' 'pveum pool delete' 'STATE_FILE' \
'felhom-priv-apply' '_teardown_wg_tunnel' '_purge_agent_config'; do
# a here-string, not `echo | grep -q`: under pipefail grep -q's early exit SIGPIPEs the echo
# and a token that IS present reads as missing (AGENT_SUDOERS did, 2026-10-05).
grep -qF -- "$tok" <<<"$usect" || d_missing+="$tok "
done
if [[ -z "$d_missing" ]]; then
verdict PASS "GL4-D disclosure↔uninstall parity (all artifact tokens covered)"
@@ -326,25 +329,13 @@ else
verdict FAIL "GL8-F6 byo :53 gate refuses+instructs, never mutates the owner's resolver"
fi
# GL8-F1(static): uninstall removes the agent config's .bak* siblings (not just agent.json).
if grep -q '"${agent_cfg}".bak\*' "$SCRIPT"; then
verdict PASS "GL8-F1 uninstall removes \${agent_cfg}.bak* (secret-bearing backups)"
# GL8-F1 / R-275: the agent config and EVERY copy of it go — the agent's own config dir is purged as a
# directory (the old `.bak*` glob missed `agent.json.campaign9-before` & co.). Behaviour is pinned by
# scripts/test_hostinstall.py (test_purge_*, run in CI); here only the call site.
if grep -q '^ _purge_agent_config "\$agent_cfg"' "$SCRIPT" && ! grep -q '"${agent_cfg}".bak\*' "$SCRIPT"; then
verdict PASS "GL8-F1 uninstall purges the agent config dir (every copy, R-275)"
else
verdict FAIL "GL8-F1 uninstall removes \${agent_cfg}.bak* (secret-bearing backups)"
fi
# GL8-F1(behavioural): the exact glob-removal pattern the script uses, exercised in a temp dir —
# both agent.json AND its .bak* siblings must go (a plain `rm -f agent.json` would leave the .bak).
f1dir="$WORK/etc-felhom-agent"; mkdir -p "$f1dir"
: > "$f1dir/agent.json"; : > "$f1dir/agent.json.bak-0.75.0"; : > "$f1dir/agent.json.bak-ceremony-2026-07-08"; : > "$f1dir/agent.json.bak-pre064"
agent_cfg="$f1dir/agent.json"
rm -f "$agent_cfg"
for _cfgbak in "${agent_cfg}".bak*; do [[ -e "$_cfgbak" ]] && rm -f "$_cfgbak"; done
rmdir "$f1dir" 2>/dev/null || true
if [[ ! -e "$f1dir" ]]; then
verdict PASS "GL8-F1b glob removal clears agent.json + every .bak* + the empty dir"
else
verdict FAIL "GL8-F1b glob removal clears agent.json + every .bak* + the empty dir" "residue: $(ls -A "$f1dir" 2>/dev/null | tr '\n' ' ')"
verdict FAIL "GL8-F1 uninstall purges the agent config dir (every copy, R-275)"
fi
echo ""
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Behaviour tests for felhom-host-install.sh — the paths that need no Proxmox host.
HOW IT RUNS ANYWHERE. The installer runs as root on a Proxmox host; the CI runner is Alpine + BusyBox +
bash + python3 + git. So each test lifts the functions it needs out of felhom-host-install.sh VERBATIM
(by name, `^name() {` to the first `^}`), runs them under bash in a temp directory, and replaces the
host commands (`systemctl`, `chown`, …) with PATH stubs that record what they were asked. Paths the
functions act on are variables the test points into the temp directory. Nothing touches the real host.
Where behaviour cannot be isolated, a STATIC test checks the wiring (the function is CALLED, from the
right place) — a helper defined and never called is the seam-built-never-wired shape.
Rows: R-275, R-276, R-881 (uninstall residue).
Run: python3 scripts/test_hostinstall.py
"""
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "felhom-host-install.sh")
AGENT_OS_APPLY = os.path.join(os.path.dirname(os.path.dirname(HERE)), "felhom-agent", "configs", "felhom-os-apply")
# The root-owned files the agent's config bundle installs (felhom-agent configs/felhom-os-apply
# BUNDLE_FILES, agent v0.147.0). Frozen here so CI (which has no sibling checkout) still checks it;
# where the sibling IS present, test_bundle_list_is_current fails when the bundle gains a file.
BUNDLE_DESTS = [
"/usr/local/sbin/felhom-mkfs-guarded",
"/usr/local/sbin/felhom-selfupdate-guarded",
"/usr/local/sbin/felhom-pbs-apply",
"/usr/local/sbin/felhom-backup-target-apply",
"/usr/local/sbin/felhom-os-apply",
"/usr/local/sbin/felhom-crash-guard",
"/usr/local/sbin/felhom-priv-apply",
"/var/lib/vz/snippets/felhom-guest-hook.sh",
"/usr/local/sbin/felhom-shared-parent.sh",
"/etc/systemd/system/felhom-shared-parent.service",
"/etc/systemd/system/felhom-crash-guard.service",
"/etc/systemd/system/felhom-crash-guard-check.service",
"/etc/systemd/system/felhom-crash-guard-check.timer",
"/etc/felhom/crash-guard.conf",
"/etc/systemd/system/felhom-agent.service",
"/etc/systemd/system/felhom-agent-rollback.service",
"/etc/systemd/system/felhom-agent.service.d/felhom-agent-limits.conf",
"/usr/local/sbin/felhom-mgmt-watchdog",
"/etc/tmpfiles.d/felhom-privsep.conf",
"/etc/systemd/system/felhom-mgmt-watchdog.service",
"/etc/systemd/system/felhom-mgmt-watchdog.timer",
"/etc/systemd/system/felhom-sshd.service",
"/etc/felhom-oob.nft",
"/etc/systemd/system/felhom-oob-nft.service",
"/etc/sudoers.d/felhom-op",
"/etc/sudoers.d/felhom-agent",
]
SRC = open(SCRIPT, encoding="utf-8").read()
def func(name):
m = re.search(r"^%s\(\) \{[^\n]*\n.*?^\}\n" % re.escape(name), SRC, re.S | re.M)
if not m:
raise AssertionError("%s() not found in felhom-host-install.sh" % name)
return m.group(0)
def one_liners():
"""The log_* helpers and die (one-line definitions)."""
out = [l for l in SRC.splitlines() if re.match(r"^(log_\w+|die)\(\)\s+\{.*\}\s*$", l)]
if len(out) < 8:
raise AssertionError("log helpers not found (%d)" % len(out))
return "RED=; GREEN=; YELLOW=; BLUE=; CYAN=; NC=\n" + "\n".join(out) + "\n"
def section(start_re, end_re):
s = re.search(start_re, SRC, re.M)
e = re.search(end_re, SRC, re.M)
if not s or not e or e.start() <= s.start():
raise AssertionError("section %r..%r not found" % (start_re, end_re))
return SRC[s.start():e.start()]
class Sandbox:
"""A temp dir with a stub bin/ first on PATH. Each stub appends its argv to calls.log."""
def __init__(self):
self.root = tempfile.mkdtemp(prefix="hostinstall-test-")
self.bin = os.path.join(self.root, "bin")
os.mkdir(self.bin)
self.calls = os.path.join(self.root, "calls.log")
open(self.calls, "w").close()
def stub(self, name, body="exit 0"):
p = os.path.join(self.bin, name)
with open(p, "w") as f:
f.write('#!/bin/sh\necho "%s $*" >> "%s"\n%s\n' % (name, self.calls, body))
os.chmod(p, 0o755)
def path(self, *parts):
return os.path.join(self.root, *parts)
def logged(self):
return open(self.calls).read()
def run(self, script):
env = dict(os.environ)
env["PATH"] = self.bin + os.pathsep + env.get("PATH", "")
env["SB"] = self.root
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env)
return p.returncode, p.stdout + p.stderr
def close(self):
shutil.rmtree(self.root, ignore_errors=True)
def prelude(dry=False):
return one_liners() + ("DRY_RUN=%s\n" % ("true" if dry else "false")) + func("run")
# ── R-275: the agent config and every copy of it ─────────────────────────────────────────────────
# The names measured on demo-hp 2026-08-09; none but the last matched the old `.bak*` glob's intent,
# and the old glob missed even that one's siblings.
DEMO_HP_COPIES = ["agent.json.campaign8-before", "agent.json.campaign9-before", "agent.json.campaign9-prev",
"agent.json.pre-e-target-move", "agent.json.pre-prunegate.bak"]
def test_purge_default_dir_removes_every_copy():
sb = Sandbox()
try:
d = sb.path("etc-felhom-agent")
os.mkdir(d)
for n in ["agent.json", ".hidden-copy"] + DEMO_HP_COPIES:
open(os.path.join(d, n), "w").write("secret")
rc, out = sb.run(prelude() + func("_purge_agent_config") +
'AGENT_CFG_DIR_DEFAULT="$SB/etc-felhom-agent"\n_purge_agent_config "$SB/etc-felhom-agent/agent.json"\n')
assert rc == 0, out
left = os.listdir(d) if os.path.exists(d) else []
assert not os.path.exists(d), "agent config dir survived the uninstall with: %s" % sorted(left)
finally:
sb.close()
def test_purge_custom_path_keeps_foreign_files():
sb = Sandbox()
try:
d = sb.path("shared")
os.mkdir(d)
for n in ["agent.json", "other.conf"] + DEMO_HP_COPIES:
open(os.path.join(d, n), "w").write("x")
rc, out = sb.run(prelude() + func("_purge_agent_config") +
'AGENT_CFG_DIR_DEFAULT="/etc/felhom-agent"\n_purge_agent_config "$SB/shared/agent.json"\n')
assert rc == 0, out
left = sorted(os.listdir(d))
assert left == ["other.conf"], "custom-path purge left/removed the wrong files: %s" % left
finally:
sb.close()
def test_purge_dry_run_touches_nothing():
sb = Sandbox()
try:
d = sb.path("etc-felhom-agent")
os.mkdir(d)
open(os.path.join(d, "agent.json"), "w").write("x")
rc, out = sb.run(prelude(dry=True) + func("_purge_agent_config") +
'AGENT_CFG_DIR_DEFAULT="$SB/etc-felhom-agent"\n_purge_agent_config "$SB/etc-felhom-agent/agent.json"\n')
assert rc == 0, out
assert os.path.exists(os.path.join(d, "agent.json")), "dry-run removed the config"
assert "rm -rf" in out, "dry-run did not print the removal: %s" % out
finally:
sb.close()
def test_seal_makes_old_copies_root_only():
sb = Sandbox()
try:
sb.stub("chown")
d = sb.path("etc-felhom-agent")
os.mkdir(d)
for n in DEMO_HP_COPIES:
p = os.path.join(d, n)
open(p, "w").write("x")
os.chmod(p, 0o644)
rc, out = sb.run(prelude() + func("_seal_old_agent_config") + '_seal_old_agent_config "$SB/etc-felhom-agent"\n')
assert rc == 0, out
log = sb.logged()
for n in DEMO_HP_COPIES:
p = os.path.join(d, n)
assert "chown root:root %s" % p in log, "%s not given to root: %s" % (n, log)
assert stat.S_IMODE(os.stat(p).st_mode) == 0o600, "%s mode %o" % (n, stat.S_IMODE(os.stat(p).st_mode))
assert os.path.exists(p), "seal DELETED %s (it may be an operator's backup)" % n
assert "now root-only" in out
finally:
sb.close()
def test_seal_is_called_when_the_user_is_created():
body = func("step_agent_install")
m = re.search(r'useradd --system[^\n]*"\$AGENT_USER"\n(.*?)\n\s*fi\n', body, re.S)
assert m and '_seal_old_agent_config "$AGENT_CFG_DIR_DEFAULT"' in m.group(1), \
"_seal_old_agent_config is not called right after the service user is created"
def test_uninstall_wiring():
body = func("run_uninstall")
stop = body.find("run systemctl stop felhom-agent")
purge = body.find('_purge_agent_config "$agent_cfg"')
wg = body.find("_teardown_wg_tunnel")
assert purge > 0, "run_uninstall does not call _purge_agent_config"
assert wg > 0, "run_uninstall does not call _teardown_wg_tunnel (R-276)"
assert stop > 0 and stop < wg, "the WireGuard teardown must run after the agent is stopped"
assert '"${agent_cfg}".bak*' not in body, "the old .bak* glob is back"
assert re.search(r'for bak in "\$\{AGENT_SUDOERS\}"\.\*', body), "sudoers copies are not removed"
# ── R-276: the WireGuard tunnel ──────────────────────────────────────────────────────────────────
def _wg_sandbox(active, enabled, conf, sticky=False):
sb = Sandbox()
# systemctl stub: is-active/is-enabled read flag files; disable --now clears them (unless sticky).
sb.stub("systemctl", r'''
case "$1" in
is-active) [ -e "$SB/wg.active" ]; exit $? ;;
is-enabled) [ -e "$SB/wg.enabled" ]; exit $? ;;
disable) rm -f "$SB/wg.enabled"; %s exit 0 ;;
esac
exit 0''' % ("" if sticky else 'rm -f "$SB/wg.active";'))
if active:
open(sb.path("wg.active"), "w").close()
if enabled:
open(sb.path("wg.enabled"), "w").close()
if conf:
open(sb.path("wg-felhom.conf"), "w").write("[Interface]\nPrivateKey = x\n")
return sb
WG_RUN = ('WG_UNIT="wg-quick@wg-felhom"; WG_CONF="$SB/wg-felhom.conf"; _WG_TEARDOWN_NOTE=""; _WG_PRESENT=false\n'
'_teardown_wg_tunnel\necho "PRESENT=$_WG_PRESENT NOTE=$_WG_TEARDOWN_NOTE"\n')
def test_wg_teardown_brings_the_tunnel_down():
sb = _wg_sandbox(active=True, enabled=True, conf=True)
try:
rc, out = sb.run(prelude() + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert "systemctl disable --now wg-quick@wg-felhom" in sb.logged(), sb.logged()
assert not os.path.exists(sb.path("wg.active")), "tunnel still active"
assert not os.path.exists(sb.path("wg-felhom.conf")), "wg-felhom.conf survived"
assert "PRESENT=true NOTE=\n" in out + "\n", out
assert "is down" in out
finally:
sb.close()
def test_wg_still_active_is_reported_not_claimed_down():
sb = _wg_sandbox(active=True, enabled=True, conf=True, sticky=True)
try:
rc, out = sb.run(prelude() + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert "STILL active" in out and "is down" not in out, out
assert "NOTE=wg-quick@wg-felhom is STILL active" in out, out
finally:
sb.close()
def test_wg_absent_is_a_noop():
sb = _wg_sandbox(active=False, enabled=False, conf=False)
try:
rc, out = sb.run(prelude() + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert "disable" not in sb.logged(), sb.logged()
assert "PRESENT=false" in out, out
finally:
sb.close()
def test_wg_dry_run_changes_nothing():
sb = _wg_sandbox(active=True, enabled=True, conf=True)
try:
rc, out = sb.run(prelude(dry=True) + func("_teardown_wg_tunnel") + WG_RUN)
assert rc == 0, out
assert os.path.exists(sb.path("wg-felhom.conf")) and os.path.exists(sb.path("wg.active"))
assert "disable --now wg-quick@wg-felhom" in out, out
finally:
sb.close()
def test_statement_names_the_tunnel_and_the_peer():
sb = Sandbox()
try:
sb.stub("pvesm", "exit 1")
rc, out = sb.run(prelude() + func("_uninstall_statement") +
'vmid=9201; pool_removed=false; _busy_mounts=(); _had_break_glass=false; REMOVE_GOLDEN=false\n'
'PVE_POOL=felhom; ISLAND_BRIDGE=vmbr9; WG_UNIT="wg-quick@wg-felhom"; WG_CONF=/etc/wireguard/wg-felhom.conf\n'
'_WG_PRESENT=true; _WG_TEARDOWN_NOTE=""\n_uninstall_statement full\n')
assert rc == 0, out
wiped, kept = out.split("KEPT", 1)
assert "WireGuard tunnel to the Felhom off-site endpoint" in wiped, out
assert "WireGuard PEER" in kept, out
assert "priv-apply" in wiped, out
finally:
sb.close()
# ── R-881: every file the config bundle installs is removed by the uninstall ─────────────────────
def test_uninstall_removes_every_bundle_file():
usect = section(r"^_guest_drive_note\(\)", r"^# run_adopt_pool")
# the uninstall names some paths through these variables — expand them before searching.
for var, val in [("AGENT_UNIT", "/etc/systemd/system/felhom-agent.service"),
("AGENT_SUDOERS", "/etc/sudoers.d/felhom-agent"),
("AGENT_BIN", "/usr/local/bin/felhom-agent")]:
usect = usect.replace("${%s}" % var, val).replace("$%s" % var, val)
missing = [p for p in BUNDLE_DESTS if p not in usect]
assert not missing, "the uninstall does not remove bundle file(s): %s" % missing
def test_bundle_list_is_current():
if not os.path.exists(AGENT_OS_APPLY):
print(" note: %s absent (CI) — the frozen list is checked, not its currency" % AGENT_OS_APPLY)
return
text = open(AGENT_OS_APPLY, encoding="utf-8").read()
m = re.search(r"^BUNDLE_FILES = \[(.*?)^\]", text, re.S | re.M)
assert m, "BUNDLE_FILES not found in felhom-os-apply"
dests = re.findall(r'^\s*\("(/[^"]+)"', m.group(1), re.M)
assert len(dests) >= 20, "parsed only %d bundle entries" % len(dests)
new = sorted(set(dests) - set(BUNDLE_DESTS))
assert not new, "the agent bundle installs file(s) this test (and maybe the uninstall) does not know: %s" % new
def main():
tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
fails = 0
for name, f in tests:
try:
f()
print("PASS", name)
except AssertionError as e:
fails += 1
print("FAIL", name, "--", e)
print("%d passed, %d failed" % (len(tests) - fails, fails))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())