Compare commits
341 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd1b087db7 | |||
| 53d0c6bfc4 | |||
| 4d82591052 | |||
| 4618169036 | |||
| 1b14cfd0b4 | |||
| 0db77666c6 | |||
| dd2d1feb6e | |||
| 9dfd89cb94 | |||
| 4bb84fc3ca | |||
| cd6e26785a | |||
| 587dbb43fe | |||
| eb99144509 | |||
| 2c4efed5de | |||
| 75245a467c | |||
| 054e85a2bf | |||
| 4663df7ff3 | |||
| 14642e3c7b | |||
| 6b5dade4dc | |||
| 1c8a67eece | |||
| 1913e12031 | |||
| 966d8f41ff | |||
| 6be168d1a0 | |||
| d4eb259da2 | |||
| 21b0164fad | |||
| 2f4ccab166 | |||
| a58239f6de | |||
| b58d7bcf39 | |||
| 58b598b697 | |||
| 958e54f6a6 | |||
| 38176ada9d | |||
| d5c769173b | |||
| 50751b8901 | |||
| ff7f68e089 | |||
| 88b3cf03dd | |||
| f27f7a2659 | |||
| 8db92947cd | |||
| 367a503a0f | |||
| a18b18e5de | |||
| af1c21abc4 | |||
| c9a5cc664a | |||
| 023655370b | |||
| 9842c52853 | |||
| 1c2664b0c1 | |||
| 5bca7bfc9a | |||
| 043c7622bc | |||
| 765d8b3168 | |||
| edde8a01ca | |||
| a7ef497cc4 | |||
| 5acf1033a2 | |||
| e4f22f4c4f | |||
| 13ca2d96b2 | |||
| 005083b558 | |||
| 0fabc15896 | |||
| a7421b09c7 | |||
| 3d955e4edd | |||
| a667c269c7 | |||
| 68bcebe493 | |||
| 739b3c3b58 | |||
| dfd5d731ee | |||
| 36ed6594d4 | |||
| 271aa3d9ed | |||
| ed97232598 | |||
| 643899c191 | |||
| 21fee69154 | |||
| c230258542 | |||
| eba040d0be | |||
| a452dc3314 | |||
| 935904fa4e | |||
| 332235bfd1 | |||
| 3f58121b98 | |||
| 71e1777369 | |||
| 98adb72204 | |||
| 0e8fd814ba | |||
| c0966d753d | |||
| 08b55a1015 | |||
| 042a401e71 | |||
| edac02ec9f | |||
| d4a7a5bad3 | |||
| dcf3a5530b | |||
| ab8f682f0b | |||
| b2ca63ee9f | |||
| 8c55ac7fda | |||
| d81fddc7b1 | |||
| 8fc44d8d9e | |||
| 25c30ba625 | |||
| f22f70ce58 | |||
| 9596d5a8d0 | |||
| f222a7b366 | |||
| 8208a80cc7 | |||
| ac112c956e | |||
| 9127f547f9 | |||
| 9effc85a47 | |||
| a659e5dc09 | |||
| c040c180e9 | |||
| 1c3a3ef9ad | |||
| adf7882f7d | |||
| 4e6171442f | |||
| 3c174bc6f2 | |||
| c20814e6c2 | |||
| bcb8dad2aa | |||
| dbd6d4c57a | |||
| b6300250aa | |||
| 47eb0bf967 | |||
| 0b55a11667 | |||
| a527b29ac3 | |||
| bc4eda926b | |||
| bec4bac076 | |||
| a8b773f892 | |||
| 474b858c0b | |||
| 0df72ea643 | |||
| cb692f8788 | |||
| 461eaf42c1 | |||
| fa9c7fe198 | |||
| 1e60e88eb2 | |||
| added9d226 | |||
| 300f06722b | |||
| 3d1323cc09 | |||
| e5e8f3920a | |||
| a6e8bcb475 | |||
| 301c84d9b5 | |||
| bd9e777f41 | |||
| 98ea1ed49c | |||
| 4449118783 | |||
| 0c22b9bbf3 | |||
| 7f07393623 | |||
| b1697874ec | |||
| 5a72a4b59c | |||
| b3446213df | |||
| 3bf0110697 | |||
| c12b512316 | |||
| 4c40846769 | |||
| ced60ddf76 | |||
| ca0b169a4e | |||
| e04b75e1f8 | |||
| 7545af8a2c | |||
| 26664d6d51 | |||
| 223c235f01 | |||
| 72d6132907 | |||
| 2bf9354c0e | |||
| b99d02b7a9 | |||
| d880289b06 | |||
| a34aac64d0 | |||
| 9d9e4a5ea7 | |||
| c983a25609 | |||
| effff53f99 | |||
| 4b7c5bf128 | |||
| 8675bff3da | |||
| fd4e177216 | |||
| 1c75a45a42 | |||
| 084fc691f5 | |||
| 915642aaaa | |||
| 8033a522cd | |||
| b7cbded429 | |||
| 72d7f05bf1 | |||
| bd4bced771 | |||
| 567cf9f401 | |||
| dc70e15d28 | |||
| fee1fcfccd | |||
| 027948bf3f | |||
| 734f45c422 | |||
| c618fc69f7 | |||
| ef18454e65 | |||
| 312fd5ee29 | |||
| 03edb40fc2 | |||
| e2b6c63ea2 | |||
| fb248961c6 | |||
| 0daae92c4f | |||
| 4ba1b144d6 | |||
| ceca355493 | |||
| c9f963d9f4 | |||
| f31a76f788 | |||
| 84f3f7ddb1 | |||
| 3f37c5fc23 | |||
| 3e760a559a | |||
| 2a024b6039 | |||
| 0db09bf34f | |||
| 3f382bf762 | |||
| cc93dae792 | |||
| b9356d60ab | |||
| 55ade9e254 | |||
| 39ec76dc5a | |||
| 5ff5f8e0ab | |||
| b52680ab47 | |||
| 3825664aed | |||
| 6448b80361 | |||
| 67e2144f11 | |||
| e593fb277a | |||
| 4f8d2fcb57 | |||
| 91f6a26490 | |||
| 066e3bf153 | |||
| 52098302ab | |||
| 05f22a9ab4 | |||
| 7639ab5c4b | |||
| 33e3443545 | |||
| 0573ec95c1 | |||
| 1502ca644a | |||
| 06e0bc9c25 | |||
| 79eb0a8486 | |||
| 63aa63d0d6 | |||
| 7aeb7caefe | |||
| d077c8e4dd | |||
| 1c078b943d | |||
| 6e38e2f921 | |||
| 81954c3d2a | |||
| bf8e3be3f4 | |||
| 61f9b4dcc3 | |||
| 3844df7c23 | |||
| bb548e3c5a | |||
| 9b0d6c2c82 | |||
| de6b72651b | |||
| 8a4ccab3e6 | |||
| 61c89a7efa | |||
| 25024d9dda | |||
| 19582046ba | |||
| 55888e5686 | |||
| c3020ee3a8 | |||
| a8d14fc384 | |||
| 29aeaa6bb4 | |||
| aaa276a7b9 | |||
| 2dcee2d7e8 | |||
| b17d07bf2d | |||
| 6f14b66191 | |||
| 4725396c81 | |||
| 3b752b48f2 | |||
| 166a1c8bcb | |||
| db95d5106e | |||
| 64b7436015 | |||
| 459dad954b | |||
| b908b9a8e5 | |||
| fbe113011d | |||
| 8abc1b8852 | |||
| c8d7ec6bb5 | |||
| 3e39dbb4f8 | |||
| a621f4c5a0 | |||
| 437f096d9d | |||
| 1e205840dc | |||
| 2b17419e60 | |||
| 2da4c38773 | |||
| a356d6def4 | |||
| cff9284453 | |||
| 038f0ecd5d | |||
| 281c7b7e3f | |||
| 05be509e6e | |||
| 9b4ae3df28 | |||
| a2374ca679 | |||
| 26c6d1e4d1 | |||
| 3a9be73875 | |||
| 44cdf82631 | |||
| 2a4affc3a8 | |||
| 76f9cb5977 | |||
| f43697c881 | |||
| 8e6d00a57f | |||
| 20e03902c8 | |||
| 4777f8a221 | |||
| 4cd1d024e9 | |||
| a2a76e7624 | |||
| b566fbe57f | |||
| 238ec02754 | |||
| 1c37a1689e | |||
| 8572a0f5f4 | |||
| 9370b6751d | |||
| d96e5bddd0 | |||
| d17b5ab45d | |||
| 77fa5af592 | |||
| 4d30444914 | |||
| e8dab27653 | |||
| 239f5f6440 | |||
| d7d68fdd83 | |||
| 5ab159521d | |||
| 716cbcd705 | |||
| b66c137aa7 | |||
| 7fb7ef5c3d | |||
| 109dd853a3 | |||
| 9ff0410755 | |||
| 9580c8c75d | |||
| bbb284e378 | |||
| 237b85f420 | |||
| bc4f2b9168 | |||
| bb1692cbfb | |||
| 7336a87514 | |||
| 13a02e4871 | |||
| 5f87e9099e | |||
| c1d04c28c1 | |||
| d1bd44d2d5 | |||
| 7ae82e1d5d | |||
| 832b73e6e8 | |||
| 15f7529a1c | |||
| 9e3513557f | |||
| e57fbc9b80 | |||
| 4734d4a132 | |||
| 790adb07a9 | |||
| a43e9813ad | |||
| 621a09a1c5 | |||
| 79ba2f158a | |||
| e17385a332 | |||
| 1799fcd0e4 | |||
| cfebdf5cd4 | |||
| de34d170d2 | |||
| e5a18194f4 | |||
| e4dfe5ccc7 | |||
| 89e9f98a95 | |||
| 588fed2aa9 | |||
| 8ecf8929fb | |||
| aa4dfb75ea | |||
| 9a0e7e168b | |||
| 570410cd1a | |||
| fe7b3c4ab7 | |||
| c17cfde236 | |||
| 4d9e76e66a | |||
| 33dfd9afb3 | |||
| e51b3a2f66 | |||
| 3fecf4c713 | |||
| fae11020a5 | |||
| 47dd0bd244 | |||
| 6e036e4c75 | |||
| 57405c1a99 | |||
| 9f6753de0f | |||
| 6e86483185 | |||
| 7eea638b92 | |||
| 697e67c85e | |||
| dcd8a8eff4 | |||
| 766500dfc3 | |||
| faba8e4ff7 | |||
| 280a7f80e5 | |||
| b527430ec7 | |||
| e548ab57fe | |||
| 66bd015ee5 | |||
| 77b4f21450 | |||
| 9d6e49236c | |||
| 27b68f043b | |||
| 1af21a6cac | |||
| 05c450147c | |||
| 605ce25f58 | |||
| 237452c8c6 | |||
| 11c91a0dde | |||
| e68a7af4d3 | |||
| ab77fa3544 | |||
| f0fee7e193 | |||
| 43b7e96905 | |||
| 7dcc80fde8 | |||
| a042316d6d |
@@ -0,0 +1,116 @@
|
|||||||
|
# gates — re-run this repo's gate entry point on every push, on a machine that does not care who
|
||||||
|
# pushed or what they typed.
|
||||||
|
#
|
||||||
|
# *** THIS REPORTS. IT CANNOT REFUSE. ***
|
||||||
|
#
|
||||||
|
# felhom repos push straight to `main` with no pull request, so there is no merge for a status
|
||||||
|
# check to stand at. The refusing half is `.githooks/pre-push`, which is local to a clone and which
|
||||||
|
# `git push --no-verify` skips; this half is what notices when that happened. Neither half is the
|
||||||
|
# whole thing, and both are named in felhom.eu documentation/backlog/OPEN-ITEMS.md R-168.
|
||||||
|
#
|
||||||
|
# NO `uses:` STEP ANYWHERE, deliberately: JavaScript actions need a node runtime in the runner, and
|
||||||
|
# the runner is a host-mode container with python3 and git and nothing else (see
|
||||||
|
# homelab-manifests/gitea-system/act-runner.yaml for why it is not privileged). Probe P3 measured
|
||||||
|
# that a plain `git fetch` of the pushed SHA from the in-cluster Gitea service is enough.
|
||||||
|
#
|
||||||
|
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
|
||||||
|
# layer up. That is the last step, and it runs ONLY on failure.
|
||||||
|
name: gates
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gates:
|
||||||
|
runs-on: felhom-gates
|
||||||
|
steps:
|
||||||
|
- name: Fetch the pushed commit and the sibling clone it needs
|
||||||
|
# This repo's entry point invokes a SHARED checker that lives in the felhom.eu clone next
|
||||||
|
# door and is deliberately never copied here — so CI has to reproduce the workspace's
|
||||||
|
# sibling layout or the gate fails closed with "gate is MISSING". The sibling is also
|
||||||
|
# needed for CONTENT: this repo's REUSE.md cites a path that lives in the hub.
|
||||||
|
run: |
|
||||||
|
# Shallow, and pinned to the exact SHA that was pushed — not to the branch tip,
|
||||||
|
# which can move under us if two pushes race.
|
||||||
|
mkdir -p ws/felhom-agent
|
||||||
|
cd ws/felhom-agent
|
||||||
|
git init -q .
|
||||||
|
git remote add origin http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom-agent.git
|
||||||
|
git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
echo "checked out $(git rev-parse HEAD)"
|
||||||
|
cd .. && git clone -q --depth 1 http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git felhom.eu
|
||||||
|
echo "sibling felhom.eu present at $(cd felhom.eu && git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
- name: Run the gate entry point
|
||||||
|
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy. The
|
||||||
|
# exit code IS the result: no `|| true`, no pipe that could swallow it.
|
||||||
|
#
|
||||||
|
# THE FULL SET, NOT `--fast` (R-115, 2026-08-03). `--fast` means "no network and no
|
||||||
|
# container runtime" and exists for `.githooks/pre-push`, where a push must not fail
|
||||||
|
# because Gitea blinked or because someone is on a train. CI is the opposite machine: it
|
||||||
|
# has the network, it is not in anyone's way, and it is the half that emails. The
|
||||||
|
# published-versions gate — the R-115 mechanism, which asks Gitea whether a released
|
||||||
|
# version can actually be downloaded — is network-bound and therefore runs ONLY here.
|
||||||
|
# Leaving `--fast` in place would have registered that gate and never run it, which is the
|
||||||
|
# built-but-never-wired failure this project has shipped four times.
|
||||||
|
env:
|
||||||
|
# In-cluster, so the check does not depend on public DNS or the ingress TLS chain.
|
||||||
|
GITEA_BASE: http://gitea.gitea-system.svc.cluster.local:3000
|
||||||
|
run: cd ws/felhom-agent && python3 scripts/agent_gates.py
|
||||||
|
|
||||||
|
- name: Alarm on failure
|
||||||
|
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
||||||
|
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
|
||||||
|
# is exactly the shape R-29 filed against. So the run sends its own alarm, on the project's
|
||||||
|
# existing transactional path (Resend, the same one the hub uses), and prints the provider's
|
||||||
|
# accepted id so "a message left the machine" is an observable, not an assumption.
|
||||||
|
#
|
||||||
|
# Pure python3 and urllib, NOT curl: the runner image carries python3 and git and nothing
|
||||||
|
# else on purpose, and the first version of this step died on `curl: command not found`.
|
||||||
|
# Reaching for a bigger image to send one HTTP request would have been the wrong trade.
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, sys, urllib.request, urllib.error
|
||||||
|
|
||||||
|
key = os.environ.get("RESEND_API_KEY", "")
|
||||||
|
if not key:
|
||||||
|
sys.exit("ALARM FAILED: RESEND_API_KEY is empty — the alarm cannot be sent, and a "
|
||||||
|
"silent alarm is worse than none. Set the user-level Actions secret.")
|
||||||
|
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "?")
|
||||||
|
sha = os.environ.get("GITHUB_SHA", "?")
|
||||||
|
run = os.environ.get("GITHUB_RUN_NUMBER", "?")
|
||||||
|
srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu")
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"from": "Felhom CI <monitoring@felhom.eu>",
|
||||||
|
"to": ["admin@felhom.eu"],
|
||||||
|
"subject": "[felhom CI] gates FAILED in %s" % repo,
|
||||||
|
"text": (
|
||||||
|
"The gate entry point exited non-zero.\n\n"
|
||||||
|
"Repository : %s\n"
|
||||||
|
"Commit : %s\n"
|
||||||
|
"Run : %s/%s/actions/runs/%s\n\n"
|
||||||
|
"The failing gate names itself in the run log.\n\n"
|
||||||
|
"If the local pre-push hook was GREEN for this commit, then CI and the hook\n"
|
||||||
|
"disagree - that is a finding about the gates themselves, not about CI, and it\n"
|
||||||
|
"outranks whatever the push was for.\n"
|
||||||
|
) % (repo, sha, srv, repo, run),
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.resend.com/emails", data=body, method="POST",
|
||||||
|
headers={"Authorization": "Bearer %s" % key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
# Cloudflare fronts api.resend.com and BLOCKS the default
|
||||||
|
# "Python-urllib/3.x" agent with its own 403 (error 1010) — which looks
|
||||||
|
# exactly like an auth failure and is not one. Measured 2026-08-02.
|
||||||
|
"User-Agent": "felhom-ci/1.0"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
print("RESEND-ACCEPTED id=%s" % json.load(r)["id"])
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit("ALARM FAILED: Resend returned HTTP %s: %s" % (e.code, e.read().decode()[:300]))
|
||||||
|
PY
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.)
|
||||||
|
#
|
||||||
|
# Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no
|
||||||
|
# container runtime, so a push stays a push and never pulls images or starts containers. The slow
|
||||||
|
# gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and
|
||||||
|
# the bypass becomes the habit.
|
||||||
|
#
|
||||||
|
# BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is
|
||||||
|
# equally consistent with "gates green" and "hook never fired", so a passing push says so out loud.
|
||||||
|
#
|
||||||
|
# HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide:
|
||||||
|
# * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once:
|
||||||
|
# git config core.hooksPath .githooks
|
||||||
|
# Any manual entry-point run WARNS when the clone is unarmed.
|
||||||
|
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
|
||||||
|
# hatch that cannot be reached is one that gets removed the first time it is
|
||||||
|
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
|
||||||
|
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
|
||||||
|
#
|
||||||
|
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
|
||||||
|
# is the repo root whether `git push` is issued from the root or from any subdirectory. The
|
||||||
|
# explicit rev-parse below does not depend on that.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||||
|
echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
cd "$root" || exit 1
|
||||||
|
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2
|
||||||
|
echo " pass by default. Install python3, or push with --no-verify and say so." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-push [felhom-agent]: running scripts/agent_gates.py --fast ..."
|
||||||
|
python3 "scripts/agent_gates.py" --fast
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo "pre-push [felhom-agent]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
|
||||||
|
echo " 'git push --no-verify' and state that you did in the session report." >&2
|
||||||
|
else
|
||||||
|
echo "pre-push [felhom-agent]: gates OK - push proceeding."
|
||||||
|
fi
|
||||||
|
exit $rc
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# build output
|
||||||
|
/felhom-agent
|
||||||
|
/felhom-agent.exe
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# local config that may carry a real token secret
|
||||||
|
/agent.json
|
||||||
|
*.local.json
|
||||||
|
|
||||||
|
# go
|
||||||
|
/vendor/
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# AGENT-001 fix — inline customer-confirmed wipe: anti-retarget re-resolution
|
||||||
|
|
||||||
|
**Branch:** `fix/agent-001-wipe-durable-reresolve` (off `main` @ d17b5ab) — **PENDING REVIEW, NOT deployed.**
|
||||||
|
**Status:** branch-only. No agent build, no deploy, no golden rebake, no re-provision. That is reserved for a supervised session (this fix touches the destructive storage-wipe path).
|
||||||
|
|
||||||
|
## The bug
|
||||||
|
|
||||||
|
`internal/localapi/disks.go` `handleDiskFormat`, the data-bearing **customer-confirmed** branch
|
||||||
|
(`dec.Allowed`), inspected and gate-bound the device by its durable id but then called
|
||||||
|
`s.disks.Format(ctx, req.Device, …)` on the **caller-supplied mutable `/dev` path**. The durable id
|
||||||
|
only bound the *confirmation*; it was never re-resolved to the device just before `mkfs`. If a USB
|
||||||
|
re-enumeration reassigned `/dev/sdbN` to a **different physical disk** between inspection and `mkfs`
|
||||||
|
(the classify→mkfs TOCTOU), the wipe hit the wrong drive — data loss on an unintended disk.
|
||||||
|
|
||||||
|
The signed-jobs path (`internal/signedjobs/wipe.go` `WipeExecutor.Execute`) already does this
|
||||||
|
correctly: resolve durable→device, re-derive the device's durable id and require an exact match,
|
||||||
|
re-inspect, then format the *re-resolved* device. The inline path did not.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- **New `internal/localapi/wipe_reresolve.go`:**
|
||||||
|
- `antiRetargetResolve(durableID, resolve, derive, inspect)` — pure, injected-deps helper mirroring
|
||||||
|
`WipeExecutor`: refuse empty durable id; resolve→current device; re-derive and require exact
|
||||||
|
match (a `/dev` node now pointing at a different disk derives a different id → refuse); re-inspect
|
||||||
|
and require still-`DataBearing()`. Returns the **re-resolved device** to format.
|
||||||
|
- `(*Server).reresolveDurableForWipe` wires it with the real `storage.ResolveDurableDevice` /
|
||||||
|
`storage.DeviceDurableID` / `s.disks.InspectDevice`.
|
||||||
|
- **`internal/localapi/disks.go`:** the `dec.Allowed` branch now calls `s.reresolveWipe(ctx, deviceDurable)`
|
||||||
|
and formats the **returned** device (never `req.Device`). On any refusal it returns `409 Conflict`
|
||||||
|
with the reason and does **not** call `mkfs`. The response/log now reference the re-resolved device.
|
||||||
|
- **`internal/localapi/server.go`:** new injectable field `reresolveWipe`, defaulted in `NewServer` to
|
||||||
|
`s.reresolveDurableForWipe`. Production behaviour unchanged (same real functions); the seam exists so
|
||||||
|
the inline path is unit-testable without real `/dev/disk/by-*`.
|
||||||
|
- **Tests:** `wipe_reresolve_test.go` covers `antiRetargetResolve` directly — happy path, empty durable,
|
||||||
|
no-longer-resolves, **durable-id mismatch (the core retarget case)**, no-longer-data-bearing,
|
||||||
|
re-inspect error. The existing integration test `TestFormat_DataBearing_UserDataConfirmed_Formats`
|
||||||
|
stubs `reresolveWipe` (the test helper sets it). Full `go build/vet/test ./...` green at the branch HEAD.
|
||||||
|
|
||||||
|
## Why it can't break legitimate wipes
|
||||||
|
|
||||||
|
`storage.DeviceDurableID` produces `byid:`/`byuuid:` ids and `storage.ResolveDurableDevice` accepts
|
||||||
|
exactly those — they round-trip. A normal user-data drive resolves back to itself, re-derives the same
|
||||||
|
id, and inspects data-bearing → format proceeds. Only three new refusals occur, all correct:
|
||||||
|
durable id gone, durable-id mismatch (retarget), or no-longer-data-bearing.
|
||||||
|
|
||||||
|
## Review checklist for the supervised merge+deploy+golden-rebake session
|
||||||
|
|
||||||
|
1. Confirm `antiRetargetResolve` refuses on mismatch/gone/blank/empty (run `go test ./internal/localapi/ -run AntiRetarget -v`).
|
||||||
|
2. Sanity-check `DeviceDurableID`↔`ResolveDurableDevice` scheme round-trip on the real demo USB (`felhom-usb`) so a genuine customer wipe still resolves+matches (read-only `DeviceDurableID` on the live device; do NOT wipe).
|
||||||
|
3. Confirm the `409 Conflict` refusal surfaces sensibly in the controller UI (controller `agentapi` maps the format response — verify it doesn't treat 409 as a hard error that hides the reason).
|
||||||
|
4. Bump agent version + CHANGELOG, build, golden-rebake, re-provision per the agent deploy runbook.
|
||||||
|
5. Live-validate one happy customer-confirmed wipe on a demo scratch drive (re-resolve matches → mkfs) and confirm the audit log line shows the re-resolved device == the confirmed durable id.
|
||||||
+4910
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
|||||||
|
# CLAUDE.md — `felhom-agent`
|
||||||
|
|
||||||
|
> Loads when Claude Code touches this repo. Stable orientation only — **current state lives in
|
||||||
|
> `CONTEXT.md` and the top of `CHANGELOG.md`**, never here. Cross-repo orientation: workspace-root
|
||||||
|
> `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`.
|
||||||
|
|
||||||
|
## What this repo is
|
||||||
|
|
||||||
|
`felhom-agent` is the operator-tier **host agent** that runs on each Proxmox host and owns **all**
|
||||||
|
Proxmox interaction: provision/restore guests, host storage, backup/restore orchestration, the hub
|
||||||
|
control loop, and a narrow per-guest local API. It is the **most privilege-sensitive** component.
|
||||||
|
|
||||||
|
- Renamed former `proxmox-controller` repo.
|
||||||
|
- **Distinct from `felhom-controller`** — that is the *in-guest* controller (Docker-only, no Proxmox
|
||||||
|
creds). Do not confuse them.
|
||||||
|
- Control plane, not data plane: if the agent dies, apps keep serving; only management degrades.
|
||||||
|
|
||||||
|
## Read before writing code
|
||||||
|
|
||||||
|
- **`REUSE.md`** — canonical helpers, format-safety guards, traps, seams. Check it first; update it
|
||||||
|
in the same commit that changes a shared helper or pattern.
|
||||||
|
- `CONTEXT.md` (current state + open threads) and the top `CHANGELOG.md` entry (authoritative history).
|
||||||
|
- Design doc: `felhom.eu/documentation/architecture/03-host-agent.md` (locked). Platform facts:
|
||||||
|
`felhom.eu/documentation/proxmox-platform.md` + `tests/phase{0,1-2,3,4}-findings.md`.
|
||||||
|
|
||||||
|
## Layout (verified against the tree)
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/felhom-agent/ main + flags + --selftest modes + the daemon entry
|
||||||
|
cmd/felhom-opsign/ offline operator signing CLI (SSHSIG)
|
||||||
|
internal/authz/ operator signed-op verifier (SSHSIG) + durable FileNonceStore
|
||||||
|
internal/backup/ vzdump backup runner + restore-test scheduler + report store
|
||||||
|
internal/capability/ live sudo-policy capability probe (degradation visibility)
|
||||||
|
internal/config/ JSON config + FELHOM_AGENT_* env overlay; secrets redacted (Redacted())
|
||||||
|
internal/desired/ hub desired-state syncer (envelope observer)
|
||||||
|
internal/escrow/ PBS-key escrow (zero-knowledge recovery code)
|
||||||
|
internal/guesthook/ pre-start self-heal hookscript install
|
||||||
|
internal/hub/ daemon: HostReport collector + Bearer client + resilient Loop
|
||||||
|
internal/lanresolver/ split-horizon DNS on guest IP change (dnsmasq RESTART, not reload)
|
||||||
|
internal/localapi/ per-guest local API: token store, disks/format, guest binds, controller swap,
|
||||||
|
stale-lock recovery, pinned self-signed leaf
|
||||||
|
internal/log/ slog setup
|
||||||
|
internal/pbs/ PBS-API client (fingerprint-pinned) + verify maintenance loop
|
||||||
|
internal/provision/ guest bootstrap back-half (token mint → bootstrap.json → pct bind)
|
||||||
|
internal/proxmox/ API-first Client + fenced root-CLI Privileged + UPID WaitTask
|
||||||
|
internal/reconcile/ reconcile engine + reversibility gate + op journal + crash recovery
|
||||||
|
internal/signedjobs/ operator-signed destructive executors (wipe, decommission)
|
||||||
|
internal/storage/ storage observer + durable ids + role/claim classifiers + SudoHostOps + watchdog
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build / run
|
||||||
|
|
||||||
|
- Module `gitea.dooplex.hu/admin/felhom-agent`; binary `felhom-agent` (`cmd/felhom-agent/`).
|
||||||
|
- **Pure Go stdlib + `golang.org/x/crypto` only** — no web frameworks. `go.mod` directive go 1.25.0;
|
||||||
|
DooPlex (192.168.0.180, where CC runs) has the Go toolchain and is on the same LAN as the demo
|
||||||
|
host — build and run live tests locally.
|
||||||
|
- Version via `-ldflags "-X main.version=<v>"`; `--version` flag. Bump on meaningful changes + CHANGELOG entry.
|
||||||
|
- **Full build/deploy/publish runbook: use the `felhom-build-deploy` skill.** Summary:
|
||||||
|
|
||||||
|
> **Clean-tree gate before any build:** `git status --porcelain` must be empty and
|
||||||
|
> `git rev-parse HEAD` must equal `git rev-parse origin/main` in the repo being built. An unpushed
|
||||||
|
> change does not exist — never build a dirty or unpushed tree. The `git pull` in the build step
|
||||||
|
> stays (it is a no-op when you work in this tree, and load-bearing if anything was pushed from
|
||||||
|
> elsewhere).
|
||||||
|
|
||||||
|
> **RELEASING IS ONE COMMAND, AND IT PUBLISHES (R-115).** There used to be a raw `go build` line
|
||||||
|
> here and a *separate* "Publish" row, so publishing was a step someone had to remember — and it was
|
||||||
|
> **forgotten three times in five days**, the last leaving agent v0.120.0 deployed on both demo hosts
|
||||||
|
> and undownloadable, where a documented-path reinstall would have silently downgraded them while
|
||||||
|
> reporting success. Do not hand-roll the build: the script also creates the `v<version>` git TAG
|
||||||
|
> that `felhom-host-install.sh` fetches this version's sixteen config files from (R-183), and it
|
||||||
|
> verifies by an **independent download** rather than trusting the publish step's own output.
|
||||||
|
> `scripts/publish-agent.sh` still exists and is still correct — the release script CALLS it rather
|
||||||
|
> than reimplementing it.
|
||||||
|
|
||||||
|
| Step | Where | One-liner |
|
||||||
|
|---|---|---|
|
||||||
|
| **Release** (build + tag + publish + verify) | DooPlex (local) | `GITEA_USER=admin GITEA_TOKEN=<tok> scripts/release-agent.sh <ver>` — refuses a dirty/unpushed tree and refuses to re-release an existing version |
|
||||||
|
| Copy | local → felhom-pve | `scp /tmp/felhom-agent-<v> felhom-pve:/tmp/` (one hop) |
|
||||||
|
| Deploy | felhom-pve | backup `.bak-<old>` → `install -m0755` → `systemctl restart felhom-agent` (non-root `felhom-agent` user, config `/etc/felhom-agent/agent.json`) |
|
||||||
|
| Ship configs | felhom-pve | sudoers (`/etc/sudoers.d/felhom-agent`) + guarded-mkfs wrapper WITH the binary when `configs/` changed |
|
||||||
|
| **Vouch** | hub operator UI | Configs → Day-0 artifacts. **Deliberately NOT automated** — vouching is what points machines at a version, and it stays your act (prove-then-vouch) |
|
||||||
|
| Verify | felhom-pve | `felhom-agent --version` + journal (clean ReassertGuestBinds, no capability degradation) |
|
||||||
|
|
||||||
|
## Proxmox model (the load-bearing rules)
|
||||||
|
|
||||||
|
- **API-first** via a scoped `FelhomAgent` token. Raw root-CLI is **fenced to exactly 3 exceptions**:
|
||||||
|
keyctl `pct create` (golden image), USB mount/fstab, SMART/sensors. `Client` never shells out;
|
||||||
|
`Privileged` never makes HTTP calls (asserted by `routing_test.go`). Keep that fence.
|
||||||
|
- **Every mutating op is async** → returns a UPID → `WaitTask` asserts `exitstatus == "OK"`. A 200 on
|
||||||
|
the POST is **not** success; authorization can fail at task execution.
|
||||||
|
- **TLS:** SHA-256 leaf-cert pinning (self-signed host cert). No insecure default.
|
||||||
|
- **Privsep token gotcha:** a `--privsep 1` token's rights = intersection of the backing user's perms
|
||||||
|
AND the token's ACLs — the role must be granted on **both**, or every call 403s.
|
||||||
|
- Destructive ops go through the reconcile gate / signed-jobs path — never call `Client.DestroyLXC`/
|
||||||
|
`Vzdump`/`SetConfig` ad-hoc (REUSE.md §3).
|
||||||
|
|
||||||
|
## Demo host (for live tests)
|
||||||
|
|
||||||
|
Node **`demo-felhom`**, API `https://192.168.0.162:8006`. SSH alias `felhom-pve` (root@pam) —
|
||||||
|
available to CC as plain `ssh felhom-pve`. A **second demo node `demo-hp`** (HP t740, node name
|
||||||
|
`felhom-host`, `ssh demo-hp` — no baked key; break-glass root via hub `host_recovery/demo-hp-bb76ea` +
|
||||||
|
`sshpass`) is the **designated drill+build VM host** per the 2026-07-25 operator ruling, and that ruling
|
||||||
|
is **realized** — it hosts drill VM `300` (`drill-r50`), so **start there**, not on DooPlex. (The
|
||||||
|
historical golden-bake `drill.qcow2` still lives on DooPlex and is a bake fixture, not a drill target.)
|
||||||
|
**Which box is safe to break, and what may be done to each:
|
||||||
|
`felhom.eu/documentation/runbooks/target-selection.md`** — read it before any destructive test. Both
|
||||||
|
nodes + the break-glass recipe: `felhom.eu/documentation/operations/nodes.md`. The agent pins the served leaf cert — verify the
|
||||||
|
fingerprint still matches before a live run. Selftest modes (run locally on DooPlex, pointed at the
|
||||||
|
demo API): `--selftest[=read|task|hub|storage|backup|restore-test|pbs-verify]`; no flag = the daemon.
|
||||||
|
|
||||||
|
> **TEMPORARY — felhom-pve is at a remote site (until ~2026-08-02).** The home-LAN literal
|
||||||
|
> `192.168.0.162` is NOT reachable from DooPlex for the duration. Access via Tailscale:
|
||||||
|
> felhom-pve = 100.70.170.35; the `Host felhom-pve` entry in `~/.ssh/config` on DooPlex already
|
||||||
|
> points there (the direct-LAN path stays available as `Host felhom-pve-lan`). Delete this block on
|
||||||
|
> return. All documented `ssh felhom-pve` / `pct exec` workflows are unchanged. Path is **direct**
|
||||||
|
> (not DERP), ~37 ms rtt per hop. At the remote site the host is on **DHCP**; re-check its address
|
||||||
|
> rather than trusting one written here (`ip -br addr show vmbr0` — it read `192.168.0.162/24` on
|
||||||
|
> 2026-07-30, and `felhom-pve-lan` from DooPlex is still `No route to host`). Details + findings:
|
||||||
|
> `felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md`
|
||||||
|
>
|
||||||
|
> **The "agent does not run at the remote site" warning this block used to carry is RETRACTED
|
||||||
|
> (2026-07-30) — it was true before R-50 and is false now.** `localapi` no longer binds a LAN literal:
|
||||||
|
> since the R-50 island migration (2026-07-25) it binds `169.254.253.1:8443` on `vmbr9`, which is
|
||||||
|
> location-independent by design, and `proxmox.endpoint` is `https://127.0.0.1:8006`. Verified live:
|
||||||
|
> `systemctl is-active felhom-agent` → `active`, `felhom-agent --version` → 0.115.0, and the per-guest
|
||||||
|
> local API answered `GET /disks` over the island. No config edit and no Viktor GO are outstanding.
|
||||||
|
|
||||||
|
> **Legacy: Windows workstation.** Until 2026-07-19 CC ran on Windows 11; `pct` commands over SSH
|
||||||
|
> needed `export MSYS_NO_PATHCONV=1`, and every remote command used
|
||||||
|
> `SSH=/c/Windows/System32/OpenSSH/ssh.exe`. Agent deploy was a two-hop copy via the Windows box
|
||||||
|
> (`cygpath -w` for the local scp path; CRLF hazard on config files).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Trunk-based — no branches
|
||||||
|
|
||||||
|
All shippable work commits **directly to `main`**; `main` equals what is deployed.
|
||||||
|
- Report-only artifacts (audits, findings, fixspecs) → `felhom.eu/documentation/` (`audits/`, `backlog/`).
|
||||||
|
- Risky/supervised fixes are spec'd, then implemented **during the supervised session, on `main`**.
|
||||||
|
- Unattended escape hatch: if a fix can't be cleanly verified/shipped, revert + report — never park on a branch.
|
||||||
|
|
||||||
|
> **In every repository where you make a change, update both files in that repo:**
|
||||||
|
> - **`CHANGELOG.md`** — cumulative log, newest on top.
|
||||||
|
> - **`REPORT.md`** — **overwrite** with the most recent implementation/validation summary only.
|
||||||
|
>
|
||||||
|
> **Never write secrets** into any committed file — reference them as "stored out-of-band".
|
||||||
|
|
||||||
|
- Code quality: verify generated code for bugs/edge cases; add debug logging; **ask rather than
|
||||||
|
guess** when you'd otherwise invent input/output.
|
||||||
|
- **A health check issues no block I/O** — no `statfs`, no `getdents`, no read, write or `fsync`, **not
|
||||||
|
even behind a timeout**. Liveness is decided from `/proc` and kernel state. The full rule + the
|
||||||
|
measurement lives in `felhom.eu/CLAUDE.md` "Code quality rules"; it is repeated here because health
|
||||||
|
checks are written in THIS repo and that file does not load in an agent-only session. R-117 spike §6.3.
|
||||||
|
- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit).
|
||||||
|
- **Run `python3 scripts/agent_gates.py` from the repo root after ANY change in this repo.** It is
|
||||||
|
the ONE entry point for this repo's gates. Today it runs one — `reuse_refs_check` over this
|
||||||
|
repo's `REUSE.md` — and it exists at one gate on purpose: a census on 2026-08-02 found that every
|
||||||
|
check a `CLAUDE.md` names was passing and two of the four nobody is told to run were failing, and
|
||||||
|
this repo was the extreme case, with nothing running against it at all and 90 cited paths checked
|
||||||
|
by no one. It grows when the agent grows a second check. `--fast` selects the gates that touch no
|
||||||
|
network and no container runtime; today that is all of them. A missing gate is a FAILURE, never a
|
||||||
|
skip. **The shared `reuse_refs_check.py` lives in `felhom.eu/scripts/` and is never copied here**
|
||||||
|
— a copy would recreate the drift it detects; an absent sibling clone FAILS the gate.
|
||||||
|
**The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It
|
||||||
|
is per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run
|
||||||
|
WARNS when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the
|
||||||
|
session report when you use it.** Both facts are why CI is still owed (`OPEN-ITEMS.md` R-168).
|
||||||
|
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
|
||||||
|
- **Logging**: the slog logger fans out to journald (configured level) + the always-DEBUG `applog.Ring`
|
||||||
|
(remote pulls) — English, keys-never-values, durations on outcomes; full rules in
|
||||||
|
`felhom.eu/documentation/runbooks/logging-conventions.md`.
|
||||||
|
|
||||||
|
### Live validation
|
||||||
|
|
||||||
|
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end. The forbidden shortcut is
|
||||||
|
BYPASSING it (the F9 episode: raw guest-attach + hand-set state). Invoking the exact endpoint the UI
|
||||||
|
invokes is an acceptable proxy when a browser isn't available — say which method was used. Low-level
|
||||||
|
mechanism tests where the direct call IS the mechanism are exempt.
|
||||||
|
|
||||||
|
## Workflow & artifacts
|
||||||
|
|
||||||
|
- Implement **`TASK.md` / `TASK-*.md`** specs (when placed as `TASK.md` or told to), then push +
|
||||||
|
CHANGELOG + REPORT.md.
|
||||||
|
- **`RUNBOOK-*.md`** — an operational procedure. CC executes the steps it has access and capability
|
||||||
|
for, including live validation on the demo Proxmox host (CC has root@felhom-pve SSH + the
|
||||||
|
felhom-agent token). Mark a step HUMAN only when it genuinely needs physical presence, a real-world
|
||||||
|
decision, or credentials CC truly lacks. Judgment still applies: confirm before irreversible ops on
|
||||||
|
real customer data — demo scratch guests are fair game.
|
||||||
+409
@@ -0,0 +1,409 @@
|
|||||||
|
# CONTEXT — felhom-agent working state
|
||||||
|
|
||||||
|
> Snapshot of the current state + open threads. Authoritative history lives in `CHANGELOG.md` (top
|
||||||
|
> entry = current); the end-of-task detail lives in `REPORT.md`.
|
||||||
|
|
||||||
|
## Current
|
||||||
|
|
||||||
|
- **2026-08-03 — v0.121.0 (R-86): the restore-test follows the BACKUP, not the clock.** The ticker is
|
||||||
|
now only the **evaluation interval**; a tier is **DUE** when its newest archive that has settled for
|
||||||
|
`settle` (default 24 h) **has not been proven**. Daily tier → proved daily on yesterday's archive;
|
||||||
|
weekly tier → weekly on its own; newborn → UNKNOWN. **The trap, so it is not reintroduced:** the
|
||||||
|
literal reading of R-86 — *"due when the newest archive is ≥ 24 h old"* — is NEVER true on a daily
|
||||||
|
tier (a new archive resets the age before it reaches the lag), so it switches restore-testing off
|
||||||
|
where it matters most. Red-proved at 0 runs over 5 simulated days.
|
||||||
|
- **The state now records WHICH archive was proven**, not just when a tier passed. A pre-R-86 file
|
||||||
|
keeps its time (ordering survives) and yields no proven archive → each tier is due once after the
|
||||||
|
upgrade, deliberately.
|
||||||
|
- **The old cadence key:** `restore_test_cadence_seconds` is DEPRECATED. Negative still DISABLES
|
||||||
|
(verbatim); a positive value now seeds the **settle lag** and the daemon WARNs once at start-up
|
||||||
|
naming `restore_test_eval_interval_seconds` (default 6 h) and `restore_test_settle_seconds`
|
||||||
|
(default 24 h). It is NOT carried into the evaluation interval.
|
||||||
|
- **6 h is bounded from both ends:** measured evaluation cost (local 18 ms, PBS-over-WAN 392 ms,
|
||||||
|
both 430 ms) says cost is irrelevant; the ceiling is that a FAILING tier stays due, so the
|
||||||
|
evaluation interval is also its retry interval for a multi-GB restore.
|
||||||
|
- The due-check now runs **before** the heavy-operation gate is taken (a frequent poll must not be
|
||||||
|
able to make a starting backup record a failure — F-A1), and the candidate picker skips archives
|
||||||
|
failing `archivePlausiblyComplete` (a phantom would be due forever and fail forever).
|
||||||
|
- New read-only `--selftest=restore-test-due` prints the per-tier verdict + its cost.
|
||||||
|
- **R-185 (filed, NOT fixed here):** on demo-felhom the agent token has no ACL on
|
||||||
|
`/storage/felhom-backup`, so its content listing comes back EMPTY (root sees 3 archives) — the
|
||||||
|
host tier has never been restore-testable there, and the due-check cannot distinguish that from
|
||||||
|
a newborn tier.
|
||||||
|
|
||||||
|
- **2026-07-28 — v0.107.0: F-REBOOT fixed — a guest rebooted mid-backup now comes back by itself.**
|
||||||
|
New `internal/localapi/guestpower.go`: a 60 s watchdog that starts a guest which is `onboot:1`,
|
||||||
|
stopped, unlocked, and has no vzdump in flight. It closes the two narrow gaps that let
|
||||||
|
`RecoverStaleLockedGuests` miss campaign fault 11 — that recovery acts only on a **stale vzdump
|
||||||
|
lock** (fault 11's guest was unlocked) and runs **once at agent startup** (fault 11's guest went
|
||||||
|
down while the agent was already up). `onboot` is the deliberate-stop discriminator and is *not*
|
||||||
|
invented here: it is already what `stalelock.go` uses for this decision, it is 0 on scratch/golden
|
||||||
|
guests, and it is what `pve-guests` consults at host boot — so the agent agrees with the platform
|
||||||
|
instead of keeping a second private definition of "should be running". Retry bounded at 3
|
||||||
|
(1m/2m/4m) then escalates **once**; an unbounded silent retry loop is the over-correction here.
|
||||||
|
Live on demo-hp: **120 s unattended** recovery vs the incident's **587 s** with a human; Scenario B
|
||||||
|
proven (an `onboot:0` guest left stopped throughout). Detail: `REPORT.md`.
|
||||||
|
|
||||||
|
- **2026-07-28 — F-LEAK took THREE attempts; v0.108.0 and v0.110.0 are the corrections.** The cause is
|
||||||
|
structural: `FelhomAgentGuest` is granted at `/pool/felhom` and a guest joins that pool only when its
|
||||||
|
restore **completes**, so a *failed* restore-test leaves a pool-less guest out of reach (403).
|
||||||
|
**(1) v0.107.0 pool adoption — REFUTED LIVE:** `PUT /pools/{pool}` also requires `VM.Allocate` on the
|
||||||
|
VM being added, so membership cannot bootstrap its own authority; removed in **v0.108.0**.
|
||||||
|
**(2) host-install v1.21.0 per-path `/vms/990000..990009` ACLs — works, but exactly ONCE per slot:**
|
||||||
|
PVE's destroy calls `AccessControl::remove_vm_access` (`API2/LXC.pm:906`) which deletes every ACL at
|
||||||
|
`/vms/<vmid>` (`AccessControl.pm:1898`) — **the grant is consumed by the op it authorises**. Caught by
|
||||||
|
counting ACL rows after the fix, not by reasoning. **(3) v0.110.0 SHIPPED —
|
||||||
|
`Privileged.DestroyScratchLXC`, the FOURTH root-fenced exception** (was exactly three: keyctl
|
||||||
|
`pct create`, USB mount/fstab, SMART/sensors). Band enforced in **sudoers literally**
|
||||||
|
(`pct destroy 99000[0-9] --purge`) + re-checked in code + journal provenance at the caller; none is
|
||||||
|
consumed by use. API destroy still tried FIRST; band ACLs stay provisioned so the common case needs no
|
||||||
|
privileged call. **Ships with a sudoers change — deploy `configs/felhom-agent.sudoers` WITH the
|
||||||
|
binary.** Live: token 403 on a stranded scratch → fenced path removed the guest and all 3 LVs; sudo
|
||||||
|
PERMITS the band and REFUSES `9201`/`9100`/`9999`/`990010`/`1`, and refuses `pct start 990000` too.
|
||||||
|
|
||||||
|
- **2026-07-28 — v0.109.0: the guest-power watchdog got the observable it shipped without.** A
|
||||||
|
self-correction: v0.107.0's watchdog logged only at startup and when it *acted*, so on a healthy box
|
||||||
|
its health could be read only from **absence** — F-OBS's exact shape, shipped in the same session
|
||||||
|
F-OBS was fixed in the controller. Now an INFO summary every 10th sweep carrying
|
||||||
|
`sweeps_since_boot`/`guests_evaluated`/`currently_stopped`. An **aborted** sweep (unproven
|
||||||
|
ownership) does not count, or the heartbeat would claim liveness for a watchdog examining nothing.
|
||||||
|
|
||||||
|
- **2026-07-28 — v0.106.0: F-CRIT-2 fixed — a failed backup no longer looks like a fresh one.**
|
||||||
|
`NewestArchiveTime` counted an aborted PBS upload (1 byte, manifest-less, NEWEST) as a successful
|
||||||
|
backup, so the tier reported fresh, went **not due**, and was never retried — 7 days of silence on
|
||||||
|
the real 168h cadence, invisible to both the R-88 breaker (defers only DUE tiers) and the hub
|
||||||
|
deadline monitor (reads the same freshness). Now only *plausibly complete* entries count, via a
|
||||||
|
measured floor `minPlausibleArchiveBytes` = 1 MiB; undecidable ⇒ not counted.
|
||||||
|
**Size is the only tier-agnostic discriminator** — `verification` and `encrypted` are absent on
|
||||||
|
every local (dir) archive and on a good PBS snapshot until verify-new catches up, so gating on
|
||||||
|
either would reject 100% of local backups and cause fleet-wide backup THRASH. Floor measured:
|
||||||
|
smallest real backup on the fleet is 612,397,450 B, so 1 MiB leaves 584x headroom (asserted by a
|
||||||
|
test). Rejections logged at WARN once per volid. Re-tested live by replaying campaign fault 2 on
|
||||||
|
demo-hp — both directions, incl. a no-thrash window with 91 scheduler ticks as the positive
|
||||||
|
observable. Deployed on both boxes. Detail: `REPORT.md`.
|
||||||
|
**Also established:** server-side prune does NOT count phantoms toward `keep-last` (dry-run kept
|
||||||
|
2 real + the phantom) ⇒ **no retention/data-loss bug** — but it never removes them either, so they
|
||||||
|
accumulate. Filed as R-99 (LOW).
|
||||||
|
|
||||||
|
- **2026-07-25 — v0.95.0 (additive): SMART coverage fixes (spike B+A) + device model.** Union-path
|
||||||
|
drives (USB/registry) now get SMART via `storage.SmartReader.SMARTForBacking` wired into the localapi
|
||||||
|
`/disks` union (localapi `Smart` seam); `smartDeviceFor` resolves dm/LVM to the whole disk via
|
||||||
|
`/sys/block/<dm>/slaves` (recursive, skips >1-disk); the builtin `local` dir on the LVM root gets a
|
||||||
|
**SMART-only** device from its containing filesystem (never touches backing/durable_id — the
|
||||||
|
removable-safety guard in build() stays intact); `SmartSummary.ModelName` captured from smartctl. The
|
||||||
|
watchdog `Known` path stays enrich-free. Consumed by controller v0.171.0. Source of WHERE:
|
||||||
|
`felhom.eu/documentation/audits/SPIKE-smart-coverage-2026-07-25.md`.
|
||||||
|
- **2026-07-24 — v0.94.0 (additive): SMART serialized into /disks.** `localapi.DiskInfo` gains
|
||||||
|
`Smart *hub.SmartSummary` (omitempty), copied from the target's already-computed Observe-time
|
||||||
|
enrichment when `Health != ""` — no new smartctl load, no endpoint, no sudoers/MinAgent change. The
|
||||||
|
controller v0.169.0 renders a "Lemezek állapota" card + 6h degradation alert from it; old controllers
|
||||||
|
ignore it. **NOTE: at the remote-site vacation window the agent is DOWN (localapi binds .162 → fails),
|
||||||
|
so live /disks-from-real-agent validation is deferred — the field is unit-proven; publish only.**
|
||||||
|
- **2026-07-22 — v0.93.0 is the FLEET AGENT.** Built, published (sha `a68b2ff73200622e…`),
|
||||||
|
Day-0-manifest-vouched (MinAgent also 0.93.0, operator-ruled) and deployed to BOTH boxes
|
||||||
|
(`demo-felhom-8363b5` + `demo-hp-bb76ea`, the latter over G1 break-glass — still no key baked);
|
||||||
|
clean-restart 5/5 on both, `.bak-0.92.1` retained. Discharges the onboarding runbook §A5
|
||||||
|
ceremony gate. Record: `felhom.eu/documentation/pilot/RUNBOOK-publish-agent-0.93-2026-07-22.md`.
|
||||||
|
**The bullet below ("agent is DOWN … deployed 0.90.0") is SUPERSEDED history** — vmbr0 was made
|
||||||
|
static .162 on 2026-07-20 (F1 mitigation) and the agent has been up since; kept for the record.
|
||||||
|
|
||||||
|
- **2026-07-20 — REMOTE SITE until ~2026-08-02; the agent is DOWN there and cannot self-recover.**
|
||||||
|
felhom-pve moved off the home LAN; `ssh felhom-pve` = tailnet `100.70.170.35` (direct, ~37 ms). The
|
||||||
|
host is on DHCP and holds `192.168.0.147`, so `localapi`'s literal `192.168.0.162` bind fails with
|
||||||
|
`bind: cannot assign requested address` — the daemon exits ~1.1 s after start, systemd gave up after
|
||||||
|
4 retries, and a manual restart reproduces it exactly. Deployed binary is **0.90.0**. Fix needs
|
||||||
|
`listen_addr` in `/etc/felhom-agent/agent.json` **and** the guest bootstrap endpoint (plus the
|
||||||
|
pinned leaf's SAN) → **Viktor GO**; re-pinning to another literal just re-breaks on the next lease.
|
||||||
|
Also re-observed each start: `pbs: cannot read token secret … /etc/pve/priv/storage/felhom-pbs.pw:
|
||||||
|
permission denied` (R-39-adjacent). Evidence + ranked findings:
|
||||||
|
`felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md`
|
||||||
|
|
||||||
|
- **v0.90.0** (2026-07-17) — **agent train: guest RAM resize (R-24) + fast-tick (R-28); LIVE on BOTH
|
||||||
|
demo hosts (felhom-pve + nested demo-vm-felhom-4846bc).** MinAgent coupling: felhom-controller
|
||||||
|
v0.143.0 gates its resize UI on this agent. (1) **R-24 guest RAM resize (controller-direct)** —
|
||||||
|
self-scoped `GET`/`POST /guest/memory` (`internal/localapi/guestmemory.go`); the AGENT enforces
|
||||||
|
every bound fresh per request (min 2048 / max host_total−2048 / shrink floor max(2048, usage+512)) and
|
||||||
|
applies via PVE `SetConfig` — **live cgroup apply, no reboot** (Phase-0 PROVEN on the nested box; the
|
||||||
|
break-glass access path + the proof are in `~/.claude/.../nested-vm-access-breakglass.md`). Verify-
|
||||||
|
after-apply re-reads maxmem before claiming success. New narrow `MemoryOps` seam (GuestAPI untouched);
|
||||||
|
memory only. (2) **R-28 fast-tick** (`internal/fasttick/`) — while any desired-state item is unapplied
|
||||||
|
(esp. the pre-tunnel WG-registration window a hub poke can't reach) pulse the shared out-of-band
|
||||||
|
trigger every 30 s, self-disarm on convergence; four cached sources (desired-gen==0, reconcile
|
||||||
|
Planned−Pending>0, pbsdr waiting_secret ONLY, wgtunnel desired-not-operational). Seams:
|
||||||
|
`reconcile.Engine.LastResult()` + `wgtunnel.Manager.TunnelConvergence()` (cached — no per-tick exec).
|
||||||
|
(3) **Guests-0/0** REFUTED live: the 0/0 was the pre-provision window (guest not yet created), not a
|
||||||
|
pool-membership bug; the fast-tick shortens that window. **OPEN (operator GO):** publish 0.90.0 +
|
||||||
|
hub Day-0 manifest vouch + MinAgent-floor raise to 0.90.0 (password-gated UI; the safety gate — both
|
||||||
|
agents on 0.90.0 — is satisfied and the coupling is proven live via the version header). See REPORT.md.
|
||||||
|
|
||||||
|
- **v0.89.0** (2026-07-16) — **agent train: three bundled agent-plane items; built + published to
|
||||||
|
Gitea (sha256 `3969fd91…`); paired with hub 0.59.0 (LIVE).** (1) **pbsdr self-grant (R-22)** —
|
||||||
|
closes the F4 self-deadlock: a 403 on the token-auth `StorageEntry` pre-check now self-grants via
|
||||||
|
the root wrapper + re-reads instead of aborting before the grant (the demo's `felhom-offsite`
|
||||||
|
case). (2) **escrow config live-reload** — `/escrow/preflight`'s `pbs_storage_id` row now reads
|
||||||
|
the live agent.json (late-bound `CurrentPBSStorageID`) so a pbsdr-seeded id flips green with no
|
||||||
|
restart. (3) **agent-plane poke listener (Direction-2a)** — `internal/poke`: contentless UDP poke
|
||||||
|
bound to the box WG /32 (port **51822**), leading-edge debounced, fires the hub-loop out-of-band
|
||||||
|
trigger for an immediate desired-state cycle; enabled with `wg_tunnel.enabled`; first slice of
|
||||||
|
R-13. Red-proofs for all three (run-fail-revert). **ALL THREE LIVE LEGS PROVEN on the demo
|
||||||
|
(2026-07-17), demo now LIVE on 0.89.0:** Scenario 4 floor-driven A/B train 0.88→0.89 (operator
|
||||||
|
signed+enqueued the `agent_update` op — the vouch+floor alone does NOT trigger it; committed, no
|
||||||
|
rollback); Scenario 1 R-22 self-heal (marker aside + ACLs revoked → `pre-check 403 … self-granting
|
||||||
|
(R-22)` → `converged state=adopted` in ~3 s, ACLs restored, offsite active); Scenario 3 poke→tick
|
||||||
|
~31 ms ep0→box + immediate report cycle (save→tick ≈ ~0.45 s). Details: REPORT.md.
|
||||||
|
|
||||||
|
- **v0.88.0** (2026-07-13 eve) — **controller-driven escrow ceremony (agent half), LIVE on demo
|
||||||
|
host + drill VM (63/63 capabilities both).** `--output=json` machine mode (text mode
|
||||||
|
byte-identical; extraction into `escrowCeremony()`); the ONE fixed argv
|
||||||
|
(`escrow.CeremonyArgs()` — shared by the localapi exec + the `escrow-ceremony` capability
|
||||||
|
(Critical, pbs_dr-gated EXPLICIT) + the new `FELHOM_ESCROW` sudoers alias, three-way
|
||||||
|
pin-tested); localapi job endpoints (`POST /escrow/ceremony` single-flight 60 s,
|
||||||
|
status, ONE-SHOT claim → 410, 10-min TTL → `unclaimed_void`, `GET /escrow/preflight`).
|
||||||
|
R in-memory ONLY (never the job struct — snapshot-hygiene-tested; restart loses it safely).
|
||||||
|
Live-proven on drill endpoint-exact: stage → preflight all-green (live FELHOM_ESCROW
|
||||||
|
list-probe) → job ~4 s → hub blob `restic_pw_sha256` covering (repaired the spike's hash-less
|
||||||
|
blob) → claim 200 once → 410. Coupled: controller v0.127.0 (MinAgent 0.88.0 for the wizard).
|
||||||
|
**OPEN: publish 0.88.0 + Day-0 manifest vouch (operator) at the next train; deployed hosts got
|
||||||
|
direct deploys.** Details: REPORT.md + felhom.eu RUNBOOK-escrow-ceremony.md (F1 threat model).
|
||||||
|
|
||||||
|
- **v0.87.0** (2026-07-13) — **SystemDisks device-mapper walk (IA finding 2, MEDIUM):
|
||||||
|
legacy-boot hosts get a working drive wizard.** Operator ruling (approved 2026-07-13,
|
||||||
|
verbatim): *resolve device-mapper/raid parents — for the root filesystem's backing block
|
||||||
|
device, walk `/sys/block/<dev>/slaves` recursively down to physical disks; those, plus any
|
||||||
|
ESP holder when present, are system. Disks outside that set become wizard candidates (still
|
||||||
|
subject to the existing data-bearing guards). The all-system fail-safe remains ONLY for walk
|
||||||
|
failure — it returns to being the error case, not the legacy-boot common case.* Implemented as
|
||||||
|
`physicalDisksOf`/`walkSlaves` + `HostReader.BlockSlaves` (one seam method); per-branch
|
||||||
|
conservatism (any unresolvable slave → ok=false → unchanged all-system path); signature test
|
||||||
|
`TestSystemDisks_WalkTopologies` (root-backing disk ALWAYS system — never weaken). §3 spike
|
||||||
|
transcripts: drill (legacy) dm-1→sda3→sda; felhom-pve (EFI+LVM) ESP+walk agree on sda →
|
||||||
|
byte-identical regression. §13.2 wizard leg COMPLETE (offered → enrolled → formatted →
|
||||||
|
torn down, boxes as found) + Day-0 manifest vouched to 0.87.0 (operator). The leg also
|
||||||
|
surfaced two CONTROLLER bugs (fixed same-day: v0.126.3 claimed-box wizard CSRF, v0.126.4
|
||||||
|
502-through-CF + native-alert ban).
|
||||||
|
|
||||||
|
- **v0.83.0** (2026-07-11, LIVE on felhom-pve; NOT published — Peti stays 0.81.0) — **observability
|
||||||
|
pass** (pairs with controller v0.116.1 + hub v0.46.0). `applog.New` → `(logger, *Ring)`: slog
|
||||||
|
fan-out, journald at the configured level, ~1000-entry ring FIXED at DEBUG. `GET /debug/logs`
|
||||||
|
(local API, token-authed; the controller Debug page's Ügynök tab) + request-level DEBUG
|
||||||
|
middleware. Heartbeat log-pull: envelope `log_tail_requested` → next heartbeat ships `log_tail`
|
||||||
|
(128 KB, consume-once; failed push re-armed by the next envelope; `operator log pull served` INFO
|
||||||
|
on fulfillment). Gap-fill sweep: netverify phase/verdict lines (job start, trigger outcome,
|
||||||
|
/proc/mounts verdict, journal bytes, classification code, rollback outcome, durations), netmount
|
||||||
|
unit steps, signedjobs op-received (class/host/expiry — never signatures) + fetch duration,
|
||||||
|
selfupdate invariants + download sha/duration, disks outcome INFOs, controller-swap pre-pull +
|
||||||
|
health verdicts, desired/loop per-exchange DEBUG. Logging conventions:
|
||||||
|
`felhom.eu/documentation/runbooks/logging-conventions.md`. OPEN: the hub-side live pull awaits
|
||||||
|
the operator's button click (hub UI password-gated); pre-existing lanresolver
|
||||||
|
permission-denied WARN on /var/lib/felhom-agent/guests noted in REPORT.
|
||||||
|
|
||||||
|
- **v0.77.0** (2026-07-09) — **fork-4: escrow the offsite restic repo password under R.** `IdentityBundle`
|
||||||
|
gains `ResticRepoPassword` (rides the existing age-under-R `WrapIdentityBundle` path — validated by the
|
||||||
|
custody spike `febdc56`). New `POST /escrow/stage-secret` (`withGuest`) transiently stages the
|
||||||
|
controller-pushed password (0600, never logged), which the `--selftest=escrow-create` ceremony
|
||||||
|
auto-injects into the bundle and then wipes. `AttachResticPassword`/`StagedResticPasswordPath`/
|
||||||
|
`WipeStagedResticPassword` added. Pairs with controller v0.105.0 (push + atomicity gate + DR inject +
|
||||||
|
`DRResticCoord`). **NOT yet live-validated** — the supervised escrow ceremony is operator-run.
|
||||||
|
|
||||||
|
- **v0.76.0** (2026-07-08, LIVE on felhom-pve + **PUBLISHED sha `9828c5f7…f50b`** — THE Day-0
|
||||||
|
manifest bump target; **0.75.0 superseded unpublished**) — **GL-5b / G12: restore-test
|
||||||
|
full-fidelity**. Params derive from the ARCHIVE's embedded config (`drRestoreOverrides`, same as
|
||||||
|
DR — the old live-source-config path verified the wrong object AND dropped storage mpN per PVE's
|
||||||
|
all-or-nothing rule; deleted with `bindMountOverrides`/`archiveVMID`). NEW mount-parity assert
|
||||||
|
(restored mpN vs archive; miss/mispath/undersize/extra = FAIL naming the delta) + `MountParity`/
|
||||||
|
`MountInventory` on the wire record (additive). Live-proven: scratch 990000 ← 6.5GB 9201 archive,
|
||||||
|
parity ok, inventory mp0 200G+mp1 50G+2 throwaways, **3m4s local tier** (cheaper than feared);
|
||||||
|
rotated-out archive volid → clean up-front refusal (nice failure mode). bringup.go untouched.
|
||||||
|
|
||||||
|
- **v0.75.0** (2026-07-08, LIVE on felhom-pve) — **GL-5 / go-live G8: guest-loss DR bring-up
|
||||||
|
actually restores** (closes the v0.74.0 OPEN item + SPIKE-dr-bindmount-source §8). DR passes the
|
||||||
|
COMPLETE explicit restore param set derived from the archive's embedded config (NEW
|
||||||
|
`Client.ExtractArchiveConfig`, 200 under the scoped token) — **two live-discovered PVE rules: mpN
|
||||||
|
params need an explicit rootfs, AND unlisted mountpoints are silently DROPPED** (first run booted
|
||||||
|
without mp0/mp1!) — storage mpN passed through, structural mp8/mp9 → throwaways, then step 4d
|
||||||
|
swaps the REAL binds in via the host runner (root pct; new `EngineOptions.HostRunner`+`StateDir`
|
||||||
|
seam) and deletes the unusedN residue. Scratch-DR live-proven end-to-end (9310 from a real 9201
|
||||||
|
archive: mp0 200G + mp1 50G + real binds + no residue + clean teardown). Provision = nil
|
||||||
|
overrides (regression-tested). NOTE: published/vouch-pending agent is 0.74.0 — publish 0.75.0
|
||||||
|
before/with the manifest bump. OBSERVATION: the DR selftest hardcodes KeepMAC=true — a scratch DR
|
||||||
|
while the SOURCE guest is live briefly duplicates its MAC on the bridge (pre-existing; fine for
|
||||||
|
supervised runs, worth a -keep-mac flag someday). Full customer-data DR drill = GL-6/S5 family.
|
||||||
|
|
||||||
|
- **2026-07-07 — v0.74.0 Gitea-PUBLISHED (RUNBOOK GL-1)** — the LIVE felhom-pve binary's exact
|
||||||
|
bytes, sha256 `1ec3f58842edce1e…76af05`, anon-fetch-verified. This supersedes/closes every
|
||||||
|
standing "publish 0.6x + Day-0 vouch" OPEN item below (0.64→0.73 were never published; 0.74.0
|
||||||
|
is the vouch target). Golden 0.103.0 published in the same run (felhom.eu execution record
|
||||||
|
`documentation/pilot/RUNBOOK-GL1-publish-2026-07-07.md`). **Day-0 manifest vouch = operator
|
||||||
|
step** (agent 0.74.0 / golden 0.103.0).
|
||||||
|
|
||||||
|
- **v0.74.0** (2026-07-07) — **campaign-2 R2 CLOSED; the mislabelled "R1" was a symptom** (LIVE on
|
||||||
|
felhom-pve). Pool membership is what lets the pool-scoped token reach a guest; `pct restore --pool`
|
||||||
|
sets it only at CREATE, so a restore-over-existing dropped 9201 from the `felhom` pool → no
|
||||||
|
`VM.Audit` → restore-test's *existing* `bindMountOverrides` never ran → "mp8 … only possible for
|
||||||
|
root". Fix: `Client.PoolAddVMID` + bring-up re-asserts membership post-restore (warn-not-fail).
|
||||||
|
Role/ACL + `bindMountOverrides` untouched (both correct). **Live restore-test PASSED for the first
|
||||||
|
time** once the pool was healed (Part A one-liner): read config → neutralize 2 binds → restore →
|
||||||
|
boot+running → clean teardown, 4m35s. B3 (scratch-teardown 403) confirmed a cascade — no code.
|
||||||
|
OPEN: DR `bring-up -mode dr` bind-override gap (spike `SPIKE-dr-bindmount-source-2026-07-07.md`:
|
||||||
|
small known-constant override reusing `bindMountOverrides`; mp8/mp9 are structural constants).
|
||||||
|
|
||||||
|
- **v0.73.0** (2026-07-06) — **F2 mount-role fallback CLOSED** (LIVE on felhom-pve). `roleForMountPath`
|
||||||
|
gained a mount-table fallback (Impl-2b style): a bind-mounted RAW enrolled user-data drive is not a PVE
|
||||||
|
storage, so it fail-safe'd to `system` and the eject/decommission gates 403'd EVERY user-data drive
|
||||||
|
(campaign F2, `where=/mnt/teszt_enroll role=system`). Device-keyed classification + whole-disk containment
|
||||||
|
(`storage.SameWholeDisk`); Observe-error keeps the fail-safe BEFORE the fallback. Only `roleForMountPath`
|
||||||
|
touched. Live-proven full lifecycle on teszt_enroll (eject/decommission 200, no-rebind across restart,
|
||||||
|
end==pre). OPEN follow-up: the `deviceRole`/`roleForMountPath` unification refactor (deferred).
|
||||||
|
|
||||||
|
- **v0.72.0** (2026-07-05) — **OOB operator access (merged E1+H1)** — TASK H1, provenance both
|
||||||
|
`SPIKE-{felhom-sshd,oob-wg-operator-peer}-2026-07-05`. Operator `/32` RENDERED into wg-felhom
|
||||||
|
AllowedIPs (survives self-heal, [OF-1]); dedicated `internal/felhomsshd` (port claim + config
|
||||||
|
render→sshd -t→reload + operator authorized_keys + heal + oob heartbeat stanza); static
|
||||||
|
`inet felhom_oob` belt (agent mutates SET ELEMENTS ONLY); `configs/felhom-sshd.service` (NO
|
||||||
|
RuntimeDirectory [SF-1]) + `felhom-oob.nft` + `felhom-op.sudoers`; `FELHOM_SSHD`+`FELHOM_OOB`
|
||||||
|
grants; `oob.enabled` DEFAULT FALSE. Live on felhom-pve (8822, belt filled, operator SSH as
|
||||||
|
felhom-op with scoped sudo); hub v0.35.0. Rollback `.bak-0.71.0`. 5 live-found bugs fixed (port
|
||||||
|
path, self-listen flip-flop, nil-block lockout, reachable-via-dial, operator-configured source).
|
||||||
|
- **v0.71.0** (2026-07-05) — **management-plane break-glass: privsep-dir watchdog + mgmt_plane
|
||||||
|
health** — TASK G1 (prereq for felhom-sshd/H1), provenance `SPIKE-felhom-sshd-2026-07-05` §8.
|
||||||
|
Host artifacts (`configs/felhom-privsep.tmpfiles` + `felhom-mgmt-watchdog.{sh,service,timer}`) make
|
||||||
|
`/run/sshd` boot-persistent AND auto-heal it every ~60s **agent-independently** (heals with the
|
||||||
|
agent stopped — proven live: `/run/sshd` removed → restored in 30.0s, `:22` back, no login).
|
||||||
|
`internal/mgmtplane` reports the additive `mgmt_plane` heartbeat stanza; hub v0.34.1 raises
|
||||||
|
`mgmt_plane_healed`. **NO unit declares `RuntimeDirectory=`** (the incident cause). H1 may now
|
||||||
|
assume `/run/sshd` is guaranteed present. Live on felhom-pve; rollback `.bak-0.70.0`.
|
||||||
|
- **v0.70.0** (2026-07-05) — **agent self-update (operator-signed A/B slots + crash-loop
|
||||||
|
auto-rollback)** — TASK D1, provenance `SPIKE-agent-selfupdate-2026-07-05`. An operator-signed
|
||||||
|
`agent_update` op (version+sha256, sha is the only integrity root) rides the signed-jobs gate;
|
||||||
|
`internal/selfupdate.Executor` downloads+verifies+hands to `felhom-selfupdate-guarded apply` (root
|
||||||
|
re-verify → A/B atomic flip → pending marker → detached restart); the new binary commits after a
|
||||||
|
60s dwell; a crash-looping binary is auto-reverted by `OnFailure=felhom-agent-rollback.service`
|
||||||
|
(first-crash trigger [SF-1]) with the tuned `[Unit]` start-limit (120s/4) as backstop. Host
|
||||||
|
artifacts + sudoers `FELHOM_SELFUPDATE` + `felhom-host-install.sh` day-0 install + report field
|
||||||
|
`selfupdate_pending`. Green tests + companions. **LIVE-VALIDATED on felhom-pve (2026-07-05): all 4
|
||||||
|
drills PASS** — happy path (0.70.0→0.70.1 signed op → download+verify+flip+commit), crash-rollback
|
||||||
|
(0.70.2-crash → OnFailure → **~2s crash-to-recovered**, byte-identical revert, no loop), no-pending
|
||||||
|
guard, gate refusal (non-pinned key). Full agent-side pipeline ran real (envelope injected into the
|
||||||
|
hub `signed_jobs` queue — CC lacks the hub global operator key; hub enqueue-auth is hub-unit-tested).
|
||||||
|
Box restored to canonical **v0.70.0** (host artifacts KEPT installed; scratch operator key REMOVED —
|
||||||
|
self-update dormant until an operator pins a real key, a Day-0-vouch-style follow-up). Rollback
|
||||||
|
`felhom-agent.bak-0.69.0`. OPEN (v1 scope-outs): no hub-floor auto-update, no failed-update
|
||||||
|
auto-retry, no pending-timeout auto-rollback; per-crash OnFailure can double-fire (idempotent —
|
||||||
|
future: serialize the rollback oneshot). Detail: REPORT.md.
|
||||||
|
|
||||||
|
- **v0.69.0** (2026-07-04, live on felhom-pve) — **S5: host-loss DR — safe halves shipped**.
|
||||||
|
**Part 1** `wgtunnel.InstallRecoveredKey` — writes an escrow-recovered WG privkey (create-only,
|
||||||
|
refuse-overwrite) so the tunnel re-establishes with the SAME identity/pubkey (same /32), no keygen;
|
||||||
|
wired into `--selftest=identity-consume -install-wg-key` (opt-in; pre-S3 blob → logged fresh-keygen
|
||||||
|
fallback). **Part 2** new `internal/dr` — consumes the host_loss `restore_directive` (was
|
||||||
|
logged-ignored) into an inspectable RestorePlan via AddConsumer: per-guest {vmid,archive,target,
|
||||||
|
sizing} + per-drive {durable_id→mount} + offsite PBS coord; DERIVE-AND-SURFACE only (Consumer has
|
||||||
|
no restore/destroy dep — execute-nothing is structural). Tests + red-proofs (WG create-only; plan
|
||||||
|
mode-gate). **Part 3** hub escrow-GET NOT needed (operator exports the blob via `sqlite3 writefile`
|
||||||
|
on a cp'd hub.db). **Part 4-A** re-attach wrong-disk safety already unit-proven
|
||||||
|
(`ResolveStorageDevice`: match resolves, absent/mismatch ERRORS, non-uuid scheme refused — never a
|
||||||
|
near disk). **Part 4-B (destructive in-place 9201 restore) PREPARED + OPERATOR-GATED, NOT executed**
|
||||||
|
— pre-flight green (offsite ct/9201 restorable per S4.1); the operator runs the R-consume steps +
|
||||||
|
confirms the destroy (§9-4a: CC never runs a consume/R command — see [[operator-present-one-time-secrets]]).
|
||||||
|
OPEN: the operator-run 4-B drill; guest_loss DR; hub-driven full-auto DR. Rollback
|
||||||
|
felhom-agent.bak-0.68.0. Detail: REPORT.md + doc-06 §3.5/S5.
|
||||||
|
- **v0.68.0** (2026-07-04, live on felhom-pve) — **S4.1: unattended offsite restore-test**.
|
||||||
|
**Tier-aware restore-task deadline:** `RestoreTestSpec.RestoreTaskTimeout` (0→10m default) from
|
||||||
|
`config.RestoreTestPBSRestoreTimeoutSeconds` (accessor default **120m**), set only when
|
||||||
|
`SourceTier=="pbs"` (`main.restoreTaskTimeout`); local tier UNCHANGED. Fixes the WAN restore being
|
||||||
|
killed at 10m → mid-restore teardown → leaked scratch. **Teardown "VM.Allocate" follow-up =
|
||||||
|
PHANTOM (diagnosed, not blind-fixed):** ran the restore-test on the AGENT-TOKEN path sourcing the
|
||||||
|
offsite (pbs) backup → `pass:true verified:boot+running`, teardown succeeded (`torn down
|
||||||
|
vmid=990000`, no 403), scratch band clean. The earlier 403 was the 10m-timeout consequence (guest
|
||||||
|
not yet pool-associated); the scratch is restored INTO `/pool/felhom` (ACL already grants
|
||||||
|
VM.Allocate) so teardown is authorized once the restore completes. **No ACL/host-install change.**
|
||||||
|
OPEN: publish 0.68.0 + Day-0 vouch; Tier-1/Tier-2 split for offsite-as-default; S5 DR consume.
|
||||||
|
Rollback `felhom-agent.bak-0.67.0`. Detail: REPORT.md.
|
||||||
|
- **v0.66.0 + v0.67.0** (2026-07-04, live on felhom-pve) — **S4: PBS over the tunnel**. **v0.66.0**:
|
||||||
|
wgtunnel **v4-pin** (renderConf writes the resolved A LITERAL, never DNS/AAAA; `Resolver` seam,
|
||||||
|
lowest addr; cached → steady-state zero-DNS/zero-exec) + **re-resolve watchdog** (`Manager.Watchdog`,
|
||||||
|
loop-only; handshake stale > `stale_after_seconds`=180 → re-resolve → IP-changed re-render+restart)
|
||||||
|
+ FELHOM_WG **Critical** flips (conf-install/enable/restart/handshake-read). **v0.67.0**:
|
||||||
|
**namespace-aware PBS client** (Config.Namespace → `Snapshots ?ns=`, `Verify ns=`; root-ns
|
||||||
|
unchanged) — the operator-approved fix after Phase-1 showed the ns-unaware datastore-root 403s a
|
||||||
|
per-tenant token. **Live Scenario-D (all green):** real vzdump of 9201 → **ciphertext** in ns
|
||||||
|
`demo-felhom-01` over the tunnel; ns-scoped verify=ok under the box's own `felhom@pbs!demo-felhom-01`
|
||||||
|
token; WARN gone; restore round-tripped (decrypt with box-born key → boot → teardown).
|
||||||
|
**Confirmed tenant ACL (felhom-hetzner):** `DatastoreBackup` on `/datastore/felhom-offsite/<ns>`
|
||||||
|
(NOT `/ns/<ns>`) to BOTH user `felhom@pbs` AND token (privsep=intersection; cross-ns 403);
|
||||||
|
DatastoreBackup can't prune (safety). **FINDINGS:** retarget field is `local_backup_target` (not
|
||||||
|
`backup_target`); retarget REVERTED to `local` (controller backs up ~every 30 min → single-target
|
||||||
|
offsite = near-continuous 20-min uploads; needs Tier-1/Tier-2 split); restore-test scheduler needs
|
||||||
|
a WAN restore deadline + scratch-band `VM.Allocate` before it runs offsite unattended. **OPEN:**
|
||||||
|
escrow-create (OPERATOR-PRESENT, new R); publish 0.66/0.67 + Day-0 vouch; S5 DR consume. Rollback:
|
||||||
|
`felhom-agent.bak-0.65.0`/`.bak-0.66.0`. Detail: REPORT.md + doc-06 §3.4/§4.2 + runbook §4a/§4b.
|
||||||
|
- **v0.65.0** (2026-07-04, live on felhom-pve) — **S3.1 offsite-tunnel client MTU 1420 → 1280**:
|
||||||
|
resolves `06 §4.3`'s OPEN DECISION left by the CGNAT smoke test. 1420 **silently black-holed bulk
|
||||||
|
TCP** on sub-~1480 paths (mobile ~1400, DS-Lite ~1452) — handshake+ping healthy, PBS TLS page
|
||||||
|
(and at S4 the backup itself) drops. New `const clientMTU = 1280` (RFC 8200 IPv6-minimum floor;
|
||||||
|
outer 1340 v4 / 1360 v6 fits every realistic path), **permanent + fleet-wide + family-agnostic**.
|
||||||
|
**Client-only by construction** — interface MTU caps box→PBS, advertised MSS caps PBS→box, so the
|
||||||
|
endpoint's `wg0` is untouched (zero live-endpoint risk). Golden pins exact `MTU = 1280`
|
||||||
|
(red-proofed vs a 1420 flip); no wire/JSON change. Live: agent re-rendered on restart (hash-gated
|
||||||
|
apply), conf + live iface both 1280, PBS page loads at 1280 (no regression on wired). OPEN:
|
||||||
|
true-CGNAT-SIM retest (low risk); publish 0.65.0 + Day-0 vouch (operator); S4 PBS-over-tunnel.
|
||||||
|
Rollback: `felhom-agent.bak-0.64.0` on the box. The v4-pin (§4.2 determinism) is a separate,
|
||||||
|
optional future note — NOT needed for MTU correctness.
|
||||||
|
- **v0.64.0** (2026-07-04, live on felhom-pve) — **S3 offsite WG tunnel**: new `internal/wgtunnel`
|
||||||
|
(keygen 0600/0700, marker-gated one-shot registration, agent-managed `wg-quick@wg-felhom` from
|
||||||
|
the hub's desired-state `wireguard` block via the new `desired.Syncer.AddConsumer` seam,
|
||||||
|
revoked-stays-revoked teardown, report stanza) + `FELHOM_WG` sudoers/capabilities +
|
||||||
|
`IdentityBundle.WGPrivateKey` escrow auto-inject. **`wg_tunnel.enabled` DEFAULTS FALSE** (safety
|
||||||
|
gate — rollout to Peti's box is a no-op until the production endpoint exists; enabled explicitly
|
||||||
|
on felhom-pve only). Live: tunnel to ep0.felhom.eu:443 up 3 s after enable (PBS page through
|
||||||
|
10.77.0.1:8007), reboot-persistent, revocation drill clean, 30-min keepalive soak. GOTCHAS:
|
||||||
|
hub envelope poll_interval_seconds (hub-side const 900 s) silently overrides agent poll_seconds
|
||||||
|
on cycle 1; `wg show <if> dump` leaks the PRIVATE key (forbidden everywhere — sudoers only
|
||||||
|
grants `latest-handshakes`). OPEN: CGNAT/mobile-hotspot smoke (operator-assisted appendix);
|
||||||
|
publish 0.64.0 to Gitea + Day-0 vouch (operator); S4 points PBS at the tunnel.
|
||||||
|
- **configs: build-golden.sh v2.0.0** (2026-07-03, @ `ceca355`; no agent version change) — **drill
|
||||||
|
findings B5 + B1 FIXED** (`DRILL-golden-098-2026-07-03.md`): the controller tag is a MANDATORY
|
||||||
|
argument (the default rotted twice — a fresh install booted a pre-floor controller, forcing the
|
||||||
|
guide's manual D.1b update) and the golden now bakes a `felhom-controller-bootstrap.path` unit
|
||||||
|
(controller deploys the moment the back-half hot-plugs the bootstrap mount — no reboot; installer
|
||||||
|
v1.9.1's reboot is a redundant belt, kept). **Golden 0.98.3** baked on the drill VM, clean-room
|
||||||
|
validated (bake integrity → isolated hot-plug proof → local-golden Day-0 → published-artifact
|
||||||
|
Day-0), published (sha256 b9a02ef1…fd01) + operator-vouched — Day-0 manifest now vouches
|
||||||
|
**agent 0.63.0 + golden 0.98.3** (the v0.63.0 vouch follow-up below is DONE). Fresh installs land
|
||||||
|
current and self-manage. NEW operator follow-up (SECURITY): the customer-config `git.token` has
|
||||||
|
Gitea package-WRITE rights — scope down + rotate (evidence-doc observation O1).
|
||||||
|
- **v0.63.0** (2026-07-03, live on felhom-pve + Gitea-published sha256 b4a89c81…) — **drill findings
|
||||||
|
B3 + B2 FIXED** (`DRILL-day0-cleanroom-2026-07-03.md`): `TokenStore.Lookup` reloads the append-only
|
||||||
|
store once on a miss (cross-process coherence with the one-shot provisioner — no more fresh-install
|
||||||
|
`/controller/swap` 401 / manual restart; size short-circuit bounds the cost; behind the
|
||||||
|
`TokenAuthority` seam) + `guesthook.InstallSnippet` issues a fenced `mkdir -p /var/lib/vz/snippets`
|
||||||
|
first (fresh boxes lacked the dir → the self-heal hook silently never installed). Sudoers gained
|
||||||
|
exactly that one grant — **ship sudoers WITH the binary** (done on felhom-pve). Red-proofed both;
|
||||||
|
Scenario-E method: compiled test suite run ON felhom-pve + live channel-health hit-path.
|
||||||
|
**OPERATOR FOLLOW-UP: bump the hub Day-0 manifest to agent 0.63.0** — until then fresh installs get
|
||||||
|
0.62.0 and the guide's D.1b restart-first step still applies (narrowed to "< v0.63.0" in the guide).
|
||||||
|
- **v0.62.0** (2026-07-03) — **audit A1 RESOLVED**: the stale-lock reaper's scan is now
|
||||||
|
pool-intersected (`staleLockController.Guests()` = `ListLXC` ∩ `Client.Pool("felhom")` members),
|
||||||
|
fail-safe skip on pool-read failure; `pve:pool-read` capability (non-critical) + `--selftest`
|
||||||
|
"pool read" line. Companion host-install **v1.9.0** adds `Pool.Audit` to `FelhomAgentGuest` —
|
||||||
|
**deploy order on any box: rescope ACL first, then this agent.** Per
|
||||||
|
`SPIKE-a1-pool-membership-read-2026-07-03.md`; red-proofed tests in stalelock_pool_test.go.
|
||||||
|
- **2026-07-03 — CLAUDE.md refreshed**: version narrative removed (state lives HERE + CHANGELOG top), layout completed (all 17 internal packages + cmd/felhom-opsign); deploy runbook now in the `felhom-build-deploy` skill (`felhom.eu/skills/`).
|
||||||
|
- **2026-07-03 — `REUSE.md` exists at the repo root** (canonical helpers / format-safety guards / traps / seams, code-verified); maintenance rule active: update it in the same commit that changes a shared helper.
|
||||||
|
- **v0.61.0** (2026-07-03) — blast-radius audit fixes **B1 + D1 + D2 + D3** from
|
||||||
|
`felhom.eu/documentation/audits/AUDIT-blast-radius-hostroot-localapi-2026-07-02.md`: random temp
|
||||||
|
staging for root-installed scripts (+ sudoers/manifest glob updates), mkfs-wrapper member/RO
|
||||||
|
re-checks (validated by `scripts/mkfs-guarded-harness.sh`), classifyClaim empty-lsblk fail-safe,
|
||||||
|
and the blank-format anti-retarget (durable-id-bound, AGENT-001's benign-branch twin).
|
||||||
|
- Deployed on demo host `felhom-pve` (node `demo-felhom`), non-root `felhom-agent` service user,
|
||||||
|
pool-scoped token (`felhom` pool).
|
||||||
|
|
||||||
|
## Open threads
|
||||||
|
|
||||||
|
- Deferred audit items (housekeeping/design, all INFO): C1 (controller-swap version floor), C2 (NAS
|
||||||
|
server allowlist), A2 (gate journal cross-check), B2–B5, E1/E2.
|
||||||
|
- Drive-enrollment leftovers: (a) `runStorageInit` slow-device detached-format polling; (b) Impl-3
|
||||||
|
shared-box operator format gate.
|
||||||
|
- BUNDLE leftover: non-root agent can't read the PBS key; migration must preserve cert/key/tokens.
|
||||||
|
- Not run (needs a supervised session): the destructive D1/D3 live proofs (real mkfs on a crafted
|
||||||
|
member; a live /dev re-enumeration race during a real format).
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Slice 6 Phase A — backup + restore-test orchestration (felhom-agent)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The agent can observe/report storage (slice 5) and reconcile benign guest ops behind a
|
||||||
|
signed-op gate (slice 4), but it has no guest-level **backup/restore** layer and — the point
|
||||||
|
of this slice — no **self-restore-test**, which closes the "a backup you haven't restored
|
||||||
|
isn't a backup" theme (doc 03 §8). This adds: a vzdump backup primitive that resolves the
|
||||||
|
produced archive, a benign restore-to-a-**new** guest, and a journaled **self-restore-test**
|
||||||
|
(restore → boot → verify → teardown) that inherits the slice-4 journal/serialization/crash
|
||||||
|
recovery so a mid-test crash can't leak a scratch guest. Provisioning/identity-reset/golden
|
||||||
|
base (§9) are **slice 7**; PBS/offsite/zero-knowledge (§8 offsite tier) are **Phase B**.
|
||||||
|
|
||||||
|
Everything here is **benign** (backup, restore-to-new, scratch teardown): reuse the slice-4
|
||||||
|
classifier/gate/journal — **no new destructive class, no new crypto**. Version → **v0.6.0-rc1**,
|
||||||
|
stop at the checkpoint.
|
||||||
|
|
||||||
|
**Locked decisions (from the task + clarifications):**
|
||||||
|
- Backups are **crash-consistent only** (marked so in the report); app-consistency needs the
|
||||||
|
controller quiesce (slice 8).
|
||||||
|
- Restore is **to a NEW guest only** (no overwrite anywhere this slice).
|
||||||
|
- Restore-test verify = **net link-down (benign SetConfig) → boot → reaches `running`** (no
|
||||||
|
in-guest probe — slice 8). Link-down avoids the cloned source MAC/IP conflicting on a live
|
||||||
|
host; it is test-safety, **not** slice-7 identity reset.
|
||||||
|
- Restore-test cadence **defaults ON at 24h**, configurable; runs only when a valid scratch
|
||||||
|
VMID band is set; also on-demand via the selftest harness.
|
||||||
|
- Scratch VMID band **990000–990009** (lowest-free, excludes 9999/real guests); refuse to run
|
||||||
|
if unset/invalid.
|
||||||
|
- **Local target only** this phase; `PBSSnapshot` stays a `struct{}` stub (Phase B).
|
||||||
|
- Bulk volumes: **report the gap only** (which `backup=0` mountpoints the guest vzdump omits).
|
||||||
|
|
||||||
|
## The crash-safe core (load-bearing)
|
||||||
|
|
||||||
|
The restore-test is journaled as a **single** entry whose `VMID` is the scratch id and
|
||||||
|
`Kind = "scratch_restore_test"` with a new `Scratch bool` flag. The entry is **terminal only
|
||||||
|
after teardown** — NOT when the restore sub-task's UPID completes. `Recover` must special-case
|
||||||
|
Scratch entries **before** the generic UPID-recheck path (else it would mark the entry
|
||||||
|
`succeeded` because the restore task ran OK, and drop it while the guest still exists → leak).
|
||||||
|
|
||||||
|
`RunRestoreTest` (pseudocode) — `defer` teardown so it runs on every path incl. failed verify:
|
||||||
|
```
|
||||||
|
vmid := pickScratchVMID(ListLXC, [min,max], exclude 9999) // refuse if band unset/invalid
|
||||||
|
opID := "scratch-restore-"+vmid+"-"+seq
|
||||||
|
append({OpID:opID, VMID:vmid, Kind:"scratch_restore_test", Scratch:true, State:OpStarted}) // BEFORE any mutation
|
||||||
|
defer teardownScratch(opID, vmid) // gate.Authorize(IntentForScratchDestroy)=benign -> DestroyLXC -> WaitTask -> terminal
|
||||||
|
upid := RestoreLXC{VMID:vmid, Archive, Storage:RestoreStorage}; waitOK(upid) // UPID = error-detection, NOT terminal
|
||||||
|
for net := range GuestConfig(vmid).Nets(): SetConfig(vmid, {net: existing+",link_down=1"}) // benign
|
||||||
|
Start(vmid); res.Reached = waitRunning(GuestStatus, vmid, BootTimeout)
|
||||||
|
res = {Archive, ScratchVMID:vmid, Pass:res.Reached, Verified:"boot+running", Duration, Err}
|
||||||
|
return res // defer tears down regardless
|
||||||
|
```
|
||||||
|
|
||||||
|
`Recover` scratch branch (added before the existing UPID logic):
|
||||||
|
```
|
||||||
|
if entry.Scratch {
|
||||||
|
guests, err := ListLXC; if err { Unresolved++; continue } // can't decide -> leave in-flight
|
||||||
|
if !contains(guests, entry.VMID) { append(terminal(succeeded)); ScratchClean++; continue } // already gone
|
||||||
|
dec := gate.Authorize(IntentForScratchDestroy(hostID, entry.VMID), nil)
|
||||||
|
if !dec.Allowed { Unresolved++; continue } // fail-safe (should be benign)
|
||||||
|
upid, err := DestroyLXC(entry.VMID); if err { Unresolved++; continue } // retry next Recover
|
||||||
|
waitOK(upid); append(terminal(succeeded)); ScratchDestroyed++
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Idempotent: crash mid-destroy → next `Recover` finds the guest gone → `ScratchClean`. Both
|
||||||
|
teardown paths (normal + recovery) go through `gate.Authorize` (benign `ClassGuestDestroy` +
|
||||||
|
`Provenance{AgentTaggedScratch:true}`) for the audit trail.
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
- **`internal/reconcile/restoretest.go`** (+ `restoretest_test.go`): `RunRestoreTest(ctx, RestoreTestSpec{Archive, RestoreStorage, BootTimeout}) RestoreTestResult` (engine method — it needs the journal/gate/queue internals), `RestoreTestResult` (reconcile-local data — must NOT return `hub.RestoreTest`, to avoid a reconcile→hub edge), `IntentForScratchDestroy(hostID, vmid)`, `pickScratchVMID`. Runs the sequence on the scratch VMID's `Queue` lane.
|
||||||
|
- **`internal/backup/{doc,runner,store,schedule}.go`** (+ tests):
|
||||||
|
- `runner.go`: `BackupRunner.Backup(ctx, vmid) (hub.Backup, error)` = `Vzdump`+`WaitTask`+volid-resolve + bulk-gap from `GuestConfig.MountPoints()` `backup=0`; `LatestBackup(ctx, target) (volid, error)`; the `RestoreTestRunner` seam (satisfied by `*reconcile.Engine`); `RestoreTestResult → hub.RestoreTest` mapping.
|
||||||
|
- `store.go`: mutex-guarded in-memory latest-`Backup`-per-target + latest-`RestoreTest`, implementing the hub `BackupReporter`/`RestoreTestReporter` seams.
|
||||||
|
- `schedule.go`: cadence goroutine (default 24h; 0=disabled) → `LatestBackup` → `engine.RunRestoreTest` → write `Store`. No-ops cleanly when no backup exists yet.
|
||||||
|
- Imports `reconcile`+`hub`+`proxmox` (acyclic; hub imports neither).
|
||||||
|
- **`configs/`**: example agent config with the `backup` block.
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- **`internal/proxmox/mutate.go`**: `DestroyLXC(ctx, vmid)` → `DELETE /nodes/{node}/lxc/{vmid}` with `purge=1&destroy-unreferenced-disks=1` via `dataString` (async→UPID); add `Notes string` to `VzdumpOptions` → `notes-template` param (probe `notes-template` vs `notes` on the demo PVE 9.2.2 first, read-only).
|
||||||
|
- **`internal/proxmox/query.go`**: `LatestBackupVolID(ctx, store, vmid) (string, error)` — `StorageContent` filtered `Content=="backup" && VMID==vmid`, max `CTime`.
|
||||||
|
- **`internal/reconcile/state.go`**: add `RestoreLXC`, `DestroyLXC`, `GuestStatus` to the `GuestAPI` interface (`*proxmox.Client` already satisfies all three).
|
||||||
|
- **`internal/reconcile/journal.go`**: add `Scratch bool \`json:"scratch,omitempty"\``; carry it (and `Kind`) through `terminal()`.
|
||||||
|
- **`internal/reconcile/recover.go`**: the scratch branch above; add `ScratchClean`/`ScratchDestroyed` to `RecoverResult`.
|
||||||
|
- **`internal/reconcile/recover_test.go`** + **`engine_test.go`**: `fakeAPI` gains `RestoreLXC`/`DestroyLXC`/`GuestStatus` recorders; new `TestRecover_LeakedScratchDestroyed` (in-flight Scratch + ListLXC returns the VMID → DestroyLXC called, gate benign, no longer in-flight), `…AlreadyGone` (ScratchClean), `…ListUnreadable` (Unresolved).
|
||||||
|
- **`internal/hub/report.go`**: fill `Backup` + `RestoreTest` structs (see wire shapes); `PBSSnapshot` stays `struct{}`.
|
||||||
|
- **`internal/hub/collect.go`**: add `BackupReporter { Backups(ctx) []Backup }` + `RestoreTestReporter { RestoreTests(ctx) []RestoreTest }` consumer seams (mirror `StorageObserver`); `collectBackups`/`collectRestoreTests` degrade nil/err → non-nil empty.
|
||||||
|
- **`internal/hub/contract_test.go`** + **`internal/hub/testdata/host-report.golden.json`**: populated `backups[0]`/`restore_tests[0]`; assert their key sets (bidirectional, slice-5 pattern).
|
||||||
|
- **`internal/config/config.go`**: `BackupConfig{RestoreTestCadenceSeconds, ScratchVMIDMin, ScratchVMIDMax, LocalBackupTarget, RestoreStorage}` + `RestoreTestCadence()` accessor (0→24h default) + env overlay; validate band (min>0, max>=min, 9999 excluded) only when cadence>0. `Config.Validate` stays proxmox-only.
|
||||||
|
- **`cmd/felhom-agent/main.go`**: wire `backup.Store` into `NewCollector`; add the cadence goroutine alongside `engine.Run`/`loop.Run`/`watchdog.Run`; add `backup` + `restore-test` to `selftestFlag.Set` + the switch + a `-archive` flag; `runSelftestBackup`/`runSelftestRestoreTest` reuse the `runSelftestStorage` wiring (`NewGate(nil, hostID, SlogAudit{}, logger)` + journal + engine).
|
||||||
|
- **felhom.eu/hub**: `hub/internal/api/handler.go` — add `hostBackup`/`hostRestoreTest` mirror structs to `hostReportPayload`, parse, persist via existing `report_json` (no new DB columns — slice-5 precedent), and **log a FAILED restore-test prominently** (`[WARN]`, the loudest DR signal). `hub/internal/api/testdata/host-report.golden.json` byte-identical with the agent golden; `host_test.go` adds `TestHostBackup_GoldenContract`/`TestHostRestoreTest_GoldenContract` (bidirectional key-set, slice-5 pattern).
|
||||||
|
- **`CHANGELOG.md`** (prepend v0.6.0-rc1), **`REPORT.md`** (overwrite), `CLAUDE.md` current-state line.
|
||||||
|
|
||||||
|
## Proposed wire shapes (draft — must land byte-identical in both repos)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Backup struct {
|
||||||
|
TargetID string `json:"target_id"` // backup storage name
|
||||||
|
VMID int `json:"vmid"`
|
||||||
|
Archive string `json:"archive"` // produced volid
|
||||||
|
Mode string `json:"mode"` // snapshot|stop
|
||||||
|
CrashConsistent bool `json:"crash_consistent"` // always true this slice
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
StartedAt string `json:"started_at"` // RFC3339
|
||||||
|
DurationSeconds float64 `json:"duration_seconds"`
|
||||||
|
UncoveredVolumes []string `json:"uncovered_volumes"` // backup=0 mountpoints (bulk gap)
|
||||||
|
}
|
||||||
|
type RestoreTest struct {
|
||||||
|
SourceArchive string `json:"source_archive"`
|
||||||
|
SourceTier string `json:"source_tier"` // "local" (pbs = Phase B)
|
||||||
|
ScratchVMID int `json:"scratch_vmid"`
|
||||||
|
Pass bool `json:"pass"`
|
||||||
|
Verified string `json:"verified"` // "boot+running"
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
TestedAt string `json:"tested_at"` // RFC3339
|
||||||
|
DurationSeconds float64 `json:"duration_seconds"`
|
||||||
|
}
|
||||||
|
type PBSSnapshot struct{} // Phase B stub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `go test ./...` (local, Windows) + `go test -race ./...` on the build server (192.168.0.180, cgo) — the cadence adds a goroutine; the Store is mutex-guarded.
|
||||||
|
- New unit tests: vzdump async UPID→WaitTask + volid-resolve (fake API); restore-to-new benign `ClassCreate` passes the gate; restore-test end-to-end against fakes incl. **(a) teardown-on-failed-verify** and **(b) journal-recovery cleanup** (extend the slice-4 recover test: in-flight Scratch → `Recover` destroys the leaked guest, idempotent when already gone); cadence fires on interval / no-ops when disabled; cross-repo golden + hub-ingest key-set tests.
|
||||||
|
- Build the linux binary on 192.168.0.180, relay to `felhom-pve` (see memory `demo-felhom-live-agent`: `MSYS_NO_PATHCONV=1`, config at `/root/.config/felhom-agent/agent.json`, mode=direct, served-cert pin `BA:7C:99…`).
|
||||||
|
- **Live on the demo** (the checkpoint validation): `--selftest=backup -vmid <small stopped guest>` to a local target (e.g. `felhom-usb` or `local`, content=backup) → print the `Backup` record; then `--selftest=restore-test -archive <volid>` → restore into a 990000-band scratch guest, net-link-down, boot, verify `running`, teardown → print the `RestoreTest` record. Confirm no leaked scratch guest remains (`pct list`), and that a simulated mid-test crash + restart triggers `Recover` teardown.
|
||||||
|
|
||||||
|
## Push & checkpoint
|
||||||
|
|
||||||
|
Push **v0.6.0-rc1** to felhom-agent (and the hub changes to felhom.eu, deploy per the GitOps
|
||||||
|
runbook if needed), update CHANGELOG/REPORT, then **stop and await validation** (restore-test
|
||||||
|
teardown + recovery, the benign classifications, reporting) + the live demo restore-test.
|
||||||
|
**Phase B (next): PBS** — datastore on the USB, zero-knowledge key custody, restore-from-PBS,
|
||||||
|
PBS integrity-verify as the lighter frequent check.
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# felhom-agent
|
||||||
|
|
||||||
|
The **host agent** for the Felhom platform — the operator-tier component that runs on each
|
||||||
|
Proxmox host and owns *all* Proxmox interaction (provision/restore guests, host storage,
|
||||||
|
backups, host+tunnel monitoring, hub control loop, per-guest local API). Design:
|
||||||
|
[`felhom.eu/documentation/architecture/03-host-agent.md`](https://gitea.dooplex.hu/admin/felhom.eu/raw/branch/main/documentation/architecture/03-host-agent.md).
|
||||||
|
|
||||||
|
> **Status — slice 1 of N.** This repo currently contains the project scaffold and the
|
||||||
|
> **`internal/proxmox`** interaction layer (the typed library every other module will call to
|
||||||
|
> talk to Proxmox), plus a runnable read-only `--selftest`. **No** reconcile loop, hub client,
|
||||||
|
> signing, or storage/backup orchestration yet — those are later slices.
|
||||||
|
|
||||||
|
Module: `gitea.dooplex.hu/admin/felhom-agent` · binary: `felhom-agent` · Go 1.24.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/felhom-agent/ # entry point + --selftest modes + the daemon (poll loop + reconcile + watchdog)
|
||||||
|
internal/proxmox/ # the Proxmox interaction layer (API-first + fenced root-CLI)
|
||||||
|
internal/config/ # JSON config + env overrides (secrets never logged)
|
||||||
|
internal/log/ # slog setup
|
||||||
|
internal/authz/ # operator signed-op verifier (SSHSIG); durable nonce store
|
||||||
|
internal/hub/ # daemon: host-report collector + Bearer client + resilient poll loop
|
||||||
|
internal/reconcile/ # reconcile engine + reversibility gate + op journal + crash recovery
|
||||||
|
internal/storage/ # storage-target observer + durable_id + fast-poll watchdog (slice 5)
|
||||||
|
internal/capability/ # privileged-capability self-probe (manifest + sudo -n -l check, v0.44.0)
|
||||||
|
configs/agent.example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## The `capability` package — privileged self-check (v0.44.0)
|
||||||
|
|
||||||
|
The non-root agent depends on a fixed set of `sudo -n` grants (`configs/felhom-agent.sudoers`). When
|
||||||
|
the 2026-06-28 root→non-root cutover silently dropped some (lxc-info, make-private, …), features broke
|
||||||
|
unnoticed until a user hit them. `internal/capability` makes that loud:
|
||||||
|
|
||||||
|
- **`Manifest()` is the source of truth** for the required `(binary, representative-arg)` vectors,
|
||||||
|
seeded from the sudoers audit (the OK + CLOSED rows; the surfaced/deferred rows are excluded). When
|
||||||
|
you add a privileged `runner.Run` call that needs a NEW grant, add it to BOTH the sudoers file AND
|
||||||
|
the manifest — `manifest_test.go` asserts **manifest ⊆ sudoers** at build time (with a red-proof
|
||||||
|
that dropping the lxc-info grant fails the gate), so a missing grant is caught in CI, not in prod.
|
||||||
|
- **`Prober.Probe`** lists each vector with `sudo -n -l` (a policy LIST — never executes, safe for
|
||||||
|
mkfs/pct) + an `os.Stat` existence check → an `ok`/`degraded` snapshot. It runs at startup (logged
|
||||||
|
loud) and on every collect, riding the hub report as `HostReport.Capabilities`; the hub
|
||||||
|
(`HostCapabilityChecker`) alerts the operator on a Critical capability going degraded. Serve-degraded
|
||||||
|
— the probe never blocks startup. (Next self-health slice: the controller↔agent channel check.)
|
||||||
|
|
||||||
|
**Controller-swap under non-root (v0.45.0).** The agent-owned controller image swap
|
||||||
|
(`internal/localapi/controllerswap.go`) no longer shells out: `writeImage` pipes the image ref on
|
||||||
|
**stdin** into an in-guest `tee /etc/felhom-controller-image` (via `GuestExecStdin` →
|
||||||
|
`Runner.RunStdin`, the same fenced `sudo -n` runner) — no `bash -c`, no interpolation. Its 5 narrow
|
||||||
|
grants live in the `FELHOM_CONTROLLERSWAP` sudoers alias (all read-only or fixed-target; the `tee`
|
||||||
|
target is the FIXED image path, content stdin-fed) and in the capability manifest (Critical), so a
|
||||||
|
dropped grant is a build failure + a live degraded signal. No general `pct exec` is granted.
|
||||||
|
|
||||||
|
## The `storage` package — observe + watchdog (slice 5)
|
||||||
|
|
||||||
|
Read-only this slice (no hub desired-state until slice 10):
|
||||||
|
|
||||||
|
- **Observer** builds the host-report's `storage_targets` from `ListStorage`/`NodeStorage`
|
||||||
|
joined with non-privileged host reads (`/proc/mounts`, `/dev/disk/by-uuid`,
|
||||||
|
`/sys/.../rotational`). It reports each target's `durable_id` (the DR-load-bearing
|
||||||
|
re-attach key: fs-UUID for usb/local-dir, `server:export` for nfs/cifs,
|
||||||
|
`repo+fingerprint` for pbs, `vg/pool` for lvmthin), state, usage, a rotational
|
||||||
|
**class hint** (never authoritative — class is hub-owned), and the lvmthin **thin-pool
|
||||||
|
data fill** (a full pool corrupts every guest on it). SMART is a Phase-B privileged read.
|
||||||
|
- **Watchdog** is the third daemon goroutine: a fast poll (seconds) over the *known*
|
||||||
|
target set that detects an `attached↔disconnected` transition and fires a **debounced,
|
||||||
|
out-of-band** host-report so the hub learns of a USB drop in seconds rather than at the
|
||||||
|
~15-minute cycle. It mutates nothing (the benign re-mount-by-UUID response lands in
|
||||||
|
Phase B). The `HostReader` seam keeps it root-free and unit-testable with no real devices.
|
||||||
|
|
||||||
|
The reported `StorageTarget` shape is a cross-repo contract duplicated in `felhom.eu/hub`;
|
||||||
|
`internal/hub/testdata/host-report.golden.json` is byte-identical with the hub's copy and a
|
||||||
|
bidirectional key-set test guards drift.
|
||||||
|
|
||||||
|
### DR recipe — the storage/guest/PBS half (v0.38.0)
|
||||||
|
|
||||||
|
The host-report carries an additive `dr_recipe` section (`internal/hub/dr_recipe.go`) — the agent half
|
||||||
|
of the secret-free reconstruction recipe (`SPIKE-dr-recipe-2026-06-16.md`). It is the non-secret
|
||||||
|
**re-provision scaffolding** that complements escrow (keys) + PBS/restic (bytes): `guests[]` sizing,
|
||||||
|
`drives[]` (user-data drives by `durable_id` → role → mount → intent), `pve_storage[]` (the
|
||||||
|
`storage.cfg` defs), and `pbs` coordinates. Built by the pure `BuildDRRecipeHostHalf` from facts the
|
||||||
|
report already collects — **no new reads**. **Boundary:** every field is an identifier/intent/size/
|
||||||
|
coordinate — never a key/password/token/hash/`ENC:`; the PBS key + restic password stay in escrow.
|
||||||
|
`recipe_version=1`, ignore-unknown on read, pinned in the cross-repo golden. The hub assembles it with
|
||||||
|
the controller's app half into one customer recipe.
|
||||||
|
|
||||||
|
### The privileged `HostOps` surface (slice 5 Phase B)
|
||||||
|
|
||||||
|
The write side — the one place the agent steps outside its Proxmox API token into OS-root —
|
||||||
|
is isolated behind the `HostOps` seam (`hostops.go`): production `SudoHostOps` shells out via
|
||||||
|
a narrow **sudoers allowlist** (`configs/felhom-agent.sudoers`) with **fixed argument vectors
|
||||||
|
and no shell**; tests use a fake (no real root in the suite).
|
||||||
|
|
||||||
|
- **Persistent mounts** are **systemd `.mount` units keyed by fs-UUID**
|
||||||
|
(`What=/dev/disk/by-uuid/<UUID>`, enabled so they survive reboot) — not raw fstab or a
|
||||||
|
transient `mount`. Benign re-mount is idempotent; **detach** (stop+disable) is destructive
|
||||||
|
and routes through the gate.
|
||||||
|
- **Host-reboot remount re-resolution (v0.37.0):** at startup (before binding drives into the
|
||||||
|
guest) and on the periodic tick, `ReassertEnrolledMounts` re-asserts every enrolled `.mount`
|
||||||
|
unit that isn't currently mounted: it re-resolves the drive's `uuid:<fs-uuid>` durable id to
|
||||||
|
its **current** `/dev` node by re-scanning `/dev/disk/by-uuid` (`ResolveStorageDevice`, never a
|
||||||
|
cached node) and re-runs the idempotent `enable --now`. This re-enables a unit a prior detach
|
||||||
|
left `disabled` AND tolerates kernel re-enumeration moving a drive's letter (`/dev/sdb`→`sdc`)
|
||||||
|
— the reshuffle is a no-op. Already-mounted drives and genuinely-absent UUIDs are skipped.
|
||||||
|
- **Every argument is validated before any command is constructed** (`validate.go`): UUIDs
|
||||||
|
against a strict hex regex, mount paths confined + traversal-checked, SMART devices
|
||||||
|
whitelisted to raw disks, LVM names charset-checked. The adversarial matrix in
|
||||||
|
`validate_test.go` proves a hostile UUID / path / device is refused with **zero** exec.
|
||||||
|
- **SMART** (`smart.go`) fills `StorageTarget.smart` via `smartctl -a -j` — SATA *and* NVMe
|
||||||
|
attribute sets, degrading to `UNKNOWN` for devices that expose no SMART (e.g. a USB bridge).
|
||||||
|
**`lvs`** fills the lvmthin thin-pool **metadata** fill (metadata exhaustion corrupts a pool
|
||||||
|
like data exhaustion).
|
||||||
|
- The **watchdog** gains a benign **re-mount response**: when a known mount-backed target's
|
||||||
|
device returns unmounted, it dispatches (off the poll path) a by-UUID re-mount, routed
|
||||||
|
through the gate as benign. The **disk-grow executor** (`pct resize`, **grow-only**) lands
|
||||||
|
in `internal/reconcile` as a benign action; **destructive storage ops** (detach/wipe/
|
||||||
|
data-losing-resize) construct a `ClassStorageWipe`/`ClassDecommission` intent bound to the
|
||||||
|
storage **target identity** and go through the slice-4 gate (built + tested, inert live).
|
||||||
|
|
||||||
|
### `--selftest=storage` (live storage harness)
|
||||||
|
|
||||||
|
Runs standalone on the Proxmox host (no hub needed):
|
||||||
|
- bare: an **observe pass** printing the full `StorageTarget` table incl. the SMART summary
|
||||||
|
and thin-pool data+metadata fill.
|
||||||
|
- `-watch <dur>` (e.g. `--selftest=storage -watch 3m`): runs the watchdog verbose for the
|
||||||
|
window with the **re-mount response live**, so an operator can physically cycle a drive and
|
||||||
|
watch detect → report → re-mount in the logs.
|
||||||
|
|
||||||
|
## The `proxmox` package — model
|
||||||
|
|
||||||
|
Two backends, one fixed routing policy (the fence is structural — `Client` never shells out,
|
||||||
|
`Privileged` never makes an HTTP call; asserted in `routing_test.go`):
|
||||||
|
|
||||||
|
| | Backend | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| **API (default)** | `proxmox.Client` | everything the scoped **FelhomAgent** token can do |
|
||||||
|
| **root-CLI (fenced)** | `proxmox.Privileged` | the **three** proven OS-root exceptions only |
|
||||||
|
|
||||||
|
Grounded entirely in the spike findings (`felhom.eu/documentation/proxmox-platform.md`,
|
||||||
|
`tests/phase{0,1-2,3}-findings.md`). Every mutating API op is **async**: it returns a UPID and
|
||||||
|
the caller `WaitTask`s until the task stops, then asserts `exitstatus == "OK"` — authorization
|
||||||
|
can surface at task execution, not the HTTP POST (phase1-2 §1.3).
|
||||||
|
|
||||||
|
### Public surface
|
||||||
|
|
||||||
|
`Client` (API):
|
||||||
|
|
||||||
|
- Read: `Version`, `Nodes`, `NodeStatus`, `ListLXC`, `GuestStatus`, `GuestConfig`,
|
||||||
|
`ListStorage`, `NodeStorage`, `StorageContent`.
|
||||||
|
- Async mutating (return UPID): `RestoreLXC` (primary create path), `Vzdump`, `Snapshot`,
|
||||||
|
`Rollback`, `DeleteSnapshot`, `SetConfig`, `Start`, `Stop`.
|
||||||
|
- Tasks: `WaitTask`, `TaskStatusOnce`, `TaskLogTail`.
|
||||||
|
- Errors: `*APIError` (parses the offending privilege from a 403), `*TaskError` (parses it from
|
||||||
|
a failed task `exitstatus`).
|
||||||
|
|
||||||
|
`Privileged` (fenced root-CLI) — each method documents *why it can't be the API*:
|
||||||
|
|
||||||
|
- `CreateGoldenLXC` — `pct create` with `keyctl=1` (root@pam-only; the only root-fenced create —
|
||||||
|
the per-customer path provisions by **restore**, which preserves keyctl).
|
||||||
|
- `MountUSBByUUID` — host mount-by-UUID (not a Proxmox API op).
|
||||||
|
- `SMART`, `Sensors` — hardware reads (not API-exposed).
|
||||||
|
|
||||||
|
### API-vs-root routing table
|
||||||
|
|
||||||
|
See the table in [`internal/proxmox/doc.go`](internal/proxmox/doc.go). Summary: the entire guest
|
||||||
|
lifecycle **including restore** is API-token-covered; OS-root is confined to golden-image
|
||||||
|
`keyctl` create, host mounts, and SMART/sensors (phase3 §B3).
|
||||||
|
|
||||||
|
### Controller swap (agentic controller update, Phase 1 — v0.42.0)
|
||||||
|
|
||||||
|
The local API (`internal/localapi/`) owns the in-guest controller image **swap** — the new-architecture
|
||||||
|
replacement for the controller's dead in-container `docker compose` self-update. The in-guest controller
|
||||||
|
pre-pulls the target image then calls the agent:
|
||||||
|
|
||||||
|
- `POST /controller/swap {image}` (`withGuest`-scoped) → **202**, then async: record previous
|
||||||
|
(`/var/lib/felhom-agent/controller-swap-<vmid>.json`) → confirm the target is present in the guest →
|
||||||
|
write `/etc/felhom-controller-image` → `systemctl restart felhom-controller-bootstrap.service` → poll
|
||||||
|
the new controller to healthy (`docker inspect`, ≤90s) → **roll back** to the previous image if not (the
|
||||||
|
guest is never left without a controller). Strict image-ref gate; single-flight per guest (409).
|
||||||
|
- **(v0.47.0) Verify hardening (F1):** the health poll reads `{{.RestartCount}}` (a 4th inspect
|
||||||
|
field) — `running && RestartCount>0` is **not** healthy (a process that already crash-restarted
|
||||||
|
isn't stably up), and a **no-healthcheck** image must report ok on 3 **consecutive** polls (a
|
||||||
|
stability dwell) before acceptance; a real `healthy` result is trusted immediately. This closes the
|
||||||
|
hole where a no-HEALTHCHECK image that crash-loops could land one "Running" instant and false-pass
|
||||||
|
the point-in-time check → no rollback. Verify predicate only — the rollback orchestration + the
|
||||||
|
`docker inspect -f *` grant (the `*` spans the extended template) are unchanged.
|
||||||
|
- `GET /controller/swap/status` → `{state, current, previous, target, error}`.
|
||||||
|
|
||||||
|
The agent is **external** to the controller container, so it survives the controller being killed
|
||||||
|
mid-swap (which the controller cannot do to itself). `GuestBinder.GuestExec` is the single `pct exec`
|
||||||
|
seam. Exercise directly with `--selftest=controller-swap -vmid <id> -image <ref>`.
|
||||||
|
|
||||||
|
## Agent self-update (operator-signed, A/B slots, crash-loop auto-rollback — v0.70.0, TASK D1)
|
||||||
|
|
||||||
|
The agent updates ITSELF the same way it swaps the controller: **the thing that performs rollback is
|
||||||
|
never the thing being updated.** For the agent that means systemd + an ~80-line root shell wrapper
|
||||||
|
(`configs/felhom-selfupdate-guarded`) that changes almost never; the Go binary is what flips.
|
||||||
|
|
||||||
|
**Trust model.** An update is an **operator-signed `agent_update` op** delivered through the existing
|
||||||
|
signed-jobs pipeline (same LOCKED authz gate as `storage_wipe`/`decommission`). The signed params pin
|
||||||
|
the exact **version + sha256**, so the pinned sha is the *only* integrity root — **neither a
|
||||||
|
compromised hub (dumb transport) nor a compromised Gitea (dumb storage) can substitute a binary.**
|
||||||
|
The operator signs offline with `felhom-opsign -op agent_update -agent-version <v> -sha256 <hex>`.
|
||||||
|
|
||||||
|
**The flow** (`internal/selfupdate/` = the Go half; the wrapper = the root half):
|
||||||
|
|
||||||
|
1. The control loop sees a pending signed op → the gate verifies it (pinned-key SSHSIG → namespace →
|
||||||
|
allow-list → crypto → host → time → **durable nonce-burn**) → the `agent_update` executor runs.
|
||||||
|
2. Executor downloads the binary for the signed version from the config'd artifact host
|
||||||
|
(`selfupdate.url_template`, `{version}` interpolated) to `/var/lib/felhom-agent/selfupdate/`,
|
||||||
|
verifies its sha256 against the **signed** value (mismatch → refuse, remove, agent untouched),
|
||||||
|
and hands it to `sudo -n felhom-selfupdate-guarded apply <staged> <sha>`. The job is completed on
|
||||||
|
the hub **after verify+download, before apply** (the nonce is already burned — a queued job would
|
||||||
|
only re-fetch and no-op on the spent nonce; a failed/rolled-back update is visible via the report).
|
||||||
|
3. The wrapper (as root) **re-verifies** the sha, confines the staged path to the staging dir, asserts
|
||||||
|
same-filesystem (the atomic-rename guarantee), snapshots the current binary to `.prev`, atomically
|
||||||
|
`mv`s the new binary into place, writes a `pending.json` marker, `reset-failed`s, and schedules a
|
||||||
|
**detached** restart (`systemd-run --on-active=2s … systemctl restart felhom-agent`, so the caller
|
||||||
|
survives to log the handoff).
|
||||||
|
4. The **new** binary boots; after it has run cleanly for a dwell (`selfupdate.dwell_seconds`, default
|
||||||
|
60) *and* core init is done, `internal/selfupdate.Manager` calls the wrapper's `commit` (clears the
|
||||||
|
marker; `.prev` retained as a manual net). A pending marker naming a *different* version than the
|
||||||
|
running binary is **not** committed — loud WARN, marker left so the report shows why (a human
|
||||||
|
decides).
|
||||||
|
5. **Crash-loop auto-rollback (the safety property).** If the new binary crashes, systemd's
|
||||||
|
`OnFailure=felhom-agent-rollback.service` (the `felhom-agent-limits.conf` drop-in) runs the
|
||||||
|
wrapper's `rollback`: pending marker present → restore `.prev` byte-identical → clear marker →
|
||||||
|
restart → the old binary is back **within seconds of the first crash**. On systemd 257 `OnFailure=`
|
||||||
|
fires on *every* crash, so rollback triggers at the first one; the marker-guard makes every later
|
||||||
|
fire (and any crash with no update in flight) a harmless no-op. The tuned start-limit
|
||||||
|
(`[Unit] StartLimitIntervalSec=120 + StartLimitBurst=4`) is the terminal **backstop** (e.g. an
|
||||||
|
environmental crash loop of the known-good binary → terminal `failed` ≈20s → the hub's
|
||||||
|
`host_staleness` dead-man's-switch alerts the operator).
|
||||||
|
|
||||||
|
**Design provenance:** every systemd behaviour above is empirically validated in
|
||||||
|
`felhom.eu/documentation/audits/SPIKE-agent-selfupdate-2026-07-05.md` (the SF-findings). The host
|
||||||
|
report carries `selfupdate_pending` (+ version) so a runs-but-never-commits binary is visible even
|
||||||
|
though it never crashes. v1 scope: no hub-floor auto-update, no auto-retry of a failed update, no
|
||||||
|
pending-timeout auto-rollback (a stuck-but-alive binary is caught by `host_staleness`).
|
||||||
|
|
||||||
|
### TLS trust
|
||||||
|
|
||||||
|
The host serves a self-signed cert. Verification is **not** blanket-disabled. Pick one in
|
||||||
|
config: `ca_file` (PEM, full verify), `fingerprint` (SHA-256 of the host leaf cert — pinned
|
||||||
|
exact-cert match; the `/nodes` API returns each node's `ssl_fingerprint` to pin), or the
|
||||||
|
explicitly-named `insecure_skip_verify` (off by default; selftest-against-127.0.0.1 only).
|
||||||
|
|
||||||
|
## Provisioning the token (out-of-band, operator side)
|
||||||
|
|
||||||
|
The agent only **consumes** a privilege-separated API token; role setup is a provisioning step.
|
||||||
|
The role must be granted on **both the user AND the token** for the same path, or the
|
||||||
|
intersection is empty and every call 403s (phase1-2 §1.2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pveum role add FelhomAgent -privs "VM.Allocate VM.Audit VM.Config.Disk VM.Config.CPU \
|
||||||
|
VM.Config.Memory VM.Config.Network VM.Config.Options VM.PowerMgmt VM.Snapshot \
|
||||||
|
VM.Snapshot.Rollback VM.Backup Datastore.Allocate Datastore.AllocateSpace \
|
||||||
|
Datastore.Audit Sys.Audit SDN.Use" # 16 privileges, validated Phase 3 B3
|
||||||
|
pveum user add felhom-agent@pve
|
||||||
|
pveum user token add felhom-agent@pve agent --privsep 1 # capture the secret (shown once)
|
||||||
|
pveum acl modify / -user 'felhom-agent@pve' -role FelhomAgent
|
||||||
|
pveum acl modify / -token 'felhom-agent@pve!agent' -role FelhomAgent
|
||||||
|
```
|
||||||
|
|
||||||
|
(`VM.Config.CPUMemory` is **not** a real privilege; `SDN.Use` **is** required for bridge use.)
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
# read-only health check against the host:
|
||||||
|
./felhom-agent --config configs/agent.example.json --selftest
|
||||||
|
# or via env (keeps the secret off disk):
|
||||||
|
FELHOM_AGENT_PROXMOX_TOKEN='felhom-agent@pve!agent=SECRET' \
|
||||||
|
FELHOM_AGENT_PROXMOX_NODE=demo-felhom \
|
||||||
|
FELHOM_AGENT_PROXMOX_ENDPOINT=https://192.168.0.162:8006 \
|
||||||
|
FELHOM_AGENT_PROXMOX_TLS_FINGERPRINT='BA:7C:...:CF' \
|
||||||
|
./felhom-agent --selftest
|
||||||
|
```
|
||||||
|
|
||||||
|
`--selftest` (read-only) loads config, builds the API client, and runs the read queries (version,
|
||||||
|
nodes, node status, guests, storage), printing a short health report. It mutates nothing and says
|
||||||
|
so cleanly if the token/endpoint isn't configured.
|
||||||
|
|
||||||
|
`--selftest=task --vmid N` (explicitly gated) exercises `WaitTask` on a **reversible** op
|
||||||
|
(snapshot → rollback → delete-snapshot) against guest `N`. Default `--selftest` never mutates.
|
||||||
|
|
||||||
|
`--selftest=bring-up|provision` accept an optional operator **CPU/RAM cap**: `-cores N` and
|
||||||
|
`-memory M` (MiB). Both default to `0` = keep the golden's baked sizes. When set, the cap is written
|
||||||
|
into the SAME pre-start config PUT as the identity reset (via `BringUpSpec.Cores/MemoryMB`), so the
|
||||||
|
guest never boots uncapped — useful when the appliance shares a host with other guests.
|
||||||
|
|
||||||
|
**Pool-scoped restore (v0.53.0):** every restore (provision bring-up, DR, and restore-test) allocates
|
||||||
|
the guest INTO the `felhom` PVE pool (`reconcile.DefaultPool`; `RestoreLXCOptions.Pool` → `pct restore
|
||||||
|
--pool`). This is what lets the agent token be scoped to `/pool/felhom` + `/storage/<targets>` instead
|
||||||
|
of `/` (blast-radius containment on a shared host) — the restore is how a fresh vmid is allocated under
|
||||||
|
that scoped token (`VM.Allocate`+`Pool.Allocate` at `/pool/felhom`). Layout + validation:
|
||||||
|
`felhom.eu/documentation/audits/SPIKE-pool-scoped-acl-2026-07-01.md`.
|
||||||
|
|
||||||
|
**Drive discovery + tracking (v0.55.0, Impl-2a):** `GET /disks/candidates` lists host disks the Impl-1
|
||||||
|
filter proves are free to enroll (split initialize/attach). The watchdog's known-drive set is sourced
|
||||||
|
from the intent registry + Felhom `.mount` units (`RegistryKnownTargets`), NOT PVE storages — so a drive
|
||||||
|
enrolled with no PVE dir-storage is still health-tracked (`Observe()` stays for real PVE storages). The
|
||||||
|
controller wizard consuming this is Impl-2b.
|
||||||
|
|
||||||
|
**Format safety (v0.54.0, Impl-1; hardened v0.61.0, audit D1/D2/D3):** `Format` (mkfs) is gated by a
|
||||||
|
mandatory **unclaimed-disk guard** (`internal/storage/claim.go`) — it refuses any device not provably
|
||||||
|
free for Felhom (OS disk, LVM PV, ZFS/mdraid member, foreign mount, read-only; fail-safe on any read
|
||||||
|
error, and on an empty/target-absent lsblk topology — D2), independent of `DataBearing`. Below the
|
||||||
|
agent, mkfs runs ONLY through `configs/felhom-mkfs-guarded.sh` (the sole mkfs the sudoers permits),
|
||||||
|
which re-checks the catastrophic cases as root: system disk, LVM PV (absolute-path pvs), foreign
|
||||||
|
mount, read-only device, and any LVM/ZFS/mdraid/LUKS/swap member signature (D1 — validated by
|
||||||
|
`scripts/mkfs-guarded-harness.sh`, a loop-device + recorder harness). The blank-format local-API path
|
||||||
|
binds to the device's durable id and anti-retarget re-resolves before mkfs, same as the confirmed
|
||||||
|
wipe (D3, AGENT-001's benign-branch twin). The pool-scoped token does NOT touch mkfs (sudo op) — the
|
||||||
|
filter + wrapper are the guard. See `SPIKE-drive-enrollment-2026-07-01.md` and
|
||||||
|
`felhom.eu/documentation/audits/AUDIT-blast-radius-hostroot-localapi-2026-07-02.md`.
|
||||||
|
|
||||||
|
## Process model
|
||||||
|
|
||||||
|
Native Go binary, systemd service, **non-root** `felhom-agent` service user holding the scoped token,
|
||||||
|
with a **narrow sudoers allowlist** for the fenced host-root ops. `privileged.mode: "sudo"` matches
|
||||||
|
this; `"direct"` is for dev/CI where the agent is already root.
|
||||||
|
|
||||||
|
The canonical artifacts (BUNDLE slice):
|
||||||
|
- **`configs/felhom-agent.service`** — the canonical unit (`User=felhom-agent`,
|
||||||
|
`ExecStart=/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json`). It deliberately sets
|
||||||
|
**no** `NoNewPrivileges` and **no** mount-namespacing hardening (`ProtectHome`/`PrivateTmp`/…): the
|
||||||
|
first would block the `sudo` the agent needs, the second would put the agent in a private mount
|
||||||
|
namespace so its `mount --bind` drive enrollments wouldn't propagate into the running guest. The
|
||||||
|
security boundary is the sudoers allowlist, not systemd sandboxing.
|
||||||
|
- **`configs/felhom-agent.sudoers`** → `/etc/sudoers.d/felhom-agent` (0440, `visudo -cf`-validated).
|
||||||
|
- **`scripts/publish-agent.sh`** publishes the binary to Gitea as a generic package
|
||||||
|
(`/api/packages/admin/generic/felhom-agent/<ver>/felhom-agent`), printing the sha256 the operator
|
||||||
|
records in the hub artifact manifest.
|
||||||
|
|
||||||
|
**Install is automated.** The host-bootstrap script (`felhom.eu/scripts/felhom-host-install.sh`) fetches
|
||||||
|
the binary from Gitea, verifies its sha256 against the hub-vouched manifest, then installs the user +
|
||||||
|
binary + sudoers + unit + config — no manual agent install step.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./... && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit tests use a mock HTTP transport + mock runner (no live host): UPID parse, `WaitTask`
|
||||||
|
(running→OK / running→failed-403 / timeout / ctx-cancel), 403→privilege-named error, response
|
||||||
|
decoding against the captured live shapes, and the API-vs-root routing fence.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Findings — OS/Docker-data storage-split spike (Part B)
|
||||||
|
|
||||||
|
**Date:** 2026-06-13 · **Host:** demo-felhom (PVE 9.2.2) · **Throwaway:** unprivileged LXC 9300 (created, exercised, destroyed) · **Builds nothing** — this report gates the provisioning spec.
|
||||||
|
|
||||||
|
**Goal:** prove the mechanics of splitting the controller guest's **OS rootfs** from its **Docker/data** onto separate `local-lvm` volumes, and the live-migration path, before writing the provisioning spec. Target shape to validate: a small OS rootfs (~32 GB default) + a separate large Docker-data volume (~256 GB), sizes configurable at install.
|
||||||
|
|
||||||
|
**Method:** all risky steps were run on a throwaway unprivileged LXC (9300) replicating guest 9201's config (`unprivileged: 1`, `features nesting=1,keyctl=1`, rootfs on `local-lvm`), never on 9201's live data. Docker 29.5.3 — **identical to production 9201** (verified: both `overlayfs` driver + `io.containerd.snapshotter.v1`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict summary
|
||||||
|
|
||||||
|
| Item | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| B1 — second volume + Docker `data-root` on unprivileged LXC | ✅ **Works.** overlayfs on the ext4 `local-lvm` mountpoint; no idmap/ownership problem; survives reboot. |
|
||||||
|
| B2 — live move-then-verify migration | ✅ **Works**, copy-not-move is the safety net. Caveat: rsync must exist + its exit code must be checked. |
|
||||||
|
| B3 — vzdump/PBS coverage of the new mountpoint | ⚠️ **NOT by default — contradicts the spec's premise.** Extra CT mountpoints are **excluded** from vzdump unless `backup=1` is set **and the CT restarted**. |
|
||||||
|
| B4 — live resize vs fresh-install carving | ✅ Live grow is online/non-disruptive (`pct resize`). Fresh-install carving = installer `hdsize`/`maxroot`/`maxvz` (host-level, documented below). |
|
||||||
|
| B5 — provisioning seams | ✅ Mapped: golden rootfs size + `BringUpSpec.RootfsGrowGB`/`Mounts`; `bringup.go:313` is the exact spot that today omits `backup=1`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B1 — Second volume + Docker `data-root` on an unprivileged LXC ✅
|
||||||
|
|
||||||
|
- Added a second `local-lvm` mountpoint: `pct set 9300 -mp0 local-lvm:16,mp=/mnt/dockerdata`. PVE created `vm-9300-disk-1`, formatted it **ext4**, mounted it; in-container it is `root:root 0755` — **no idmap/ownership fix needed** (PVE maps the unprivileged offset for a fresh volume automatically).
|
||||||
|
- Pointed Docker at it via `/etc/docker/daemon.json` `{"data-root":"/mnt/dockerdata"}` → after `systemctl restart docker`, `docker info` reports `Docker Root Dir: /mnt/dockerdata`, `Storage Driver: overlayfs`.
|
||||||
|
- **overlay works on the mountpoint's filesystem** (ext4): pulled an image + created a named volume + wrote to both the volume and the container's overlay layer. Image/volume bytes landed on `/mnt/dockerdata` (228K → 64M) while the rootfs `/var/lib/docker` stayed flat (232K).
|
||||||
|
- **Survives reboot:** after `pct reboot`, `data-root` is still `/mnt/dockerdata`, the volume's marker file persists, the image is present.
|
||||||
|
|
||||||
|
**Gotcha (Docker 29 / containerd-snapshotter):** the daemon already auto-starts at boot, so `systemctl start docker` is a no-op and won't pick up a freshly-written `daemon.json` — use `systemctl restart docker`. Also note the driver is the new **`overlayfs`** (containerd-snapshotter), not legacy `overlay2` — relevant to B2.
|
||||||
|
|
||||||
|
## B2 — Live migration sequence (move-then-verify) ✅
|
||||||
|
|
||||||
|
Proven on the throwaway with seeded **running** apps (postgres with a known row `migrate-survive` in a named volume + nginx). The safe sequence:
|
||||||
|
|
||||||
|
1. `docker stop <apps>` 2. `systemctl stop docker docker.socket` 3. **copy, not move:** `rsync -aHAX --numeric-ids /var/lib/docker/ /mnt/dockerdata/` 4. write `data-root` → `/mnt/dockerdata` in `daemon.json` 5. `systemctl start docker` 6. **verify** images + `docker volume ls` + start apps + query the DB row 7. **only then** reclaim the old location (`rm -rf /var/lib/docker/*`).
|
||||||
|
|
||||||
|
Result: post-switch the `pgdata` volume was present, both apps booted, and the DB row was intact. Reclaiming the old path while apps ran on the new `data-root` did not disturb them — the new `data-root` is fully self-sufficient.
|
||||||
|
|
||||||
|
**Two hard lessons (both nearly caused a silent false-positive):**
|
||||||
|
- **`rsync` was not installed** on the fresh guest; the first attempt's `rsync … | grep …` swallowed the `127` exit (pipe returns grep's status, `set -e` doesn't fire), so the migration "completed" having copied **nothing**. The migration script MUST install rsync and **check rsync's own exit code**, not a piped one.
|
||||||
|
- With **containerd-snapshotter** the image content store sits under `data-root/containerd…`; switching `data-root` to an empty volume made images *appear* present (they resolve from the snapshotter's own addressing) while volumes/containers were empty — a misleading partial state. The reliable approach is to **rsync the entire `data-root` with docker stopped** (captures the containerd store + volumes + containers) and verify functional integrity (volume + DB row + boot), or treat images as re-pullable and migrate only the volumes (the irreplaceable data). The copy-not-move discipline meant the source was always intact and recoverable — I reverted `data-root` and the original apps + DB row came straight back.
|
||||||
|
|
||||||
|
## B3 — Backup coverage after the split ⚠️ (the load-bearing finding)
|
||||||
|
|
||||||
|
**The spec's premise is wrong.** It assumed the new mountpoint "should be [captured], as a CT mountpoint unless `backup=0`." The opposite is true on PVE 9.2.2:
|
||||||
|
|
||||||
|
```
|
||||||
|
# mp0 with NO backup flag:
|
||||||
|
INFO: excluding volume mount point mp0 ('/mnt/dockerdata') from backup (disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional LXC mountpoints are **excluded from vzdump by default**. They are included **only** when `backup=1` is set on the mountpoint. And setting it on a running CT is a **pending change** (`[pve:pending]`) — vzdump kept excluding mp0 until the **CT was restarted**, after which:
|
||||||
|
|
||||||
|
```
|
||||||
|
# mp0 with backup=1, post-restart:
|
||||||
|
INFO: including mount point mp0 ('/mnt/dockerdata') in backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirmed end-to-end: the resulting archive contains `./mnt/dockerdata/volumes/pgdata/_data/…` — the DB data is in PBS/vzdump only once `backup=1` is active.
|
||||||
|
|
||||||
|
**Implication:** if the OS/data split lands without `backup=1` on the docker-data mountpoint, **every named-volume database silently falls out of the PBS whole-guest snapshot** — exactly the class of silent-default trap the spike exists to catch (cf. the PBS `ignore-verified` default). This is the single must-fix for the provisioning spec.
|
||||||
|
|
||||||
|
Honest note (as the spec asked): because **both** volumes stay in PBS once `backup=1` is set, the backup **size does not shrink** — the win of the split is independent sizing/growth and a smaller, faster-to-restore OS rootfs, **not** a smaller backup. (Excluding images from PBS would shrink it but would drop DB coverage — out of scope; not done.)
|
||||||
|
|
||||||
|
## B4 — Resize paths: live vs fresh install ✅
|
||||||
|
|
||||||
|
- **Live (within the existing 349 GB `local-lvm`), non-disruptive:** `pct resize 9300 rootfs +2G` and `pct resize 9300 mp0 +4G` both grew the volume **and** the in-guest ext4 online (7.8→9.8 GB rootfs, 16→20 GB docker-data) with the CT running and the DB still queryable — no reboot, no downtime. Adding a brand-new mountpoint (`pct set -mpN`) is also non-disruptive to the rootfs, but the **mount itself only appears after a CT restart** on an unprivileged guest (same pending-activation behaviour seen with `backup=1` and with drive binds — consistent with the agent's existing "activate at next boot" note). Grow-only is safe; shrinking LVM-thin volumes is not supported online and was not attempted. The host root (`local`) was **not** repartitioned on the live system.
|
||||||
|
- **Fresh install (host-level carving — this is where "configurable at install" lives):** the Proxmox ISO installer's *Advanced LVM options* expose `hdsize` (total of the disk PVE uses), `maxroot` (cap on the host `/` = `pve/root`), `maxvz` (cap on the `data` thin pool = `local-lvm`; set to 0 / leave headroom to keep space unallocated), `minfree` (reserved free space in the VG), and `swapsize`. These decide how the SSD is split between a small **host** root and a large **`local-lvm` thin pool**. Note the distinction the spec's target shape blurs: those installer knobs size the **host's** local-vs-thinpool; the **guest's** 32 GB-rootfs + 256 GB-docker-data split is sized at *provision* time via `pct` (rootfs size + an additive mountpoint), carved out of the thin pool — not by the installer. On the demo, the thin pool already has ~350 GB free, so per-guest carving needs no host repartition.
|
||||||
|
|
||||||
|
## B5 — Provisioning integration points ✅
|
||||||
|
|
||||||
|
Where the guest is sized today, and where the split + configurable sizes slot in:
|
||||||
|
|
||||||
|
- **Golden rootfs size — `configs/build-golden.sh:40`:** `--rootfs "${ROOTFS_STORAGE}:8"` — the OS rootfs is **hardcoded at 8 GB**. The golden also `docker pull`s the controller + app images into `/var/lib/docker` **on the rootfs** (lines ~71–92), so the baked-image set is bounded by this size. `ROOTFS_STORAGE` is the 3rd script arg (default `local-lvm`). The golden runs the same docker-ce as production (→ 29.5.3 overlayfs/containerd-snapshotter).
|
||||||
|
- **Provision bring-up — `internal/reconcile/bringup.go`:** `BringUpSpec` already carries the right seams: `RestoreStorage` (rootfs target storage), `RootfsGrowGB` (grow-only rootfs resize, applied as its own `ResizeLXC` call at line ~205), and `Mounts []GuestMount{Storage,SizeGB,MountPoint}` (additive `mpN`, attached in `buildConfigParams` at line ~313). So a docker-data volume is a natural `GuestMount`, and the OS rootfs can be grown per-customer via `RootfsGrowGB`.
|
||||||
|
- **🔴 Exact code spot for the B3 fix — `bringup.go:313`:** today it builds `fmt.Sprintf("%s:%d,mp=%s", m.Storage, m.SizeGB, m.MountPoint)` — **no `backup=` flag**, so any additive mount (including a future docker-data volume) is created `backup=0` = **excluded from PBS**. The docker-data mount MUST be attached with `,backup=1` (and the provision flow must account for the restart-to-activate behaviour). `GuestMount` should gain a `Backup bool` (or always-on for the data mount).
|
||||||
|
- **Where the size config comes from:** currently caller-provided — the `--selftest=provision`/bring-up path and `cfg.Backup.RestoreStorage`. The in-code marker (`bringup.go:49-50`) states slice 10 wires the **hub storage manifest** into `Mounts`; that is the right home for per-customer sizes, consistent with bootstrap-v2 (the controller already pulls customer-scoped config from the hub). So "configurable at install" = a per-customer field in the hub desired-state → `RootfsGrowGB` + `GuestMount.SizeGB`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions for the provisioning spec to resolve
|
||||||
|
|
||||||
|
1. **Where does `data-root` get redirected for a *fresh* guest?** The golden bakes images into `/var/lib/docker` on the rootfs. Mounting an empty data volume **over** `/var/lib/docker` hides the baked images. Options: (a) bake `daemon.json` `data-root=/mnt/dockerdata` into the golden and attach the (empty) data mount before first Docker start, re-pulling images on first boot; (b) bake the split into the golden itself (data volume present at golden-build time, images baked onto it); (c) first-boot migration step. Pick one; (b) keeps the baked-image fast-start, (a) is simplest but loses it.
|
||||||
|
2. **`backup=1` is mandatory on the docker-data mount** (B3). Encode it at `bringup.go:313` and decide whether the OS rootfs alone (no app data) is worth a separate lighter backup cadence.
|
||||||
|
3. **Restart-to-activate** (B3/B4): attaching a mount or flipping `backup=1` on a running unprivileged guest is pending until reboot. Provisioning attaches mounts *before* first start, so this is a non-issue at provision time, but any *post-hoc* migration of an existing customer guest must schedule a reboot.
|
||||||
|
4. **Default sizes & source:** confirm 32 GB rootfs / 256 GB docker-data defaults, and wire them through the hub storage manifest (per-customer), sized to the actual SSD via the installer carving facts in B4.
|
||||||
|
5. **Backup size does not shrink** (B3): set expectations — the split is for independent sizing/growth and a smaller OS-rootfs restore, not a smaller PBS footprint.
|
||||||
|
|
||||||
|
**Nothing was shipped.** Throwaway LXC 9300 and its test archives were destroyed after the spike.
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# REPORT — R-86: a restore-test proves each BACKUP, not the clock
|
||||||
|
|
||||||
|
**Date:** 2026-08-03 · **Repo:** `felhom-agent` **v0.120.0 → v0.121.0 → v0.121.1**
|
||||||
|
(`4618169`, `4d82591`, `53d0c6b`) ·
|
||||||
|
released, published, verified by independent download, deployed to demo-felhom and **proven live**.
|
||||||
|
Sibling half: `felhom.eu` hub **v0.91.0 → v0.91.1** — the two ship together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Baselines, re-read on arrival
|
||||||
|
|
||||||
|
| Repo | `main` @ commit | Version | Matched §1? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `felhom-agent` | `1b14cfd0b48b` | `v0.120.0` | **yes** |
|
||||||
|
| `felhom.eu` | `e34b614e5b65` | CHANGELOG `v0.90.0`, deployed image `0.90.1` | **yes — the discrepancy was real and is fixed** (entry backfilled) |
|
||||||
|
|
||||||
|
Highest register ID in use was **R-184**; `R-185`–`R-187` established free by grep across all four
|
||||||
|
repos and `documentation/`.
|
||||||
|
|
||||||
|
## 2. The rule, in one sentence — and the trap it avoids
|
||||||
|
|
||||||
|
> Let **A** be the newest archive on a tier that has settled for at least the settle lag (24 h).
|
||||||
|
> The tier is **DUE** when A exists and **A has not already been proven**.
|
||||||
|
|
||||||
|
The literal reading of R-86's own wording — *"due when the newest archive is ≥ 24 h old"* — is
|
||||||
|
**never true on a daily tier**, because a new archive lands each day and resets the newest-archive age
|
||||||
|
to zero long before it reaches the lag. It would have switched restore-testing **off** for the tier
|
||||||
|
that matters most, silently, while looking like the row was implemented.
|
||||||
|
|
||||||
|
**Evidence that a daily tier does become due**, at three levels:
|
||||||
|
|
||||||
|
1. **Unit, time-driven** — `TestDue_DailyTierIsProvedDailyOnItsOwnArchive`: five simulated days, one
|
||||||
|
archive a day, evaluated hourly (120 evaluations) → **exactly 5 runs**, and run *i* tests day
|
||||||
|
*i−1*'s archive, never the still-settling one.
|
||||||
|
2. **The red-proof of the naive rule** — implemented and observed failing at **0 runs over 5 days**
|
||||||
|
(§6), which is the trap made visible rather than argued about.
|
||||||
|
3. **Live** — the offsite tier on demo-felhom became due on its own archive and ran (§7).
|
||||||
|
|
||||||
|
## 3. What changed
|
||||||
|
|
||||||
|
| Piece | File | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| the due-check | `internal/backup/restoretest_due.go` (new) | `EvaluateDue` / `evaluateTier` — per-tier verdict + the reason, ordered oldest-proven first |
|
||||||
|
| the trigger | `internal/backup/schedule.go` | the ticker is now the **evaluation interval**; `pickForThisRun` answers *"is anything due?"*, and "nothing" is a normal answer |
|
||||||
|
| the state | `internal/backup/restoretest_state.go` | records **which archive** was proven, with migration |
|
||||||
|
| the picker | `internal/backup/runner.go` | `PickSettledRestoreCandidateOn(ctx, target, notAfter)`; `PickRestoreCandidateOn` is a one-line call into it |
|
||||||
|
| the knobs | `internal/config/config.go` | `restore_test_eval_interval_seconds` + `restore_test_settle_seconds`; the old key deprecated, not repurposed |
|
||||||
|
| the wiring | `cmd/felhom-agent/main.go` | settle-aware picker + `Settle`; deprecation WARN; new `--selftest=restore-test-due` |
|
||||||
|
| the observable (**v0.121.1**) | `internal/backup/schedule.go`, `restoretest_due.go` | a not-due evaluation logs one **INFO** line naming every tier's verdict — see §9 |
|
||||||
|
|
||||||
|
### The state records the archive (§8.2)
|
||||||
|
|
||||||
|
A timestamp cannot answer *"have we proven **this** archive"* — it is the same class as the workspace
|
||||||
|
rule that a timestamp records an *attempt*, not a *result*: here it records a result, but not **which**
|
||||||
|
result. `RestoreTestState` now holds `{archive, proven_at}` per tier.
|
||||||
|
|
||||||
|
**Migration:** a pre-R-86 file (`{"target": "<RFC3339>"}`) keeps its **time** — rotation ordering
|
||||||
|
survives a deploy, which is why the file exists at all — and yields **no proven archive**, so each
|
||||||
|
tier is due exactly once after the upgrade. One extra test per tier, once, is the safe direction;
|
||||||
|
reading a legacy time as proof of whatever archive is current would invent a guarantee.
|
||||||
|
|
||||||
|
### The config (§8.3) — and a correction to the spec
|
||||||
|
|
||||||
|
The spec said *"`0` must keep meaning disabled"*. **In the code as it stands, `0` means *use the
|
||||||
|
default* and NEGATIVE means disabled** (`RestoreTestCadence`, pre-existing). Making `0` disable would
|
||||||
|
have switched restore-testing off on every box that leaves the key unset — the worst possible reading
|
||||||
|
— so the actual semantics were preserved and this is flagged rather than silently followed.
|
||||||
|
|
||||||
|
- `restore_test_eval_interval_seconds` — how often due-ness is **asked**. Default **6 h**.
|
||||||
|
- `restore_test_settle_seconds` — how long an archive must sit. Default **24 h**.
|
||||||
|
- `restore_test_cadence_seconds` — **deprecated**. Negative still **disables**, verbatim. A positive
|
||||||
|
value seeds the **settle lag** (the quantity a person setting it was expressing: how long may pass
|
||||||
|
between a backup and confidence that it restores), and the daemon logs one start-up WARN naming
|
||||||
|
both replacements. It is deliberately **not** carried into the evaluation interval: a box that set
|
||||||
|
72 h to spare a weak endpoint would otherwise get a 72-hour-latency due-check, whereas what it
|
||||||
|
wanted — fewer heavy restores — is what per-archive due-ness already gives it.
|
||||||
|
|
||||||
|
## 4. Part 1.4 — the measurement, and the interval chosen from it
|
||||||
|
|
||||||
|
Measured on demo-felhom, 2026-08-03, via `--selftest=restore-test-due` and by timing the underlying
|
||||||
|
API call directly (3 runs each):
|
||||||
|
|
||||||
|
| tier | what it is | one due-check |
|
||||||
|
|---|---|---|
|
||||||
|
| `felhom-backup` (dir) | local, on-box | **18 ms** (18.7 / 18.3 / 18.5) |
|
||||||
|
| `felhom-pbs` | offsite, **WAN to ep0** | **392 ms** (375 / 378 / 424) |
|
||||||
|
| both together | one full evaluation | **430 ms** |
|
||||||
|
|
||||||
|
**Cost does not set the interval** — even at one evaluation a minute the offsite leg would be ~0.7 %
|
||||||
|
of the link's time. What sets it is the other bound, and it is not in the brief: **under a per-archive
|
||||||
|
due-check a FAILING tier stays due, so the evaluation interval is also its RETRY interval — and a
|
||||||
|
retry is a multi-GB restore.** Every few minutes would be an incident of its own; the old timer
|
||||||
|
retried a broken tier once a day.
|
||||||
|
|
||||||
|
**6 h chosen from both ends:** at most four heavy retries a day in the worst case, and at most 6 h of
|
||||||
|
latency between an archive settling and its proof — negligible against a 24 h settle lag, so a daily
|
||||||
|
tier is still proved daily. No second rate limiter was added (§8.4): the pacing remains one test per
|
||||||
|
archive generation.
|
||||||
|
|
||||||
|
## 5. Two hazards the new frequency created, and their fixes
|
||||||
|
|
||||||
|
Both are consequences of evaluating often rather than daily, and neither is in the brief:
|
||||||
|
|
||||||
|
1. **The due-check now runs BEFORE the heavy-operation gate is taken.** Holding that gate for a read
|
||||||
|
that answers "nothing to do" would open a window at *every* evaluation in which a starting backup
|
||||||
|
cannot acquire — and a backup that cannot acquire does not merely wait, it **records a failure and
|
||||||
|
pages the operator** (F-A1). Nothing heavy starts before the gate; due-ness does not expire while
|
||||||
|
we check.
|
||||||
|
2. **The candidate picker skips implausible archives.** Under per-archive due-ness an incomplete
|
||||||
|
1-byte phantom (F-CRIT-2's artefact, which server-side prune does **not** collect) would be picked
|
||||||
|
forever, fail forever, never earn proof, and leave the tier due at *every* evaluation — turning the
|
||||||
|
evaluation interval into the retry rate for a multi-GB restore. `archivePlausiblyComplete` (the
|
||||||
|
canonical helper, with its warn-once companion) is applied in the shared scan, so both callers
|
||||||
|
agree. **This is a behaviour change to `PickRestoreCandidateOn`** and is recorded as such.
|
||||||
|
|
||||||
|
## 6. Tests and red-proofs
|
||||||
|
|
||||||
|
Green gate, both repos: `go build ./... && go vet ./... && go test ./...` — agent **29 packages ok,
|
||||||
|
rc=0**; hub **rc=0**. The test run and the commit were always separate commands.
|
||||||
|
|
||||||
|
| # | Test | Asserts | Mutation | Observed |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A | `TestDue_DailyTierIsProvedDailyOnItsOwnArchive` | 5 runs over 5 days, each on the settled archive | the naive rule (`now-landed >= settle`, proven-archive check deleted, cutoff removed) | **FAIL** — `a daily tier must be proved once per day; got 0 run(s) over 5 days: []` |
|
||||||
|
| B | `TestDue_WeeklyTierIsProvedOncePerArchive` | 84 evaluations over 3 weeks → exactly 3 runs, one per archive | — | pass |
|
||||||
|
| C | `TestDue_RestartRunsNothing` | two restarts + 4 evaluations → **0 runs** | `ProvenArchive` reverted to per-tier time | **FAIL** — `2 restart(s) produced 4 run(s)` |
|
||||||
|
| D | `TestDue_NewSettledArchiveMakesAProvedTierDueAgain` | a newly settled archive re-arms the tier, and the NEW archive is tested | — | pass |
|
||||||
|
| E | `TestDue_FailingTierIsRetriedAndNeverProven` | 3 evaluations → 3 retries, no proof recorded | credit on failure (`rt.Pass &&` dropped) | **FAIL** — `got 1 run(s) over 3 evaluations` + `TestRotation_FailureEarnsNoCredit` also failed |
|
||||||
|
| F | `TestDue_TwoDueTiersRunOneAtATime` | one evaluation → one run; the other is deferred and runs next | — | pass |
|
||||||
|
| F′ | `TestDue_DeferredBehindABackupStaysDue` | the gate holds; a deferred tier stays DUE | — | pass |
|
||||||
|
| H | `TestDue_NewbornTierIsNotDueAndNotAnError` | no archive → not due, no error, **and a reason** | — | pass |
|
||||||
|
| — | `TestDue_UnsettledArchiveIsNotACandidate` | a 2 h-old archive is not a candidate under a 24 h lag | — | pass |
|
||||||
|
| — | `TestDue_LookupFailureIsUnknownNotNotDue` | a tier that cannot be listed is UNKNOWN, the error travels, the other tier still runs | — | pass |
|
||||||
|
| — | `TestRestoreTestState_LegacyFileMigratesToNothingProven` | legacy time kept, no archive claimed | — | pass |
|
||||||
|
| — | `TestPickRestoreCandidate_SkipsImplausibleArchives` | the newest entry is not a candidate if it cannot be complete | guard removed | **FAIL** — `pick = "phantom" want the newest COMPLETE archive 'real'` |
|
||||||
|
| I | `TestMainWiresTheSettleAwareTierPicker` | **AST** of `main.go`: settle picker wired, old picker gone, `Settle` set, eval-interval accessor called | the wiring line commented out | **FAIL** — `main.go never passes runner.PickSettledRestoreCandidateOn …` (a `strings.Contains` check would have PASSED — the string is still there, in a comment) |
|
||||||
|
| I′ | `TestMainStillWiresTheHeavyOperationGateAndPerRunSpec` | R-85's gate + per-run spec survive | — | pass |
|
||||||
|
|
||||||
|
Hub-side (Scenario G) is in `felhom.eu/REPORT.md`, including **a hollow test caught by its own
|
||||||
|
red-proof**: the first weekly fixture had no jitter, sat on exactly 168 h, and PASSED under the
|
||||||
|
flat-window mutation.
|
||||||
|
|
||||||
|
### Tests deliberately changed, and why
|
||||||
|
|
||||||
|
`TestRotation_BothTiersExercisedAcrossCadences` asserted *4 ticks → 4 runs*. That was a faithful
|
||||||
|
statement of the defect — every tick produced a heavy restore-test, because the ticker **was** the
|
||||||
|
trigger. It is now `TestRotation_BothTiersExercisedOncePerArchive`: **2 runs across 4 evaluations**,
|
||||||
|
one per tier, one per archive. Strictly stronger — it pins both the coverage R-85 won and the pacing
|
||||||
|
R-86 adds. The old assertion is quoted in the test's comment so the change is legible.
|
||||||
|
|
||||||
|
## 7. The live run — triggered by due-ness, on real hardware
|
||||||
|
|
||||||
|
Deployed to **demo-felhom** (Tier 0). The deployed binary is the **published artifact downloaded from
|
||||||
|
Gitea**, not a local rebuild — see R-186.
|
||||||
|
|
||||||
|
### 7.1 The due verdict, per tier, before anything ran
|
||||||
|
|
||||||
|
```
|
||||||
|
eval_interval=6h0m0s settle=24h0m0s
|
||||||
|
tier=felhom-backup due=false archive="" landed=- proven=""
|
||||||
|
reason: no settled archive yet — nothing to prove (newborn or still settling)
|
||||||
|
tier=felhom-pbs due=true archive="felhom-pbs:backup/ct/9201/2026-07-28T04:49:43Z"
|
||||||
|
landed=2026-07-28T04:49:43Z proven=""
|
||||||
|
reason: newest settled archive (landed 2026-07-28T04:49:43Z) has not been proven; nothing proven yet
|
||||||
|
```
|
||||||
|
|
||||||
|
`felhom-backup` reads "no settled archive" for a reason that is **not** the one it appears to be —
|
||||||
|
see **R-185**: the agent cannot list that storage at all.
|
||||||
|
|
||||||
|
### 7.2 A real run, started by the due-check
|
||||||
|
|
||||||
|
Only the **evaluation interval** was shortened for the validation (a systemd drop-in, since removed):
|
||||||
|
the due rule, the settle lag and the restore-test itself were untouched.
|
||||||
|
|
||||||
|
```
|
||||||
|
15:14:38 backup: restore-test tier is DUE (per-archive; oldest-proven first among due tiers)
|
||||||
|
target=felhom-pbs archive=felhom-pbs:backup/ct/9201/2026-07-28T04:49:43Z
|
||||||
|
landed=2026-07-28T04:49:43Z
|
||||||
|
reason="newest settled archive … has not been proven; nothing proven on this tier yet"
|
||||||
|
15:14:39 restore-test: full-fidelity restore params derived from the archive config scratch=990000
|
||||||
|
… proxmox-backup-client restore --crypt-mode=encrypt … (felhom-agent@pve!agent)
|
||||||
|
15:25:08 audit: gate decision class=guest_destroy guest=990000 source=one_shot_job allowed=true
|
||||||
|
15:25:14 restore-test: scratch guest torn down vmid=990000
|
||||||
|
15:25:14 backup: scheduled restore-test PASSED archive=felhom-pbs:… duration_s=635.1
|
||||||
|
```
|
||||||
|
|
||||||
|
A **14.5 GB encrypted offsite archive pulled from ep0 over the WAN**, restored into a scratch guest,
|
||||||
|
booted, verified and destroyed — **635 s**, unattended, and started by *"this archive has not been
|
||||||
|
proven"* rather than by a timer.
|
||||||
|
|
||||||
|
### 7.3 The state now names that archive, and a second evaluation runs nothing
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "felhom-pbs": { "archive": "felhom-pbs:backup/ct/9201/2026-07-28T04:49:43Z",
|
||||||
|
"proven_at": "2026-08-03T13:25:14Z" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
tier=felhom-pbs due=false proven="felhom-pbs:backup/ct/9201/2026-07-28T04:49:43Z"
|
||||||
|
reason: newest settled archive (landed 2026-07-28T04:49:43Z) is already proven
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Teardown — all three layers
|
||||||
|
|
||||||
|
| Layer | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| scratch guest 990000 | `stopped lock=create` during the run | **absent** from `pct list` |
|
||||||
|
| its volumes | 5 thin LVs (32 G + 200 G + 50 G + 2×1 G) | **0** matches in `lvs` |
|
||||||
|
| hub-side record | — | the run's **`restore_tests[]` entry is RETAINED deliberately** — it is the proof the hub's staleness check reads, and deleting it would delete the result. No event was created: the run passed, and `restore_test_failed`/`restore_test_stale` fire only on failure or staleness |
|
||||||
|
|
||||||
|
`pvesm status` before and after: `local-lvm` 1.95 % used before the run, and the thin volumes are gone
|
||||||
|
after it — the restore reclaimed to the same shape it started in.
|
||||||
|
|
||||||
|
### 7.5 The restart, which is the defect a person would actually notice
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 8. Release and deployment
|
||||||
|
|
||||||
|
| Step | Evidence |
|
||||||
|
|---|---|
|
||||||
|
| Released via `scripts/release-agent.sh 0.121.0` | tag `v0.121.0` at `4d82591`, package published |
|
||||||
|
| Verified by **independent download** | sha256 `b2128f3cd4539225a2842f541f56ffaf5390b1d97f3f3a80076ec5f53dbc7d7a`, 14 081 336 B, round-trip GET matched |
|
||||||
|
| Gate re-run after release | `2 released version(s) to verify: 0.120.0, 0.121.0` → both **installable** |
|
||||||
|
| Deployed | `felhom-agent --version` → **0.121.0**, `systemctl is-active` → **active**, prior binary kept as `.bak-0.120.0` |
|
||||||
|
| Startup | `capabilities self-check ok=68 total=68 degraded=0`, and `backup: restore-test scheduler starting (per-archive due-check) eval_interval=6h0m0s settle=24h0m0s` |
|
||||||
|
| **Vouching** | **NOT done — deliberately the operator's act.** Hub UI → Configs → Day-0 artifacts: agent `0.121.0`, sha above |
|
||||||
|
|
||||||
|
## 9. Findings filed (none fixed blind)
|
||||||
|
|
||||||
|
- **R-185 — the agent cannot see demo-felhom's host backup tier at all.** The PVE token has no ACL on
|
||||||
|
`/storage/felhom-backup`, so the content listing returns `{"data":[]}` where root sees three
|
||||||
|
archives (6.1–6.3 GB, 08-01/02/03). Verified three ways, including `local` — which *has* a grant —
|
||||||
|
returning its archives through the same token. **Pre-existing and independent of R-86** (R-85's
|
||||||
|
rotation had the same blindness). The part worth fixing is the **silence**: a permission-blinded
|
||||||
|
tier is today indistinguishable from a newborn one, and the agent already records the backups it
|
||||||
|
wrote to that target, so the contradiction is detectable.
|
||||||
|
- **R-186 — a released binary's sha cannot be reproduced from its tag.** `release-agent.sh` builds
|
||||||
|
before tagging, so Go stamps a pseudo-version into the published bytes: published `b2128f3c…`
|
||||||
|
(14 081 336 B) vs rebuild-at-tag `8302e396…` (14 077 240 B), identical source and toolchain. The
|
||||||
|
build order is deliberate, so the fix is not to swap the steps blind. **Mitigated here** by
|
||||||
|
deploying the published artifact.
|
||||||
|
- **R-187 — R-115's one-command release had never run its publish leg** (`CLOSED`, fixed in the same
|
||||||
|
session): `publish-agent.sh` has been mode `0644` since 2026-06-28 because every earlier caller used
|
||||||
|
`bash …`, and `release-agent.sh` called it directly → `Permission denied` on the first real release.
|
||||||
|
Fixed both ways: the mode bit restored **and** the call made mode-independent. The tag the failed
|
||||||
|
run created was withdrawn (nothing had been published under it — verified 404) and recreated on the
|
||||||
|
fix commit, so one version name still means one binary.
|
||||||
|
|
||||||
|
## 10. CI
|
||||||
|
|
||||||
|
| Repo | Run | Commit | Result |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `felhom-agent` | **#15** (id 83) | `4d82591` | **success** |
|
||||||
|
| `felhom-agent` | #13 (id 81) | `4618169` | **failure — explained, and it is CI doing its job** |
|
||||||
|
| `felhom.eu` | **#48** (id 86) | `ff2655c` | **success** |
|
||||||
|
|
||||||
|
Run #13 fired on the **tag push** from the *failed* first release: `v0.121.0` existed as a tag while
|
||||||
|
nothing was published, and `check-published-versions.py` correctly refused — *"every released agent
|
||||||
|
version must be INSTALLABLE"*. That is precisely the state R-115's gate exists to catch, caught within
|
||||||
|
minutes and self-resolved by the corrected release. Confirmed locally afterwards: both 0.120.0 and
|
||||||
|
0.121.0 verify. `--no-verify` was **not** used anywhere.
|
||||||
|
|
||||||
|
## 11. Observations — noticed, recorded, not acted on
|
||||||
|
|
||||||
|
- **`felhom.eu/CONTEXT.md` has duplicate standing-ruling IDs** — three `S-14`s and two `S-15`s already
|
||||||
|
in the file before this session. New rulings were numbered **S-17/S-18** rather than adding to the
|
||||||
|
collision; the existing duplicates are untouched.
|
||||||
|
- **`agent_gates.py --fast` skips the published-versions gate**, so the pre-push hook cannot catch an
|
||||||
|
unpublished release — only CI can. That is the intended split (no network in a hook), and it is why
|
||||||
|
run #13 mattered.
|
||||||
|
- **The hub sweeps every 60 s and re-reads 14 days of host-reports per customer** for this check. Not
|
||||||
|
changed here (the read window is the same as before), but it is the cost centre if the fleet grows.
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# REUSE.md — felhom-agent
|
||||||
|
|
||||||
|
> Before writing new code, check here. Canonical helpers, patterns to copy, traps to avoid.
|
||||||
|
> Maintenance: update in the SAME commit that adds/changes/deprecates a shared helper.
|
||||||
|
> Entries cite file + symbol. Line numbers are landmarks only — reconfirm before editing.
|
||||||
|
|
||||||
|
## 1. Canonical helpers (MUST reuse — do not reinvent)
|
||||||
|
|
||||||
|
### Allowlisted exec / privileged surface (sudoers)
|
||||||
|
|
||||||
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Runner` / `ExecRunner.Run`, `RunStdin` | internal/proxmox/privileged.go | `Run(ctx, name, args...) (stdout, stderr []byte, err)` | ALL host command exec (direct or `sudo -n` prefix) | Arg vectors only, never a shell string; `capBuf` caps output at 1 MiB |
|
||||||
|
| `Privileged` (CreateGoldenLXC/MountUSBByUUID/SMART/Sensors) | internal/proxmox/privileged.go | methods on `*Privileged` | the 3 fenced root-CLI exceptions ONLY | Do NOT add methods — fence is structural (`routing_test.go` asserts it) |
|
||||||
|
| `SudoHostOps.run` | internal/storage/hostops.go | `run(ctx, name, args...) error` | allowlisted exec with stderr-wrapped error | Every arg pre-validated via validate.go before this is called |
|
||||||
|
| `Prober.Probe` | internal/capability/probe.go | `Probe(ctx) []Status` | live sudo-policy capability check (`sudo -n -l --`) | Needs a DIRECT runner (never the sudo-prefixing one — double-sudo); never executes probed cmds. v0.86.0: config-gated caps (`Capability.GatedBy` + `Prober.GateActive`) report `inactive`/"disabled by configuration" ONLY when healthy — broken plumbing stays degraded; the pbsdr-* gate answers from `pbsdr.Manager.DRConfigured` (marker-backed across restarts) |
|
||||||
|
| `stageTemp` | internal/localapi/intermediary.go | `stageTemp(pattern, content) (path, err)` | random-named temp before a root `install` (audit B1) | Fixed /tmp names are a TOCTOU — sudoers globs expect `/tmp/felhom-*-*.ext` |
|
||||||
|
| `guesthook.InstallSnippet` / `Register` | internal/guesthook/install.go | `InstallSnippet(ctx, runner) error` | pre-start self-heal hook install (C1 net) | Same random-temp+install pattern; snippet delegates to the agent binary (no shell logic). Issues `mkdir -p /var/lib/vz/snippets` FIRST (v0.63.0, B2 — fresh boxes lack the dir; sudoers grants exactly that argv) |
|
||||||
|
|
||||||
|
### Disk / format safety (role gates, durable IDs, format guards)
|
||||||
|
|
||||||
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SudoHostOps.Format` | internal/storage/hostops.go | `Format(ctx, device, fstype) error` | THE only mkfs path | Guards, in order: `ValidateBlockDevice` + `ValidateFSType` → mandatory `deviceUnclaimed` (claim.go) → exec `felhom-mkfs-guarded` (sudoers allowlists ONLY the wrapper, not raw mkfs) |
|
||||||
|
| `SudoHostOps.InspectDevice` + `DeviceProbe.DataBearing` | internal/storage/hostops.go | `InspectDevice(ctx, device) (DeviceProbe, error)` | data-bearing verdict from the AGENT's own read | Fail-safe: `Probed=false` ⇒ DataBearing=true; blkid output is evidence, lsblk is read-success authority |
|
||||||
|
| `classifyClaim` / `SudoHostOps.deviceUnclaimed` | internal/storage/claim.go | `classifyClaim(claimFacts) (unclaimed bool, reason string)` | "is this disk provably free to format" | Pure function of `gatherClaimFacts`; ANY read error/ambiguity/empty-lsblk ⇒ CLAIMED (audit D2); Felhom's own `/mnt/felhom-drives` mounts are not a foreign claim |
|
||||||
|
| `SudoHostOps.ListCandidateDisks` | internal/storage/candidates.go | `ListCandidateDisks(ctx) ([]CandidateDisk, error)` | enroll-candidate discovery | Fail-safe: omits anything not provably unclaimed |
|
||||||
|
| `antiRetargetResolveExpect` (+ `antiRetargetResolve`, `antiRetargetResolveBlank`) | internal/localapi/wipe_reresolve.go | `(durableID, expectDataBearing, resolve, derive, inspect) (device, err)` | pre-mkfs anti-retarget: resolve durable id → re-derive+match → re-inspect | AGENT-001 + audit D3; refuses path-only bindings; wired via `Server.reresolveWipe`/`reresolveBlank` (test-injectable) |
|
||||||
|
| `signedjobs.WipeExecutor.Execute` | internal/signedjobs/wipe.go | `Execute(ctx, op, params) error` | operator-signed data-bearing wipe | Durable-id bound; nonce burned by gate BEFORE execute; refuses no-longer-data-bearing targets |
|
||||||
|
| `selfupdate.Executor` / `selfupdate.Manager` | internal/selfupdate/{executor,commit}.go | `NewExecutor(Config)` / `NewManager(ManagerConfig)` | operator-signed agent self-update (D1): download+verify-vs-signed-sha → wrapper `apply`; startup dwell → `commit` | sha is the ONLY integrity root; wrapper (`felhom-selfupdate-guarded`) re-verifies as root + does the A/B flip; NEVER rolls back (systemd + wrapper do). `WrapperRunner` seam. Report seam `SelfUpdatePending()` |
|
||||||
|
| `Gate.AuthorizeStorageWipe` | internal/reconcile/gate.go | `AuthorizeStorageWipe(StorageWipeAuthz, *SignedOp) Decision` | tiered wipe authz | user-data ⇒ customer confirm bound to agent's DeviceDurableID; system/backup ⇒ operator signature only, `Confirmed` IGNORED by role |
|
||||||
|
| `Gate.Authorize` | internal/reconcile/gate.go | `Authorize(Intent, *SignedOp) Decision` | every destructive intent | role-scoping (`roleAuthorizes`) + op-to-action binding; benign passes unsigned; audits every decision |
|
||||||
|
| `storage.DeviceDurableID` / `ResolveDurableDevice` | internal/storage/durable_device.go | `DeviceDurableID(device) (string, error)` | WIPE-binding ids (`byid:`/`byuuid:`) | Single seam for /disks list AND gate (F20-BUG2); `ResolveDurableDevice` refuses bare paths |
|
||||||
|
| `storage.ResolveStorageDevice` | internal/storage/durable_device.go | `ResolveStorageDevice("uuid:<fs-uuid>") (dev, err)` | re-resolve enrolled STORAGE drives (remount) | `uuid:` scheme ONLY — distinct from the wipe schemes; never trust a remembered /dev node |
|
||||||
|
| `deriveDurableID` | internal/storage/durableid.go | `deriveDurableID(typ, s, backingDevice, uuid) string` | storage-target durable id (DR re-attach key) | Deterministic per type; `uuid:` for usb/local-dir; PBS id includes `#<fingerprint>` |
|
||||||
|
| `SystemDisks` / `isSystemBacked` / `RoleForStorage` / `RoleForRawDevice` | internal/storage/role.go | `RoleForRawDevice(device, sysDisks, sysKnown) DeviceRole` | protection-tier classification | Fails safe to `system` (most protected) on any ambiguity; role is AGENT-derived, never caller-supplied |
|
||||||
|
| `ValidateUUID/MountPath/BlockDevice/FSType/SMARTDevice/LVMName`, `UnitNameForMount` | internal/storage/validate.go | `Validate*(v) error` | EVERY arg that reaches a root shell-out | The security boundary; strict whitelists (no by-* symlinks, no dm, no traversal); `systemdEscapePath` computed in-process |
|
||||||
|
| `ValidateNetworkMountSpec` | internal/storage/netmount.go | `ValidateNetworkMountSpec(spec) error` | NAS mount input boundary | Same discipline as validate.go; SMB requires a creds ref; mountpoint confined under `NetworkMountRoot` |
|
||||||
|
|
||||||
|
### Mount lifecycle (host + guest binds)
|
||||||
|
|
||||||
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SudoHostOps.EnsureMount` | internal/storage/hostops.go | `EnsureMount(ctx, MountSpec) error` | persistent by-UUID systemd .mount | Validate→render→stage→`install`→`enable --now`; idempotent |
|
||||||
|
| `SudoHostOps.Unmount` | internal/storage/hostops.go | `Unmount(ctx, where) error` | detach a mount unit | DESTRUCTIVE — caller MUST have gated it; does not self-authorize |
|
||||||
|
| `SudoHostOps.ReassertEnrolledMounts` | internal/storage/hostops.go | `ReassertEnrolledMounts(ctx)` | reboot remount (re-resolve by UUID) | Re-asserts unless mounted AND enabled (`shouldReassertMount`); skips absent UUIDs |
|
||||||
|
| `GuestBinder.AttachDrive` / `DetachDrive` | internal/localapi/intermediary.go | `AttachDrive(ctx, vmid, where) (guestPath, err)` | live drive hot-swap under `/mnt/felhom-drives` | Normalizes to EXACTLY ONE bind via `countHostMounts` (converges double-binds); force re-bind when guest can't see it |
|
||||||
|
| `GuestBinder.EnsureSharedParent` | internal/localapi/intermediary.go | `EnsureSharedParent(ctx) error` | shared-parent bind + boot unit | make-private+make-shared ONLY on first bind — re-running orphans the guest's slave; F2-a: compares script AND unit for staleness |
|
||||||
|
| `StablePathForRaw` / `DriveNameFromRaw` | internal/localapi/intermediary.go | `StablePathForRaw("/mnt/<n>") string` | raw host mount → stable guest path | Single source of truth both repos derive from |
|
||||||
|
| `GuestBinder.GuestSeesMount` / `GuestBootID` | internal/localapi/intermediary.go | `GuestSeesMount(ctx, vmid, path) bool` | guest-visible (usable) signal; reboot detection | Host bind present ≠ guest sees it (non-recursive parent bind) |
|
||||||
|
| `SudoHostOps.EnsureNetworkMount` / `RemoveNetworkMount` / `ListNetworkMounts` | internal/storage/netmount.go | `EnsureNetworkMount(ctx, spec) error` | NAS automount pair | rm glob confined to `mnt-felhom*` units; NAS ≠ drive (no durable-id/SMART/wipe); RemoveNetworkMount doubles as the verify-fail rollback (idempotent) |
|
||||||
|
| `NetworkMountedAt` / `NetworkEndpointReachable` | internal/storage/netmount.go | `NetworkMountedAt(where) bool` | verify mount-truth + the 2 s add pre-probe | /proc/mounts is the ONLY mount-success judge (autofs trigger ≠ mounted; readability ≠ mounted — SPIKE-nas-verify §8) |
|
||||||
|
| `SudoHostOps.ReassertNetworkAutomounts` + `Server.ReassertNetworkMounts` + `guesthook.PostStartNetworkReassert` | internal/storage/netreassert.go, internal/localapi/netreassert.go, internal/guesthook/netreassert.go | `ReassertNetworkAutomounts(ctx) []NetReassertResult` | NAS guest-reboot heal (RCA fix 1): re-arm idle automount triggers (stop + enable --now) so the fresh mount event propagates into running guests | NEVER call from periodic health paths (an idle trigger is HEALTHY); active real mounts are never touched; hook leg runs as root (direct systemctl), daemon leg via sudo |
|
||||||
|
| `ClassifyNetVerifyFailure` | internal/storage/netverify.go | `ClassifyNetVerifyFailure(journalTail, tcpReachable) (code, hint)` | NAS verify failure categories | String-based BY DESIGN (every mount failure is rc=32); substrings verbatim from SPIKE-nas-verify Q4; `nfs_export` merges not-found/not-permitted (NFSv4 identical) |
|
||||||
|
|
||||||
|
### Durable stores (atomic state)
|
||||||
|
|
||||||
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `IntentStore` (`Get/SetEnrolled/SetEjected/SetDecommissioned/OnAbsent`) | internal/storage/intent.go | `OpenIntentStore(path)` | drive intent (4-state self-heal) | Keyed by durable-id only; `OnAbsent` is the ONLY ejected→enrolled path; refuses empty ids |
|
||||||
|
| `GuestBindStore` (`Record/Remove/Guests`) | internal/localapi/guestbindstore.go | `OpenGuestBindStore(path)` | per-guest enrolled binds (F9 re-assert) | Same tmp+rename 0600 pattern as IntentStore |
|
||||||
|
| `FormatJobStore` + `startFormatDetached` + `RecoverFormatJob` | internal/localapi/formatjob.go | `startFormatDetached(device, durableID, fstype, blank) <-chan error` | detached, restart-surviving mkfs (F20-BUG3) | Runs off `s.baseCtx` (60-min bound) so a request deadline can't SIGKILL mkfs; recovery re-resolves by durable id; blank jobs re-check STILL-blank |
|
||||||
|
| `TokenStore.Mint` / `Lookup` | internal/localapi/tokenstore.go | `Mint(vmid) (plaintext, error)` | per-guest local-API tokens | Only the SHA-256 hash persists (fsync'd append log); constant-time compare on lookup; plaintext returned exactly once. Lookup RELOADS the file once on a miss (v0.63.0, B3): the one-shot provisioner mints into the same file the daemon indexes — cross-process coherence without a restart; append-only size check bounds the re-read |
|
||||||
|
| `FileNonceStore.SeenOrRecord` | internal/authz/noncestore.go | `SeenOrRecord(nonce, exp) bool` | durable anti-replay | fsync'd before returning false; prune only after exp |
|
||||||
|
| `Journal` (`Append/Latest/InFlight/AlreadyApplied`) | internal/reconcile/journal.go | `OpenJournal(path)` | op journal + idempotency + crash recovery | `Recover` consumes `InFlight()`; scratch entries special-cased |
|
||||||
|
|
||||||
|
### Local-API plumbing
|
||||||
|
|
||||||
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Server.withGuest` | internal/localapi/server.go | `withGuest(fn(w, r, vmid)) http.HandlerFunc` | bearer auth + self-scoping for EVERY route | Token→vmid is the authority; explicit `?vmid=` only a consistency check (403 on mismatch) |
|
||||||
|
| `Server.scopedFromBody` | internal/localapi/server.go | `scopedFromBody(w, bodyVMID, tokenVMID, path) bool` | POST-body vmid self-scope check | Call right after decode; false = already 403'd |
|
||||||
|
| `decodeBody` / `writeOK` / `writeErr` / `writeStatus` | internal/localapi/server.go | `writeStatus(w, code, ok, data, errMsg)` | ALL local-API JSON I/O | Do not hand-roll response envelopes |
|
||||||
|
| `EnsureLeaf` | internal/localapi/cert.go | `EnsureLeaf(certPath, keyPath, host) (cert, fingerprint, generated, err)` | pinned self-signed leaf | `generated=true` invalidates every issued bootstrap pin — log LOUD (B.1) |
|
||||||
|
| `Server.RecoverStaleLockedGuests` | internal/localapi/stalelock.go | `RecoverStaleLockedGuests(ctx)` | startup stale vzdump-lock heal (F2-b) | Clears ONLY `backup`/`snapshot-delete`, only when no vzdump in-flight; A1 RESOLVED (v0.62.0): scan is pool-intersected (`ListLXC` ∩ `Client.Pool`), fail-safe skip on pool-read failure |
|
||||||
|
| `ControllerSwapper.Swap` + `ValidControllerImage` | internal/localapi/controllerswap.go | `Swap(ctx, vmid, target) *ControllerSwapState` | agent-owned controller image swap + rollback | Strict image regex (repo + 3-part semver); state file written BEFORE swap; no-healthcheck images need `verifyDwell` |
|
||||||
|
| `MemoryOps` + `Server.readMemoryBounds` | internal/localapi/guestmemory.go | `readMemoryBounds(ctx, vmid) (memoryBounds, err)` | guest RAM resize (v0.90.0, R-24): GET/POST /guest/memory | NEW narrow seam (never extend `GuestAPI` — it breaks every fake); the AGENT is the boundary — bounds recomputed FRESH per request (min 2048 / max host_total−2048 / shrink floor max(2048, usage+512)); §8 UNITS TRAP (config `memory`=MB, status/node=bytes); verify maxmem==target after `SetConfig` before claiming success; SetConfig NEVER called on a refusal path |
|
||||||
|
|
||||||
|
### Proxmox client / hub / PBS / provisioning
|
||||||
|
|
||||||
|
| Symbol | File | Short signature | Use for | Gotchas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Client.WaitTask` | internal/proxmox/task.go | `WaitTask(ctx, upid, opts) (TaskStatus, error)` | asserting EVERY mutating op | POST 200 ≠ success; authz can fail at task exec; `AllowWarnings` opt-in |
|
||||||
|
| `Client.Pool` | internal/proxmox/query.go | `Pool(ctx, name) (PoolInfo, error)` | felhom-pool membership (the ownership registry, A1) | Needs `Pool.Audit` at `/pool/<name>` (host-install v1.9.0+); `Pool.Allocate` does NOT satisfy the read; members can be storages (type `storage`, vmid 0) — filter them |
|
||||||
|
| `Client` mutate wrappers (`RestoreLXC/Vzdump/DestroyLXC/Snapshot/Rollback/SetConfig/ResizeLXC/Start/Stop`) | internal/proxmox/mutate.go | return `(upid, error)` | all API mutations | Async → always pair with WaitTask; route via gate/queue, not ad-hoc |
|
||||||
|
| `Client.PoolAddVMID` | internal/proxmox/mutate.go | `PoolAddVMID(ctx, pool, vmid) error` | re-assert pool membership after a restore-over-existing (campaign-2 R2) | SYNC (no UPID, don't WaitTask); PVE `PUT /pools` is additive (merge, not replace) — `delete=1` removes; idempotent (already-member swallowed); needs `Pool.Allocate` at `/pool/<pool>`. `pct restore --pool` sets membership only at CREATE — a restore over an existing vmid drops it, so bring-up re-asserts post-restore |
|
||||||
|
| `TLSConfig.build` / `normalizeFingerprint` | internal/proxmox/tls.go | `build() (*tls.Config, error)` | PVE leaf-cert SHA-256 pinning | No insecure default |
|
||||||
|
| `pinnedTLS` | internal/pbs/pin.go | `pinnedTLS(fingerprint) (*tls.Config, error)` | PBS leaf pinning | Same model as PVE; 64-hex fingerprint normalized |
|
||||||
|
| `hub.Client.Report` | internal/hub/client.go | `Report(ctx, *HostReport) (*ControlEnvelope, error)` | the heartbeat | Typed `TransportError`/`HTTPError`, never contain the bearer token |
|
||||||
|
| `hub.Loop` + `MultiObserver` | internal/hub/loop.go | `NewLoop(...)`; `MultiObserver(obs...)` | resilient report loop + envelope fan-out | Errors logged, loop continues; interval clamped 60–3600 s |
|
||||||
|
| `provision.BackHalf.Provision` | internal/provision/backhalf.go | `Provision(ctx, Input) (Result, error)` | guest bootstrap back-half | mint→render→0600 write→chown 100000:100000→`pct set` ro bind→onboot; token NEVER logged/returned. Bootstrap `local_api.endpoint` = the caller's `cfg.LocalAPI.ListenAddr` (main.go) — moving the agent bind to the island moves the guest dial for free (R-50, no template) |
|
||||||
|
| `buildBringUpConfig` island NIC | internal/reconcile/bringup.go | (pure) `BringUpSpec{IslandBridge,IslandGuestAddr}` → `params["net1"]` | R-50 island control plane | When BOTH island fields are set (from `cfg.LocalAPI`), attaches a static `net1=name=eth1,bridge=<vmbr9>,ip=<.2/30>` (no hwaddr → fresh MAC), so the controller reaches the agent over a fixed private address immune to LAN/DHCP/site moves. Empty = pre-R-50, no net1. All-or-nothing + CIDR enforced in `LocalAPIConfig.Validate`. The guestnet healer is eth0-only (`parseMode` is dev-scoped) so it never touches the static island NIC |
|
||||||
|
| `reconcile.Queue.Submit` | internal/reconcile/queue.go | `Submit(vmid, fn) <-chan error` | per-guest serialization of ALL mutations | Same vmid strictly FIFO; lanes parallel across guests |
|
||||||
|
| `Engine.RunSignedJob` | internal/reconcile/job.go | `RunSignedJob(ctx, intent, signed, exec) JobResult` | executing a gated destructive job | Idempotency by nonce; journaled |
|
||||||
|
| `escrow.Create` | internal/escrow/escrow.go | `Create(ctx, CreateOptions) (CreateResult, R, error)` | PBS-key escrow (zero-knowledge) | Recovery code returned SEPARATELY from the result (anti-log); self-verifies recoverability |
|
||||||
|
| `escrow.GenerateRecoveryCode` / `joinSafe` / `RecoveryCodeSep` | internal/escrow/wordlist.go | `GenerateRecoveryCode() (string, error)` | minting the customer recovery code R | Draws from the EFF large list **filtered of every word containing `RecoveryCodeSep`** (4 entries: drop-down, felt-tip, t-shirt, yo-yo) so a code always segments back into exactly 10 words — a hyphenated word made codes ambiguous to transcribe AND flaked the test ~1/5 (v0.93.0). Generation-only: **already-issued codes stay valid**, R is verified as a whole passphrase and never re-split. Never count words by splitting the joined string — count what the generator drew |
|
||||||
|
| `escrow.CeremonyBinary` / `CeremonyArgs()` / `CeremonyOutput` | internal/escrow/ceremony.go | the ONE fixed sudo self-invocation argv + the `--output=json` wire object (v1) | controller-driven ceremony (v0.88.0) | SINGLE SOURCE shared by the localapi exec, the capability manifest entry, and (byte-identically) the FELHOM_ESCROW sudoers line — `TestEscrowCeremonyArgvPinned` + `TestManifestCoveredBySudoers` lock all three. Never flag-helpers, never `--`→`-` (spike §2.2) |
|
||||||
|
| localapi escrow ceremony job | internal/localapi/escrow_ceremony.go | `POST /escrow/ceremony` + status + ONE-SHOT claim + preflight | the wizard's agent half | R lives ONLY in `Server.escrowR` (NEVER the job struct — snapshots must be structurally R-free); zeroed on claim/supersede/10-min TTL (`unclaimed_void`); in-memory BY DESIGN (restart loses R safely; re-run supersedes); subprocess stdout is SECRET-BEARING → parsed then zeroed, never logged |
|
||||||
|
| `poke.Listener` + `poke.Port` | internal/poke/poke.go | `NewListener(resolve, trigger, port, logger)`; `poke.Port = 51822` | agent-plane immediate-sync (Direction-2a, v0.89.0) | Binds a contentless UDP socket EXCLUSIVELY to the box's WG /32 (`wgtunnel.LoadAssignedAddr`), fires the hub-loop out-of-band trigger. **Port 51822 is a SHARED cross-repo contract** — the hub poke sender + the ep0 `felhom-poke` forced-command target the SAME number; change one → change all three. Contentless (payload ignored), leading-edge debounced (`DebounceWindow`), WG-confined (kernel EKEYREJECTED refuses non-peer /32s). Wired only when `wg_tunnel.enabled` |
|
||||||
|
| `wgtunnel.LoadAssignedAddr` | internal/wgtunnel/manager.go | `LoadAssignedAddr(stateDir) (netip.Addr, bool)` | the box's own WG /32 without a Manager | Reads `registered.json`; ok=false until registered; strips the /32 → bare addr (the poke bind target) |
|
||||||
|
| `fasttick.Loop` + `fasttick.SourceFunc` | internal/fasttick/fasttick.go | `New(out chan<- struct{}, interval, logger, sources...)`; `Source.Unconverged() (bool, reason)` | agent-plane immediacy SECONDARY (v0.90.0, R-28): pulse the SAME out-of-band trigger every 30 s while ANY source is unconverged, self-disarm on convergence | STATE-BASED (no timer, nothing to journal). Every source MUST be a CACHED read (no exec/network per tick) — `desiredProvider.Generation()`, `reconcile.Engine.LastResult()` (Planned−Pending>0), `pbsdrLoop.PBSDRStatus().State=="waiting_secret"` ONLY, `wgtunnel.Manager.TunnelConvergence()`. The LOUD pbsdr states + destructive `pending_signature` are DELIBERATELY EXCLUDED (a stuck-loud box must not hammer). Pulses the cap-1 channel non-blocking (coalesces with poke/watchdog); NEVER touch `MinPollSeconds`/`clampInterval`/the ticker |
|
||||||
|
| `wgtunnel.Manager.TunnelConvergence` / `reconcile.Engine.LastResult` | internal/wgtunnel/manager.go, internal/reconcile/engine.go | `TunnelConvergence() (desired, operational bool)`; `LastResult() (Result, bool)` | cached convergence snapshots for the fast-tick | TunnelConvergence is refreshed at the END of every `Apply` (its own cadence) so the fast-tick never execs `wg`/`systemctl`; LastResult is mutex-recorded per `reconcileOnce`, ok=false until the first pass |
|
||||||
|
|
||||||
|
## 2. Canonical patterns (copy structure from THE named file)
|
||||||
|
|
||||||
|
| Pattern | Canonical file | Key traits |
|
||||||
|
|---|---|---|
|
||||||
|
| Validate-then-exec privileged op | internal/storage/hostops.go (`EnsureMount`) | validate EVERY arg (validate.go) → render → stage in agent dir → root `install` → `systemctl`; refuse before any command exists |
|
||||||
|
| Fail-safe pure classifier over gathered facts | internal/storage/claim.go (`classifyClaim` over `claimFacts`) | pure function ⇒ fixture-testable; any error/ambiguity refuses; gather separated from verdict |
|
||||||
|
| Anti-retarget durable-id binding | internal/localapi/wipe_reresolve.go | resolve id → re-derive + exact match → re-inspect expected state → act on RE-RESOLVED device only |
|
||||||
|
| Atomic single-file JSON store | internal/storage/intent.go | `Open*` loads (missing=empty, corrupt=fail-loud), mutex, tmp+rename 0600, idempotent set |
|
||||||
|
| Durable append-only log + index | internal/authz/noncestore.go (`FileNonceStore`) | fsync before returning "new"; replay into index on open; expiry-only compaction |
|
||||||
|
| Injectable seam funcs on Server | internal/localapi/server.go (`reresolveWipe`, `deviceDurableID`, `boundCheck`, `deviceCheck`, `livenessCheck`, net-verify: `netTrigger`/`netMounted`/`netJournal`/`netReachable`) | prod default wired in `NewServer`; tests override — no real /dev, /proc/mounts, journalctl or TCP in tests. **For mount-table predicates prefer the DATA seams `procSelfMountinfo` / `procGuestMountinfo` (internal/localapi/intermediary.go) over `boundCheck`/`livenessCheck`**: pointing them at a captured fixture runs the real parser, the real predicate and the real handler, so the test cannot go hollow the way R-116's did |
|
||||||
|
| `Server.devicePresent` (R-113, v0.114.0) | internal/localapi/disks.go | `devicePresent(rawMountPath) bool`; seam `deviceCheck`, default `isHostMountpoint` | the agent's DEVICE-presence signal — asks whether the drive's RAW mount is still mounted | **Use this, never the bind, to answer "is the drive there".** The raw mount is a device-bound systemd unit and dies with its device; the agent's own bind under the shared parent is NOT device-bound and outlives it as a stale shell. `BoundUnderParent` is now `boundUnderParent(...) && devicePresent(...)` at BOTH /disks construction sites — dropping either half is a regression with its own red-proof. Empty path ⇒ **true** (unknown is never absent: absent stops a customer's apps) |
|
||||||
|
| `bindLiveness` + `BindLiveness` (R-117, v0.117.0) | internal/localapi/intermediary.go | `bindLiveness(stable, raw) BindLiveness`; seam `livenessCheck`; read verdicts ONLY via `.Usable()` | the agent's bind-LIVENESS signal — the third term of `BoundUnderParent` | **`devicePresent` and `boundUnderParent` are both PATH-PRESENCE tests and neither is liveness.** They compare only mountinfo field 5, so both stay true over a bind that names the drive that went away while the raw mount healed onto the returning one (measured: raw 8:32 /dev/sdc, bind 8:16 /dev/sdb `shutdown`, EIO both ways, payload healthy). Two dead states, and a fix needs BOTH checks: devno mismatch (the detach/return case) AND the ext4 abort tokens `shutdown`/`emergency_ro` (the steady-state case, where the devnos AGREE because the device never left). **THREE states, never a bool** — `BindUnknown` must exist and `Usable()` treats it as PRESENT (absent stops a customer's apps). **Order matters:** compare devices first and read the abort flag off the RAW mount in the stale case — abort-first classifies the real return state as aborted and refuses the re-bind that repairs it. **NO BLOCK I/O, ever** (CLAUDE.md rule; a probe on a wedged device survives SIGKILL). 6 red-proofs |
|
||||||
|
| `AttachDrive` repair ruling (R-117, v0.117.0) | internal/localapi/intermediary.go | the `switch bindLiveness(...)` inside the `n == 1 && GuestSeesMount` arm | decides whether the existing self-heal runs | `BindStaleDevice` ⇒ **re-bind** (the raw mount is a healthy new superblock; repairs live, no guest restart). `BindAborted` ⇒ **quiet no-op** — a re-bind lands on the SAME dead superblock and this runs every 20 s, so re-binding is an infinite silent retry that also masks the state; it must surface via `BoundUnderParent=false`. `BindLive`/`BindUnknown` ⇒ no-op, unchanged. **Do not return an error for the aborted case** — the reconcile loop would log a failure every 20 s |
|
||||||
|
| Detached IN-MEMORY verify job (single slot, deliberately unpersisted) | internal/localapi/netverifyjob.go | claim slot sync (single-flight 409) → detached pipeline off baseCtx → auto-rollback on fail; restart ⇒ slot empty ⇒ the CALLER rolls back (Scenario F) — contrast formatjob (persisted+recovered) |
|
||||||
|
| Optional dependency degradation | internal/localapi/server.go (`Options`) | nil dep ⇒ endpoint answers "not configured" (503), never a crash |
|
||||||
|
| Version channel (v0.82.0) | internal/localapi/server.go (`Options.AgentVersion`; `Handler()` mux wrap) | sets `X-Felhom-Agent-Version` on EVERY response (all routes/statuses, incl. auth-fail/404) — the controller's capability-comparison source; empty version ⇒ header omitted |
|
||||||
|
| Root-file install via random temp | internal/localapi/intermediary.go (`installSharedParentUnit`) | `stageTemp` (os.CreateTemp) → sudoers-globbed `install -m` → pinned destination |
|
||||||
|
| Detached destructive job + restart recovery | internal/localapi/formatjob.go | persist `running` → run off baseCtx → record outcome; recovery re-resolves durable id, never a path |
|
||||||
|
| Signed-op verify pipeline | internal/authz/verifier.go (`Verify`) | armor→namespace→key-material allowlist→crypto over RAW bytes→blob→target→window→nonce LAST |
|
||||||
|
| Resilient daemon loop | internal/hub/loop.go (`Loop.Run`) | ctx-cancel returns nil; errors logged and loop continues |
|
||||||
|
| Guarded-below-the-agent wrapper | configs/felhom-mkfs-guarded.sh | root re-checks catastrophic cases (system disk/LVM PV/foreign mount/RO/member FS) even against an agent bug |
|
||||||
|
|
||||||
|
## 3. Dangerous lookalikes — do NOT reuse
|
||||||
|
|
||||||
|
| Trap | Why it bites | Use instead |
|
||||||
|
|---|---|---|
|
||||||
|
| Acting on the caller's `req.Device` (or any remembered /dev path) after inspection | classify→mkfs TOCTOU (AGENT-001): /dev re-enumeration retargets the node to a different physical disk | `Server.reresolveWipe`/`reresolveBlank` → format the RE-RESOLVED device |
|
||||||
|
| Exec'ing raw `mkfs.*` (incl. `Binaries.MkfsExt4/MkfsXfs`) | sudoers no longer allowlists raw mkfs; bypasses the claim filter + wrapper re-checks | `SudoHostOps.Format` (→ `felhom-mkfs-guarded`) |
|
||||||
|
| `DiskInfo.DurableID` (`uuid:`) as a wipe-confirmation id | wipe gate resolves `byid:`/`byuuid:` — a `uuid:` id is a `binding_mismatch` (F20-BUG2) | `DiskInfo.WipeDurableID` / `storage.DeviceDurableID` |
|
||||||
|
| `ResolveDurableDevice` for enrolled-storage remounts (or vice versa) | schemes differ: wipe = `byid:`/`byuuid:`, storage = `uuid:` — each refuses the other | `ResolveStorageDevice` for mounts; `ResolveDurableDevice` for wipes |
|
||||||
|
| `authz.MemoryNonceStore` on a real host | replay protection dies on restart | `authz.FileNonceStore` |
|
||||||
|
| Adding methods to `proxmox.Privileged` | breaks the 3-exception root-CLI fence (`routing_test.go`) | `proxmox.Runner` + a new sudoers Cmnd_Alias + validate.go-style checks |
|
||||||
|
| Calling `Client.DestroyLXC`/`Vzdump`/`SetConfig` outside the gate/queue/journal | skips classification, signature, per-guest serialization, crash recovery | `reconcile.Engine` paths / `RunSignedJob`; queue via `Queue.Submit` |
|
||||||
|
| `GuestBinder.AttachBind`/`DetachBind` (per-drive `pct set -mpN`) | legacy model; a missing bind source can brick guest boot (C1) | `AttachDrive`/`DetachDrive` (intermediary model) |
|
||||||
|
| `isHostMountpoint` to reconcile bind state | boolean can't converge stacked double-binds (the /mnt doubling bug) | `countHostMounts` normalization inside `AttachDrive` |
|
||||||
|
| Acting on a raw `ListLXC` list as if it were "guests the agent owns" | audit A1 (pre-v0.62.0 the stale-lock reaper did exactly this — contained only by the pool-scoped token) | ownership must be PROVEN: intersect with `Client.Pool` membership like `staleLockController.Guests()` (v0.62.0), fail-safe on read failure |
|
||||||
|
|
||||||
|
## 4. Seams & interfaces (testing + cross-repo)
|
||||||
|
|
||||||
|
| Interface | Defined in | Implemented by | Fakes/tests at |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `proxmox.Runner` | internal/proxmox/privileged.go | `*ExecRunner` (direct/sudo) | `mockRunner` internal/proxmox/mock_test.go; runner fakes in storage tests |
|
||||||
|
| `storage.HostOps` | internal/storage/hostops.go | `*SudoHostOps` (prod), `NoopHostOps` (degraded) | fakes in internal/storage/observe_test.go, watchdog_test.go |
|
||||||
|
| `storage.HostReader` | internal/storage/hostread.go | `*ProcHostReader` | `fakeHostReader` internal/localapi/disks_test.go; internal/storage/role_test.go. v0.87.0: `BlockSlaves(name)` lists `/sys/block/<name>/slaves` (root-free) — backs the `SystemDisks` dm/md walk (`physicalDisksOf`/`walkSlaves`, role.go); per-branch conservatism: an unresolvable slave fails the WHOLE walk → all-system fail-safe. NEVER weaken the signature test `TestSystemDisks_WalkTopologies` (root-backing disk always in the system set). |
|
||||||
|
| `localapi.DiskOps` / `StorageGate` / `GuestAttacher` / `GuestLister` | internal/localapi/disks.go | `*storage.SudoHostOps`; `storageGateAdapter` (cmd/felhom-agent/main.go); `*GuestBinder`; `*proxmox.Client` | `fakeDiskOps`/`fakeGate`/`fakeGuestAttacher`/`fakeGuestList` internal/localapi/disks_test.go |
|
||||||
|
| `localapi.GuestAPI` / `BackupService` / `BackupStore` / `TokenAuthority` | internal/localapi/server.go | `*proxmox.Client`, `*backup.BackupRunner`, `*backup.Store`, `*TokenStore` | `fakeGuests`/`fakeBackups`/`fakeStore` internal/localapi/server_test.go |
|
||||||
|
| `backup.InFlight` | internal/backup/inflight.go | `TryAcquire(what) (release, busy, ok)` / `Busy()` | THE host-wide "one heavy guest operation at a time" gate — shared by the local-API backup path and the restore-test scheduler (R-85) | A **LINK** guard, not a lock one: the scratch VMID never touches the live guest's vzdump lock, but an offsite restore PULLS multi-GB over the tunnel a backup PUSHES one. Callers **DEFER, never cancel** — a deferred restore-test costs coverage, a cancelled backup costs the backup. A nil gate is ungated (pre-R-85 callers). |
|
||||||
|
| `backup.RestoreTestState` | internal/backup/restoretest_state.go | `RecordSuccess(target,archive,t)` / `ProvenArchive(target)` / `LastSuccess(target)` / `OldestFirst(targets)` | Per-tier restore-test PROOF state, persisted (atomic tmp+rename) — **which archive** was proven, and when (R-86) | **Credit ONLY on success** — a permanently failing tier must keep sorting first, or it looks freshly proven and stops being retried. Ties break on target id: without it, two tiers proven in the same second rotate by Go's randomised map order. **This one NEEDS persistence unlike R-84** — R-84 had ground truth to consult (the archive is still on the storage); a restore-test destroys its scratch and leaves no artifact. **R-86: the ARCHIVE is the state, the time is metadata** — a time alone cannot answer "have we proven THIS archive", which is the due-check's whole question. A pre-R-86 file (bare RFC3339 per target) keeps its time and yields NO proven archive, so each tier is due once after the upgrade; reading a legacy time as proof of the current archive would invent a guarantee. |
|
||||||
|
| `backup.SpecBuilder` / `backup.TierPicker` / `(*BackupRunner).PickSettledRestoreCandidateOn` | internal/backup/schedule.go, runner.go | `func(ctx,archive) RestoreTestSpec`; `func(ctx,target,notAfter) (archive,landed,error)` | The per-run restore-test spec + per-tier **settled** candidate lookup (R-85, widened by R-86) | The spec is built **PER RUN**, never frozen at construction — the pre-R-85 immediately-invoked value made the offsite tier unschedulable AND went stale on any config change. `SourceTier` comes from **the archive**, never the configured target (the v0.100.0 rule). A tier with no archive returns `("", zero, nil)` — **`""` is NOT an error**, or every fresh box looks broken for its first week. **R-86: `notAfter` is the settle cutoff** (zero = no cutoff, which is what keeps `PickRestoreCandidateOn` a one-line call into it), and the picker now skips entries failing `archivePlausiblyComplete` — under per-archive due-ness an incomplete phantom would be picked forever, fail forever, never earn proof, and make the tier due at EVERY evaluation. |
|
||||||
|
| `localapi.BackupTier` + `normalizeBackupTiers` / `config.BackupConfig.BackupTiers` | internal/localapi/backup_tiers.go, internal/config/config.go | `normalizeBackupTiers(tiers, legacy, cadence) []BackupTier`; `BackupTiers() ([]BackupTier, []string)` | THE R-82 multi-tier resolution — one runner per tier, primary first | **The untargeted local-API contract is FROZEN**: no `?target=` ⇒ primary tier ⇒ pre-R-82 response BYTES (Target is `omitempty` and stays empty). Never default a missing cadence — reject it and log the warning at ERROR. Never share one retention knob between tiers. Jobs are keyed by (vmid,target). |
|
||||||
|
| `localapi.StaleLockController` | internal/localapi/stalelock.go | `*staleLockController` (Client + Runner + pool) | `fakeStaleLock` (Server-level) stalelock_test.go; `fakeStaleLockAPI` (controller-level, tests the A1 pool intersect) stalelock_pool_test.go |
|
||||||
|
| `localapi.GuestExecutor` | internal/localapi/controllerswap.go | `*GuestBinder` (pct exec) | `fakeGuestExec` internal/localapi/controllerswap_test.go |
|
||||||
|
| `guestnet.Runner` / `guestnet.GuestSource` (R-54, v0.92.0) | internal/guestnet/{probe,watchdog}.go | `*proxmox.ExecRunner`; the POOL-VERIFIED `localapi.StaleLockController.Guests` (ListLXC ∩ felhom pool, audit A1) | `scriptedRunner` + `fakeGuests` internal/guestnet/watchdog_test.go. **Never wire a bare `ListLXC` here** — under a broad token that would run dhclient inside a co-tenant's container. Every assertion is an exec COUNT, and the load-bearing ones are the negatives: a static guest, an unprobeable guest, a boot-race guest and an unproven guest list must record **zero** heal calls |
|
||||||
|
| `guestnet.Watchdog.SetDampers` / `now` (clock seam) | internal/guestnet/watchdog.go | config `guest_net.*`; `now` defaults to `time.Now` | tests advance a manual clock (the storage-watchdog pattern) and assert the heal ceilings EXACTLY — ≥10 min apart, ≤3/hour, and ≤30 over a scripted 10 hours of permanent failure. A damper with no test is a comment |
|
||||||
|
| `hub.GuestNetReporter` (R-54) | internal/hub/collect.go | `*guestnet.Watchdog` (`GuestNetStatus`) | internal/hub/collect_guestnet_test.go asserts the stanza through the PRODUCTION `Collect` path AND that the `guest_net` key is ABSENT from the wire when no reporter is wired — an always-present empty stanza would make "not wired" and "found nothing" the same signal, which is the shape v0.91.0 hid behind |
|
||||||
|
| `hub.AddressEnumerator` (v0.119.0) | internal/hub/hostaddr.go | **defaults to the REAL `systemInterfaces`** when `Collector.addrEnum` is nil — deliberately inverting the nil-reporter-means-off convention, because this stanza has no config gate and a forgotten wiring call would otherwise ship silently empty (the inert-seam shape, four instances on record) | internal/hub/hostaddr_test.go drives fixtures TRANSCRIBED from `ip -o addr show` on demo-felhom AND demo-hp, including the address-less veth/NIC rows — the "no denylist needed" claim rests on those rows really being empty, so omitting them would prove the claim by assuming it. `filterHostAddresses` keeps GLOBAL UNICAST only: one predicate that drops loopback, `fe80::/10`, and `169.254/16` — the last being the R-50 island literal, identical on every box and actively misleading if surfaced |
|
||||||
|
| `reconcile.OpVerifier` | internal/reconcile/gate.go | `*authz.Verifier` | fake verifier in internal/reconcile gate tests |
|
||||||
|
| `signedjobs.WipeOps` / `Executor` (`ExecutorChain`) | internal/signedjobs/wipe.go + runner.go | `*storage.SudoHostOps`; `WipeExecutor`+`DecommissionExecutor` | internal/signedjobs wipe/runner/decommission tests |
|
||||||
|
| `hub.reporter` / `collectorIface` / `EnvelopeObserver` | internal/hub/loop.go | `*hub.Client`, `*hub.Collector`; `desired.Syncer` + `signedjobs.Runner` | `fakeReporter`/`fakeCollector` internal/hub/loop_test.go; `recordingReporter` loop_logtail_test.go |
|
||||||
|
| `applog.Ring` (always-DEBUG capture ring) + fan-out `applog.New → (logger, ring)` | internal/log/log.go | wired in cmd/felhom-agent/main.go → `localapi.Options.LogRing` + `Loop.SetLogTailSource(ring.Lines)` | internal/log/log_test.go; localapi/debuglogs_test.go — v0.83.0; the byte-capped `Lines` is the heartbeat tail source |
|
||||||
|
| `pbsdr.StorageReader` / `SecretConsumer` / `Manager.probeFP` (func seam) | internal/pbsdr/manager.go | `*proxmox.Client`; `*hub.Client`; `pbs.ProbeFingerprint` | `fakeStorage`/`fakeConsumer`/`fakeRunner` internal/pbsdr/manager_test.go (argv+stdin recorder) |
|
||||||
|
| `capability.Runner` | internal/capability/probe.go | `*proxmox.ExecRunner` (RunnerDirect) | `fakeRunner` internal/capability/probe_test.go |
|
||||||
|
| Cross-repo: local API ↔ controller | internal/localapi/server.go routes; contract seeded by internal/provision/doc.go (`bootstrap.json`: endpoint + leaf fingerprint + token) | felhom-controller's agentapi client | pin = served leaf cert (memory gotcha) |
|
||||||
|
| Cross-repo: agent ↔ hub | internal/hub/report.go (`HostReport`), `ControlEnvelope`; POST `/api/v1/host-report` | hub mirrors structs field-for-field | new event/report fields need hub-side ingest changes |
|
||||||
|
| Cross-repo: shipped host artifacts | configs/felhom-agent.sudoers, configs/felhom-mkfs-guarded.sh, configs/felhom-pbs-apply, shared-parent script/unit (inline in internal/localapi/intermediary.go) | deployed WITH the binary | sudoers globs must match `stageTemp` patterns + staging dirs exactly |
|
||||||
|
| Operator signing | internal/authz (OpBlob, SSHSIG) | cmd/felhom-opsign (offline CLI) | blob/verify tests in internal/authz |
|
||||||
|
|
||||||
|
## 5. Extension points (where new features plug in)
|
||||||
|
|
||||||
|
- **Local-API route**: add to `Server.Handler` (internal/localapi/server.go) wrapped in `s.withGuest`; new deps go into `Options` as OPTIONAL fields degrading to "not configured".
|
||||||
|
- **New signed-job verb**: implement `signedjobs.Executor` (return `ErrNoExecutor` for foreign ops) and append to the `signedjobs.ExecutorChain` in cmd/felhom-agent/main.go; add the op class + role scoping in internal/reconcile (classify.go, gate.go `roleAuthorizes`).
|
||||||
|
- **New privileged host op**: validate args (internal/storage/validate.go style) → exec via `Runner` → add a `Cmnd_Alias` to configs/felhom-agent.sudoers → add a probe vector to internal/capability/manifest.go (so degradation is visible) → ship sudoers with the binary.
|
||||||
|
- **New reconcile action**: `ActionKind` + `classOfAction` (internal/reconcile/classify.go), plan emission in internal/reconcile/plan.go; destructive ⇒ gate handles it automatically.
|
||||||
|
- **Hub-report field**: extend `hub.HostReport` (internal/hub/report.go) + `Collector` — hub side must mirror + allowlist it (cross-repo).
|
||||||
|
- **DR-recipe section (host-half)**: add the field to `DRRecipeHostHalf` (internal/hub/dr_recipe.go) **AND** to
|
||||||
|
the hub's `hostHalfShape` + `AssembledRecipe` (felhom.eu `hub/internal/store/dr_recipe.go`). Those two
|
||||||
|
hub structs are **ALLOW-LISTS**: a section only the agent knows about is stored intact and silently
|
||||||
|
dropped before any operator sees it — that is R-122, which cost `offsite_restic` its entire existence.
|
||||||
|
Then update BOTH copies of `testdata/host-report.golden.json` (byte-identical, cross-repo) and extend
|
||||||
|
`TestAssembleDRRecipe_CarriesEveryEmittedSection`. **A recipe field that cannot be resolved records an
|
||||||
|
explicit unknown with a reason — never a default, an empty string, or a placeholder** (`DRState*` /
|
||||||
|
`DRReason*`); a recipe read during a rebuild must not present a guess as a fact.
|
||||||
|
- **Envelope-driven behavior**: implement `hub.EnvelopeObserver`, add to the `MultiObserver` in cmd/felhom-agent/main.go.
|
||||||
|
- **Selftest mode**: `selftestFlag` + `runSelftest*` in cmd/felhom-agent/main.go.
|
||||||
|
- **Config**: internal/config/config.go (`Load` + `applyEnv` `FELHOM_AGENT_*` overlay; keep secrets out of `Redacted()` output).
|
||||||
|
|
||||||
|
## 6. Known duplication (observed — NOT fixed)
|
||||||
|
|
||||||
|
- Two lsblk `-J` parsers with near-identical structs: `parseLsblkDevice`/`lsblkDevice` (internal/storage/hostops.go) vs `parseLsblkNodes`/`lsblkDev` (internal/storage/claim.go).
|
||||||
|
- Two smartctl `-a -j` paths: `SudoHostOps.SMART` (internal/storage/hostops.go, parsed `hub.SmartSummary`) vs `Privileged.SMART` (internal/proxmox/privileged.go, raw map).
|
||||||
|
- **SMART device resolution (v0.95.0):** `smartDeviceFor` (internal/storage/observe.go) resolves partition→disk AND dm/LVM→disk (`dmWholeDisk` in internal/storage/smartdev.go, via `/sys/block/<dm>/slaves`, `sysBlockRoot` test seam). `storage.SmartReader.SMARTForBacking` is the shared read the localapi `/disks` union path uses (Fix B) — do NOT re-implement smartctl parsing. The builtin-`local` SMART device comes from `containingMountDevice` (SMART-only; never feeds backing/durable_id).
|
||||||
|
- Atomic tmp+rename JSON store implemented 3×: `IntentStore.saveLocked` (internal/storage/intent.go), `FormatJobStore.save` (internal/localapi/formatjob.go), `GuestBindStore.saveLocked` (internal/localapi/guestbindstore.go) — comments say "mirrors", no shared helper.
|
||||||
|
- `run(ctx, name, args...) error` stderr-wrapping helper duplicated 4×: `SudoHostOps.run`, `Privileged.run`, `BackHalf.run` (internal/provision/backhalf.go), `GuestBinder.run` (internal/localapi/guestbind.go).
|
||||||
|
- Several independent /proc mount-table readers: `SudoHostOps.mountedSet` (internal/storage/hostops.go), `ProcHostReader.Mounts` (internal/storage/hostread.go). **In localapi they were unified in v0.117.0**: `isHostMountpoint` and `countHostMounts` are now one-liners over `hostMountEntries`, the single parser that also yields devno/fstype/super-options for `bindLiveness`.
|
||||||
|
- Deliberate mirror: `antiRetargetResolveExpect` (internal/localapi/wipe_reresolve.go) duplicates `WipeExecutor.Execute` steps 1–3 (internal/signedjobs/wipe.go) across packages.
|
||||||
|
- `stableParentDir` literal duplicated in internal/provision/backhalf.go to avoid a provision→localapi import edge (commented as intentional); `trim` (internal/storage/hostops.go) vs `trimBody` (internal/proxmox/errors.go) output-truncation twins.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# RUNBOOK — provisioning the OS / Docker-data / user-data storage split
|
||||||
|
|
||||||
|
How the controller guest's **OS rootfs**, **Docker data**, and **SSD user-data** are carved onto three
|
||||||
|
separate `local-lvm` volumes, and how to size them per node/customer. Grounded by the storage-split spike
|
||||||
|
(`REPORT-storage-split-spike.md`). Defaults target a ~512 GB SSD: **32 GB OS rootfs + 200 GB Docker-data
|
||||||
|
+ 50 GB user-data**, rest of the thin pool free for other guests.
|
||||||
|
|
||||||
|
## The design (locked)
|
||||||
|
|
||||||
|
Three thin volumes are baked into the golden and grown at provision:
|
||||||
|
|
||||||
|
| mp slot | Mount | Holds | Golden default | Provision grow knob |
|
||||||
|
|---------|-------|-------|----------------|---------------------|
|
||||||
|
| rootfs | `/` | OS only | `OS_SIZE_GB=32` | `-rootfs-grow` |
|
||||||
|
| `mp0` | `/var/lib/docker` | ALL Docker images + overlay + named volumes | `GOLDEN_DOCKER_GB=16` | `-datavol-grow` |
|
||||||
|
| `mp1` | `/mnt/sys_drive` | SSD user-data (the controller's `system_data_path`; `<sys_drive>/felhom-data` = SSD-only app data + DBs) | `GOLDEN_SYSDATA_GB=8` | `-sysdata-grow` |
|
||||||
|
|
||||||
|
- **Split for resilience, not backup size.** An isolated OS rootfs stays bootable + agent-recoverable
|
||||||
|
if the Docker volume fills. All three volumes ride into PBS regardless (so the backup size is
|
||||||
|
unchanged) — the win is independent sizing/growth and a smaller, faster-to-restore OS rootfs.
|
||||||
|
- **One Docker daemon, one data-root, one volume** at `/var/lib/docker` for ALL images + overlay
|
||||||
|
(controller, traefik, cloudflared, filebrowser, and customer apps). Infra is NOT relocated to the OS
|
||||||
|
partition — infra/user separation under one daemon isn't cleanly possible and isn't needed. Infra is
|
||||||
|
protected by the controller's **prevention layer** (reserved buffer + deploy gate + disk alerts +
|
||||||
|
baked log rotation), not by placement.
|
||||||
|
- **`/mnt/sys_drive` is a static CT volume, NOT an enrolled drive.** It never enrolls, ejects, or
|
||||||
|
decommissions — it is part of the golden's baked layout. The controller auto-discovers
|
||||||
|
`<sys_drive>/felhom-data` and, via `system.IsMountPoint`, **warns when `/mnt/sys_drive` is not its own
|
||||||
|
mount** (`selftest.go`). Making `mp1` a real mount clears that warning with **zero controller change**;
|
||||||
|
the controller is untouched by this slice. The `mp` mounted under the guest's `/mnt` propagates into
|
||||||
|
the controller container through the existing `-v /mnt:/mnt:rslave` bind (the same path enrolled drives
|
||||||
|
use).
|
||||||
|
- **`backup=1` on the Docker-data AND user-data volumes is non-negotiable.** Extra LXC mountpoints
|
||||||
|
default to `backup=0` = EXCLUDED from vzdump/PBS (spike B3). Without it, named-volume DBs / SSD app
|
||||||
|
data fall out of the snapshot AND (for mp0) the baked images are excluded from the golden archive.
|
||||||
|
|
||||||
|
## Golden build (per controller-image baseline)
|
||||||
|
|
||||||
|
`configs/build-golden.sh` builds the template guest with the split baked in:
|
||||||
|
|
||||||
|
```
|
||||||
|
# defaults shown; override via env
|
||||||
|
OS_SIZE_GB=32 GOLDEN_DOCKER_GB=16 GOLDEN_SYSDATA_GB=8 \
|
||||||
|
./build-golden.sh <VMID> <TEMPLATE> <ROOTFS_STORAGE> <ARCHIVE_STORAGE> <BRIDGE> <CONTROLLER_IMAGE>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--rootfs ${ROOTFS_STORAGE}:${OS_SIZE_GB}`
|
||||||
|
+ `--mp0 ${ROOTFS_STORAGE}:${GOLDEN_DOCKER_GB},mp=/var/lib/docker,backup=1`
|
||||||
|
+ `--mp1 ${ROOTFS_STORAGE}:${GOLDEN_SYSDATA_GB},mp=/mnt/sys_drive,backup=1`.
|
||||||
|
- Keep `GOLDEN_DOCKER_GB` / `GOLDEN_SYSDATA_GB` small (the golden ships them near-empty) — provision
|
||||||
|
grows both. The controller creates `<sys_drive>/felhom-data` itself once `/mnt/sys_drive` is a real
|
||||||
|
mount; the golden does NOT pre-create it.
|
||||||
|
- The script bakes `/etc/docker/daemon.json` with **`features.containerd-snapshotter: false`** (the
|
||||||
|
classic **overlay2** driver) + log rotation (`max-size 10m`, `max-file 3`), verifies `/var/lib/docker`
|
||||||
|
AND `/mnt/sys_drive` are separate mounts + the driver is overlay2, and **aborts if vzdump excludes mp0
|
||||||
|
or mp1** (the B3 trap).
|
||||||
|
- **Why overlay2 (not the Docker-29 default containerd-snapshotter):** containerd-snapshotter keeps the
|
||||||
|
image content store under `/var/lib/containerd`, which is NOT `/var/lib/docker` — so it would stay on
|
||||||
|
the OS rootfs and the split would only move named volumes, leaving images (the bulk) on the rootfs
|
||||||
|
(validated live: 1.2 GB of images landed on the rootfs). overlay2 stores **everything** (images +
|
||||||
|
overlay + volumes) under data-root = the data volume, which is what the split + the controller's
|
||||||
|
`statfs("/")` prevention guard both require.
|
||||||
|
|
||||||
|
## Provision (per customer guest)
|
||||||
|
|
||||||
|
The bring-up restores the golden (carrying rootfs + the small mp0 Docker-data + mp1 user-data volumes +
|
||||||
|
baked images), then sizes per customer. Each volume is **grown** (not re-attached) so the baked images
|
||||||
|
are never shadowed:
|
||||||
|
|
||||||
|
```
|
||||||
|
felhom-agent --selftest=provision \
|
||||||
|
-archive <golden-volid> -vmid <VMID> \
|
||||||
|
-customer-id <id> -hub-password <retrieval-passphrase> \
|
||||||
|
-rootfs-grow <N> # GiB to add to the OS rootfs beyond the golden's 32 (0 = keep 32)
|
||||||
|
-datavol-grow <N> # GiB to add to the Docker-data volume beyond the golden's 16 (e.g. 184 → 200 total)
|
||||||
|
-datavol-mount mp0 # the golden's Docker-data slot (default mp0)
|
||||||
|
-sysdata-grow <N> # GiB to add to the user-data volume beyond the golden's 8 (e.g. 42 → 50 total)
|
||||||
|
-sysdata-mount mp1 # the golden's user-data slot (default mp1)
|
||||||
|
```
|
||||||
|
|
||||||
|
In code: `reconcile.BringUpSpec.{RootfsGrowGB, DataVolGrowGB, DataVolMount, SysDataGrowGB, SysDataMount}`
|
||||||
|
— all grows are online (`pct resize`, spike B4), each its own task. **Separateness, not size, clears the
|
||||||
|
controller warning** — `-sysdata-grow 0` still yields a separate, warning-free mount at the golden size;
|
||||||
|
the grow is an orthogonal knob. `GuestMount.Backup` (→ `,backup=1`) is set on any data-bearing additive
|
||||||
|
mount (`bringup.go` `buildBringUpConfig`). The bootstrap mount is `mp9`, the shared parent bind is `mp8`;
|
||||||
|
USB/data drive enroll picks the lowest free slot (`freeMountSlot`), which auto-skips the baked mp0/mp1.
|
||||||
|
|
||||||
|
**Auto-start on host reboot (`onboot:1`).** The provision back-half sets the customer guest to
|
||||||
|
**`onboot:1`** (`provision/backhalf.go`, v0.41.0) so the whole home-server (controller + apps) comes back
|
||||||
|
on its own after a host reboot/power-cut. The **golden template stays `onboot:0`** (`build-golden.sh`) —
|
||||||
|
a template must not auto-start. No `startup`/boot-order is used: the v0.75 mountpoint-gate covers the
|
||||||
|
drive-bind race at boot, so the controller won't write app data onto the rootfs while drives re-bind.
|
||||||
|
(Guests provisioned before v0.41.0 need a one-time `pct set <vmid> -onboot 1`.)
|
||||||
|
|
||||||
|
**Per-customer sizing source:** flags today; the **slice-10 hub storage manifest** is the intended
|
||||||
|
home (marked at `bringup.go` `GuestMount` / the `slice 10 wires the hub storage manifest` comment) —
|
||||||
|
the controller already pulls customer-scoped config from the hub, so OS/data sizes belong there.
|
||||||
|
|
||||||
|
### Default sizing guidance
|
||||||
|
- ~512 GB SSD: 32 GB rootfs + 200 GB Docker-data + 50 GB user-data
|
||||||
|
(`-rootfs-grow 0 -datavol-grow 184 -sysdata-grow 42`), leaving the rest of the thin pool free for
|
||||||
|
other guests.
|
||||||
|
- Other SSDs: size the Docker-data + user-data volumes to "most of the free thin pool minus a reserve
|
||||||
|
for other guests." `local-lvm` is thin-provisioned, so an over-allocation only consumes space as it's
|
||||||
|
written.
|
||||||
|
|
||||||
|
## Fresh PVE host install — thin-pool carving (host-level, do this at install)
|
||||||
|
|
||||||
|
This sizes the **host's** `local` root vs the `local-lvm` thin pool (the pool the guests' volumes are
|
||||||
|
carved from). It is NOT the guest split — do it on the Proxmox ISO installer's **Advanced LVM options**:
|
||||||
|
|
||||||
|
- `hdsize` — total disk PVE uses (leave headroom on multi-disk nodes).
|
||||||
|
- `maxroot` — cap on the host `/` (`pve/root`). Keep small (e.g. 20–40 GB) — the host OS is thin.
|
||||||
|
- `maxvz` — cap on the `data` thin pool (`local-lvm`). Set to 0 / leave default to let the pool take the
|
||||||
|
rest, OR cap it to reserve unallocated VG space.
|
||||||
|
- `minfree` — reserved free space in the VG (thin-pool safety).
|
||||||
|
- `swapsize` — host swap.
|
||||||
|
|
||||||
|
**Never repartition a live host root.** Per-guest carving (rootfs + Docker-data) is done at provision
|
||||||
|
time via `pct` against the existing thin pool — no host repartition needed (the demo's pool already has
|
||||||
|
~350 GB free).
|
||||||
|
|
||||||
|
## In-place migration of an EXISTING guest (NOT used by this slice)
|
||||||
|
|
||||||
|
This slice rebuilds clean (fresh re-provision), so the move-then-verify in-place migration is **not
|
||||||
|
used here**. It remains documented in `REPORT-storage-split-spike.md` §B2 for a future in-place node
|
||||||
|
upgrade: stop apps → stop docker → `rsync -aHAX --numeric-ids /var/lib/docker/ <new-vol>/` (check
|
||||||
|
rsync's OWN exit code) → switch data-root → verify volumes+DBs+boot → only then reclaim. `backup=1` on
|
||||||
|
the new mount and a CT restart to activate it are required (spike B3/B4).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// R-82 live regression (2026-07-26): the restore-test derived its tier from the CONFIGURED default
|
||||||
|
// target instead of the archive's own storage. Restoring a `felhom-pbs:` archive on a box whose
|
||||||
|
// primary target is "local" was classified "local" → the 10-minute local wait instead of the
|
||||||
|
// generous PBS one → the wait expired mid-restore at 600s against a 14.46 GB WAN restore, teardown
|
||||||
|
// fired at a still-restoring guest, and the scratch leaked.
|
||||||
|
func TestArchiveStorageID(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"felhom-pbs:backup/ct/9201/2026-07-26T12:21:48Z", "felhom-pbs"},
|
||||||
|
{"local:backup/vzdump-lxc-9201-2026_07_26-09_03_19.tar.zst", "local"},
|
||||||
|
{"", ""},
|
||||||
|
{"no-prefix", ""},
|
||||||
|
{":leading-colon", ""}, // i>0 guard: a leading colon is not a storage id
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := archiveStorageID(c.in); got != c.want {
|
||||||
|
t.Fatalf("archiveStorageID(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEscrowUploadContract pins the agent→hub escrow wire shape. It MUST match the hub's ingest
|
||||||
|
// struct (felhom-hub api.escrowUploadRequest). Cross-repo, no shared module — this is the agent
|
||||||
|
// half of the contract guard; the hub has the mirror in its own test.
|
||||||
|
func TestEscrowUploadContract(t *testing.T) {
|
||||||
|
b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t", ResticPwSHA256: "h"})
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
got = append(got, k)
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"blob_b64", "created_at", "key_fingerprint", "posture", "restic_pw_sha256"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("escrow wire contract drift: got %v want %v (must match the hub ingest struct)", got, want)
|
||||||
|
}
|
||||||
|
// SLICE 3: no staged password folded in → the hash field is OMITTED on the wire (the hub stores NULL →
|
||||||
|
// the controller never matches → stays pending; correct — the blob doesn't cover the key).
|
||||||
|
b2, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"})
|
||||||
|
var m2 map[string]any
|
||||||
|
_ = json.Unmarshal(b2, &m2)
|
||||||
|
if _, present := m2["restic_pw_sha256"]; present {
|
||||||
|
t.Fatal("restic_pw_sha256 must be omitted when no staged password was sealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// F10/rc255 (CAMPAIGN-3): a guest-hook phase body that PANICS must never crash the process — the hook
|
||||||
|
// must return cleanly so the guest start proceeds (a nonzero exit blocks the start). runHookPhase
|
||||||
|
// recovers the panic and returns.
|
||||||
|
func TestRunHookPhase_PanicRecovered(t *testing.T) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
runHookPhase("9201", "pre-start", 5*time.Second, func(context.Context) {
|
||||||
|
panic("simulated heal panic (e.g. a future Heal bug)")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// returned cleanly — the guest start would proceed
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("runHookPhase did not return after a panicking body (would have crashed the hook)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phase body that overruns the timeout must be abandoned — the hook returns rather than hanging the
|
||||||
|
// PVE start task. (The body's context is cancelled; the hook does not wait for the body to notice.)
|
||||||
|
func TestRunHookPhase_TimeoutReturns(t *testing.T) {
|
||||||
|
bodyCtxCancelled := make(chan struct{}, 1)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
runHookPhase("9201", "post-start", 20*time.Millisecond, func(ctx context.Context) {
|
||||||
|
<-ctx.Done() // simulate a body that respects cancellation eventually
|
||||||
|
bodyCtxCancelled <- struct{}{}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("runHookPhase did not return after the timeout (would hang the guest start)")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-bodyCtxCancelled:
|
||||||
|
// the body's context was cancelled at the deadline — the intended signal
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("the phase body's context was not cancelled at the timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A body that errors (returns normally, no panic) is fine — the hook returns cleanly.
|
||||||
|
func TestRunHookPhase_NormalBodyReturns(t *testing.T) {
|
||||||
|
ran := false
|
||||||
|
runHookPhase("9201", "pre-start", time.Second, func(context.Context) { ran = true })
|
||||||
|
if !ran {
|
||||||
|
t.Fatal("the phase body must run")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The hook WIRING red-proof target: `guest-hook <vmid> post-start` must invoke the network
|
||||||
|
// reassert with the vmid; pre-start and unknown phases must NOT. (Companion red-proof: remove the
|
||||||
|
// PhasePostStart case from runGuestHook → the invoked assertion fails.)
|
||||||
|
func TestRunGuestHook_PostStartInvokesNetworkReassert(t *testing.T) {
|
||||||
|
orig := postStartNetworkReassertFn
|
||||||
|
t.Cleanup(func() { postStartNetworkReassertFn = orig })
|
||||||
|
|
||||||
|
var gotVMIDs []string
|
||||||
|
postStartNetworkReassertFn = func(_ context.Context, vmid string) {
|
||||||
|
gotVMIDs = append(gotVMIDs, vmid)
|
||||||
|
}
|
||||||
|
|
||||||
|
runGuestHook([]string{"9201", "post-start"})
|
||||||
|
if len(gotVMIDs) != 1 || gotVMIDs[0] != "9201" {
|
||||||
|
t.Fatalf("post-start must invoke the network reassert with vmid 9201, got %v", gotVMIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre-start must not touch the network reassert (it is the placeholder-heal phase; the heal
|
||||||
|
// no-ops on a nonexistent config path and never blocks).
|
||||||
|
runGuestHook([]string{"9201", "pre-start"})
|
||||||
|
// unknown phases are ignored entirely.
|
||||||
|
runGuestHook([]string{"9201", "pre-stop"})
|
||||||
|
if len(gotVMIDs) != 1 {
|
||||||
|
t.Fatalf("only post-start may invoke the network reassert, got %v", gotVMIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-54 §9 rule 6 — the seam-discipline test, and the one this repo has the most reason to write:
|
||||||
|
// v0.91.0 shipped the PBS auth-probe seam with `main.go` never calling `SetAuthSink`, every unit
|
||||||
|
// test green because they all injected the seam directly. The guestnet watchdog has the identical
|
||||||
|
// shape (a component + a reporter seam + a goroutine), so its wiring is asserted here rather than
|
||||||
|
// trusted.
|
||||||
|
//
|
||||||
|
// This walks the AST rather than grepping the source: a commented-out call still satisfies a
|
||||||
|
// substring match (found while red-proofing the controller's twin of this test), and a comment is
|
||||||
|
// not a caller.
|
||||||
|
func TestMainWiresGuestNetWatchdog(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var constructed, reporterWired, started bool
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
switch node := n.(type) {
|
||||||
|
case *ast.CallExpr:
|
||||||
|
switch fn := node.Fun.(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
switch fn.Sel.Name {
|
||||||
|
case "New":
|
||||||
|
// guestnet.New(...)
|
||||||
|
if pkg, ok := fn.X.(*ast.Ident); ok && pkg.Name == "guestnet" {
|
||||||
|
constructed = true
|
||||||
|
}
|
||||||
|
case "SetGuestNetReporter":
|
||||||
|
reporterWired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *ast.GoStmt:
|
||||||
|
if sel, ok := node.Call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Watch" {
|
||||||
|
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "gnWatchdog" {
|
||||||
|
started = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !constructed {
|
||||||
|
t.Error("main.go never calls guestnet.New — the watchdog does not exist at runtime")
|
||||||
|
}
|
||||||
|
if !reporterWired {
|
||||||
|
t.Error("main.go never calls collector.SetGuestNetReporter — the guest_net stanza would " +
|
||||||
|
"never reach the hub (the exact v0.91.0 inert-seam defect)")
|
||||||
|
}
|
||||||
|
if !started {
|
||||||
|
t.Error("main.go never starts the watchdog with `go gnWatchdog.Watch(ctx)` — it would be " +
|
||||||
|
"constructed, reported on, and never probe anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watchdog must NOT join the errc fan-out: a guest-network watchdog that can terminate the
|
||||||
|
// agent turns a customer's DHCP problem into an operator-plane outage. If it is ever changed to
|
||||||
|
// `errc <- ...`, the drain bound at the bottom of main() also has to change — this catches the
|
||||||
|
// first half of that mistake.
|
||||||
|
func TestGuestNetWatchdogIsNotInTheErrcFanout(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
bad := false
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
send, ok := n.(*ast.SendStmt)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ch, ok := send.Chan.(*ast.Ident); !ok || ch.Name != "errc" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
call, ok := send.Value.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Watch" {
|
||||||
|
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "gnWatchdog" {
|
||||||
|
bad = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if bad {
|
||||||
|
t.Fatal("the guestnet watchdog was added to the errc fan-out — a watchdog over customer " +
|
||||||
|
"guests must never be able to bring the agent down (and the drain bound in main() " +
|
||||||
|
"would now be off by one, hanging shutdown)")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-86 Scenario I — the seam-discipline test for the due-check.
|
||||||
|
//
|
||||||
|
// A due-check is worth nothing if the daemon still wires the OLD picker: every unit test in
|
||||||
|
// internal/backup would stay green (they inject the seam directly), the scheduler would ask for the
|
||||||
|
// newest archive with no settle cutoff, and the per-archive rule would run against a candidate that
|
||||||
|
// changes every time a backup lands. That is the same shape as the v0.91.0 inert seam — built,
|
||||||
|
// tested, never called — and this repo has shipped it four times.
|
||||||
|
//
|
||||||
|
// It walks main.go's AST rather than grepping: a commented-out call still satisfies a substring
|
||||||
|
// match, and a comment is not a caller.
|
||||||
|
func TestMainWiresTheSettleAwareTierPicker(t *testing.T) {
|
||||||
|
f := parseMainForWiring(t)
|
||||||
|
|
||||||
|
var settlePicker, oldPicker, settleWired, evalInterval bool
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
switch node := n.(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
// runner.PickSettledRestoreCandidateOn passed as a value (not called).
|
||||||
|
switch node.Sel.Name {
|
||||||
|
case "PickSettledRestoreCandidateOn":
|
||||||
|
settlePicker = true
|
||||||
|
case "PickRestoreCandidateOn":
|
||||||
|
oldPicker = true
|
||||||
|
}
|
||||||
|
case *ast.KeyValueExpr:
|
||||||
|
key, ok := node.Key.(*ast.Ident)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if key.Name == "Settle" {
|
||||||
|
settleWired = true
|
||||||
|
}
|
||||||
|
case *ast.CallExpr:
|
||||||
|
if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "RestoreTestEvalInterval" {
|
||||||
|
evalInterval = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !settlePicker {
|
||||||
|
t.Error("main.go never passes runner.PickSettledRestoreCandidateOn as the scheduler's TierPick — " +
|
||||||
|
"the due-check would run without a settle cutoff, i.e. against an archive that may still be being written")
|
||||||
|
}
|
||||||
|
if oldPicker {
|
||||||
|
t.Error("main.go still wires the pre-R-86 PickRestoreCandidateOn as a tier picker — " +
|
||||||
|
"two pickers means the one under test is not the one running")
|
||||||
|
}
|
||||||
|
if !settleWired {
|
||||||
|
t.Error("main.go never sets SchedulerOptions.Settle — the settle lag would default to 0 in the daemon " +
|
||||||
|
"and every freshly-landed archive would be an immediate candidate")
|
||||||
|
}
|
||||||
|
if !evalInterval {
|
||||||
|
t.Error("main.go never calls cfg.Backup.RestoreTestEvalInterval() — the scheduler would be driven by " +
|
||||||
|
"the retired cadence knob")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two R-85 guarantees the due-check must not have quietly dropped: the spec is still built PER
|
||||||
|
// RUN, and the shared heavy-operation gate is still handed to the scheduler.
|
||||||
|
func TestMainStillWiresTheHeavyOperationGateAndPerRunSpec(t *testing.T) {
|
||||||
|
f := parseMainForWiring(t)
|
||||||
|
|
||||||
|
var inFlightWired, specIsAFunc bool
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
kv, ok := n.(*ast.KeyValueExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
key, ok := kv.Key.(*ast.Ident)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch key.Name {
|
||||||
|
case "InFlight":
|
||||||
|
inFlightWired = true
|
||||||
|
case "Spec":
|
||||||
|
// A FuncLit means it is evaluated per run; anything else is a frozen value.
|
||||||
|
if _, isFunc := kv.Value.(*ast.FuncLit); isFunc {
|
||||||
|
specIsAFunc = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if !inFlightWired {
|
||||||
|
t.Error("main.go no longer hands the scheduler the shared InFlight gate — a restore-test could pull a " +
|
||||||
|
"multi-GB archive over the same tunnel an offsite backup is pushing one over (Scenario F)")
|
||||||
|
}
|
||||||
|
if !specIsAFunc {
|
||||||
|
t.Error("SchedulerOptions.Spec is no longer a function literal — a frozen spec is the R-85 defect " +
|
||||||
|
"(the tier and its timeout evaluated once at daemon start, forever)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMainForWiring(t *testing.T) *ast.File {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse main.go: %v", err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/localapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// COMPILE-TIME WITNESSES for OPTIONAL interfaces that are satisfied by a RUNTIME type assertion.
|
||||||
|
//
|
||||||
|
// WHY THIS FILE EXISTS. `localapi.BackupArchiveLister` is asserted at server.go's `newestArchiveOn`
|
||||||
|
// via `tier.Service.(BackupArchiveLister)`. A failed assertion does not error — it degrades to
|
||||||
|
// `archiveAbsent`, i.e. the pre-R-84 "ask the in-memory record only" behaviour. That degrade is
|
||||||
|
// SILENT and it is behaviour-relevant: it is exactly the R-84 bug (a cold store after a restart
|
||||||
|
// reading as "no backup ever") coming back, with nothing in any log to say so.
|
||||||
|
//
|
||||||
|
// The precedent is not hypothetical. During R-88 Part 2 the controller's `quiesceBackend` stopped
|
||||||
|
// satisfying `quiesce.TieredBackend` when a signature changed, and `go build` AND `go vet` both
|
||||||
|
// passed — because the interface is only ever asserted at runtime. Every box would have degraded to
|
||||||
|
// the single-tier path, losing R-82's multi-tier backups, with no error anywhere. It was caught by
|
||||||
|
// accident.
|
||||||
|
//
|
||||||
|
// A witness costs one line and converts that class of failure from a silent production degrade into
|
||||||
|
// a compile error.
|
||||||
|
//
|
||||||
|
// THIS DOES NOT MAKE THE INTERFACE REQUIRED. The optionality is deliberate — it is what lets a
|
||||||
|
// BackupService without a lister still work. The witness pins the IMPLEMENTATION (this concrete type
|
||||||
|
// really does satisfy it), not the CONTRACT.
|
||||||
|
var _ localapi.BackupArchiveLister = (*backup.BackupRunner)(nil)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Command felhom-opsign is the OPERATOR's offline signing CLI for destructive ops (slice 10B,
|
||||||
|
// decision (a): offline operator key + signing CLI, hardware-key-ready).
|
||||||
|
//
|
||||||
|
// It constructs the canonical OpBlob bytes by REUSING internal/authz.CanonicalBlob — the exact
|
||||||
|
// production path the agent's verifier authenticates over — so signer and verifier can never drift.
|
||||||
|
// It signs that canonical message with the operator's key via `ssh-keygen -Y sign`, which makes it
|
||||||
|
// hardware-ready: an `sk-`/YubiKey key works through ssh-keygen unchanged. The output is a signed-op
|
||||||
|
// envelope { op_blob_b64, sig_armored } to hand to the hub's jobs queue (optionally uploaded with
|
||||||
|
// --upload). This CLI touches ONLY the operator's signing key — never the hub's or agent's keys.
|
||||||
|
//
|
||||||
|
// Example — sign a data-bearing wipe (closing the 8C pending_signature gap):
|
||||||
|
//
|
||||||
|
// felhom-opsign -op storage_wipe -host demo-felhom-01 \
|
||||||
|
// -durable-id byid:wwn-0x5000c500abcd1234 -fstype ext4 \
|
||||||
|
// -key-id ops-key-1 -key ~/.ssh/felhom_op_ed25519 -ttl 30m
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "felhom-opsign:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
var (
|
||||||
|
op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update")
|
||||||
|
host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)")
|
||||||
|
guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)")
|
||||||
|
keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)")
|
||||||
|
paramsRaw = flag.String("params", "", "op params as JSON (overrides -durable-id/-fstype)")
|
||||||
|
durableID = flag.String("durable-id", "", "storage_wipe: the DURABLE device id (byid:…|byuuid:…); decommission: the drive's STORAGE durable-id (e.g. uuid:<fs-uuid>)")
|
||||||
|
fstype = flag.String("fstype", "ext4", "for storage_wipe: the filesystem to mkfs after wipe")
|
||||||
|
agentVer = flag.String("agent-version", "", "for agent_update: the target agent version (e.g. 0.70.1)")
|
||||||
|
sha256Hex = flag.String("sha256", "", "for agent_update: the pinned lowercase-hex sha256 of the target binary")
|
||||||
|
keyFile = flag.String("key", "", "operator signing key (ssh private key / sk- key handle) for ssh-keygen -Y sign")
|
||||||
|
ttl = flag.Duration("ttl", 30*time.Minute, "validity window from now (issued_at..expires_at)")
|
||||||
|
nonce = flag.String("nonce", "", "explicit nonce (default: a fresh 128-bit random nonce)")
|
||||||
|
uploadURL = flag.String("upload", "", "optional hub base URL to POST the signed op to the jobs queue")
|
||||||
|
hubKey = flag.String("hub-key", "", "hub bearer key for --upload (operator's hub key — NOT a signing key)")
|
||||||
|
out = flag.String("o", "", "write the envelope JSON to this file (default: stdout)")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *op == "" || *host == "" || *keyID == "" || *keyFile == "" {
|
||||||
|
return fmt.Errorf("-op, -host, -key-id and -key are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params: explicit JSON, or built from the convenience flags.
|
||||||
|
params := strings.TrimSpace(*paramsRaw)
|
||||||
|
if params == "" {
|
||||||
|
switch *op {
|
||||||
|
case "storage_wipe":
|
||||||
|
if *durableID == "" {
|
||||||
|
return fmt.Errorf("storage_wipe needs -durable-id (byid:…|byuuid:…) — a path-only binding is refused by the agent")
|
||||||
|
}
|
||||||
|
pj, _ := json.Marshal(map[string]string{"durable_id": *durableID, "fstype": *fstype})
|
||||||
|
params = string(pj)
|
||||||
|
case "decommission":
|
||||||
|
// Decommission binds to the drive's STORAGE durable-id (the watchdog's key, e.g.
|
||||||
|
// "uuid:<fs-uuid>"), NOT the device-level byid:/byuuid: scheme. The agent records this
|
||||||
|
// id into the intent map, so it must match what the storage observer reports.
|
||||||
|
if *durableID == "" {
|
||||||
|
return fmt.Errorf("decommission needs -durable-id (the drive's storage durable-id, e.g. uuid:<fs-uuid>)")
|
||||||
|
}
|
||||||
|
pj, _ := json.Marshal(map[string]string{"durable_id": *durableID})
|
||||||
|
params = string(pj)
|
||||||
|
case "agent_update":
|
||||||
|
// The agent downloads the binary for -agent-version and verifies it against -sha256.
|
||||||
|
// The sha is the ONLY integrity root, so both are mandatory and the sha is strict-validated.
|
||||||
|
if *agentVer == "" || *sha256Hex == "" {
|
||||||
|
return fmt.Errorf("agent_update needs -agent-version and -sha256 (the pinned binary hash)")
|
||||||
|
}
|
||||||
|
if !isHex64(*sha256Hex) {
|
||||||
|
return fmt.Errorf("agent_update -sha256 must be 64 lowercase hex chars (got %d)", len(*sha256Hex))
|
||||||
|
}
|
||||||
|
pj, _ := json.Marshal(map[string]string{"version": *agentVer, "sha256": *sha256Hex})
|
||||||
|
params = string(pj)
|
||||||
|
default:
|
||||||
|
params = "{}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n := *nonce
|
||||||
|
if n == "" {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return fmt.Errorf("generating nonce: %w", err)
|
||||||
|
}
|
||||||
|
n = hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
issued := now
|
||||||
|
expires := now.Add(*ttl)
|
||||||
|
|
||||||
|
// Canonical OpBlob bytes — the EXACT bytes the agent verifier authenticates over.
|
||||||
|
blob, err := authz.CanonicalBlob(*op, *host, *guest, *keyID, n, params, issued, expires)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sigArmored, err := signWithSSHKeygen(blob, *keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
env := map[string]string{
|
||||||
|
"op_blob_b64": base64.StdEncoding.EncodeToString(blob),
|
||||||
|
"sig_armored": sigArmored,
|
||||||
|
}
|
||||||
|
envJSON, _ := json.Marshal(env)
|
||||||
|
|
||||||
|
// Emit the envelope (stdout or file). Also print the human summary to stderr (never the key).
|
||||||
|
fmt.Fprintf(os.Stderr, "signed: op=%s host=%s guest=%q key_id=%s nonce=%s expires=%s\n",
|
||||||
|
*op, *host, *guest, *keyID, n, expires.Format(time.RFC3339))
|
||||||
|
if *out != "" {
|
||||||
|
if err := os.WriteFile(*out, envJSON, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "wrote envelope to", *out)
|
||||||
|
} else {
|
||||||
|
fmt.Println(string(envJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
if *uploadURL != "" {
|
||||||
|
if *hubKey == "" {
|
||||||
|
return fmt.Errorf("--upload needs --hub-key (the operator's hub bearer key)")
|
||||||
|
}
|
||||||
|
if err := upload(*uploadURL, *hubKey, *host, envJSON); err != nil {
|
||||||
|
return fmt.Errorf("upload to hub: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "uploaded signed op to the hub jobs queue")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHex64 reports whether s is exactly 64 lowercase hex chars (a sha256 hex digest).
|
||||||
|
func isHex64(s string) bool {
|
||||||
|
if len(s) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// signWithSSHKeygen signs `message` with `ssh-keygen -Y sign -n <namespace>`, the hardware-ready
|
||||||
|
// path (sk-/YubiKey keys work unchanged). It writes the message to a temp file, runs ssh-keygen,
|
||||||
|
// and reads the armored SSHSIG it produces. The namespace is the agent's FIXED domain separator.
|
||||||
|
func signWithSSHKeygen(message []byte, keyFile string) (string, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "felhom-opsign-")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
msgPath := filepath.Join(dir, "op.blob")
|
||||||
|
if err := os.WriteFile(msgPath, message, 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cmd := exec.Command("ssh-keygen", "-Y", "sign", "-n", authz.Namespace, "-f", keyFile, msgPath)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("ssh-keygen -Y sign: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
sig, err := os.ReadFile(msgPath + ".sig")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading produced signature: %w", err)
|
||||||
|
}
|
||||||
|
return string(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload POSTs the signed-op envelope to the hub's jobs queue (POST /api/v1/admin/hosts/{id}/jobs).
|
||||||
|
// The queued blob is base64(envelope JSON); the hub stores it opaquely (it cannot forge or open it).
|
||||||
|
func upload(baseURL, hubKey, hostID string, envJSON []byte) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"blob_b64": base64.StdEncoding.EncodeToString(envJSON),
|
||||||
|
})
|
||||||
|
// 10A's enqueue lives under /admin/hosts/{id}/jobs (operator/global key). The queued blob is
|
||||||
|
// base64(envelope JSON); the hub stores it opaquely.
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/api/v1/admin/hosts/" + hostID + "/jobs"
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+hubKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
hc := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TASK D1 Group D — agent_update opsign param validation. isHex64 is the sha gate the CLI applies
|
||||||
|
// before it will build an agent_update envelope (the agent re-validates too, but a bad sha should
|
||||||
|
// never even be signed).
|
||||||
|
func TestIsHex64(t *testing.T) {
|
||||||
|
good := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 'a'
|
||||||
|
if !isHex64(good) {
|
||||||
|
t.Errorf("isHex64(%q) = false, want true", good)
|
||||||
|
}
|
||||||
|
for name, bad := range map[string]string{
|
||||||
|
"too short": "abcdef",
|
||||||
|
"too long": good + "a",
|
||||||
|
"uppercase hex": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
"non-hex char": "g" + good[1:],
|
||||||
|
"empty": "",
|
||||||
|
} {
|
||||||
|
if isHex64(bad) {
|
||||||
|
t.Errorf("%s: isHex64(%q) = true, want false", name, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"proxmox": {
|
||||||
|
"endpoint": "https://127.0.0.1:8006",
|
||||||
|
"node": "demo-felhom",
|
||||||
|
"token": "felhom-agent@pve!agent=REPLACE_WITH_SECRET",
|
||||||
|
"tls": {
|
||||||
|
"ca_file": "",
|
||||||
|
"fingerprint": "BA:7C:99:7D:45:D0:67:91:E2:F2:72:74:6E:D6:9F:83:51:D1:61:E5:C3:BD:F6:A0:B8:0B:E3:D8:DB:89:5B:CF",
|
||||||
|
"insecure_skip_verify": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"privileged": {
|
||||||
|
"mode": "sudo",
|
||||||
|
"sudo_path": "sudo"
|
||||||
|
},
|
||||||
|
"authz": {
|
||||||
|
"nonce_store_path": "/var/lib/felhom-agent/nonces.log",
|
||||||
|
"signers": [
|
||||||
|
{
|
||||||
|
"key_id": "felhom-op-1",
|
||||||
|
"role": "operational",
|
||||||
|
"public_key": "ssh-ed25519 AAAA... felhom-op-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key_id": "felhom-recovery-1",
|
||||||
|
"role": "recovery",
|
||||||
|
"public_key": "ssh-ed25519 AAAA... felhom-recovery-1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hub": {
|
||||||
|
"url": "https://hub.felhom.eu",
|
||||||
|
"host_id": "demo-host-01",
|
||||||
|
"api_key": "REPLACE_WITH_PER_HOST_HUB_KEY",
|
||||||
|
"poll_seconds": 900,
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
"ca_file": ""
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"local_backup_target": "local",
|
||||||
|
"restore_storage": "local-lvm",
|
||||||
|
"restore_test_cadence_seconds": 0,
|
||||||
|
"scratch_vmid_min": 990000,
|
||||||
|
"scratch_vmid_max": 990009,
|
||||||
|
"pbs_verify_cadence_seconds": 0,
|
||||||
|
"pbs_secret_dir": "/etc/pve/priv/storage",
|
||||||
|
"backup_cadence_seconds": 0
|
||||||
|
},
|
||||||
|
"local_api": {
|
||||||
|
"enable": true,
|
||||||
|
"listen_addr": "169.254.253.1:8443",
|
||||||
|
"cert_file": "/var/lib/felhom-agent/local-api.crt",
|
||||||
|
"key_file": "/var/lib/felhom-agent/local-api.key",
|
||||||
|
"token_store": "/var/lib/felhom-agent/local-tokens.log",
|
||||||
|
"island_bridge": "vmbr9",
|
||||||
|
"island_guest_addr": "169.254.253.2/30"
|
||||||
|
},
|
||||||
|
"lan_resolver": {
|
||||||
|
"enable": true,
|
||||||
|
"host_ip": "192.168.0.162"
|
||||||
|
},
|
||||||
|
"log_level": "info"
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-golden.sh — build the Felhom golden base LXC archive (slice 7).
|
||||||
|
#
|
||||||
|
# Produces a minimal Debian + Docker, unprivileged, nesting=1,keyctl=1, overlayfs LXC, baked
|
||||||
|
# identity-clean, and archives it for a token-restore by the bring-up reconcile job
|
||||||
|
# (internal/reconcile/bringup.go). Run as root@pam on a Proxmox host (the keyctl=1 feature flag
|
||||||
|
# is root-only — phase3 #1; this is the ONE root step, off the per-customer path).
|
||||||
|
#
|
||||||
|
# Grounded by documentation/tests/slice7-bringup-spike-findings.md (commit 3342993):
|
||||||
|
# - F3: removing the SSH host keys does NOT auto-regenerate them on Debian (pct restore runs no
|
||||||
|
# keygen hook), so a baked, Condition-gated first-boot unit regenerates them — keeping the
|
||||||
|
# agent's front half host-side-only. The gate (ConditionPathExists=!…) makes it fire on a
|
||||||
|
# provision (golden, keys absent) and no-op on a DR restore (customer backup, keys present),
|
||||||
|
# symmetric with machine-id.
|
||||||
|
# - machine-id: truncated; systemd regenerates it on first boot for free (no unit needed).
|
||||||
|
#
|
||||||
|
# Slice 8A — the golden now also BAKES the in-guest controller (decision: image baked at golden
|
||||||
|
# build on the trusted host, so NO registry credential ever enters a customer guest at deploy) and
|
||||||
|
# a controller-bootstrap unit that, on boot, deploys the baked image from the agent-populated
|
||||||
|
# config mount (/etc/felhom-bootstrap/bootstrap.json) — no docker login/pull at deploy. Refreshing
|
||||||
|
# the golden bumps the controller baseline; controller self-update covers in-between drift.
|
||||||
|
#
|
||||||
|
# Usage: build-golden.sh [VMID] [TEMPLATE_VOLID] [ROOTFS_STORAGE] [ARCHIVE_STORAGE] [BRIDGE] CONTROLLER_IMAGE
|
||||||
|
# CONTROLLER_IMAGE is REQUIRED (no default) — pass the released controller tag explicitly,
|
||||||
|
# e.g. gitea.dooplex.hu/admin/felhom-controller:0.98.3.
|
||||||
|
# Build-time registry login for the controller pull (used ONCE inside the build guest, then logged
|
||||||
|
# out — never baked): set REGISTRY_USER + REGISTRY_TOKEN in the environment.
|
||||||
|
#
|
||||||
|
# OS / DATA SPLIT, and since v3.0.0 ONE DATA VOLUME (R-165, decision D-a + variant V-c).
|
||||||
|
#
|
||||||
|
# The golden is built with a SMALL OS rootfs and a SINGLE data volume (mp0, backup=1) mounted at a
|
||||||
|
# NEUTRAL path, /var/lib/felhom. Both consumer paths are binds of subdirectories of it:
|
||||||
|
#
|
||||||
|
# /var/lib/felhom/docker --bind--> /var/lib/docker (Docker's data-root)
|
||||||
|
# /var/lib/felhom/sys_drive --bind--> /mnt/sys_drive (the controller's system_data_path)
|
||||||
|
#
|
||||||
|
# WHAT THIS REPLACED, AND WHY. Until v2.1.0 these were TWO volumes (mp0 16 G at /var/lib/docker,
|
||||||
|
# mp1 8 G at /mnt/sys_drive, grown separately at provision). The second one was a fixed ceiling: an
|
||||||
|
# app whose local recovery unit outgrew it stopped being backed up even with free space next door.
|
||||||
|
# D-a removed the wall rather than moving it — one volume, one free-space figure, no ceiling.
|
||||||
|
#
|
||||||
|
# WHY A NEUTRAL MOUNT AND NOT SIMPLY NESTING ONE PATH INSIDE THE OTHER. Both simpler shapes were
|
||||||
|
# built and measured (SPIKE-r165-phase0-2026-08-03.md); both boot and reboot cleanly, and each breaks
|
||||||
|
# a different documented guarantee:
|
||||||
|
# * volume at /var/lib/docker -> customer backups live INSIDE Docker's data-root, so `du` there
|
||||||
|
# stops meaning what it says and the ordinary "clear /var/lib/docker to fix Docker" reflex
|
||||||
|
# destroys every local recovery unit on the box;
|
||||||
|
# * volume at /mnt/sys_drive -> Docker's ENTIRE data-root lands under /mnt, which the controller
|
||||||
|
# container mounts wholesale (`-v /mnt:/mnt:rslave`). Measured: the container then sees
|
||||||
|
# /mnt/sys_drive/docker. The bootstrap's own claim that /mnt "holds only Felhom's
|
||||||
|
# felhom-data-namespace mounts" would become false.
|
||||||
|
# The neutral mount breaks neither, for one extra path and one extra fstab line.
|
||||||
|
#
|
||||||
|
# The split from the OS rootfs is still for RESILIENCE: an isolated rootfs stays bootable +
|
||||||
|
# agent-recoverable if the data volume fills (the controller's prevention layer, and since
|
||||||
|
# controller v0.192.0 the capture floor, keep it from filling). Size is env-overridable
|
||||||
|
# (OS_SIZE_GB / GOLDEN_VOLUME_GB); provision GROWS the one volume (bringup.go DataVolGrowGB).
|
||||||
|
# backup=1 is MANDATORY: without it vzdump EXCLUDES the volume (extra LXC mountpoints default
|
||||||
|
# backup=0 — storage-split B3), so the archive would carry no images AND no user data.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Script provenance — logged into every bake transcript next to the baked controller tag, so an
|
||||||
|
# archive can always be traced to the script that produced it. Bump on any behavior change.
|
||||||
|
GOLDEN_SCRIPT_VERSION="3.0.0"
|
||||||
|
|
||||||
|
VMID="${1:-9100}"
|
||||||
|
TEMPLATE="${2:-local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst}"
|
||||||
|
ROOTFS_STORAGE="${3:-local-lvm}"
|
||||||
|
ARCHIVE_STORAGE="${4:-local}"
|
||||||
|
BRIDGE="${5:-vmbr0}"
|
||||||
|
# CONTROLLER_IMAGE is MANDATORY — no default. The hand-bumped default rotted twice (0.43.0 →
|
||||||
|
# 0.85.1 → stale again): each time, a fresh provision would have booted a pre-floor controller
|
||||||
|
# needing a manual install-day update (drill finding B5). A required argument cannot rot, and
|
||||||
|
# auto-resolving "latest" could bake a tag the hub manifest never vouched — so the caller states
|
||||||
|
# the released controller tag explicitly at every rebuild.
|
||||||
|
CONTROLLER_IMAGE="${6:-}"
|
||||||
|
if [ -z "$CONTROLLER_IMAGE" ]; then
|
||||||
|
echo "[golden] FATAL: CONTROLLER_IMAGE (argument 6) is required — pass the released controller tag explicitly." >&2
|
||||||
|
echo "Usage: build-golden.sh [VMID] [TEMPLATE_VOLID] [ROOTFS_STORAGE] [ARCHIVE_STORAGE] [BRIDGE] CONTROLLER_IMAGE" >&2
|
||||||
|
echo " e.g.: build-golden.sh 9100 local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst local-lvm local vmbr0 gitea.dooplex.hu/admin/felhom-controller:0.98.3" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REGISTRY_HOST="${CONTROLLER_IMAGE%%/*}"
|
||||||
|
# OS rootfs size (GiB) and the golden's SINGLE data volume size (GiB).
|
||||||
|
#
|
||||||
|
# ONE VOLUME MEANS ONE NUMBER (v3.0.0). The retired GOLDEN_SYSDATA_GB has no successor: there is
|
||||||
|
# nothing left to size separately. Keep GOLDEN_VOLUME_GB just large enough for the baked images plus
|
||||||
|
# headroom for the controller's felhom-data skeleton; provision grows the one volume to the
|
||||||
|
# per-customer target (bringup.go DataVolGrowGB).
|
||||||
|
OS_SIZE_GB="${OS_SIZE_GB:-32}"
|
||||||
|
# 24 = the retired pair's 16 (docker) + 8 (user-data), so a golden archive carries the same content it
|
||||||
|
# did before the merge. It is deliberately NOT a per-customer size: provision grows it.
|
||||||
|
GOLDEN_VOLUME_GB="${GOLDEN_VOLUME_GB:-24}"
|
||||||
|
# The neutral mount path of the single volume. Both consumer paths are binds of subdirectories of it.
|
||||||
|
GOLDEN_VOLUME_MP="/var/lib/felhom"
|
||||||
|
|
||||||
|
echo "[golden] build-golden.sh v${GOLDEN_SCRIPT_VERSION} — baking controller ${CONTROLLER_IMAGE}"
|
||||||
|
echo "[golden] creating build LXC $VMID (nesting=1,keyctl=1, unprivileged; rootfs ${OS_SIZE_GB}G + ONE data volume ${GOLDEN_VOLUME_GB}G @ ${GOLDEN_VOLUME_MP}, backup=1) …"
|
||||||
|
# ONE mpN slot. There is deliberately no mp1: that slot held the retired user-data volume, and the
|
||||||
|
# whole point of R-165 is that it stops existing rather than being made bigger.
|
||||||
|
pct create "$VMID" "$TEMPLATE" \
|
||||||
|
--hostname felhom-golden --unprivileged 1 \
|
||||||
|
--features nesting=1,keyctl=1 \
|
||||||
|
--rootfs "${ROOTFS_STORAGE}:${OS_SIZE_GB}" --cores 2 --memory 2048 \
|
||||||
|
--mp0 "${ROOTFS_STORAGE}:${GOLDEN_VOLUME_GB},mp=${GOLDEN_VOLUME_MP},backup=1" \
|
||||||
|
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" --onboot 0
|
||||||
|
|
||||||
|
echo "[golden] starting + installing Docker (official repo, trixie channel) …"
|
||||||
|
pct start "$VMID"
|
||||||
|
# wait for DHCP/DNS
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if pct exec "$VMID" -- getent hosts download.docker.com >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
pct exec "$VMID" -- bash -c '
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl >/dev/null
|
||||||
|
install -m0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io >/dev/null
|
||||||
|
'
|
||||||
|
echo "[golden] baking daemon.json: classic overlay2 driver (containerd-snapshotter OFF) + log rotation …"
|
||||||
|
# containerd-snapshotter (Docker 28+/29 default) keeps the IMAGE content store under
|
||||||
|
# /var/lib/containerd — which is NOT /var/lib/docker, so it would stay on the OS rootfs and the split
|
||||||
|
# would only move named volumes, defeating the whole point (validated: images landed on the rootfs).
|
||||||
|
# The classic overlay2 driver stores EVERYTHING (images + overlay + volumes) under data-root
|
||||||
|
# (/var/lib/docker) = the data volume, which is exactly what "one data-root = one partition for all
|
||||||
|
# images + overlay" requires. It also makes the controller's statfs("/") (its overlay root) report the
|
||||||
|
# DATA volume, which the prevention layer depends on — MEASURED to still hold under the v3.0.0 merged
|
||||||
|
# layout (a container's `df /` reports the single volume, phase-0 spike). Since v3.0.0 /var/lib/docker
|
||||||
|
# is a BIND of <volume>/docker rather than the mp0 mount itself, wired immediately below; data-root
|
||||||
|
# still needs no override because the path is unchanged. Log caps kill the most common runaway.
|
||||||
|
pct exec "$VMID" -- bash -c 'mkdir -p /etc/docker; cat > /etc/docker/daemon.json <<JSON
|
||||||
|
{
|
||||||
|
"features": { "containerd-snapshotter": false },
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": { "max-size": "10m", "max-file": "3" }
|
||||||
|
}
|
||||||
|
JSON'
|
||||||
|
echo "[golden] wiring the single data volume (R-165 variant V-c): ${GOLDEN_VOLUME_MP}/{docker,sys_drive} -> binds …"
|
||||||
|
# docker-ce has already populated /var/lib/docker ON THE ROOTFS by now (it auto-starts on install), so
|
||||||
|
# the content is MOVED onto the volume before the bind is laid over the top. Doing it the other way
|
||||||
|
# round would hide those files under the bind and silently ship a golden whose baked images are on the
|
||||||
|
# rootfs — the exact failure class the assertions below exist to catch.
|
||||||
|
#
|
||||||
|
# /etc/fstab, not a hand-run mount: systemd's fstab generator orders both binds under local-fs.target,
|
||||||
|
# which precedes basic.target and therefore docker.service. MEASURED across 3 reboots per variant in
|
||||||
|
# the phase-0 spike — the ordering worry that motivated the probe did not materialise.
|
||||||
|
pct exec "$VMID" -- bash -c "
|
||||||
|
set -e
|
||||||
|
systemctl stop docker docker.socket containerd 2>/dev/null || true
|
||||||
|
mkdir -p '${GOLDEN_VOLUME_MP}/docker' '${GOLDEN_VOLUME_MP}/sys_drive'
|
||||||
|
if [ -d /var/lib/docker ] && [ -n \"\$(ls -A /var/lib/docker 2>/dev/null)\" ]; then
|
||||||
|
cp -a /var/lib/docker/. '${GOLDEN_VOLUME_MP}/docker'/
|
||||||
|
rm -rf /var/lib/docker/*
|
||||||
|
fi
|
||||||
|
mkdir -p /var/lib/docker /mnt/sys_drive
|
||||||
|
printf '%s /var/lib/docker none bind 0 0\n' '${GOLDEN_VOLUME_MP}/docker' >> /etc/fstab
|
||||||
|
printf '%s /mnt/sys_drive none bind 0 0\n' '${GOLDEN_VOLUME_MP}/sys_drive' >> /etc/fstab
|
||||||
|
systemctl daemon-reload
|
||||||
|
mount /var/lib/docker
|
||||||
|
mount /mnt/sys_drive
|
||||||
|
systemctl start containerd
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "[golden] verifying Docker works in the build guest (storage driver should be overlay2 on the ext4 data volume) …"
|
||||||
|
# RESTART (not start): docker-ce auto-starts on install with the DEFAULT config, so it is already
|
||||||
|
# running by now; only a restart picks up the daemon.json just written (overlay2 + log caps).
|
||||||
|
pct exec "$VMID" -- bash -c 'systemctl restart docker; sleep 3; docker run --rm hello-world >/dev/null && echo " docker OK ($(docker info 2>/dev/null | sed -n "s/.*Storage Driver: //p"); data-root $(docker info 2>/dev/null | sed -n "s/.*Docker Root Dir: //p"))"'
|
||||||
|
# Guard: the image store MUST be on the data volume now. /var/lib/containerd holding the images would
|
||||||
|
# mean containerd-snapshotter is still on (the split would leave images on the rootfs).
|
||||||
|
pct exec "$VMID" -- bash -c 'drv=$(docker info 2>/dev/null | sed -n "s/.*Storage Driver: //p"); [ "$drv" = "overlay2" ] || { echo "[golden] FATAL: storage driver is $drv, expected overlay2 — images would not land on the data volume"; exit 1; }'
|
||||||
|
# ASSERTION 1 (RETARGETED v3.0.0, not removed). /var/lib/docker must be a real mount — now the V-c
|
||||||
|
# bind of <volume>/docker rather than the mp0 mount itself. Still fails closed on the same failure:
|
||||||
|
# if the bind did not take, Docker's data-root silently sits on the OS rootfs and the golden ships
|
||||||
|
# its baked images there.
|
||||||
|
pct exec "$VMID" -- bash -c 'findmnt -no SOURCE,FSTYPE /var/lib/docker | grep -q . && echo " /var/lib/docker is a real mount: $(findmnt -no SOURCE,FSTYPE /var/lib/docker | head -1)" || { echo "[golden] FATAL: /var/lib/docker is NOT a mount — the V-c docker bind did not take, so the baked images would land on the OS rootfs"; exit 1; }'
|
||||||
|
# ASSERTION 2 (RETARGETED v3.0.0). /mnt/sys_drive must be a real mount — now the V-c bind of
|
||||||
|
# <volume>/sys_drive. Otherwise the controller's system_data_path lands on the OS drive and it warns
|
||||||
|
# (clearing that warning is the whole point of the volume).
|
||||||
|
pct exec "$VMID" -- bash -c 'findmnt -no SOURCE,FSTYPE /mnt/sys_drive | grep -q . && echo " /mnt/sys_drive is a real mount: $(findmnt -no SOURCE,FSTYPE /mnt/sys_drive | head -1)" || { echo "[golden] FATAL: /mnt/sys_drive is NOT a mount — the V-c sys_drive bind did not take, so the controller system_data_path would be the OS rootfs"; exit 1; }'
|
||||||
|
# ASSERTION 2b (NEW v3.0.0 — the invariant the merge is FOR). Both paths must be backed by the SAME
|
||||||
|
# device, i.e. ONE filesystem with ONE free-space figure. Two devices here is the S2 shape the R-165
|
||||||
|
# spike ranked strictly WORSE than the split it replaced: every assertion satisfied, the ceiling still
|
||||||
|
# there, and a shared pool neither `df` can see coming.
|
||||||
|
pct exec "$VMID" -- bash -c 'n=$(df --output=source /var/lib/docker /mnt/sys_drive | tail -n +2 | sort -u | wc -l); [ "$n" = "1" ] && echo " both paths are ONE filesystem: $(df --output=source,avail /var/lib/docker | tail -1)" || { echo "[golden] FATAL: /var/lib/docker and /mnt/sys_drive are on $n DIFFERENT filesystems — that is the S2 shape (two ceilings), not the R-165 merge"; exit 1; }'
|
||||||
|
|
||||||
|
echo "[golden] baking the in-guest controller image $CONTROLLER_IMAGE (no registry cred at deploy) …"
|
||||||
|
# docker login is used ONCE here on the trusted build host, then logged out before archiving so
|
||||||
|
# the credential is NEVER baked into the golden. The IMAGE is what gets baked (in Docker storage).
|
||||||
|
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||||
|
pct exec "$VMID" -- bash -c "systemctl start docker; sleep 1; echo '$REGISTRY_TOKEN' | docker login '$REGISTRY_HOST' -u '$REGISTRY_USER' --password-stdin >/dev/null"
|
||||||
|
fi
|
||||||
|
pct exec "$VMID" -- bash -c "docker pull '$CONTROLLER_IMAGE'"
|
||||||
|
# Record the baked image ref for the bootstrap unit (so the unit needs no login/pull).
|
||||||
|
pct exec "$VMID" -- bash -c "printf '%s\n' '$CONTROLLER_IMAGE' > /etc/felhom-controller-image"
|
||||||
|
|
||||||
|
# Bake EVERY controller-managed infra image so enabling an infra stack on a fresh box is near-instant
|
||||||
|
# instead of a multi-minute silent registry pull.
|
||||||
|
#
|
||||||
|
# THE LIST COMES FROM THE CONTROLLER WE JUST PULLED, not from a copy here. This script used to carry
|
||||||
|
# a hand-maintained array of three tags with a comment telling the reader to keep it in sync with the
|
||||||
|
# controller's internal/infra constants. It drifted the moment a fourth stack was added: felhom-samba
|
||||||
|
# was never added here, so the golden baked 3 of 4 and turning on Megosztás pulled from the registry
|
||||||
|
# with zero UI feedback — observed live, twice. Asking the binary (`--print-infra-images`, backed by
|
||||||
|
# infra.Images()) makes golden-vs-controller drift structurally impossible: the golden bakes exactly
|
||||||
|
# what THIS controller version will request.
|
||||||
|
echo "[golden] asking the controller which infra images it manages …"
|
||||||
|
INFRA_LIST="$(pct exec "$VMID" -- bash -c "docker run --rm --entrypoint /usr/local/bin/felhom-controller '$CONTROLLER_IMAGE' --print-infra-images 2>/dev/null" || true)"
|
||||||
|
mapfile -t INFRA_IMAGES < <(printf '%s\n' "$INFRA_LIST" | grep -E '^[a-z0-9._/-]+:[A-Za-z0-9._-]+$' || true)
|
||||||
|
if [ "${#INFRA_IMAGES[@]}" -eq 0 ]; then
|
||||||
|
# Controllers older than v0.147.0 have no --print-infra-images. Fall back to the historical list so
|
||||||
|
# an old-controller bake still works — but say so loudly, because the fallback is exactly the
|
||||||
|
# drift-prone thing this change removed, and it CANNOT know about stacks added after it was written.
|
||||||
|
echo "[golden] WARN: '$CONTROLLER_IMAGE' does not support --print-infra-images (pre-0.147.0)."
|
||||||
|
echo "[golden] WARN: falling back to the historical 3-image list. felhom-samba will NOT be baked,"
|
||||||
|
echo "[golden] WARN: so enabling Megosztás on this golden will pull at runtime. Bake >=0.147.0."
|
||||||
|
INFRA_IMAGES=(
|
||||||
|
"traefik:v3.6.7"
|
||||||
|
"cloudflare/cloudflared:2026.6.0"
|
||||||
|
"gtstef/filebrowser:1.3.3-stable"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
echo "[golden] baking infra images (${#INFRA_IMAGES[@]}): ${INFRA_IMAGES[*]} …"
|
||||||
|
for img in "${INFRA_IMAGES[@]}"; do
|
||||||
|
# Hard gate: fail loudly BEFORE pulling if a pinned tag doesn't resolve (a bad pin otherwise fails
|
||||||
|
# mid-bake with a confusing error).
|
||||||
|
pct exec "$VMID" -- bash -c "docker manifest inspect '$img' >/dev/null 2>&1" \
|
||||||
|
|| { echo "[golden] FATAL: pinned infra image does not resolve: $img"; exit 1; }
|
||||||
|
pct exec "$VMID" -- bash -c "docker pull '$img'"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Log out LAST — not right after the controller pull. felhom-samba lives on the same private registry
|
||||||
|
# as the controller, so the infra loop above needs the credential; logging out first made that pull
|
||||||
|
# 401. The credential is still never archived: the logout + config.json removal happen before the
|
||||||
|
# guest is stopped and templated.
|
||||||
|
pct exec "$VMID" -- bash -c "docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -f /root/.docker/config.json"
|
||||||
|
pct exec "$VMID" -- bash -c '[ ! -s /root/.docker/config.json ]' \
|
||||||
|
|| { echo "[golden] FATAL: registry credential still present in the guest — refusing to archive"; exit 1; }
|
||||||
|
|
||||||
|
echo "[golden] baking the controller-bootstrap unit (deploys the BAKED controller from the config mount) …"
|
||||||
|
pct push "$VMID" /dev/stdin /usr/local/sbin/felhom-controller-bootstrap.sh --perms 700 <<'BOOTSH'
|
||||||
|
#!/bin/bash
|
||||||
|
# felhom controller-bootstrap (slice 8A): the host agent's back-half populated the read-only
|
||||||
|
# config mount /etc/felhom-bootstrap; this golden-baked oneshot deploys the BAKED controller image
|
||||||
|
# with that config. NO docker login / NO docker pull — the image is already in this golden's Docker
|
||||||
|
# storage (and self-update handles version drift). Host-side only; the agent never enters the guest.
|
||||||
|
set -euo pipefail
|
||||||
|
CFG=/etc/felhom-bootstrap/bootstrap.json
|
||||||
|
[ -r "$CFG" ] || { echo "[ctrl-bootstrap] no $CFG — not provisioned, nothing to do"; exit 0; }
|
||||||
|
IMAGE=$(cat /etc/felhom-controller-image 2>/dev/null || true)
|
||||||
|
[ -n "$IMAGE" ] || { echo "[ctrl-bootstrap] FATAL: /etc/felhom-controller-image missing"; exit 1; }
|
||||||
|
# Per-guest container hostname (slice base-infra/3A): derive from the bootstrap's customer.id so the
|
||||||
|
# controller's os.Hostname() (its hub-reported hostname) is the customer id, not the Docker container
|
||||||
|
# ID. Portable, dependency-free parse (NO jq in the golden) — bootstrap.json has exactly one "id" key
|
||||||
|
# (customer.id). Falls back to no --hostname if the parse yields nothing (fail-safe).
|
||||||
|
CUSTOMER_ID=$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CFG" | head -1)
|
||||||
|
# SECURITY: $CUSTOMER_ID is interpolated into `docker run` — reject anything that isn't a DNS-safe
|
||||||
|
# label so a malformed/hostile customer.id can't smuggle extra docker flags (e.g. --privileged, -v).
|
||||||
|
# Then pass it via a quoted array (never word-split). Empty/invalid → no --hostname (fail-safe).
|
||||||
|
case "$CUSTOMER_ID" in
|
||||||
|
""|*[!a-zA-Z0-9._-]*|-*) CUSTOMER_ID="" ;;
|
||||||
|
esac
|
||||||
|
HOSTNAME_ARGS=()
|
||||||
|
[ -n "$CUSTOMER_ID" ] && HOSTNAME_ARGS=(--hostname "$CUSTOMER_ID")
|
||||||
|
echo "[ctrl-bootstrap] deploying $IMAGE from $CFG (hostname=${CUSTOMER_ID:-<unset>})"
|
||||||
|
docker rm -f felhom-controller >/dev/null 2>&1 || true
|
||||||
|
# Section-G fix (base-infra slice): the controller writes app/infra compose stacks under
|
||||||
|
# /opt/docker/stacks INSIDE the container, but `docker compose up` is executed by the GUEST daemon
|
||||||
|
# (shared socket), which resolves every relative bind source on the GUEST filesystem. Without a
|
||||||
|
# SAME-PATH host bind for /opt/docker/stacks, the daemon can't see those bind sources and silently
|
||||||
|
# creates empty dirs → every bind-mounted stack (base infra AND customer apps) breaks. A named volume
|
||||||
|
# would NOT fix this (it resolves to /var/lib/docker/volumes/...). Pre-create the dir, then same-path bind.
|
||||||
|
mkdir -p /opt/docker/stacks
|
||||||
|
# slice 10 P2: the controller must SEE enrolled external data drives, which the agent binds into the
|
||||||
|
# guest at /mnt/<name>. Make /mnt a SHARED mount so those binds (and later host-remount self-heal)
|
||||||
|
# propagate into the controller container, then bind /mnt :rslave (host->container propagation ONLY —
|
||||||
|
# the container can't mutate the guest mount tree). This is the ONE bind the 8C de-privileging left
|
||||||
|
# out; scoped to /mnt, which (Model A) holds only Felhom's felhom-data-namespace mounts, never the
|
||||||
|
# customer's other on-drive data. rbind preserves existing submounts; re-running re-shares (safe).
|
||||||
|
mkdir -p /mnt
|
||||||
|
mountpoint -q /mnt || mount --rbind /mnt /mnt
|
||||||
|
mount --make-rshared /mnt
|
||||||
|
# Otherwise still DE-PRIVILEGED: disk EXECUTION (scan/format/mount) stays the agent's — NO --privileged,
|
||||||
|
# no /dev, no /etc/fstab. Bootstrap config (ro), data volume, stacks dir (same-path), the /mnt :rslave
|
||||||
|
# view, and the docker socket. The controller reaches the agent's local API for disk management.
|
||||||
|
docker run -d --name felhom-controller --restart unless-stopped "${HOSTNAME_ARGS[@]}" \
|
||||||
|
-e FELHOM_BOOTSTRAP_PATH=/etc/felhom-bootstrap/bootstrap.json \
|
||||||
|
-v /etc/felhom-bootstrap:/etc/felhom-bootstrap:ro \
|
||||||
|
-v felhom-controller-data:/opt/docker/felhom-controller \
|
||||||
|
-v /opt/docker/stacks:/opt/docker/stacks \
|
||||||
|
-v /mnt:/mnt:rslave \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
"$IMAGE"
|
||||||
|
echo "[ctrl-bootstrap] controller started"
|
||||||
|
BOOTSH
|
||||||
|
pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-controller-bootstrap.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom controller bootstrap (deploy the baked controller from the agent-populated config mount)
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Wants=docker.service network-online.target
|
||||||
|
ConditionPathExists=/etc/felhom-bootstrap/bootstrap.json
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/local/sbin/felhom-controller-bootstrap.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl enable felhom-controller-bootstrap.service'
|
||||||
|
|
||||||
|
# B1 (DRILL-day0-cleanroom-2026-07-03 R6/B1): the service's ConditionPathExists is evaluated only
|
||||||
|
# when the service is STARTED — i.e. at boot via multi-user.target — but the agent back-half
|
||||||
|
# hot-plugs the bootstrap mount into the ALREADY-RUNNING guest, so on a provision the boot-time
|
||||||
|
# start races the mount and loses on slow hardware. This path unit watches for bootstrap.json and
|
||||||
|
# starts the service when it APPEARS — covering the provision hot-plug without a reboot. The boot
|
||||||
|
# case is still served by the enabled service itself; RemainAfterExit=yes on the service means the
|
||||||
|
# path unit cannot re-trigger it in a loop once it has run.
|
||||||
|
echo "[golden] baking the controller-bootstrap PATH unit (starts the service on bootstrap-mount hot-plug — B1) …"
|
||||||
|
pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-controller-bootstrap.path <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Watch for the agent-populated bootstrap config; start the controller bootstrap when it appears
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
PathExists=/etc/felhom-bootstrap/bootstrap.json
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl enable felhom-controller-bootstrap.path'
|
||||||
|
|
||||||
|
echo "[golden] baking the first-boot SSH host-key regeneration unit (F3) …"
|
||||||
|
pct exec "$VMID" -- bash -c 'cat > /etc/systemd/system/felhom-regen-hostkeys.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Regenerate SSH host keys on first boot if absent
|
||||||
|
ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key
|
||||||
|
DefaultDependencies=no
|
||||||
|
After=local-fs.target
|
||||||
|
Before=ssh.service sshd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/bin/ssh-keygen -A
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl enable felhom-regen-hostkeys.service'
|
||||||
|
|
||||||
|
echo "[golden] identity-clean + minimize …"
|
||||||
|
pct exec "$VMID" -- bash -c '
|
||||||
|
systemctl stop docker containerd 2>/dev/null || true
|
||||||
|
apt-get clean; rm -rf /var/lib/apt/lists/*
|
||||||
|
rm -f /etc/ssh/ssh_host_* # regenerated on first boot by the baked unit (F3)
|
||||||
|
truncate -s 0 /etc/machine-id # systemd regenerates on first boot (free)
|
||||||
|
rm -f /var/lib/dbus/machine-id; ln -sf /etc/machine-id /var/lib/dbus/machine-id
|
||||||
|
rm -rf /var/log/*; : > /root/.bash_history
|
||||||
|
rm -f /etc/hostname # set per-guest at provision (host-side token config)
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "[golden] stop + archive …"
|
||||||
|
pct stop "$VMID"
|
||||||
|
# --mode stop with mp0 backup=1 → the SINGLE data volume (baked images AND the user-data area) is
|
||||||
|
# INCLUDED. The log MUST show "including mount point mp0" and must NOT show it being excluded — an
|
||||||
|
# exclusion means the backup flag was lost and the archive carries neither (storage-split B3 trap).
|
||||||
|
# Since v3.0.0 there is no mp1; the guard that covered it is retargeted below rather than deleted,
|
||||||
|
# because a guard whose pattern can no longer match is a guard that has silently stopped guarding.
|
||||||
|
vzdump "$VMID" --storage "$ARCHIVE_STORAGE" --mode stop --compress zstd 2>&1 | tee /tmp/golden-vzdump.log | grep -iE "including mount point|excluding|archive file size|Finished Backup" || true
|
||||||
|
if grep -q "excluding volume mount point mp0" /tmp/golden-vzdump.log; then
|
||||||
|
echo "[golden] FATAL: mp0 (/var/lib/docker) was EXCLUDED from the archive — backup=1 was lost; the golden would carry no images. Aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# ASSERTION 4 (RETARGETED v3.0.0). The mp1 guard used to catch "the user-data volume fell out of the
|
||||||
|
# archive". After the merge there is no mp1 — so the same failure now looks like the volume being
|
||||||
|
# mounted at the WRONG PATH, which would carry the images but not the user-data area. Assert the
|
||||||
|
# inclusion line names the volume's actual mount path.
|
||||||
|
if ! grep -q "including mount point mp0 ('${GOLDEN_VOLUME_MP}')" /tmp/golden-vzdump.log; then
|
||||||
|
echo "[golden] FATAL: the archive's mp0 is not ${GOLDEN_VOLUME_MP} — the single data volume is mounted somewhere unexpected, so the archive would not carry both the baked images and the user-data area. Aborting."
|
||||||
|
grep -iE "mount point" /tmp/golden-vzdump.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# ASSERTION 5 (RETARGETED v3.0.0). There must be NO mp1 in the archive at all. A leftover second
|
||||||
|
# volume means the merge did not take and this golden would ship the very ceiling R-165 removed.
|
||||||
|
if grep -qE "mount point mp1" /tmp/golden-vzdump.log; then
|
||||||
|
echo "[golden] FATAL: the archive still carries an mp1 — the R-165 merge did not take and this golden would ship a second, ceilinged volume. Aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q "including mount point mp0" /tmp/golden-vzdump.log \
|
||||||
|
|| echo "[golden] WARN: could not confirm mp0 inclusion in the vzdump log — verify manually before using this archive."
|
||||||
|
|
||||||
|
VOLID=$(pvesm list "$ARCHIVE_STORAGE" --content backup 2>/dev/null | awk -v v="$VMID" '$1 ~ ("vzdump-lxc-" v "-") {print $1}' | sort | tail -1)
|
||||||
|
echo "[golden] DONE. golden archive volid: ${VOLID:-<check ${ARCHIVE_STORAGE} dump dir>} (rootfs ${OS_SIZE_GB}G + ONE data volume ${GOLDEN_VOLUME_GB}G @ ${GOLDEN_VOLUME_MP}, all in the archive)"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# Publish to Gitea (BUNDLE slice) — make this golden fetchable by the host-bootstrap script.
|
||||||
|
#-------------------------------------------------------------------------------
|
||||||
|
# The host-install script fetches the golden from Gitea
|
||||||
|
# (/api/packages/admin/generic/felhom-golden/<golden-version>/golden.tar.zst) and verifies its sha256
|
||||||
|
# against the hub-vouched artifact manifest before importing it. <golden-version> = the BAKED
|
||||||
|
# controller version (so the golden's published version tracks what it ships). Publishing is OPT-IN:
|
||||||
|
# only runs when GITEA_USER + GITEA_TOKEN (or REGISTRY_USER/REGISTRY_TOKEN) are set. The local-golden
|
||||||
|
# auto-discovery in the host-install script stays as a fallback either way.
|
||||||
|
GITEA_BASE="${GITEA_BASE:-https://gitea.dooplex.hu}"
|
||||||
|
GITEA_OWNER="${GITEA_OWNER:-admin}"
|
||||||
|
PUB_USER="${GITEA_USER:-${REGISTRY_USER:-}}"
|
||||||
|
PUB_TOKEN="${GITEA_TOKEN:-${REGISTRY_TOKEN:-}}"
|
||||||
|
# Golden version = the baked controller tag's version (strip the image path + any leading 'v').
|
||||||
|
GOLDEN_VERSION="${GOLDEN_VERSION:-${CONTROLLER_IMAGE##*:}}"; GOLDEN_VERSION="${GOLDEN_VERSION#v}"
|
||||||
|
|
||||||
|
if [ -z "$VOLID" ]; then
|
||||||
|
echo "[golden] WARN: could not resolve the archive volid — skipping Gitea publish."
|
||||||
|
elif [ -z "$PUB_USER" ] || [ -z "$PUB_TOKEN" ]; then
|
||||||
|
echo "[golden] Gitea publish SKIPPED (set GITEA_USER+GITEA_TOKEN or REGISTRY_USER+REGISTRY_TOKEN to enable)."
|
||||||
|
echo "[golden] would publish version=$GOLDEN_VERSION from volid $VOLID"
|
||||||
|
else
|
||||||
|
# Resolve the archive's on-disk path (pvesm path turns a volid into a filesystem path).
|
||||||
|
ARCHIVE_PATH="$(pvesm path "$VOLID" 2>/dev/null || true)"
|
||||||
|
if [ -z "$ARCHIVE_PATH" ] || [ ! -f "$ARCHIVE_PATH" ]; then
|
||||||
|
echo "[golden] WARN: cannot resolve archive path for $VOLID — skipping publish."
|
||||||
|
else
|
||||||
|
GOLDEN_SHA256="$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}')"
|
||||||
|
PUB_URL="${GITEA_BASE}/api/packages/${GITEA_OWNER}/generic/felhom-golden/${GOLDEN_VERSION}/golden.tar.zst"
|
||||||
|
echo "[golden] publishing golden ($(wc -c < "$ARCHIVE_PATH") bytes, sha256 ${GOLDEN_SHA256:0:16}…) → $PUB_URL"
|
||||||
|
# Delete-then-PUT so re-publishing the same version overwrites cleanly (idempotent).
|
||||||
|
dcode="$(curl -fsS -o /dev/null -w '%{http_code}' -u "${PUB_USER}:${PUB_TOKEN}" -X DELETE "$PUB_URL" 2>/dev/null || true)"
|
||||||
|
echo "[golden] pre-delete existing: HTTP ${dcode} (404/204 expected)"
|
||||||
|
ucode="$(curl -sS -o /dev/null -w '%{http_code}' -u "${PUB_USER}:${PUB_TOKEN}" -X PUT --upload-file "$ARCHIVE_PATH" "$PUB_URL")"
|
||||||
|
if [ "$ucode" = "201" ] || [ "$ucode" = "200" ]; then
|
||||||
|
echo "[golden] upload OK (HTTP $ucode)"
|
||||||
|
echo "GOLDEN_VERSION=${GOLDEN_VERSION}"
|
||||||
|
echo "GOLDEN_SHA256=${GOLDEN_SHA256}"
|
||||||
|
echo "[golden] Record in the hub operator UI (Configs → Day-0 artifacts): golden ${GOLDEN_VERSION} / ${GOLDEN_SHA256}"
|
||||||
|
else
|
||||||
|
echo "[golden] WARN: golden upload failed (HTTP $ucode) — the local archive is still usable via auto-discovery."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[golden] (the build guest $VMID is stopped; destroy it with: pct destroy $VMID --purge)"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# felhom-agent-limits.conf — start-limit + rollback-trigger drop-in for felhom-agent.service
|
||||||
|
# (TASK D1). Install as /etc/systemd/system/felhom-agent.service.d/felhom-agent-limits.conf
|
||||||
|
# and `systemctl daemon-reload`.
|
||||||
|
#
|
||||||
|
# Values are the SPIKE-agent-selfupdate-2026-07-05 tuned set, verbatim [SF-2]: with the unit's
|
||||||
|
# Restart=on-failure + RestartSec=5s and systemd 257's compiled defaults (10s/5), a crash-looping
|
||||||
|
# binary NEVER trips the start limit and loops forever (35 starts/180s measured). 120s/4 gives a
|
||||||
|
# terminal `failed` ≈20s after the first crash.
|
||||||
|
#
|
||||||
|
# PLACEMENT TRAP [SF-3]: these keys MUST be in [Unit]. On systemd 257 a [Service] placement is
|
||||||
|
# HALF-APPLIED — StartLimitBurst is accepted while StartLimitIntervalSec is silently ignored
|
||||||
|
# (journal warning only). Never split them; never put them in [Service].
|
||||||
|
#
|
||||||
|
# OnFailure fires on EVERY crash on systemd 257 [SF-1] — see the comment block in
|
||||||
|
# felhom-agent-rollback.service for why that is safe (pending-marker guard).
|
||||||
|
[Unit]
|
||||||
|
StartLimitIntervalSec=120
|
||||||
|
StartLimitBurst=4
|
||||||
|
OnFailure=felhom-agent-rollback.service
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# felhom-agent-rollback.service — the OnFailure= target that auto-reverts a crash-looping agent
|
||||||
|
# self-update (TASK D1; SPIKE-agent-selfupdate-2026-07-05).
|
||||||
|
#
|
||||||
|
# Install as /etc/systemd/system/felhom-agent-rollback.service. It is referenced by the
|
||||||
|
# felhom-agent-limits.conf drop-in's OnFailure= line.
|
||||||
|
#
|
||||||
|
# THE PER-CRASH-FIRING REALITY [SF-1]: on systemd 257, OnFailure= fires on EVERY crash of the main
|
||||||
|
# unit — even while it is merely `activating (auto-restart)`, long before (and regardless of) the
|
||||||
|
# terminal start-limit `failed` state. During one crash incident this unit therefore runs MANY
|
||||||
|
# times. That is safe BY DESIGN: the wrapper's rollback verb is pending-marker-guarded — the first
|
||||||
|
# fire after a bad update reverts the binary and clears the marker; every later fire (and any fire
|
||||||
|
# with no update in flight at all) is a logged no-op that touches nothing. Consequence: a bad
|
||||||
|
# update is rolled back at the FIRST crash (~seconds), not after the start-limit burst — the tuned
|
||||||
|
# start-limit in the drop-in is the terminal BACKSTOP (e.g. an environmental crash loop of the
|
||||||
|
# known-good binary), not the rollback trigger.
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom agent self-update auto-rollback (pending-guarded; fires per crash, no-ops without a pending update)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/felhom-selfupdate-guarded rollback
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# felhom-agent.service — canonical systemd unit for the Felhom host agent.
|
||||||
|
#
|
||||||
|
# Install as /etc/systemd/system/felhom-agent.service (the host-bootstrap script installs this from
|
||||||
|
# the Gitea-published binary; previously this unit was hand-made on each host). The agent runs as the
|
||||||
|
# NON-root `felhom-agent` service user (the documented production model — README "Process model";
|
||||||
|
# `privileged.mode: "sudo"`) and shells the few host-root ops out via `sudo -n` against the fixed-arg
|
||||||
|
# allowlist in /etc/sudoers.d/felhom-agent (configs/felhom-agent.sudoers). The User= here and the
|
||||||
|
# sudoers `felhom-agent ALL=(root) NOPASSWD: …` MUST name the SAME user.
|
||||||
|
#
|
||||||
|
# Paths MUST match what the sudoers / host-install script expect:
|
||||||
|
# binary /usr/local/bin/felhom-agent
|
||||||
|
# config /etc/felhom-agent/agent.json (0600 felhom-agent:felhom-agent — secrets live here)
|
||||||
|
# state /var/lib/felhom-agent (nonces, local-api cert/key/tokens, staged units, guests)
|
||||||
|
#
|
||||||
|
# === DELIBERATELY NO SANDBOXING — read before adding any hardening directive ===
|
||||||
|
#
|
||||||
|
# 1. NoNewPrivileges is NOT set. It is INCOMPATIBLE with the agent's privilege model: it blocks the
|
||||||
|
# setuid `sudo` the agent relies on for EVERY host-root op (mount, format, pct, dnsmasq …), so the
|
||||||
|
# agent would silently lose all privileged capability. The narrow surface comes from the sudoers
|
||||||
|
# fixed-arg allowlist + the agent's in-process fine validation (internal/storage/validate.go), NOT
|
||||||
|
# from NoNewPrivileges. Do not add it.
|
||||||
|
#
|
||||||
|
# 2. NO mount-namespacing hardening (ProtectHome, ProtectSystem, PrivateTmp, ReadOnlyPaths,
|
||||||
|
# ProtectControlGroups, …). Any of these give the unit a PRIVATE mount namespace — and the agent's
|
||||||
|
# intermediary-mount drive model does `mount --make-shared /mnt/felhom-drives` + `mount --bind` and
|
||||||
|
# relies on those propagating into the RUNNING customer guest. In a private namespace the binds
|
||||||
|
# would be invisible to the host/guest and every external-drive enrollment would silently break.
|
||||||
|
# The agent MUST share the host mount namespace. The security boundary is the sudoers allowlist.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom host agent (Proxmox host tier; hub control loop + PBS verify + storage watchdog)
|
||||||
|
Documentation=https://gitea.dooplex.hu/admin/felhom-agent
|
||||||
|
After=network-online.target pve-cluster.service pveproxy.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=felhom-agent
|
||||||
|
Group=felhom-agent
|
||||||
|
ExecStart=/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# State dir: created 0750 felhom-agent:felhom-agent on start if absent (local-api cert/key/tokens,
|
||||||
|
# nonces, staged .mount units, per-guest bootstrap dirs all live here).
|
||||||
|
StateDirectory=felhom-agent
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# felhom-agent sudoers allowlist — the NARROW host-root surface (slice 5 Phase B, doc 03 §3/§7).
|
||||||
|
#
|
||||||
|
# Install as a drop-in: /etc/sudoers.d/felhom-agent (mode 0440, root:root), validated with
|
||||||
|
# `visudo -cf`. The agent runs as the non-root `felhom-agent` service user and shells out via
|
||||||
|
# `sudo -n` with FIXED argument vectors (no shell). The fine-grained validation is done IN
|
||||||
|
# the agent BEFORE exec (internal/storage/validate.go): UUIDs against a strict hex regex,
|
||||||
|
# mount paths confined+traversal-checked, SMART devices whitelisted to raw disks, LVM names
|
||||||
|
# charset-checked. These sudoers wildcards are the COARSE allowlist; the agent is the fine
|
||||||
|
# gate, so a wildcard can never be abused by a value the agent didn't already validate.
|
||||||
|
#
|
||||||
|
# Binary paths MUST match the agent config (privileged.systemctl/install/smartctl/lvs). Adjust
|
||||||
|
# for your distro (Debian/PVE shown). A missing/declined entry degrades the agent with a
|
||||||
|
# warning (SMART→UNKNOWN, mount→logged error), it does not crash.
|
||||||
|
|
||||||
|
Cmnd_Alias FELHOM_MOUNT = \
|
||||||
|
/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/units/* /etc/systemd/system/*.mount, \
|
||||||
|
/usr/bin/systemctl daemon-reload, \
|
||||||
|
/usr/bin/systemctl enable --now -- *.mount, \
|
||||||
|
/usr/bin/systemctl disable -- *.mount, \
|
||||||
|
/usr/bin/systemctl stop -- *.mount
|
||||||
|
|
||||||
|
Cmnd_Alias FELHOM_DISK = \
|
||||||
|
/usr/sbin/smartctl -a -j /dev/sd[a-z]*, \
|
||||||
|
/usr/sbin/smartctl -a -j /dev/nvme[0-9]*n[0-9]*, \
|
||||||
|
/usr/sbin/smartctl -a -j /dev/vd[a-z]*, \
|
||||||
|
/usr/sbin/smartctl -a -j /dev/hd[a-z]*, \
|
||||||
|
/usr/sbin/lvs --reportformat json --units b -o lv_name\,data_percent\,metadata_percent -- *, \
|
||||||
|
/usr/sbin/pvs --reportformat json --noheadings -o pv_name, \
|
||||||
|
/usr/sbin/zpool status -P
|
||||||
|
|
||||||
|
# Provisioning back-half (slice 8A, doc 03 §6): populate a guest's bootstrap config mount
|
||||||
|
# host-side (internal/provision). These are host-root ops the API token cannot do — a bind mount
|
||||||
|
# is root@pam-only, and the chown maps the 0600 bootstrap.json to the unprivileged-LXC guest-root
|
||||||
|
# (uid/gid 100000, spike gotcha 1). The host dir is AGENT-OWNED state under /var/lib/felhom-agent/
|
||||||
|
# (the wildcard only ever names a path the agent itself created), and the bootstrap file the agent
|
||||||
|
# writes there is the only thing these touch. ':' is escaped per sudoers grammar.
|
||||||
|
Cmnd_Alias FELHOM_PROVISION = \
|
||||||
|
/usr/bin/chown -R 100000\:100000 /var/lib/felhom-agent/guests/*, \
|
||||||
|
/usr/sbin/pct set [0-9]* -mp[0-9]* /var/lib/felhom-agent/guests/*, \
|
||||||
|
/usr/sbin/pct set [0-9]* -onboot 1
|
||||||
|
|
||||||
|
# Disk inspection + format (slice 8C + Impl-1). blkid/lsblk read the device's data-bearing evidence
|
||||||
|
# (the agent decides data-bearing-ness from THIS, never the caller's claim). Format goes ONLY through
|
||||||
|
# felhom-mkfs-guarded (Impl-1 Part B): raw mkfs.* is NO LONGER allowlisted, so even a bad agent cannot
|
||||||
|
# mkfs the OS disk — the wrapper re-checks the catastrophic cases (system disk / LVM PV / foreign mount)
|
||||||
|
# as root and refuses, and the agent's unclaimed-disk filter (claim.go) is the primary guard above it.
|
||||||
|
Cmnd_Alias FELHOM_FORMAT = \
|
||||||
|
/usr/sbin/blkid -p -o export /dev/*, \
|
||||||
|
/usr/bin/lsblk -J -o NAME\,FSTYPE\,PTTYPE\,MOUNTPOINT /dev/*, \
|
||||||
|
/usr/local/sbin/felhom-mkfs-guarded /dev/* *
|
||||||
|
|
||||||
|
# LAN split-horizon resolver (internal/lanresolver): the agent manages a host-side dnsmasq that
|
||||||
|
# answers *.<customer-domain> with each guest's live LAN IP. install only ever writes felhom-*.conf
|
||||||
|
# drop-ins (from agent-written /tmp temp files); the two `pct exec` reads are FIXED command vectors
|
||||||
|
# (the guest's eth0 IPv4 + the controller's pulled controller.yaml for the domain) — NOT a general
|
||||||
|
# `pct exec`. systemctl is scoped to the dnsmasq unit only. The agent never edits /etc/resolv.conf.
|
||||||
|
Cmnd_Alias FELHOM_DNSMASQ = \
|
||||||
|
/usr/bin/apt-get install -y -q dnsmasq, \
|
||||||
|
/usr/bin/install -m 0644 /tmp/felhom-resolver-*.conf /etc/dnsmasq.d/felhom-*.conf, \
|
||||||
|
/usr/bin/systemctl enable --now dnsmasq, \
|
||||||
|
/usr/bin/systemctl reload dnsmasq, \
|
||||||
|
/usr/bin/systemctl restart dnsmasq, \
|
||||||
|
/usr/bin/rm -f /etc/dnsmasq.d/felhom-*.conf, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- ip -4 -o addr show dev eth0, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- docker exec felhom-controller cat /opt/docker/felhom-controller/controller.yaml
|
||||||
|
|
||||||
|
# Guest mountpoint lifecycle (intermediary-mount re-architecture + C1 net). The pre-start self-heal hook
|
||||||
|
# wrapper is installed once into the PVE snippets dir (from an agent-written /tmp file) and registered
|
||||||
|
# per-guest; decommission/eject DELETE the dead mountpoint slot so a missing bind source can't brick the
|
||||||
|
# guest at next boot (the B3 C1 fix). The agent fine-validates the vmid (numeric) + slot (mp[0-9]+) and
|
||||||
|
# the snippet path is fixed — the wildcards are the coarse allowlist. The install SOURCE is a
|
||||||
|
# random-named agent temp (os.CreateTemp, audit B1 — a fixed /tmp name was a local TOCTOU), hence the
|
||||||
|
# glob; the DESTINATION stays pinned. The `mkdir -p` creates the snippets dir on a FRESH box —
|
||||||
|
# `install` won't create parents, so without it the hook install failed silently on Day-0 boxes
|
||||||
|
# (B2, DRILL-day0-cleanroom-2026-07-03; fixed agent v0.63.0).
|
||||||
|
Cmnd_Alias FELHOM_GUESTHOOK = \
|
||||||
|
/usr/bin/mkdir -p /var/lib/vz/snippets, \
|
||||||
|
/usr/bin/install -m 0755 -- /tmp/felhom-guest-hook-*.sh /var/lib/vz/snippets/felhom-guest-hook.sh, \
|
||||||
|
/usr/sbin/pct set [0-9]* --hookscript local\:snippets/felhom-guest-hook.sh, \
|
||||||
|
/usr/sbin/pct set [0-9]* --delete mp[0-9]*, \
|
||||||
|
/usr/sbin/pct reboot [0-9]*
|
||||||
|
|
||||||
|
# Intermediary mount model (the drive hot-swap re-architecture). The agent keeps a SHARED host parent
|
||||||
|
# /mnt/felhom-drives (self-bind + make-shared + a boot-persistence systemd unit) and binds/unbinds each
|
||||||
|
# drive's felhom-data namespace UNDERNEATH it so the change propagates into the running guest live (no
|
||||||
|
# pct, no reboot). The agent fine-validates the drive name + confines paths before any exec; the trailing
|
||||||
|
# `*` (matching the comma-laden mp spec) mirrors the existing FELHOM_PROVISION pattern.
|
||||||
|
# `lxc-info -n <vmid> -p -H` resolves the guest init PID for the GuestSeesMount / bound_under_parent check
|
||||||
|
# (a READ — the drive-gate's "is the drive live in the guest?" signal); WITHOUT it the non-root agent gets
|
||||||
|
# an empty PID and reports every drive absent (multi-drive flapping, audit 2026-06-29). `make-private`
|
||||||
|
# isolates the parent's peer group on FIRST setup only (EnsureSharedParent guards on mountpoint, so it
|
||||||
|
# never re-churns a live parent); without it the parent stays in root's group and submounts double.
|
||||||
|
Cmnd_Alias FELHOM_INTERMEDIARY = \
|
||||||
|
/usr/bin/mkdir -p /mnt/felhom-drives, \
|
||||||
|
/usr/bin/mkdir -p /mnt/felhom-drives/*, \
|
||||||
|
/usr/bin/mkdir -p /mnt/*/felhom-data, \
|
||||||
|
/usr/bin/chown 100000\:100000 /mnt/*/felhom-data, \
|
||||||
|
/usr/bin/mount --bind /mnt/felhom-drives /mnt/felhom-drives, \
|
||||||
|
/usr/bin/mount --make-shared /mnt/felhom-drives, \
|
||||||
|
/usr/bin/mount --make-private /mnt/felhom-drives, \
|
||||||
|
/usr/bin/mount --bind /mnt/*/felhom-data /mnt/felhom-drives/*, \
|
||||||
|
/usr/bin/umount /mnt/felhom-drives/*, \
|
||||||
|
/usr/bin/install -m 0755 -- /tmp/felhom-shared-parent-*.sh /usr/local/sbin/felhom-shared-parent.sh, \
|
||||||
|
/usr/bin/install -m 0644 -- /tmp/felhom-shared-parent-*.service /etc/systemd/system/felhom-shared-parent.service, \
|
||||||
|
/usr/bin/systemctl enable felhom-shared-parent.service, \
|
||||||
|
/usr/bin/lxc-info -n [0-9]* -p -H, \
|
||||||
|
/usr/sbin/pct set [0-9]* -mp8 /mnt/felhom-drives*
|
||||||
|
|
||||||
|
# Controller-swap / managed auto-update (Option A, non-root). The agent owns the in-guest controller
|
||||||
|
# image SWAP (it survives the controller being killed mid-swap): read the baked image ref, check the
|
||||||
|
# pre-pulled target is present, rewrite /etc/felhom-controller-image, restart the bootstrap unit,
|
||||||
|
# health-check, roll back on failure. Each grant is bounded — NO general `pct exec` and NO `bash -c`:
|
||||||
|
# cat <fixed file> — read the current image ref (read-only)
|
||||||
|
# docker image inspect * — is the pre-pulled target present? (read-only)
|
||||||
|
# docker inspect -f * — container running/health/image (read-only; `*` spans the -f template
|
||||||
|
# + container across spaces, spike-confirmed)
|
||||||
|
# systemctl restart <fixed unit> — re-run the golden's bootstrap (the only state change)
|
||||||
|
# tee <FIXED image file> — WRITE the ref; content is fed on STDIN (no shell, no interpolation),
|
||||||
|
# the agent strict-validates the ref (controllerImageRe) before the write.
|
||||||
|
# Validated GO: felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md.
|
||||||
|
Cmnd_Alias FELHOM_CONTROLLERSWAP = \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- cat /etc/felhom-controller-image, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- docker image inspect *, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- docker inspect -f *, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- systemctl restart felhom-controller-bootstrap.service, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- tee /etc/felhom-controller-image
|
||||||
|
|
||||||
|
# Stale-lock recovery (F2-b, v0.49.0). A host reboot DURING a vzdump backup leaves the guest with a
|
||||||
|
# `snapshot-delete`/`backup` lock + `onboot:1` then can't start it → the customer box stays DOWN. The
|
||||||
|
# agent clears the STALE lock at startup (only when no vzdump is in-flight). `pct unlock` is the one op
|
||||||
|
# with no API equivalent (snapshot-delete + start go through the API token); the agent fine-validates the
|
||||||
|
# vmid (numeric) before exec — the `[0-9]*` is the coarse allowlist.
|
||||||
|
Cmnd_Alias FELHOM_STALELOCK = \
|
||||||
|
/usr/sbin/pct unlock [0-9]*
|
||||||
|
|
||||||
|
# Restore-test scratch teardown (F-LEAK, Campaign 8, v0.110.0). A restore-test whose restore FAILS
|
||||||
|
# leaves a scratch guest the API token CANNOT destroy: `FelhomAgentGuest` is granted at /pool/felhom and
|
||||||
|
# a guest joins that pool only when its restore COMPLETES, so a failed restore leaves a pool-less guest
|
||||||
|
# out of reach (403 VM.Allocate) holding its disks until a human removes it.
|
||||||
|
#
|
||||||
|
# TWO API-SIDE FIXES WERE TRIED AND BOTH REFUTED LIVE on 2026-07-28, which is why this grant exists:
|
||||||
|
# 1. Adopt the stranded guest into the pool, then retry. `PUT /pools/{pool}` ALSO requires
|
||||||
|
# VM.Allocate on the VM being added — pool membership cannot bootstrap its own authority.
|
||||||
|
# 2. Grant FelhomAgentGuest per-path at /vms/990000..990009. Durable for exactly one use per slot:
|
||||||
|
# PVE's own destroy path calls `AccessControl::remove_vm_access($vmid)` (LXC.pm:906), which DELETES
|
||||||
|
# every ACL at /vms/<vmid> (AccessControl.pm:1898). The grant is consumed by the operation it
|
||||||
|
# authorises, so after ten teardowns the band is ungranted and the defect returns.
|
||||||
|
#
|
||||||
|
# WHY THIS IS THE TIGHTEST AVAILABLE FENCE, not a widening: sudo matches the vmid LITERALLY, so
|
||||||
|
# `99000[0-9]` is exactly the ten-slot scratch band the restore-test picks from — nothing else. There is
|
||||||
|
# no `[0-9]*` coarse allowlist here on purpose: unlike `pct unlock`, this op DESTROYS, so the band must
|
||||||
|
# be in the policy and not merely validated in the agent. Even a compromised agent asking for
|
||||||
|
# `pct destroy 9201` is refused by sudo itself. Unlike an ACL, a sudoers rule is not consumed by use.
|
||||||
|
# The agent re-checks the band in code before exec (defence in depth); this is the outer fence.
|
||||||
|
Cmnd_Alias FELHOM_SCRATCH_TEARDOWN = \
|
||||||
|
/usr/sbin/pct destroy 99000[0-9] --purge
|
||||||
|
|
||||||
|
# Network storage / NAS (Part A1, SPIKE-nas-storage-2026-06-29). The agent mounts a customer NAS share
|
||||||
|
# HOST-SIDE under /mnt/felhom-drives/<name> via a systemd .automount (+ .mount) pair so it propagates
|
||||||
|
# into the guest through the existing shared bind (an unprivileged LXC cannot mount NFS/CIFS itself).
|
||||||
|
# A NAS is NOT a drive — no durable-id, no SMART, no wipe; these grants only install/enable/remove the
|
||||||
|
# unit pair. The agent fine-validates every value (share name, server, export, uid/gid, creds path) before
|
||||||
|
# any unit is rendered (internal/storage/netmount.go ValidateNetworkMountSpec); the trailing globs are the
|
||||||
|
# COARSE allowlist. The `.mount` install/enable/disable/stop reuse FELHOM_MOUNT; this alias adds the
|
||||||
|
# `.automount` variants + the unit-file removal. The unit FILE name is the systemd-escaped mountpoint,
|
||||||
|
# which always begins `mnt-felhom` (the mountpoint is /mnt/felhom-drives/<name>), so the rm glob is scoped
|
||||||
|
# to felhom mount units only. mkdir of the mountpoint reuses FELHOM_INTERMEDIARY's /mnt/felhom-drives/*.
|
||||||
|
# CAMPAIGN-3 additions (loud, per the no-widening rule):
|
||||||
|
# - `systemctl reset-failed -- mnt-felhom*`: F10 (CRITICAL) — a NAS automount that hit
|
||||||
|
# mount-start-limit-hit during an outage was re-armable by NO platform path; the reassert now
|
||||||
|
# reset-failed's the stuck unit before `enable --now` (which the start-limit otherwise refuses),
|
||||||
|
# and RemoveNetworkMount clears failed-state residue (F2). Scoped to felhom mount units (the unit
|
||||||
|
# name is the systemd-escaped mountpoint, always beginning `mnt-felhom`). reset-failed only clears
|
||||||
|
# a unit's failed latch — it cannot start/stop/alter anything.
|
||||||
|
# - `rmdir /mnt/felhom-drives/*`: F1 — remove the now-empty mountpoint dir a removed share leaves
|
||||||
|
# behind (the campaign accumulated 10 stub-shaped leftovers). rmdir ONLY (never rm -rf): it refuses
|
||||||
|
# a non-empty dir, so unexpected data is preserved, not destroyed — a fail-safe grant.
|
||||||
|
Cmnd_Alias FELHOM_NETMOUNT = \
|
||||||
|
/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/units/* /etc/systemd/system/*.automount, \
|
||||||
|
/usr/bin/systemctl enable --now -- *.automount, \
|
||||||
|
/usr/bin/systemctl disable -- *.automount, \
|
||||||
|
/usr/bin/systemctl stop -- *.automount, \
|
||||||
|
/usr/bin/systemctl reset-failed -- mnt-felhom*, \
|
||||||
|
/usr/bin/rmdir /mnt/felhom-drives/*, \
|
||||||
|
/usr/bin/rm -f /etc/systemd/system/mnt-felhom*
|
||||||
|
|
||||||
|
# Offsite WG tunnel (S3, doc 06 §3.3). The agent manages wg-quick@wg-felhom as an agent-managed
|
||||||
|
# host service (the dnsmasq/lanresolver shape): conf staged in the agent-owned StateDir (never
|
||||||
|
# /tmp), installed 0600 to the FIXED destination, unit enable/restart/disable. The ONLY wg read
|
||||||
|
# is `latest-handshakes` — `wg show <if> dump` is FORBIDDEN everywhere (its interface line
|
||||||
|
# carries the PRIVATE KEY; the S1 session-log incident). Both install paths are FIXED (no glob):
|
||||||
|
# the agent has exactly one tunnel conf to manage.
|
||||||
|
Cmnd_Alias FELHOM_WG = \
|
||||||
|
/usr/bin/apt-get install -y -q wireguard-tools, \
|
||||||
|
/usr/bin/install -o root -g root -m 0600 -- /var/lib/felhom-agent/wg/wg-felhom.conf /etc/wireguard/wg-felhom.conf, \
|
||||||
|
/usr/bin/systemctl enable --now wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/systemctl restart wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/systemctl disable --now wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/wg show wg-felhom latest-handshakes
|
||||||
|
|
||||||
|
# Agent self-update (TASK D1, SPIKE-agent-selfupdate-2026-07-05). The agent downloads the
|
||||||
|
# operator-SIGNED binary (sha256 pinned in the signed op — neither hub nor Gitea compromise can
|
||||||
|
# substitute it), verifies the sha in-process, then hands off to the guarded wrapper, which
|
||||||
|
# RE-verifies the sha as root, confines the staged path to /var/lib/felhom-agent/selfupdate/,
|
||||||
|
# performs the A/B flip (atomic same-fs rename, .prev retained) and schedules a detached restart.
|
||||||
|
# The apply args are a COARSE glob (spike S4b: sudoers fnmatch makes a [a-f0-9]* sha pattern
|
||||||
|
# first-char-only anyway) — the wrapper's own sha re-verify + path confinement is the real gate.
|
||||||
|
# `rollback` is normally run by felhom-agent-rollback.service (root, OnFailure=), not via sudo;
|
||||||
|
# granting it here keeps the verb probe-able (capability self-check) and operator-invokable.
|
||||||
|
Cmnd_Alias FELHOM_SELFUPDATE = \
|
||||||
|
/usr/local/sbin/felhom-selfupdate-guarded apply /var/lib/felhom-agent/selfupdate/* *, \
|
||||||
|
/usr/local/sbin/felhom-selfupdate-guarded commit, \
|
||||||
|
/usr/local/sbin/felhom-selfupdate-guarded rollback
|
||||||
|
|
||||||
|
# Dedicated OOB sshd (TASK H1). The agent manages felhom-sshd like wg-felhom/dnsmasq: it RENDERS the
|
||||||
|
# config (Port from its claim) + the operator's authorized_keys, validates with `sshd -t`, and reloads
|
||||||
|
# (never restart-on-change [SF-2]). Both install SOURCES are the agent-owned staged files under
|
||||||
|
# StateDir; both DESTINATIONS are FIXED. `sshd -t/-T` are the validate/discover reads. The
|
||||||
|
# systemctl verbs are SCOPED to felhom-sshd only. reset-failed precedes a deliberate restart [SF-5].
|
||||||
|
# NOTHING here can touch the stock sshd, :22, or /etc/ssh.
|
||||||
|
Cmnd_Alias FELHOM_SSHD = \
|
||||||
|
/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/felhom-sshd/sshd_config /etc/felhom-sshd/sshd_config, \
|
||||||
|
/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/felhom-sshd/authorized_keys.felhom-op /etc/felhom-sshd/authorized_keys/felhom-op, \
|
||||||
|
/usr/sbin/sshd -t -f /var/lib/felhom-agent/felhom-sshd/sshd_config, \
|
||||||
|
/usr/sbin/sshd -t -f /etc/felhom-sshd/sshd_config, \
|
||||||
|
/usr/sbin/sshd -T -f /etc/felhom-sshd/sshd_config, \
|
||||||
|
/usr/bin/systemctl enable --now felhom-sshd, \
|
||||||
|
/usr/bin/systemctl reload felhom-sshd, \
|
||||||
|
/usr/bin/systemctl restart felhom-sshd, \
|
||||||
|
/usr/bin/systemctl reset-failed felhom-sshd, \
|
||||||
|
/usr/bin/wg show wg-felhom latest-handshakes
|
||||||
|
|
||||||
|
# PBS DR tier apply (slice 2, SPIKE-pbs-tier-provisioning-2026-07-10 §2b). Storage-entry
|
||||||
|
# lifecycle is /storage-ROOT-gated in the PVE API (spike Probe 1: create/modify/delete all check
|
||||||
|
# Datastore.Allocate on /storage), so the agent token cannot do it — this wrapper is the pinned
|
||||||
|
# vector. THE SET-ONLY LAW: the wrapper contains NO deletion path (entry deletion destroys the
|
||||||
|
# client encryption key = un-decryptable backups); verbs are create/reconcile/grant only. The
|
||||||
|
# token secret rides the wrapper's STDIN — sudo logs argv, so it must never appear here. The
|
||||||
|
# agent fine-validates every field (charset + descriptor equality) before exec; these globs are
|
||||||
|
# the coarse allowlist.
|
||||||
|
#
|
||||||
|
# `read` (R-39 leg b, agent v0.91.0) is the ONE added verb. It prints a token secret to stdout and
|
||||||
|
# performs no mutation. It exists because the agent writes that file through this wrapper but could
|
||||||
|
# never read it back (/etc/pve/priv is 0700 root:www-data), leaving its PBS verify loop permanently
|
||||||
|
# blind to an `applied`-but-401 tier. It is NOT a general file-read: the wrapper pins the directory
|
||||||
|
# and prefix-asserts the resolved path, and the id grammar admits no slash. The secret goes to
|
||||||
|
# STDOUT, never argv — sudo logs argv.
|
||||||
|
# E-2a: the backup-target storage shim. Creating a PVE storage needs Datastore.Allocate at /storage
|
||||||
|
# and the grant needs Permissions.Modify -- the agent holds NEITHER by design (blast-radius
|
||||||
|
# containment; Permissions.Modify would let it rewrite its own authority). Both live behind this
|
||||||
|
# fixed-vocabulary root shim instead, exactly like the mkfs and pbs-apply wrappers. The wrapper has
|
||||||
|
# NO storage-removal path, enforces is_mountpoint 1, and refuses a target on the root device.
|
||||||
|
Cmnd_Alias FELHOM_BACKUPTARGET = \
|
||||||
|
/usr/local/sbin/felhom-backup-target-apply create *, \
|
||||||
|
/usr/local/sbin/felhom-backup-target-apply grant *
|
||||||
|
|
||||||
|
Cmnd_Alias FELHOM_PBSDR = \
|
||||||
|
/usr/local/sbin/felhom-pbs-apply create *, \
|
||||||
|
/usr/local/sbin/felhom-pbs-apply reconcile *, \
|
||||||
|
/usr/local/sbin/felhom-pbs-apply grant *, \
|
||||||
|
/usr/local/sbin/felhom-pbs-apply read *
|
||||||
|
|
||||||
|
# OOB nft belt (TASK H1). The STATIC table `inet felhom_oob` is installed once by host-install; the
|
||||||
|
# agent mutates ONLY its two SETS — @operator_ips (the operator /32) + @ssh_port (the claimed port).
|
||||||
|
# SET ELEMENTS ONLY [trap 4]: NO `nft add rule`, NO `nft -f`, NO `flush ruleset/table` — a rule grant
|
||||||
|
# would let the agent firewall anything. The agent fine-validates every element (netip / int range)
|
||||||
|
# before exec; the trailing wildcards are the coarse allowlist (values only).
|
||||||
|
Cmnd_Alias FELHOM_OOB = \
|
||||||
|
/usr/sbin/nft list set inet felhom_oob operator_ips, \
|
||||||
|
/usr/sbin/nft list set inet felhom_oob ssh_port, \
|
||||||
|
/usr/sbin/nft flush set inet felhom_oob operator_ips, \
|
||||||
|
/usr/sbin/nft flush set inet felhom_oob ssh_port, \
|
||||||
|
/usr/sbin/nft add element inet felhom_oob operator_ips *, \
|
||||||
|
/usr/sbin/nft add element inet felhom_oob ssh_port *
|
||||||
|
|
||||||
|
# Escrow ceremony (controller-driven, TASK 2026-07-13; mechanics validated by
|
||||||
|
# SPIKE-controller-escrow-2026-07-13). ONE fixed argv — sudoers matches the argument vector
|
||||||
|
# byte-for-byte (spike §2.2): any alteration (value, extra flag, order, config path) is refused.
|
||||||
|
# --config pinned: env_reset strips FELHOM_AGENT_CONFIG and the pin closes alternate-config
|
||||||
|
# injection. R rides the subprocess stdout pipe only; sudo logs argv = secrets-free. The argv
|
||||||
|
# MUST stay byte-identical to escrow.CeremonyArgs() (internal/escrow/ceremony.go) — the
|
||||||
|
# capability manifest entry + TestManifestCoveredBySudoers lock the three copies together.
|
||||||
|
Cmnd_Alias FELHOM_ESCROW = \
|
||||||
|
/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json --selftest=escrow-create --upload --output=json
|
||||||
|
|
||||||
|
# Node self-heal (CAMPAIGN-3 Part 6, F12-class defense in depth). The ONE fixed unit the appliance
|
||||||
|
# watchdog may (re)start when a boot leaves networking down — the exact command the morning recovery
|
||||||
|
# ran by hand after the F12 host loss. FIXED unit, no glob: this grant alone cannot harm — starting
|
||||||
|
# networking.service is precisely what the boot should have done. The remedy is ALSO code-gated on
|
||||||
|
# deployment_mode="appliance" (the Manager refuses to invoke it on a byo host); the sudoers grant is
|
||||||
|
# the coarse floor, the mode gate is the fine one.
|
||||||
|
Cmnd_Alias FELHOM_SELFHEAL = \
|
||||||
|
/usr/bin/systemctl start networking.service
|
||||||
|
|
||||||
|
# Guest-network watchdog (internal/guestnet, R-54). The guest's DHCP client is unsupervised — when it
|
||||||
|
# died on 2026-07-20 the box lost its address ~80 minutes later and went off the internet for 1h15m
|
||||||
|
# (INCIDENT-guest-dhclient-killed-2026-07-20). Four FIXED read vectors plus ONE fixed heal vector; the
|
||||||
|
# heal is the incident's own restored invocation, byte for byte. This is NOT a general `pct exec`: every
|
||||||
|
# argument after the numeric vmid is a literal, so the grant cannot be widened by anything the guest or
|
||||||
|
# the hub says. The address read is deliberately NOT duplicated here — it is already FELHOM_DNSMASQ's,
|
||||||
|
# and the same command must not be granted twice under two names.
|
||||||
|
Cmnd_Alias FELHOM_GUESTNET = \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- ip route show default, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- cat /etc/network/interfaces, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- pgrep -x dhclient, \
|
||||||
|
/usr/sbin/pct exec [0-9]* -- dhclient -pf /run/dhclient.eth0.pid -lf /var/lib/dhcp/dhclient.eth0.leases eth0
|
||||||
|
|
||||||
|
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT, FELHOM_WG, FELHOM_SELFUPDATE, FELHOM_SSHD, FELHOM_OOB, FELHOM_PBSDR, FELHOM_BACKUPTARGET, FELHOM_SELFHEAL, FELHOM_ESCROW, FELHOM_GUESTNET, FELHOM_SCRATCH_TEARDOWN
|
||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# felhom-agent local API — host firewall narrowing (doc 03 §6; R-50 island update 2026-07-25)
|
||||||
|
#
|
||||||
|
# Defense-in-depth for the per-guest local API (the controller→agent channel). The PER-GUEST BEARER
|
||||||
|
# TOKEN + the served-leaf pin are the authorization gate; a firewall rule is only an ADDITIONAL layer
|
||||||
|
# limiting who can even open the port.
|
||||||
|
#
|
||||||
|
# === R-50 ISLAND INSTALL (the default on a fresh appliance) =================================
|
||||||
|
# The agent binds local_api.listen_addr on the HOST-INTERNAL island bridge — 169.254.253.1:8443 on
|
||||||
|
# vmbr9, a bridge with NO physical port (bridge-ports none). That bind is the security win:
|
||||||
|
# * Nothing listens on the LAN IP at all, so no LAN host (or off-site attacker on the LAN) can
|
||||||
|
# reach the local API — the LAN:8443 surface is CLOSED by the bind, not by a rule.
|
||||||
|
# * vmbr9 has no uplink, so 169.254.253.1:8443 is reachable ONLY from the one guest wired to the
|
||||||
|
# /30 (169.254.253.2) — the controller. The portless bridge is the isolation.
|
||||||
|
# So on an island install NO firewall rule is required for exposure; the topology provides it. If you
|
||||||
|
# want belt-and-suspenders, restrict the port to the island bridge (it changes nothing, since nothing
|
||||||
|
# off-bridge can route to a portless bridge anyway):
|
||||||
|
#
|
||||||
|
# nft add rule inet filter input iifname != "vmbr9" ip daddr 169.254.253.1 tcp dport 8443 drop
|
||||||
|
#
|
||||||
|
# Verify: from the guest, a TLS connect to 169.254.253.1:8443 succeeds; there is no LAN listener to
|
||||||
|
# probe (`ss -lnt 'sport = :8443'` shows only the island IP).
|
||||||
|
#
|
||||||
|
# === LEGACY LAN BIND (byo, --no-island, or an explicit --bridge-ip) =========================
|
||||||
|
# When the agent still binds a LAN bridge IP (e.g. 192.168.0.162:8443), the port is exposed to the
|
||||||
|
# whole LAN and the subnet-narrowing rule below is worth applying. Replace the bridge IP, port, and
|
||||||
|
# the guest bridge subnet with this host's values.
|
||||||
|
#
|
||||||
|
# Option A — nftables (recommended on PVE 8/9; inet filter table). Insert ABOVE any accept:
|
||||||
|
#
|
||||||
|
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
|
||||||
|
# ip saddr != 192.168.0.0/24 drop
|
||||||
|
# nft add rule inet filter input ip daddr 192.168.0.162 tcp dport 8443 \
|
||||||
|
# ip saddr 192.168.0.0/24 accept
|
||||||
|
#
|
||||||
|
# Option B — iptables:
|
||||||
|
#
|
||||||
|
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -s 192.168.0.0/24 -j ACCEPT
|
||||||
|
# iptables -A INPUT -d 192.168.0.162 -p tcp --dport 8443 -j DROP
|
||||||
|
#
|
||||||
|
# Option C — PVE host firewall (/etc/pve/nodes/<node>/host.fw), if the PVE firewall is enabled.
|
||||||
|
# Add under [RULES] (and ensure the firewall is enabled in cluster.fw / host.fw):
|
||||||
|
#
|
||||||
|
# [RULES]
|
||||||
|
# IN ACCEPT -source 192.168.0.0/24 -dport 8443 -proto tcp -log nolog
|
||||||
|
# IN DROP -dport 8443 -proto tcp -log nolog
|
||||||
|
#
|
||||||
|
# Apply at HOST SETUP — a host-level packet-filter change, intentionally OUTSIDE the agent's
|
||||||
|
# 3-exception privileged fence (the agent never mutates the host firewall at runtime). The token +
|
||||||
|
# leaf-pin still gate the request regardless of which bind is in force.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# felhom-mgmt-watchdog.service (TASK G1) — install as /etc/systemd/system/felhom-mgmt-watchdog.service.
|
||||||
|
#
|
||||||
|
# LAYER 2 (auto-heal without login): a DUMB oneshot that runs /usr/local/sbin/felhom-mgmt-watchdog,
|
||||||
|
# triggered every ~60s by felhom-mgmt-watchdog.timer. It recreates a missing /run/sshd and clears a
|
||||||
|
# start-limited stock sshd — so a management-plane lockout self-corrects in ≤1 tick with NOBODY logged
|
||||||
|
# in and WITHOUT the felhom-agent binary running (the agent only OBSERVES/reports; the heal must not
|
||||||
|
# depend on it — SPIKE-felhom-sshd-2026-07-05 finding #9 / TASK G1 trap 1).
|
||||||
|
#
|
||||||
|
# CRITICAL: this unit MUST NOT declare `RuntimeDirectory=` — that directive (with value `sshd`) is the
|
||||||
|
# incident it exists to fix. It writes only to /run (dir + heal marker) as root; no state dir needed.
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom management-plane watchdog (privsep-dir auto-heal; agent-independent)
|
||||||
|
# Ordering only — the timer drives cadence; no hard dependency so it runs even in degraded boots.
|
||||||
|
After=systemd-tmpfiles-setup.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/felhom-mgmt-watchdog
|
||||||
|
# Deliberately NO RuntimeDirectory=, NO PrivateTmp, NO sandboxing that would hide /run/sshd from it.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# felhom-mgmt-watchdog — the DUMB, agent-INDEPENDENT management-plane healer (TASK G1).
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS: /run/sshd is OpenSSH's compiled-in privilege-separation directory, SHARED by
|
||||||
|
# every sshd on the host. If it goes missing, sshd's listener stays up but every new session RESETS
|
||||||
|
# right after SSH2_MSG_KEXINIT — a management-plane lockout on an otherwise-healthy box
|
||||||
|
# (SPIKE-felhom-sshd-2026-07-05 §8, caused live by a second unit's `RuntimeDirectory=sshd` cleanup).
|
||||||
|
# The tmpfiles.d entry (felhom-privsep.tmpfiles) PREVENTS the known cause; this script AUTO-HEALS any
|
||||||
|
# novel cause with ZERO login and ZERO dependency on the felhom-agent binary (so it self-corrects even
|
||||||
|
# when the agent is down — the whole point). It is run by felhom-mgmt-watchdog.timer every ~60s.
|
||||||
|
#
|
||||||
|
# CONTRACT (all four are load-bearing):
|
||||||
|
# 1. STAT-FIRST / IDEMPOTENT — a HEALTHY /run/sshd is NEVER touched (no mkdir/chmod/chown call), so
|
||||||
|
# steady state is zero mutation and the dir's mtime is preserved. Only a MISSING or
|
||||||
|
# wrong-mode/owner dir is corrected.
|
||||||
|
# 2. reset-failed the STOCK sshd ONLY when it is in the `failed` state (a start-limit lockout) —
|
||||||
|
# never otherwise, and NEVER `restart` it (mkdir alone restores it; PID stays, spike-proven).
|
||||||
|
# 3. HEAL MARKER — writes an RFC3339 UTC timestamp to $MARKER only on a real heal, so the agent can
|
||||||
|
# REPORT the condition to the hub (a recurring clobber must surface BEFORE it becomes a lockout).
|
||||||
|
# 4. NO `RuntimeDirectory=` anywhere in this feature (that IS the incident cause) — the unit that
|
||||||
|
# runs this script must not declare one either.
|
||||||
|
#
|
||||||
|
# Touches host /run + the stock sshd unit ONLY — no guests (pool-scoping is moot here). Fail-safe:
|
||||||
|
# any single step failing is logged and does not abort the others (`|| true`); the next tick retries.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PRIVSEP=/run/sshd
|
||||||
|
MARKER=/run/felhom-mgmt-watchdog.healed
|
||||||
|
SSHD_UNIT=ssh.service # Debian/PVE stock sshd unit name (sshd.service is an alias)
|
||||||
|
healed=0
|
||||||
|
|
||||||
|
log() { logger -t felhom-mgmt-watchdog "$*" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
# 1. Privsep dir — stat-first: create only when missing; correct mode/owner only when wrong.
|
||||||
|
if [ ! -d "$PRIVSEP" ]; then
|
||||||
|
if mkdir -p "$PRIVSEP" && chown root:root "$PRIVSEP" && chmod 0755 "$PRIVSEP"; then
|
||||||
|
healed=1
|
||||||
|
log "recreated missing privsep dir $PRIVSEP (0755 root:root)"
|
||||||
|
else
|
||||||
|
log "ERROR: failed to recreate $PRIVSEP"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
mode=$(stat -c %a "$PRIVSEP" 2>/dev/null || echo "")
|
||||||
|
owner=$(stat -c %U:%G "$PRIVSEP" 2>/dev/null || echo "")
|
||||||
|
if [ "$mode" != "755" ]; then
|
||||||
|
chmod 0755 "$PRIVSEP" && healed=1 && log "corrected $PRIVSEP mode ($mode -> 755)"
|
||||||
|
fi
|
||||||
|
if [ "$owner" != "root:root" ]; then
|
||||||
|
chown root:root "$PRIVSEP" && healed=1 && log "corrected $PRIVSEP owner ($owner -> root:root)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Stock sshd — clear a start-limit lockout ONLY when the unit is genuinely `failed`. Never restart
|
||||||
|
# it (unnecessary — a recreated privsep dir is picked up by the next fork; the listener never died).
|
||||||
|
if systemctl is-failed --quiet "$SSHD_UNIT" 2>/dev/null; then
|
||||||
|
if systemctl reset-failed "$SSHD_UNIT" 2>/dev/null; then
|
||||||
|
healed=1
|
||||||
|
log "reset-failed $SSHD_UNIT (was in the failed state)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Heal marker — only on a real heal, so the agent reports the condition (hub raises a warning).
|
||||||
|
if [ "$healed" = "1" ]; then
|
||||||
|
date -u +%Y-%m-%dT%H:%M:%SZ > "$MARKER" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# felhom-mgmt-watchdog.timer (TASK G1) — install as /etc/systemd/system/felhom-mgmt-watchdog.timer.
|
||||||
|
#
|
||||||
|
# Drives felhom-mgmt-watchdog.service on a ~60s cadence (the auto-heal tick). OnBootSec fires shortly
|
||||||
|
# after boot (belt-and-suspenders with the tmpfiles layer); OnUnitActiveSec=60s gives the ≤1-tick
|
||||||
|
# heal budget the G1 acceptance drill measures. Persistent=true runs a missed tick immediately after a
|
||||||
|
# resume/late boot. The oneshot is idempotent, so a fast cadence never churns a healthy host.
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom management-plane watchdog timer (~60s privsep-dir auto-heal tick)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=30s
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
AccuracySec=5s
|
||||||
|
Persistent=true
|
||||||
|
Unit=felhom-mgmt-watchdog.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#===============================================================================
|
||||||
|
# felhom-mkfs-guarded — the ONLY mkfs path the felhom-agent sudoers permits (Impl-1 Part B,
|
||||||
|
# SPIKE-drive-enrollment-2026-07-01 §SQ3). Defense-in-depth BELOW the agent: even a buggy or
|
||||||
|
# compromised agent cannot mkfs a catastrophic target through this — it re-checks, as root, the
|
||||||
|
# cheap catastrophic cases (OS/system disk, LVM physical volume, a foreign mount, a read-only
|
||||||
|
# device, and any LVM/ZFS/mdraid/LUKS/swap member signature — audit D1) and refuses.
|
||||||
|
#
|
||||||
|
# The agent's full unclaimed-disk filter (internal/storage/claim.go) is the PRIMARY guard; this
|
||||||
|
# wrapper is a deliberately minimal, auditable second gate. It is NOT the place for the full filter.
|
||||||
|
#
|
||||||
|
# Usage: felhom-mkfs-guarded <device> <fstype:ext4|xfs>
|
||||||
|
#===============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() { echo "felhom-mkfs-guarded: REFUSED: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
dev="${1:-}"; fstype="${2:-}"
|
||||||
|
[[ -n "$dev" && -n "$fstype" ]] || die "usage: felhom-mkfs-guarded <device> <fstype>"
|
||||||
|
|
||||||
|
# The device must be a REAL block-device node under /dev — no symlink (e.g. /dev/disk/by-*), no traversal.
|
||||||
|
[[ "$dev" == /dev/* ]] || die "device must be under /dev ($dev)"
|
||||||
|
[[ "$dev" != *..* ]] || die "path traversal ($dev)"
|
||||||
|
[[ -b "$dev" ]] || die "not a block device ($dev)"
|
||||||
|
[[ ! -L "$dev" ]] || die "device must be a real node, not a symlink ($dev)"
|
||||||
|
|
||||||
|
# Whole-disk of the target (a partition's parent, else the disk itself).
|
||||||
|
pk="$(lsblk -ndo PKNAME "$dev" 2>/dev/null || true)"
|
||||||
|
whole="$dev"; [[ -n "$pk" ]] && whole="/dev/$pk"
|
||||||
|
|
||||||
|
# 1) OS/system disk — does the target's whole-disk back /, /boot or /boot/efi?
|
||||||
|
while read -r src mnt _rest; do
|
||||||
|
case "$mnt" in
|
||||||
|
/|/boot|/boot/efi)
|
||||||
|
spk="$(lsblk -ndo PKNAME "$src" 2>/dev/null || true)"
|
||||||
|
swhole="$src"; [[ -n "$spk" ]] && swhole="/dev/$spk"
|
||||||
|
[[ "$swhole" == "$whole" || "$src" == "$dev" || "$src" == "$whole" ]] && die "system/OS disk ($dev backs $mnt)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < /proc/mounts
|
||||||
|
|
||||||
|
# 2) LVM physical volume anywhere on the target disk or its partitions. pvs is resolved by ABSOLUTE
|
||||||
|
# path (audit D1: `command -v pvs` silently skipped this check when pvs wasn't on the caller's
|
||||||
|
# PATH); if neither candidate exists, check 5's LVM2_member FSTYPE loop still catches a PV
|
||||||
|
# independently — pvs-absent never silently drops LVM detection.
|
||||||
|
pvsbin=""
|
||||||
|
for c in /usr/sbin/pvs /sbin/pvs; do
|
||||||
|
[[ -x "$c" ]] && { pvsbin="$c"; break; }
|
||||||
|
done
|
||||||
|
if [[ -n "$pvsbin" ]]; then
|
||||||
|
while read -r pv; do
|
||||||
|
pv="${pv//[[:space:]]/}"; [[ -z "$pv" ]] && continue
|
||||||
|
pvpk="$(lsblk -ndo PKNAME "$pv" 2>/dev/null || true)"
|
||||||
|
pvwhole="$pv"; [[ -n "$pvpk" ]] && pvwhole="/dev/$pvpk"
|
||||||
|
[[ "$pvwhole" == "$whole" ]] && die "device holds an LVM physical volume ($pv)"
|
||||||
|
done < <("$pvsbin" --noheadings -o pv_name 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) mounted OUTSIDE Felhom's own drive area = a live foreign filesystem → catastrophic. Mounts under
|
||||||
|
# /mnt/felhom-drives are our own drives (the agent detaches before a re-init) → allowed.
|
||||||
|
while read -r mp; do
|
||||||
|
[[ -z "$mp" ]] && continue
|
||||||
|
case "$mp" in
|
||||||
|
/mnt/felhom-drives|/mnt/felhom-drives/*) : ;;
|
||||||
|
*) die "device (or a partition) is mounted at $mp ($dev)" ;;
|
||||||
|
esac
|
||||||
|
done < <(lsblk -nro MOUNTPOINT "$whole" 2>/dev/null || true)
|
||||||
|
|
||||||
|
# 4) read-only device (audit D1): a device the kernel marks RO is never a formattable data disk.
|
||||||
|
wbase="${whole#/dev/}"
|
||||||
|
rof="/sys/block/$wbase/ro"
|
||||||
|
if [[ -r "$rof" ]]; then
|
||||||
|
ro="$(cat "$rof" 2>/dev/null || true)"
|
||||||
|
[[ "$ro" == "1" ]] && die "read-only device ($whole)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5) member/active FSTYPEs anywhere on the target disk or its partitions (audit D1). Mirrors
|
||||||
|
# claim.go memberFSTypes exactly: a member of LVM/ZFS/mdraid/LUKS or active-swap signature is
|
||||||
|
# always a claim, never a plain formattable data disk. This also independently catches an LVM PV
|
||||||
|
# when pvs is not installed (check 2's belt-and-suspenders).
|
||||||
|
while read -r fst; do
|
||||||
|
[[ -z "$fst" ]] && continue
|
||||||
|
case "$fst" in
|
||||||
|
LVM2_member|zfs_member|linux_raid_member|crypto_LUKS|swap)
|
||||||
|
die "device holds a $fst signature ($whole)" ;;
|
||||||
|
esac
|
||||||
|
done < <(lsblk -nro FSTYPE "$whole" 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Passed the catastrophic checks → format. exec so the mkfs exit status is the wrapper's.
|
||||||
|
case "$fstype" in
|
||||||
|
ext4) exec /usr/sbin/mkfs.ext4 -F "$dev" ;;
|
||||||
|
xfs) exec /usr/sbin/mkfs.xfs -f "$dev" ;;
|
||||||
|
*) die "unsupported fstype ($fstype)" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# felhom-oob-nft.service (TASK H1) — install as /etc/systemd/system/felhom-oob-nft.service.
|
||||||
|
#
|
||||||
|
# Loads the STATIC felhom_oob belt table on boot (the agent then fills its sets each tick). Oneshot +
|
||||||
|
# RemainAfterExit so `systemctl status` reflects "loaded". Independent of pve-firewall/nftables.service
|
||||||
|
# (the table is a self-contained `inet felhom_oob`, additive — it never flushes the ruleset).
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom OOB belt — load the static felhom_oob nft table (H1)
|
||||||
|
After=network-pre.target
|
||||||
|
Before=network.target felhom-agent.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/sbin/nft -f /etc/felhom-oob.nft
|
||||||
|
# On stop, remove the table (best-effort) so an uninstall/disable leaves nft clean.
|
||||||
|
ExecStop=-/usr/sbin/nft delete table inet felhom_oob
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# felhom OOB belt — STATIC table (TASK H1). Installed by host-install; loaded on boot by
|
||||||
|
# felhom-oob-nft.service. The agent mutates ONLY the two SETS (@operator_ips, @ssh_port) via the
|
||||||
|
# narrow FELHOM_OOB sudoers — it NEVER touches the rules [trap 4], so the enforcement semantics are
|
||||||
|
# fixed here and cannot be changed by the agent.
|
||||||
|
#
|
||||||
|
# Enforcement (scoped to iifname "wg-felhom" — the offsite tunnel — so :22 and every other interface
|
||||||
|
# are UNTOUCHED [SF-3/OF-5]):
|
||||||
|
# - ESTABLISHED,RELATED replies always pass (covers the PBS path + reply traffic; PMTU-safe).
|
||||||
|
# - felhom-sshd's port is reachable from the operator /32 (in @operator_ips) over the tunnel ONLY.
|
||||||
|
# - any other tunnel source to that port is DROPPED at the host (defense-in-depth vs the endpoint).
|
||||||
|
# - the port is unreachable OFF the tunnel entirely.
|
||||||
|
# Empty sets (before the agent's first sync) = the port rules match nothing = no enforcement yet
|
||||||
|
# (felhom-sshd is not started until the agent renders its config either). priority -5 sits just above
|
||||||
|
# the default; policy accept so this table only ever ADDS drops for the felhom-sshd port.
|
||||||
|
#
|
||||||
|
# Idempotent load: create-then-delete-then-define (a re-run/boot yields a clean table with EMPTY sets;
|
||||||
|
# the agent refills them within one tick).
|
||||||
|
|
||||||
|
table inet felhom_oob
|
||||||
|
delete table inet felhom_oob
|
||||||
|
|
||||||
|
table inet felhom_oob {
|
||||||
|
set operator_ips {
|
||||||
|
type ipv4_addr
|
||||||
|
}
|
||||||
|
set ssh_port {
|
||||||
|
type inet_service
|
||||||
|
}
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority -5; policy accept;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "wg-felhom" tcp dport @ssh_port ip saddr @operator_ips accept
|
||||||
|
iifname "wg-felhom" tcp dport @ssh_port drop
|
||||||
|
tcp dport @ssh_port iifname != "wg-felhom" drop
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# felhom-op operator sudoers (TASK H1) — install as /etc/sudoers.d/felhom-op (0440 root:root).
|
||||||
|
#
|
||||||
|
# The DEFAULT operator identity (login via felhom-sshd only; key in /etc/felhom-sshd/authorized_keys/
|
||||||
|
# felhom-op, outside ~/.ssh, so the customer's sshd never honours it [SF-3]). felhom-op is a normal
|
||||||
|
# login user with NO ambient privilege — it escalates ONLY through these explicit repair verbs, so a
|
||||||
|
# leaked operator key's blast radius is this verb set, not the box (locked decision §6). Deep repairs
|
||||||
|
# that genuinely need root (pct, deeper systemctl) remain available via `root` as a tunnel-gated
|
||||||
|
# break-glass account (PermitRootLogin prohibit-password on felhom-sshd), but the day-to-day identity
|
||||||
|
# is felhom-op.
|
||||||
|
Cmnd_Alias FELHOM_OP_REPAIR = \
|
||||||
|
/usr/bin/mkdir -p /run/sshd, \
|
||||||
|
/usr/bin/systemctl reset-failed wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/systemctl restart wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/systemctl start felhom-agent, \
|
||||||
|
/usr/bin/systemctl restart felhom-agent, \
|
||||||
|
/usr/bin/systemctl reset-failed felhom-sshd, \
|
||||||
|
/usr/bin/systemctl restart felhom-sshd, \
|
||||||
|
/usr/sbin/pct list, \
|
||||||
|
/usr/sbin/pct start [0-9]*, \
|
||||||
|
/usr/sbin/pct stop [0-9]*, \
|
||||||
|
/usr/sbin/pct unlock [0-9]*
|
||||||
|
|
||||||
|
felhom-op ALL=(root) NOPASSWD: FELHOM_OP_REPAIR
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/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 (<id>.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 <id> <server> <datastore> <namespace> <token-id> <fingerprint> <secret-dir>
|
||||||
|
# secret on stdin (required). Creates the pbs entry with --encryption-key autogen
|
||||||
|
# (K born at /etc/pve/priv/storage/<id>.enc), then places .pw/.enc copies in
|
||||||
|
# <secret-dir> when it differs (the §4b WARN-fix dir; escrow-create's PBSEncKeyPath
|
||||||
|
# must find K there — spike §4 flag).
|
||||||
|
# reconcile <id> <server> <namespace> <token-id> <fingerprint> <secret-dir>
|
||||||
|
# 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 <id> <secret-dir>
|
||||||
|
# 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 <id>
|
||||||
|
# The Part-0-evidenced dual-grant: FelhomAgentStore on /storage/<id> 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 <create|reconcile|grant|read> <storage-id> ..."
|
||||||
|
|
||||||
|
# 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(<secret-dir>/<id>.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: <id> <server> <datastore> <namespace> <token-id> <fingerprint> <secret-dir>"
|
||||||
|
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: <id> <server> <namespace> <token-id> <fingerprint> <secret-dir>"
|
||||||
|
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 <id>
|
||||||
|
# --server <same> --fingerprint <same>` -> 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: <id> <secret-dir>"
|
||||||
|
sdir="$3"
|
||||||
|
val_sdir "$sdir"
|
||||||
|
target="$sdir/$id.pw"
|
||||||
|
# Belt: resolve and re-check the prefix (guards a symlinked <id>.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 <id>"
|
||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# felhom privsep-dir persistence (TASK G1) — install as /etc/tmpfiles.d/felhom-privsep.conf.
|
||||||
|
#
|
||||||
|
# LAYER 1 (prevent): make OpenSSH's shared privilege-separation directory /run/sshd boot-persistent
|
||||||
|
# and owned by NO systemd unit's lifecycle. This closes the exact incident cause from
|
||||||
|
# SPIKE-felhom-sshd-2026-07-05 §8: a second sshd unit declaring `RuntimeDirectory=sshd` had systemd
|
||||||
|
# REMOVE the shared /run/sshd on that unit's stop/failure, taking the stock sshd on :22 down with it
|
||||||
|
# (sessions reset right after SSH2_MSG_KEXINIT). A tmpfiles.d entry recreates the dir at every boot
|
||||||
|
# independently of any unit, so no unit's RuntimeDirectory cleanup can be the sole owner.
|
||||||
|
#
|
||||||
|
# systemd-tmpfiles is idempotent: `systemd-tmpfiles --create` re-run is a no-op on an existing,
|
||||||
|
# correct dir (it only creates/fixes, never churns). Complemented at runtime by the
|
||||||
|
# felhom-mgmt-watchdog timer (layer 2), which re-heals a dir removed AFTER boot.
|
||||||
|
#
|
||||||
|
# Type d = create the directory if absent (leaves an existing one, only fixing mode/owner).
|
||||||
|
d /run/sshd 0755 root root -
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# felhom-selfupdate-guarded — the ROOT half of the agent's A/B self-update (TASK D1).
|
||||||
|
#
|
||||||
|
# Install as /usr/local/sbin/felhom-selfupdate-guarded (0755 root:root). The non-root agent invokes
|
||||||
|
# `apply`/`commit` via `sudo -n` (FELHOM_SELFUPDATE alias); `rollback` is ALSO the ExecStart of
|
||||||
|
# felhom-agent-rollback.service — the OnFailure= target that auto-reverts a crash-looping update.
|
||||||
|
#
|
||||||
|
# Design provenance: SPIKE-agent-selfupdate-2026-07-05 (its SF-findings are cited inline). The core
|
||||||
|
# principle: the thing that performs rollback is never the thing being updated — this wrapper +
|
||||||
|
# systemd change almost never; the agent binary is what flips.
|
||||||
|
#
|
||||||
|
# Trust model: the agent verifies the download against the OPERATOR-SIGNED sha before staging; this
|
||||||
|
# wrapper RE-verifies the same sha as root (defense in depth — the sudoers arg glob is coarse, the
|
||||||
|
# sha check here is the real gate). Path confinement: apply only ever reads from the agent's own
|
||||||
|
# staging dir and only ever writes the fixed live path + its siblings. NO env-overridable paths —
|
||||||
|
# path-fixedness IS the security property (a test-mode override would be a root escalation hole).
|
||||||
|
#
|
||||||
|
# Verbs:
|
||||||
|
# apply <staged> <sha256> stage-verify → .prev → atomic flip → pending marker → detached restart
|
||||||
|
# commit clear the pending marker (idempotent; .prev retained as a manual net)
|
||||||
|
# rollback pending-guarded revert to .prev + restart (no pending → exit 0 no-op)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BIN=/usr/local/bin/felhom-agent
|
||||||
|
PREV=$BIN.prev
|
||||||
|
STAGING=/var/lib/felhom-agent/selfupdate
|
||||||
|
PENDING=$STAGING/pending.json
|
||||||
|
UNIT=felhom-agent.service
|
||||||
|
|
||||||
|
# Every refusal/decision goes to stderr AND the journal (strict rule 10).
|
||||||
|
log() { echo "felhom-selfupdate-guarded: $*" >&2; logger -t felhom-selfupdate-guarded -- "$*" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
|
||||||
|
apply)
|
||||||
|
staged=${2:-}; want=${3:-}
|
||||||
|
# [SF-7] entry sweep: a kill between staging-copy and mv leaves an orphaned temp — harmless,
|
||||||
|
# but sweep it so temps can never accumulate.
|
||||||
|
rm -f "$BIN".new.*
|
||||||
|
|
||||||
|
if [ -z "$staged" ] || [ -z "$want" ]; then
|
||||||
|
log "refusing apply: usage: apply <staged> <sha256>"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# Root-side path confinement: the staged binary MUST live in the agent's staging dir.
|
||||||
|
case "$staged" in
|
||||||
|
"$STAGING"/*) ;;
|
||||||
|
*) log "refusing apply: staged path outside $STAGING: $staged"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$staged" in
|
||||||
|
*..*) log "refusing apply: staged path contains '..'"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
[ -f "$staged" ] || { log "refusing apply: staged file missing: $staged"; exit 1; }
|
||||||
|
# The sha must be 64 lowercase hex chars — anything else is refused before any hashing.
|
||||||
|
case "$want" in
|
||||||
|
*[!0-9a-f]*) log "refusing apply: sha256 is not lowercase hex"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
[ "${#want}" -eq 64 ] || { log "refusing apply: sha256 must be 64 hex chars (got ${#want})"; exit 1; }
|
||||||
|
|
||||||
|
# [SF-7] sha-verify FIRST — before .prev, before any mutation (spike S3a companion ordering).
|
||||||
|
got=$(sha256sum "$staged" | awk '{print $1}')
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
log "refusing apply: sha mismatch (got=$got want=$want)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same-fs assert (§8): the atomic-rename guarantee only holds within one filesystem.
|
||||||
|
if [ "$(stat -c %d "$staged")" != "$(stat -c %d /usr/local/bin)" ]; then
|
||||||
|
log "refusing apply: staging and /usr/local/bin are on different filesystems — atomic rename impossible"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
old_ver=$("$BIN" --version 2>/dev/null | awk '{print $2}')
|
||||||
|
[ -n "$old_ver" ] || old_ver=unknown
|
||||||
|
# The staged filename is felhom-agent-<version> (executor contract) — version without executing.
|
||||||
|
new_ver=$(basename "$staged"); new_ver=${new_ver#felhom-agent-}
|
||||||
|
|
||||||
|
cp -p "$BIN" "$PREV" || { log "apply failed: cannot snapshot current binary to .prev"; exit 1; }
|
||||||
|
# Stage a root-owned 0755 copy next to the live path, then ATOMIC same-fs rename.
|
||||||
|
if ! cp "$staged" "$BIN.new.$$" || ! chmod 0755 "$BIN.new.$$" || ! chown root:root "$BIN.new.$$"; then
|
||||||
|
rm -f "$BIN.new.$$"; log "apply failed: staging copy"; exit 1
|
||||||
|
fi
|
||||||
|
mv "$BIN.new.$$" "$BIN" || { rm -f "$BIN.new.$$"; log "apply failed: atomic rename"; exit 1; }
|
||||||
|
|
||||||
|
# Pending marker: written AFTER the flip — its existence means "an update is awaiting commit",
|
||||||
|
# which is exactly the rollback unit's trigger condition.
|
||||||
|
printf '{"old_version":"%s","new_version":"%s","sha256":"%s","applied_at":"%s"}\n' \
|
||||||
|
"$old_ver" "$new_ver" "$want" "$(date -Is)" > "$PENDING" \
|
||||||
|
|| { log "apply failed: cannot write pending marker"; exit 1; }
|
||||||
|
|
||||||
|
# [SF-4/5] deliberate restarts consume start-limit budget — clear the counter first.
|
||||||
|
systemctl reset-failed "$UNIT" 2>/dev/null || true
|
||||||
|
# [SF-6] the spike's S2b winner, verbatim: detached transient timer OUTSIDE the agent's cgroup,
|
||||||
|
# so the sudo/agent caller survives to log the handoff and the restart cannot be torn down
|
||||||
|
# by its own requester dying.
|
||||||
|
systemd-run --on-active=2s --timer-property=AccuracySec=100ms systemctl restart "$UNIT" \
|
||||||
|
|| { log "apply: flip done but detached restart scheduling FAILED — restart $UNIT manually"; exit 1; }
|
||||||
|
log "applied $new_ver (prev $old_ver, sha $want); detached restart scheduled"
|
||||||
|
;;
|
||||||
|
|
||||||
|
commit)
|
||||||
|
if [ ! -f "$PENDING" ]; then
|
||||||
|
log "commit: no pending — no-op"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# .prev is deliberately RETAINED (spike S3d) — a manual safety net until the next apply.
|
||||||
|
rm -f "$PENDING" || { log "commit failed: cannot remove pending marker"; exit 1; }
|
||||||
|
log "committed (pending cleared, .prev retained)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
rollback)
|
||||||
|
# [SF-1] On systemd 257 OnFailure= fires on EVERY crash, so this verb runs MANY times per
|
||||||
|
# incident — the pending-guard makes every fire after the first a harmless no-op, and makes a
|
||||||
|
# crash with NO update in flight touch nothing at all (spike S1d/S3e).
|
||||||
|
if [ ! -f "$PENDING" ]; then
|
||||||
|
log "rollback: no pending update — no-op"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[ -f "$PREV" ] || { log "rollback FAILED: pending exists but no .prev binary"; exit 1; }
|
||||||
|
rm -f "$BIN".new.*
|
||||||
|
if ! cp "$PREV" "$BIN.new.$$" || ! chmod 0755 "$BIN.new.$$" || ! chown root:root "$BIN.new.$$"; then
|
||||||
|
rm -f "$BIN.new.$$"; log "rollback FAILED: staging copy"; exit 1
|
||||||
|
fi
|
||||||
|
mv "$BIN.new.$$" "$BIN" || { rm -f "$BIN.new.$$"; log "rollback FAILED: atomic rename"; exit 1; }
|
||||||
|
# Clear pending BEFORE the restart: once the binary is reverted, later OnFailure fires must
|
||||||
|
# no-op (the guard above) instead of re-copying .prev forever.
|
||||||
|
rm -f "$PENDING"
|
||||||
|
# [SF-4/5] the crash burst has been eating the start-limit budget — reset before starting.
|
||||||
|
systemctl reset-failed "$UNIT" 2>/dev/null || true
|
||||||
|
# Direct restart is correct HERE: this caller is the rollback oneshot, OUTSIDE the agent cgroup.
|
||||||
|
systemctl restart "$UNIT" || { log "rollback: binary reverted but restart FAILED"; exit 1; }
|
||||||
|
log "rolled back to previous binary and restarted $UNIT"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
log "usage: felhom-selfupdate-guarded apply <staged> <sha256> | commit | rollback"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# felhom-sshd.service (TASK H1) — install as /etc/systemd/system/felhom-sshd.service.
|
||||||
|
#
|
||||||
|
# The dedicated OOB sshd instance: a SECOND sshd on a claimed non-22 port, COEXISTING with the
|
||||||
|
# customer's/stock sshd on :22 (never touched). Config is AGENT-RENDERED at /etc/felhom-sshd/sshd_config
|
||||||
|
# (Port from the agent's claim); the agent reloads on change (never restart-on-change [SF-2]).
|
||||||
|
#
|
||||||
|
# CRITICAL [SF-1]: this unit MUST NOT declare `RuntimeDirectory=` — that directive (value `sshd`)
|
||||||
|
# removed the SHARED /run/sshd and broke the stock sshd live (SPIKE-felhom-sshd §8). /run/sshd is
|
||||||
|
# guaranteed by G1 (tmpfiles + watchdog); the ExecStartPre mkdir is a harmless extra guarantee. The
|
||||||
|
# host-install RuntimeDirectory guard will REJECT this unit if the directive is ever added.
|
||||||
|
[Unit]
|
||||||
|
Description=Felhom OOB sshd (dedicated instance, agent-managed; H1)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
ExecStartPre=-/usr/bin/mkdir -p /run/sshd
|
||||||
|
ExecStartPre=/usr/sbin/sshd -t -f /etc/felhom-sshd/sshd_config
|
||||||
|
ExecStart=/usr/sbin/sshd -D -f /etc/felhom-sshd/sshd_config
|
||||||
|
# Config-change path: validate then HUP — a bad reload is REFUSED and the running daemon SURVIVES
|
||||||
|
# [SF-2]. The agent always writes→sshd -t→reload; it never `restart`s on a config change.
|
||||||
|
ExecReload=/usr/sbin/sshd -t -f /etc/felhom-sshd/sshd_config
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
KillMode=process
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
module gitea.dooplex.hu/admin/felhom-agent
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require golang.org/x/crypto v0.52.0
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.45.0 // indirect
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanonicalBlob builds the canonical OpBlob bytes (phase4 §2 field order: keys sorted at
|
||||||
|
// every level, no insignificant whitespace, no trailing newline, UTF-8). This is the SINGLE
|
||||||
|
// production source of the signed bytes — the operator signing CLI (cmd/felhom-opsign) and the
|
||||||
|
// in-Go test minting both call it, so the signer can NEVER drift from what the verifier expects
|
||||||
|
// (the verifier authenticates over the RAW received bytes, so these bytes ARE the contract).
|
||||||
|
//
|
||||||
|
// params is canonicalized internally (parsed + re-marshaled → object keys sorted, whitespace
|
||||||
|
// stripped) so the same op+params always yields identical bytes; "" → "{}". Returns an error
|
||||||
|
// only when params is not valid JSON.
|
||||||
|
func CanonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON string, issued, expires time.Time) ([]byte, error) {
|
||||||
|
params := strings.TrimSpace(paramsJSON)
|
||||||
|
if params == "" {
|
||||||
|
params = "{}"
|
||||||
|
}
|
||||||
|
var pv interface{}
|
||||||
|
if err := json.Unmarshal([]byte(params), &pv); err != nil {
|
||||||
|
return nil, fmt.Errorf("authz: params is not valid JSON: %w", err)
|
||||||
|
}
|
||||||
|
pc, err := json.Marshal(pv) // Go marshals object keys sorted, compact
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("authz: canonicalizing params: %w", err)
|
||||||
|
}
|
||||||
|
return []byte(fmt.Sprintf(
|
||||||
|
`{"expires_at":%q,"issued_at":%q,"key_id":%q,"nonce":%q,"op":%q,"params":%s,"target":{"guest_id":%q,"host_id":%q}}`,
|
||||||
|
expires.UTC().Format(time.RFC3339), issued.UTC().Format(time.RFC3339),
|
||||||
|
keyID, nonce, op, pc, guestID, hostID)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target binds an op to a specific box (and optionally a guest) — the anti-retarget
|
||||||
|
// field. The §7 reference omitted the json tags; production needs them so the
|
||||||
|
// signed canonical bytes decode correctly.
|
||||||
|
type Target struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
GuestID string `json:"guest_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpBlob is the canonical signed object (phase4 §2). The signature covers the
|
||||||
|
// EXACT bytes of this object's canonical JSON (keys sorted at every level, no
|
||||||
|
// insignificant whitespace, no trailing newline, UTF-8) — produced by the
|
||||||
|
// operator CLI / hub, verified here over the raw received bytes.
|
||||||
|
type OpBlob struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Target Target `json:"target"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifiedOp is the authenticated, parsed op returned on success — everything the
|
||||||
|
// reconcile layer (slice 4) needs to route and execute, not just the op string.
|
||||||
|
type VerifiedOp struct {
|
||||||
|
Op string // the operation, e.g. "guest_destroy"
|
||||||
|
HostID string // target host (== this agent's host)
|
||||||
|
// GuestID is non-empty for a guest-scoped op; the caller routes by it. "" =
|
||||||
|
// host-scoped op. The verifier does NOT need to know all guest ids.
|
||||||
|
GuestID string
|
||||||
|
Params json.RawMessage
|
||||||
|
Nonce string
|
||||||
|
IssuedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
|
||||||
|
// KeyID is the blob's self-declared key id — ADVISORY / audit only, never an
|
||||||
|
// authz input. Authz is the key-material allow-list match (Signer below).
|
||||||
|
KeyID string
|
||||||
|
// Signer is the allow-listed key whose material matched the signature.
|
||||||
|
Signer AllowedSigner
|
||||||
|
// KeyIDMatchesSigner is false when the blob's advisory KeyID disagrees with
|
||||||
|
// the matched signer's id (a benign audit signal, not a rejection).
|
||||||
|
KeyIDMatchesSigner bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Package authz is the control-plane-authorization layer: it verifies
|
||||||
|
// operator-signed destructive ops before the agent executes them. It is what the
|
||||||
|
// reconcile loop (slice 4) calls to gate destructive desired-state deltas and
|
||||||
|
// signed one-shot jobs (03 §4, 04). The signing mechanism is proven (Phase 4,
|
||||||
|
// 14/14) — this package is its production form: a key-type-agnostic SSHSIG
|
||||||
|
// verifier, the full anti-replay/authorization pipeline, and a durable,
|
||||||
|
// crash-safe nonce store.
|
||||||
|
//
|
||||||
|
// # Mechanism (LOCKED — do not redesign)
|
||||||
|
//
|
||||||
|
// - SSHSIG via golang.org/x/crypto/ssh; no hand-rolled crypto, no raw-Ed25519
|
||||||
|
// fallback. pub.Verify dispatches on the key's own algorithm, so the same path
|
||||||
|
// accepts ed25519 / sk-ssh-ed25519 (FIDO2) / rsa / ecdsa — a hardware operator
|
||||||
|
// key later is a box no-op (Phase 4 §5/§6, doc 04 §7).
|
||||||
|
// - Fixed namespace felhom-op-v1 (package constant, never caller-supplied).
|
||||||
|
// - The verifier verifies over the RAW received blob bytes and never
|
||||||
|
// canonicalizes — the canonical form (sorted-key, whitespace-free JSON) is the
|
||||||
|
// signer's contract, shared by the hub and the felhom-sign CLI.
|
||||||
|
//
|
||||||
|
// # Pipeline order (load-bearing — Verify)
|
||||||
|
//
|
||||||
|
// parse armor → namespace → parse pubkey → allow-list (by key MATERIAL, not
|
||||||
|
// key_id) → crypto verify → parse blob → target → time window → nonce LAST
|
||||||
|
//
|
||||||
|
// Each post-crypto stage rejects even with an otherwise-valid signature. The nonce
|
||||||
|
// is recorded last, so an invalid signature can never consume a nonce. key_id is
|
||||||
|
// advisory/audit only — authz is the key-material allow-list match.
|
||||||
|
//
|
||||||
|
// # Shared-contract dependency (flag for later, not built here)
|
||||||
|
//
|
||||||
|
// Signatures only verify if the op-generator (hub) and the felhom-sign CLI produce
|
||||||
|
// BYTE-IDENTICAL canonical JSON (keys sorted at every level, no insignificant
|
||||||
|
// whitespace, no trailing newline, UTF-8 — Phase 4 §2). The verifier deliberately
|
||||||
|
// does NOT re-canonicalize, so a divergence between those two producers surfaces as
|
||||||
|
// a crypto failure here. A shared canonicalizer that both import would be the right
|
||||||
|
// home for that contract; it is out of scope for this slice.
|
||||||
|
package authz
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Typed rejection sentinels — one per pipeline stage so the reconcile layer can
|
||||||
|
// distinguish "rejected" (a real signed op that failed a check) from "malformed"
|
||||||
|
// from a future "not yet signed". All are errors.Is-friendly: Verify wraps them
|
||||||
|
// with %w plus context.
|
||||||
|
var (
|
||||||
|
// ErrMalformed: the armor/SSHSIG/blob could not be parsed (not a rejection of
|
||||||
|
// a well-formed op — bad input).
|
||||||
|
ErrMalformed = errors.New("authz: malformed signature or blob")
|
||||||
|
// ErrNamespace: SSHSIG namespace != the fixed felhom-op-v1 domain separator.
|
||||||
|
ErrNamespace = errors.New("authz: namespace mismatch")
|
||||||
|
// ErrUnknownSigner: the signing key's material is not in the pinned allow-list.
|
||||||
|
ErrUnknownSigner = errors.New("authz: signer not in allowed set")
|
||||||
|
// ErrBadSignature: cryptographic verification failed (tamper / wrong key).
|
||||||
|
ErrBadSignature = errors.New("authz: signature did not verify")
|
||||||
|
// ErrTarget: target.host_id is not this box.
|
||||||
|
ErrTarget = errors.New("authz: target mismatch")
|
||||||
|
// ErrExpired: now > expires_at.
|
||||||
|
ErrExpired = errors.New("authz: op expired")
|
||||||
|
// ErrNotYetValid: now < issued_at (minus clock-skew tolerance).
|
||||||
|
ErrNotYetValid = errors.New("authz: op not yet valid")
|
||||||
|
// ErrReplay: the nonce was already recorded in the window.
|
||||||
|
ErrReplay = errors.New("authz: replay (nonce already seen)")
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/pem"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test helpers that MINT armored SSHSIGs in-Go (hermetic) — the inverse of the
|
||||||
|
// production framing. They reuse the production signedData()/sshsigBlob so a test
|
||||||
|
// can never drift from the verifier's notion of the signed bytes.
|
||||||
|
|
||||||
|
// canonicalBlob delegates to the production CanonicalBlob (so the in-Go test minting can never
|
||||||
|
// drift from the real signed-bytes path). Panics on a params error — tests pass valid JSON.
|
||||||
|
func canonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON string, issued, expires time.Time) []byte {
|
||||||
|
b, err := CanonicalBlob(op, hostID, guestID, keyID, nonce, paramsJSON, issued, expires)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintArmor builds an armored SSHSIG over message, using sign to produce the inner
|
||||||
|
// ssh.Signature over the recomputed SSHSIG signed-data.
|
||||||
|
func mintArmor(t *testing.T, pubMarshaled []byte, namespace, hashName string, message []byte, sign func([]byte) ssh.Signature) []byte {
|
||||||
|
t.Helper()
|
||||||
|
sb := &sshsigBlob{Version: 1, PublicKey: string(pubMarshaled), Namespace: namespace, Reserved: "", HashAlgo: hashName}
|
||||||
|
signed, err := signedData(sb, message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signedData: %v", err)
|
||||||
|
}
|
||||||
|
sig := sign(signed)
|
||||||
|
sb.Signature = string(ssh.Marshal(&sig))
|
||||||
|
raw := append([]byte(sshsigMagic), ssh.Marshal(sb)...)
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "SSH SIGNATURE", Bytes: raw})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEd25519Signer returns an ssh.PublicKey + a sign closure for a fresh ed25519 key.
|
||||||
|
func newEd25519Signer(t *testing.T) (ssh.PublicKey, func([]byte) ssh.Signature) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sshPub, err := ssh.NewPublicKey(pub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sign := func(signed []byte) ssh.Signature {
|
||||||
|
return ssh.Signature{Format: ssh.KeyAlgoED25519, Blob: ed25519.Sign(priv, signed)}
|
||||||
|
}
|
||||||
|
return sshPub, sign
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSyntheticSKSigner emulates a FIDO2 sk-ssh-ed25519@openssh.com key with NO
|
||||||
|
// hardware (Phase 4 §5). It builds a spec-faithful sk public key and an sk-format
|
||||||
|
// signature: ed25519 over sha256(application)‖flags‖counter‖sha256(signed_data),
|
||||||
|
// sig.Blob = the raw ed25519 signature, sig.Rest = flags‖counter. It must verify
|
||||||
|
// through the UNCHANGED Verify path.
|
||||||
|
func newSyntheticSKSigner(t *testing.T) (ssh.PublicKey, func([]byte) ssh.Signature) {
|
||||||
|
t.Helper()
|
||||||
|
edPub, edPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const application = "ssh:"
|
||||||
|
skBlob := ssh.Marshal(struct {
|
||||||
|
Name string
|
||||||
|
KeyBytes []byte
|
||||||
|
Application string
|
||||||
|
}{"sk-ssh-ed25519@openssh.com", []byte(edPub), application})
|
||||||
|
skPub, err := ssh.ParsePublicKey(skBlob)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse synthetic sk pubkey: %v", err)
|
||||||
|
}
|
||||||
|
if skPub.Type() != "sk-ssh-ed25519@openssh.com" {
|
||||||
|
t.Fatalf("sk pubkey type = %q", skPub.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
sign := func(signed []byte) ssh.Signature {
|
||||||
|
const flagUserPresence = byte(0x01) // required, else Verify rejects
|
||||||
|
const counter = uint32(1)
|
||||||
|
appDigest := sha256.Sum256([]byte(application))
|
||||||
|
dataDigest := sha256.Sum256(signed)
|
||||||
|
// original = appDigest ‖ flags ‖ counter(BE) ‖ dataDigest (x/crypto layout)
|
||||||
|
var original []byte
|
||||||
|
original = append(original, appDigest[:]...)
|
||||||
|
original = append(original, flagUserPresence)
|
||||||
|
original = binary.BigEndian.AppendUint32(original, counter)
|
||||||
|
original = append(original, dataDigest[:]...)
|
||||||
|
edSig := ed25519.Sign(edPriv, original)
|
||||||
|
// sig.Rest = skFields{Flags, Counter} = flags ‖ counter(BE)
|
||||||
|
rest := append([]byte{flagUserPresence}, binary.BigEndian.AppendUint32(nil, counter)...)
|
||||||
|
return ssh.Signature{Format: "sk-ssh-ed25519@openssh.com", Blob: edSig, Rest: rest}
|
||||||
|
}
|
||||||
|
return skPub, sign
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemoryNonceStore is a non-durable NonceStore for tests. Replay protection does
|
||||||
|
// NOT survive process restart — never use it on a real host.
|
||||||
|
type MemoryNonceStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryNonceStore builds an empty in-memory store.
|
||||||
|
func NewMemoryNonceStore() *MemoryNonceStore {
|
||||||
|
return &MemoryNonceStore{seen: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeenOrRecord reports whether nonce was already recorded, recording it if not.
|
||||||
|
func (m *MemoryNonceStore) SeenOrRecord(nonce string, exp time.Time) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if _, ok := m.seen[nonce]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
m.seen[nonce] = exp
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileNonceStore is the durable, crash-safe NonceStore for the host. Mechanism:
|
||||||
|
// an fsync'd append-only JSONL log with an in-memory index, periodic compaction,
|
||||||
|
// and expiry-only pruning.
|
||||||
|
//
|
||||||
|
// Durability guarantee: a nonce is on disk AND fsync'd before SeenOrRecord returns
|
||||||
|
// false, so the caller acting on a verified op always does so AFTER the durable
|
||||||
|
// record. A crash between verify and execute therefore drops the op (fail-safe
|
||||||
|
// direction) and never enables a replay. Replay protection survives restarts: the
|
||||||
|
// log is replayed into the index on Open.
|
||||||
|
//
|
||||||
|
// Pruning: a nonce is dropped only after its exp (compaction), never before —
|
||||||
|
// pruning before expiry would reopen the replay window. (An expired nonce can't be
|
||||||
|
// replayed anyway: the time-window check rejects an expired op before the nonce
|
||||||
|
// check, so pruning is housekeeping, not an authz hole.)
|
||||||
|
//
|
||||||
|
// Concurrency: a single mutex guards the file handle and index (single-process; the
|
||||||
|
// agent is concurrent — 03 §10).
|
||||||
|
type FileNonceStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
f *os.File
|
||||||
|
idx map[string]time.Time
|
||||||
|
sinceCompact int
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
// CompactEvery is the append count that triggers a compaction (default 1000).
|
||||||
|
CompactEvery int
|
||||||
|
}
|
||||||
|
|
||||||
|
type nonceRecord struct {
|
||||||
|
Nonce string `json:"n"`
|
||||||
|
Exp time.Time `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFileNonceStore opens (or creates) the durable store at path, replaying any
|
||||||
|
// existing log into the index.
|
||||||
|
func OpenFileNonceStore(path string) (*FileNonceStore, error) {
|
||||||
|
s := &FileNonceStore{
|
||||||
|
path: path,
|
||||||
|
idx: make(map[string]time.Time),
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
CompactEvery: 1000,
|
||||||
|
}
|
||||||
|
if err := s.load(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.f = f
|
||||||
|
syncDir(filepath.Dir(path)) // make a freshly-created file's dir entry durable
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileNonceStore) load() error {
|
||||||
|
b, err := os.ReadFile(s.path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r nonceRecord
|
||||||
|
if json.Unmarshal(line, &r) != nil {
|
||||||
|
continue // skip a torn trailing line from a crash mid-append
|
||||||
|
}
|
||||||
|
s.idx[r.Nonce] = r.Exp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeenOrRecord durably records an unseen nonce before returning false. On any I/O
|
||||||
|
// failure it returns true (fail-safe: the op is NOT executed rather than risk an
|
||||||
|
// unrecorded nonce enabling a later replay).
|
||||||
|
func (s *FileNonceStore) SeenOrRecord(nonce string, exp time.Time) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.idx[nonce]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rec, _ := json.Marshal(nonceRecord{Nonce: nonce, Exp: exp})
|
||||||
|
rec = append(rec, '\n')
|
||||||
|
if _, err := s.f.Write(rec); err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := s.f.Sync(); err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s.idx[nonce] = exp
|
||||||
|
s.sinceCompact++
|
||||||
|
s.maybeCompact()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the file handle.
|
||||||
|
func (s *FileNonceStore) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.f != nil {
|
||||||
|
return s.f.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeCompact rewrites the log keeping only non-expired entries once enough
|
||||||
|
// appends have accrued. Caller holds the mutex. Compaction is housekeeping: the
|
||||||
|
// recorded nonce is already durable, so a compaction failure never fails the op.
|
||||||
|
func (s *FileNonceStore) maybeCompact() {
|
||||||
|
if s.CompactEvery <= 0 || s.sinceCompact < s.CompactEvery {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sinceCompact = 0
|
||||||
|
now := s.now()
|
||||||
|
|
||||||
|
live := make(map[string]time.Time, len(s.idx))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for n, e := range s.idx {
|
||||||
|
if e.Before(now) {
|
||||||
|
continue // prune AFTER expiry only — safe
|
||||||
|
}
|
||||||
|
live[n] = e
|
||||||
|
rec, _ := json.Marshal(nonceRecord{Nonce: n, Exp: e})
|
||||||
|
buf.Write(rec)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil {
|
||||||
|
return // keep using the existing handle; nonce already durable
|
||||||
|
}
|
||||||
|
if tf, err := os.OpenFile(tmp, os.O_WRONLY, 0o600); err == nil {
|
||||||
|
_ = tf.Sync()
|
||||||
|
_ = tf.Close()
|
||||||
|
}
|
||||||
|
if s.f != nil {
|
||||||
|
_ = s.f.Close()
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, s.path); err != nil {
|
||||||
|
s.f, _ = os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncDir(filepath.Dir(s.path))
|
||||||
|
s.f, _ = os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
s.idx = live
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDir best-effort fsyncs a directory so a create/rename is durable.
|
||||||
|
func syncDir(dir string) {
|
||||||
|
if d, err := os.Open(dir); err == nil {
|
||||||
|
_ = d.Sync()
|
||||||
|
_ = d.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemoryNonceStore(t *testing.T) {
|
||||||
|
m := NewMemoryNonceStore()
|
||||||
|
exp := time.Now().Add(time.Hour)
|
||||||
|
if m.SeenOrRecord("a", exp) {
|
||||||
|
t.Fatal("first record should be unseen")
|
||||||
|
}
|
||||||
|
if !m.SeenOrRecord("a", exp) {
|
||||||
|
t.Fatal("second record should be seen")
|
||||||
|
}
|
||||||
|
if m.SeenOrRecord("b", exp) {
|
||||||
|
t.Fatal("distinct nonce should be unseen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileNonceStore_RecordAndReload(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "nonces.log")
|
||||||
|
exp := refNow.Add(time.Hour)
|
||||||
|
|
||||||
|
s1, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s1.SeenOrRecord("dead", exp) {
|
||||||
|
t.Fatal("first record should be unseen")
|
||||||
|
}
|
||||||
|
if err := s1.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen: the recorded nonce must still be seen (durable across restart).
|
||||||
|
s2, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
if !s2.SeenOrRecord("dead", exp) {
|
||||||
|
t.Fatal("nonce not durable across reopen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileNonceStore_CompactionPrunesExpiredOnly(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "nonces.log")
|
||||||
|
s, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.now = func() time.Time { return refNow }
|
||||||
|
s.CompactEvery = 2 // force a compaction after two appends
|
||||||
|
|
||||||
|
s.SeenOrRecord("expired", refNow.Add(-time.Hour)) // exp in the past
|
||||||
|
s.SeenOrRecord("live", refNow.Add(time.Hour)) // triggers compaction
|
||||||
|
if err := s.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen: the live nonce survived, the expired one was pruned (housekeeping;
|
||||||
|
// an expired op is rejected by the time check before the nonce check anyway).
|
||||||
|
s2, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s2.Close()
|
||||||
|
if !s2.SeenOrRecord("live", refNow.Add(time.Hour)) {
|
||||||
|
t.Error("live nonce should have survived compaction")
|
||||||
|
}
|
||||||
|
if s2.SeenOrRecord("expired", refNow.Add(-time.Hour)) {
|
||||||
|
t.Error("expired nonce should have been pruned (was still present)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileNonceStore_SkipsTornLine(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "nonces.log")
|
||||||
|
// a valid record line + a torn/garbage trailing line from a hypothetical crash
|
||||||
|
content := `{"n":"good","e":"` + refNow.Add(time.Hour).Format(time.RFC3339Nano) + `"}` + "\n" + `{"n":"tor`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open with torn line should not fail: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if !s.SeenOrRecord("good", refNow.Add(time.Hour)) {
|
||||||
|
t.Error("valid record before the torn line should have loaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAllowedSigner(t *testing.T) {
|
||||||
|
line, err := os.ReadFile("testdata/operator.pub")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := NewAllowedSigner("felhom-op-1", RoleOperational, string(line))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAllowedSigner: %v", err)
|
||||||
|
}
|
||||||
|
if s.KeyID != "felhom-op-1" || s.Role != RoleOperational || s.PublicKey == nil {
|
||||||
|
t.Errorf("signer = %+v", s)
|
||||||
|
}
|
||||||
|
if s.PublicKey.Type() != "ssh-ed25519" {
|
||||||
|
t.Errorf("key type = %q", s.PublicKey.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAllowedSigner_BadRole(t *testing.T) {
|
||||||
|
line, _ := os.ReadFile("testdata/operator.pub")
|
||||||
|
if _, err := NewAllowedSigner("k", "bogus", string(line)); err == nil {
|
||||||
|
t.Fatal("invalid role should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAllowedSigner_BadLine(t *testing.T) {
|
||||||
|
if _, err := NewAllowedSigner("k", RoleOperational, "not a key"); err == nil {
|
||||||
|
t.Fatal("malformed key line should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHSIG framing — ported verbatim-in-spirit from phase4-signing-findings.md §7.
|
||||||
|
// The only manual work is SSHSIG *framing*; all crypto and key-type dispatch is
|
||||||
|
// x/crypto/ssh's (pub.Verify dispatches on the key's own algorithm, which is what
|
||||||
|
// makes the verifier key-type-agnostic — ed25519 / sk-ssh-ed25519 / rsa / ecdsa).
|
||||||
|
// No hand-rolled crypto.
|
||||||
|
|
||||||
|
const sshsigMagic = "SSHSIG"
|
||||||
|
|
||||||
|
// sshsigBlob is the binary SSHSIG body (after the 6-byte magic). Field order is
|
||||||
|
// the SSH wire order — do not reorder.
|
||||||
|
type sshsigBlob struct {
|
||||||
|
Version uint32
|
||||||
|
PublicKey string
|
||||||
|
Namespace string
|
||||||
|
Reserved string
|
||||||
|
HashAlgo string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashByName(n string) (hash.Hash, error) {
|
||||||
|
switch n {
|
||||||
|
case "sha256":
|
||||||
|
return sha256.New(), nil
|
||||||
|
case "sha512":
|
||||||
|
return sha512.New(), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: unsupported SSHSIG hash %q", ErrMalformed, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArmoredSSHSIG decodes the `-----BEGIN SSH SIGNATURE-----` armor into the
|
||||||
|
// SSHSIG body: pem.Decode → strip the literal 6-byte magic (not length-prefixed)
|
||||||
|
// → ssh.Unmarshal.
|
||||||
|
func parseArmoredSSHSIG(armored []byte) (*sshsigBlob, error) {
|
||||||
|
block, _ := pem.Decode(armored)
|
||||||
|
if block == nil || block.Type != "SSH SIGNATURE" {
|
||||||
|
return nil, fmt.Errorf("%w: not an SSH SIGNATURE armor", ErrMalformed)
|
||||||
|
}
|
||||||
|
if len(block.Bytes) < len(sshsigMagic) || string(block.Bytes[:len(sshsigMagic)]) != sshsigMagic {
|
||||||
|
return nil, fmt.Errorf("%w: missing SSHSIG magic", ErrMalformed)
|
||||||
|
}
|
||||||
|
var sb sshsigBlob
|
||||||
|
if err := ssh.Unmarshal(block.Bytes[len(sshsigMagic):], &sb); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrMalformed, err)
|
||||||
|
}
|
||||||
|
if sb.Version != 1 {
|
||||||
|
return nil, fmt.Errorf("%w: bad SSHSIG version %d", ErrMalformed, sb.Version)
|
||||||
|
}
|
||||||
|
return &sb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedData recomputes the bytes the signature actually covers, per the SSHSIG
|
||||||
|
// spec: "SSHSIG" || ssh.Marshal(namespace, reserved, hash_algorithm, H(message)),
|
||||||
|
// where H is the named hash. The message is the RAW received blob bytes — the
|
||||||
|
// verifier never canonicalizes (the canonical form is the signer's contract).
|
||||||
|
func signedData(sb *sshsigBlob, msg []byte) ([]byte, error) {
|
||||||
|
h, err := hashByName(sb.HashAlgo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.Write(msg)
|
||||||
|
md := h.Sum(nil)
|
||||||
|
body := ssh.Marshal(struct {
|
||||||
|
Namespace string
|
||||||
|
Reserved string
|
||||||
|
HashAlgo string
|
||||||
|
Hash []byte
|
||||||
|
}{sb.Namespace, sb.Reserved, sb.HashAlgo, md})
|
||||||
|
return append([]byte(sshsigMagic), body...), nil
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"expires_at":"2026-06-09T00:00:00Z","issued_at":"2026-06-08T00:00:00Z","key_id":"felhom-op-1","nonce":"a1b2c3d4e5f60718293a4b5c6d7e8f90","op":"guest_destroy","params":{"purge":true},"target":{"guest_id":"9001","host_id":"demo-felhom"}}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
-----BEGIN SSH SIGNATURE-----
|
||||||
|
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgNXOOuMvD3Fh9MJYspBRWLXyQAd
|
||||||
|
WVeBICspeB9eL1xfIAAAAMZmVsaG9tLW9wLXYxAAAAAAAAAAZzaGE1MTIAAABTAAAAC3Nz
|
||||||
|
aC1lZDI1NTE5AAAAQG+bj+GNodNw7cfGYg3HWTDyJiu3g/5Aez1xlZQ540JGUIG9FV7vv8
|
||||||
|
wrgN0r+rNh+ytEAM6UTOyI7g3LOjuVJgY=
|
||||||
|
-----END SSH SIGNATURE-----
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDVzjrjLw9xYfTCWLKQUVi18kAHVlXgSArKXgfXi9cXy felhom-op-1
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Namespace is the FIXED SSHSIG domain separator. It is a package constant, never
|
||||||
|
// caller-supplied (phase4 §2.2): a signature minted for any other namespace must
|
||||||
|
// not verify.
|
||||||
|
const Namespace = "felhom-op-v1"
|
||||||
|
|
||||||
|
// DefaultClockSkew tolerates operator/host clock drift on the not-yet-valid check
|
||||||
|
// only (issued_at may be up to this far in the future). Expiry is NOT extended —
|
||||||
|
// the validity window stays an honest upper bound.
|
||||||
|
const DefaultClockSkew = 2 * time.Minute
|
||||||
|
|
||||||
|
// KeyRole tags a pinned operator key (doc 04 §3 two-key model).
|
||||||
|
type KeyRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RoleOperational signs ordinary destructive ops (the "master stamp").
|
||||||
|
RoleOperational KeyRole = "operational"
|
||||||
|
// RoleRecovery is the cold key; authorizes ONLY key-rotation/break-glass ops.
|
||||||
|
// Role-scoping is enforced by the consuming layer (slice 4), not here.
|
||||||
|
RoleRecovery KeyRole = "recovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowedSigner is one pinned operator public key.
|
||||||
|
type AllowedSigner struct {
|
||||||
|
KeyID string
|
||||||
|
Role KeyRole
|
||||||
|
PublicKey ssh.PublicKey // parsed; allow-list match is by PublicKey.Marshal()
|
||||||
|
Comment string // from the authorized_keys line, if any
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAllowedSigner parses a standard authorized_keys line ("ssh-ed25519 AAAA… [comment]"
|
||||||
|
// or "sk-ssh-ed25519@openssh.com AAAA… …") into an AllowedSigner with the given id+role.
|
||||||
|
func NewAllowedSigner(keyID string, role KeyRole, authorizedKeyLine string) (AllowedSigner, error) {
|
||||||
|
pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyLine))
|
||||||
|
if err != nil {
|
||||||
|
return AllowedSigner{}, fmt.Errorf("authz: parsing pinned key %q: %w", keyID, err)
|
||||||
|
}
|
||||||
|
if role != RoleOperational && role != RoleRecovery {
|
||||||
|
return AllowedSigner{}, fmt.Errorf("authz: pinned key %q has invalid role %q", keyID, role)
|
||||||
|
}
|
||||||
|
return AllowedSigner{KeyID: keyID, Role: role, PublicKey: pub, Comment: comment}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NonceStore records seen nonces for anti-replay. SeenOrRecord reports whether the
|
||||||
|
// nonce was already recorded; if not, it records it (durably, in the host impl)
|
||||||
|
// before returning false. See noncestore.go.
|
||||||
|
type NonceStore interface {
|
||||||
|
SeenOrRecord(nonce string, exp time.Time) (seen bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier authenticates operator-signed destructive ops. Construct with New.
|
||||||
|
type Verifier struct {
|
||||||
|
signers []AllowedSigner
|
||||||
|
store NonceStore
|
||||||
|
hostID string
|
||||||
|
|
||||||
|
// ClockSkew tolerance for the not-yet-valid check (default DefaultClockSkew).
|
||||||
|
ClockSkew time.Duration
|
||||||
|
// Logger, if set, emits a warning when a blob's advisory key_id disagrees with
|
||||||
|
// the matched signer. Never affects the verdict.
|
||||||
|
Logger *slog.Logger
|
||||||
|
|
||||||
|
now func() time.Time // injectable for tests
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Verifier over the pinned signer set, a nonce store, and this box's
|
||||||
|
// host id. allowedSigners is a set (single signer today; quorum is just sizing).
|
||||||
|
func New(signers []AllowedSigner, store NonceStore, hostID string) *Verifier {
|
||||||
|
return &Verifier{
|
||||||
|
signers: signers,
|
||||||
|
store: store,
|
||||||
|
hostID: hostID,
|
||||||
|
ClockSkew: DefaultClockSkew,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify runs the LOCKED pipeline (phase4 §4 / doc 04 §2.3) and returns the
|
||||||
|
// authenticated op. Order is load-bearing and each post-crypto stage rejects even
|
||||||
|
// with an otherwise-valid signature:
|
||||||
|
//
|
||||||
|
// parse armor → namespace → parse pubkey → allow-list (by key MATERIAL, not
|
||||||
|
// key_id) → crypto verify (over the RAW received blob bytes) → parse blob →
|
||||||
|
// target → time window → nonce SeenOrRecord (LAST)
|
||||||
|
//
|
||||||
|
// The nonce is recorded last, so an invalid signature can never consume a nonce
|
||||||
|
// (DoS / replay-priming safe). Errors wrap the typed sentinels in errors.go.
|
||||||
|
func (v *Verifier) Verify(blob, sigArmored []byte) (*VerifiedOp, error) {
|
||||||
|
// 1. parse armor
|
||||||
|
sb, err := parseArmoredSSHSIG(sigArmored)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. namespace (fixed domain separator)
|
||||||
|
if sb.Namespace != Namespace {
|
||||||
|
return nil, fmt.Errorf("%w: got %q want %q", ErrNamespace, sb.Namespace, Namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. parse the embedded public key
|
||||||
|
pub, err := ssh.ParsePublicKey([]byte(sb.PublicKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: parsing signature public key: %v", ErrMalformed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. allow-list match by KEY MATERIAL (pub.Marshal equality) — NOT by key_id
|
||||||
|
matched, ok := v.matchSigner(pub)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnknownSigner
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. crypto verify over the RAW received bytes (never re-serialized)
|
||||||
|
signed, err := signedData(sb, blob)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var inner ssh.Signature
|
||||||
|
if err := ssh.Unmarshal([]byte(sb.Signature), &inner); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrMalformed, err)
|
||||||
|
}
|
||||||
|
if err := pub.Verify(signed, &inner); err != nil { // dispatches on the key's algorithm
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrBadSignature, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. parse the (now authenticated) blob bytes
|
||||||
|
var op OpBlob
|
||||||
|
if err := json.Unmarshal(blob, &op); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decoding op blob: %v", ErrMalformed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. target binding — host must be this box. guest_id is surfaced, not matched
|
||||||
|
// here (the verifier doesn't enumerate guests; the caller routes by it).
|
||||||
|
if op.Target.HostID != v.hostID {
|
||||||
|
return nil, fmt.Errorf("%w: blob host_id=%q this=%q", ErrTarget, op.Target.HostID, v.hostID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. time window (clock-skew tolerance on not-yet-valid only)
|
||||||
|
now := v.now()
|
||||||
|
if now.Before(op.IssuedAt.Add(-v.ClockSkew)) {
|
||||||
|
return nil, fmt.Errorf("%w: issued_at=%s now=%s", ErrNotYetValid, op.IssuedAt, now)
|
||||||
|
}
|
||||||
|
if now.After(op.ExpiresAt) {
|
||||||
|
return nil, fmt.Errorf("%w: expires_at=%s now=%s", ErrExpired, op.ExpiresAt, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. nonce LAST — only now is it durably recorded.
|
||||||
|
if v.store.SeenOrRecord(op.Nonce, op.ExpiresAt) {
|
||||||
|
return nil, fmt.Errorf("%w: nonce %s", ErrReplay, op.Nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advisory key_id audit (never a verdict input)
|
||||||
|
keyIDMatches := op.KeyID == matched.KeyID
|
||||||
|
if !keyIDMatches && v.Logger != nil {
|
||||||
|
v.Logger.Warn("authz: blob key_id does not match the matched signer (advisory)",
|
||||||
|
"blob_key_id", op.KeyID, "matched_signer", matched.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &VerifiedOp{
|
||||||
|
Op: op.Op,
|
||||||
|
HostID: op.Target.HostID,
|
||||||
|
GuestID: op.Target.GuestID,
|
||||||
|
Params: op.Params,
|
||||||
|
Nonce: op.Nonce,
|
||||||
|
IssuedAt: op.IssuedAt,
|
||||||
|
ExpiresAt: op.ExpiresAt,
|
||||||
|
KeyID: op.KeyID,
|
||||||
|
Signer: matched,
|
||||||
|
KeyIDMatchesSigner: keyIDMatches,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) matchSigner(pub ssh.PublicKey) (AllowedSigner, bool) {
|
||||||
|
pm := pub.Marshal()
|
||||||
|
for _, s := range v.signers {
|
||||||
|
if s.PublicKey != nil && bytes.Equal(s.PublicKey.Marshal(), pm) {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AllowedSigner{}, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixed reference instant used across in-Go tests (deterministic time window).
|
||||||
|
var refNow = time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func atRefNow(v *Verifier) *Verifier { v.now = func() time.Time { return refNow }; return v }
|
||||||
|
|
||||||
|
// rejects asserts a Verify error matches the expected sentinel.
|
||||||
|
func rejects(t *testing.T, err, want error) {
|
||||||
|
t.Helper()
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("want %v, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signerSet builds a one-key operational allow-list around an ssh.PublicKey.
|
||||||
|
func signerSet(pub ssh.PublicKey, keyID string) []AllowedSigner {
|
||||||
|
return []AllowedSigner{{KeyID: keyID, Role: RoleOperational, PublicKey: pub}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validBlob is an op blob valid at refNow.
|
||||||
|
func validBlob(host, guest, keyID, nonce string) []byte {
|
||||||
|
return canonicalBlob("guest_destroy", host, guest, keyID, nonce, `{"purge":true}`,
|
||||||
|
refNow.Add(-time.Hour), refNow.Add(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Real OpenSSH interop: committed ssh-keygen fixture ---
|
||||||
|
|
||||||
|
func TestVerify_RealSSHKeygenFixture(t *testing.T) {
|
||||||
|
blob := readFile(t, "testdata/op_blob.json")
|
||||||
|
sig := readFile(t, "testdata/op_blob.sig")
|
||||||
|
pubLine := readFile(t, "testdata/operator.pub")
|
||||||
|
|
||||||
|
signer, err := NewAllowedSigner("felhom-op-1", RoleOperational, string(pubLine))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAllowedSigner: %v", err)
|
||||||
|
}
|
||||||
|
v := New([]AllowedSigner{signer}, NewMemoryNonceStore(), "demo-felhom")
|
||||||
|
v.now = func() time.Time { return time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) } // inside fixture window
|
||||||
|
|
||||||
|
op, err := v.Verify(blob, sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("real fixture did not verify: %v", err)
|
||||||
|
}
|
||||||
|
if op.Op != "guest_destroy" || op.HostID != "demo-felhom" || op.GuestID != "9001" {
|
||||||
|
t.Errorf("unexpected op: %+v", op)
|
||||||
|
}
|
||||||
|
if op.KeyID != "felhom-op-1" || !op.KeyIDMatchesSigner {
|
||||||
|
t.Errorf("key_id audit wrong: %q matches=%v", op.KeyID, op.KeyIDMatchesSigner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Happy path (in-Go ed25519) ---
|
||||||
|
|
||||||
|
func TestVerify_HappyPath(t *testing.T) {
|
||||||
|
pub, sign := newEd25519Signer(t)
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-happy-0001")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
op, err := v.Verify(blob, sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
if op.Op != "guest_destroy" || op.Signer.KeyID != "op" {
|
||||||
|
t.Errorf("op = %+v", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-stage rejection, each with an otherwise-valid signature ---
|
||||||
|
|
||||||
|
func TestVerify_RejectsPerStage(t *testing.T) {
|
||||||
|
pub, sign := newEd25519Signer(t)
|
||||||
|
other, _ := newEd25519Signer(t)
|
||||||
|
|
||||||
|
t.Run("wrong namespace", func(t *testing.T) {
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-ns-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), "felhom-op-wrong", "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrNamespace)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("signer not in set", func(t *testing.T) {
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-unk-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(other, "other"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrUnknownSigner)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tampered blob (crypto)", func(t *testing.T) {
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-tamper-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
tampered := append([]byte{}, blob...)
|
||||||
|
tampered[len(tampered)-2] = '!' // mutate inside the JSON
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(tampered, sig)
|
||||||
|
rejects(t, err, ErrBadSignature)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retargeted host", func(t *testing.T) {
|
||||||
|
blob := validBlob("other-host", "9001", "op", "n-target-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrTarget)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired", func(t *testing.T) {
|
||||||
|
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-exp-1", "{}",
|
||||||
|
refNow.Add(-2*time.Hour), refNow.Add(-time.Hour))
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrExpired)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not yet valid", func(t *testing.T) {
|
||||||
|
blob := canonicalBlob("guest_destroy", "demo-felhom", "9001", "op", "n-nyv-1", "{}",
|
||||||
|
refNow.Add(time.Hour), refNow.Add(2*time.Hour))
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrNotYetValid)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replay", func(t *testing.T) {
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-replay-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
if _, err := v.Verify(blob, sig); err != nil {
|
||||||
|
t.Fatalf("first use: %v", err)
|
||||||
|
}
|
||||||
|
_, err := v.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrReplay)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- THE anti-replay invariant: an invalid-sig attempt must NOT burn the nonce ---
|
||||||
|
|
||||||
|
func TestVerify_InvalidSigDoesNotBurnNonce(t *testing.T) {
|
||||||
|
pub, sign := newEd25519Signer(t)
|
||||||
|
store := NewMemoryNonceStore()
|
||||||
|
const nonce = "n-not-burned-cafe"
|
||||||
|
|
||||||
|
blobV := validBlob("demo-felhom", "9001", "op", nonce)
|
||||||
|
validSig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blobV, sign)
|
||||||
|
|
||||||
|
// Attacker reuses the SAME nonce but a signature that fails crypto (valid key,
|
||||||
|
// signed over different bytes) — passes namespace + allow-list, fails at the
|
||||||
|
// crypto stage, which is BEFORE the nonce stage.
|
||||||
|
badSig := mintArmor(t, pub.Marshal(), Namespace, "sha512", []byte(`{"different":"bytes"}`), sign)
|
||||||
|
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), store, "demo-felhom"))
|
||||||
|
if _, err := v.Verify(blobV, badSig); !errors.Is(err, ErrBadSignature) {
|
||||||
|
t.Fatalf("invalid attempt: want ErrBadSignature, got %v", err)
|
||||||
|
}
|
||||||
|
// The genuine valid op with the same nonce must still succeed — proving the
|
||||||
|
// failed attempt did NOT burn the nonce (nonce-recorded-last).
|
||||||
|
if _, err := v.Verify(blobV, validSig); err != nil {
|
||||||
|
t.Fatalf("valid op after invalid attempt should succeed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Persistence across restart (durable nonce store) ---
|
||||||
|
|
||||||
|
func TestVerify_ReplayRejectedAcrossRestart(t *testing.T) {
|
||||||
|
pub, sign := newEd25519Signer(t)
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-persist-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
path := filepath.Join(t.TempDir(), "nonces.log")
|
||||||
|
|
||||||
|
store1, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v1 := atRefNow(New(signerSet(pub, "op"), store1, "demo-felhom"))
|
||||||
|
if _, err := v1.Verify(blob, sig); err != nil {
|
||||||
|
t.Fatalf("first use: %v", err)
|
||||||
|
}
|
||||||
|
if err := store1.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh store + verifier over the SAME path — simulates an agent restart.
|
||||||
|
store2, err := OpenFileNonceStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store2.Close()
|
||||||
|
v2 := atRefNow(New(signerSet(pub, "op"), store2, "demo-felhom"))
|
||||||
|
_, err = v2.Verify(blob, sig)
|
||||||
|
rejects(t, err, ErrReplay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Key-type-agnostic: synthetic FIDO2 sk-ssh-ed25519 through the unchanged path ---
|
||||||
|
|
||||||
|
func TestVerify_KeyTypeAgnostic_SK(t *testing.T) {
|
||||||
|
skPub, skSign := newSyntheticSKSigner(t)
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-sk-1")
|
||||||
|
sig := mintArmor(t, skPub.Marshal(), Namespace, "sha512", blob, skSign)
|
||||||
|
|
||||||
|
v := atRefNow(New(signerSet(skPub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
op, err := v.Verify(blob, sig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sk verify through unchanged path failed: %v", err)
|
||||||
|
}
|
||||||
|
if op.Op != "guest_destroy" {
|
||||||
|
t.Errorf("op = %q", op.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Byte-exactness: a re-serialized blob is NOT re-canonicalized (fails crypto) ---
|
||||||
|
|
||||||
|
func TestVerify_ByteExactNoRecanonicalization(t *testing.T) {
|
||||||
|
pub, sign := newEd25519Signer(t)
|
||||||
|
blob := validBlob("demo-felhom", "9001", "op", "n-bytes-1")
|
||||||
|
sig := mintArmor(t, pub.Marshal(), Namespace, "sha512", blob, sign)
|
||||||
|
|
||||||
|
// Same fields, different whitespace + key order — what a non-identical producer
|
||||||
|
// canonicalizer would emit. The verifier verifies raw bytes, so this fails crypto.
|
||||||
|
reserialized := []byte(`{ "op":"guest_destroy", "target":{"host_id":"demo-felhom","guest_id":"9001"}, "params":{"purge":true}, "nonce":"n-bytes-1", "issued_at":"` +
|
||||||
|
refNow.Add(-time.Hour).Format(time.RFC3339) + `", "expires_at":"` + refNow.Add(time.Hour).Format(time.RFC3339) + `", "key_id":"op" }`)
|
||||||
|
|
||||||
|
v := atRefNow(New(signerSet(pub, "op"), NewMemoryNonceStore(), "demo-felhom"))
|
||||||
|
_, err := v.Verify(reserialized, sig)
|
||||||
|
rejects(t, err, ErrBadSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, path string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// F-CRIT-2 (Campaign 8): a failed backup must not look like a fresh one.
|
||||||
|
//
|
||||||
|
// Every fixture below is a VERBATIM shape captured from the live PVE API on 2026-07-28
|
||||||
|
// (`pvesh get /nodes/<node>/storage/<store>/content`), not a hand-invented struct. That matters:
|
||||||
|
// the `unparseable` path in this package went untested for months behind a JSON shape that did not
|
||||||
|
// match production, and the whole point of this fix is that presence != validity.
|
||||||
|
|
||||||
|
// phantomEntry is the artefact a PBS daemon killed mid-upload leaves behind: listed as a restorable
|
||||||
|
// backup, 1 byte, NEWEST, and carrying no `verification`/`encrypted`/`notes` at all because it has
|
||||||
|
// no manifest (`index.json.blob` is absent on disk).
|
||||||
|
func phantomEntry() proxmox.StorageContent {
|
||||||
|
return proxmox.StorageContent{
|
||||||
|
VolID: "felhom-pbs:backup/ct/9201/2026-07-28T05:31:14Z",
|
||||||
|
Content: "backup",
|
||||||
|
Format: "pbs-ct",
|
||||||
|
Size: 1,
|
||||||
|
CTime: 1785216674,
|
||||||
|
VMID: 9201,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goodPBSEntry is a real, complete offsite snapshot (demo-hp, 2026-07-28T03:40:42Z).
|
||||||
|
func goodPBSEntry() proxmox.StorageContent {
|
||||||
|
return proxmox.StorageContent{
|
||||||
|
VolID: "felhom-pbs:backup/ct/9201/2026-07-28T03:40:42Z",
|
||||||
|
Content: "backup",
|
||||||
|
Format: "pbs-ct",
|
||||||
|
Size: 4353457559,
|
||||||
|
CTime: 1785210042,
|
||||||
|
VMID: 9201,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goodLocalEntry is a real, complete LOCAL vzdump (demo-hp). Note it legitimately has no
|
||||||
|
// `verification` and no `encrypted` on the wire — a dir storage has no such concept — which is
|
||||||
|
// exactly why those fields must never be used as completeness discriminators.
|
||||||
|
func goodLocalEntry() proxmox.StorageContent {
|
||||||
|
return proxmox.StorageContent{
|
||||||
|
VolID: "local:backup/vzdump-lxc-9201-2026_07_28-07_29_54.tar.zst",
|
||||||
|
Content: "backup",
|
||||||
|
Format: "tar.zst",
|
||||||
|
Size: 1590431865,
|
||||||
|
CTime: 1785216594,
|
||||||
|
VMID: 9201,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runnerWithContent(t *testing.T, buf *bytes.Buffer, content []proxmox.StorageContent) *BackupRunner {
|
||||||
|
t.Helper()
|
||||||
|
lg := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
return NewBackupRunner(&fakeBackupAPI{content: content}, "felhom-pbs", proxmox.ModeSnapshot, "", "", lg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group A — the phantom must NOT set tier freshness, even though it is the newest entry.
|
||||||
|
//
|
||||||
|
// RED-PROOF: restore the old predicate in NewestArchiveTime
|
||||||
|
// (`if e.Content == "backup" && e.VMID == vmid && e.CTime > best`) → the phantom's ctime
|
||||||
|
// (1785216674) wins over the good snapshot's (1785210042) and this test fails with
|
||||||
|
// "got 1785216674, want 1785210042" — i.e. the exact F-CRIT-2 defect.
|
||||||
|
func TestNewestArchiveTime_PhantomIsNotCounted(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
// phantom deliberately listed FIRST and is also the newest by ctime.
|
||||||
|
r := runnerWithContent(t, &buf, []proxmox.StorageContent{phantomEntry(), goodPBSEntry()})
|
||||||
|
|
||||||
|
got, found, err := r.NewestArchiveTime(context.Background(), 9201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewestArchiveTime: %v", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("found=false — the GOOD snapshot must still be counted; rejecting everything is the thrash path")
|
||||||
|
}
|
||||||
|
if got.Unix() != goodPBSEntry().CTime {
|
||||||
|
t.Errorf("freshness came from the wrong entry: got ctime %d, want %d (the good snapshot)", got.Unix(), goodPBSEntry().CTime)
|
||||||
|
}
|
||||||
|
if got.Unix() == phantomEntry().CTime {
|
||||||
|
t.Error("the 1-byte manifest-less phantom set tier freshness — this is F-CRIT-2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group A — with ONLY a phantom present the tier must report "no backup", not a fresh one.
|
||||||
|
// That is what lets the controller see age_state=absent and fire its first-backup valve.
|
||||||
|
func TestNewestArchiveTime_OnlyPhantomReportsNotFound(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
r := runnerWithContent(t, &buf, []proxmox.StorageContent{phantomEntry()})
|
||||||
|
|
||||||
|
_, found, err := r.NewestArchiveTime(context.Background(), 9201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewestArchiveTime: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Error("found=true with only a phantom present — the tier would report fresh and go silent for a full cadence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group B — THE SCENARIO-D GUARD. A valid snapshot on EITHER tier must still be counted.
|
||||||
|
//
|
||||||
|
// This is what makes Group A safe. A filter that is too aggressive does not merely lose safety
|
||||||
|
// margin: the tier reports absent on every poll, backs up every cycle, and the R-88 breaker cannot
|
||||||
|
// save it because those backups SUCCEED. That is a continuous multi-GB write loop across the fleet.
|
||||||
|
//
|
||||||
|
// RED-PROOF: make archivePlausiblyComplete return `false, "reject everything"` unconditionally →
|
||||||
|
// both subtests fail with found=false.
|
||||||
|
func TestNewestArchiveTime_ValidSnapshotsAreStillCounted(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
entry proxmox.StorageContent
|
||||||
|
}{
|
||||||
|
{"pbs offsite (has verification+encrypted on the wire)", goodPBSEntry()},
|
||||||
|
{"local dir vzdump (has NEITHER verification NOR encrypted — and must still count)", goodLocalEntry()},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
r := runnerWithContent(t, &buf, []proxmox.StorageContent{tc.entry})
|
||||||
|
|
||||||
|
got, found, err := r.NewestArchiveTime(context.Background(), 9201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewestArchiveTime: %v", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("a REAL %s backup was rejected — this is the backup-thrash path, not extra safety", tc.name)
|
||||||
|
}
|
||||||
|
if got.Unix() != tc.entry.CTime {
|
||||||
|
t.Errorf("got ctime %d, want %d", got.Unix(), tc.entry.CTime)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), "INCOMPLETE archive") {
|
||||||
|
t.Errorf("a valid archive was announced as incomplete:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group B — the smallest REAL backup measured anywhere on the fleet (612,397,450 B, a guest-9100
|
||||||
|
// vzdump) must clear the floor with room to spare. If someone ever raises
|
||||||
|
// minPlausibleArchiveBytes past this, that is the fleet-thrash bug and this test is the tripwire.
|
||||||
|
func TestMinPlausibleArchiveBytes_LeavesHeadroomBelowTheSmallestRealBackup(t *testing.T) {
|
||||||
|
const smallestObservedRealBackup int64 = 612397450 // fleet survey 2026-07-28
|
||||||
|
if minPlausibleArchiveBytes >= smallestObservedRealBackup {
|
||||||
|
t.Fatalf("floor %d B is not below the smallest real backup ever observed (%d B) — this WILL reject real archives",
|
||||||
|
minPlausibleArchiveBytes, smallestObservedRealBackup)
|
||||||
|
}
|
||||||
|
if ratio := smallestObservedRealBackup / minPlausibleArchiveBytes; ratio < 100 {
|
||||||
|
t.Errorf("floor %d B leaves only %dx headroom below the smallest real backup (%d B) — too tight",
|
||||||
|
minPlausibleArchiveBytes, ratio, smallestObservedRealBackup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group C — UNDECIDABLE ⇒ NOT COUNTED (the fail-safe direction).
|
||||||
|
//
|
||||||
|
// A zero/absent size is not evidence of a good backup; it is absence of evidence. Erring toward
|
||||||
|
// "not fresh" costs one extra backup. Erring the other way is F-CRIT-2.
|
||||||
|
//
|
||||||
|
// RED-PROOF: flip the comparison in archivePlausiblyComplete to `e.Size > minPlausibleArchiveBytes
|
||||||
|
// || e.Size == 0` (i.e. treat unknown as complete) → the size-0 case reports ok=true and this fails.
|
||||||
|
func TestArchivePlausiblyComplete_UndecidableIsNotCounted(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
}{
|
||||||
|
{"the observed phantom", 1},
|
||||||
|
{"absent size field (unmarshals to 0)", 0},
|
||||||
|
{"just under the floor", minPlausibleArchiveBytes - 1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
e := phantomEntry()
|
||||||
|
e.Size = tc.size
|
||||||
|
ok, why := archivePlausiblyComplete(e)
|
||||||
|
if ok {
|
||||||
|
t.Errorf("size %d counted as a complete backup — undecidable must fail safe", tc.size)
|
||||||
|
}
|
||||||
|
if why == "" {
|
||||||
|
t.Error("rejection carried no reason — a silent rejection is a new quiet path")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ok, why := archivePlausiblyComplete(goodPBSEntry()); !ok {
|
||||||
|
t.Errorf("a real snapshot was rejected: %s", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group D — the rejection is announced ONCE per snapshot, not once per due-check.
|
||||||
|
//
|
||||||
|
// The due-check runs every 5 minutes and a phantom persists indefinitely (server-side prune does
|
||||||
|
// not collect it), so per-poll logging would emit ~288 identical lines a day and bury the signal.
|
||||||
|
//
|
||||||
|
// RED-PROOF: delete the `if seen { return }` guard in warnRejectedArchiveOnce → this test reports
|
||||||
|
// "logged 5 times, want 1".
|
||||||
|
func TestNewestArchiveTime_RejectionLoggedOncePerSnapshot(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
r := runnerWithContent(t, &buf, []proxmox.StorageContent{phantomEntry(), goodPBSEntry()})
|
||||||
|
|
||||||
|
const polls = 5
|
||||||
|
for i := 0; i < polls; i++ {
|
||||||
|
if _, _, err := r.NewestArchiveTime(context.Background(), 9201); err != nil {
|
||||||
|
t.Fatalf("poll %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n := strings.Count(buf.String(), "INCOMPLETE archive")
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("rejection logged %d times across %d polls, want exactly 1:\n%s", n, polls, buf.String())
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, phantomEntry().VolID) {
|
||||||
|
t.Errorf("the log line does not NAME the rejected snapshot:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "below the") {
|
||||||
|
t.Errorf("the log line does not say WHY it was rejected:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "level=WARN") {
|
||||||
|
t.Errorf("rejection was not logged at WARN:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group D — a SECOND, distinct phantom is announced separately. The dedupe must be per snapshot,
|
||||||
|
// not a one-shot latch that hides every later phantom.
|
||||||
|
func TestNewestArchiveTime_DistinctPhantomsEachAnnounced(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
second := phantomEntry()
|
||||||
|
second.VolID = "felhom-pbs:backup/ct/9201/2026-07-29T05:31:14Z"
|
||||||
|
second.CTime = phantomEntry().CTime + 86400
|
||||||
|
r := runnerWithContent(t, &buf, []proxmox.StorageContent{phantomEntry(), second, goodPBSEntry()})
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, _, err := r.NewestArchiveTime(context.Background(), 9201); err != nil {
|
||||||
|
t.Fatalf("poll %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := strings.Count(buf.String(), "INCOMPLETE archive"); n != 2 {
|
||||||
|
t.Errorf("got %d rejection lines for 2 distinct phantoms across 3 polls, want 2:\n%s", n, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
// fakeBackupAPI is a synthetic BackupAPI.
|
||||||
|
type fakeBackupAPI struct {
|
||||||
|
vzdumpUPID string
|
||||||
|
vzdumpErr error
|
||||||
|
waitErr error
|
||||||
|
cfg proxmox.GuestConfig
|
||||||
|
cfgErr error
|
||||||
|
content []proxmox.StorageContent
|
||||||
|
contentErr error
|
||||||
|
storages []proxmox.Storage // returned by ListStorage (the local-prune scope gate)
|
||||||
|
storageErr error
|
||||||
|
vzdumps []proxmox.VzdumpOptions
|
||||||
|
logLines []string // returned by TaskLogTail (e.g. "INFO: backup mode: stop")
|
||||||
|
waitGate chan struct{} // if non-nil, WaitTask blocks until closed (8B.2 watcher timing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBackupAPI) Vzdump(_ context.Context, o proxmox.VzdumpOptions) (string, error) {
|
||||||
|
f.vzdumps = append(f.vzdumps, o)
|
||||||
|
return f.vzdumpUPID, f.vzdumpErr
|
||||||
|
}
|
||||||
|
func (f *fakeBackupAPI) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) {
|
||||||
|
if f.waitGate != nil {
|
||||||
|
<-f.waitGate
|
||||||
|
}
|
||||||
|
return proxmox.TaskStatus{Status: "stopped", ExitStatus: "OK"}, f.waitErr
|
||||||
|
}
|
||||||
|
func (f *fakeBackupAPI) GuestConfig(_ context.Context, _ int) (proxmox.GuestConfig, error) {
|
||||||
|
return f.cfg, f.cfgErr
|
||||||
|
}
|
||||||
|
func (f *fakeBackupAPI) StorageContent(_ context.Context, _ string) ([]proxmox.StorageContent, error) {
|
||||||
|
return f.content, f.contentErr
|
||||||
|
}
|
||||||
|
func (f *fakeBackupAPI) ListStorage(_ context.Context) ([]proxmox.Storage, error) {
|
||||||
|
return f.storages, f.storageErr
|
||||||
|
}
|
||||||
|
func (f *fakeBackupAPI) TaskLogTail(_ context.Context, _ string, _ int) ([]string, error) {
|
||||||
|
return f.logLines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guestCfgWithMounts builds a GuestConfig whose Extra carries the given mpN strings.
|
||||||
|
func guestCfgWithMounts(mps map[string]string) proxmox.GuestConfig {
|
||||||
|
extra := map[string]json.RawMessage{}
|
||||||
|
for k, v := range mps {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
extra[k] = b
|
||||||
|
}
|
||||||
|
return proxmox.GuestConfig{Extra: extra}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackup_SuccessResolvesArchiveAndBulkGap(t *testing.T) {
|
||||||
|
api := &fakeBackupAPI{
|
||||||
|
vzdumpUPID: "UPID:vzdump:1",
|
||||||
|
cfg: guestCfgWithMounts(map[string]string{
|
||||||
|
"mp0": "local-lvm:8,mp=/mnt/bulk,backup=0", // explicit opt-out → uncovered
|
||||||
|
"mp1": "local-lvm:4,mp=/mnt/db,backup=1", // covered
|
||||||
|
"mp2": "local-lvm:2,mp=/mnt/scratch", // UNSET → uncovered (opt-in default)
|
||||||
|
}),
|
||||||
|
content: []proxmox.StorageContent{
|
||||||
|
{VolID: "local:backup/old-9001.tar.zst", Content: "backup", VMID: 9001, Size: 100, CTime: 100},
|
||||||
|
{VolID: "local:backup/new-9001.tar.zst", Content: "backup", VMID: 9001, Size: 524288000, CTime: 200},
|
||||||
|
{VolID: "local:backup/other-9002.tar.zst", Content: "backup", VMID: 9002, Size: 7, CTime: 999},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := NewBackupRunner(api, "local", "", "felhom test", "", quiet())
|
||||||
|
rec, err := r.Backup(context.Background(), 9001)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Backup: %v", err)
|
||||||
|
}
|
||||||
|
if !rec.Success || !rec.CrashConsistent {
|
||||||
|
t.Errorf("record = %+v, want success + crash_consistent", rec)
|
||||||
|
}
|
||||||
|
if rec.Archive != "local:backup/new-9001.tar.zst" || rec.SizeBytes != 524288000 {
|
||||||
|
t.Errorf("resolved wrong archive/size: %+v", rec)
|
||||||
|
}
|
||||||
|
if rec.Mode != string(proxmox.ModeSnapshot) {
|
||||||
|
t.Errorf("mode = %q, want snapshot (default)", rec.Mode)
|
||||||
|
}
|
||||||
|
// Bulk gap: mp0 (backup=0) AND mp2 (unset) are uncovered; mp1 (backup=1) is NOT.
|
||||||
|
if got := rec.UncoveredVolumes; len(got) != 2 || !has(got, "/mnt/bulk") || !has(got, "/mnt/scratch") {
|
||||||
|
t.Errorf("uncovered = %v, want [/mnt/bulk /mnt/scratch] (unset is uncovered too)", got)
|
||||||
|
}
|
||||||
|
if has(rec.UncoveredVolumes, "/mnt/db") {
|
||||||
|
t.Error("backup=1 mountpoint must NOT be reported uncovered")
|
||||||
|
}
|
||||||
|
// Notes-template threaded through.
|
||||||
|
if len(api.vzdumps) != 1 || api.vzdumps[0].Notes != "felhom test" {
|
||||||
|
t.Errorf("vzdump opts = %+v", api.vzdumps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackup_ReportsActualModeFromTaskLog(t *testing.T) {
|
||||||
|
// Requested snapshot, but PVE used stop (stopped guest) — the report must reflect ACTUAL.
|
||||||
|
api := &fakeBackupAPI{
|
||||||
|
vzdumpUPID: "UPID:vzdump:1",
|
||||||
|
content: []proxmox.StorageContent{{VolID: "v", Content: "backup", VMID: 9001, Size: 10, CTime: 1}},
|
||||||
|
logLines: []string{"INFO: CT Name: spike", "INFO: backup mode: stop", "INFO: Finished"},
|
||||||
|
}
|
||||||
|
r := NewBackupRunner(api, "local", proxmox.ModeSnapshot, "", "", quiet())
|
||||||
|
rec, err := r.Backup(context.Background(), 9001)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Mode != "stop" {
|
||||||
|
t.Errorf("mode = %q, want the ACTUAL %q from the task log (not the requested snapshot)", rec.Mode, "stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackup_VzdumpFailureReturnsFailedRecord(t *testing.T) {
|
||||||
|
api := &fakeBackupAPI{vzdumpErr: errors.New("vzdump boom")}
|
||||||
|
r := NewBackupRunner(api, "local", "", "", "", quiet())
|
||||||
|
rec, err := r.Backup(context.Background(), 9001)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if rec.Success || rec.Error == "" {
|
||||||
|
t.Errorf("failed backup must produce a Success=false record with an Error: %+v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickRestoreCandidate_NewestOrEmpty(t *testing.T) {
|
||||||
|
const big = 4 << 30 // a plausible whole-guest archive
|
||||||
|
api := &fakeBackupAPI{content: []proxmox.StorageContent{
|
||||||
|
{VolID: "a", Content: "backup", CTime: 10, Size: big},
|
||||||
|
{VolID: "b", Content: "backup", CTime: 99, Size: big},
|
||||||
|
{VolID: "iso", Content: "iso", CTime: 999, Size: big}, // not a backup → ignored
|
||||||
|
}}
|
||||||
|
r := NewBackupRunner(api, "local", "", "", "", quiet())
|
||||||
|
vol, err := r.PickRestoreCandidate(context.Background())
|
||||||
|
if err != nil || vol != "b" {
|
||||||
|
t.Fatalf("pick = %q,%v want newest 'b'", vol, err)
|
||||||
|
}
|
||||||
|
// no backups → "".
|
||||||
|
api.content = []proxmox.StorageContent{{VolID: "iso", Content: "iso"}}
|
||||||
|
if vol, _ := r.PickRestoreCandidate(context.Background()); vol != "" {
|
||||||
|
t.Errorf("no backup → empty, got %q", vol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-86: the NEWEST entry is not a candidate if it cannot be a complete archive. An incomplete
|
||||||
|
// artefact (F-CRIT-2's 1-byte phantom, which server-side prune does not collect) would otherwise be
|
||||||
|
// picked forever, fail its restore forever, never earn proof, and so leave the tier due at every
|
||||||
|
// evaluation — turning the evaluation interval into the retry rate for a multi-GB restore.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed): drop the `archivePlausiblyComplete` guard from
|
||||||
|
// PickSettledRestoreCandidateOn and this fails with
|
||||||
|
// `pick = "phantom" want the newest COMPLETE archive 'real'`.
|
||||||
|
func TestPickRestoreCandidate_SkipsImplausibleArchives(t *testing.T) {
|
||||||
|
api := &fakeBackupAPI{content: []proxmox.StorageContent{
|
||||||
|
{VolID: "real", Content: "backup", CTime: 10, Size: 4 << 30},
|
||||||
|
{VolID: "phantom", Content: "backup", CTime: 99, Size: 1}, // newest, and impossible
|
||||||
|
}}
|
||||||
|
r := NewBackupRunner(api, "local", "", "", "", quiet())
|
||||||
|
vol, err := r.PickRestoreCandidate(context.Background())
|
||||||
|
if err != nil || vol != "real" {
|
||||||
|
t.Fatalf("pick = %q,%v want the newest COMPLETE archive 'real'", vol, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scheduler ---
|
||||||
|
|
||||||
|
type fakeRTRunner struct {
|
||||||
|
res reconcile.RestoreTestResult
|
||||||
|
runs int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRTRunner) RunRestoreTest(_ context.Context, _ reconcile.RestoreTestSpec) reconcile.RestoreTestResult {
|
||||||
|
f.runs++
|
||||||
|
return f.res
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_TickRunsAndRecords(t *testing.T) {
|
||||||
|
store := NewStore()
|
||||||
|
rt := &fakeRTRunner{res: reconcile.RestoreTestResult{Archive: "vol", Pass: true, Verified: "boot+running", Duration: time.Second}}
|
||||||
|
s := NewScheduler(SchedulerOptions{
|
||||||
|
Runner: rt,
|
||||||
|
Pick: func(context.Context) (string, error) { return "vol", nil },
|
||||||
|
Store: store,
|
||||||
|
Spec: func(context.Context, string) reconcile.RestoreTestSpec {
|
||||||
|
return reconcile.RestoreTestSpec{RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009}
|
||||||
|
},
|
||||||
|
Cadence: time.Hour,
|
||||||
|
Logger: quiet(),
|
||||||
|
})
|
||||||
|
s.tick(context.Background())
|
||||||
|
if rt.runs != 1 {
|
||||||
|
t.Fatalf("tick should run the restore-test once, got %d", rt.runs)
|
||||||
|
}
|
||||||
|
got := store.RestoreTests(context.Background())
|
||||||
|
if len(got) != 1 || !got[0].Pass || got[0].SourceArchive != "vol" {
|
||||||
|
t.Fatalf("store should have the recorded result: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_TickNoBackupNoOp(t *testing.T) {
|
||||||
|
rt := &fakeRTRunner{}
|
||||||
|
s := NewScheduler(SchedulerOptions{
|
||||||
|
Runner: rt, Pick: func(context.Context) (string, error) { return "", nil },
|
||||||
|
Store: NewStore(), Cadence: time.Hour, Logger: quiet(),
|
||||||
|
})
|
||||||
|
s.tick(context.Background())
|
||||||
|
if rt.runs != 0 {
|
||||||
|
t.Errorf("no backup available → no restore-test run, got %d", rt.runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduler_DisabledRunReturnsOnCancel(t *testing.T) {
|
||||||
|
s := NewScheduler(SchedulerOptions{Cadence: 0, Logger: quiet()})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- s.Run(ctx) }()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("disabled scheduler Run should return nil on cancel, got %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("disabled scheduler did not return on cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_Reporters(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
if len(s.Backups(context.Background())) != 0 || len(s.RestoreTests(context.Background())) != 0 {
|
||||||
|
t.Fatal("empty store must report empty (non-nil) slices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(ss []string, want string) bool {
|
||||||
|
for _, s := range ss {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package backup is the guest-level backup + self-restore-test layer (doc 03 §8, slice 6
|
||||||
|
// Phase A). It orchestrates a crash-consistent vzdump to a LOCAL target, resolves the
|
||||||
|
// produced archive, and drives the self-restore-test (restore → boot → verify → teardown)
|
||||||
|
// through the reconcile engine so it inherits the journal / per-guest serialization /
|
||||||
|
// crash-safe recovery.
|
||||||
|
//
|
||||||
|
// Everything here is BENIGN (backup, restore-to-NEW, scratch teardown): it reuses the
|
||||||
|
// slice-4 classifier/gate/journal via reconcile — no new destructive class, no new crypto.
|
||||||
|
// Restore is to a NEW guest only (no overwrite this slice). PBS / offsite / zero-knowledge
|
||||||
|
// is Phase B.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
// - runner.go — BackupRunner: vzdump + archive-volid/size resolve + the bulk-volume gap;
|
||||||
|
// restore-candidate picker.
|
||||||
|
// - store.go — in-memory latest-backup-per-target + latest-restore-test, implementing
|
||||||
|
// the hub BackupReporter / RestoreTestReporter seams (point-in-time state
|
||||||
|
// the collector reads; re-populated each cadence/selftest run).
|
||||||
|
// - schedule.go — the restore-test cadence goroutine (default 24h; disabled when 0).
|
||||||
|
//
|
||||||
|
// hub does NOT import this package (the report types live in hub; this package imports hub
|
||||||
|
// for them, mirroring the slice-5 storage seam). This package may import reconcile + hub +
|
||||||
|
// proxmox (acyclic).
|
||||||
|
package backup
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// InFlight is the host-wide "one heavy guest operation at a time" gate.
|
||||||
|
//
|
||||||
|
// R-85 (Scenario F). The operator's R-82 ruling was "one backup at a time per guest"; a restore-test
|
||||||
|
// must JOIN that single-flight rather than sit outside it. It is not a lock-contention concern —
|
||||||
|
// a restore-test uses a scratch VMID, so it never touches the live guest's vzdump lock. It is a
|
||||||
|
// LINK concern: an offsite restore PULLS a multi-GB archive while an offsite backup PUSHES one, over
|
||||||
|
// the same WireGuard tunnel. On the demo fleet that link runs at ~33 MB/min upstream; running both
|
||||||
|
// at once makes each slower and pushes both toward their timeouts, which is how a healthy tier ends
|
||||||
|
// up recorded as failed.
|
||||||
|
//
|
||||||
|
// It is deliberately host-wide and coarse rather than per-guest: these boxes carry one customer
|
||||||
|
// guest, and the resource being protected (the uplink) is shared by everything on the host anyway.
|
||||||
|
//
|
||||||
|
// The gate is ADVISORY in one direction only — it never cancels anything already running. A caller
|
||||||
|
// that cannot acquire DEFERS to its next cadence. Deferring a restore-test costs a few hours of
|
||||||
|
// coverage; cancelling a running backup costs the backup.
|
||||||
|
//
|
||||||
|
// CORRECTED 2026-07-28 (F-A1). That "DEFERS" was true of the restore-test caller and NOT of the
|
||||||
|
// backup caller, and the comment did not say so. The controller's start path had no 409 branch, so
|
||||||
|
// a refusal here was recorded as a tier FAILURE: the R-88 breaker armed and the operator was
|
||||||
|
// emailed "Whole-guest backup FAILED" about a backup that was merely waiting its turn. Campaign 8
|
||||||
|
// observed it on both demo boxes in the same minute.
|
||||||
|
//
|
||||||
|
// Fixed on the CONTROLLER side (v0.179.0), which is where the misreading lived — this gate's
|
||||||
|
// behaviour was correct throughout and is unchanged. The controller now maps HTTP 409 to a
|
||||||
|
// contention path: it defers the tier, keeps it DUE, and alarms only if contention outlives the
|
||||||
|
// agent's own restore-test ceiling. Nothing here needs to change; the claim above is simply now
|
||||||
|
// true of both callers.
|
||||||
|
type InFlight struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
what string // "" = idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAcquire claims the gate for `what`. ok=false means something else holds it, and `busy` names
|
||||||
|
// it — the name matters, because "deferred" with no reason is indistinguishable from "broken".
|
||||||
|
func (g *InFlight) TryAcquire(what string) (release func(), busy string, ok bool) {
|
||||||
|
if g == nil {
|
||||||
|
// Not wired (older call sites, tests) → no gating, previous behaviour.
|
||||||
|
return func() {}, "", true
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.what != "" {
|
||||||
|
return nil, g.what, false
|
||||||
|
}
|
||||||
|
g.what = what
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(func() {
|
||||||
|
g.mu.Lock()
|
||||||
|
g.what = ""
|
||||||
|
g.mu.Unlock()
|
||||||
|
})
|
||||||
|
}, "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busy reports what currently holds the gate ("" = idle).
|
||||||
|
func (g *InFlight) Busy() string {
|
||||||
|
if g == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
return g.what
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// localTarget is a non-PBS dir storage; pbsTarget is a PBS storage — for the scope gate.
|
||||||
|
var (
|
||||||
|
localTargetStores = []proxmox.Storage{{Storage: "local", Type: "dir", Content: "backup"}}
|
||||||
|
pbsTargetStores = []proxmox.Storage{{Storage: "felhom-pbs", Type: "pbs", Content: "backup"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
func okAPI(stores []proxmox.Storage) *fakeBackupAPI {
|
||||||
|
return &fakeBackupAPI{vzdumpUPID: "UPID:vzdump:1", storages: stores}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrune_LocalCarriesKeepLast: a LOCAL-target backup with retention carries `--prune-backups
|
||||||
|
// keep-last=3` on the vzdump. Companion: the SAME runner built with no retention ("") emits NO prune
|
||||||
|
// option — proving the flag only rides when retention is set (a no-prune build accumulates).
|
||||||
|
func TestPrune_LocalCarriesKeepLast(t *testing.T) {
|
||||||
|
api := okAPI(localTargetStores)
|
||||||
|
r := NewBackupRunner(api, "local", proxmox.ModeStop, "", "keep-last=3", quiet())
|
||||||
|
_, _ = r.Backup(context.Background(), 9201) // archive-resolution may fail in the fake; we assert the captured vzdump opts
|
||||||
|
if len(api.vzdumps) != 1 || api.vzdumps[0].PruneBackups != "keep-last=3" {
|
||||||
|
t.Fatalf("local backup must carry prune-backups keep-last=3, got %q", api.vzdumps[0].PruneBackups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPANION: no retention → no prune option (dumps would accumulate).
|
||||||
|
api2 := okAPI(localTargetStores)
|
||||||
|
r2 := NewBackupRunner(api2, "local", proxmox.ModeStop, "", "", quiet())
|
||||||
|
_, _ = r2.Backup(context.Background(), 9201)
|
||||||
|
if api2.vzdumps[0].PruneBackups != "" {
|
||||||
|
t.Fatalf("a no-retention runner must NOT prune, got %q", api2.vzdumps[0].PruneBackups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrune_NeverPrunesPBS is the scope rule (§9): retention is NOT applied when the target is a PBS
|
||||||
|
// storage (offsite retention is a separate lifecycle). Companion: the identical retention on a LOCAL
|
||||||
|
// target IS applied — proving the gate keys on storage type, not luck.
|
||||||
|
func TestPrune_NeverPrunesPBS(t *testing.T) {
|
||||||
|
api := okAPI(pbsTargetStores)
|
||||||
|
r := NewBackupRunner(api, "felhom-pbs", proxmox.ModeStop, "", "keep-last=3", quiet())
|
||||||
|
_, _ = r.Backup(context.Background(), 9201)
|
||||||
|
if api.vzdumps[0].PruneBackups != "" {
|
||||||
|
t.Fatalf("a PBS target must NEVER be pruned by the per-run flag, got %q", api.vzdumps[0].PruneBackups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPANION: same retention, local target → applied.
|
||||||
|
api2 := okAPI(localTargetStores)
|
||||||
|
r2 := NewBackupRunner(api2, "local", proxmox.ModeStop, "", "keep-last=3", quiet())
|
||||||
|
_, _ = r2.Backup(context.Background(), 9201)
|
||||||
|
if api2.vzdumps[0].PruneBackups != "keep-last=3" {
|
||||||
|
t.Fatalf("control: a local target with the same retention MUST be pruned, got %q", api2.vzdumps[0].PruneBackups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrune_FailSafeOnUnknownTarget: if the target's type can't be confirmed (lookup error / not in the
|
||||||
|
// list), the run SKIPS pruning rather than risk pruning a PBS/unknown storage.
|
||||||
|
func TestPrune_FailSafeOnUnknownTarget(t *testing.T) {
|
||||||
|
// target not present in the list → skip.
|
||||||
|
api := &fakeBackupAPI{vzdumpUPID: "UPID:vzdump:1", storages: localTargetStores}
|
||||||
|
r := NewBackupRunner(api, "some-other-store", proxmox.ModeStop, "", "keep-last=3", quiet())
|
||||||
|
_, _ = r.Backup(context.Background(), 9201)
|
||||||
|
if api.vzdumps[0].PruneBackups != "" {
|
||||||
|
t.Fatalf("an unknown target must skip pruning (fail-safe), got %q", api.vzdumps[0].PruneBackups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrune_KeepLastClamp (§7-B): a 0/negative/unset LocalBackupRetention clamps to ≥1 (default 3) so the
|
||||||
|
// vzdump NEVER prunes the archive it just made. Companion: a no-clamp impl that returns 0 would emit
|
||||||
|
// keep-last=0 → PVE prunes everything → FAILS the "≥1" assertion.
|
||||||
|
func TestPrune_KeepLastClamp(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
set int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, 3}, // unset → default
|
||||||
|
{-5, 3}, // negative → default
|
||||||
|
{1, 1}, // honored
|
||||||
|
{3, 3}, // honored
|
||||||
|
{10, 10}, // honored
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
b := config.BackupConfig{LocalBackupRetention: c.set}
|
||||||
|
if got := b.KeepLast(); got != c.want {
|
||||||
|
t.Errorf("KeepLast(%d) = %d, want %d", c.set, got, c.want)
|
||||||
|
}
|
||||||
|
if b.KeepLast() < 1 {
|
||||||
|
t.Fatalf("keep-last must NEVER be < 1 (would prune the fresh backup), got %d for %d", b.KeepLast(), c.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec := (config.BackupConfig{}).PruneBackupsSpec(); spec != "keep-last=3" {
|
||||||
|
t.Fatalf("default PruneBackupsSpec = %q, want keep-last=3", spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-86 — a restore-test follows the BACKUP, not the clock.
|
||||||
|
//
|
||||||
|
// ── WHAT WAS WRONG ───────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The trigger was `time.NewTicker(cadence)` started at daemon start, and the tier was chosen by
|
||||||
|
// oldest-proven rotation. Its phase was therefore the PROCESS'S UPTIME: agent deploys are routine,
|
||||||
|
// so the test drifted to an arbitrary time of day every week; a fresh archive could sit unproven
|
||||||
|
// while an older one was re-tested; and a weekly tier was tested on the same rhythm as a daily one,
|
||||||
|
// sometimes twice on the same archive.
|
||||||
|
//
|
||||||
|
// ── THE RULE, AND THE TRAP IN ITS OBVIOUS FORM ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// R-86's ask reads "test a tier ~24 h after its own newest archive". Implemented literally —
|
||||||
|
// *"due when the newest archive is at least `settle` old"* — a DAILY tier is NEVER due: a new
|
||||||
|
// archive lands every day, so the newest archive's age resets to zero long before it reaches 24 h.
|
||||||
|
// The naive rule silently switches restore-testing off for the tier that matters most, and it is
|
||||||
|
// the version a reasonable person would write. It has a red-proof of its own
|
||||||
|
// (TestDue_NaiveNewestArchiveAgeRuleNeverFiresOnADailyTier).
|
||||||
|
//
|
||||||
|
// The rule implemented here:
|
||||||
|
//
|
||||||
|
// Let A = the newest archive on this tier that is at least `settle` old.
|
||||||
|
// The tier is DUE when A exists and A HAS NOT ALREADY BEEN PROVEN.
|
||||||
|
//
|
||||||
|
// daily tier → A is yesterday's archive; a new one settles each day → proved once per day
|
||||||
|
// weekly tier → A is last week's until the next settles → proved once per week
|
||||||
|
// newborn tier → A does not exist → UNKNOWN, never a fault
|
||||||
|
//
|
||||||
|
// Per-archive due-ness IS the pacing: one test per archive generation and no more. There is
|
||||||
|
// deliberately no second rate limiter on top of it (§8.4) — two independent pacing mechanisms
|
||||||
|
// produce a cadence nobody can predict from either.
|
||||||
|
//
|
||||||
|
// ── WHAT DID NOT CHANGE ──────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The one-heavy-operation gate, the success-only proof credit, the oldest-proven ordering (now the
|
||||||
|
// tie-break between two DUE tiers), the restore-test itself, its journal and its scratch band. Only
|
||||||
|
// the trigger changed.
|
||||||
|
|
||||||
|
// DueVerdict is one tier's due-ness, and the evidence for it. Every field is logged: a due-check
|
||||||
|
// that cannot say WHY is a quiet path, and quiet paths are what this monitor family keeps shipping.
|
||||||
|
type DueVerdict struct {
|
||||||
|
Target string // the tier's storage target id
|
||||||
|
|
||||||
|
// Due is true only when Archive is set and has not been proven.
|
||||||
|
Due bool
|
||||||
|
// Archive is the settled candidate A ("" when the tier holds none).
|
||||||
|
Archive string
|
||||||
|
// Landed is when A landed on the tier (zero when Archive is "").
|
||||||
|
Landed time.Time
|
||||||
|
// ProvenArchive is what the state says was last proven on this tier ("" = nothing/legacy).
|
||||||
|
ProvenArchive string
|
||||||
|
// Err is a candidate-lookup failure. A tier whose archives cannot be listed is UNKNOWN — it is
|
||||||
|
// NEVER reported as "not due", which would silently retire a tier the moment its storage
|
||||||
|
// stopped answering. Due stays false (we have no archive to test) and the error travels.
|
||||||
|
Err error
|
||||||
|
// Reason is the one-line human account of this verdict.
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders a verdict for the operator log / selftest output.
|
||||||
|
func (v DueVerdict) String() string {
|
||||||
|
return fmt.Sprintf("tier=%s due=%v archive=%q reason=%s", v.Target, v.Due, v.Archive, v.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateDue returns the due verdict for every configured tier, ordered oldest-proven first.
|
||||||
|
//
|
||||||
|
// Ordering is the R-85 rotation, demoted to a TIE-BREAK: it no longer decides whether a test
|
||||||
|
// happens (due-ness does), only which of several due tiers goes first. Keeping it means a tier can
|
||||||
|
// still never be starved — a tier that has waited longest is served first — and keeping it as the
|
||||||
|
// order rather than as the trigger is the whole of this change.
|
||||||
|
func (s *Scheduler) EvaluateDue(ctx context.Context) []DueVerdict {
|
||||||
|
if !s.rotating() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
order := s.tiers
|
||||||
|
if s.rtState != nil {
|
||||||
|
order = s.rtState.OldestFirst(s.tiers)
|
||||||
|
}
|
||||||
|
cutoff := s.settleCutoff()
|
||||||
|
out := make([]DueVerdict, 0, len(order))
|
||||||
|
for _, target := range order {
|
||||||
|
out = append(out, s.evaluateTier(ctx, target, cutoff))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleCutoff is the newest landing time an archive may have and still count as settled.
|
||||||
|
func (s *Scheduler) settleCutoff() time.Time {
|
||||||
|
if s.settle <= 0 {
|
||||||
|
return time.Time{} // no settle requirement configured → any archive is a candidate
|
||||||
|
}
|
||||||
|
return s.now().Add(-s.settle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateTier is the per-tier due-check. PURE given the picker and the state, so the rule is
|
||||||
|
// unit-tested directly rather than inferred from whether a fake runner happened to be called.
|
||||||
|
func (s *Scheduler) evaluateTier(ctx context.Context, target string, cutoff time.Time) DueVerdict {
|
||||||
|
v := DueVerdict{Target: target}
|
||||||
|
archive, landed, err := s.tierPick(ctx, target, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
// UNKNOWN, never "not due", and never silent.
|
||||||
|
v.Err = err
|
||||||
|
v.Reason = fmt.Sprintf("candidate lookup FAILED (%v) — tier is unknown this evaluation, not proven and not dismissed", err)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
v.Archive, v.Landed = archive, landed
|
||||||
|
if archive == "" {
|
||||||
|
v.Reason = "no settled archive yet — nothing to prove (newborn or still settling)"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
proven, ok := "", false
|
||||||
|
if s.rtState != nil {
|
||||||
|
proven, ok = s.rtState.ProvenArchive(target)
|
||||||
|
}
|
||||||
|
v.ProvenArchive = proven
|
||||||
|
if ok && proven == archive {
|
||||||
|
v.Reason = fmt.Sprintf("newest settled archive (landed %s) is already proven", landed.Format(time.RFC3339))
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
v.Due = true
|
||||||
|
switch {
|
||||||
|
case !ok && proven == "":
|
||||||
|
v.Reason = fmt.Sprintf("newest settled archive (landed %s) has not been proven; nothing proven on this tier yet", landed.Format(time.RFC3339))
|
||||||
|
default:
|
||||||
|
v.Reason = fmt.Sprintf("newest settled archive (landed %s) has not been proven (last proven archive was a different one)", landed.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateDueTier is EvaluateDue for ONE named tier — the selftest's per-tier cost probe, so the
|
||||||
|
// WAN leg of an offsite lookup is attributable rather than buried in an aggregate.
|
||||||
|
func (s *Scheduler) EvaluateDueTier(ctx context.Context, target string) DueVerdict {
|
||||||
|
return s.evaluateTier(ctx, target, s.settleCutoff())
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdictSummary renders one compact line of per-tier verdicts for the "nothing due" log.
|
||||||
|
//
|
||||||
|
// It re-evaluates rather than threading the verdicts out of pickForThisRun, and that is a
|
||||||
|
// deliberate trade: this runs only on the path where NOTHING is due, so the cost is one extra
|
||||||
|
// storage listing per tier on an otherwise idle evaluation (measured 18 ms local / 392 ms offsite,
|
||||||
|
// R-86 Part 1.4), and in exchange the logging path cannot drift from the deciding path by holding a
|
||||||
|
// stale copy of it. If that cost ever matters, pass the verdicts in — do not let the two diverge.
|
||||||
|
func (s *Scheduler) verdictSummary(ctx context.Context) string {
|
||||||
|
out := ""
|
||||||
|
for _, v := range s.EvaluateDue(ctx) {
|
||||||
|
if out != "" {
|
||||||
|
out += "; "
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Err != nil:
|
||||||
|
out += v.Target + ": UNKNOWN (" + v.Err.Error() + ")"
|
||||||
|
default:
|
||||||
|
out += v.Target + ": " + v.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
return "no tiers configured"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-86 — the restore-test follows the BACKUP, not the clock.
|
||||||
|
//
|
||||||
|
// Every test here DRIVES time (`s.now` is injected and stepped) rather than waiting for it. A test
|
||||||
|
// that slept could not say anything about a 24-hour rule in under 24 hours, and one that only
|
||||||
|
// asserted "no error" would pass against a scheduler that never ran anything at all — which is
|
||||||
|
// precisely the failure mode §8.1's trap produces. So the assertions are: did a test run, on WHICH
|
||||||
|
// archive, and did a second evaluation correctly run NOTHING.
|
||||||
|
|
||||||
|
// ── the fake tier storage ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// archiveStub is one archive on a tier: its volid and when it landed.
|
||||||
|
type archiveStub struct {
|
||||||
|
volid string
|
||||||
|
landed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// tierStorage is a TierPicker over per-tier archive lists. It implements the SAME contract as the
|
||||||
|
// production picker (*BackupRunner).PickSettledRestoreCandidateOn — newest archive that landed at
|
||||||
|
// or before the cutoff — which is itself covered against a fake PVE API in backup_test.go, and
|
||||||
|
// end-to-end by the live run. Naming the seam explicitly: everything below is true up to this
|
||||||
|
// picker; that the real picker obeys the same rule is asserted there, not here.
|
||||||
|
type tierStorage struct {
|
||||||
|
archives map[string][]archiveStub
|
||||||
|
err map[string]error // target → lookup failure
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *tierStorage) pick(_ context.Context, target string, notAfter time.Time) (string, time.Time, error) {
|
||||||
|
if e, ok := ts.err[target]; ok && e != nil {
|
||||||
|
return "", time.Time{}, e
|
||||||
|
}
|
||||||
|
var best archiveStub
|
||||||
|
for _, a := range ts.archives[target] {
|
||||||
|
if !notAfter.IsZero() && a.landed.After(notAfter) {
|
||||||
|
continue // not settled yet
|
||||||
|
}
|
||||||
|
if best.volid == "" || a.landed.After(best.landed) {
|
||||||
|
best = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best.volid, best.landed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueHarness is a scheduler with a driven clock over a fake tier storage.
|
||||||
|
type dueHarness struct {
|
||||||
|
s *Scheduler
|
||||||
|
rr *rotRunner
|
||||||
|
st *RestoreTestState
|
||||||
|
ts *tierStorage
|
||||||
|
clock time.Time
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDueHarness(t *testing.T, start time.Time, settle time.Duration, pass bool, tiers []string, ts *tierStorage) *dueHarness {
|
||||||
|
t.Helper()
|
||||||
|
return newDueHarnessAt(t, filepath.Join(t.TempDir(), "rt.json"), start, settle, pass, tiers, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDueHarnessAt(t *testing.T, statePath string, start time.Time, settle time.Duration, pass bool, tiers []string, ts *tierStorage) *dueHarness {
|
||||||
|
t.Helper()
|
||||||
|
h := &dueHarness{rr: &rotRunner{pass: pass}, ts: ts, clock: start, path: statePath}
|
||||||
|
h.st = NewRestoreTestState(statePath)
|
||||||
|
h.s = NewScheduler(SchedulerOptions{
|
||||||
|
Runner: h.rr,
|
||||||
|
Store: NewStore(),
|
||||||
|
Spec: func(_ context.Context, archive string) reconcile.RestoreTestSpec {
|
||||||
|
return reconcile.RestoreTestSpec{RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009}
|
||||||
|
},
|
||||||
|
Cadence: time.Hour,
|
||||||
|
Settle: settle,
|
||||||
|
Logger: quiet(),
|
||||||
|
Tiers: tiers,
|
||||||
|
TierPick: ts.pick,
|
||||||
|
State: h.st,
|
||||||
|
InFlight: &InFlight{},
|
||||||
|
})
|
||||||
|
h.s.now = func() time.Time { return h.clock }
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// advance steps the clock by step, evaluating once at every step — the scheduler's real shape.
|
||||||
|
func (h *dueHarness) advance(step, total time.Duration) {
|
||||||
|
for elapsed := time.Duration(0); elapsed < total; elapsed += step {
|
||||||
|
h.clock = h.clock.Add(step)
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var day0 = time.Date(2026, 8, 1, 2, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// dailyArchives lands one archive a day at 02:00 for n days, starting at day0.
|
||||||
|
func dailyArchives(tier string, n int) []archiveStub {
|
||||||
|
out := make([]archiveStub, 0, n)
|
||||||
|
for d := 0; d < n; d++ {
|
||||||
|
out = append(out, archiveStub{
|
||||||
|
volid: fmt.Sprintf("%s:backup/vzdump-lxc-9201-day%d.tar.zst", tier, d),
|
||||||
|
landed: day0.AddDate(0, 0, d),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO A — a daily tier is proved daily, on its own archive ────────────────────────────
|
||||||
|
//
|
||||||
|
// THE TRAP THIS PINS (§8.1). R-86 reads "trigger a tier ~24 h after its own newest archive", and
|
||||||
|
// the literal implementation of that — *due when the newest archive is at least `settle` old* — is
|
||||||
|
// NEVER true on a daily tier: a new archive lands every day, so the newest archive's age resets to
|
||||||
|
// zero long before it reaches 24 h. The literal reading silently switches restore-testing OFF for
|
||||||
|
// the tier that matters most.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed 2026-08-03). In Scheduler.evaluateTier, the per-archive comparison
|
||||||
|
// was replaced by the naive age rule:
|
||||||
|
//
|
||||||
|
// - if ok && proven == archive { … not due … }
|
||||||
|
// - if s.now().Sub(landed) < s.settle { … not due … } // and the proven-archive check deleted
|
||||||
|
//
|
||||||
|
// and the picker cutoff was removed (`cutoff := time.Time{}`), i.e. exactly "is the newest archive
|
||||||
|
// old enough". Result:
|
||||||
|
//
|
||||||
|
// --- FAIL: TestDue_DailyTierIsProvedDailyOnItsOwnArchive
|
||||||
|
// restoretest_due_test.go: a daily tier must be proved once per day; got 0 run(s) over 5 days
|
||||||
|
//
|
||||||
|
// Zero runs — restore-testing off. Restored immediately afterwards.
|
||||||
|
func TestDue_DailyTierIsProvedDailyOnItsOwnArchive(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"local": dailyArchives("local", 6)}}
|
||||||
|
h := newDueHarness(t, day0.Add(time.Hour), 24*time.Hour, true, []string{"local"}, ts)
|
||||||
|
|
||||||
|
// Five days, evaluated hourly.
|
||||||
|
h.advance(time.Hour, 5*24*time.Hour)
|
||||||
|
|
||||||
|
got := h.rr.seen()
|
||||||
|
if len(got) != 5 {
|
||||||
|
t.Fatalf("a daily tier must be proved once per day; got %d run(s) over 5 days: %v", len(got), got)
|
||||||
|
}
|
||||||
|
// And each run must be on the archive that settled that day — day0's on day 1, and so on.
|
||||||
|
for i, a := range got {
|
||||||
|
want := fmt.Sprintf("local:backup/vzdump-lxc-9201-day%d.tar.zst", i)
|
||||||
|
if a != want {
|
||||||
|
t.Fatalf("run %d tested %q, want %q — the test is not following the archive", i+1, a, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The newest archive is NEVER the one tested: it has not settled.
|
||||||
|
if last := got[len(got)-1]; last == "local:backup/vzdump-lxc-9201-day5.tar.zst" {
|
||||||
|
t.Fatal("the still-settling archive was tested — the settle cutoff is not being applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO B — a weekly tier is proved weekly, not every other day ─────────────────────────
|
||||||
|
func TestDue_WeeklyTierIsProvedOncePerArchive(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"felhom-pbs": {
|
||||||
|
{volid: "felhom-pbs:backup/ct/9201/w0", landed: day0},
|
||||||
|
{volid: "felhom-pbs:backup/ct/9201/w1", landed: day0.AddDate(0, 0, 7)},
|
||||||
|
{volid: "felhom-pbs:backup/ct/9201/w2", landed: day0.AddDate(0, 0, 14)},
|
||||||
|
}}}
|
||||||
|
h := newDueHarness(t, day0.Add(time.Hour), 24*time.Hour, true, []string{"felhom-pbs"}, ts)
|
||||||
|
|
||||||
|
// Three weeks, evaluated every 6 hours — 84 evaluations.
|
||||||
|
h.advance(6*time.Hour, 21*24*time.Hour)
|
||||||
|
|
||||||
|
got := h.rr.seen()
|
||||||
|
want := []string{
|
||||||
|
"felhom-pbs:backup/ct/9201/w0",
|
||||||
|
"felhom-pbs:backup/ct/9201/w1",
|
||||||
|
"felhom-pbs:backup/ct/9201/w2",
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("a weekly tier must be proved ONCE PER ARCHIVE (3 archives over 3 weeks); got %d run(s): %v", len(got), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("run %d tested %q, want %q", i+1, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO C — an agent restart does not change the schedule ───────────────────────────────
|
||||||
|
//
|
||||||
|
// This is the defect a person actually notices: today every deploy restarts the ticker, so a
|
||||||
|
// restore-test runs one interval after each deploy regardless of what has already been proven.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed 2026-08-03): revert the state to per-tier TIME by making
|
||||||
|
// ProvenArchive ignore the stored archive —
|
||||||
|
//
|
||||||
|
// - if !ok || p.Archive == "" { return "", false }
|
||||||
|
// - return "", false // per-tier time only, the pre-R-86 state
|
||||||
|
//
|
||||||
|
// → --- FAIL: TestDue_RestartRunsNothing
|
||||||
|
//
|
||||||
|
// restoretest_due_test.go:226: an agent restart must not trigger a restore-test; 2 restart(s)
|
||||||
|
// produced 4 run(s)
|
||||||
|
//
|
||||||
|
// Four: the same already-proven archive re-tested on EVERY evaluation after EVERY restart, which is
|
||||||
|
// today's behaviour with the ticker's phase reset by the deploy. Restored.
|
||||||
|
func TestDue_RestartRunsNothing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "rt.json")
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"local": dailyArchives("local", 2)}}
|
||||||
|
start := day0.AddDate(0, 0, 1).Add(time.Hour) // day 1, 03:00 — day0's archive has settled
|
||||||
|
|
||||||
|
h := newDueHarnessAt(t, path, start, 24*time.Hour, true, []string{"local"}, ts)
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if n := len(h.rr.seen()); n != 1 {
|
||||||
|
t.Fatalf("precondition: the settled archive should have been proved once; got %d run(s)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- two restarts: brand-new scheduler + brand-new state object over the SAME file ---
|
||||||
|
total := 0
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
h2 := newDueHarnessAt(t, path, start.Add(time.Duration(i+1)*time.Hour), 24*time.Hour, true, []string{"local"}, ts)
|
||||||
|
h2.s.tick(context.Background())
|
||||||
|
h2.s.tick(context.Background())
|
||||||
|
total += len(h2.rr.seen())
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("an agent restart must not trigger a restore-test; 2 restart(s) produced %d run(s)", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO D — a new archive makes a tier due even if it was tested yesterday ──────────────
|
||||||
|
func TestDue_NewSettledArchiveMakesAProvedTierDueAgain(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"local": dailyArchives("local", 2)}}
|
||||||
|
h := newDueHarness(t, day0.AddDate(0, 0, 1).Add(time.Hour), 24*time.Hour, true, []string{"local"}, ts)
|
||||||
|
|
||||||
|
h.s.tick(context.Background()) // proves day0's archive
|
||||||
|
h.s.tick(context.Background()) // nothing new has settled → nothing
|
||||||
|
if n := len(h.rr.seen()); n != 1 {
|
||||||
|
t.Fatalf("want exactly 1 run before the new archive settles, got %d: %v", n, h.rr.seen())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day 2, 03:00 — day1's archive has now settled.
|
||||||
|
h.clock = day0.AddDate(0, 0, 2).Add(time.Hour)
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
|
||||||
|
got := h.rr.seen()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("a newly settled archive must make the tier due again; got %v", got)
|
||||||
|
}
|
||||||
|
if got[1] != "local:backup/vzdump-lxc-9201-day1.tar.zst" {
|
||||||
|
t.Fatalf("the NEW archive must be the one tested; got %q", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO E — a failing tier keeps being retried, and earns no proof ──────────────────────
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed 2026-08-03): give credit on failure in Scheduler.tick —
|
||||||
|
//
|
||||||
|
// - if rt.Pass && s.rtState != nil && target != "" {
|
||||||
|
// - if s.rtState != nil && target != "" {
|
||||||
|
//
|
||||||
|
// → --- FAIL: TestDue_FailingTierIsRetriedAndNeverProven
|
||||||
|
//
|
||||||
|
// restoretest_due_test.go: a failing tier must keep being retried; got 1 run(s) over 3
|
||||||
|
// evaluations
|
||||||
|
//
|
||||||
|
// A single failure would have retired the archive as proven — a permanently broken DR tier looking
|
||||||
|
// freshly verified, which is the loudest signal this system produces going silent. Restored.
|
||||||
|
func TestDue_FailingTierIsRetriedAndNeverProven(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"local": dailyArchives("local", 1)}}
|
||||||
|
h := newDueHarness(t, day0.AddDate(0, 0, 1).Add(time.Hour), 24*time.Hour, false, []string{"local"}, ts)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
got := h.rr.seen()
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("a failing tier must keep being retried; got %d run(s) over 3 evaluations: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if _, ok := h.st.ProvenArchive("local"); ok {
|
||||||
|
t.Fatal("a FAILED restore-test must not record the archive as proven")
|
||||||
|
}
|
||||||
|
if _, ok := h.st.LastSuccess("local"); ok {
|
||||||
|
t.Fatal("a FAILED restore-test must not stamp the tier as proven")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO F — two tiers due at once do not run at once ────────────────────────────────────
|
||||||
|
func TestDue_TwoDueTiersRunOneAtATime(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{
|
||||||
|
"local": {{volid: "local:backup/a.tar.zst", landed: day0}},
|
||||||
|
"felhom-pbs": {{volid: "felhom-pbs:backup/ct/9201/a", landed: day0}},
|
||||||
|
}}
|
||||||
|
h := newDueHarness(t, day0.AddDate(0, 0, 1).Add(time.Hour), 24*time.Hour, true, []string{"local", "felhom-pbs"}, ts)
|
||||||
|
|
||||||
|
// Both tiers are due at this instant.
|
||||||
|
due := h.s.EvaluateDue(context.Background())
|
||||||
|
if len(due) != 2 || !due[0].Due || !due[1].Due {
|
||||||
|
t.Fatalf("precondition: both tiers should be due; got %v", due)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if n := len(h.rr.seen()); n != 1 {
|
||||||
|
t.Fatalf("ONE evaluation must start ONE restore-test, never two multi-GB restores over one link; got %d: %v", n, h.rr.seen())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other tier was DEFERRED, not cancelled: it is still due and runs on the next evaluation.
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
got := h.rr.seen()
|
||||||
|
if len(got) != 2 || got[0] == got[1] {
|
||||||
|
t.Fatalf("the deferred tier must run on the NEXT evaluation, on its own archive; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The heavy-operation gate still holds, and a tier deferred behind a backup stays DUE.
|
||||||
|
func TestDue_DeferredBehindABackupStaysDue(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"local": {{volid: "local:backup/a.tar.zst", landed: day0}}}}
|
||||||
|
h := newDueHarness(t, day0.AddDate(0, 0, 1).Add(time.Hour), 24*time.Hour, true, []string{"local"}, ts)
|
||||||
|
|
||||||
|
gate := &InFlight{}
|
||||||
|
h.s.inFlight = gate
|
||||||
|
release, _, _ := gate.TryAcquire("backup:felhom-pbs")
|
||||||
|
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if n := len(h.rr.seen()); n != 0 {
|
||||||
|
t.Fatalf("the restore-test must DEFER while a backup holds the gate; got %d run(s)", n)
|
||||||
|
}
|
||||||
|
if due := h.s.EvaluateDue(context.Background()); !due[0].Due {
|
||||||
|
t.Fatal("a deferred tier must remain DUE — deferral is not dismissal")
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if n := len(h.rr.seen()); n != 1 {
|
||||||
|
t.Fatalf("must resume once the gate frees; got %d run(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO H — a newborn box is UNKNOWN, not stale and not a fault ─────────────────────────
|
||||||
|
func TestDue_NewbornTierIsNotDueAndNotAnError(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"felhom-pbs": nil}}
|
||||||
|
h := newDueHarness(t, day0, 24*time.Hour, true, []string{"felhom-pbs"}, ts)
|
||||||
|
|
||||||
|
due := h.s.EvaluateDue(context.Background())
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("want one verdict, got %v", due)
|
||||||
|
}
|
||||||
|
v := due[0]
|
||||||
|
if v.Due || v.Err != nil || v.Archive != "" {
|
||||||
|
t.Fatalf("a tier with no archive is UNKNOWN — not due, not an error; got %+v", v)
|
||||||
|
}
|
||||||
|
if v.Reason == "" {
|
||||||
|
t.Fatal("every verdict must carry a reason — a due-check that cannot say why is a quiet path")
|
||||||
|
}
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if n := len(h.rr.seen()); n != 0 {
|
||||||
|
t.Fatalf("a newborn tier must not be restore-tested; got %d run(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An archive that exists but has NOT settled yet is not a candidate — and that is not an error.
|
||||||
|
func TestDue_UnsettledArchiveIsNotACandidate(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{"local": {{volid: "local:backup/fresh.tar.zst", landed: day0}}}}
|
||||||
|
h := newDueHarness(t, day0.Add(2*time.Hour), 24*time.Hour, true, []string{"local"}, ts)
|
||||||
|
|
||||||
|
if v := h.s.EvaluateDue(context.Background())[0]; v.Due || v.Archive != "" {
|
||||||
|
t.Fatalf("an archive 2h old must not be a candidate under a 24h settle lag; got %+v", v)
|
||||||
|
}
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if n := len(h.rr.seen()); n != 0 {
|
||||||
|
t.Fatalf("nothing settled → no run; got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tier whose archives cannot be LISTED is UNKNOWN — never silently "not due", and never silent.
|
||||||
|
// Treating a lookup failure as "not due" would retire a tier the moment its storage stopped
|
||||||
|
// answering, which is the same absence-is-not-evidence error this monitor family keeps making.
|
||||||
|
func TestDue_LookupFailureIsUnknownNotNotDue(t *testing.T) {
|
||||||
|
boom := errors.New("storage unreachable")
|
||||||
|
ts := &tierStorage{
|
||||||
|
archives: map[string][]archiveStub{"local": {{volid: "local:backup/a.tar.zst", landed: day0}}},
|
||||||
|
err: map[string]error{"felhom-pbs": boom},
|
||||||
|
}
|
||||||
|
h := newDueHarness(t, day0.AddDate(0, 0, 1), 24*time.Hour, true, []string{"local", "felhom-pbs"}, ts)
|
||||||
|
|
||||||
|
var pbs DueVerdict
|
||||||
|
for _, v := range h.s.EvaluateDue(context.Background()) {
|
||||||
|
if v.Target == "felhom-pbs" {
|
||||||
|
pbs = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pbs.Err == nil {
|
||||||
|
t.Fatal("a lookup failure must travel in the verdict, not be swallowed")
|
||||||
|
}
|
||||||
|
if pbs.Due {
|
||||||
|
t.Fatal("a tier we could not list must not be reported DUE — we have no archive to test")
|
||||||
|
}
|
||||||
|
if pbs.Reason == "" {
|
||||||
|
t.Fatal("the failure must be explained, not merely flagged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the OTHER tier still runs: one tier's storage being unreadable must not cost the other
|
||||||
|
// tier its proof.
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
if got := h.rr.seen(); len(got) != 1 || got[0] != "local:backup/a.tar.zst" {
|
||||||
|
t.Fatalf("the readable tier must still be proved; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the state's migration (§8.2) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// A pre-R-86 state file carries a TIME and no archive. It must keep its time (rotation ordering
|
||||||
|
// survives the upgrade) and yield NO proven archive, so each tier is due exactly once. Reading a
|
||||||
|
// legacy time as proof of the CURRENT archive would mark an unproven archive proven — a guarantee
|
||||||
|
// invented by a migration.
|
||||||
|
func TestRestoreTestState_LegacyFileMigratesToNothingProven(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "rt.json")
|
||||||
|
legacy := `{"local":"2026-08-01T02:00:00Z","felhom-pbs":"2026-07-30T02:00:00Z"}`
|
||||||
|
if err := writeFileForTest(path, legacy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st := NewRestoreTestState(path)
|
||||||
|
if _, ok := st.ProvenArchive("local"); ok {
|
||||||
|
t.Fatal("a legacy record names no archive — it must NOT be read as proof of the current one")
|
||||||
|
}
|
||||||
|
at, ok := st.LastSuccess("local")
|
||||||
|
if !ok || !at.Equal(time.Date(2026, 8, 1, 2, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Fatalf("the legacy TIME must survive (rotation ordering depends on it); got %v ok=%v", at, ok)
|
||||||
|
}
|
||||||
|
// Ordering still works off the legacy times.
|
||||||
|
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "felhom-pbs" {
|
||||||
|
t.Fatalf("oldest-first must still order legacy records; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new shape round-trips, archive and all.
|
||||||
|
func TestRestoreTestState_ArchiveRoundTrips(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "rt.json")
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
st := NewRestoreTestState(path)
|
||||||
|
if err := st.RecordSuccess("felhom-pbs", "felhom-pbs:backup/ct/9201/x", now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
re := NewRestoreTestState(path)
|
||||||
|
got, ok := re.ProvenArchive("felhom-pbs")
|
||||||
|
if !ok || got != "felhom-pbs:backup/ct/9201/x" {
|
||||||
|
t.Fatalf("the proven ARCHIVE must survive a restart; got %q ok=%v", got, ok)
|
||||||
|
}
|
||||||
|
at, ok := re.LastSuccess("felhom-pbs")
|
||||||
|
if !ok || !at.Equal(now) {
|
||||||
|
t.Fatalf("the proven TIME must survive too; got %v ok=%v", at, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileForTest is a tiny helper so the legacy-migration fixture reads clearly above.
|
||||||
|
func writeFileForTest(path, content string) error {
|
||||||
|
return os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standing rule 3: an absent log line is not evidence. "Nothing is due" is now the NORMAL outcome of
|
||||||
|
// an evaluation, so it must produce a POSITIVE observable naming each tier's verdict — otherwise a
|
||||||
|
// quiet journal is equally consistent with a healthy loop and a dead goroutine.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed 2026-08-03): drop the summary back to a bare
|
||||||
|
// `s.logger.Debug("backup: restore-test not due this evaluation")` and this fails with
|
||||||
|
// "a not-due evaluation must name each tier's verdict; got \"\"" — i.e. nothing at INFO at all.
|
||||||
|
func TestDue_NothingDueStillNamesEveryTiersVerdict(t *testing.T) {
|
||||||
|
ts := &tierStorage{archives: map[string][]archiveStub{
|
||||||
|
"local": {{volid: "local:backup/a.tar.zst", landed: day0}},
|
||||||
|
"felhom-pbs": nil, // no archive at all
|
||||||
|
}}
|
||||||
|
h := newDueHarness(t, day0.AddDate(0, 0, 1), 24*time.Hour, true, []string{"local", "felhom-pbs"}, ts)
|
||||||
|
// Prove the local tier so NOTHING is due.
|
||||||
|
if err := h.st.RecordSuccess("local", "local:backup/a.tar.zst", h.clock); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert what the SCHEDULER emits on a real evaluation, not what a helper returns — a helper
|
||||||
|
// test would pass against a tick that never calls it.
|
||||||
|
var logbuf strings.Builder
|
||||||
|
h.s.logger = slog.New(slog.NewTextHandler(&logbuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
h.s.tick(context.Background())
|
||||||
|
got := logbuf.String()
|
||||||
|
for _, want := range []string{"local", "felhom-pbs", "already proven", "no settled archive"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("a not-due evaluation must name each tier's verdict; got %q (missing %q)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tier whose storage cannot be listed must say UNKNOWN in that same line — a lookup failure that
|
||||||
|
// reads as "nothing due" is the silence this rule exists to prevent.
|
||||||
|
func TestDue_VerdictSummaryNamesAnUnknownTier(t *testing.T) {
|
||||||
|
ts := &tierStorage{
|
||||||
|
archives: map[string][]archiveStub{"local": nil},
|
||||||
|
err: map[string]error{"felhom-pbs": errors.New("storage unreachable")},
|
||||||
|
}
|
||||||
|
h := newDueHarness(t, day0, 24*time.Hour, true, []string{"local", "felhom-pbs"}, ts)
|
||||||
|
got := h.s.verdictSummary(context.Background())
|
||||||
|
if !strings.Contains(got, "UNKNOWN") || !strings.Contains(got, "storage unreachable") {
|
||||||
|
t.Fatalf("an unlistable tier must read as UNKNOWN with its error; got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreTestState persists the last SUCCESSFUL restore-test per backup tier.
|
||||||
|
//
|
||||||
|
// R-85 (1.4). This one genuinely needs PERSISTENCE, unlike R-84 — and the difference is worth
|
||||||
|
// stating, because the two look like the same problem and are not:
|
||||||
|
//
|
||||||
|
// - R-84 (backup freshness) had a GROUND TRUTH to consult: the archive is still on the storage,
|
||||||
|
// so the agent could ask "when did a backup last land?" and never persist anything. That is
|
||||||
|
// strictly better, because a pruned archive correctly stops counting.
|
||||||
|
// - A restore-test leaves NO artifact — the scratch guest is destroyed as its final act. There is
|
||||||
|
// nothing to query. "Did we prove this tier restores?" exists only as remembered state, so it
|
||||||
|
// must be written down or it is lost.
|
||||||
|
//
|
||||||
|
// Why it must survive a restart: rotation is oldest-first (the operator ruling), so an in-memory map
|
||||||
|
// would reset every tier to "never tested" on each restart. Ordering would then depend on map
|
||||||
|
// iteration order, and one tier could be starved indefinitely while the other is re-tested — with
|
||||||
|
// agent deploys as routine as they are, that is not a corner case.
|
||||||
|
//
|
||||||
|
// Only SUCCESS is recorded. A failed run must not satisfy rotation, or a tier that fails every time
|
||||||
|
// would look freshly proven and stop being retried — the same "a failure satisfies the cadence"
|
||||||
|
// trap the backup due-check avoids. R-86 keeps that property unchanged and gives it a second job:
|
||||||
|
// the due-check reads this state, so a failure that recorded proof would ALSO stop the tier from
|
||||||
|
// ever becoming due again. The rule earns its keep twice now.
|
||||||
|
//
|
||||||
|
// R-86 (1.2) — WHICH ARCHIVE, not just when.
|
||||||
|
//
|
||||||
|
// A timestamp alone cannot answer the question the due-check asks. "This tier passed at 04:00" is
|
||||||
|
// consistent both with "yesterday's archive is proven" and with "an archive from a week ago is
|
||||||
|
// proven and nothing since has been looked at". Restore-testing is now per ARCHIVE GENERATION —
|
||||||
|
// a tier is due once it holds a settled archive that has not been proven — so the identity of the
|
||||||
|
// proven archive is the state, and the time is metadata (rotation ordering, operator reporting).
|
||||||
|
//
|
||||||
|
// This is the same class as the workspace rule "a timestamp records an ATTEMPT, not a RESULT":
|
||||||
|
// here it records a result, but not WHICH result, and that is just as unable to answer the question
|
||||||
|
// being asked of it.
|
||||||
|
type RestoreTestState struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
last map[string]provenTier // target id → what was last PROVEN on that tier
|
||||||
|
}
|
||||||
|
|
||||||
|
// provenTier is one tier's proof: the archive that passed, and when it passed.
|
||||||
|
type provenTier struct {
|
||||||
|
Archive string // volid of the archive that PASSED; "" = a legacy record with no archive
|
||||||
|
At time.Time // when that run passed (UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// provenTierJSON is the on-disk shape (R-86). The legacy shape was a bare RFC3339 STRING per
|
||||||
|
// target; both are read, only this one is written — see NewRestoreTestState.
|
||||||
|
type provenTierJSON struct {
|
||||||
|
Archive string `json:"archive"`
|
||||||
|
ProvenAt string `json:"proven_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRestoreTestState opens (or creates) the state at path. A missing or unreadable file is NOT an
|
||||||
|
// error: it degrades to "nothing proven yet", which is the correct starting point and keeps a
|
||||||
|
// corrupt file from wedging the daemon.
|
||||||
|
//
|
||||||
|
// MIGRATION (R-86). The pre-R-86 file is `{"<target>": "<RFC3339>"}` — a time and no archive. A
|
||||||
|
// legacy record keeps its TIME (rotation ordering survives a deploy, which is why the file exists
|
||||||
|
// at all) but yields NO proven archive, so every tier is due exactly once on first evaluation after
|
||||||
|
// the upgrade. One extra restore-test per tier, once, is the safe direction: the alternative is to
|
||||||
|
// read a legacy time as proof of whatever archive happens to be current, which would mark an
|
||||||
|
// unproven archive proven — inventing a guarantee out of a migration.
|
||||||
|
func NewRestoreTestState(path string) *RestoreTestState {
|
||||||
|
s := &RestoreTestState{path: path, last: map[string]provenTier{}}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(data, &raw) != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for target, msg := range raw {
|
||||||
|
// Legacy shape: a bare RFC3339 string.
|
||||||
|
var legacy string
|
||||||
|
if json.Unmarshal(msg, &legacy) == nil {
|
||||||
|
if t, perr := time.Parse(time.RFC3339, legacy); perr == nil {
|
||||||
|
s.last[target] = provenTier{At: t.UTC()} // no archive → due once, deliberately
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var cur provenTierJSON
|
||||||
|
if json.Unmarshal(msg, &cur) != nil {
|
||||||
|
continue // one unreadable entry must not lose the others
|
||||||
|
}
|
||||||
|
t, perr := time.Parse(time.RFC3339, cur.ProvenAt)
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.last[target] = provenTier{Archive: cur.Archive, At: t.UTC()}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess stamps a tier as proven at t, naming the ARCHIVE that passed. Only call this for a
|
||||||
|
// PASSING restore-test — the archive is what makes the tier not-due, so recording one for a failed
|
||||||
|
// run would retire the archive unproven.
|
||||||
|
func (s *RestoreTestState) RecordSuccess(target, archive string, t time.Time) error {
|
||||||
|
if target == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.last[target] = provenTier{Archive: archive, At: t.UTC()}
|
||||||
|
return s.saveLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSuccess returns when this tier was last proven (ok=false = never).
|
||||||
|
func (s *RestoreTestState) LastSuccess(target string) (time.Time, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
p, ok := s.last[target]
|
||||||
|
return p.At, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvenArchive returns the archive last PROVEN on this tier (ok=false = none — either never tested,
|
||||||
|
// or a legacy record carrying only a time). It is the due-check's whole question: an archive that is
|
||||||
|
// not this one has not been proven.
|
||||||
|
func (s *RestoreTestState) ProvenArchive(target string) (string, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
p, ok := s.last[target]
|
||||||
|
if !ok || p.Archive == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return p.Archive, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a copy of the last-proven TIMES — for the host-report gauge.
|
||||||
|
func (s *RestoreTestState) Snapshot() map[string]time.Time {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make(map[string]time.Time, len(s.last))
|
||||||
|
for k, v := range s.last {
|
||||||
|
out[k] = v.At
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OldestFirst orders targets by "least recently proven first"; never-proven sorts FIRST.
|
||||||
|
//
|
||||||
|
// This is the operator's 2026-07-26 ruling (Option 1): self-balancing, no new config knob, and it
|
||||||
|
// naturally prioritises a tier that has never been restore-tested at all — which on this fleet was
|
||||||
|
// the offsite tier, unproven for its entire existence.
|
||||||
|
//
|
||||||
|
// Ties break on target id so the order is deterministic; without that, two tiers proven in the same
|
||||||
|
// second would rotate by map iteration order, which is randomised in Go and would make the
|
||||||
|
// behaviour untestable and occasionally starving.
|
||||||
|
func (s *RestoreTestState) OldestFirst(targets []string) []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := append([]string(nil), targets...)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
pi, oki := s.last[out[i]]
|
||||||
|
pj, okj := s.last[out[j]]
|
||||||
|
ti, tj := pi.At, pj.At
|
||||||
|
switch {
|
||||||
|
case !oki && !okj:
|
||||||
|
return out[i] < out[j] // both never proven → deterministic
|
||||||
|
case !oki:
|
||||||
|
return true // never proven wins
|
||||||
|
case !okj:
|
||||||
|
return false
|
||||||
|
case !ti.Equal(tj):
|
||||||
|
return ti.Before(tj)
|
||||||
|
default:
|
||||||
|
return out[i] < out[j]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RestoreTestState) saveLocked() error {
|
||||||
|
raw := make(map[string]provenTierJSON, len(s.last))
|
||||||
|
for target, p := range s.last {
|
||||||
|
raw[target] = provenTierJSON{Archive: p.Archive, ProvenAt: p.At.UTC().Format(time.RFC3339)}
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(raw, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-85 Phase 2 — tier rotation, persisted state, and the one-heavy-operation gate.
|
||||||
|
//
|
||||||
|
// The failure this prevents is not hypothetical: demo-hp's DR tier reported `applied` with ZERO
|
||||||
|
// snapshots for five days and nobody noticed, because the scheduler could only ever see the primary
|
||||||
|
// tier. Rotation is what makes the offsite tier testable at all.
|
||||||
|
|
||||||
|
// rotRunner records which archives it was asked to restore.
|
||||||
|
type rotRunner struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
archives []string
|
||||||
|
pass bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rotRunner) RunRestoreTest(_ context.Context, spec reconcile.RestoreTestSpec) reconcile.RestoreTestResult {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.archives = append(r.archives, spec.Archive)
|
||||||
|
return reconcile.RestoreTestResult{
|
||||||
|
Archive: spec.Archive, SourceTier: spec.SourceTier,
|
||||||
|
Pass: r.pass, Verified: "boot+running",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (r *rotRunner) seen() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return append([]string(nil), r.archives...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testLanded is a landing time old enough to be settled under any cutoff these tests use. R-86
|
||||||
|
// widened the TierPicker seam with the archive's landing time; the rotation tests below are about
|
||||||
|
// tier ORDER and the heavy-operation gate, not about settling, so they hold it constant.
|
||||||
|
var testLanded = time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// archiveFor is a TierPicker over a fixed map: target → archive ("" = that tier holds none).
|
||||||
|
func archiveFor(m map[string]string) TierPicker {
|
||||||
|
return func(_ context.Context, target string, _ time.Time) (string, time.Time, error) {
|
||||||
|
a := m[target]
|
||||||
|
if a == "" {
|
||||||
|
return "", time.Time{}, nil
|
||||||
|
}
|
||||||
|
return a, testLanded, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotScheduler(t *testing.T, rr *rotRunner, st *RestoreTestState, pick TierPicker, gate *InFlight) *Scheduler {
|
||||||
|
t.Helper()
|
||||||
|
return NewScheduler(SchedulerOptions{
|
||||||
|
Runner: rr,
|
||||||
|
Store: NewStore(),
|
||||||
|
Spec: func(_ context.Context, archive string) reconcile.RestoreTestSpec {
|
||||||
|
return reconcile.RestoreTestSpec{RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009}
|
||||||
|
},
|
||||||
|
Cadence: time.Hour,
|
||||||
|
Logger: quiet(),
|
||||||
|
Tiers: []string{"local", "felhom-pbs"},
|
||||||
|
TierPick: pick,
|
||||||
|
State: st,
|
||||||
|
InFlight: gate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO A — both tiers get tested, each ONCE per archive ────────────────────────────────
|
||||||
|
//
|
||||||
|
// R-86 CHANGED THIS TEST'S CONTRACT, deliberately, and the old assertion is worth recording because
|
||||||
|
// it was a faithful statement of the defect. It read:
|
||||||
|
//
|
||||||
|
// 4 ticks → 4 runs, and consecutive runs must hit different tiers
|
||||||
|
//
|
||||||
|
// i.e. every tick produced a heavy restore-test, because the ticker WAS the trigger. Under R-86 a
|
||||||
|
// tick is an EVALUATION: both tiers are still exercised (rotation is intact), but a tier whose
|
||||||
|
// newest settled archive is already proven is not re-tested just because time passed. So the
|
||||||
|
// assertion is now 2 runs across 4 evaluations — one per tier, one per archive — which is a
|
||||||
|
// STRICTLY STRONGER statement: it pins both the coverage R-85 won and the pacing R-86 adds.
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed): restore the single-target picker — set `Tiers`/`TierPick` to nil
|
||||||
|
// so `pickForThisRun` falls back to `s.pick` on the primary runner — and this fails with
|
||||||
|
// "both tiers must be exercised; got [local:…]", i.e. the offsite tier never appears. That is
|
||||||
|
// pre-R-85 behaviour, and it is why demo-hp's DR tier went unproven for its entire existence.
|
||||||
|
func TestRotation_BothTiersExercisedOncePerArchive(t *testing.T) {
|
||||||
|
rr := &rotRunner{pass: true}
|
||||||
|
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
|
||||||
|
s := rotScheduler(t, rr, st, archiveFor(map[string]string{
|
||||||
|
"local": "local:backup/vzdump-lxc-9201-x.tar.zst",
|
||||||
|
"felhom-pbs": "felhom-pbs:backup/ct/9201/2026-07-26T15:42:42Z",
|
||||||
|
}), &InFlight{})
|
||||||
|
s.now = func() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
s.tick(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
got := rr.seen()
|
||||||
|
var sawLocal, sawPBS bool
|
||||||
|
for _, a := range got {
|
||||||
|
if len(a) >= 5 && a[:5] == "local" {
|
||||||
|
sawLocal = true
|
||||||
|
}
|
||||||
|
if len(a) >= 10 && a[:10] == "felhom-pbs" {
|
||||||
|
sawPBS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawLocal || !sawPBS {
|
||||||
|
t.Fatalf("both tiers must be exercised; got %v", got)
|
||||||
|
}
|
||||||
|
// Exactly one run per tier: the archives never changed, so nothing became due a second time.
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 runs across 4 evaluations (one per archive generation), got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] == got[1] {
|
||||||
|
t.Fatalf("the two runs must be different tiers — oldest-first is not ordering due tiers: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tier with NO archive is skipped, not failed, and the other tier still runs. A brand-new offsite
|
||||||
|
// tier legitimately has nothing to restore; turning that into a failure would make every fresh box
|
||||||
|
// look broken for its first week.
|
||||||
|
func TestRotation_EmptyTierSkippedNotFailed(t *testing.T) {
|
||||||
|
rr := &rotRunner{pass: true}
|
||||||
|
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
|
||||||
|
s := rotScheduler(t, rr, st, archiveFor(map[string]string{
|
||||||
|
"local": "local:backup/vzdump-lxc-9201-x.tar.zst",
|
||||||
|
"felhom-pbs": "", // provisioned but empty
|
||||||
|
}), &InFlight{})
|
||||||
|
|
||||||
|
s.tick(context.Background())
|
||||||
|
got := rr.seen()
|
||||||
|
if len(got) != 1 || got[0][:5] != "local" {
|
||||||
|
t.Fatalf("an empty tier must be skipped and the testable one still run; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing testable anywhere → a clean no-op, not an error and not a run.
|
||||||
|
func TestRotation_NoArchivesAnywhereIsANoOp(t *testing.T) {
|
||||||
|
rr := &rotRunner{pass: true}
|
||||||
|
s := rotScheduler(t, rr, NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json")),
|
||||||
|
archiveFor(map[string]string{}), &InFlight{})
|
||||||
|
s.tick(context.Background())
|
||||||
|
if got := rr.seen(); len(got) != 0 {
|
||||||
|
t.Fatalf("no archives anywhere → no run; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A FAILED restore-test must NOT earn rotation credit, or a tier that fails every time would look
|
||||||
|
// freshly proven and quietly stop being retried.
|
||||||
|
func TestRotation_FailureEarnsNoCredit(t *testing.T) {
|
||||||
|
rr := &rotRunner{pass: false}
|
||||||
|
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
|
||||||
|
s := rotScheduler(t, rr, st, archiveFor(map[string]string{
|
||||||
|
"local": "local:backup/x.tar.zst",
|
||||||
|
"felhom-pbs": "felhom-pbs:backup/ct/9201/y",
|
||||||
|
}), &InFlight{})
|
||||||
|
s.tick(context.Background())
|
||||||
|
if _, ok := st.LastSuccess("local"); ok {
|
||||||
|
t.Fatal("a FAILED restore-test must not stamp the tier as proven")
|
||||||
|
}
|
||||||
|
if _, ok := st.LastSuccess("felhom-pbs"); ok {
|
||||||
|
t.Fatal("a FAILED restore-test must not stamp the tier as proven")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO E — rotation survives a restart ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed): make the state in-memory (construct a fresh
|
||||||
|
// `NewRestoreTestState` on a DIFFERENT path for the second scheduler, i.e. lose the file) and this
|
||||||
|
// fails with "after a restart the OTHER tier must be next; got felhom-pbs" — the same tier repeats
|
||||||
|
// and the other is starved indefinitely, which with agent deploys as routine as they are is not a
|
||||||
|
// corner case.
|
||||||
|
func TestRotation_SurvivesRestart(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "rt.json")
|
||||||
|
picks := archiveFor(map[string]string{
|
||||||
|
"local": "local:backup/x.tar.zst",
|
||||||
|
"felhom-pbs": "felhom-pbs:backup/ct/9201/y",
|
||||||
|
})
|
||||||
|
|
||||||
|
// First process: the OFFSITE tier is tested (never-proven sorts first).
|
||||||
|
rr1 := &rotRunner{pass: true}
|
||||||
|
st1 := NewRestoreTestState(path)
|
||||||
|
s1 := rotScheduler(t, rr1, st1, picks, &InFlight{})
|
||||||
|
s1.tick(context.Background())
|
||||||
|
first := rr1.seen()
|
||||||
|
if len(first) != 1 {
|
||||||
|
t.Fatalf("want one run, got %v", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- restart: brand-new state object reading the SAME file ---
|
||||||
|
rr2 := &rotRunner{pass: true}
|
||||||
|
st2 := NewRestoreTestState(path)
|
||||||
|
s2 := rotScheduler(t, rr2, st2, picks, &InFlight{})
|
||||||
|
s2.tick(context.Background())
|
||||||
|
second := rr2.seen()
|
||||||
|
if len(second) != 1 {
|
||||||
|
t.Fatalf("want one run after restart, got %v", second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if second[0] == first[0] {
|
||||||
|
t.Fatalf("after a restart the OTHER tier must be next; got %s twice (rotation state was lost)", second[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO F — no collision with a backup ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// COMPANION RED-PROOF (observed): drop the TryAcquire guard from `tick` and this fails with
|
||||||
|
// "the restore-test must DEFER while a backup holds the gate; concurrent operations = 2" — the
|
||||||
|
// count is the assertion, since "both completed" would pass against a fully concurrent
|
||||||
|
// implementation.
|
||||||
|
func TestRotation_DefersWhileABackupHoldsTheGate(t *testing.T) {
|
||||||
|
gate := &InFlight{}
|
||||||
|
release, _, ok := gate.TryAcquire("backup:felhom-pbs")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("precondition: the gate should have been free")
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
rr := &rotRunner{pass: true}
|
||||||
|
s := rotScheduler(t, rr, NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json")),
|
||||||
|
archiveFor(map[string]string{"local": "local:backup/x.tar.zst"}), gate)
|
||||||
|
|
||||||
|
s.tick(context.Background())
|
||||||
|
|
||||||
|
concurrent := 1 + len(rr.seen()) // the backup holding the gate, plus anything the tick started
|
||||||
|
if concurrent != 1 {
|
||||||
|
t.Fatalf("the restore-test must DEFER while a backup holds the gate; concurrent operations = %d", concurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the backup releases, the next cadence proceeds — deferral must not be permanent.
|
||||||
|
func TestRotation_ResumesAfterTheGateFrees(t *testing.T) {
|
||||||
|
gate := &InFlight{}
|
||||||
|
release, _, _ := gate.TryAcquire("backup:local")
|
||||||
|
|
||||||
|
rr := &rotRunner{pass: true}
|
||||||
|
s := rotScheduler(t, rr, NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json")),
|
||||||
|
archiveFor(map[string]string{"local": "local:backup/x.tar.zst"}), gate)
|
||||||
|
|
||||||
|
s.tick(context.Background())
|
||||||
|
if len(rr.seen()) != 0 {
|
||||||
|
t.Fatal("should have deferred while held")
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
s.tick(context.Background())
|
||||||
|
if len(rr.seen()) != 1 {
|
||||||
|
t.Fatalf("must resume once the gate frees; got %v", rr.seen())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate itself: one holder at a time, named, and release is idempotent.
|
||||||
|
func TestInFlight_Semantics(t *testing.T) {
|
||||||
|
g := &InFlight{}
|
||||||
|
rel, busy, ok := g.TryAcquire("backup:local")
|
||||||
|
if !ok || busy != "" {
|
||||||
|
t.Fatalf("first acquire must succeed; ok=%v busy=%q", ok, busy)
|
||||||
|
}
|
||||||
|
if _, busy2, ok2 := g.TryAcquire("restore-test"); ok2 || busy2 != "backup:local" {
|
||||||
|
t.Fatalf("second acquire must fail and NAME the holder; ok=%v busy=%q", ok2, busy2)
|
||||||
|
}
|
||||||
|
rel()
|
||||||
|
rel() // idempotent — a double release must not free someone else's later claim
|
||||||
|
if g.Busy() != "" {
|
||||||
|
t.Fatalf("gate should be idle after release; busy=%q", g.Busy())
|
||||||
|
}
|
||||||
|
if _, _, ok3 := g.TryAcquire("restore-test"); !ok3 {
|
||||||
|
t.Fatal("gate must be reusable after release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil gate means "not wired" → no gating, pre-R-85 behaviour. Keeps every existing caller working.
|
||||||
|
func TestInFlight_NilIsUngated(t *testing.T) {
|
||||||
|
var g *InFlight
|
||||||
|
rel, _, ok := g.TryAcquire("x")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a nil gate must not block")
|
||||||
|
}
|
||||||
|
rel()
|
||||||
|
if g.Busy() != "" {
|
||||||
|
t.Fatal("a nil gate is never busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── oldest-first ordering ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestOldestFirst_Ordering(t *testing.T) {
|
||||||
|
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Never-proven sorts FIRST — the case that matters, since the offsite tier starts there.
|
||||||
|
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "felhom-pbs" {
|
||||||
|
// both never proven → deterministic tie-break by id
|
||||||
|
if got[0] != "felhom-pbs" && got[0] != "local" {
|
||||||
|
t.Fatalf("unexpected: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = st.RecordSuccess("local", "local:backup/a.tar.zst", now)
|
||||||
|
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "felhom-pbs" {
|
||||||
|
t.Fatalf("a never-proven tier must sort before a proven one; got %v", got)
|
||||||
|
}
|
||||||
|
_ = st.RecordSuccess("felhom-pbs", "felhom-pbs:backup/ct/9201/b", now.Add(time.Hour))
|
||||||
|
if got := st.OldestFirst([]string{"local", "felhom-pbs"}); got[0] != "local" {
|
||||||
|
t.Fatalf("the least recently proven must sort first; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordering must be DETERMINISTIC for equal timestamps, or two tiers proven in the same second would
|
||||||
|
// rotate by Go's randomised map iteration — untestable, and occasionally starving.
|
||||||
|
func TestOldestFirst_DeterministicOnTies(t *testing.T) {
|
||||||
|
st := NewRestoreTestState(filepath.Join(t.TempDir(), "rt.json"))
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_ = st.RecordSuccess("b-tier", "b:archive", now)
|
||||||
|
_ = st.RecordSuccess("a-tier", "a:archive", now)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if got := st.OldestFirst([]string{"b-tier", "a-tier"}); got[0] != "a-tier" {
|
||||||
|
t.Fatalf("tie-break must be deterministic; iteration %d gave %v", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state file round-trips, and a corrupt file degrades to "nothing proven" rather than wedging.
|
||||||
|
func TestRestoreTestState_PersistenceAndCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "rt.json")
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
st := NewRestoreTestState(path)
|
||||||
|
if err := st.RecordSuccess("felhom-pbs", "felhom-pbs:backup/ct/9201/x", now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reopened := NewRestoreTestState(path)
|
||||||
|
got, ok := reopened.LastSuccess("felhom-pbs")
|
||||||
|
if !ok || !got.Equal(now) {
|
||||||
|
t.Fatalf("state must round-trip; got %v ok=%v want %v", got, ok, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := filepath.Join(dir, "corrupt.json")
|
||||||
|
if err := os.WriteFile(bad, []byte("{{{not json"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := NewRestoreTestState(bad)
|
||||||
|
if _, ok := c.LastSuccess("felhom-pbs"); ok {
|
||||||
|
t.Fatal("a corrupt state file must degrade to 'nothing proven', not invent a timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupAPI is the read+backup proxmox surface the runner needs. *proxmox.Client satisfies it.
|
||||||
|
type BackupAPI interface {
|
||||||
|
Vzdump(ctx context.Context, opts proxmox.VzdumpOptions) (string, error)
|
||||||
|
WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error)
|
||||||
|
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
|
||||||
|
StorageContent(ctx context.Context, store string) ([]proxmox.StorageContent, error)
|
||||||
|
// ListStorage enumerates storages (name+type) — used to scope local-only retention (never prune PBS).
|
||||||
|
ListStorage(ctx context.Context) ([]proxmox.Storage, error)
|
||||||
|
// TaskLogTail reads trailing task-log lines — used to read the ACTUAL vzdump mode
|
||||||
|
// (PVE may downgrade a requested snapshot to stop for a stopped guest — spike B1).
|
||||||
|
TaskLogTail(ctx context.Context, upid string, limit int) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupRunner orchestrates a crash-consistent vzdump to a local target and reports the
|
||||||
|
// result (incl. the bulk-volume gap). An agent-initiated vzdump is crash-consistent only
|
||||||
|
// (no fsfreeze); the report says so.
|
||||||
|
type BackupRunner struct {
|
||||||
|
api BackupAPI
|
||||||
|
target string // backup storage (content=backup)
|
||||||
|
mode proxmox.BackupMode // default ModeSnapshot
|
||||||
|
notes string // optional notes-template
|
||||||
|
// retention is the per-run `--prune-backups` spec (e.g. "keep-last=3") applied to a LOCAL target after
|
||||||
|
// each successful backup, so the agent's own backups can't pile up and refill root. Empty → no prune
|
||||||
|
// (the legacy behaviour; restore-test/selftest runners pass ""). NEVER applied to a PBS target.
|
||||||
|
retention string
|
||||||
|
// waitTimeout bounds the WaitTask poll on this runner's vzdump. Per-TIER since R-82: 30m is
|
||||||
|
// right for a local vzdump and badly wrong for an offsite PBS upload (see the 2026-07-26 live
|
||||||
|
// failure recorded on config.BackupTargetConfig.WaitTimeoutSeconds). 0 → 30m (legacy).
|
||||||
|
waitTimeout time.Duration
|
||||||
|
// allowPBSPrune permits `--prune-backups` on a PBS-type target. OFF by default and ON only for
|
||||||
|
// an ADDITIONAL tier whose keep_last was set explicitly (operator ruling 2026-07-26: keep two
|
||||||
|
// weeks of weekly offsite backups).
|
||||||
|
//
|
||||||
|
// The blanket PBS refusal it replaces existed for a real reason and still applies to the
|
||||||
|
// PRIMARY tier: BackupTarget() DEFAULTS to "felhom-pbs" and KeepLast() DEFAULTS to 3, so a box
|
||||||
|
// with neither key set would silently prune its offsite DR to 3 restore points. An additional
|
||||||
|
// tier cannot have that accident — its keep_last defaults to 0 (never prune), so any value
|
||||||
|
// there is a deliberate act.
|
||||||
|
allowPBSPrune bool
|
||||||
|
logger *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
// rejected remembers the volids already announced by warnRejectedArchiveOnce, so an incomplete
|
||||||
|
// archive is reported ONCE rather than on every 5-minute due-check. Bounded in practice: one
|
||||||
|
// entry per aborted upload, and a process restart clears it. Guarded by rejectedMu because the
|
||||||
|
// due-check is served from the local-API handler goroutines.
|
||||||
|
rejectedMu sync.Mutex
|
||||||
|
rejected map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackupRunner builds a runner. mode defaults to snapshot (works for a stopped guest and
|
||||||
|
// for lvm-thin); the caller may pass ModeStop for storages without snapshot support. retention is the
|
||||||
|
// per-run prune spec ("keep-last=N", or "" to never prune) — only the periodic local backup sets it.
|
||||||
|
func NewBackupRunner(api BackupAPI, target string, mode proxmox.BackupMode, notes, retention string, logger *slog.Logger) *BackupRunner {
|
||||||
|
return NewBackupRunnerWithWait(api, target, mode, notes, retention, 0, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackupRunnerWithWait is NewBackupRunner plus an explicit vzdump wait bound (0 → 30m).
|
||||||
|
func NewBackupRunnerWithWait(api BackupAPI, target string, mode proxmox.BackupMode, notes, retention string, waitTimeout time.Duration, logger *slog.Logger) *BackupRunner {
|
||||||
|
return NewBackupRunnerFull(api, target, mode, notes, retention, waitTimeout, false, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackupRunnerFull is the full constructor. allowPBSPrune must be true ONLY for an additional
|
||||||
|
// tier with an explicitly configured keep_last — see BackupRunner.allowPBSPrune.
|
||||||
|
func NewBackupRunnerFull(api BackupAPI, target string, mode proxmox.BackupMode, notes, retention string, waitTimeout time.Duration, allowPBSPrune bool, logger *slog.Logger) *BackupRunner {
|
||||||
|
if mode == "" {
|
||||||
|
mode = proxmox.ModeSnapshot
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
if waitTimeout <= 0 {
|
||||||
|
waitTimeout = 30 * time.Minute
|
||||||
|
}
|
||||||
|
return &BackupRunner{api: api, target: target, mode: mode, notes: notes, retention: retention,
|
||||||
|
waitTimeout: waitTimeout, allowPBSPrune: allowPBSPrune, logger: logger,
|
||||||
|
now: func() time.Time { return time.Now().UTC() }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPruneSpec returns the `--prune-backups` spec to apply to THIS backup, or "" to skip pruning. It
|
||||||
|
// applies the configured retention ONLY when the target is a non-PBS storage — PBS offsite retention is a
|
||||||
|
// separate lifecycle and must never be pruned by the per-run flag (§9). Fail-safe: if the target's type
|
||||||
|
// can't be confirmed (lookup error / not found), it SKIPS pruning rather than risk pruning PBS — the
|
||||||
|
// host_disk + storage_fill detectors remain the safety net.
|
||||||
|
func (r *BackupRunner) localPruneSpec(ctx context.Context) string {
|
||||||
|
if r.retention == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
stores, err := r.api.ListStorage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Warn("backup: could not resolve target storage type — skipping local prune this run", "target", r.target, "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, s := range stores {
|
||||||
|
if s.Storage == r.target {
|
||||||
|
if s.Type == "pbs" && !r.allowPBSPrune {
|
||||||
|
// Not opted in → never prune the offsite DR (the pre-R-82 rule, and still the rule
|
||||||
|
// for the primary tier, whose target+retention both DEFAULT and could prune by
|
||||||
|
// accident).
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.retention
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.logger.Warn("backup: target storage not found in list — skipping local prune this run (fail-safe)", "target", r.target)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotMarker is the vzdump task-log line that signals the storage snapshot has been created
|
||||||
|
// and the backup is now reading from it — the point after which resuming the guest's app cannot
|
||||||
|
// affect the backup (slice 8B.2; validated on PVE 9.2.2: `INFO: create storage snapshot 'vzdump'`).
|
||||||
|
// It only appears in snapshot mode (stop mode takes no storage snapshot), so its presence ⟹
|
||||||
|
// snapshot mode — the basis for the controller's early resume.
|
||||||
|
const snapshotMarker = "create storage snapshot"
|
||||||
|
|
||||||
|
// snapshotWatchInterval is how often watchForSnapshot polls the task log. A package var so tests
|
||||||
|
// can shrink it (production: poll once a second — the marker appears in the first ~1s, §0).
|
||||||
|
var snapshotWatchInterval = time.Second
|
||||||
|
|
||||||
|
// Backup runs one vzdump of vmid to the local target and returns the report record.
|
||||||
|
func (r *BackupRunner) Backup(ctx context.Context, vmid int) (hub.Backup, error) {
|
||||||
|
return r.backup(ctx, vmid, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupWithSnapshotHook is Backup plus an onSnapshot callback invoked ONCE, mid-backup, when the
|
||||||
|
// storage snapshot has been taken (snapshot mode only) — the 8B.2 early-resume signal. In
|
||||||
|
// stop/downgraded mode the marker never appears, so onSnapshot is never called (the caller then
|
||||||
|
// resumes at completion). onSnapshot must be cheap + non-blocking (it runs on a watcher goroutine).
|
||||||
|
func (r *BackupRunner) BackupWithSnapshotHook(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
|
||||||
|
return r.backup(ctx, vmid, onSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backup is the shared body. A failure is returned BOTH as an error and as a
|
||||||
|
// Backup{Success:false,...} so the caller can record the failed attempt.
|
||||||
|
func (r *BackupRunner) backup(ctx context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
|
||||||
|
start := r.now()
|
||||||
|
rec := hub.Backup{
|
||||||
|
TargetID: r.target,
|
||||||
|
VMID: vmid,
|
||||||
|
Mode: string(r.mode),
|
||||||
|
CrashConsistent: true, // always, this slice (no controller quiesce)
|
||||||
|
StartedAt: start.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if r.target == "" {
|
||||||
|
rec.Error = "no backup target configured"
|
||||||
|
return rec, fmt.Errorf("backup: %s", rec.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk-volume gap: which mountpoints the vzdump will EXCLUDE (best-effort; a config-read
|
||||||
|
// failure just leaves the gap unknown, never fails the backup).
|
||||||
|
if cfg, err := r.api.GuestConfig(ctx, vmid); err == nil {
|
||||||
|
rec.UncoveredVolumes = uncoveredMountpoints(cfg.MountPoints())
|
||||||
|
} else {
|
||||||
|
r.logger.Warn("backup: could not read guest config for bulk-gap", "vmid", vmid, "err", err)
|
||||||
|
rec.UncoveredVolumes = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
upid, err := r.api.Vzdump(ctx, proxmox.VzdumpOptions{
|
||||||
|
VMID: vmid, Storage: r.target, Mode: r.mode, Notes: r.notes,
|
||||||
|
PruneBackups: r.localPruneSpec(ctx), // local target → keep-last=N; PBS/unknown → "" (no prune)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
rec.Error = err.Error()
|
||||||
|
rec.DurationSeconds = time.Since(start).Seconds()
|
||||||
|
return rec, fmt.Errorf("backup: vzdump vmid %d: %w", vmid, err)
|
||||||
|
}
|
||||||
|
if upid != "" {
|
||||||
|
// 8B.2: while the backup runs, watch the task log for the storage-snapshot marker and
|
||||||
|
// fire onSnapshot once (snapshot mode only) so the controller can resume its app early.
|
||||||
|
if onSnapshot != nil {
|
||||||
|
watchCtx, stopWatch := context.WithCancel(ctx)
|
||||||
|
defer stopWatch()
|
||||||
|
go r.watchForSnapshot(watchCtx, upid, onSnapshot)
|
||||||
|
}
|
||||||
|
if _, err := r.api.WaitTask(ctx, upid, proxmox.WaitOptions{Timeout: r.waitTimeout}); err != nil {
|
||||||
|
rec.Error = err.Error()
|
||||||
|
rec.DurationSeconds = time.Since(start).Seconds()
|
||||||
|
return rec, fmt.Errorf("backup: vzdump task vmid %d: %w", vmid, err)
|
||||||
|
}
|
||||||
|
// Report the ACTUAL mode PVE used (it may downgrade snapshot→stop for a stopped
|
||||||
|
// guest — spike B1), read from the task log; fall back to the requested mode.
|
||||||
|
if lines, err := r.api.TaskLogTail(ctx, upid, 200); err == nil {
|
||||||
|
if actual := parseBackupMode(lines); actual != "" {
|
||||||
|
rec.Mode = actual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the produced archive (volid + size) — the task status carries no result volid.
|
||||||
|
vol, size, err := r.latestArchive(ctx, vmid)
|
||||||
|
if err != nil {
|
||||||
|
rec.Error = fmt.Sprintf("backup succeeded but archive not resolved: %v", err)
|
||||||
|
rec.DurationSeconds = time.Since(start).Seconds()
|
||||||
|
return rec, fmt.Errorf("backup: resolve archive vmid %d: %w", vmid, err)
|
||||||
|
}
|
||||||
|
rec.Archive = vol
|
||||||
|
rec.SizeBytes = size
|
||||||
|
rec.Success = true
|
||||||
|
rec.DurationSeconds = time.Since(start).Seconds()
|
||||||
|
r.logger.Info("backup: completed", "vmid", vmid, "target", r.target, "archive", vol, "size_bytes", size,
|
||||||
|
"uncovered_volumes", len(rec.UncoveredVolumes))
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchForSnapshot polls the running backup's task log until it sees the storage-snapshot marker
|
||||||
|
// (→ onSnapshot once) or the requested mode is reported as `stop` (→ downgraded; the marker will
|
||||||
|
// never come, so stop watching) or ctx is cancelled (backup finished). Best-effort: a log-read
|
||||||
|
// error is retried on the next tick; onSnapshot fires at most once.
|
||||||
|
func (r *BackupRunner) watchForSnapshot(ctx context.Context, upid string, onSnapshot func()) {
|
||||||
|
ticker := time.NewTicker(snapshotWatchInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
lines, err := r.api.TaskLogTail(ctx, upid, 200)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A stop-mode (or downgraded) backup never creates a storage snapshot → never resume early.
|
||||||
|
if m := parseBackupMode(lines); m != "" && m != string(proxmox.ModeSnapshot) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ln := range lines {
|
||||||
|
if strings.Contains(ln, snapshotMarker) {
|
||||||
|
onSnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickRestoreCandidate returns the newest backup archive on the target (any guest), or ""
|
||||||
|
// when there is none — the restore-test then no-ops cleanly.
|
||||||
|
func (r *BackupRunner) PickRestoreCandidate(ctx context.Context) (string, error) {
|
||||||
|
return r.PickRestoreCandidateOn(ctx, r.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickRestoreCandidateOn is PickRestoreCandidate for an ARBITRARY tier's storage (R-85 1.2), so the
|
||||||
|
// scheduler can rotate across tiers instead of only ever seeing this runner's own target.
|
||||||
|
//
|
||||||
|
// Contract preserved: "" + nil error when the storage holds no archive. **A tier with nothing to
|
||||||
|
// restore is not an error** — a brand-new offsite tier legitimately has nothing yet, and turning
|
||||||
|
// that into a failure would make every fresh box look broken for its first week.
|
||||||
|
func (r *BackupRunner) PickRestoreCandidateOn(ctx context.Context, target string) (string, error) {
|
||||||
|
archive, _, err := r.PickSettledRestoreCandidateOn(ctx, target, time.Time{})
|
||||||
|
return archive, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickSettledRestoreCandidateOn is the R-86 due-check's picker: the newest archive on target that
|
||||||
|
// landed AT OR BEFORE notAfter (the settle cutoff), with the time it landed. A zero notAfter means
|
||||||
|
// "no cutoff" — that is the pre-R-86 behaviour, which is why PickRestoreCandidateOn is now a
|
||||||
|
// one-line call into this and its contract is untouched (one scan, one owner).
|
||||||
|
//
|
||||||
|
// WHY A CUTOFF AT ALL. An archive that landed minutes ago may still be settling — R-71a's
|
||||||
|
// settle-gate exists because the offsite tier's day-0 consume raced its own floor update — and
|
||||||
|
// restore-testing the archive a backup is still writing proves nothing about the backup that
|
||||||
|
// finished. The due-check therefore asks about the newest SETTLED archive, and §8.1's rule is built
|
||||||
|
// on that: the tier is due when a settled archive exists that has not been proven.
|
||||||
|
//
|
||||||
|
// The plausibility floor is applied here and not in the old path on purpose. Under R-86 the picked
|
||||||
|
// archive becomes the tier's due-ness: an incomplete 1-byte phantom (F-CRIT-2's artefact — server
|
||||||
|
// prune does NOT collect it) would be selected forever, fail its restore forever, never earn proof,
|
||||||
|
// and so make the tier due at EVERY evaluation. Skipping it is what keeps the retry rate bounded by
|
||||||
|
// the archive generation rather than by the evaluation interval.
|
||||||
|
//
|
||||||
|
// Contract preserved: ("", zero, nil) when the storage holds no eligible archive. **A tier with
|
||||||
|
// nothing to restore is not an error** — a brand-new offsite tier legitimately has nothing yet, and
|
||||||
|
// turning that into a failure would make every fresh box look broken for its first week.
|
||||||
|
func (r *BackupRunner) PickSettledRestoreCandidateOn(ctx context.Context, target string, notAfter time.Time) (string, time.Time, error) {
|
||||||
|
if target == "" {
|
||||||
|
return "", time.Time{}, nil
|
||||||
|
}
|
||||||
|
contents, err := r.api.StorageContent(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
var best string
|
||||||
|
var bestCTime int64 = -1
|
||||||
|
for _, e := range contents {
|
||||||
|
if e.Content != "backup" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !notAfter.IsZero() && e.CTime > notAfter.Unix() {
|
||||||
|
continue // not settled yet — a newer archive is not a reason to re-prove an older one
|
||||||
|
}
|
||||||
|
if ok, why := archivePlausiblyComplete(e); !ok {
|
||||||
|
r.warnRejectedArchiveOnce(e, why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.CTime > bestCTime {
|
||||||
|
bestCTime, best = e.CTime, e.VolID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == "" {
|
||||||
|
return "", time.Time{}, nil
|
||||||
|
}
|
||||||
|
return best, time.Unix(bestCTime, 0).UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestArchive finds the newest backup archive volid + size for vmid on the target.
|
||||||
|
func (r *BackupRunner) latestArchive(ctx context.Context, vmid int) (string, int64, error) {
|
||||||
|
contents, err := r.api.StorageContent(ctx, r.target)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
var vol string
|
||||||
|
var size, bestCTime int64 = 0, -1
|
||||||
|
for _, e := range contents {
|
||||||
|
if e.Content == "backup" && e.VMID == vmid && e.CTime > bestCTime {
|
||||||
|
bestCTime, vol, size = e.CTime, e.VolID, e.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vol == "" {
|
||||||
|
return "", 0, fmt.Errorf("no backup archive found for vmid %d on %s", vmid, r.target)
|
||||||
|
}
|
||||||
|
return vol, size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewestArchiveTime reports when this guest's newest backup archive LANDED ON THIS TARGET, from the
|
||||||
|
// storage itself. ok=false means the target genuinely holds no archive for this guest.
|
||||||
|
//
|
||||||
|
// R-84: this is the cure for the redundant-backup-after-restart problem. The agent's backup Store is
|
||||||
|
// in-memory ("lost on restart; the cadence re-populates"), so after every restart /backup/due
|
||||||
|
// reported "no successful backup recorded yet" and the controller dutifully took another one. On the
|
||||||
|
// local tier that is wasted minutes; on the OFFSITE tier it is a wasted multi-hour WAN upload after
|
||||||
|
// every agent deploy — and agent deploys are routine. Three redundant local backups were observed on
|
||||||
|
// minPlausibleArchiveBytes is the floor below which a storage entry cannot be a real whole-guest
|
||||||
|
// backup and is therefore treated as an INCOMPLETE artefact rather than a successful one.
|
||||||
|
//
|
||||||
|
// MEASURED, not chosen by feel — fleet survey 2026-07-28 (Campaign 8, finding F-CRIT-2):
|
||||||
|
//
|
||||||
|
// smallest REAL backup anywhere on the fleet ... 612,397,450 B (~584 MiB, a guest-9100 vzdump)
|
||||||
|
// demo-hp local / PBS ..................... 1.59 GB / 4.35-4.37 GB
|
||||||
|
// demo-felhom local / PBS ..................... 5.82-5.84 GB / 14.47-14.51 GB
|
||||||
|
// the phantom left by a PBS daemon killed mid-upload ....... 1 B
|
||||||
|
//
|
||||||
|
// 1 MiB sits 584x below the smallest real backup and 1,048,576x above the phantom. The two
|
||||||
|
// populations are nine orders of magnitude apart, so this floor cannot plausibly clip a real
|
||||||
|
// archive — which is the property that matters, because a floor set too HIGH does not merely lose
|
||||||
|
// safety margin, it causes fleet-wide backup THRASH (see archivePlausiblyComplete).
|
||||||
|
const minPlausibleArchiveBytes int64 = 1 << 20
|
||||||
|
|
||||||
|
// archivePlausiblyComplete reports whether a storage entry can be a COMPLETE backup, and if not,
|
||||||
|
// why. Pure, so the contract is unit-testable without a storage.
|
||||||
|
//
|
||||||
|
// WHY SIZE, AND NOTHING ELSE. The richer PBS fields look like better discriminators and are all
|
||||||
|
// traps, because this runner is TIER-AGNOSTIC — the same predicate runs against a PBS datastore and
|
||||||
|
// against a plain `dir` storage (verified against the live PVE API, 2026-07-28):
|
||||||
|
//
|
||||||
|
// - `verification` is absent on the phantom, but ALSO absent on every local (dir) archive — a dir
|
||||||
|
// storage has no verification concept — and absent on a good PBS snapshot until verify-new
|
||||||
|
// catches up. Gating on it would reject 100% of local backups and every freshly-taken offsite
|
||||||
|
// one: continuous re-backup across the fleet.
|
||||||
|
// - `encrypted` fails the same way, and for the same reason.
|
||||||
|
// - `notes` happens to be present on both good tiers today only because the agent sets it; an
|
||||||
|
// archive written by any other path lacks it. Too fragile to gate freshness on.
|
||||||
|
//
|
||||||
|
// Size is the only signal that means the same thing on every tier.
|
||||||
|
//
|
||||||
|
// THE FAIL-SAFE DIRECTION, stated explicitly: when completeness cannot be established the entry is
|
||||||
|
// NOT counted as a successful backup. That errs toward the tier looking LESS fresh, and its worst
|
||||||
|
// case is one extra backup. Counting an undecidable entry is precisely the F-CRIT-2 defect — a
|
||||||
|
// failed upload that made its tier look freshly backed up and silenced it for a full cadence.
|
||||||
|
func archivePlausiblyComplete(e proxmox.StorageContent) (bool, string) {
|
||||||
|
if e.Size < minPlausibleArchiveBytes {
|
||||||
|
return false, fmt.Sprintf("size %d B is below the %d B plausibility floor — an aborted/incomplete archive, not a successful backup",
|
||||||
|
e.Size, minPlausibleArchiveBytes)
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnRejectedArchiveOnce announces a rejected archive at WARN exactly once per distinct volid.
|
||||||
|
//
|
||||||
|
// A rejected archive must never be silent: a tier that quietly ignores the newest entry on its
|
||||||
|
// storage is a new quiet path, and quiet paths are what F-CRIT-2 was. But the due-check runs every
|
||||||
|
// 5 minutes and a phantom persists indefinitely — server-side prune does NOT collect it (verified
|
||||||
|
// by dry-run 2026-07-28: with keep-last 2 it retained two real snapshots PLUS the phantom) — so
|
||||||
|
// logging per poll would emit ~288 identical lines a day and bury the one that matters.
|
||||||
|
func (r *BackupRunner) warnRejectedArchiveOnce(e proxmox.StorageContent, why string) {
|
||||||
|
r.rejectedMu.Lock()
|
||||||
|
if r.rejected == nil {
|
||||||
|
r.rejected = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
_, seen := r.rejected[e.VolID]
|
||||||
|
if !seen {
|
||||||
|
r.rejected[e.VolID] = struct{}{}
|
||||||
|
}
|
||||||
|
r.rejectedMu.Unlock()
|
||||||
|
if seen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Warn("backup: ignoring an INCOMPLETE archive when computing tier freshness — it is not a successful backup",
|
||||||
|
"target", r.target, "vmid", e.VMID, "volid", e.VolID, "size_bytes", e.Size, "reason", why)
|
||||||
|
}
|
||||||
|
|
||||||
|
// demo-felhom in a single afternoon of deploys (2026-07-26).
|
||||||
|
//
|
||||||
|
// Asking the STORAGE rather than persisting the store is deliberate:
|
||||||
|
// - it is ground truth, not remembered state — if an archive was pruned or deleted it correctly
|
||||||
|
// stops counting, whereas a persisted record would keep claiming a backup that no longer exists;
|
||||||
|
// - it needs no new on-disk state and no migration;
|
||||||
|
// - it is the same source `latestArchive` already trusts to build the post-backup record.
|
||||||
|
//
|
||||||
|
// It answers ONLY "when did a plausibly COMPLETE backup last land", which is exactly what the
|
||||||
|
// due-check needs. Completeness is not optional here: PBS publishes an aborted upload into the same
|
||||||
|
// listing (manifest-less, 1 byte, and NEWEST), and counting it made the tier report fresh and go
|
||||||
|
// silent for a whole cadence — F-CRIT-2. Presence is not validity. The
|
||||||
|
// richer fields (size, duration, uncovered volumes, error) stay with the real in-memory records — a
|
||||||
|
// synthesized record would put invented numbers into the host-report.
|
||||||
|
func (r *BackupRunner) NewestArchiveTime(ctx context.Context, vmid int) (time.Time, bool, error) {
|
||||||
|
contents, err := r.api.StorageContent(ctx, r.target)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, err
|
||||||
|
}
|
||||||
|
var best int64 = -1
|
||||||
|
for _, e := range contents {
|
||||||
|
if e.Content != "backup" || e.VMID != vmid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok, why := archivePlausiblyComplete(e); !ok {
|
||||||
|
r.warnRejectedArchiveOnce(e, why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.CTime > best {
|
||||||
|
best = e.CTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
return time.Unix(best, 0).UTC(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBackupMode extracts the actual mode from a vzdump task log line `… backup mode: <x>`
|
||||||
|
// (e.g. "INFO: backup mode: stop"). Returns "" if not found.
|
||||||
|
func parseBackupMode(lines []string) string {
|
||||||
|
const marker = "backup mode:"
|
||||||
|
for _, ln := range lines {
|
||||||
|
if i := strings.Index(ln, marker); i >= 0 {
|
||||||
|
return strings.TrimSpace(ln[i+len(marker):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// uncoveredMountpoints returns the mountpoint paths the guest vzdump EXCLUDES. LXC mount
|
||||||
|
// points are OPT-IN to vzdump: a mpN with `backup=1` is covered; ANY other state — the
|
||||||
|
// `backup=` token absent OR `backup=0` — is excluded. We deliberately treat unset as
|
||||||
|
// uncovered (the safe DR direction: never imply an unprotected volume is backed up).
|
||||||
|
func uncoveredMountpoints(mps map[string]string) []string {
|
||||||
|
var out []string
|
||||||
|
for key, cfg := range mps {
|
||||||
|
if mountpointCovered(cfg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, mountpointLabel(key, cfg))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
if out == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountpointCovered reports whether a mpN config string is included in the vzdump — true
|
||||||
|
// ONLY when it carries an explicit `backup=1`.
|
||||||
|
func mountpointCovered(cfg string) bool {
|
||||||
|
for _, tok := range strings.Split(cfg, ",") {
|
||||||
|
if v, ok := strings.CutPrefix(tok, "backup="); ok {
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false // no backup= token → opt-out by default → not covered
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountpointLabel prefers the mp=/path token, falling back to the mpN key.
|
||||||
|
func mountpointLabel(key, cfg string) string {
|
||||||
|
for _, tok := range strings.Split(cfg, ",") {
|
||||||
|
if p, ok := strings.CutPrefix(tok, "mp="); ok && p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToHubRestoreTest maps a reconcile restore-test result to the hub wire record (the backup
|
||||||
|
// package owns the reconcile→hub mapping so reconcile need not import hub for the result).
|
||||||
|
func ToHubRestoreTest(res reconcile.RestoreTestResult, testedAt time.Time) hub.RestoreTest {
|
||||||
|
rt := hub.RestoreTest{
|
||||||
|
SourceArchive: res.Archive,
|
||||||
|
SourceTier: res.SourceTier,
|
||||||
|
ScratchVMID: res.ScratchVMID,
|
||||||
|
Pass: res.Pass,
|
||||||
|
Verified: res.Verified,
|
||||||
|
TestedAt: testedAt.Format(time.RFC3339),
|
||||||
|
DurationSeconds: res.Duration.Seconds(),
|
||||||
|
Warnings: res.StartWarnings,
|
||||||
|
WarningsRecognized: res.WarningsRecognized,
|
||||||
|
MountParity: res.MountParity,
|
||||||
|
MountInventory: res.MountInventory,
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
rt.Error = res.Err.Error()
|
||||||
|
}
|
||||||
|
return rt
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 8B.2: in snapshot mode, the runner fires onSnapshot when the storage-snapshot marker appears in
|
||||||
|
// the task log — mid-backup, before completion.
|
||||||
|
func TestBackupWithSnapshotHook_FiresOnMarker(t *testing.T) {
|
||||||
|
old := snapshotWatchInterval
|
||||||
|
snapshotWatchInterval = 2 * time.Millisecond
|
||||||
|
defer func() { snapshotWatchInterval = old }()
|
||||||
|
|
||||||
|
api := &fakeBackupAPI{
|
||||||
|
vzdumpUPID: "UPID:backup",
|
||||||
|
waitGate: make(chan struct{}), // hold the backup open so the watcher gets to poll
|
||||||
|
logLines: []string{"INFO: backup mode: snapshot", "INFO: create storage snapshot 'vzdump'"},
|
||||||
|
content: []proxmox.StorageContent{{VolID: "local:backup/vzdump-lxc-9001-x", Content: "backup", VMID: 9001, Size: 100, CTime: 1}},
|
||||||
|
}
|
||||||
|
r := NewBackupRunner(api, "local", "", "", "", quiet())
|
||||||
|
|
||||||
|
var fired int32
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_, _ = r.BackupWithSnapshotHook(context.Background(), 9001, func() { atomic.StoreInt32(&fired, 1) })
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// the watcher should fire onSnapshot well before we release the backup
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) && atomic.LoadInt32(&fired) == 0 {
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&fired) != 1 {
|
||||||
|
t.Fatal("onSnapshot did not fire on the storage-snapshot marker")
|
||||||
|
}
|
||||||
|
close(api.waitGate) // let the backup complete
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop/downgraded mode → no storage-snapshot marker → onSnapshot never fires (the 8B.2 fallback).
|
||||||
|
func TestBackupWithSnapshotHook_StopMode_NeverFires(t *testing.T) {
|
||||||
|
old := snapshotWatchInterval
|
||||||
|
snapshotWatchInterval = 2 * time.Millisecond
|
||||||
|
defer func() { snapshotWatchInterval = old }()
|
||||||
|
|
||||||
|
api := &fakeBackupAPI{
|
||||||
|
vzdumpUPID: "UPID:backup",
|
||||||
|
waitGate: make(chan struct{}),
|
||||||
|
logLines: []string{"INFO: backup mode: stop"}, // downgraded; no snapshot marker
|
||||||
|
content: []proxmox.StorageContent{{VolID: "local:backup/vzdump-lxc-9001-x", Content: "backup", VMID: 9001, Size: 100, CTime: 1}},
|
||||||
|
}
|
||||||
|
r := NewBackupRunner(api, "local", "", "", "", quiet())
|
||||||
|
|
||||||
|
var fired int32
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_, _ = r.BackupWithSnapshotHook(context.Background(), 9001, func() { atomic.StoreInt32(&fired, 1) })
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
time.Sleep(40 * time.Millisecond) // the watcher polls several times + sees stop mode → returns
|
||||||
|
if atomic.LoadInt32(&fired) != 0 {
|
||||||
|
t.Fatal("onSnapshot fired in stop mode (must not)")
|
||||||
|
}
|
||||||
|
close(api.waitGate)
|
||||||
|
<-done
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreTestRunner is the reconcile-engine seam the scheduler drives (*reconcile.Engine
|
||||||
|
// satisfies it). Kept narrow so the scheduler is unit-testable with a fake.
|
||||||
|
type RestoreTestRunner interface {
|
||||||
|
RunRestoreTest(ctx context.Context, spec reconcile.RestoreTestSpec) reconcile.RestoreTestResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidatePicker resolves the archive volid to restore-test (newest backup), or "" when
|
||||||
|
// there is none yet (the tick then no-ops).
|
||||||
|
type CandidatePicker func(ctx context.Context) (string, error)
|
||||||
|
|
||||||
|
// SpecBuilder yields the RestoreTestSpec for ONE run, given the archive that was picked.
|
||||||
|
//
|
||||||
|
// R-85 (1.1): this REPLACES a frozen spec value. It used to be built by an immediately-invoked
|
||||||
|
// function at daemon start, so `storageTier()` and `restoreTaskTimeout()` were evaluated ONCE and
|
||||||
|
// the resulting value reused for every run for the lifetime of the process. Two consequences:
|
||||||
|
// - nothing tier-varying was expressible at all (the offsite tier could never be scheduled), and
|
||||||
|
// - it was a latent staleness bug in its own right — a storage-type or config change did not take
|
||||||
|
// effect until the daemon restarted.
|
||||||
|
//
|
||||||
|
// The archive is passed in because the tier MUST be derived from it (the v0.100.0 rule), never from
|
||||||
|
// the configured target: deriving it from config is what produced the 600 s false failure when a
|
||||||
|
// PBS archive was classified "local" and got the 10-minute local wait.
|
||||||
|
type SpecBuilder func(ctx context.Context, archive string) reconcile.RestoreTestSpec
|
||||||
|
|
||||||
|
// TierPicker resolves the newest archive on a NAMED tier that landed AT OR BEFORE notAfter (the
|
||||||
|
// settle cutoff), together with when it landed. (*BackupRunner).PickSettledRestoreCandidateOn
|
||||||
|
// satisfies it. A zero notAfter means "no settle requirement".
|
||||||
|
//
|
||||||
|
// R-86 widened this seam from (target) → archive. The landing time is what makes the due-check's
|
||||||
|
// verdict explainable — "archive X, which landed at T, has not been proven" — and the cutoff is
|
||||||
|
// what makes the rule per-ARCHIVE-GENERATION instead of per-interval. "" must NOT be an error: a
|
||||||
|
// brand-new offsite tier legitimately has nothing to restore yet.
|
||||||
|
type TierPicker func(ctx context.Context, target string, notAfter time.Time) (archive string, landed time.Time, err error)
|
||||||
|
|
||||||
|
// Scheduler runs the self-restore-test on an agent-internal cadence. It is the fourth daemon
|
||||||
|
// goroutine; it does real restore→boot→destroy, so it only runs when the cadence is enabled
|
||||||
|
// AND a valid scratch band is configured (validated by the caller before construction).
|
||||||
|
type Scheduler struct {
|
||||||
|
runner RestoreTestRunner
|
||||||
|
pick CandidatePicker
|
||||||
|
store *Store
|
||||||
|
spec SpecBuilder // R-85: evaluated PER RUN, never frozen at construction
|
||||||
|
// cadence is the EVALUATION interval (R-86) — how often "is anything due?" is asked. It is no
|
||||||
|
// longer the thing that decides a test happens; see restoretest_due.go.
|
||||||
|
cadence time.Duration
|
||||||
|
// settle is how long an archive must have sat before it is a candidate (R-86).
|
||||||
|
settle time.Duration
|
||||||
|
logger *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
// R-85 tier rotation. All optional: without them the scheduler behaves exactly as before
|
||||||
|
// (single tier via `pick`), which keeps every existing caller and test working untouched.
|
||||||
|
tiers []string // configured tier target ids, primary first
|
||||||
|
tierPick TierPicker // newest archive on a named tier
|
||||||
|
rtState *RestoreTestState // persisted last-successful-per-tier (drives oldest-first)
|
||||||
|
inFlight *InFlight // shared with the backup path — Scenario F
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerOptions configures a Scheduler.
|
||||||
|
type SchedulerOptions struct {
|
||||||
|
Runner RestoreTestRunner
|
||||||
|
Pick CandidatePicker
|
||||||
|
Store *Store
|
||||||
|
// Spec builds the run's spec (RestoreStorage, ScratchMin/Max, SourceTier, timeouts) from the
|
||||||
|
// picked archive. Called ONCE PER RUN — see SpecBuilder for why it is not a value.
|
||||||
|
Spec SpecBuilder
|
||||||
|
// Cadence is the EVALUATION interval — how often due-ness is asked, NOT how often a test runs
|
||||||
|
// (R-86). 0 → disabled.
|
||||||
|
Cadence time.Duration
|
||||||
|
// Settle is how long an archive must have sat before it is a restore-test candidate (R-86).
|
||||||
|
// 0 → no settle requirement (any archive is a candidate).
|
||||||
|
Settle time.Duration
|
||||||
|
Logger *slog.Logger
|
||||||
|
|
||||||
|
// R-85 (all optional — omit for the pre-R-85 single-tier behaviour):
|
||||||
|
// Tiers are the configured tier target ids (primary first); TierPick resolves an archive on a
|
||||||
|
// named tier; State persists last-successful-per-tier; InFlight is the shared one-heavy-op gate.
|
||||||
|
Tiers []string
|
||||||
|
TierPick TierPicker
|
||||||
|
State *RestoreTestState
|
||||||
|
InFlight *InFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduler builds a Scheduler.
|
||||||
|
func NewScheduler(opts SchedulerOptions) *Scheduler {
|
||||||
|
logger := opts.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Scheduler{
|
||||||
|
runner: opts.Runner,
|
||||||
|
pick: opts.Pick,
|
||||||
|
store: opts.Store,
|
||||||
|
spec: opts.Spec,
|
||||||
|
cadence: opts.Cadence,
|
||||||
|
settle: opts.Settle,
|
||||||
|
logger: logger,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
tiers: append([]string(nil), opts.Tiers...),
|
||||||
|
tierPick: opts.TierPick,
|
||||||
|
rtState: opts.State,
|
||||||
|
inFlight: opts.InFlight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run EVALUATES due-ness on the interval until ctx is cancelled, and runs a restore-test only when
|
||||||
|
// a tier is actually due (R-86). A 0 interval disables it (the goroutine just waits for shutdown).
|
||||||
|
//
|
||||||
|
// The ticker survives as the evaluation interval and nothing else. It is emphatically NOT the
|
||||||
|
// trigger any more: its phase is the process's uptime, and agent deploys reset it, which is exactly
|
||||||
|
// the defect R-86 removes. What decides that a test happens is `EvaluateDue`.
|
||||||
|
//
|
||||||
|
// It still does NOT evaluate immediately on start — the first evaluation is one interval in. That
|
||||||
|
// is an EARNED restraint, kept deliberately: a restore is heavy, agent restarts are routine, and a
|
||||||
|
// crash-loop that evaluated at start would hammer a permanently-failing tier as fast as it could
|
||||||
|
// restart. Due-ness does not expire while we wait, so the only cost is up to one interval of
|
||||||
|
// latency on a tier that just became due. On-demand runs use `--selftest=restore-test`.
|
||||||
|
//
|
||||||
|
// Returns nil on ctx cancellation.
|
||||||
|
func (s *Scheduler) Run(ctx context.Context) error {
|
||||||
|
if s.cadence <= 0 || s.runner == nil || s.spec == nil || (s.pick == nil && !s.rotating()) {
|
||||||
|
s.logger.Info("backup: restore-test cadence disabled")
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.logger.Info("backup: restore-test scheduler starting (per-archive due-check)",
|
||||||
|
"eval_interval", s.cadence, "settle", s.settle)
|
||||||
|
t := time.NewTicker(s.cadence)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.logger.Info("backup: restore-test scheduler shutting down", "reason", ctx.Err())
|
||||||
|
return nil
|
||||||
|
case <-t.C:
|
||||||
|
s.tick(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tick is ONE EVALUATION: gate → due-check → run the first due tier → record which archive was
|
||||||
|
// proven. No-ops cleanly when nothing is due, when no backup exists yet, or when a heavy operation
|
||||||
|
// is already in flight. Deterministic given s.now — tests call it directly.
|
||||||
|
//
|
||||||
|
// One run per evaluation, by construction (Scenario F): a second due tier is left DUE and picked up
|
||||||
|
// by the next evaluation. Deferred, never cancelled, and never two multi-GB restores over one link.
|
||||||
|
func (s *Scheduler) tick(ctx context.Context) {
|
||||||
|
if s.spec == nil {
|
||||||
|
// Defensive: Run() already refuses to start without a SpecBuilder, but tick is also
|
||||||
|
// reachable directly. Skipping loudly beats panicking the daemon goroutine — a missing
|
||||||
|
// spec must cost a restore-test, never the agent.
|
||||||
|
s.logger.Error("backup: restore-test has no spec builder — skipping (this is a wiring bug)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The due-check runs BEFORE the gate is taken, and that ORDER is load-bearing under R-86.
|
||||||
|
//
|
||||||
|
// It used to be the other way round, and correctly so: the gate was held for one heavy run a
|
||||||
|
// day, and the candidate lookup rode along inside it. Evaluations are now frequent, and the
|
||||||
|
// lookup is a storage listing that for the offsite tier crosses the WAN. Holding the
|
||||||
|
// one-heavy-operation gate for a read that answers "nothing to do" would open a small window at
|
||||||
|
// EVERY evaluation in which a starting backup cannot acquire — and a backup that cannot acquire
|
||||||
|
// does not merely wait, it records a failure and pages the operator (F-A1). A cheap poll must
|
||||||
|
// not be able to manufacture that.
|
||||||
|
//
|
||||||
|
// Nothing is lost by checking first: due-ness does not expire, and the gate is still taken
|
||||||
|
// before anything heavy begins.
|
||||||
|
archive, target, err := s.pickForThisRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("backup: restore-test could not pick a candidate; skipping", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if archive == "" {
|
||||||
|
// A POSITIVE OBSERVABLE, at INFO, and this is not noise — it is standing rule 3.
|
||||||
|
//
|
||||||
|
// Before R-86 every tick ran a heavy restore-test, so the scheduler was audible by
|
||||||
|
// construction. Now "nothing is due" is the NORMAL outcome, and at DEBUG it is silent: an
|
||||||
|
// empty journal would be equally consistent with a healthy loop and with a dead goroutine,
|
||||||
|
// which is the exact shape the R-88 watcher was retired for. One line per evaluation is four
|
||||||
|
// lines a day at the 6h default, and it names each tier's verdict so the answer to "why did
|
||||||
|
// nothing run last night?" is in the log rather than in a re-derivation.
|
||||||
|
s.logger.Info("backup: restore-test evaluated — nothing due", "verdicts", s.verdictSummary(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario F: join the one-heavy-operation-at-a-time gate. A restore-test PULLS a multi-GB
|
||||||
|
// archive over the same tunnel an offsite backup PUSHES one; running both saturates the link and
|
||||||
|
// drives each toward its timeout, which is how a healthy tier gets recorded as failed. DEFER —
|
||||||
|
// never cancel what is already running: a deferred restore-test costs hours of coverage, a
|
||||||
|
// cancelled backup costs the backup. A deferred tier stays DUE, so the next evaluation retries it.
|
||||||
|
release, busy, ok := s.inFlight.TryAcquire("restore-test")
|
||||||
|
if !ok {
|
||||||
|
s.logger.Info("backup: restore-test deferred — a heavy operation is already in flight",
|
||||||
|
"busy", busy, "target", target, "archive", archive)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
// R-85: build the spec for THIS run, from THIS archive. Never a frozen value.
|
||||||
|
spec := s.spec(ctx, archive)
|
||||||
|
spec.Archive = archive
|
||||||
|
res := s.runner.RunRestoreTest(ctx, spec)
|
||||||
|
if res.Skipped {
|
||||||
|
return // already logged by the engine (no free scratch VMID)
|
||||||
|
}
|
||||||
|
rt := ToHubRestoreTest(res, s.now())
|
||||||
|
s.store.RecordRestoreTest(rt)
|
||||||
|
// Rotation credit is given ONLY on success. A failing tier must keep sorting first, or a tier
|
||||||
|
// that fails every time would look freshly proven and quietly stop being retried.
|
||||||
|
if rt.Pass && s.rtState != nil && target != "" {
|
||||||
|
// R-86: the ARCHIVE is recorded, not merely the time — that is what makes the tier
|
||||||
|
// not-due until a NEWER archive settles, and what makes a proof survive a restart.
|
||||||
|
if err := s.rtState.RecordSuccess(target, archive, s.now()); err != nil {
|
||||||
|
s.logger.Warn("backup: could not persist the restore-test proof state", "target", target, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !rt.Pass:
|
||||||
|
// A failing restore-test is the loudest DR signal there is.
|
||||||
|
s.logger.Error("backup: scheduled restore-test FAILED", "archive", rt.SourceArchive, "err", rt.Error)
|
||||||
|
case len(res.StartWarnings) == 0:
|
||||||
|
s.logger.Info("backup: scheduled restore-test passed", "archive", rt.SourceArchive, "duration_s", rt.DurationSeconds)
|
||||||
|
case res.WarningsRecognized:
|
||||||
|
// Passed; the only warnings are the known-benign (e.g. systemd-nesting) advisory.
|
||||||
|
s.logger.Info("backup: scheduled restore-test passed with warnings (recognized)",
|
||||||
|
"archive", rt.SourceArchive, "duration_s", rt.DurationSeconds, "warnings", res.StartWarnings)
|
||||||
|
default:
|
||||||
|
// Passed liveness, but an UNRECOGNIZED start warning stood out — worth an operator look.
|
||||||
|
s.logger.Warn("backup: scheduled restore-test passed with UNRECOGNIZED warnings",
|
||||||
|
"archive", rt.SourceArchive, "duration_s", rt.DurationSeconds, "warnings", res.StartWarnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotating reports whether multi-tier rotation is wired.
|
||||||
|
func (s *Scheduler) rotating() bool { return len(s.tiers) > 0 && s.tierPick != nil }
|
||||||
|
|
||||||
|
// pickForThisRun chooses the tier to test THIS evaluation: the first DUE tier, in oldest-proven
|
||||||
|
// order.
|
||||||
|
//
|
||||||
|
// R-86 changed what this answers. It used to answer "whose turn is it?", and the answer was always
|
||||||
|
// somebody's — the ticker had fired, so a test was going to happen. It now answers "is anything
|
||||||
|
// due?", and "nothing" is a normal, frequent and correct answer.
|
||||||
|
//
|
||||||
|
// OLDEST-FIRST (operator ruling 2026-07-26, Option 1) survives as the ORDER among due tiers: the
|
||||||
|
// tier whose last successful restore-test is oldest goes first, never-proven first of all. It is
|
||||||
|
// self-balancing, needs no config knob, and it still cannot starve a tier — but it no longer decides
|
||||||
|
// that a test happens at all.
|
||||||
|
//
|
||||||
|
// A tier with no settled archive is SKIPPED, not failed — a brand-new offsite tier has nothing to
|
||||||
|
// restore yet, and that is normal, not broken. A tier whose archives cannot be LISTED is likewise
|
||||||
|
// skipped, loudly, and its error is returned only when no other tier was testable: one tier's
|
||||||
|
// storage being unreadable must not cost the other tier its proof, and must not be silent either.
|
||||||
|
//
|
||||||
|
// Returns ("", "", nil) when nothing anywhere is due.
|
||||||
|
func (s *Scheduler) pickForThisRun(ctx context.Context) (archive, target string, err error) {
|
||||||
|
if !s.rotating() {
|
||||||
|
// Pre-R-85 single-tier path (tests and any caller that wires only `Pick`): there is no tier
|
||||||
|
// identity and no persisted proof here, so there is nothing to compare an archive against
|
||||||
|
// and no due-check is possible. It runs on every evaluation, exactly as it always did.
|
||||||
|
a, perr := s.pick(ctx)
|
||||||
|
return a, "", perr
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
for _, v := range s.EvaluateDue(ctx) {
|
||||||
|
if v.Err != nil {
|
||||||
|
s.logger.Warn("backup: restore-test candidate lookup failed for a tier; trying the next",
|
||||||
|
"target", v.Target, "err", v.Err)
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = v.Err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !v.Due {
|
||||||
|
s.logger.Debug("backup: restore-test tier is not due", "target", v.Target, "reason", v.Reason)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.logger.Info("backup: restore-test tier is DUE (per-archive; oldest-proven first among due tiers)",
|
||||||
|
"target", v.Target, "archive", v.Archive, "landed", v.Landed.Format(time.RFC3339), "reason", v.Reason)
|
||||||
|
return v.Archive, v.Target, nil
|
||||||
|
}
|
||||||
|
if firstErr != nil {
|
||||||
|
return "", "", firstErr
|
||||||
|
}
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-85 (1.1) — the spec is built PER RUN, never frozen at construction.
|
||||||
|
//
|
||||||
|
// It used to be an immediately-invoked function at daemon start, so storageTier() and
|
||||||
|
// restoreTaskTimeout() were evaluated ONCE and the value reused for every run for the process
|
||||||
|
// lifetime. That is what made an offsite restore-test impossible to schedule at all, and it was a
|
||||||
|
// latent staleness bug besides: a storage-type or config change did not take effect until restart.
|
||||||
|
|
||||||
|
type specSpy struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
archives []string
|
||||||
|
tiers []string // what the builder decided, per call
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sp *specSpy) build(_ context.Context, archive string) reconcile.RestoreTestSpec {
|
||||||
|
sp.mu.Lock()
|
||||||
|
defer sp.mu.Unlock()
|
||||||
|
sp.calls++
|
||||||
|
sp.archives = append(sp.archives, archive)
|
||||||
|
// Decide the tier from the ARCHIVE, exactly as main.go does (the v0.100.0 rule).
|
||||||
|
tier := "local"
|
||||||
|
if len(archive) > 10 && archive[:10] == "felhom-pbs" {
|
||||||
|
tier = "pbs"
|
||||||
|
}
|
||||||
|
sp.tiers = append(sp.tiers, tier)
|
||||||
|
return reconcile.RestoreTestSpec{
|
||||||
|
RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009, SourceTier: tier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPANION RED-PROOF (observed): change Scheduler.spec back to a frozen
|
||||||
|
// `reconcile.RestoreTestSpec` value captured at construction → this fails with
|
||||||
|
// "the spec builder must run ONCE PER RUN, got 1 call(s) across 3 ticks", because a frozen value is
|
||||||
|
// evaluated exactly once no matter how many ticks fire. Restored.
|
||||||
|
func TestScheduler_SpecIsBuiltPerRun(t *testing.T) {
|
||||||
|
sp := &specSpy{}
|
||||||
|
rt := &fakeRTRunner{res: reconcile.RestoreTestResult{Pass: true, Verified: "boot+running"}}
|
||||||
|
n := 0
|
||||||
|
s := NewScheduler(SchedulerOptions{
|
||||||
|
Runner: rt,
|
||||||
|
Pick: func(context.Context) (string, error) {
|
||||||
|
n++
|
||||||
|
return fmt.Sprintf("local:backup/vzdump-lxc-9201-%d.tar.zst", n), nil
|
||||||
|
},
|
||||||
|
Store: NewStore(),
|
||||||
|
Spec: sp.build,
|
||||||
|
Cadence: time.Hour,
|
||||||
|
Logger: quiet(),
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
s.tick(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
sp.mu.Lock()
|
||||||
|
defer sp.mu.Unlock()
|
||||||
|
if sp.calls != 3 {
|
||||||
|
t.Fatalf("the spec builder must run ONCE PER RUN, got %d call(s) across 3 ticks", sp.calls)
|
||||||
|
}
|
||||||
|
// And it must see the archive THIS run picked — not a stale one.
|
||||||
|
for i, a := range sp.archives {
|
||||||
|
want := fmt.Sprintf("local:backup/vzdump-lxc-9201-%d.tar.zst", i+1)
|
||||||
|
if a != want {
|
||||||
|
t.Fatalf("run %d: builder saw archive %q, want %q — the spec is not tracking the picked archive", i+1, a, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tier must follow the ARCHIVE across runs. A builder that saw only the configured target would
|
||||||
|
// return the same tier every time — which is exactly the v0.100.0 defect that killed a 14.46 GB WAN
|
||||||
|
// restore at the 10-minute local bound.
|
||||||
|
func TestScheduler_SpecTierFollowsTheArchive(t *testing.T) {
|
||||||
|
sp := &specSpy{}
|
||||||
|
rt := &fakeRTRunner{res: reconcile.RestoreTestResult{Pass: true, Verified: "boot+running"}}
|
||||||
|
archives := []string{
|
||||||
|
"local:backup/vzdump-lxc-9201-x.tar.zst",
|
||||||
|
"felhom-pbs:backup/ct/9201/2026-07-26T15:42:42Z",
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
s := NewScheduler(SchedulerOptions{
|
||||||
|
Runner: rt,
|
||||||
|
Pick: func(context.Context) (string, error) {
|
||||||
|
a := archives[i%len(archives)]
|
||||||
|
i++
|
||||||
|
return a, nil
|
||||||
|
},
|
||||||
|
Store: NewStore(), Spec: sp.build, Cadence: time.Hour, Logger: quiet(),
|
||||||
|
})
|
||||||
|
s.tick(context.Background())
|
||||||
|
s.tick(context.Background())
|
||||||
|
|
||||||
|
sp.mu.Lock()
|
||||||
|
defer sp.mu.Unlock()
|
||||||
|
if len(sp.tiers) != 2 || sp.tiers[0] != "local" || sp.tiers[1] != "pbs" {
|
||||||
|
t.Fatalf("the tier must follow the archive per run; got %v", sp.tiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil spec builder must SKIP loudly, not panic — a wiring bug costs a restore-test, never the
|
||||||
|
// daemon goroutine.
|
||||||
|
func TestScheduler_NilSpecSkipsInsteadOfPanicking(t *testing.T) {
|
||||||
|
rt := &fakeRTRunner{}
|
||||||
|
s := NewScheduler(SchedulerOptions{
|
||||||
|
Runner: rt,
|
||||||
|
Pick: func(context.Context) (string, error) { return "vol", nil },
|
||||||
|
Store: NewStore(), Cadence: time.Hour, Logger: quiet(),
|
||||||
|
})
|
||||||
|
s.tick(context.Background()) // must not panic
|
||||||
|
if rt.runs != 0 {
|
||||||
|
t.Fatalf("a nil spec must not run a restore-test; got %d run(s)", rt.runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store holds the agent's LATEST backup result per target and the latest restore-test
|
||||||
|
// result — the point-in-time state the host-report surfaces. It is updated by the backup
|
||||||
|
// runner + the restore-test scheduler/selftest and read by the collector via the hub
|
||||||
|
// BackupReporter / RestoreTestReporter seams. In-memory (lost on restart; the cadence
|
||||||
|
// re-populates) and mutex-guarded for the concurrent collector vs scheduler access.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
byTarget map[string]hub.Backup // latest backup per target id
|
||||||
|
lastTest *hub.RestoreTest
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore builds an empty Store.
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{byTarget: map[string]hub.Backup{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordBackup stores the latest backup for its target.
|
||||||
|
func (s *Store) RecordBackup(b hub.Backup) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.byTarget[b.TargetID] = b
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRestoreTest stores the latest restore-test result.
|
||||||
|
func (s *Store) RecordRestoreTest(r hub.RestoreTest) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
cp := r
|
||||||
|
s.lastTest = &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backups implements hub.BackupReporter — the latest backup per target (stable order by
|
||||||
|
// target id is not guaranteed; the hub does not depend on order).
|
||||||
|
func (s *Store) Backups(context.Context) []hub.Backup {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]hub.Backup, 0, len(s.byTarget))
|
||||||
|
for _, b := range s.byTarget {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreTests implements hub.RestoreTestReporter — the latest restore-test result (0 or 1).
|
||||||
|
func (s *Store) RestoreTests(context.Context) []hub.RestoreTest {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.lastTest == nil {
|
||||||
|
return []hub.RestoreTest{}
|
||||||
|
}
|
||||||
|
return []hub.RestoreTest{*s.lastTest}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Package capability is the agent's privileged-capability self-check (slice 1 of agent
|
||||||
|
// self-health). It declares the MANIFEST — the (binary, representative-arg-vector) pairs the
|
||||||
|
// non-root agent depends on running via `sudo -n` — and a PROBE that lists each against the live
|
||||||
|
// sudoers policy (`sudo -n -l`, never executing) + checks the binary exists. The result is a
|
||||||
|
// snapshot the agent attaches to its hub report; the hub owns the ok→degraded transition + alert.
|
||||||
|
//
|
||||||
|
// Why this exists: the 2026-06-28 root→non-root cutover dropped several grants from
|
||||||
|
// configs/felhom-agent.sudoers (lxc-info, make-private, restart dnsmasq, …). Each broke a feature
|
||||||
|
// silently until a user hit it (the multi-drive flapping incident, audit 2026-06-29). A non-root
|
||||||
|
// agent that can't run a command it depends on is DEGRADED and must SAY so — at cutover, not days
|
||||||
|
// later. The companion build-time test (manifest_test.go) asserts every manifest vector is covered
|
||||||
|
// by a sudoers pattern, catching authoring gaps in CI before they ship.
|
||||||
|
package capability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Capability is one privileged command the agent depends on. Name is a stable id; Feature is the
|
||||||
|
// human-readable thing that breaks if the grant is missing (used in logs + the operator alert).
|
||||||
|
// Binary is the absolute path the runner invokes; ReprArgs is a CONCRETE argument vector that
|
||||||
|
// matches the corresponding sudoers glob (e.g. a vmid "9201" matches `[0-9]*`, a device "/dev/sda"
|
||||||
|
// matches `/dev/*`). Critical marks the user-facing ones — the hub alerts only when a Critical
|
||||||
|
// capability is degraded (non-critical degradations still ride the report snapshot + agent log).
|
||||||
|
type Capability struct {
|
||||||
|
Name string
|
||||||
|
Feature string
|
||||||
|
Binary string
|
||||||
|
ReprArgs []string
|
||||||
|
Critical bool
|
||||||
|
// GatedBy names the config gate this capability sits behind ("" = always active). A healthy
|
||||||
|
// probe of a gated capability whose gate is OFF reports StatusInactive instead of ok — see
|
||||||
|
// Prober.GateActive. Broken plumbing stays degraded regardless of the gate.
|
||||||
|
GatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatePBSDR is the gate id for the PBS-DR tier: active when this box carries a live (enabled)
|
||||||
|
// pbs_dr descriptor or a previously-converged marker (pbsdr.Manager.DRConfigured).
|
||||||
|
const GatePBSDR = "pbs_dr"
|
||||||
|
|
||||||
|
// Manifest is the required set, seeded from the 2026-06-29 sudoers audit (felhom-agent/REPORT.md):
|
||||||
|
// the OK + newly-CLOSED rows. The SURFACED/DEFERRED rows are deliberately EXCLUDED — they are not
|
||||||
|
// required capabilities: the general `pct exec <vmid> -- *` (controller-swap; arbitrary exec, an
|
||||||
|
// open operator decision), `pct create` (golden build, maintenance, no daemon caller), `mount
|
||||||
|
// UUID=…` (legacy/unreferenced), and the callerless `sensors -j`. Adding them here would assert
|
||||||
|
// grants the agent neither has nor should depend on.
|
||||||
|
//
|
||||||
|
// Each ReprArgs is a representative instance; the probe LISTS it (`sudo -n -l`) and never runs it,
|
||||||
|
// so even mkfs/pct-set entries are side-effect-free to probe.
|
||||||
|
//
|
||||||
|
// Gating (v0.86.0): every `pbsdr-*` capability is behind GatePBSDR — the whole pbsdr vector IS
|
||||||
|
// the DR-tier apply path, so the gate rides the stable name prefix (a new pbsdr-* op is gated
|
||||||
|
// automatically; nothing else ever is). Applied here, not in the literals, to keep the manifest
|
||||||
|
// entries positional.
|
||||||
|
func Manifest() []Capability {
|
||||||
|
out := make([]Capability, len(manifest))
|
||||||
|
copy(out, manifest)
|
||||||
|
for i := range out {
|
||||||
|
if strings.HasPrefix(out[i].Name, "pbsdr-") {
|
||||||
|
out[i].GatedBy = GatePBSDR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reprFingerprint is a shape-valid all-zero SHA-256 colon fingerprint for list-mode repr vectors
|
||||||
|
// (matches the wrapper's fingerprint validation; never executed).
|
||||||
|
const reprFingerprint = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00"
|
||||||
|
|
||||||
|
var manifest = []Capability{
|
||||||
|
// ---- Intermediary drive model (the multi-drive path — mostly Critical) ----
|
||||||
|
{"guest-init-pid", "drive-gate guest-sees check (multi-drive concurrency)", "/usr/bin/lxc-info", []string{"-n", "9201", "-p", "-H"}, true, ""},
|
||||||
|
{"parent-self-bind", "intermediary shared-parent self-bind", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-drives", "/mnt/felhom-drives"}, true, ""},
|
||||||
|
{"parent-make-shared", "intermediary shared-parent propagation", "/usr/bin/mount", []string{"--make-shared", "/mnt/felhom-drives"}, true, ""},
|
||||||
|
{"parent-make-private", "intermediary shared-parent peer-group isolation", "/usr/bin/mount", []string{"--make-private", "/mnt/felhom-drives"}, true, ""},
|
||||||
|
{"drive-bind", "drive attach (felhom-data bind under parent)", "/usr/bin/mount", []string{"--bind", "/mnt/felhom-usb/felhom-data", "/mnt/felhom-drives/felhom-usb"}, true, ""},
|
||||||
|
{"drive-umount", "drive detach (fail-closed unmount)", "/usr/bin/umount", []string{"/mnt/felhom-drives/felhom-usb"}, true, ""},
|
||||||
|
{"drives-mkdir-parent", "stable parent dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives"}, false, ""},
|
||||||
|
{"drives-mkdir-sub", "per-drive stable dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives/felhom-usb"}, false, ""},
|
||||||
|
{"drives-mkdir-data", "felhom-data namespace create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-usb/felhom-data"}, false, ""},
|
||||||
|
{"drives-chown-data", "felhom-data guest-root chown", "/usr/bin/chown", []string{"100000:100000", "/mnt/felhom-usb/felhom-data"}, false, ""},
|
||||||
|
{"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent-123456789.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false, ""},
|
||||||
|
{"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent-123456789.service", "/etc/systemd/system/felhom-shared-parent.service"}, false, ""},
|
||||||
|
{"parent-unit-enable", "shared-parent boot-persistence enable", "/usr/bin/systemctl", []string{"enable", "felhom-shared-parent.service"}, false, ""},
|
||||||
|
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Disk inspect / format gate (Critical: the data-bearing classifier + format) ----
|
||||||
|
{"disk-blkid", "disk data-bearing classify (format gate)", "/usr/sbin/blkid", []string{"-p", "-o", "export", "/dev/sda"}, true, ""},
|
||||||
|
{"disk-lsblk", "disk topology read (format gate)", "/usr/bin/lsblk", []string{"-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", "/dev/sda"}, true, ""},
|
||||||
|
{"disk-mkfs-ext4", "guarded format (ext4)", "/usr/local/sbin/felhom-mkfs-guarded", []string{"/dev/sda", "ext4"}, true, ""},
|
||||||
|
{"disk-mkfs-xfs", "guarded format (xfs)", "/usr/local/sbin/felhom-mkfs-guarded", []string{"/dev/sda", "xfs"}, false, ""},
|
||||||
|
{"disk-smart", "disk SMART health read", "/usr/sbin/smartctl", []string{"-a", "-j", "/dev/sda"}, false, ""},
|
||||||
|
{"disk-lvs", "thin-pool usage read", "/usr/sbin/lvs", []string{"--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", "pve/data"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Storage mount units (watchdog re-mount) ----
|
||||||
|
{"mount-unit-install", "fs-UUID mount unit install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0644", "--", "/var/lib/felhom-agent/units/felhom-x.mount", "/etc/systemd/system/felhom-x.mount"}, false, ""},
|
||||||
|
{"mount-daemon-reload", "systemd reload after unit write", "/usr/bin/systemctl", []string{"daemon-reload"}, false, ""},
|
||||||
|
{"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "felhom-x.mount"}, false, ""},
|
||||||
|
{"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "felhom-x.mount"}, false, ""},
|
||||||
|
{"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "felhom-x.mount"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Network storage re-arm + cleanup (CAMPAIGN-3 F10/F1) ----
|
||||||
|
{"netmount-reset-failed", "NAS automount re-arm after start-limit (F10)", "/usr/bin/systemctl", []string{"reset-failed", "--", "mnt-felhom\\x2ddrives-media.automount"}, false, ""},
|
||||||
|
{"netmount-rmdir", "removed-share mountpoint cleanup (F1)", "/usr/bin/rmdir", []string{"/mnt/felhom-drives/media"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Node self-heal (CAMPAIGN-3 F12-class, appliance-gated in code) ----
|
||||||
|
{"selfheal-networking-start", "appliance networking recovery at boot (F12 defense in depth)", "/usr/bin/systemctl", []string{"start", "networking.service"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Provisioning back-half ----
|
||||||
|
{"provision-chown", "bootstrap mount guest-root chown", "/usr/bin/chown", []string{"-R", "100000:100000", "/var/lib/felhom-agent/guests/9201"}, false, ""},
|
||||||
|
{"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201"}, false, ""},
|
||||||
|
{"provision-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Pre-start self-heal hook + guest lifecycle ----
|
||||||
|
{"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook-123456789.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false, ""},
|
||||||
|
{"guesthook-register", "pre-start hook register", "/usr/sbin/pct", []string{"set", "9201", "--hookscript", "local:snippets/felhom-guest-hook.sh"}, false, ""},
|
||||||
|
{"guesthook-delete-mp", "dead mountpoint slot delete (C1 net)", "/usr/sbin/pct", []string{"set", "9201", "--delete", "mp0"}, false, ""},
|
||||||
|
{"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, false, ""},
|
||||||
|
|
||||||
|
// ---- LAN split-horizon resolver (dnsmasq) ----
|
||||||
|
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
|
||||||
|
{"dnsmasq-write", "dnsmasq drop-in write", "/usr/bin/install", []string{"-m", "0644", "/tmp/felhom-resolver-x.conf", "/etc/dnsmasq.d/felhom-x.conf"}, false, ""},
|
||||||
|
{"dnsmasq-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "dnsmasq"}, false, ""},
|
||||||
|
{"dnsmasq-reload", "dnsmasq reload", "/usr/bin/systemctl", []string{"reload", "dnsmasq"}, false, ""},
|
||||||
|
{"dnsmasq-restart", "dnsmasq restart (LAN-DNS self-heal)", "/usr/bin/systemctl", []string{"restart", "dnsmasq"}, false, ""},
|
||||||
|
{"dnsmasq-rm", "dnsmasq drop-in remove (decommission)", "/usr/bin/rm", []string{"-f", "/etc/dnsmasq.d/felhom-x.conf"}, false, ""},
|
||||||
|
{"dnsmasq-guest-ip", "guest LAN IP discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "-4", "-o", "addr", "show", "dev", "eth0"}, false, ""},
|
||||||
|
{"dnsmasq-guest-domain", "guest domain discovery", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "exec", "felhom-controller", "cat", "/opt/docker/felhom-controller/controller.yaml"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Guest-network watchdog (FELHOM_GUESTNET, R-54, v0.92.0) ----
|
||||||
|
// Deliberately NOT Critical: a host that has not yet taken the new sudoers file should show
|
||||||
|
// degraded (visible, diagnosable) without paging an operator for every box on rollout day —
|
||||||
|
// the R-50b lesson that an amber fleet is background noise. The address read is covered by
|
||||||
|
// dnsmasq-guest-ip above and is not duplicated here (one command, one capability row).
|
||||||
|
{"guestnet-route", "guest default-route probe", "/usr/sbin/pct", []string{"exec", "9201", "--", "ip", "route", "show", "default"}, false, ""},
|
||||||
|
{"guestnet-ifaces", "guest interface-mode read", "/usr/sbin/pct", []string{"exec", "9201", "--", "cat", "/etc/network/interfaces"}, false, ""},
|
||||||
|
{"guestnet-dhclient-probe", "guest DHCP-client liveness probe", "/usr/sbin/pct", []string{"exec", "9201", "--", "pgrep", "-x", "dhclient"}, false, ""},
|
||||||
|
{"guestnet-heal", "guest DHCP-client restart (the 2026-07-20 heal)", "/usr/sbin/pct", []string{"exec", "9201", "--", "dhclient", "-pf", "/run/dhclient.eth0.pid", "-lf", "/var/lib/dhcp/dhclient.eth0.leases", "eth0"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Controller-swap / managed auto-update (FELHOM_CONTROLLERSWAP, v0.45.0; Critical: a
|
||||||
|
// silently-broken fleet auto-update is operator-alert-worthy) ----
|
||||||
|
{"controllerswap-read", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "cat", "/etc/felhom-controller-image"}, true, ""},
|
||||||
|
{"controllerswap-image-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "image", "inspect", "gitea.dooplex.hu/admin/felhom-controller:0.0.0"}, true, ""},
|
||||||
|
{"controllerswap-inspect", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "docker", "inspect", "-f", "{{.State.Running}}", "felhom-controller"}, true, ""},
|
||||||
|
{"controllerswap-restart", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "systemctl", "restart", "felhom-controller-bootstrap.service"}, true, ""},
|
||||||
|
{"controllerswap-write", "controller-swap / managed auto-update", "/usr/sbin/pct", []string{"exec", "9201", "--", "tee", "/etc/felhom-controller-image"}, true, ""},
|
||||||
|
|
||||||
|
// ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale
|
||||||
|
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
|
||||||
|
{"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true, ""},
|
||||||
|
|
||||||
|
// ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical FLIPPED in S4/v0.66.0 — offsite
|
||||||
|
// backups now RIDE the tunnel, so a degraded tunnel capability is operator-alert-worthy: the
|
||||||
|
// conf install, unit enable/restart and the handshake read gate the backup path. apt-install
|
||||||
|
// (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-critical. The
|
||||||
|
// handshake read is the ONLY wg invocation (never `dump`). ----
|
||||||
|
{"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false, ""},
|
||||||
|
{"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, true, ""},
|
||||||
|
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, true, ""},
|
||||||
|
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, true, ""},
|
||||||
|
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false, ""},
|
||||||
|
{"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, true, ""},
|
||||||
|
|
||||||
|
// ---- PBS DR tier apply (FELHOM_PBSDR, slice 2). NON-critical (the selfupdate rationale):
|
||||||
|
// applying the tier is an occasional hub-driven provisioning op, not a steady-state serving
|
||||||
|
// path — a degraded grant means "can't provision/reconcile the PBS entry" (the bridge reports
|
||||||
|
// loudly anyway), not a serving outage. The steady-state backup path is covered by the wg +
|
||||||
|
// storage capabilities. List-mode representations only; never executed. ----
|
||||||
|
{"pbsdr-create", "PBS DR storage-entry create (K autogen)", "/usr/local/sbin/felhom-pbs-apply", []string{"create", "felhom-pbs", "10.77.0.1", "felhom-offsite", "ns0", "felhom@pbs!ns0", reprFingerprint, "/etc/pve/priv/storage"}, false, ""},
|
||||||
|
{"pbsdr-reconcile", "PBS DR storage-entry reconcile (set-only)", "/usr/local/sbin/felhom-pbs-apply", []string{"reconcile", "felhom-pbs", "10.77.0.1", "ns0", "felhom@pbs!ns0", reprFingerprint, "/etc/pve/priv/storage"}, false, ""},
|
||||||
|
{"pbsdr-grant", "PBS DR storage ACL self-grant", "/usr/local/sbin/felhom-pbs-apply", []string{"grant", "felhom-pbs"}, false, ""},
|
||||||
|
// R-39 leg (b), v0.91.0: the credential READ path. Its absence is what made the PBS verify loop
|
||||||
|
// permanently blind to an applied-but-401 tier, so a host missing this verb is DEGRADED in a way
|
||||||
|
// that matters — it cannot detect the failure this whole tier exists to survive.
|
||||||
|
{"pbsdr-read", "PBS DR credential read (verify-loop auth probe)", "/usr/local/sbin/felhom-pbs-apply", []string{"read", "felhom-pbs", "/etc/pve/priv/storage"}, false, ""},
|
||||||
|
|
||||||
|
// ---- Escrow ceremony (FELHOM_ESCROW, controller-driven, v0.88.0). Critical: the customer
|
||||||
|
// wizard's whole run path IS this one grant — a dropped line silently breaks every ceremony.
|
||||||
|
// GatedBy is set EXPLICITLY (the name deliberately says "escrow", not "pbsdr-": the feature is
|
||||||
|
// the ceremony, but it only exists behind the DR tier — no PBS key, no ceremony). ReprArgs is
|
||||||
|
// the SHARED argv constant (internal/escrow/ceremony.go) — the exec runner uses the same one,
|
||||||
|
// so runner ↔ manifest can't drift, and TestManifestCoveredBySudoers locks manifest ↔ sudoers.
|
||||||
|
// List-mode probe only (`sudo -n -l`), spike-confirmed side-effect-free — never a real ceremony.
|
||||||
|
{"escrow-ceremony", "customer recovery-code ceremony (controller-driven)", escrow.CeremonyBinary, escrow.CeremonyArgs(), true, GatePBSDR},
|
||||||
|
|
||||||
|
// ---- Agent self-update (FELHOM_SELFUPDATE, D1). NON-critical: self-update is an occasional
|
||||||
|
// operator-driven op, not a steady-state serving path — a degraded grant means "can't
|
||||||
|
// self-update" (fall back to a manual SSH deploy), not a serving outage. The apply repr uses a
|
||||||
|
// staging-dir path + a placeholder sha (list-mode never runs it). ----
|
||||||
|
{"selfupdate-apply", "agent self-update apply (A/B flip)", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"apply", "/var/lib/felhom-agent/selfupdate/felhom-agent-0.0.0", "0000000000000000000000000000000000000000000000000000000000000000"}, false, ""},
|
||||||
|
{"selfupdate-commit", "agent self-update commit", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"commit"}, false, ""},
|
||||||
|
{"selfupdate-rollback", "agent self-update rollback", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"rollback"}, false, ""},
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package capability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sudoersPath is the in-repo allowlist, relative to this test file (internal/capability/).
|
||||||
|
const sudoersPath = "../../configs/felhom-agent.sudoers"
|
||||||
|
|
||||||
|
// parseSudoersEntries returns every command pattern from the Cmnd_Alias blocks, with the sudoers
|
||||||
|
// escapes (`\,` `\:`) unescaped. It joins continuation lines and splits the alias RHS on commas
|
||||||
|
// that are NOT backslash-escaped (escaped commas are literal arg chars, e.g. the lvs `-o` list).
|
||||||
|
func parseSudoersEntries(t *testing.T, text string) []string {
|
||||||
|
t.Helper()
|
||||||
|
// 1. Collapse line continuations, keeping only Cmnd_Alias RHS text.
|
||||||
|
var rhs strings.Builder
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
inAlias := false
|
||||||
|
for _, ln := range lines {
|
||||||
|
trimmed := strings.TrimSpace(ln)
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "Cmnd_Alias ") {
|
||||||
|
inAlias = true
|
||||||
|
if eq := strings.IndexByte(trimmed, '='); eq >= 0 {
|
||||||
|
trimmed = trimmed[eq+1:]
|
||||||
|
}
|
||||||
|
} else if !inAlias {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The final NOPASSWD line ("felhom-agent ALL=...") ends the alias region.
|
||||||
|
if strings.Contains(trimmed, "ALL=(") {
|
||||||
|
inAlias = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cont := strings.HasSuffix(trimmed, "\\")
|
||||||
|
rhs.WriteString(strings.TrimSuffix(trimmed, "\\"))
|
||||||
|
rhs.WriteString(" ")
|
||||||
|
if !cont {
|
||||||
|
// A non-continued line is the last entry of this alias. Emit a comma so it does not
|
||||||
|
// merge with the next alias's first entry when all RHS text is concatenated.
|
||||||
|
rhs.WriteString(", ")
|
||||||
|
inAlias = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Split on unescaped commas → individual command entries.
|
||||||
|
raw := rhs.String()
|
||||||
|
var entries []string
|
||||||
|
var cur strings.Builder
|
||||||
|
for i := 0; i < len(raw); i++ {
|
||||||
|
c := raw[i]
|
||||||
|
if c == '\\' && i+1 < len(raw) {
|
||||||
|
cur.WriteByte(raw[i+1]) // unescape: keep the next char literally (\, → , ; \: → :)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ',' {
|
||||||
|
entries = appendTrimmed(entries, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur.WriteByte(c)
|
||||||
|
}
|
||||||
|
entries = appendTrimmed(entries, cur.String())
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendTrimmed(entries []string, s string) []string {
|
||||||
|
if t := strings.Join(strings.Fields(s), " "); t != "" {
|
||||||
|
return append(entries, t)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// globToRegex translates a sudoers fnmatch pattern to an anchored regex. It is NOT a perfect sudo
|
||||||
|
// emulator — it only needs to catch a removed/renamed grant (the real failure mode). `*` → `.*`,
|
||||||
|
// `[...]` char classes pass through (valid regex), regex metachars are escaped.
|
||||||
|
func globToRegex(pat string) *regexp.Regexp {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("^")
|
||||||
|
for i := 0; i < len(pat); i++ {
|
||||||
|
c := pat[i]
|
||||||
|
switch {
|
||||||
|
case c == '*':
|
||||||
|
b.WriteString(".*")
|
||||||
|
case c == '[': // copy the char class verbatim (valid in regex too)
|
||||||
|
if j := strings.IndexByte(pat[i:], ']'); j > 0 {
|
||||||
|
b.WriteString(pat[i : i+j+1])
|
||||||
|
i += j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("\\[")
|
||||||
|
case strings.IndexByte(`.+()|{}^$\?`, c) >= 0:
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteByte(c)
|
||||||
|
default:
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("$")
|
||||||
|
return regexp.MustCompile(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAny reports whether cmdline matches at least one sudoers entry pattern.
|
||||||
|
func matchesAny(cmdline string, entries []string) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if globToRegex(e).MatchString(cmdline) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManifestCoveredBySudoers is the headline build-time gate: EVERY manifest capability's
|
||||||
|
// representative command line must be permitted by at least one sudoers pattern. This is exactly
|
||||||
|
// the check that would have caught the lxc-info / make-private grants being dropped at the
|
||||||
|
// 2026-06-28 cutover — in CI, before shipping.
|
||||||
|
func TestManifestCoveredBySudoers(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(sudoersPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sudoers %s: %v", sudoersPath, err)
|
||||||
|
}
|
||||||
|
entries := parseSudoersEntries(t, string(data))
|
||||||
|
if len(entries) < 20 {
|
||||||
|
t.Fatalf("parsed only %d sudoers entries — parser likely broke", len(entries))
|
||||||
|
}
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
cmdline := strings.TrimSpace(c.Binary + " " + strings.Join(c.ReprArgs, " "))
|
||||||
|
if !matchesAny(cmdline, entries) {
|
||||||
|
t.Errorf("capability %q (%s) NOT covered by any sudoers grant:\n %s",
|
||||||
|
c.Name, c.Feature, cmdline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedProof_DroppedGrantFailsCheck is the companion red-proof: with the lxc-info line removed
|
||||||
|
// from an in-memory copy of the sudoers, the coverage check for guest-init-pid MUST fail. Proves
|
||||||
|
// the build gate actually catches the regression (a green test that can never go red is hollow).
|
||||||
|
func TestRedProof_DroppedGrantFailsCheck(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(sudoersPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sudoers: %v", err)
|
||||||
|
}
|
||||||
|
// Drop the lxc-info grant line.
|
||||||
|
var kept []string
|
||||||
|
for _, ln := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.Contains(ln, "lxc-info") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, ln)
|
||||||
|
}
|
||||||
|
mutated := strings.Join(kept, "\n")
|
||||||
|
if strings.Contains(mutated, "lxc-info") {
|
||||||
|
t.Fatal("setup: lxc-info line not removed")
|
||||||
|
}
|
||||||
|
entries := parseSudoersEntries(t, mutated)
|
||||||
|
|
||||||
|
var guestInit Capability
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
if c.Name == "guest-init-pid" {
|
||||||
|
guestInit = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if guestInit.Name == "" {
|
||||||
|
t.Fatal("manifest missing guest-init-pid")
|
||||||
|
}
|
||||||
|
cmdline := guestInit.Binary + " " + strings.Join(guestInit.ReprArgs, " ")
|
||||||
|
if matchesAny(cmdline, entries) {
|
||||||
|
t.Errorf("red-proof FAILED: guest-init-pid still matches after dropping the lxc-info grant — the build gate would NOT catch the regression")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the UNMUTATED file MUST cover it (so the failure above is specific to the drop).
|
||||||
|
full := parseSudoersEntries(t, string(data))
|
||||||
|
if !matchesAny(cmdline, full) {
|
||||||
|
t.Errorf("guest-init-pid should be covered by the real sudoers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedProof_DroppedControllerSwapTeeFailsCheck is the companion red-proof for the v0.45.0
|
||||||
|
// FELHOM_CONTROLLERSWAP grants: with the `tee /etc/felhom-controller-image` line removed, the
|
||||||
|
// controllerswap-write capability MUST be reported uncovered. Proves the build gate watches the new
|
||||||
|
// swap write grant (so dropping it can't ship a non-root agent that silently can't auto-update).
|
||||||
|
func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(sudoersPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sudoers: %v", err)
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
for _, ln := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.Contains(ln, "tee /etc/felhom-controller-image") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, ln)
|
||||||
|
}
|
||||||
|
mutated := strings.Join(kept, "\n")
|
||||||
|
entries := parseSudoersEntries(t, mutated)
|
||||||
|
|
||||||
|
var write Capability
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
if c.Name == "controllerswap-write" {
|
||||||
|
write = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if write.Name == "" {
|
||||||
|
t.Fatal("manifest missing controllerswap-write")
|
||||||
|
}
|
||||||
|
cmdline := write.Binary + " " + strings.Join(write.ReprArgs, " ")
|
||||||
|
if matchesAny(cmdline, entries) {
|
||||||
|
t.Errorf("red-proof FAILED: controllerswap-write still matches after dropping the tee grant")
|
||||||
|
}
|
||||||
|
if full := parseSudoersEntries(t, string(data)); !matchesAny(cmdline, full) {
|
||||||
|
t.Errorf("controllerswap-write should be covered by the real sudoers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEscrowCeremonyArgvPinned locks the ceremony argv copies together (Scenario G, v0.88.0).
|
||||||
|
// The exec runner and the manifest entry both consume escrow.CeremonyArgs() (one shared source),
|
||||||
|
// and TestManifestCoveredBySudoers proves manifest ⊆ sudoers — so pinning the shared source to
|
||||||
|
// the EXPECTED literal here transitively locks all three: runner == manifest == sudoers.
|
||||||
|
// Red-proof: mutate one element of the argv in internal/escrow/ceremony.go and THIS test fails
|
||||||
|
// (and so does the sudoers coverage); a sudoers-side mutation is caught by the existing
|
||||||
|
// TestRedProof_* machinery.
|
||||||
|
func TestEscrowCeremonyArgvPinned(t *testing.T) {
|
||||||
|
wantBinary := "/usr/local/bin/felhom-agent"
|
||||||
|
wantArgs := []string{"--config", "/etc/felhom-agent/agent.json", "--selftest=escrow-create", "--upload", "--output=json"}
|
||||||
|
|
||||||
|
if escrow.CeremonyBinary != wantBinary {
|
||||||
|
t.Errorf("escrow.CeremonyBinary = %q, want %q", escrow.CeremonyBinary, wantBinary)
|
||||||
|
}
|
||||||
|
if got := escrow.CeremonyArgs(); !reflect.DeepEqual(got, wantArgs) {
|
||||||
|
t.Errorf("escrow.CeremonyArgs() = %q, want %q (the sudoers line + manifest entry must stay byte-identical)", got, wantArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry Capability
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
if c.Name == "escrow-ceremony" {
|
||||||
|
entry = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Name == "" {
|
||||||
|
t.Fatal("manifest missing escrow-ceremony")
|
||||||
|
}
|
||||||
|
if entry.Binary != escrow.CeremonyBinary || !reflect.DeepEqual(entry.ReprArgs, escrow.CeremonyArgs()) {
|
||||||
|
t.Errorf("manifest escrow-ceremony argv diverged from the shared constant: %s %q", entry.Binary, entry.ReprArgs)
|
||||||
|
}
|
||||||
|
if !entry.Critical {
|
||||||
|
t.Error("escrow-ceremony must be Critical (the wizard's whole run path is this one grant)")
|
||||||
|
}
|
||||||
|
if entry.GatedBy != GatePBSDR {
|
||||||
|
t.Errorf("escrow-ceremony GatedBy = %q, want %q (no PBS key → no ceremony; inactive, never red, on a DR-off box)", entry.GatedBy, GatePBSDR)
|
||||||
|
}
|
||||||
|
// CeremonyArgs must return a COPY — a caller mutating its slice must not poison the source.
|
||||||
|
mutated := escrow.CeremonyArgs()
|
||||||
|
mutated[0] = "--poisoned"
|
||||||
|
if got := escrow.CeremonyArgs(); !reflect.DeepEqual(got, wantArgs) {
|
||||||
|
t.Error("escrow.CeremonyArgs() shares its backing array — callers can mutate the source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWGCapabilityCriticality pins the exact S4 (v0.66.0) Critical set for the FELHOM_WG entries:
|
||||||
|
// the backup path (conf install, unit enable/restart, handshake read) is operator-alert-worthy now
|
||||||
|
// that offsite backups ride the tunnel; the one-time apt install and the deliberate disable
|
||||||
|
// (revocation) are NOT. Red-proof: flip any one entry's Critical in manifest.go and this fails.
|
||||||
|
func TestWGCapabilityCriticality(t *testing.T) {
|
||||||
|
wantCritical := map[string]bool{
|
||||||
|
"wg-tools-install": false,
|
||||||
|
"wg-conf-install": true,
|
||||||
|
"wg-enable": true,
|
||||||
|
"wg-restart": true,
|
||||||
|
"wg-disable": false,
|
||||||
|
"wg-handshake-read": true,
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
want, ok := wantCritical[c.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c.Name] = true
|
||||||
|
if c.Critical != want {
|
||||||
|
t.Errorf("%s: Critical = %v, want %v", c.Name, c.Critical, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range wantCritical {
|
||||||
|
if !seen[name] {
|
||||||
|
t.Errorf("manifest missing wg capability %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package capability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is one capability's live result — the wire shape the agent attaches to its hub report
|
||||||
|
// (HostReport.Capabilities). The hub mirrors this struct field-for-field and keys its alert on
|
||||||
|
// Critical+degraded. Reason is empty when ok.
|
||||||
|
//
|
||||||
|
// "inactive" (v0.86.0, DR-tier-by-default): a config-GATED capability whose plumbing is HEALTHY
|
||||||
|
// (binary present, sudo granted) but whose gating feature is disabled by configuration. Distinct
|
||||||
|
// from degraded on purpose — disabled ≠ broken; the hub renders it as a neutral chip, never red.
|
||||||
|
// Broken plumbing (binary missing / grant denied) stays DEGRADED even when the gate is off: an
|
||||||
|
// un-migrated box must never look deliberately disabled.
|
||||||
|
type Status struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Feature string `json:"feature"`
|
||||||
|
Critical bool `json:"critical"`
|
||||||
|
Status string `json:"status"` // "ok" | "degraded" | "inactive"
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOK = "ok"
|
||||||
|
StatusDegraded = "degraded"
|
||||||
|
StatusInactive = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReasonInactive is the fixed reason string for the inactive state (the hub + operator docs
|
||||||
|
// reference it verbatim).
|
||||||
|
const ReasonInactive = "disabled by configuration"
|
||||||
|
|
||||||
|
// Runner is the minimal exec seam the probe needs (satisfied by proxmox.ExecRunner). The probe
|
||||||
|
// runs `sudo -n -l -- <binary> <args…>` LITERALLY — a sudo POLICY LIST that never executes the
|
||||||
|
// command — so the Runner MUST be a DIRECT runner (RunnerDirect), not the sudo-prepending one
|
||||||
|
// (else it would double-sudo). exit 0 ⇔ the command is permitted under the NOPASSWD allowlist.
|
||||||
|
type Runner interface {
|
||||||
|
Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prober checks the manifest against the live host. Exists defaults to an os.Stat check on the
|
||||||
|
// absolute binary path (what `command -v` would resolve for an absolute path) when nil.
|
||||||
|
// GateActive answers "is the feature behind this gate id configured on?" for GATED capabilities
|
||||||
|
// (Capability.GatedBy). nil, or a gate it answers true for, keeps the historical behavior; false
|
||||||
|
// downgrades a HEALTHY probe to StatusInactive (broken plumbing stays degraded regardless).
|
||||||
|
type Prober struct {
|
||||||
|
Runner Runner
|
||||||
|
Exists func(path string) bool // nil → os.Stat
|
||||||
|
GateActive func(gate string) bool // nil → every gate treated active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe lists every manifest capability against the sudo policy and checks its binary exists,
|
||||||
|
// mapping to ok/degraded (§8 of the spec). It NEVER executes a probed command and NEVER returns a
|
||||||
|
// fatal error (serve-degraded): a probe failure is reported, not raised. If sudo itself is
|
||||||
|
// unavailable for the agent (the drop-in is missing / the user has no sudo at all), it collapses
|
||||||
|
// to ONE aggregate degraded signal instead of N identical ones.
|
||||||
|
func (p Prober) Probe(ctx context.Context) []Status {
|
||||||
|
exists := p.Exists
|
||||||
|
if exists == nil {
|
||||||
|
exists = func(path string) bool { _, err := os.Stat(path); return err == nil }
|
||||||
|
}
|
||||||
|
caps := Manifest()
|
||||||
|
|
||||||
|
// Preflight: a bare `sudo -n -l` lists the user's allowed commands. For our NOPASSWD service
|
||||||
|
// user it exits 0; if it fails, the drop-in isn't installed (or sudo is gone) and EVERY vector
|
||||||
|
// would individually fail — collapse to one aggregate signal so the operator gets one alert.
|
||||||
|
if p.Runner != nil {
|
||||||
|
if _, _, err := p.Runner.Run(ctx, "sudo", "-n", "-l"); err != nil {
|
||||||
|
return []Status{{
|
||||||
|
Name: "sudo",
|
||||||
|
Feature: "the entire privileged surface (mount/format/pct/dnsmasq/lxc-info)",
|
||||||
|
Critical: true,
|
||||||
|
Status: StatusDegraded,
|
||||||
|
Reason: "sudoers drop-in not installed / sudo unavailable",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Status, 0, len(caps))
|
||||||
|
for _, c := range caps {
|
||||||
|
s := Status{Name: c.Name, Feature: c.Feature, Critical: c.Critical, Status: StatusOK}
|
||||||
|
switch {
|
||||||
|
case !exists(c.Binary):
|
||||||
|
s.Status, s.Reason = StatusDegraded, "binary not found"
|
||||||
|
case p.Runner != nil && !p.granted(ctx, c):
|
||||||
|
s.Status, s.Reason = StatusDegraded, "sudo policy denied"
|
||||||
|
}
|
||||||
|
// Config gate (v0.86.0): only a HEALTHY probe is downgraded to inactive — a degraded one
|
||||||
|
// stays degraded (missing binary/grant = un-migrated or mis-installed box, never "off").
|
||||||
|
if s.Status == StatusOK && c.GatedBy != "" && p.GateActive != nil && !p.GateActive(c.GatedBy) {
|
||||||
|
s.Status, s.Reason = StatusInactive, ReasonInactive
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// granted reports whether `sudo -n -l -- <binary> <reprArgs…>` is permitted (exit 0). List-mode is
|
||||||
|
// side-effect-free — the command is matched against the policy, never run.
|
||||||
|
func (p Prober) granted(ctx context.Context, c Capability) bool {
|
||||||
|
args := append([]string{"-n", "-l", "--", c.Binary}, c.ReprArgs...)
|
||||||
|
_, _, err := p.Runner.Run(ctx, "sudo", args...)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarize returns (okCount, total, degraded) for logging. degraded lists DEGRADED statuses
|
||||||
|
// only — inactive is a deliberate, healthy state and must not land in the error log (it is
|
||||||
|
// counted via len(statuses)-ok-len(degraded) by callers that want it).
|
||||||
|
func Summarize(statuses []Status) (ok, total int, degraded []Status) {
|
||||||
|
total = len(statuses)
|
||||||
|
for _, s := range statuses {
|
||||||
|
switch s.Status {
|
||||||
|
case StatusOK:
|
||||||
|
ok++
|
||||||
|
case StatusDegraded:
|
||||||
|
degraded = append(degraded, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok, total, degraded
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package capability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRunner returns a canned error per (command line) and records calls. deny holds binaries (or
|
||||||
|
// the bare "sudo -n -l" preflight) that should fail; everything else exits 0.
|
||||||
|
type fakeRunner struct {
|
||||||
|
preflightErr error
|
||||||
|
denyBinary map[string]bool // binary path → policy-denied
|
||||||
|
calls int
|
||||||
|
executedReal bool // set if a probed command was ever run WITHOUT -l (must never happen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||||
|
f.calls++
|
||||||
|
// Preflight is `sudo -n -l` (exactly 2 args, no `--`).
|
||||||
|
if name == "sudo" && len(args) == 2 && args[0] == "-n" && args[1] == "-l" {
|
||||||
|
return nil, nil, f.preflightErr
|
||||||
|
}
|
||||||
|
// Every real probe must be a LIST: `sudo -n -l -- <binary> …`.
|
||||||
|
if name != "sudo" || len(args) < 4 || args[0] != "-n" || args[1] != "-l" || args[2] != "--" {
|
||||||
|
f.executedReal = true
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
binary := args[3]
|
||||||
|
if f.denyBinary[binary] {
|
||||||
|
return nil, nil, errors.New("sudo: a password is required")
|
||||||
|
}
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func find(statuses []Status, name string) Status {
|
||||||
|
for _, s := range statuses {
|
||||||
|
if s.Name == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Status{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §7-A: all grants present + binaries exist → every capability ok.
|
||||||
|
func TestProbe_AllOK(t *testing.T) {
|
||||||
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
||||||
|
p := Prober{Runner: r, Exists: func(string) bool { return true }}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
ok, total, degraded := Summarize(statuses)
|
||||||
|
if total != len(Manifest()) {
|
||||||
|
t.Fatalf("total=%d want %d", total, len(Manifest()))
|
||||||
|
}
|
||||||
|
if ok != total || len(degraded) != 0 {
|
||||||
|
t.Fatalf("expected all ok, got %d/%d (degraded: %+v)", ok, total, degraded)
|
||||||
|
}
|
||||||
|
if r.executedReal {
|
||||||
|
t.Fatal("probe executed a command without -l (must be list-only)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §7-B: one grant denied → that capability degraded "sudo policy denied", others ok. Serve-degraded.
|
||||||
|
func TestProbe_OneDenied(t *testing.T) {
|
||||||
|
r := &fakeRunner{denyBinary: map[string]bool{"/usr/bin/lxc-info": true}}
|
||||||
|
p := Prober{Runner: r, Exists: func(string) bool { return true }}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
|
||||||
|
gi := find(statuses, "guest-init-pid")
|
||||||
|
if gi.Status != StatusDegraded || gi.Reason != "sudo policy denied" {
|
||||||
|
t.Fatalf("guest-init-pid = %+v, want degraded/sudo policy denied", gi)
|
||||||
|
}
|
||||||
|
if !gi.Critical {
|
||||||
|
t.Fatal("guest-init-pid should be Critical")
|
||||||
|
}
|
||||||
|
// A sibling stays ok.
|
||||||
|
if s := find(statuses, "drive-bind"); s.Status != StatusOK {
|
||||||
|
t.Fatalf("drive-bind = %+v, want ok", s)
|
||||||
|
}
|
||||||
|
ok, total, _ := Summarize(statuses)
|
||||||
|
if ok != total-1 {
|
||||||
|
t.Fatalf("expected exactly one degraded, got ok=%d total=%d", ok, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §7-D: binary missing but policy granted → degraded "binary not found".
|
||||||
|
func TestProbe_BinaryMissing(t *testing.T) {
|
||||||
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
||||||
|
p := Prober{Runner: r, Exists: func(path string) bool { return path != "/usr/bin/lxc-info" }}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
gi := find(statuses, "guest-init-pid")
|
||||||
|
if gi.Status != StatusDegraded || gi.Reason != "binary not found" {
|
||||||
|
t.Fatalf("guest-init-pid = %+v, want degraded/binary not found", gi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §8 aggregate: sudo itself unavailable for the user → ONE aggregate degraded, not N.
|
||||||
|
func TestProbe_SudoUnavailableAggregates(t *testing.T) {
|
||||||
|
r := &fakeRunner{preflightErr: errors.New("Sorry, user felhom-agent may not run sudo"), denyBinary: map[string]bool{}}
|
||||||
|
p := Prober{Runner: r, Exists: func(string) bool { return true }}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
if len(statuses) != 1 {
|
||||||
|
t.Fatalf("expected 1 aggregate status, got %d", len(statuses))
|
||||||
|
}
|
||||||
|
s := statuses[0]
|
||||||
|
if s.Name != "sudo" || s.Status != StatusDegraded || !s.Critical || !strings.Contains(s.Reason, "drop-in not installed") {
|
||||||
|
t.Fatalf("aggregate = %+v, want critical degraded sudo-unavailable", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe must never raise — even with a nil runner (e.g. a dev path) it returns statuses.
|
||||||
|
func TestProbe_NilRunnerNoPanic(t *testing.T) {
|
||||||
|
p := Prober{Runner: nil, Exists: func(string) bool { return true }}
|
||||||
|
if got := len(p.Probe(context.Background())); got != len(Manifest()) {
|
||||||
|
t.Fatalf("nil-runner probe returned %d statuses, want %d", got, len(Manifest()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DR-tier gate (v0.86.0) ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Gate OFF + healthy plumbing → the gated pbsdr-* capabilities report INACTIVE (the neutral
|
||||||
|
// "disabled by configuration" state), NOT ok and NOT degraded — Scenario B of the DR-by-default
|
||||||
|
// spec. Ungated siblings are untouched. Red-proof partner: collapse inactive into ok (drop the
|
||||||
|
// gate branch in Probe) → this fails while TestProbe_GateOffBinaryMissingStaysDegraded passes.
|
||||||
|
func TestProbe_GateOffHealthyIsInactive(t *testing.T) {
|
||||||
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
||||||
|
p := Prober{
|
||||||
|
Runner: r,
|
||||||
|
Exists: func(string) bool { return true },
|
||||||
|
GateActive: func(gate string) bool { return gate != GatePBSDR }, // DR tier OFF
|
||||||
|
}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
// v0.88.0: escrow-ceremony joins the gate EXPLICITLY (non-pbsdr name, GatedBy literal) —
|
||||||
|
// the ceremony only exists behind the DR tier (no PBS key, no ceremony).
|
||||||
|
// v0.91.0: pbsdr-read (the R-39 credential-read verb) rides the same `pbsdr-` prefix gate — a new
|
||||||
|
// pbsdr-* op is gated by construction, which is exactly the property this list is here to hold.
|
||||||
|
for _, name := range []string{"pbsdr-create", "pbsdr-reconcile", "pbsdr-grant", "pbsdr-read", "escrow-ceremony"} {
|
||||||
|
s := find(statuses, name)
|
||||||
|
if s.Status != StatusInactive || s.Reason != ReasonInactive {
|
||||||
|
t.Fatalf("%s = %+v, want inactive/%q", name, s, ReasonInactive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An ungated sibling stays plain ok.
|
||||||
|
if s := find(statuses, "drive-bind"); s.Status != StatusOK {
|
||||||
|
t.Fatalf("drive-bind = %+v, want ok (ungated)", s)
|
||||||
|
}
|
||||||
|
// Summarize must NOT count inactive as degraded (it is not error-log-worthy).
|
||||||
|
ok, total, degraded := Summarize(statuses)
|
||||||
|
if len(degraded) != 0 {
|
||||||
|
t.Fatalf("inactive leaked into degraded: %+v", degraded)
|
||||||
|
}
|
||||||
|
if ok != total-5 {
|
||||||
|
t.Fatalf("ok=%d total=%d, want exactly the 5 gated ones non-ok", ok, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate OFF + BROKEN plumbing (binary missing) → DEGRADED stays degraded. An un-migrated
|
||||||
|
// pre-v1.15.0 box must never masquerade as deliberately disabled ("never silently pretend").
|
||||||
|
func TestProbe_GateOffBinaryMissingStaysDegraded(t *testing.T) {
|
||||||
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
||||||
|
p := Prober{
|
||||||
|
Runner: r,
|
||||||
|
Exists: func(path string) bool { return path != "/usr/local/sbin/felhom-pbs-apply" },
|
||||||
|
GateActive: func(gate string) bool { return gate != GatePBSDR }, // DR tier OFF
|
||||||
|
}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
for _, name := range []string{"pbsdr-create", "pbsdr-reconcile", "pbsdr-grant"} {
|
||||||
|
s := find(statuses, name)
|
||||||
|
if s.Status != StatusDegraded || s.Reason != "binary not found" {
|
||||||
|
t.Fatalf("%s = %+v, want degraded/binary not found even with the gate off", name, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate ON (DR configured) + healthy plumbing → plain ok, exactly the pre-v0.86.0 behavior.
|
||||||
|
// A nil GateActive must behave the same (fails ACTIVE).
|
||||||
|
func TestProbe_GateOnOrNilIsOK(t *testing.T) {
|
||||||
|
for _, gate := range []func(string) bool{nil, func(string) bool { return true }} {
|
||||||
|
r := &fakeRunner{denyBinary: map[string]bool{}}
|
||||||
|
p := Prober{Runner: r, Exists: func(string) bool { return true }, GateActive: gate}
|
||||||
|
statuses := p.Probe(context.Background())
|
||||||
|
if s := find(statuses, "pbsdr-create"); s.Status != StatusOK {
|
||||||
|
t.Fatalf("pbsdr-create = %+v, want ok (gate active/nil)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate covers exactly the pbsdr-* entries (name-prefix mechanism) PLUS escrow-ceremony (an
|
||||||
|
// explicit GatedBy literal — v0.88.0: the ceremony only exists behind the DR tier, but its name
|
||||||
|
// says what the feature is). Nothing else may be gated (a regression here would silently un-gate
|
||||||
|
// the tier or gate an unrelated capability).
|
||||||
|
func TestManifest_ExactlyPBSDRGated(t *testing.T) {
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
wantGated := strings.HasPrefix(c.Name, "pbsdr-") || c.Name == "escrow-ceremony"
|
||||||
|
if gated := c.GatedBy == GatePBSDR; gated != wantGated {
|
||||||
|
t.Fatalf("%s: GatedBy=%q, want gated=%v", c.Name, c.GatedBy, wantGated)
|
||||||
|
}
|
||||||
|
if c.GatedBy != "" && c.GatedBy != GatePBSDR {
|
||||||
|
t.Fatalf("%s: unknown gate id %q", c.Name, c.GatedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-82 Slice A.1 — per-target cadence + retention resolution.
|
||||||
|
//
|
||||||
|
// The load-bearing property is ADDITIVITY: every config that exists on a live box today must
|
||||||
|
// resolve to exactly one tier that behaves as it does now. The second property is that a
|
||||||
|
// mis-configured tier is REJECTED LOUDLY rather than defaulted — a weekly DR tier silently running
|
||||||
|
// daily would fill the datastore, and a silently dropped tier is the "applied and empty" fault
|
||||||
|
// R-82 exists to fix.
|
||||||
|
|
||||||
|
func TestBackupTiers_LegacyConfigIsUnchanged(t *testing.T) {
|
||||||
|
// Exactly the shape live on demo-felhom today.
|
||||||
|
var b BackupConfig
|
||||||
|
raw := `{"local_backup_target":"local","local_backup_retention":3,"backup_cadence_seconds":0}`
|
||||||
|
if err := json.Unmarshal([]byte(raw), &b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tiers, warnings := b.BackupTiers()
|
||||||
|
if len(warnings) != 0 {
|
||||||
|
t.Fatalf("a legacy config must produce NO warnings; got %v", warnings)
|
||||||
|
}
|
||||||
|
if len(tiers) != 1 {
|
||||||
|
t.Fatalf("a config with no backup_targets must resolve to exactly ONE tier; got %+v", tiers)
|
||||||
|
}
|
||||||
|
got := tiers[0]
|
||||||
|
if got.TargetID != "local" || got.Cadence != 24*time.Hour || got.KeepLast != 3 || !got.Primary {
|
||||||
|
t.Fatalf("legacy tier changed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty BackupConfig still resolves — to the felhom-pbs default target, 24h, keep-last 3.
|
||||||
|
// (Unchanged pre-R-82 behaviour; pinned so the default target can't drift unnoticed.)
|
||||||
|
func TestBackupTiers_ZeroConfigKeepsDefaults(t *testing.T) {
|
||||||
|
tiers, warnings := BackupConfig{}.BackupTiers()
|
||||||
|
if len(warnings) != 0 || len(tiers) != 1 {
|
||||||
|
t.Fatalf("zero config: tiers=%+v warnings=%v", tiers, warnings)
|
||||||
|
}
|
||||||
|
if tiers[0].TargetID != defaultBackupTarget || tiers[0].Cadence != 24*time.Hour || tiers[0].KeepLast != 3 {
|
||||||
|
t.Fatalf("zero-config defaults changed: %+v", tiers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: local daily + PBS weekly, each with its OWN retention.
|
||||||
|
func TestBackupTiers_LocalDailyPlusPBSWeekly(t *testing.T) {
|
||||||
|
var b BackupConfig
|
||||||
|
raw := `{
|
||||||
|
"local_backup_target":"local",
|
||||||
|
"local_backup_retention":3,
|
||||||
|
"backup_cadence_seconds":86400,
|
||||||
|
"backup_targets":[{"target_id":"felhom-pbs","cadence_seconds":604800,"keep_last":2}]
|
||||||
|
}`
|
||||||
|
if err := json.Unmarshal([]byte(raw), &b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tiers, warnings := b.BackupTiers()
|
||||||
|
if len(warnings) != 0 {
|
||||||
|
t.Fatalf("unexpected warnings: %v", warnings)
|
||||||
|
}
|
||||||
|
if len(tiers) != 2 {
|
||||||
|
t.Fatalf("want 2 tiers, got %+v", tiers)
|
||||||
|
}
|
||||||
|
if !tiers[0].Primary || tiers[0].TargetID != "local" || tiers[0].Cadence != 24*time.Hour || tiers[0].KeepLast != 3 {
|
||||||
|
t.Fatalf("primary tier wrong: %+v", tiers[0])
|
||||||
|
}
|
||||||
|
if tiers[1].Primary || tiers[1].TargetID != "felhom-pbs" || tiers[1].Cadence != 7*24*time.Hour || tiers[1].KeepLast != 2 {
|
||||||
|
t.Fatalf("PBS tier wrong: %+v", tiers[1])
|
||||||
|
}
|
||||||
|
// THE knob-sharing check: the two retentions are independent values, not one shared number.
|
||||||
|
if tiers[0].KeepLast == tiers[1].KeepLast {
|
||||||
|
t.Fatalf("this fixture sets 3 and 2 deliberately — equal values mean the knob is shared: %+v", tiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tier with no cadence is REJECTED, not defaulted. Defaulting would turn a weekly DR tier into a
|
||||||
|
// daily one and fill the 37.2 GB datastore (R-82 Phase 0, P0.3).
|
||||||
|
func TestBackupTiers_MissingCadenceIsRejectedLoudly(t *testing.T) {
|
||||||
|
b := BackupConfig{
|
||||||
|
LocalBackupTarget: "local",
|
||||||
|
ExtraTargets: []BackupTargetConfig{{TargetID: "felhom-pbs", KeepLast: 2}},
|
||||||
|
}
|
||||||
|
tiers, warnings := b.BackupTiers()
|
||||||
|
if len(tiers) != 1 {
|
||||||
|
t.Fatalf("a cadence-less tier must NOT be armed; got %+v", tiers)
|
||||||
|
}
|
||||||
|
if len(warnings) != 1 || !strings.Contains(warnings[0], "cadence_seconds must be > 0") {
|
||||||
|
t.Fatalf("rejection must be reported so the caller can log it loudly; got %v", warnings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(warnings[0], "felhom-pbs") {
|
||||||
|
t.Fatalf("the warning must name the tier it dropped; got %q", warnings[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupTiers_RejectsEmptyAndDuplicateTargets(t *testing.T) {
|
||||||
|
b := BackupConfig{
|
||||||
|
LocalBackupTarget: "local",
|
||||||
|
ExtraTargets: []BackupTargetConfig{
|
||||||
|
{TargetID: "", CadenceSeconds: 3600},
|
||||||
|
{TargetID: "local", CadenceSeconds: 3600}, // repeats the primary
|
||||||
|
{TargetID: "felhom-pbs", CadenceSeconds: 604800}, // good
|
||||||
|
{TargetID: "felhom-pbs", CadenceSeconds: 99}, // duplicate
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tiers, warnings := b.BackupTiers()
|
||||||
|
if len(tiers) != 2 || tiers[1].TargetID != "felhom-pbs" || tiers[1].Cadence != 7*24*time.Hour {
|
||||||
|
t.Fatalf("want primary + one PBS tier at the FIRST definition; got %+v", tiers)
|
||||||
|
}
|
||||||
|
if len(warnings) != 3 {
|
||||||
|
t.Fatalf("want 3 rejections (empty, duplicate-of-primary, duplicate); got %v", warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep_last unset means DO NOT PRUNE. That is the fail-safe: a DR tier must never start pruning
|
||||||
|
// itself because someone forgot a field.
|
||||||
|
func TestBackupTiers_UnsetKeepLastMeansNoPrune(t *testing.T) {
|
||||||
|
b := BackupConfig{
|
||||||
|
LocalBackupTarget: "local",
|
||||||
|
ExtraTargets: []BackupTargetConfig{{TargetID: "felhom-pbs", CadenceSeconds: 604800}},
|
||||||
|
}
|
||||||
|
tiers, _ := b.BackupTiers()
|
||||||
|
if len(tiers) != 2 {
|
||||||
|
t.Fatalf("got %+v", tiers)
|
||||||
|
}
|
||||||
|
if tiers[1].KeepLast != 0 {
|
||||||
|
t.Fatalf("an unset keep_last must resolve to 0 = never prune; got %d", tiers[1].KeepLast)
|
||||||
|
}
|
||||||
|
// And a negative is clamped to the same fail-safe rather than becoming a prune spec.
|
||||||
|
b.ExtraTargets[0].KeepLast = -5
|
||||||
|
tiers, _ = b.BackupTiers()
|
||||||
|
if tiers[1].KeepLast != 0 {
|
||||||
|
t.Fatalf("a negative keep_last must clamp to 0 (never prune); got %d", tiers[1].KeepLast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The primary's retention still comes from the legacy knob with its legacy clamp — untouched.
|
||||||
|
func TestBackupTiers_PrimaryRetentionClampUnchanged(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want int }{{0, 3}, {-1, 3}, {1, 1}, {7, 7}} {
|
||||||
|
b := BackupConfig{LocalBackupTarget: "local", LocalBackupRetention: tc.in}
|
||||||
|
tiers, _ := b.BackupTiers()
|
||||||
|
if tiers[0].KeepLast != tc.want {
|
||||||
|
t.Fatalf("LocalBackupRetention=%d → KeepLast=%d, want %d", tc.in, tiers[0].KeepLast, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-82 live-failure regression (2026-07-26): the runner hard-coded a 30-minute vzdump wait, which
|
||||||
|
// is right for a local vzdump and wrong for an offsite PBS upload. The first full ~10 GB PBS
|
||||||
|
// snapshot on demo-felhom ran past 30 min; the agent gave up waiting and recorded success=false
|
||||||
|
// WHILE THE BACKUP WAS STILL RUNNING — a false failure that leaves the tier permanently "due" and
|
||||||
|
// makes the next attempt collide with the guest lock vzdump still holds.
|
||||||
|
func TestBackupTiers_WaitTimeoutIsPerTier(t *testing.T) {
|
||||||
|
b := BackupConfig{
|
||||||
|
LocalBackupTarget: "local",
|
||||||
|
ExtraTargets: []BackupTargetConfig{{TargetID: "felhom-pbs", CadenceSeconds: 604800}},
|
||||||
|
}
|
||||||
|
tiers, _ := b.BackupTiers()
|
||||||
|
if len(tiers) != 2 {
|
||||||
|
t.Fatalf("got %+v", tiers)
|
||||||
|
}
|
||||||
|
if tiers[0].WaitTimeout != 30*time.Minute {
|
||||||
|
t.Fatalf("the PRIMARY must keep the historical 30m wait (unchanged behaviour); got %s", tiers[0].WaitTimeout)
|
||||||
|
}
|
||||||
|
if tiers[1].WaitTimeout != 12*time.Hour {
|
||||||
|
t.Fatalf("an offsite tier must default to a GENEROUS wait (operator ruling: let the first backup run as long as needed) — a false timeout is worse than a slow pass; got %s", tiers[1].WaitTimeout)
|
||||||
|
}
|
||||||
|
// And it must be overridable per tier.
|
||||||
|
b.ExtraTargets[0].WaitTimeoutSeconds = 3600
|
||||||
|
tiers, _ = b.BackupTiers()
|
||||||
|
if tiers[1].WaitTimeout != time.Hour {
|
||||||
|
t.Fatalf("wait_timeout_seconds must override; got %s", tiers[1].WaitTimeout)
|
||||||
|
}
|
||||||
|
// The two tiers must NOT share one bound.
|
||||||
|
if tiers[0].WaitTimeout == tiers[1].WaitTimeout {
|
||||||
|
t.Fatalf("wait bounds are shared between tiers — the whole point is that they differ: %+v", tiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRestoreTestPBSRestoreTimeout mirrors the BackupCadence accessor contract: positive as-is,
|
||||||
|
// 0 → default (120m), negative → default.
|
||||||
|
func TestRestoreTestPBSRestoreTimeout(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
secs int
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{0, 120 * time.Minute},
|
||||||
|
{-5, 120 * time.Minute},
|
||||||
|
{1800, 30 * time.Minute},
|
||||||
|
{7200, 120 * time.Minute},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := BackupConfig{RestoreTestPBSRestoreTimeoutSeconds: c.secs}.RestoreTestPBSRestoreTimeout()
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("RestoreTestPBSRestoreTimeout(secs=%d) = %v, want %v", c.secs, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactedMasksSecret(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
c.Proxmox.Token = "felhom-agent@pve!agent=b6547d9d-08ec-4f22-beb8-a551dc2cd69d"
|
||||||
|
got := c.Redacted().Proxmox.Token
|
||||||
|
if strings.Contains(got, "b6547d9d") {
|
||||||
|
t.Fatalf("secret leaked in redacted token: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got, "felhom-agent@pve!agent=") {
|
||||||
|
t.Errorf("redacted token lost its public prefix: %q", got)
|
||||||
|
}
|
||||||
|
// The original must be untouched (Redacted returns a copy).
|
||||||
|
if !strings.Contains(c.Proxmox.Token, "b6547d9d") {
|
||||||
|
t.Errorf("Redacted mutated the original config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
c.Proxmox.Node = "demo-felhom"
|
||||||
|
c.Proxmox.Token = "felhom-agent@pve!agent=secret"
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid config rejected: %v", err)
|
||||||
|
}
|
||||||
|
c.Proxmox.Token = "no-bang-no-eq"
|
||||||
|
if err := c.Validate(); err == nil {
|
||||||
|
t.Errorf("malformed token accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactedMasksHubKey(t *testing.T) {
|
||||||
|
c := Default()
|
||||||
|
c.Hub.APIKey = "hub-secret-abcdef"
|
||||||
|
if got := c.Redacted().Hub.APIKey; got == "hub-secret-abcdef" || got == "" {
|
||||||
|
t.Fatalf("hub key not masked: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.Hub.APIKey, "abcdef") {
|
||||||
|
t.Error("Redacted mutated the original hub key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubConfigValidate(t *testing.T) {
|
||||||
|
base := HubConfig{URL: "https://hub.felhom.eu", HostID: "h1", APIKey: "k"}
|
||||||
|
if err := base.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid hub config rejected: %v", err)
|
||||||
|
}
|
||||||
|
bad := []HubConfig{
|
||||||
|
{HostID: "h", APIKey: "k"}, // no URL
|
||||||
|
{URL: "https://x", APIKey: "k"}, // no host
|
||||||
|
{URL: "https://x", HostID: "h"}, // no key
|
||||||
|
{URL: "http://hub.felhom.eu", HostID: "h", APIKey: "k"}, // http non-loopback
|
||||||
|
{URL: "ftp://x", HostID: "h", APIKey: "k"}, // bad scheme
|
||||||
|
}
|
||||||
|
for i, h := range bad {
|
||||||
|
if err := h.Validate(); err == nil {
|
||||||
|
t.Errorf("case %d: expected validation error for %+v", i, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// http is allowed for loopback (tests).
|
||||||
|
if err := (HubConfig{URL: "http://127.0.0.1:8443", HostID: "h", APIKey: "k"}).Validate(); err != nil {
|
||||||
|
t.Errorf("http loopback should be allowed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubEnvOverlayAndDefaults(t *testing.T) {
|
||||||
|
t.Setenv("FELHOM_AGENT_HUB_URL", "https://hub.example")
|
||||||
|
t.Setenv("FELHOM_AGENT_HUB_HOST_ID", "env-host")
|
||||||
|
t.Setenv("FELHOM_AGENT_HUB_API_KEY", "env-key")
|
||||||
|
t.Setenv("FELHOM_AGENT_HUB_POLL_SECONDS", "120")
|
||||||
|
cfg, err := Load("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Hub.URL != "https://hub.example" || cfg.Hub.HostID != "env-host" || cfg.Hub.APIKey != "env-key" {
|
||||||
|
t.Errorf("hub env overlay failed: %+v", cfg.Hub)
|
||||||
|
}
|
||||||
|
if cfg.Hub.PollSeconds != 120 {
|
||||||
|
t.Errorf("poll seconds = %d, want 120", cfg.Hub.PollSeconds)
|
||||||
|
}
|
||||||
|
// withDefaults fills zero timeout.
|
||||||
|
if (HubConfig{}).WithDefaults().TimeoutSeconds != 30 {
|
||||||
|
t.Error("WithDefaults should set TimeoutSeconds=30")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileThenEnvOverride(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "agent.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"proxmox":{"node":"file-node","token":"u@pve!t=filesecret"}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("FELHOM_AGENT_PROXMOX_NODE", "env-node")
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Proxmox.Node != "env-node" {
|
||||||
|
t.Errorf("env did not override node: %q", cfg.Proxmox.Node)
|
||||||
|
}
|
||||||
|
if cfg.Proxmox.Token != "u@pve!t=filesecret" {
|
||||||
|
t.Errorf("token from file lost: %q", cfg.Proxmox.Token)
|
||||||
|
}
|
||||||
|
if cfg.Proxmox.Endpoint != "https://127.0.0.1:8006" {
|
||||||
|
t.Errorf("default endpoint lost: %q", cfg.Proxmox.Endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAMPAIGN-3 Part 6: deployment_mode gates node self-heal, and it is FAIL-SAFE to byo — absent or any
|
||||||
|
// unknown value is byo, ONLY the exact "appliance" unlocks the remedy.
|
||||||
|
func TestIsAppliance_FailSafeToByo(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
mode string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"appliance", true},
|
||||||
|
{"byo", false},
|
||||||
|
{"", false}, // absent field → byo (fail-safe)
|
||||||
|
{"Appliance", false}, // case-sensitive — a typo must not unlock the remedy
|
||||||
|
{"garbage", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
cfg := &Config{DeploymentMode: c.mode}
|
||||||
|
if got := cfg.IsAppliance(); got != c.want {
|
||||||
|
t.Errorf("IsAppliance(mode=%q) = %t, want %t", c.mode, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The env overlay can set deployment_mode (FELHOM_AGENT_DEPLOYMENT_MODE).
|
||||||
|
func TestDeploymentModeEnvOverlay(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "agent.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"proxmox":{"node":"n","token":"u@pve!t=s"},"deployment_mode":"byo"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("FELHOM_AGENT_DEPLOYMENT_MODE", "appliance")
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.IsAppliance() {
|
||||||
|
t.Errorf("env overlay did not set deployment_mode: %q", cfg.DeploymentMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-50: the island NIC fields are all-or-nothing and the guest addr must be a CIDR. A half-set or
|
||||||
|
// malformed island must fail at config load (a botched install) rather than silently fall back to
|
||||||
|
// LAN-only, which would leave a guest with an island bind and no island NIC — the exact silent break
|
||||||
|
// R-50 exists to kill. Covers LocalAPIConfig.Validate + IslandEnabled.
|
||||||
|
func TestLocalAPIConfig_IslandValidation(t *testing.T) {
|
||||||
|
base := LocalAPIConfig{Enable: true, ListenAddr: "169.254.253.1:8443"}
|
||||||
|
|
||||||
|
// both empty → fine (pre-R-50 default), IslandEnabled false
|
||||||
|
if err := base.Validate(); err != nil {
|
||||||
|
t.Errorf("no island config must validate: %v", err)
|
||||||
|
}
|
||||||
|
if base.IslandEnabled() {
|
||||||
|
t.Errorf("IslandEnabled must be false when unset")
|
||||||
|
}
|
||||||
|
// both set, valid CIDR → fine, IslandEnabled true
|
||||||
|
ok := base
|
||||||
|
ok.IslandBridge, ok.IslandGuestAddr = "vmbr9", "169.254.253.2/30"
|
||||||
|
if err := ok.Validate(); err != nil {
|
||||||
|
t.Errorf("valid island config must validate: %v", err)
|
||||||
|
}
|
||||||
|
if !ok.IslandEnabled() {
|
||||||
|
t.Errorf("IslandEnabled must be true when both set")
|
||||||
|
}
|
||||||
|
// bridge only → rejected (all-or-nothing)
|
||||||
|
half := base
|
||||||
|
half.IslandBridge = "vmbr9"
|
||||||
|
if err := half.Validate(); err == nil {
|
||||||
|
t.Errorf("half-set island (bridge only) must be rejected")
|
||||||
|
}
|
||||||
|
// guest addr only → rejected
|
||||||
|
half2 := base
|
||||||
|
half2.IslandGuestAddr = "169.254.253.2/30"
|
||||||
|
if err := half2.Validate(); err == nil {
|
||||||
|
t.Errorf("half-set island (guest addr only) must be rejected")
|
||||||
|
}
|
||||||
|
// both set but guest addr is not a CIDR → rejected
|
||||||
|
bad := base
|
||||||
|
bad.IslandBridge, bad.IslandGuestAddr = "vmbr9", "169.254.253.2" // missing /30
|
||||||
|
if err := bad.Validate(); err == nil {
|
||||||
|
t.Errorf("island guest addr without a CIDR mask must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package desired
|
||||||
|
|
||||||
|
// S3 Group C — the raw-consumer fan-out seam: called on generation advance, NOT on no-advance,
|
||||||
|
// and a panicking consumer is contained (the guest reconcile path must never break).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingConsumer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []*hub.DesiredStateResponse
|
||||||
|
panic bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingConsumer) OnDesiredState(_ context.Context, resp *hub.DesiredStateResponse) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.calls = append(r.calls, resp)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if r.panic {
|
||||||
|
panic("consumer exploded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingConsumer) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.calls) }
|
||||||
|
|
||||||
|
type stubFetcher struct{ resp *hub.DesiredStateResponse }
|
||||||
|
|
||||||
|
func (s *stubFetcher) FetchDesiredState(context.Context) (*hub.DesiredStateResponse, error) {
|
||||||
|
return s.resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResp(gen int64) *hub.DesiredStateResponse {
|
||||||
|
return &hub.DesiredStateResponse{
|
||||||
|
Generation: gen,
|
||||||
|
DesiredState: hub.WireDesiredState{
|
||||||
|
Guests: []hub.WireDesiredGuest{},
|
||||||
|
Wireguard: &hub.WireWireguard{Pubkey: "PK", AssignedIP: "10.77.0.2/32"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncer_ConsumerCalledOnAdvanceOnly(t *testing.T) {
|
||||||
|
provider := reconcile.NewCachingProvider()
|
||||||
|
f := &stubFetcher{resp: testResp(2)}
|
||||||
|
s := NewSyncer(f, provider, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c := &recordingConsumer{}
|
||||||
|
s.AddConsumer(c)
|
||||||
|
|
||||||
|
// Advance → fetch → consumer called with the raw doc (wireguard block intact).
|
||||||
|
s.OnEnvelope(context.Background(), &hub.ControlEnvelope{DesiredGeneration: 2})
|
||||||
|
if c.count() != 1 {
|
||||||
|
t.Fatalf("consumer calls = %d, want 1", c.count())
|
||||||
|
}
|
||||||
|
if c.calls[0].DesiredState.Wireguard == nil || c.calls[0].DesiredState.Wireguard.Pubkey != "PK" {
|
||||||
|
t.Fatalf("consumer got %+v — the raw wireguard block must ride through", c.calls[0].DesiredState.Wireguard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No advance → no fetch → no consumer call (the negative).
|
||||||
|
s.OnEnvelope(context.Background(), &hub.ControlEnvelope{DesiredGeneration: 2})
|
||||||
|
if c.count() != 1 {
|
||||||
|
t.Errorf("consumer called without a generation advance: %d", c.count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncer_PanickingConsumerContained(t *testing.T) {
|
||||||
|
provider := reconcile.NewCachingProvider()
|
||||||
|
f := &stubFetcher{resp: testResp(1)}
|
||||||
|
s := NewSyncer(f, provider, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
bomb := &recordingConsumer{panic: true}
|
||||||
|
after := &recordingConsumer{}
|
||||||
|
s.AddConsumer(bomb)
|
||||||
|
s.AddConsumer(after)
|
||||||
|
|
||||||
|
// Must not panic out; the second consumer still runs; the provider still updated.
|
||||||
|
s.OnEnvelope(context.Background(), &hub.ControlEnvelope{DesiredGeneration: 1})
|
||||||
|
if after.count() != 1 {
|
||||||
|
t.Errorf("consumer after the panicking one not called: %d", after.count())
|
||||||
|
}
|
||||||
|
if provider.Generation() != 1 {
|
||||||
|
t.Errorf("provider generation = %d, want 1 (guest path unaffected)", provider.Generation())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Package desired bridges the hub's "Down" channel (the control-envelope generation signal +
|
||||||
|
// the desired-state fetch) to the reconcile engine's provider (slice 10A). It implements
|
||||||
|
// hub.EnvelopeObserver: on each heartbeat it inspects the envelope's DesiredGeneration and, only
|
||||||
|
// when it has ADVANCED past the cached one, fetches the full desired-state and updates the
|
||||||
|
// engine's CachingProvider. So the heartbeat stays light; the heavy state moves on change.
|
||||||
|
//
|
||||||
|
// It lives in its own package because it imports BOTH hub (the wire client + types) and reconcile
|
||||||
|
// (the domain DesiredState + CachingProvider). hub does not import it (the loop sees only the
|
||||||
|
// hub.EnvelopeObserver seam) and reconcile does not import it — so there is no import cycle.
|
||||||
|
package desired
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetcher fetches this host's desired-state from the hub. Satisfied by *hub.Client.
|
||||||
|
type Fetcher interface {
|
||||||
|
FetchDesiredState(ctx context.Context) (*hub.DesiredStateResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawConsumer receives the FULL fetched desired-state document after each successful
|
||||||
|
// generation-advance fetch (S3 seam — internal/wgtunnel consumes its wireguard block this way
|
||||||
|
// without the reconcile engine learning about tunnels). Implementations must not block: do the
|
||||||
|
// cheap store-and-nudge, never network/exec inline.
|
||||||
|
type RawConsumer interface {
|
||||||
|
OnDesiredState(ctx context.Context, resp *hub.DesiredStateResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncer keeps the engine's CachingProvider in step with the hub's authoritative desired-state.
|
||||||
|
type Syncer struct {
|
||||||
|
fetcher Fetcher
|
||||||
|
provider *reconcile.CachingProvider
|
||||||
|
consumers []RawConsumer
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddConsumer registers a raw desired-state consumer (nil-safe no-op). Not concurrency-safe —
|
||||||
|
// call during wiring, before the hub loop starts.
|
||||||
|
func (s *Syncer) AddConsumer(c RawConsumer) {
|
||||||
|
if c != nil {
|
||||||
|
s.consumers = append(s.consumers, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncer builds a Syncer over the hub fetcher and the engine's provider.
|
||||||
|
func NewSyncer(fetcher Fetcher, provider *reconcile.CachingProvider, logger *slog.Logger) *Syncer {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Syncer{fetcher: fetcher, provider: provider, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnEnvelope implements hub.EnvelopeObserver. It fetches + caches the desired-state ONLY when the
|
||||||
|
// envelope's generation advances past the provider's cached generation — otherwise it is a no-op
|
||||||
|
// (the cached state is already current). A fetch failure keeps the last-known state (the engine
|
||||||
|
// keeps reconciling toward it) and is retried on the next advance signal.
|
||||||
|
func (s *Syncer) OnEnvelope(ctx context.Context, env *hub.ControlEnvelope) {
|
||||||
|
if env == nil || s.provider == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
have := s.provider.Generation()
|
||||||
|
if env.DesiredGeneration <= have {
|
||||||
|
return // cached: the heavy desired-state moves only on a generation advance
|
||||||
|
}
|
||||||
|
s.logger.Debug("desired: generation advanced — fetching desired-state",
|
||||||
|
"have_generation", have, "envelope_generation", env.DesiredGeneration)
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := s.fetcher.FetchDesiredState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("desired: fetch failed; keeping cached desired-state",
|
||||||
|
"have_generation", have, "envelope_generation", env.DesiredGeneration, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Debug("desired: fetched", "generation", resp.Generation,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds())
|
||||||
|
state := mapWire(resp.DesiredState, s.logger)
|
||||||
|
// Cache against the FETCHED generation (not the envelope's) — robust to a generation that
|
||||||
|
// advanced again between the heartbeat and this fetch (we won't re-fetch the same state).
|
||||||
|
s.provider.Update(resp.Generation, state)
|
||||||
|
s.logger.Info("desired: updated from hub",
|
||||||
|
"generation", resp.Generation, "guests", len(state.Guests))
|
||||||
|
// S3: fan the raw document out to registered consumers (wgtunnel etc). A panicking consumer
|
||||||
|
// is contained — the guest reconcile path must never break over a tunnel add-on.
|
||||||
|
for _, c := range s.consumers {
|
||||||
|
s.notifyConsumer(ctx, c, resp)
|
||||||
|
}
|
||||||
|
if env.HasSignedOps {
|
||||||
|
// 10A only notes the flag; fetching + verifying + executing signed ops is slice 10B.
|
||||||
|
s.logger.Info("desired: hub reports pending signed ops (fetch/execute is slice 10B)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyConsumer delivers one raw document with panic containment.
|
||||||
|
func (s *Syncer) notifyConsumer(ctx context.Context, c RawConsumer, resp *hub.DesiredStateResponse) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
s.logger.Error("desired: raw consumer panicked (contained)", "panic", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
c.OnDesiredState(ctx, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapWire maps the hub wire desired-state to the reconcile domain. 10A acts only on guests; the
|
||||||
|
// forward-compat fields (restore_directive — 10D — etc.) are carried on the wire and logged, but
|
||||||
|
// not translated into actions here.
|
||||||
|
func mapWire(w hub.WireDesiredState, logger *slog.Logger) reconcile.DesiredState {
|
||||||
|
guests := make(map[int]reconcile.DesiredGuest, len(w.Guests))
|
||||||
|
for _, g := range w.Guests {
|
||||||
|
dg := reconcile.DesiredGuest{
|
||||||
|
VMID: g.VMID,
|
||||||
|
Spec: g.Spec,
|
||||||
|
Description: g.Description,
|
||||||
|
Decommission: g.Decommission,
|
||||||
|
}
|
||||||
|
switch g.Run {
|
||||||
|
case "running":
|
||||||
|
dg.Run = reconcile.RunRunning
|
||||||
|
case "stopped":
|
||||||
|
dg.Run = reconcile.RunStopped
|
||||||
|
default:
|
||||||
|
dg.Run = reconcile.RunUnspecified // unknown/empty → unmanaged (planner leaves run alone)
|
||||||
|
}
|
||||||
|
guests[g.VMID] = dg
|
||||||
|
}
|
||||||
|
if w.RestoreDirective != nil {
|
||||||
|
// The reconcile mapping does NOT act on the directive; the DR consumer (raw-consumer seam,
|
||||||
|
// S5 internal/dr) surfaces it as an inspectable restore PLAN — no restore is executed here.
|
||||||
|
logger.Info("desired: restore_directive present (surfaced as a restore PLAN by the DR consumer; not acted on in the reconcile mapping)",
|
||||||
|
"mode", w.RestoreDirective.Mode)
|
||||||
|
}
|
||||||
|
return reconcile.DesiredState{Guests: guests}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package desired
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
// fakeFetcher counts FetchDesiredState calls and returns a fixed response (or error).
|
||||||
|
type fakeFetcher struct {
|
||||||
|
resp *hub.DesiredStateResponse
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFetcher) FetchDesiredState(context.Context) (*hub.DesiredStateResponse, error) {
|
||||||
|
f.calls++
|
||||||
|
return f.resp, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(gen int64, signed bool) *hub.ControlEnvelope {
|
||||||
|
return &hub.ControlEnvelope{DesiredGeneration: gen, HasSignedOps: signed}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The headline caching behaviour: desired-state is fetched ONCE when the generation advances, and
|
||||||
|
// NOT re-fetched while the generation is unchanged (the heartbeat stays light).
|
||||||
|
func TestSyncer_FetchesOnceOnGenerationAdvance(t *testing.T) {
|
||||||
|
run := "running"
|
||||||
|
f := &fakeFetcher{resp: &hub.DesiredStateResponse{
|
||||||
|
Generation: 1,
|
||||||
|
DesiredState: hub.WireDesiredState{Guests: []hub.WireDesiredGuest{
|
||||||
|
{VMID: 100, Run: run},
|
||||||
|
{VMID: 200, Decommission: true},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
p := reconcile.NewCachingProvider()
|
||||||
|
s := NewSyncer(f, p, quiet())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Generation 0 (fresh host, no intent) → NO fetch.
|
||||||
|
s.OnEnvelope(ctx, env(0, false))
|
||||||
|
if f.calls != 0 {
|
||||||
|
t.Fatalf("fetched %d times at generation 0, want 0", f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generation advances to 1 → fetch exactly once, cache updated.
|
||||||
|
s.OnEnvelope(ctx, env(1, false))
|
||||||
|
if f.calls != 1 {
|
||||||
|
t.Fatalf("fetched %d times on advance, want 1", f.calls)
|
||||||
|
}
|
||||||
|
if p.Generation() != 1 {
|
||||||
|
t.Errorf("cached generation = %d, want 1", p.Generation())
|
||||||
|
}
|
||||||
|
st, _ := p.Desired(ctx)
|
||||||
|
if st.Guests[100].Run != reconcile.RunRunning {
|
||||||
|
t.Errorf("guest 100 run = %q, want running", st.Guests[100].Run)
|
||||||
|
}
|
||||||
|
if !st.Guests[200].Decommission {
|
||||||
|
t.Errorf("guest 200 decommission = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same generation on the next heartbeats → NO re-fetch (cached).
|
||||||
|
s.OnEnvelope(ctx, env(1, false))
|
||||||
|
s.OnEnvelope(ctx, env(1, false))
|
||||||
|
if f.calls != 1 {
|
||||||
|
t.Errorf("re-fetched on an unchanged generation (calls=%d, want 1)", f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A further advance → one more fetch.
|
||||||
|
f.resp = &hub.DesiredStateResponse{Generation: 2, DesiredState: hub.WireDesiredState{}}
|
||||||
|
s.OnEnvelope(ctx, env(2, false))
|
||||||
|
if f.calls != 2 || p.Generation() != 2 {
|
||||||
|
t.Errorf("second advance: calls=%d gen=%d, want 2/2", f.calls, p.Generation())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fetch failure keeps the last-known cache (the engine keeps reconciling toward it) and does not
|
||||||
|
// advance the cached generation (so the next heartbeat retries).
|
||||||
|
func TestSyncer_FetchFailureKeepsCache(t *testing.T) {
|
||||||
|
p := reconcile.NewCachingProvider()
|
||||||
|
p.Update(1, reconcile.DesiredState{Guests: map[int]reconcile.DesiredGuest{100: {VMID: 100, Run: reconcile.RunRunning}}})
|
||||||
|
f := &fakeFetcher{err: errors.New("hub down")}
|
||||||
|
s := NewSyncer(f, p, quiet())
|
||||||
|
|
||||||
|
s.OnEnvelope(context.Background(), env(5, false)) // generation jumped, but fetch fails
|
||||||
|
if p.Generation() != 1 {
|
||||||
|
t.Errorf("generation advanced to %d despite fetch failure, want kept at 1", p.Generation())
|
||||||
|
}
|
||||||
|
st, _ := p.Desired(context.Background())
|
||||||
|
if st.Guests[100].Run != reconcile.RunRunning {
|
||||||
|
t.Errorf("cache lost on fetch failure: %+v", st.Guests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fetched generation (not the envelope's) is what gets cached — robust to a generation that
|
||||||
|
// advanced again between the heartbeat and the fetch.
|
||||||
|
func TestSyncer_CachesFetchedGeneration(t *testing.T) {
|
||||||
|
f := &fakeFetcher{resp: &hub.DesiredStateResponse{Generation: 7, DesiredState: hub.WireDesiredState{}}}
|
||||||
|
p := reconcile.NewCachingProvider()
|
||||||
|
s := NewSyncer(f, p, quiet())
|
||||||
|
s.OnEnvelope(context.Background(), env(5, false)) // envelope said 5, fetch returned 7
|
||||||
|
if p.Generation() != 7 {
|
||||||
|
t.Errorf("cached generation = %d, want 7 (the fetched generation)", p.Generation())
|
||||||
|
}
|
||||||
|
// A later envelope at generation 6 must NOT trigger a re-fetch (we already have 7).
|
||||||
|
s.OnEnvelope(context.Background(), env(6, false))
|
||||||
|
if f.calls != 1 {
|
||||||
|
t.Errorf("re-fetched at generation 6 when cache is 7 (calls=%d)", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Package dr consumes the host-loss restore_directive (slice 10D / S5) into an inspectable restore
|
||||||
|
// PLAN. It is DERIVE-AND-SURFACE only: the plan is logged (and exposed for the report), never
|
||||||
|
// executed — the destructive restore is a separate, operator-present, STOP-gated step. The Consumer
|
||||||
|
// has NO restore/destroy API by construction, so "execute nothing" is a structural guarantee.
|
||||||
|
package dr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestorePlan is the derived-but-not-executed host-loss plan: per guest → restore coords + sizing;
|
||||||
|
// per drive → durable_id → expected mount. No secrets (coordinates/identifiers/sizes only).
|
||||||
|
type RestorePlan struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Guests []PlannedGuest `json:"guests"`
|
||||||
|
Drives []PlannedDrive `json:"drives"`
|
||||||
|
PBS *hub.DRPBSCoord `json:"pbs,omitempty"` // WHERE the offsite backups live (repo/ns/latest snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlannedGuest is one guest to restore in place, from the offsite datastore, at its original sizing.
|
||||||
|
type PlannedGuest struct {
|
||||||
|
VMID int `json:"vmid"`
|
||||||
|
Archive string `json:"archive,omitempty"` // explicit archive from the directive; "" → resolve latest at restore time
|
||||||
|
TargetStorage string `json:"target_storage"` // where the restored volumes land (e.g. local-lvm)
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
MemoryBytes int64 `json:"memory_bytes"`
|
||||||
|
DiskBytes int64 `json:"disk_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlannedDrive is one data drive to re-attach BY DURABLE_ID (the wrong-disk guard: a match attaches,
|
||||||
|
// a non-match is refused — the matcher, exercised in the Part-4 spike, never resolves to a near disk).
|
||||||
|
type PlannedDrive struct {
|
||||||
|
DurableID string `json:"durable_id"`
|
||||||
|
ExpectedMount string `json:"expected_mount"`
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRestorePlan derives the plan from a host_loss directive + the live DR recipe. Returns
|
||||||
|
// (nil,false) for a guest_loss/absent directive or a nil recipe (nothing to plan). PURE: reads
|
||||||
|
// nothing, executes nothing — the whole point of this slice's safe half.
|
||||||
|
func BuildRestorePlan(directive *hub.WireRestoreDirective, recipe *hub.DRRecipeHostHalf, restoreStorage string) (*RestorePlan, bool) {
|
||||||
|
if directive == nil || directive.Mode != "host_loss" || recipe == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
plan := &RestorePlan{Mode: directive.Mode, PBS: recipe.PBS}
|
||||||
|
for _, g := range recipe.Guests {
|
||||||
|
pg := PlannedGuest{
|
||||||
|
VMID: g.VMID,
|
||||||
|
TargetStorage: restoreStorage,
|
||||||
|
Cores: g.Cores,
|
||||||
|
MemoryBytes: g.MemoryBytes,
|
||||||
|
DiskBytes: g.DiskBytes,
|
||||||
|
}
|
||||||
|
// The directive may name an explicit archive for a specific guest (else the restore step
|
||||||
|
// resolves the latest snapshot from the PBS coord at execution time).
|
||||||
|
if directive.Archive != "" && (directive.VMID == 0 || directive.VMID == g.VMID) {
|
||||||
|
pg.Archive = directive.Archive
|
||||||
|
}
|
||||||
|
plan.Guests = append(plan.Guests, pg)
|
||||||
|
}
|
||||||
|
for _, d := range recipe.Drives {
|
||||||
|
plan.Drives = append(plan.Drives, PlannedDrive{
|
||||||
|
DurableID: d.DurableID,
|
||||||
|
ExpectedMount: d.MountPath,
|
||||||
|
Intent: d.Intent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plan, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipeFunc yields the current DR recipe (the agent-derived scaffolding). It is called ONLY when a
|
||||||
|
// host_loss directive is present (a rare DR event), so an on-demand Collect is acceptable.
|
||||||
|
type RecipeFunc func(ctx context.Context) *hub.DRRecipeHostHalf
|
||||||
|
|
||||||
|
// Consumer implements desired.RawConsumer: on a host_loss restore_directive it builds + SURFACES the
|
||||||
|
// plan (structured log + LastPlan for the report/inspection) and executes NOTHING. A guest_loss or
|
||||||
|
// absent directive clears the plan. It holds no restore/destroy dependency — surfacing is all it can do.
|
||||||
|
type Consumer struct {
|
||||||
|
recipe RecipeFunc
|
||||||
|
restoreStorage string
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastPlan *RestorePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConsumer builds the DR plan consumer. recipe may be nil (then no plan can be built — logged).
|
||||||
|
func NewConsumer(recipe RecipeFunc, restoreStorage string, logger *slog.Logger) *Consumer {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Consumer{recipe: recipe, restoreStorage: restoreStorage, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDesiredState implements desired.RawConsumer. Non-host_loss → clear + no-op.
|
||||||
|
func (c *Consumer) OnDesiredState(ctx context.Context, resp *hub.DesiredStateResponse) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := resp.DesiredState.RestoreDirective
|
||||||
|
if dir == nil || dir.Mode != "host_loss" {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastPlan = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var recipe *hub.DRRecipeHostHalf
|
||||||
|
if c.recipe != nil {
|
||||||
|
recipe = c.recipe(ctx)
|
||||||
|
}
|
||||||
|
plan, ok := BuildRestorePlan(dir, recipe, c.restoreStorage)
|
||||||
|
if !ok {
|
||||||
|
c.logger.Warn("dr: host_loss restore_directive present but no DR recipe available yet — cannot build a plan",
|
||||||
|
"directive_vmid", dir.VMID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastPlan = plan
|
||||||
|
c.mu.Unlock()
|
||||||
|
// SURFACE only — the destructive restore is a separate, operator-present step.
|
||||||
|
c.logger.Warn("dr: host_loss RESTORE PLAN derived (NOT executed — supervised in-place restore is a separate, gated step)",
|
||||||
|
"mode", plan.Mode, "guests", len(plan.Guests), "drives", len(plan.Drives), "plan", plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastPlan returns the most recently derived plan (nil if none / cleared). For the report + tests.
|
||||||
|
func (c *Consumer) LastPlan() *RestorePlan {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.lastPlan
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package dr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleRecipe() *hub.DRRecipeHostHalf {
|
||||||
|
return &hub.DRRecipeHostHalf{
|
||||||
|
RecipeVersion: 1,
|
||||||
|
Guests: []hub.DRGuest{{VMID: 9201, Cores: 2, MemoryBytes: 12 << 30, DiskBytes: 32 << 30}},
|
||||||
|
PBS: &hub.DRPBSCoord{RepoID: "felhom-offsite", Namespace: "demo-felhom-01", LatestSnapshotID: "9201"},
|
||||||
|
Drives: []hub.DRDrive{{DurableID: "uuid:abc", MountPath: "/mnt/felhom-drives/photos", Intent: "enrolled", TotalBytes: 500 << 30}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRestorePlan_HostLoss: a host_loss directive + recipe yields per-guest {vmid, archive,
|
||||||
|
// target, sizing} + per-drive {durable_id → mount} + the offsite PBS coord.
|
||||||
|
func TestBuildRestorePlan_HostLoss(t *testing.T) {
|
||||||
|
dir := &hub.WireRestoreDirective{Mode: "host_loss", VMID: 9201, Archive: "felhom-offsite:backup/ct/9201/2026-07-04T14:55:44Z"}
|
||||||
|
plan, ok := BuildRestorePlan(dir, sampleRecipe(), "local-lvm")
|
||||||
|
if !ok || plan == nil {
|
||||||
|
t.Fatal("host_loss must yield a plan")
|
||||||
|
}
|
||||||
|
if plan.Mode != "host_loss" || len(plan.Guests) != 1 || len(plan.Drives) != 1 {
|
||||||
|
t.Fatalf("plan shape = %+v", plan)
|
||||||
|
}
|
||||||
|
g := plan.Guests[0]
|
||||||
|
if g.VMID != 9201 || g.TargetStorage != "local-lvm" || g.Cores != 2 || g.DiskBytes != 32<<30 {
|
||||||
|
t.Errorf("planned guest = %+v", g)
|
||||||
|
}
|
||||||
|
if g.Archive != dir.Archive {
|
||||||
|
t.Errorf("planned guest archive = %q, want the directive's %q", g.Archive, dir.Archive)
|
||||||
|
}
|
||||||
|
d := plan.Drives[0]
|
||||||
|
if d.DurableID != "uuid:abc" || d.ExpectedMount != "/mnt/felhom-drives/photos" {
|
||||||
|
t.Errorf("planned drive (durable_id→mount) = %+v", d)
|
||||||
|
}
|
||||||
|
if plan.PBS == nil || plan.PBS.RepoID != "felhom-offsite" {
|
||||||
|
t.Errorf("plan must carry the offsite PBS coord, got %+v", plan.PBS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRestorePlan_NoPlanCases is the red-proof anchor: guest_loss / absent / nil-recipe yield
|
||||||
|
// NO plan (execute-nothing on the wrong mode). Relaxing the mode gate → the guest_loss case fails.
|
||||||
|
func TestBuildRestorePlan_NoPlanCases(t *testing.T) {
|
||||||
|
if _, ok := BuildRestorePlan(&hub.WireRestoreDirective{Mode: "guest_loss", VMID: 9201}, sampleRecipe(), "local-lvm"); ok {
|
||||||
|
t.Error("guest_loss must NOT yield a host-loss plan")
|
||||||
|
}
|
||||||
|
if _, ok := BuildRestorePlan(nil, sampleRecipe(), "local-lvm"); ok {
|
||||||
|
t.Error("absent directive must NOT yield a plan")
|
||||||
|
}
|
||||||
|
if _, ok := BuildRestorePlan(&hub.WireRestoreDirective{Mode: "host_loss"}, nil, "local-lvm"); ok {
|
||||||
|
t.Error("nil recipe must NOT yield a plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsumer_SurfacesPlanNeverExecutes: the consumer surfaces the plan on host_loss, consults the
|
||||||
|
// recipe only then, and clears it otherwise. It has NO restore/destroy dependency (execute-nothing
|
||||||
|
// is structural — the type literally cannot call a restore).
|
||||||
|
func TestConsumer_SurfacesPlanNeverExecutes(t *testing.T) {
|
||||||
|
recipeCalls := 0
|
||||||
|
c := NewConsumer(func(context.Context) *hub.DRRecipeHostHalf { recipeCalls++; return sampleRecipe() }, "local-lvm", nil)
|
||||||
|
ds := func(d *hub.WireRestoreDirective) *hub.DesiredStateResponse {
|
||||||
|
return &hub.DesiredStateResponse{DesiredState: hub.WireDesiredState{RestoreDirective: d}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-host_loss → no plan, recipe NOT consulted.
|
||||||
|
c.OnDesiredState(context.Background(), ds(&hub.WireRestoreDirective{Mode: "guest_loss"}))
|
||||||
|
if c.LastPlan() != nil {
|
||||||
|
t.Error("guest_loss set a plan")
|
||||||
|
}
|
||||||
|
if recipeCalls != 0 {
|
||||||
|
t.Errorf("recipe consulted on a non-host_loss directive (%d calls)", recipeCalls)
|
||||||
|
}
|
||||||
|
// host_loss → plan surfaced, recipe consulted once.
|
||||||
|
c.OnDesiredState(context.Background(), ds(&hub.WireRestoreDirective{Mode: "host_loss", VMID: 9201}))
|
||||||
|
p := c.LastPlan()
|
||||||
|
if p == nil || len(p.Guests) != 1 || p.Guests[0].VMID != 9201 {
|
||||||
|
t.Fatalf("host_loss plan = %+v", p)
|
||||||
|
}
|
||||||
|
if recipeCalls != 1 {
|
||||||
|
t.Errorf("recipe calls = %d, want 1", recipeCalls)
|
||||||
|
}
|
||||||
|
// absent directive clears the plan.
|
||||||
|
c.OnDesiredState(context.Background(), ds(nil))
|
||||||
|
if c.LastPlan() != nil {
|
||||||
|
t.Error("absent directive did not clear the plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
// Controller-driven ceremony contract (v0.88.0, TASK 2026-07-13; mechanics validated by
|
||||||
|
// felhom.eu/documentation/audits/SPIKE-controller-escrow-2026-07-13.md). The agent's local API
|
||||||
|
// re-invokes the agent binary as root via `sudo -n` with ONE fixed argument vector; sudoers
|
||||||
|
// matches that vector byte-for-byte (spike §2.2: any alteration — value, extra flag, order,
|
||||||
|
// config path — is refused), so the argv below is the SINGLE SOURCE OF TRUTH shared by the
|
||||||
|
// exec (localapi), the capability manifest entry, and (byte-identically) the FELHOM_ESCROW
|
||||||
|
// sudoers line. Never build it with flag helpers and never normalize `--` to `-` — Go's flag
|
||||||
|
// package would accept either spelling, sudoers only the literal one.
|
||||||
|
|
||||||
|
// CeremonyBinary is the installed agent binary path the sudoers line pins.
|
||||||
|
const CeremonyBinary = "/usr/local/bin/felhom-agent"
|
||||||
|
|
||||||
|
// ceremonyArgv is the fixed vector. --config is pinned explicitly: `sudo -n` env_reset strips
|
||||||
|
// FELHOM_AGENT_CONFIG, and the pin closes env-injection of an alternate config (spike probe (e)).
|
||||||
|
var ceremonyArgv = []string{
|
||||||
|
"--config", "/etc/felhom-agent/agent.json",
|
||||||
|
"--selftest=escrow-create",
|
||||||
|
"--upload",
|
||||||
|
"--output=json",
|
||||||
|
}
|
||||||
|
|
||||||
|
// CeremonyArgs returns a fresh copy of the fixed argv (callers must not be able to mutate the
|
||||||
|
// shared source).
|
||||||
|
func CeremonyArgs() []string {
|
||||||
|
out := make([]string, len(ceremonyArgv))
|
||||||
|
copy(out, ceremonyArgv)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CeremonyOutput is the --output=json wire contract (version 1): the ONE JSON object json mode
|
||||||
|
// emits on stdout — nothing else lands there; every human/info line goes to stderr. RecoveryCode
|
||||||
|
// is the only secret field: the consumer must extract it, hand it to the one-shot claim holder,
|
||||||
|
// and zero both the parsed struct and the raw stdout buffer. (Best-effort — Go's GC may hold
|
||||||
|
// stale copies; the discipline still shrinks the exposure window.)
|
||||||
|
type CeremonyOutput struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
RecoveryCode string `json:"recovery_code"`
|
||||||
|
KeyFingerprint string `json:"key_fingerprint"`
|
||||||
|
EntropyBits float64 `json:"entropy_bits"`
|
||||||
|
BlobBytes int `json:"blob_bytes"`
|
||||||
|
IdentityBlobBytes int `json:"identity_blob_bytes"`
|
||||||
|
ResticPwSealed bool `json:"restic_pw_sealed"`
|
||||||
|
Uploaded bool `json:"uploaded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CeremonyOutputVersion is the current CeremonyOutput.Version value.
|
||||||
|
const CeremonyOutputVersion = 1
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Consume is the slice-10C production escrow-consumption path — the throwaway 10C spike harness
|
||||||
|
// turned into a real, tested function. It recovers the PBS client encryption key `K` from an
|
||||||
|
// R-wrapped escrow `blob`, GATES it on the expected key fingerprint, and installs it at `keyDest`
|
||||||
|
// for the PBS restore path. It is the inverse of Create: Create wraps `K` under `R`; Consume
|
||||||
|
// unwraps it back. The DR orchestration around it (re-enroll in restore mode, source the directive
|
||||||
|
// from the hub, decide which guests to restore) is slice 10D — Consume takes its four inputs as
|
||||||
|
// PARAMETERS so it stays standalone-testable (exactly as the spike harness was).
|
||||||
|
//
|
||||||
|
// The four inputs (the consumption contract — spike findings):
|
||||||
|
// - blob: the opaque R-wrapped escrow blob (hub-served restore directive in 10D).
|
||||||
|
// - recoveryCode (R): BY HAND from the customer — NEVER hub-sourced (zero-knowledge holds: a
|
||||||
|
// hub compromise alone cannot decrypt). A secret: never logged/persisted.
|
||||||
|
// - expectedFingerprint: hub-served — the gate target (F-C4).
|
||||||
|
// - keyDest: where the PBS restore reads the key (`--keyfile` or the default
|
||||||
|
// `$XDG_CONFIG_HOME/proxmox-backup/encryption-key.json`).
|
||||||
|
//
|
||||||
|
// Order (spike F-C2/F-C3/F-C4/F-C6): Unwrap → fingerprint-gate → install. On ANY failure there is
|
||||||
|
// NO partial install (nothing at keyDest) and no key material leaks: the recovered key lives only
|
||||||
|
// in a 0600 tempdir that is always removed; `R` and key bytes are never logged (only fingerprint
|
||||||
|
// prefixes); the input `blob` is read-only, so a failed Consume is RETRYABLE.
|
||||||
|
func Consume(ctx context.Context, blob []byte, recoveryCode, expectedFingerprint, keyDest string) error {
|
||||||
|
if len(blob) == 0 {
|
||||||
|
return fmt.Errorf("escrow: Consume needs a non-empty blob")
|
||||||
|
}
|
||||||
|
if recoveryCode == "" {
|
||||||
|
return fmt.Errorf("escrow: Consume needs the recovery code (R)")
|
||||||
|
}
|
||||||
|
if expectedFingerprint == "" {
|
||||||
|
// The fingerprint gate is mandatory — without it we'd install whatever a (wrong-but-valid)
|
||||||
|
// unwrap produced. There is no "skip the gate" path.
|
||||||
|
return fmt.Errorf("escrow: Consume needs the expected key fingerprint (the gate target)")
|
||||||
|
}
|
||||||
|
if keyDest == "" {
|
||||||
|
return fmt.Errorf("escrow: Consume needs a key destination path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work in a private 0700 tempdir; RemoveAll always runs, so the recovered key never lingers.
|
||||||
|
work, err := os.MkdirTemp("", "felhom-consume-")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("escrow: tempdir: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(work)
|
||||||
|
recovered := filepath.Join(work, "recovered.key")
|
||||||
|
|
||||||
|
// Operate on a COPY of the blob (F-C6: the input blob is read-only → a failed Consume is
|
||||||
|
// retryable). Unwrap re-keys this copy in place.
|
||||||
|
if err := os.WriteFile(recovered, blob, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("escrow: staging blob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Unwrap with R. A WRONG R fails closed at the scrypt KDF (F-C3): nonzero exit, no key
|
||||||
|
// emitted. Surface a clear, R-free error; nothing is written to keyDest.
|
||||||
|
if err := Unwrap(ctx, recovered, recoveryCode); err != nil {
|
||||||
|
return fmt.Errorf("escrow: the recovery code did not unwrap the escrow (wrong recovery code, or a corrupt blob): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fingerprint gate (F-C4) — the cheap correctness check, BEFORE any install or multi-GB
|
||||||
|
// restore. A mismatch means a wrong escrow/datastore (or a recovery code that, against the odds,
|
||||||
|
// produced a different key): fail fast + loud, no install.
|
||||||
|
recFP, err := KeyFingerprint(ctx, recovered)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("escrow: reading recovered key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
if !fingerprintsEqual(recFP, expectedFingerprint) {
|
||||||
|
return fmt.Errorf("escrow: recovered key fingerprint %s != expected %s — wrong escrow/datastore (not installing)",
|
||||||
|
shortFP(recFP), shortFP(expectedFingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Install atomically at keyDest (0600) — the only thing Consume writes outside its tempdir.
|
||||||
|
// A failure here leaves nothing partial at keyDest (the temp sibling is removed).
|
||||||
|
if err := installKey(recovered, keyDest); err != nil {
|
||||||
|
return fmt.Errorf("escrow: installing recovered key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installKey atomically places the recovered key at dest with 0600: write a sibling temp on the
|
||||||
|
// SAME filesystem, fsync, then rename (atomic). The parent dir is created 0700 if missing. On any
|
||||||
|
// error the temp is removed so dest is never left partial.
|
||||||
|
func installKey(recovered, dest string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("key dir: %w", err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(recovered)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var rnd [8]byte
|
||||||
|
_, _ = rand.Read(rnd[:])
|
||||||
|
tmp := dest + ".tmp-" + hex.EncodeToString(rnd[:])
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(b); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, dest); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fingerprintsEqual compares two PBS key fingerprints tolerant of formatting (case + ':'/' '
|
||||||
|
// separators) — the hub-stored value and `key show`'s output may differ only cosmetically.
|
||||||
|
func fingerprintsEqual(a, b string) bool {
|
||||||
|
return normFP(a) == normFP(b) && normFP(a) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normFP(s string) string {
|
||||||
|
return strings.ToLower(strings.NewReplacer(":", "", " ", "", "\t", "", "\n", "").Replace(strings.TrimSpace(s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortFP returns a log-safe truncated fingerprint prefix (a key fingerprint is an identifier, not
|
||||||
|
// the key — but we still show only a prefix in logs/errors).
|
||||||
|
func shortFP(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) > 23 { // ~ first 8 colon-separated octets
|
||||||
|
return s[:23] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- pure-unit tests (no proxmox-backup-client) ----------------------------------------------
|
||||||
|
|
||||||
|
func TestConsume_InputValidation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dest := filepath.Join(t.TempDir(), "k.json")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
blob []byte
|
||||||
|
r, fp, dst string
|
||||||
|
}{
|
||||||
|
{"empty blob", nil, "R", "fp", dest},
|
||||||
|
{"empty R", []byte("x"), "", "fp", dest},
|
||||||
|
{"empty fingerprint", []byte("x"), "R", "", dest},
|
||||||
|
{"empty dest", []byte("x"), "R", "fp", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := Consume(ctx, c.blob, c.r, c.fp, c.dst); err == nil {
|
||||||
|
t.Errorf("%s: expected an error", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no key written on a validation failure
|
||||||
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||||
|
t.Error("a validation failure left a key behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintsEqual_NormalizesFormatting(t *testing.T) {
|
||||||
|
if !fingerprintsEqual("01:36:E9:FE", "0136e9fe") {
|
||||||
|
t.Error("case/colon-insensitive compare failed")
|
||||||
|
}
|
||||||
|
if fingerprintsEqual("", "") {
|
||||||
|
t.Error("two empty fingerprints must NOT compare equal (no gate-bypass on empty)")
|
||||||
|
}
|
||||||
|
if fingerprintsEqual("01:36", "ff:ee") {
|
||||||
|
t.Error("distinct fingerprints compared equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallKey_AtomicAnd0600(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := filepath.Join(dir, "src")
|
||||||
|
if err := os.WriteFile(src, []byte("keymaterial"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(dir, "sub", "installed.json") // parent dir must be created
|
||||||
|
if err := installKey(src, dest); err != nil {
|
||||||
|
t.Fatalf("installKey: %v", err)
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(dest)
|
||||||
|
if string(b) != "keymaterial" {
|
||||||
|
t.Errorf("installed content = %q", b)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" { // Windows does not enforce Unix file modes
|
||||||
|
if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no .tmp- siblings left behind
|
||||||
|
entries, _ := os.ReadDir(filepath.Dir(dest))
|
||||||
|
for _, e := range entries {
|
||||||
|
if filepath.Ext(e.Name()) != ".json" && len(e.Name()) > 4 && e.Name()[:4] == "inst" {
|
||||||
|
t.Errorf("temp sibling left behind: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- integration tests (real proxmox-backup-client wrap/unwrap round-trip) --------------------
|
||||||
|
|
||||||
|
func ensurePbc(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if !pbcAvailable() {
|
||||||
|
t.Skip("skipping: proxmox-backup-client + linux required (runs on the demo/build host)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(pbcBinary); err != nil {
|
||||||
|
if p, e := exec.LookPath("proxmox-backup-client"); e == nil {
|
||||||
|
pbcBinary = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeBlob creates a throwaway kdf=none key Kt, wraps it under R, and returns (blobBytes, Kt-fp).
|
||||||
|
func makeBlob(t *testing.T, dir, R string) ([]byte, string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
Kt := filepath.Join(dir, "Kt.json")
|
||||||
|
if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("key create: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
fp, err := KeyFingerprint(ctx, Kt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
blobPath := filepath.Join(dir, "escrow.blob")
|
||||||
|
if err := Wrap(ctx, Kt, blobPath, R); err != nil {
|
||||||
|
t.Fatalf("Wrap: %v", err)
|
||||||
|
}
|
||||||
|
blob, err := os.ReadFile(blobPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return blob, fp
|
||||||
|
}
|
||||||
|
|
||||||
|
// VALID: Consume installs a key whose fingerprint matches the expected; blob is byte-unchanged.
|
||||||
|
func TestConsume_ValidInstallsGatedKey(t *testing.T) {
|
||||||
|
ensurePbc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
const R = "throwaway-correct-horse-battery-staple"
|
||||||
|
blob, wantFP := makeBlob(t, dir, R)
|
||||||
|
blobBefore := append([]byte(nil), blob...)
|
||||||
|
|
||||||
|
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
|
||||||
|
if err := Consume(ctx, blob, R, wantFP, dest); err != nil {
|
||||||
|
t.Fatalf("Consume(valid): %v", err)
|
||||||
|
}
|
||||||
|
// key installed + fingerprint matches the expected gate target.
|
||||||
|
gotFP, err := KeyFingerprint(ctx, dest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fingerprint(dest): %v", err)
|
||||||
|
}
|
||||||
|
if !fingerprintsEqual(gotFP, wantFP) {
|
||||||
|
t.Errorf("installed key fingerprint %q != expected %q", gotFP, wantFP)
|
||||||
|
}
|
||||||
|
// 0600.
|
||||||
|
if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
// blob read-only / unchanged (retryable).
|
||||||
|
if !bytes.Equal(blob, blobBefore) {
|
||||||
|
t.Error("Consume mutated the input blob — must be read-only/retryable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WRONG R: Unwrap fails closed → clear error, NO file at dest, blob unchanged.
|
||||||
|
func TestConsume_WrongRNoInstall(t *testing.T) {
|
||||||
|
ensurePbc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
blob, wantFP := makeBlob(t, dir, "the-correct-code")
|
||||||
|
blobBefore := append([]byte(nil), blob...)
|
||||||
|
|
||||||
|
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
|
||||||
|
err := Consume(ctx, blob, "DEFINITELY-the-wrong-code", wantFP, dest)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a wrong recovery code must fail")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||||
|
t.Errorf("a wrong-R Consume left a key at dest (%v) — must install nothing", statErr)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(blob, blobBefore) {
|
||||||
|
t.Error("wrong-R Consume mutated the blob — must be retryable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FINGERPRINT MISMATCH: right unwrap, wrong expected fingerprint → fail fast, NO install (proves
|
||||||
|
// the gate runs BEFORE any restore).
|
||||||
|
func TestConsume_FingerprintMismatchNoInstall(t *testing.T) {
|
||||||
|
ensurePbc(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
const R = "the-correct-code"
|
||||||
|
blob, _ := makeBlob(t, dir, R)
|
||||||
|
|
||||||
|
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
|
||||||
|
// Right R, but an expected fingerprint for a DIFFERENT datastore/key.
|
||||||
|
err := Consume(ctx, blob, R, "ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff", dest)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a fingerprint mismatch must fail")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||||
|
t.Errorf("a fingerprint-mismatch Consume left a key at dest — the gate must run before install")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pbcBinary is the PBS client CLI the escrow shells out to (the PBS-native key+passphrase KDF).
|
||||||
|
var pbcBinary = "/usr/bin/proxmox-backup-client"
|
||||||
|
|
||||||
|
// Posture is the customer's key-custody posture (doc 03 §8a). Only the zero-knowledge DEFAULT is
|
||||||
|
// implemented this slice; the other postures are documented (doc 03 §8a) and implemented only when
|
||||||
|
// a customer chooses them.
|
||||||
|
type Posture string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PostureZeroKnowledge: Felhom storage + customer-only key. The default. Felhom holds the
|
||||||
|
// opaque R-wrapped blob (cannot open it) and the customer holds R.
|
||||||
|
PostureZeroKnowledge Posture = "zero_knowledge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPosture is the posture when none is configured.
|
||||||
|
const DefaultPosture = PostureZeroKnowledge
|
||||||
|
|
||||||
|
// CreateOptions parameterizes one escrow creation.
|
||||||
|
type CreateOptions struct {
|
||||||
|
// KeyPath is the live PBS client encryption key K (e.g. /etc/pve/priv/storage/felhom-pbs.enc).
|
||||||
|
// It is read (copied) but NEVER modified.
|
||||||
|
KeyPath string
|
||||||
|
// Posture is recorded for display/audit; this slice implements only zero-knowledge.
|
||||||
|
Posture Posture
|
||||||
|
// WantOfflineCopy: opt-in (b) — also return the wrapped blob for the customer to print
|
||||||
|
// (still two-factor: useless without R). No extra trust.
|
||||||
|
WantOfflineCopy bool
|
||||||
|
// WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable;
|
||||||
|
// the caller must surface the loud caveat. Off by default.
|
||||||
|
WantPaperkey bool
|
||||||
|
// IdentityBundle (slice 10D.1), when set, is ALSO wrapped under the SAME R (via age) → an
|
||||||
|
// IdentityBlob in the result. Additive: the K-escrow path is unchanged when nil.
|
||||||
|
IdentityBundle *IdentityBundle
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateResult is the non-secret output of escrow creation. NOTE: the recovery code R is returned
|
||||||
|
// SEPARATELY (not in this struct) so it can never be accidentally logged via the result.
|
||||||
|
type CreateResult struct {
|
||||||
|
Blob []byte // the opaque R-wrapped escrow blob → hub (Phase C). Ciphertext, not K.
|
||||||
|
KeyFingerprint string // the PBS key fingerprint (identifies the key; safe to display)
|
||||||
|
Posture Posture // the posture used
|
||||||
|
EntropyBits float64 // R's approximate entropy (for display; never R itself)
|
||||||
|
OfflineCopy []byte // (b) the same wrapped blob, if WantOfflineCopy (for the customer to print)
|
||||||
|
Paperkey string // (a) raw paperkey text, if WantPaperkey — SECRET-adjacent (single factor)
|
||||||
|
IdentityBlob []byte // (10D.1) the age-wrapped identity bundle under the same R, if IdentityBundle set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create generates a recovery code R, produces the R-wrapped escrow blob from the live key, and
|
||||||
|
// SELF-VERIFIES it is recoverable (unwraps a copy with R and checks the key fingerprint matches —
|
||||||
|
// "an escrow you haven't recovered isn't an escrow"). It returns R SEPARATELY: the caller surfaces
|
||||||
|
// R to the customer exactly once and must not log it. The live key file is byte-unchanged.
|
||||||
|
func Create(ctx context.Context, opts CreateOptions) (recoveryCode string, res CreateResult, err error) {
|
||||||
|
if opts.KeyPath == "" {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: KeyPath (the live PBS key) is required")
|
||||||
|
}
|
||||||
|
posture := opts.Posture
|
||||||
|
if posture == "" {
|
||||||
|
posture = DefaultPosture
|
||||||
|
}
|
||||||
|
if posture != PostureZeroKnowledge {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: posture %q not implemented this slice (only %q)", posture, PostureZeroKnowledge)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyFP, err := KeyFingerprint(ctx, opts.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: read key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
R, err := GenerateRecoveryCode()
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
work, err := os.MkdirTemp("", "felhom-escrow-")
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: tempdir: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(work) // the blob bytes are returned in memory; no plaintext-K temp lingers
|
||||||
|
blobPath := filepath.Join(work, "escrow.blob")
|
||||||
|
|
||||||
|
if err := Wrap(ctx, opts.KeyPath, blobPath, R); err != nil {
|
||||||
|
return "", CreateResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-verify recoverability: unwrap a COPY with R and confirm the recovered key's fingerprint
|
||||||
|
// matches the original. Never ship a blob we can't recover.
|
||||||
|
verifyPath := filepath.Join(work, "verify.blob")
|
||||||
|
if err := copyFile(blobPath, verifyPath, 0o600); err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: verify copy: %w", err)
|
||||||
|
}
|
||||||
|
if err := Unwrap(ctx, verifyPath, R); err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: self-verify unwrap: %w", err)
|
||||||
|
}
|
||||||
|
recFP, err := KeyFingerprint(ctx, verifyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: self-verify fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
if recFP != keyFP {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: self-verify FAILED — recovered key fingerprint mismatch (blob is not recoverable)")
|
||||||
|
}
|
||||||
|
|
||||||
|
blob, err := os.ReadFile(blobPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: read blob: %w", err)
|
||||||
|
}
|
||||||
|
res = CreateResult{Blob: blob, KeyFingerprint: keyFP, Posture: posture, EntropyBits: RecoveryCodeEntropyBits()}
|
||||||
|
if opts.WantOfflineCopy {
|
||||||
|
res.OfflineCopy = blob // same two-factor blob; customer prints it
|
||||||
|
}
|
||||||
|
if opts.WantPaperkey {
|
||||||
|
pk, err := Paperkey(ctx, opts.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: paperkey: %w", err)
|
||||||
|
}
|
||||||
|
res.Paperkey = pk
|
||||||
|
}
|
||||||
|
// Slice 10D.1: ALSO wrap the identity bundle under the SAME R (via age), so DR can recover the
|
||||||
|
// box's identity with the one recovery code. Self-verify it round-trips before shipping.
|
||||||
|
if opts.IdentityBundle != nil {
|
||||||
|
idBlob, err := WrapIdentityBundle(ctx, *opts.IdentityBundle, R)
|
||||||
|
if err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: identity wrap: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := UnwrapIdentityBundle(ctx, idBlob, R); err != nil {
|
||||||
|
return "", CreateResult{}, fmt.Errorf("escrow: identity self-verify (not recoverable): %w", err)
|
||||||
|
}
|
||||||
|
res.IdentityBlob = idBlob
|
||||||
|
}
|
||||||
|
return R, res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap produces the R-wrapped escrow blob from the live PBS key file: copy K → blob, then re-key
|
||||||
|
// the copy to kdf=scrypt under R via a pty (spike F-A1/F-A2). The live key file is NOT modified.
|
||||||
|
func Wrap(ctx context.Context, keyPath, blobPath, recoveryCode string) error {
|
||||||
|
if err := copyFile(keyPath, blobPath, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("escrow: copy key: %w", err)
|
||||||
|
}
|
||||||
|
// `key change-passphrase --kdf scrypt` prompts New + Verify → feed R twice.
|
||||||
|
if err := runWithPassphrase(ctx, recoveryCode, 2, pbcBinary, "key", "change-passphrase", blobPath, "--kdf", "scrypt"); err != nil {
|
||||||
|
_ = os.Remove(blobPath)
|
||||||
|
return fmt.Errorf("escrow: wrap: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap recovers the unencrypted key from an R-wrapped blob, in place: re-key to kdf=none under R
|
||||||
|
// via a pty (one prompt). Used by Create's self-verify; the real recovery is slice 10.
|
||||||
|
func Unwrap(ctx context.Context, blobPath, recoveryCode string) error {
|
||||||
|
// `key change-passphrase --kdf none` prompts the Encryption Key Password → feed R once.
|
||||||
|
if err := runWithPassphrase(ctx, recoveryCode, 1, pbcBinary, "key", "change-passphrase", blobPath, "--kdf", "none"); err != nil {
|
||||||
|
return fmt.Errorf("escrow: unwrap: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paperkey emits the RAW-key paperkey (opt-in (a)) as text. Single-factor + unrevocable — the
|
||||||
|
// caller surfaces the caveat. SECRET-adjacent: do not log it.
|
||||||
|
func Paperkey(ctx context.Context, keyPath string) (string, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, pbcBinary, "key", "paperkey", keyPath, "--output-format", "text").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("escrow: paperkey: %w", err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyFingerprint returns a PBS key file's fingerprint (identifies the key; safe to display).
|
||||||
|
func KeyFingerprint(ctx context.Context, keyPath string) (string, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, pbcBinary, "key", "show", keyPath, "--output-format", "json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("escrow: key show: %w", err)
|
||||||
|
}
|
||||||
|
var meta struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &meta); err != nil {
|
||||||
|
return "", fmt.Errorf("escrow: parse key show: %w", err)
|
||||||
|
}
|
||||||
|
return meta.Fingerprint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies src→dst with the given mode (0600 for key material). Reads the whole file
|
||||||
|
// (PBS key files are tiny).
|
||||||
|
func copyFile(src, dst string, mode os.FileMode) error {
|
||||||
|
b, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dst, b, mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWordlistLoaded(t *testing.T) {
|
||||||
|
// The EFF large list is 7776 entries; joinSafe removes the 4 that contain RecoveryCodeSep
|
||||||
|
// (drop-down, felt-tip, t-shirt, yo-yo), leaving 7772 as the effective draw space.
|
||||||
|
if got := WordlistSize(); got != 7772 {
|
||||||
|
t.Fatalf("effective wordlist should be 7772 words (7776 EFF - 4 hyphenated), got %d", got)
|
||||||
|
}
|
||||||
|
if got := WordlistFilteredOut(); got != 4 {
|
||||||
|
t.Fatalf("joinSafe should have removed exactly 4 hyphenated entries, removed %d", got)
|
||||||
|
}
|
||||||
|
if got := WordlistSize() + WordlistFilteredOut(); got != 7776 {
|
||||||
|
t.Fatalf("filtered + removed should reconstitute the 7776-word EFF list, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRecoveryCode_EntropyAndFormat(t *testing.T) {
|
||||||
|
if RecoveryCodeEntropyBits() < 128 {
|
||||||
|
t.Fatalf("recovery code entropy must be ≥128 bits, got %.1f", RecoveryCodeEntropyBits())
|
||||||
|
}
|
||||||
|
inList := make(map[string]bool, WordlistSize())
|
||||||
|
for _, w := range wordlist {
|
||||||
|
inList[w] = true
|
||||||
|
}
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
// Count words by GENERATION count, not by re-splitting the joined string: the two agree
|
||||||
|
// only because joinSafe holds, and conflating them is what made this test flake ~1/5.
|
||||||
|
words, err := generateWords(wordlist)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateWords: %v", err)
|
||||||
|
}
|
||||||
|
if len(words) != RecoveryCodeWords {
|
||||||
|
t.Fatalf("generator must draw %d words, drew %d", RecoveryCodeWords, len(words))
|
||||||
|
}
|
||||||
|
for _, w := range words {
|
||||||
|
if !inList[w] {
|
||||||
|
t.Errorf("recovery-code word %q is not from the EFF wordlist", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Separately assert the property joinSafe buys: the joined code segments back to the same
|
||||||
|
// count. Never print r — it is a live-shaped secret.
|
||||||
|
r := strings.Join(words, RecoveryCodeSep)
|
||||||
|
if got := len(strings.Split(r, RecoveryCodeSep)); got != RecoveryCodeWords {
|
||||||
|
t.Fatalf("joined code must segment into %d words, got %d (a drawn word contained %q)",
|
||||||
|
RecoveryCodeWords, got, RecoveryCodeSep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRecoveryCode_Unique(t *testing.T) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
r, err := GenerateRecoveryCode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen[r] {
|
||||||
|
t.Fatalf("recovery code collision within 200 draws — entropy too low")
|
||||||
|
}
|
||||||
|
seen[r] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- integration: real PBS key wrap/unwrap round-trip (linux + proxmox-backup-client only) ---
|
||||||
|
|
||||||
|
func pbcAvailable() bool {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := exec.LookPath(strings.TrimPrefix(pbcBinary, ""))
|
||||||
|
if err != nil {
|
||||||
|
// fall back to PATH lookup of the basename
|
||||||
|
_, err = exec.LookPath("proxmox-backup-client")
|
||||||
|
}
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapUnwrapRoundTrip(t *testing.T) {
|
||||||
|
if !pbcAvailable() {
|
||||||
|
t.Skip("skipping: proxmox-backup-client + linux required (runs on the demo/build host)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(pbcBinary); err != nil {
|
||||||
|
if p, e := exec.LookPath("proxmox-backup-client"); e == nil {
|
||||||
|
pbcBinary = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
Kt := filepath.Join(dir, "Kt.json")
|
||||||
|
// throwaway unencrypted key (mimics the live K posture). NOT a real K.
|
||||||
|
if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("key create: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
ktBefore, _ := os.ReadFile(Kt)
|
||||||
|
fp0, err := KeyFingerprint(ctx, Kt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Rt = "throwaway-test-passphrase-correct-horse"
|
||||||
|
blob := filepath.Join(dir, "escrow.blob")
|
||||||
|
if err := Wrap(ctx, Kt, blob, Rt); err != nil {
|
||||||
|
t.Fatalf("Wrap: %v", err)
|
||||||
|
}
|
||||||
|
// the live key file must be byte-unchanged after wrap.
|
||||||
|
if ktAfter, _ := os.ReadFile(Kt); string(ktAfter) != string(ktBefore) {
|
||||||
|
t.Fatal("the live key file was modified by Wrap — must operate on a copy")
|
||||||
|
}
|
||||||
|
// blob is the passphrase-protected (scrypt) form.
|
||||||
|
if kdf := keyKDF(t, blob); kdf != "scrypt" {
|
||||||
|
t.Fatalf("wrapped blob kdf = %q, want scrypt", kdf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrap with the RIGHT passphrase → recovered key fingerprint matches the original.
|
||||||
|
rec := filepath.Join(dir, "rec.blob")
|
||||||
|
if err := copyFile(blob, rec, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Unwrap(ctx, rec, Rt); err != nil {
|
||||||
|
t.Fatalf("Unwrap (correct R): %v", err)
|
||||||
|
}
|
||||||
|
fp2, err := KeyFingerprint(ctx, rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fp2 != fp0 {
|
||||||
|
t.Fatalf("recovered key fingerprint %q != original %q", fp2, fp0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrap with the WRONG passphrase → must fail (and not produce the key).
|
||||||
|
wrong := filepath.Join(dir, "wrong.blob")
|
||||||
|
if err := copyFile(blob, wrong, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Unwrap(ctx, wrong, "definitely-the-wrong-code"); err == nil {
|
||||||
|
t.Fatal("Unwrap with the WRONG recovery code must FAIL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_SelfVerifiesAndKeepsKey(t *testing.T) {
|
||||||
|
if !pbcAvailable() {
|
||||||
|
t.Skip("skipping: proxmox-backup-client + linux required")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(pbcBinary); err != nil {
|
||||||
|
if p, e := exec.LookPath("proxmox-backup-client"); e == nil {
|
||||||
|
pbcBinary = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
Kt := filepath.Join(dir, "Kt.json")
|
||||||
|
if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("key create: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
ktBefore, _ := os.ReadFile(Kt)
|
||||||
|
|
||||||
|
R, res, err := Create(ctx, CreateOptions{KeyPath: Kt, Posture: PostureZeroKnowledge})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if len(R) == 0 || len(strings.Split(R, "-")) != RecoveryCodeWords {
|
||||||
|
t.Errorf("Create returned a malformed recovery code")
|
||||||
|
}
|
||||||
|
if len(res.Blob) == 0 {
|
||||||
|
t.Error("Create returned an empty blob")
|
||||||
|
}
|
||||||
|
if res.KeyFingerprint == "" || res.Posture != PostureZeroKnowledge {
|
||||||
|
t.Errorf("result meta wrong: %+v", res)
|
||||||
|
}
|
||||||
|
// the blob is ciphertext, not the key: it must NOT equal the live key bytes.
|
||||||
|
if string(res.Blob) == string(ktBefore) {
|
||||||
|
t.Fatal("blob equals the plaintext key — wrap did not encrypt")
|
||||||
|
}
|
||||||
|
// the live key file is byte-unchanged.
|
||||||
|
if ktAfter, _ := os.ReadFile(Kt); string(ktAfter) != string(ktBefore) {
|
||||||
|
t.Fatal("Create modified the live key file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyKDF(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command(pbcBinary, "key", "show", path, "--output-format", "json").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("key show: %v", err)
|
||||||
|
}
|
||||||
|
// minimal parse to avoid importing json twice; the field is `"kdf":"scrypt"` or `"none"`.
|
||||||
|
s := string(out)
|
||||||
|
for _, k := range []string{"scrypt", "none", "pbkdf2"} {
|
||||||
|
if strings.Contains(s, `"kdf":"`+k+`"`) || strings.Contains(s, `"kdf": "`+k+`"`) {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "?(" + s + ")"
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
|
||||||
|
// PBS-native scrypt path. The identity bundle `{tunnel_token, pbs_token}` is arbitrary secret bytes
|
||||||
|
// (not a PBS key), so it is wrapped under the SAME recovery code `R` with **age** (`age -p`: scrypt
|
||||||
|
// + ChaCha20-Poly1305 — a vetted passphrase-AEAD, not hand-rolled). Same two-factor, zero-knowledge
|
||||||
|
// shape as the K-escrow: the blob is opaque without `R`; `R` is the only out-of-band secret. The
|
||||||
|
// K-escrow + the 10C `Consume` path are UNTOUCHED — this is purely additive. Proven by the slice-10D
|
||||||
|
// identity-restore spike (documentation/tests/slice10d-identity-restore-spike-findings.md).
|
||||||
|
//
|
||||||
|
// age is a runtime dependency for the identity path (analogous to proxmox-backup-client for K).
|
||||||
|
var ageBinary = "/usr/bin/age"
|
||||||
|
|
||||||
|
// IdentityBundle is the box's recoverable identity — the secrets a re-enrolling box needs to come
|
||||||
|
// back "as host X". Carried only inside the R-wrapped blob; never stored or logged in the clear.
|
||||||
|
type IdentityBundle struct {
|
||||||
|
TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token
|
||||||
|
PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish)
|
||||||
|
// WGPrivateKey is the offsite WG tunnel private key (S3; base64, 32 bytes). OPTIONAL: escrow
|
||||||
|
// blobs created before S3 lack it and CANNOT be retro-fitted (R is never retained) — S5 DR
|
||||||
|
// falls back to fresh-key re-registration, which keeps the box's /32 (hub S2 re-key-in-place).
|
||||||
|
WGPrivateKey string `json:"wg_private_key,omitempty"`
|
||||||
|
// ResticRepoPassword is the offsite restic repo password (fork-4). OPTIONAL: escrow blobs created
|
||||||
|
// before fork-4 lack it and CANNOT be retro-fitted (R is never retained). It is the DATA key for the
|
||||||
|
// offsite tier — irreplaceable (unlike the SFTP access key, which is regenerable at DR). The
|
||||||
|
// controller's atomicity gate ensures no offsite ciphertext exists until this is escrowed.
|
||||||
|
ResticRepoPassword string `json:"restic_repo_password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StagedResticPasswordPath is the well-known 0600 file where the controller-pushed restic repo password
|
||||||
|
// is transiently staged (by the local API) for the escrow-create ceremony to pick up, then wiped. A fixed
|
||||||
|
// path so the local-API writer and the CLI ceremony reader agree without threading config through.
|
||||||
|
func StagedResticPasswordPath() string {
|
||||||
|
return filepath.Join("/var/lib/felhom-agent", "escrow-stage", "restic_repo_password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WipeStagedResticPassword removes the staged restic password (called by the ceremony after a successful
|
||||||
|
// escrow-create — the secret now lives only inside the R-wrapped blob). A missing file is a clean no-op.
|
||||||
|
func WipeStagedResticPassword() error {
|
||||||
|
if err := os.Remove(StagedResticPasswordPath()); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("escrow: wipe staged restic password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashResticPassword is the CANONICAL hasher for the offsite restic repo password (SLICE 3 hub-verified
|
||||||
|
// escrow auto-confirm): sha256 hex of the TRIMMED password string — exactly the value AttachResticPassword
|
||||||
|
// seals into the blob and the value the controller uses (both sides TrimSpace their file reads, so the
|
||||||
|
// trimmed string is the drift-free convention; pinned by the SAME test vector in felhom-agent and
|
||||||
|
// felhom-controller). The hash of a 256-bit random secret is non-reversible and non-brute-forceable —
|
||||||
|
// safe to store on the hub and serve in report ACKs; the PASSWORD itself is never logged or served.
|
||||||
|
func HashResticPassword(pw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(pw)))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachResticPassword injects the offsite restic repo password from the staged 0600 file into the bundle
|
||||||
|
// when it exists (fork-4 escrow-create auto-inject). Returns whether it attached. The VALUE is validated
|
||||||
|
// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file
|
||||||
|
// is a clean no-attach (pre-fork-4 behavior, byte-compatible bundle).
|
||||||
|
func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) {
|
||||||
|
raw, err := os.ReadFile(stagePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("escrow: reading staged restic password: %w", err)
|
||||||
|
}
|
||||||
|
pw := strings.TrimSpace(string(raw))
|
||||||
|
if pw == "" {
|
||||||
|
return false, fmt.Errorf("escrow: staged restic password file %s is empty", stagePath)
|
||||||
|
}
|
||||||
|
b.ResticRepoPassword = pw
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachWGKey injects the offsite WG private key into the bundle when the key file exists (S3
|
||||||
|
// escrow-create auto-inject). Returns whether it attached. The VALUE is validated (base64, 32
|
||||||
|
// bytes) but never logged by callers — log the field NAME only. A missing key file is a clean
|
||||||
|
// no-attach (pre-S3 behavior, byte-compatible bundle); a corrupt one is an error (the operator
|
||||||
|
// should know their escrow would silently lack a live identity).
|
||||||
|
func AttachWGKey(b *IdentityBundle, keyPath string) (bool, error) {
|
||||||
|
raw, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("escrow: reading wg key file: %w", err)
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(raw))
|
||||||
|
dec, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil || len(dec) != 32 {
|
||||||
|
return false, fmt.Errorf("escrow: wg key file %s is corrupt (not 32-byte base64)", keyPath)
|
||||||
|
}
|
||||||
|
b.WGPrivateKey = s
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and
|
||||||
|
// returns the opaque blob. `R` is fed via the pty (2 prompts: passphrase + confirm); the plaintext
|
||||||
|
// and ciphertext flow as files, so only `R` touches the tty (never logged).
|
||||||
|
func WrapIdentity(ctx context.Context, bundle []byte, recoveryCode string) ([]byte, error) {
|
||||||
|
if len(bundle) == 0 {
|
||||||
|
return nil, fmt.Errorf("escrow: WrapIdentity needs a non-empty bundle")
|
||||||
|
}
|
||||||
|
if recoveryCode == "" {
|
||||||
|
return nil, fmt.Errorf("escrow: WrapIdentity needs the recovery code (R)")
|
||||||
|
}
|
||||||
|
work, err := os.MkdirTemp("", "felhom-idesc-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: tempdir: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(work)
|
||||||
|
in, out := filepath.Join(work, "bundle"), filepath.Join(work, "blob")
|
||||||
|
if err := os.WriteFile(in, bundle, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: stage bundle: %w", err)
|
||||||
|
}
|
||||||
|
// `age -p -o <out> <in>` prompts the passphrase + confirm (2) and writes the armored blob.
|
||||||
|
if err := runWithPassphrase(ctx, recoveryCode, 2, ageBinary, "-p", "-a", "-o", out, in); err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: identity wrap (age -p): %w", err)
|
||||||
|
}
|
||||||
|
return os.ReadFile(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnwrapIdentity recovers the bundle bytes from an age blob with `R`. A WRONG R fails CLOSED at the
|
||||||
|
// scrypt KDF (`age -d` nonzero exit, no plaintext emitted) — never a plausible-but-wrong bundle.
|
||||||
|
func UnwrapIdentity(ctx context.Context, blob []byte, recoveryCode string) ([]byte, error) {
|
||||||
|
if len(blob) == 0 {
|
||||||
|
return nil, fmt.Errorf("escrow: UnwrapIdentity needs a non-empty blob")
|
||||||
|
}
|
||||||
|
if recoveryCode == "" {
|
||||||
|
return nil, fmt.Errorf("escrow: UnwrapIdentity needs the recovery code (R)")
|
||||||
|
}
|
||||||
|
work, err := os.MkdirTemp("", "felhom-idesc-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: tempdir: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(work)
|
||||||
|
in, out := filepath.Join(work, "blob"), filepath.Join(work, "bundle")
|
||||||
|
if err := os.WriteFile(in, blob, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: stage blob: %w", err)
|
||||||
|
}
|
||||||
|
// `age -d -o <out> <in>` prompts the passphrase (1).
|
||||||
|
if err := runWithPassphrase(ctx, recoveryCode, 1, ageBinary, "-d", "-o", out, in); err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: the recovery code did not unwrap the identity escrow (wrong recovery code, or a corrupt blob): %w", err)
|
||||||
|
}
|
||||||
|
return os.ReadFile(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapIdentityBundle marshals + wraps an IdentityBundle under R.
|
||||||
|
func WrapIdentityBundle(ctx context.Context, b IdentityBundle, recoveryCode string) ([]byte, error) {
|
||||||
|
raw, err := json.Marshal(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: marshal identity bundle: %w", err)
|
||||||
|
}
|
||||||
|
return WrapIdentity(ctx, raw, recoveryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnwrapIdentityBundle unwraps + parses an IdentityBundle (slice 10D.3 restore-mode consumption).
|
||||||
|
func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string) (IdentityBundle, error) {
|
||||||
|
raw, err := UnwrapIdentity(ctx, blob, recoveryCode)
|
||||||
|
if err != nil {
|
||||||
|
return IdentityBundle{}, err
|
||||||
|
}
|
||||||
|
var b IdentityBundle
|
||||||
|
if err := json.Unmarshal(raw, &b); err != nil {
|
||||||
|
return IdentityBundle{}, fmt.Errorf("escrow: recovered identity bundle is malformed: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ageAvailable() bool {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("age"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAge(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if !ageAvailable() {
|
||||||
|
t.Skip("skipping: the `age` CLI + linux required (runs on the demo/build host)")
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath("age"); err == nil {
|
||||||
|
ageBinary = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentity_InputValidation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := WrapIdentity(ctx, nil, "R"); err == nil {
|
||||||
|
t.Error("empty bundle must error")
|
||||||
|
}
|
||||||
|
if _, err := WrapIdentity(ctx, []byte("x"), ""); err == nil {
|
||||||
|
t.Error("empty R must error")
|
||||||
|
}
|
||||||
|
if _, err := UnwrapIdentity(ctx, nil, "R"); err == nil {
|
||||||
|
t.Error("empty blob must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip: a bundle wraps under R and recovers byte-identical (the identity analog of K-escrow).
|
||||||
|
func TestIdentity_RoundTrip(t *testing.T) {
|
||||||
|
ensureAge(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const R = "throwaway-correct-horse-battery-staple-words"
|
||||||
|
bundle := IdentityBundle{TunnelToken: "eyJhIjoidGVzdCIsInQiOiJ4In0", PBSToken: "felhom@pbs!n100:deadbeefcafe"}
|
||||||
|
|
||||||
|
blob, err := WrapIdentityBundle(ctx, bundle, R)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WrapIdentityBundle: %v", err)
|
||||||
|
}
|
||||||
|
// the blob is opaque ciphertext, not the bundle.
|
||||||
|
if bytes.Contains(blob, []byte(bundle.TunnelToken)) || bytes.Contains(blob, []byte(bundle.PBSToken)) {
|
||||||
|
t.Fatal("the blob leaks plaintext token bytes — not encrypted")
|
||||||
|
}
|
||||||
|
got, err := UnwrapIdentityBundle(ctx, blob, R)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnwrapIdentityBundle: %v", err)
|
||||||
|
}
|
||||||
|
if got != bundle {
|
||||||
|
t.Errorf("recovered bundle = %+v, want %+v", got, bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fork-4: the IdentityBundle carries the offsite restic repo password under R, byte-exact and encrypted;
|
||||||
|
// a wrong R fails closed. (The spike proved a recovered value opens the real repo; this guards the field.)
|
||||||
|
func TestIdentity_RoundTrip_CarriesResticPassword(t *testing.T) {
|
||||||
|
ensureAge(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const R = "throwaway-correct-horse-battery-staple-fork4"
|
||||||
|
const pw = "deadbeefcafef00d0123456789abcdef0123456789abcdef0123456789abcdef" // 64 hex, synthetic
|
||||||
|
bundle := IdentityBundle{TunnelToken: "tt", PBSToken: "pt", ResticRepoPassword: pw}
|
||||||
|
blob, err := WrapIdentityBundle(ctx, bundle, R)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WrapIdentityBundle: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Contains(blob, []byte(pw)) {
|
||||||
|
t.Fatal("the blob leaks the restic password plaintext — not encrypted")
|
||||||
|
}
|
||||||
|
got, err := UnwrapIdentityBundle(ctx, blob, R)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnwrapIdentityBundle: %v", err)
|
||||||
|
}
|
||||||
|
if got.ResticRepoPassword != pw {
|
||||||
|
t.Fatalf("recovered restic password not byte-exact: got %q", got.ResticRepoPassword)
|
||||||
|
}
|
||||||
|
if got != bundle {
|
||||||
|
t.Fatalf("recovered bundle = %+v, want %+v", got, bundle)
|
||||||
|
}
|
||||||
|
if _, err := UnwrapIdentityBundle(ctx, blob, R+"-WRONG"); err == nil {
|
||||||
|
t.Fatal("a wrong recovery code must fail closed (no bundle, no restic password)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PINNED CROSS-REPO TEST VECTOR (SLICE 3): the same vector is asserted in felhom-controller — if either
|
||||||
|
// side drifts (trailing newline, encoding, trim behavior), its half of this test fails and auto-confirm
|
||||||
|
// can never silently mismatch. Convention: sha256 hex over the TRIMMED password string.
|
||||||
|
func TestHashResticPassword_PinnedVector(t *testing.T) {
|
||||||
|
const vector = "cafef00ddeadbeef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
const want = "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4"
|
||||||
|
if got := HashResticPassword(vector); got != want {
|
||||||
|
t.Fatalf("pinned vector drift: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
// trim convention: surrounding whitespace/newlines do not change the hash (both sides trim)
|
||||||
|
if got := HashResticPassword(" " + vector + "\n"); got != want {
|
||||||
|
t.Fatalf("whitespace must not change the hash (trim convention), got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachResticPassword: missing file → clean no-attach; staged file → trimmed value attached; empty → error.
|
||||||
|
func TestAttachResticPassword(t *testing.T) {
|
||||||
|
b := &IdentityBundle{}
|
||||||
|
if ok, err := AttachResticPassword(b, filepath.Join(t.TempDir(), "absent")); ok || err != nil {
|
||||||
|
t.Fatalf("missing staged file must be a clean no-attach, got ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
f := filepath.Join(t.TempDir(), "pw")
|
||||||
|
if err := os.WriteFile(f, []byte(" abc123def \n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, err := AttachResticPassword(b, f)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("attach from staged file: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if b.ResticRepoPassword != "abc123def" {
|
||||||
|
t.Fatalf("want trimmed value, got %q", b.ResticRepoPassword)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(f, []byte(" \n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := AttachResticPassword(&IdentityBundle{}, f); err == nil {
|
||||||
|
t.Fatal("an empty staged file must error (an operator would want to know)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong R fails CLOSED — no bundle emitted.
|
||||||
|
func TestIdentity_WrongRFailsClosed(t *testing.T) {
|
||||||
|
ensureAge(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
blob, err := WrapIdentity(ctx, []byte(`{"tunnel_token":"a","pbs_token":"b"}`), "the-correct-code")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WrapIdentity: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := UnwrapIdentity(ctx, blob, "DEFINITELY-the-wrong-code"); err == nil {
|
||||||
|
t.Fatal("a wrong recovery code must fail closed (no bundle)")
|
||||||
|
}
|
||||||
|
// the blob is unchanged / retryable: the RIGHT code still works after a wrong attempt.
|
||||||
|
if _, err := UnwrapIdentity(ctx, blob, "the-correct-code"); err != nil {
|
||||||
|
t.Errorf("the blob was not retryable after a wrong-R attempt: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runWithPassphrase runs a TTY-requiring command (PBS `key change-passphrase`, F-A1 in the spike
|
||||||
|
// findings) on a pty, feeding `passphrase` `reps` times (once per prompt) and DISCARDING all pty
|
||||||
|
// output so the echoed passphrase can never leak (F-A2). Linux-only — the agent runs on Proxmox
|
||||||
|
// hosts; a non-linux stub returns an error.
|
||||||
|
func runWithPassphrase(ctx context.Context, passphrase string, reps int, name string, args ...string) error {
|
||||||
|
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("escrow: open ptmx: %w", err)
|
||||||
|
}
|
||||||
|
defer master.Close()
|
||||||
|
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil { // unlock
|
||||||
|
return fmt.Errorf("escrow: unlock pty: %w", err)
|
||||||
|
}
|
||||||
|
ptn, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("escrow: pty number: %w", err)
|
||||||
|
}
|
||||||
|
slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", ptn), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("escrow: open pts: %w", err)
|
||||||
|
}
|
||||||
|
defer slave.Close()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
|
||||||
|
// New session + the slave (fd 0) becomes the controlling terminal, so the child's tty prompts
|
||||||
|
// read from / write to the pty rather than failing "no tty".
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("escrow: start %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed the passphrase once (the tty line discipline buffers both lines for the sequential
|
||||||
|
// New/Verify prompts), then DISCARD everything the pty emits — the passphrase is echoed back
|
||||||
|
// on the master fd and must never reach a log.
|
||||||
|
go func() { _, _ = master.WriteString(strings.Repeat(passphrase+"\n", reps)) }()
|
||||||
|
go func() { _, _ = io.Copy(io.Discard, master) }()
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runWithPassphrase is unsupported off Linux — the agent runs only on Proxmox (Linux) hosts. This
|
||||||
|
// stub lets the package compile (and its pure-Go parts, e.g. recovery-code generation, be tested)
|
||||||
|
// on other platforms.
|
||||||
|
func runWithPassphrase(_ context.Context, _ string, _ int, _ string, _ ...string) error {
|
||||||
|
return fmt.Errorf("escrow: pty-driven key wrap is Linux-only (GOOS=%s)", runtime.GOOS)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
// S3 Group D — the WG-key escrow join. Red-proof (e): remove the auto-inject call and the
|
||||||
|
// bundle-contains-key assertion fails.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAttachWGKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(dir, "private.key")
|
||||||
|
|
||||||
|
// Missing key file → clean no-attach (pre-S3 bundles stay byte-compatible).
|
||||||
|
b := &IdentityBundle{TunnelToken: "tt", PBSToken: "pt"}
|
||||||
|
attached, err := AttachWGKey(b, keyPath)
|
||||||
|
if err != nil || attached {
|
||||||
|
t.Fatalf("missing file: attached=%v err=%v", attached, err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(b)
|
||||||
|
if string(raw) != `{"tunnel_token":"tt","pbs_token":"pt"}` {
|
||||||
|
t.Fatalf("bundle without key marshals with extra fields: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present key file → attached, field carried.
|
||||||
|
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||||
|
os.WriteFile(keyPath, []byte(key+"\n"), 0o600)
|
||||||
|
attached, err = AttachWGKey(b, keyPath)
|
||||||
|
if err != nil || !attached {
|
||||||
|
t.Fatalf("present file: attached=%v err=%v", attached, err)
|
||||||
|
}
|
||||||
|
if b.WGPrivateKey != key {
|
||||||
|
t.Fatalf("bundle key = %q", b.WGPrivateKey)
|
||||||
|
}
|
||||||
|
raw, _ = json.Marshal(b)
|
||||||
|
var back IdentityBundle
|
||||||
|
json.Unmarshal(raw, &back)
|
||||||
|
if back.WGPrivateKey != key {
|
||||||
|
t.Fatal("wg_private_key does not survive the bundle round-trip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corrupt key file → error (the operator must know their escrow would lack the identity).
|
||||||
|
os.WriteFile(keyPath, []byte("garbage"), 0o600)
|
||||||
|
if _, err := AttachWGKey(&IdentityBundle{}, keyPath); err == nil {
|
||||||
|
t.Fatal("corrupt key file attached silently")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Package escrow creates the PBS recovery-code escrow (doc 03 §8a, slice 7): an R-wrapped,
|
||||||
|
// zero-knowledge copy of the live PBS client encryption key K. It is the FIRST code that touches
|
||||||
|
// K and introduces the customer recovery code R.
|
||||||
|
//
|
||||||
|
// Secret discipline (overriding):
|
||||||
|
// - R (recovery code) is generated with crypto/rand, ≥128 bits, word-list form; surfaced to the
|
||||||
|
// customer EXACTLY ONCE (by the caller); NEVER logged, persisted, or returned in a struct that
|
||||||
|
// gets logged. GenerateRecoveryCode returns it as a bare string the caller must handle with care.
|
||||||
|
// - K (PBS key) is read by location; the live unencrypted K is NEVER modified — Wrap operates on
|
||||||
|
// a COPY. K is never logged/printed.
|
||||||
|
// - The wrapped blob is opaque ciphertext (a PBS scrypt key file); the hub stores it and never
|
||||||
|
// decrypts it (it has no R).
|
||||||
|
//
|
||||||
|
// It shells out to `proxmox-backup-client key` (the PBS-native passphrase KDF — no bespoke crypto;
|
||||||
|
// see documentation/tests/slice7-escrow-spike-findings.md). That binary is a runtime dependency.
|
||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed eff_large_wordlist.txt
|
||||||
|
var wordlistRaw []byte
|
||||||
|
|
||||||
|
// RecoveryCodeSep joins the words of a recovery code R. It is ALSO the reason for the
|
||||||
|
// joinSafe filter below: a word that itself contains the separator makes the joined code
|
||||||
|
// ambiguous to segment by eye, which is unaffordable in the one situation R exists for — a
|
||||||
|
// customer transcribing it during a disaster. Do not change it: R is consumed as a whole
|
||||||
|
// passphrase (see Wrap/Unwrap), so the separator is a transcription aid, not a parsed delimiter.
|
||||||
|
const RecoveryCodeSep = "-"
|
||||||
|
|
||||||
|
// wordlist is the EFF large wordlist (the diceware standard for human-transcribed passphrases),
|
||||||
|
// minus the handful of entries that contain RecoveryCodeSep. Parsed and filtered once at init.
|
||||||
|
// Sizes are asserted in wordlist_test.go so a wordlist swap cannot silently move the entropy floor.
|
||||||
|
var wordlist = joinSafe(parseWordlist(wordlistRaw))
|
||||||
|
|
||||||
|
// wordlistRawSize is the unfiltered parse length, kept for audit (see WordlistFilteredOut).
|
||||||
|
var wordlistRawSize = len(parseWordlist(wordlistRaw))
|
||||||
|
|
||||||
|
func parseWordlist(raw []byte) []string {
|
||||||
|
var w []string
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(raw))
|
||||||
|
for sc.Scan() {
|
||||||
|
if t := strings.TrimSpace(sc.Text()); t != "" {
|
||||||
|
w = append(w, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinSafe drops every word containing RecoveryCodeSep, so that a generated code always segments
|
||||||
|
// back into exactly RecoveryCodeWords words. In the EFF large list this removes exactly 4 entries
|
||||||
|
// (drop-down, felt-tip, t-shirt, yo-yo) of 7776, costing ~0.0007 bits/word — the floor still holds
|
||||||
|
// (asserted in the tests). Generation-time only: codes already issued remain valid, because R is
|
||||||
|
// verified as a whole passphrase and is never re-split.
|
||||||
|
func joinSafe(words []string) []string {
|
||||||
|
out := make([]string, 0, len(words))
|
||||||
|
for _, w := range words {
|
||||||
|
if strings.Contains(w, RecoveryCodeSep) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryCodeWords is the number of words in a recovery code R. 10 words from the filtered EFF
|
||||||
|
// list (7772 words) ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4).
|
||||||
|
const RecoveryCodeWords = 10
|
||||||
|
|
||||||
|
// generateWords draws RecoveryCodeWords words uniformly (crypto/rand via big.Int — no modulo bias)
|
||||||
|
// from list. Split out from GenerateRecoveryCode so tests can drive an unfiltered list and prove
|
||||||
|
// the filter is what keeps a code segmentable.
|
||||||
|
func generateWords(list []string) ([]string, error) {
|
||||||
|
if len(list) < 2 {
|
||||||
|
return nil, fmt.Errorf("escrow: wordlist not loaded (%d words)", len(list))
|
||||||
|
}
|
||||||
|
n := big.NewInt(int64(len(list)))
|
||||||
|
words := make([]string, RecoveryCodeWords)
|
||||||
|
for i := range words {
|
||||||
|
idx, err := rand.Int(rand.Reader, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("escrow: recovery-code rng: %w", err)
|
||||||
|
}
|
||||||
|
words[i] = list[idx.Int64()]
|
||||||
|
}
|
||||||
|
return words, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly
|
||||||
|
// from the filtered EFF large wordlist, joined with RecoveryCodeSep.
|
||||||
|
//
|
||||||
|
// SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it.
|
||||||
|
func GenerateRecoveryCode() (string, error) {
|
||||||
|
words, err := generateWords(wordlist)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.Join(words, RecoveryCodeSep), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryCodeEntropyBits is the approximate entropy of a generated code, for display/audit only
|
||||||
|
// (never the code itself).
|
||||||
|
func RecoveryCodeEntropyBits() float64 {
|
||||||
|
if len(wordlist) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(RecoveryCodeWords) * math.Log2(float64(len(wordlist)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WordlistSize is the effective (filtered) wordlist length — the draw space. For audit/tests.
|
||||||
|
func WordlistSize() int { return len(wordlist) }
|
||||||
|
|
||||||
|
// WordlistFilteredOut is how many parsed entries joinSafe removed. For audit/tests.
|
||||||
|
func WordlistFilteredOut() int { return wordlistRawSize - len(wordlist) }
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The four EFF large-list entries that contain RecoveryCodeSep. Named here so a wordlist swap that
|
||||||
|
// changes the set fails loudly rather than silently re-opening the ambiguity.
|
||||||
|
var hyphenatedEFFWords = []string{"drop-down", "felt-tip", "t-shirt", "yo-yo"}
|
||||||
|
|
||||||
|
func TestJoinSafe_RemovesExactlyTheHyphenatedEFFWords(t *testing.T) {
|
||||||
|
raw := parseWordlist(wordlistRaw)
|
||||||
|
rawSet := make(map[string]bool, len(raw))
|
||||||
|
for _, w := range raw {
|
||||||
|
rawSet[w] = true
|
||||||
|
}
|
||||||
|
for _, w := range hyphenatedEFFWords {
|
||||||
|
if !rawSet[w] {
|
||||||
|
t.Fatalf("fixture drift: %q is no longer in the embedded EFF list", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := joinSafe(raw)
|
||||||
|
if len(raw)-len(filtered) != len(hyphenatedEFFWords) {
|
||||||
|
t.Fatalf("joinSafe removed %d entries, expected exactly %d",
|
||||||
|
len(raw)-len(filtered), len(hyphenatedEFFWords))
|
||||||
|
}
|
||||||
|
got := make(map[string]bool, len(filtered))
|
||||||
|
for _, w := range filtered {
|
||||||
|
if strings.Contains(w, RecoveryCodeSep) {
|
||||||
|
t.Errorf("filtered wordlist still contains a separator-bearing word %q", w)
|
||||||
|
}
|
||||||
|
got[w] = true
|
||||||
|
}
|
||||||
|
for _, w := range hyphenatedEFFWords {
|
||||||
|
if got[w] {
|
||||||
|
t.Errorf("joinSafe kept %q, which contains %q", w, RecoveryCodeSep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntropyFloorSurvivesFiltering states the numbers explicitly: dropping 4 of 7776 words costs
|
||||||
|
// ~0.0007 bits/word, so the 10-word code stays above the 128-bit floor with room to spare.
|
||||||
|
func TestEntropyFloorSurvivesFiltering(t *testing.T) {
|
||||||
|
const floorBits = 128.0
|
||||||
|
before := float64(RecoveryCodeWords) * math.Log2(7776)
|
||||||
|
after := RecoveryCodeEntropyBits()
|
||||||
|
|
||||||
|
if after < floorBits {
|
||||||
|
t.Fatalf("filtered entropy %.3f bits is below the %.0f-bit floor", after, floorBits)
|
||||||
|
}
|
||||||
|
if want := float64(RecoveryCodeWords) * math.Log2(float64(WordlistSize())); math.Abs(after-want) > 1e-9 {
|
||||||
|
t.Fatalf("RecoveryCodeEntropyBits() = %.6f, want %.6f (10 * log2(%d))", after, want, WordlistSize())
|
||||||
|
}
|
||||||
|
// Concrete expectations, so a wordlist change that quietly erodes the margin is visible:
|
||||||
|
// 10*log2(7776) = 129.248 bits before, 10*log2(7772) = 129.241 bits after — a 0.007-bit cost.
|
||||||
|
if math.Abs(before-129.248) > 0.001 {
|
||||||
|
t.Fatalf("unfiltered entropy baseline moved: %.3f, expected 129.248", before)
|
||||||
|
}
|
||||||
|
if math.Abs(after-129.241) > 0.001 {
|
||||||
|
t.Fatalf("filtered entropy moved: %.3f, expected 129.241", after)
|
||||||
|
}
|
||||||
|
if cost := before - after; cost > 0.01 {
|
||||||
|
t.Fatalf("filtering cost %.4f bits, expected well under 0.01", cost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratedCodeSegments_FilteredVsUnfiltered is the deterministic red-proof companion.
|
||||||
|
//
|
||||||
|
// Against a list where EVERY word contains the separator, a 10-word draw MUST segment into more
|
||||||
|
// than 10 parts — that is the pre-fix behaviour, reproduced with probability 1 instead of the ~1/5
|
||||||
|
// flake the real list produced. Against the same list run through joinSafe, generation must refuse
|
||||||
|
// (nothing is left to draw from), proving joinSafe — not luck — is what makes a code segmentable.
|
||||||
|
func TestGeneratedCodeSegments_FilteredVsUnfiltered(t *testing.T) {
|
||||||
|
unfiltered := hyphenatedEFFWords
|
||||||
|
|
||||||
|
words, err := generateWords(unfiltered)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateWords(unfiltered): %v", err)
|
||||||
|
}
|
||||||
|
if len(words) != RecoveryCodeWords {
|
||||||
|
t.Fatalf("generator drew %d words, want %d", len(words), RecoveryCodeWords)
|
||||||
|
}
|
||||||
|
joined := strings.Join(words, RecoveryCodeSep)
|
||||||
|
segs := len(strings.Split(joined, RecoveryCodeSep))
|
||||||
|
if segs <= RecoveryCodeWords {
|
||||||
|
t.Fatalf("unfiltered draw segmented into %d parts; the pre-fix defect should yield more than %d",
|
||||||
|
segs, RecoveryCodeWords)
|
||||||
|
}
|
||||||
|
if segs != 2*RecoveryCodeWords {
|
||||||
|
t.Fatalf("every fixture word has exactly one separator, so 10 words must segment into 20 parts, got %d", segs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same fixture, filtered: the draw space is empty, so generation must error rather than
|
||||||
|
// silently fall back to something ambiguous.
|
||||||
|
if _, err := generateWords(joinSafe(unfiltered)); err == nil {
|
||||||
|
t.Fatal("generateWords on a fully-filtered list must fail, not return a code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateRecoveryCode_NeverContainsAmbiguousWord is the production-wiring test: it asserts the
|
||||||
|
// exported entry point (not just the helper) draws from the filtered list.
|
||||||
|
func TestGenerateRecoveryCode_NeverContainsAmbiguousWord(t *testing.T) {
|
||||||
|
inFiltered := make(map[string]bool, len(wordlist))
|
||||||
|
for _, w := range wordlist {
|
||||||
|
inFiltered[w] = true
|
||||||
|
}
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
r, err := GenerateRecoveryCode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateRecoveryCode: %v", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(r, RecoveryCodeSep)
|
||||||
|
if len(parts) != RecoveryCodeWords {
|
||||||
|
// Do not print r: it is a live-shaped secret.
|
||||||
|
t.Fatalf("code %d segmented into %d parts, want %d", i, len(parts), RecoveryCodeWords)
|
||||||
|
}
|
||||||
|
for _, p := range parts {
|
||||||
|
if !inFiltered[p] {
|
||||||
|
t.Fatalf("segment %q is not a filtered-wordlist word", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Package fasttick is the agent-plane immediacy SECONDARY (v0.90.0, R-28). While ANY desired-state
|
||||||
|
// item is still unapplied — most importantly the pre-tunnel WG-registration window where a hub poke
|
||||||
|
// is undeliverable by construction — it pulses the hub control loop's out-of-band report trigger on
|
||||||
|
// a fast (30 s) cadence, and self-disarms EMERGENTLY the instant everything converges. It is the
|
||||||
|
// state-based complement to the poke: the poke handles hub→box changes once the tunnel exists; the
|
||||||
|
// fast-tick handles the window before that (and any lingering unapplied drift) from the box side.
|
||||||
|
//
|
||||||
|
// By ruling it is STATE-BASED, not a fixed burst and not a timer: there is nothing to journal
|
||||||
|
// (stateless across restarts) and nothing to leak. A perma-unconverged box fast-ticks at ~2 small
|
||||||
|
// reports/min, bounded and visible; the LOUD pbsdr states (consumed_failed/verify_failed) are
|
||||||
|
// deliberately EXCLUDED from the sources so a stuck-loud box does not hammer (§8).
|
||||||
|
//
|
||||||
|
// It pulses the SAME cap-1 channel the storage watchdog and the poke listener use, so a pulse
|
||||||
|
// coalesces with a poke/watchdog nudge for free — no extra debounce here.
|
||||||
|
package fasttick
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultInterval is the ruled fast cadence while unconverged.
|
||||||
|
const DefaultInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// Source reports whether one subsystem still has unapplied desired-state. Implementations MUST be a
|
||||||
|
// cheap, CACHED read — no exec, no network per call (the fast-tick calls every source each tick).
|
||||||
|
type Source interface {
|
||||||
|
Unconverged() (unconverged bool, reason string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceFunc adapts a plain func to a Source (main.go closes over each subsystem).
|
||||||
|
type SourceFunc func() (bool, string)
|
||||||
|
|
||||||
|
// Unconverged implements Source.
|
||||||
|
func (f SourceFunc) Unconverged() (bool, string) { return f() }
|
||||||
|
|
||||||
|
// Loop evaluates the sources on a ticker and pulses the out-of-band channel while any is unconverged.
|
||||||
|
type Loop struct {
|
||||||
|
sources []Source
|
||||||
|
out chan<- struct{}
|
||||||
|
interval time.Duration
|
||||||
|
logger *slog.Logger
|
||||||
|
armed bool // for armed↔disarmed transition logging (avoids 30 s reason spam)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a fast-tick loop. out is the hub loop's out-of-band trigger channel (cap-1). A
|
||||||
|
// non-positive interval falls back to DefaultInterval.
|
||||||
|
func New(out chan<- struct{}, interval time.Duration, logger *slog.Logger, sources ...Source) *Loop {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = DefaultInterval
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Loop{sources: sources, out: out, interval: interval, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run evaluates the sources every interval until ctx is cancelled. Stateless — nothing to recover.
|
||||||
|
func (l *Loop) Run(ctx context.Context) error {
|
||||||
|
l.logger.Info("fast-tick armed: "+l.interval.String()+" out-of-band cadence while desired-state is unapplied", "interval", l.interval)
|
||||||
|
ticker := time.NewTicker(l.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
l.logger.Info("fast-tick: shutting down", "reason", ctx.Err())
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
l.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step evaluates the sources once. If any is unconverged it pulses the channel (non-blocking: a full
|
||||||
|
// channel means an out-of-band report is already pending, so the pulse coalesces harmlessly) and, on
|
||||||
|
// the disarmed→armed edge, logs the reason. When all converge it logs the armed→disarmed edge once.
|
||||||
|
// Returns whether this tick found the box unconverged (test hook). No per-tick logging when steady.
|
||||||
|
func (l *Loop) step() bool {
|
||||||
|
unconverged, reason := l.evaluate()
|
||||||
|
if unconverged {
|
||||||
|
select {
|
||||||
|
case l.out <- struct{}{}:
|
||||||
|
default: // an out-of-band report is already queued — coalesce, never block
|
||||||
|
}
|
||||||
|
if !l.armed {
|
||||||
|
l.logger.Info("fast-tick: desired-state unapplied — pulsing out-of-band reports", "reason", reason, "cadence", l.interval)
|
||||||
|
l.armed = true
|
||||||
|
}
|
||||||
|
} else if l.armed {
|
||||||
|
l.logger.Info("fast-tick: desired-state converged — back to the normal cadence")
|
||||||
|
l.armed = false
|
||||||
|
}
|
||||||
|
return unconverged
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate returns the first unconverged source's reason (order = priority for the log line).
|
||||||
|
func (l *Loop) evaluate() (bool, string) {
|
||||||
|
for _, s := range l.sources {
|
||||||
|
if u, reason := s.Unconverged(); u {
|
||||||
|
return true, reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package fasttick
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
// flagSource is a fake Source whose convergence the test flips at will.
|
||||||
|
type flagSource struct {
|
||||||
|
unconverged bool
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flagSource) Unconverged() (bool, string) { return f.unconverged, f.reason }
|
||||||
|
|
||||||
|
// drain reports how many pulses are queued (channel is cap-1 in production; tests may use larger).
|
||||||
|
func drain(ch chan struct{}) int {
|
||||||
|
n := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
n++
|
||||||
|
default:
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1 — any source unconverged → one pulse per tick (drained between ticks).
|
||||||
|
func TestFastTick_UnconvergedPulses(t *testing.T) {
|
||||||
|
out := make(chan struct{}, 1)
|
||||||
|
src := &flagSource{unconverged: true, reason: "test drift"}
|
||||||
|
l := New(out, time.Hour, quiet(), src)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if !l.step() {
|
||||||
|
t.Fatalf("tick %d: step reported converged, want unconverged", i)
|
||||||
|
}
|
||||||
|
if got := drain(out); got != 1 {
|
||||||
|
t.Fatalf("tick %d: pulses = %d, want 1", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D2 — ALL sources converged → zero pulses across N ticks (the emergent disarm).
|
||||||
|
func TestFastTick_ConvergedSilent(t *testing.T) {
|
||||||
|
out := make(chan struct{}, 4)
|
||||||
|
l := New(out, time.Hour, quiet(), &flagSource{unconverged: false}, &flagSource{unconverged: false})
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if l.step() {
|
||||||
|
t.Fatalf("tick %d: step reported unconverged with all sources converged", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := drain(out); got != 0 {
|
||||||
|
t.Fatalf("pulses on a fully-converged box = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D3 (the ruled red-proof) — flip unconverged→converged mid-run → pulses STOP from the next tick.
|
||||||
|
func TestFastTick_ConvergenceDisarms(t *testing.T) {
|
||||||
|
out := make(chan struct{}, 8)
|
||||||
|
src := &flagSource{unconverged: true, reason: "drift"}
|
||||||
|
l := New(out, time.Hour, quiet(), src)
|
||||||
|
|
||||||
|
// Two unconverged ticks pulse.
|
||||||
|
l.step()
|
||||||
|
l.step()
|
||||||
|
if got := drain(out); got != 2 {
|
||||||
|
t.Fatalf("pre-convergence pulses = %d, want 2", got)
|
||||||
|
}
|
||||||
|
// Converge.
|
||||||
|
src.unconverged = false
|
||||||
|
// Every subsequent tick is silent — the cadence returns to normal.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
if l.step() {
|
||||||
|
t.Fatalf("post-convergence tick %d still unconverged", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := drain(out); got != 0 {
|
||||||
|
t.Fatalf("pulses fired after convergence = %d, want 0 (disarm failed)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D4 — the out channel is already full (a poke just landed): the non-blocking send drops, no block,
|
||||||
|
// no goroutine leak, no queue growth.
|
||||||
|
func TestFastTick_ChannelFullDrops(t *testing.T) {
|
||||||
|
out := make(chan struct{}, 1)
|
||||||
|
out <- struct{}{} // pre-fill: an out-of-band report is already pending
|
||||||
|
l := New(out, time.Hour, quiet(), &flagSource{unconverged: true, reason: "drift"})
|
||||||
|
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
l.step() // must NOT block on the full channel
|
||||||
|
l.step()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("step blocked on a full channel (non-blocking send violated)")
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("channel depth = %d, want 1 (coalesced, no queue growth)", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority ordering: the first unconverged source supplies the reason.
|
||||||
|
func TestFastTick_FirstReasonWins(t *testing.T) {
|
||||||
|
out := make(chan struct{}, 1)
|
||||||
|
l := New(out, time.Hour, quiet(),
|
||||||
|
&flagSource{unconverged: false},
|
||||||
|
&flagSource{unconverged: true, reason: "second"},
|
||||||
|
&flagSource{unconverged: true, reason: "third"})
|
||||||
|
if u, r := l.evaluate(); !u || r != "second" {
|
||||||
|
t.Fatalf("evaluate = (%v, %q), want (true, second)", u, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package felhomsshd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Belt keeps the STATIC nft table `inet felhom_oob` (installed by host-install) converged on the
|
||||||
|
// current OOB state by mutating only its SETS — @operator_ips (the operator /32 allowed to reach
|
||||||
|
// felhom-sshd over wg-felhom) and @ssh_port (felhom-sshd's claimed port). The agent NEVER adds/removes
|
||||||
|
// RULES [trap 4]: set-element mutation can't change rule semantics, so the narrow sudoers grant stays
|
||||||
|
// safe. Idempotent: reads the current elements and mutates only on a difference (no churn).
|
||||||
|
type Belt struct {
|
||||||
|
runner proxmox.Runner
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
table string // "felhom_oob"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBelt builds a Belt over the narrow runner. table defaults to "felhom_oob".
|
||||||
|
func NewBelt(runner proxmox.Runner, logger *slog.Logger) *Belt {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Belt{runner: runner, logger: logger, table: "felhom_oob"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPort converges @ssh_port on the claimed port (0 empties it). Always safe to call — the port
|
||||||
|
// comes from the agent's own claim, not the hub desired-state.
|
||||||
|
func (b *Belt) SyncPort(ctx context.Context, port int) {
|
||||||
|
wantPorts := []string{}
|
||||||
|
if port > 0 && port <= 65535 {
|
||||||
|
wantPorts = []string{strconv.Itoa(port)}
|
||||||
|
}
|
||||||
|
b.syncSet(ctx, "ssh_port", wantPorts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncOperator converges @operator_ips on the operator /32 (operatorIP "" = OOB explicitly off →
|
||||||
|
// empty the set). Call ONLY when the desired-state has actually been FETCHED — a nil/unfetched block
|
||||||
|
// must NOT empty the set (that would lock the operator out until the next fetch, the wgtunnel
|
||||||
|
// fetched=false-is-never-a-teardown rule). Every value is netip-validated before it reaches nft.
|
||||||
|
func (b *Belt) SyncOperator(ctx context.Context, operatorIP string) {
|
||||||
|
wantOps := []string{}
|
||||||
|
if operatorIP != "" {
|
||||||
|
ip, err := netip.ParseAddr(operatorIP)
|
||||||
|
if err != nil || !ip.Is4() {
|
||||||
|
b.logger.Error("felhomsshd belt: operator ip invalid — refusing", "ip", operatorIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wantOps = []string{ip.String()}
|
||||||
|
}
|
||||||
|
b.syncSet(ctx, "operator_ips", wantOps)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nftElemRe = regexp.MustCompile(`elements\s*=\s*\{([^}]*)\}`)
|
||||||
|
|
||||||
|
// syncSet converges one named set on `want` (sorted, deduped). Reads current elements; if they match,
|
||||||
|
// ZERO nft mutations (the idempotency the scenario asserts). Otherwise flush + add the desired
|
||||||
|
// elements. A read failure (table/set absent) → one warning, no mutation.
|
||||||
|
func (b *Belt) syncSet(ctx context.Context, setName string, want []string) {
|
||||||
|
want = sortedUnique(want)
|
||||||
|
cur, ok := b.readSet(ctx, setName)
|
||||||
|
if !ok {
|
||||||
|
b.logger.Warn("felhomsshd belt: set unreadable (table not installed?) — skipping", "set", setName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if equalStringSlices(cur, want) {
|
||||||
|
return // steady state: no mutation
|
||||||
|
}
|
||||||
|
if _, errOut, err := b.runner.Run(ctx, "nft", "flush", "set", "inet", b.table, setName); err != nil {
|
||||||
|
b.logger.Error("felhomsshd belt: flush failed", "set", setName, "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range want {
|
||||||
|
if _, errOut, err := b.runner.Run(ctx, "nft", "add", "element", "inet", b.table, setName, "{ "+e+" }"); err != nil {
|
||||||
|
b.logger.Error("felhomsshd belt: add element failed", "set", setName, "elem", e, "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.logger.Info("felhomsshd belt: set synced", "set", setName, "elements", strings.Join(want, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSet returns the current elements of a named set (sorted), or ok=false if the set can't be read.
|
||||||
|
func (b *Belt) readSet(ctx context.Context, setName string) ([]string, bool) {
|
||||||
|
out, _, err := b.runner.Run(ctx, "nft", "list", "set", "inet", b.table, setName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
m := nftElemRe.FindSubmatch(out)
|
||||||
|
if m == nil {
|
||||||
|
return []string{}, true // set exists but empty (no "elements = {}" block)
|
||||||
|
}
|
||||||
|
var elems []string
|
||||||
|
for _, f := range strings.Split(string(m[1]), ",") {
|
||||||
|
f = strings.TrimSpace(f)
|
||||||
|
if f != "" {
|
||||||
|
elems = append(elems, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortedUnique(elems), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedUnique(in []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStringSlices(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user