diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 8f559b78..1d72cf05 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -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-, # .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 diff --git a/scripts/hostinstall-mode-harness.sh b/scripts/hostinstall-mode-harness.sh index 81ad3b9a..af5d4334 100644 --- a/scripts/hostinstall-mode-harness.sh +++ b/scripts/hostinstall-mode-harness.sh @@ -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 "" diff --git a/scripts/test_hostinstall.py b/scripts/test_hostinstall.py new file mode 100644 index 00000000..3d52f1e0 --- /dev/null +++ b/scripts/test_hostinstall.py @@ -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())