Compare commits
363 Commits
4d84207572
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f17ed11599 | |||
| 1db56bf837 | |||
| 53d047a6c1 | |||
| 28ba8593b8 | |||
| 6981450110 | |||
| 703db166e7 | |||
| aa74294a7d | |||
| 5b2666e3a2 | |||
| 062a7027ab | |||
| a2e914f683 | |||
| 0404f60e6a | |||
| 3f5f61b716 | |||
| 6d7904786c | |||
| 856a127cd6 | |||
| 257c4d85c0 | |||
| 72161f6cf0 | |||
| 03b58cec0a | |||
| fe14bc62c0 | |||
| 0b28eae7bb | |||
| 7581f8140a | |||
| 3d0a1d615d | |||
| 77e2cc4583 | |||
| 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,46 @@
|
||||
---
|
||||
paths: ["internal/backup/**", "internal/pbs/**", "internal/pbsdr/**", "internal/dr/**"]
|
||||
---
|
||||
|
||||
# Backup, PBS and DR
|
||||
|
||||
`internal/backup/` is the vzdump runner, restore-test scheduler and report store. `internal/pbs/` is
|
||||
the fingerprint-pinned PBS-API client plus the verify maintenance loop. `internal/pbsdr/` and
|
||||
`internal/dr/` carry the DR tier and recipe halves.
|
||||
|
||||
## The three PBS laws
|
||||
|
||||
1. **Set-only.** `pvesm remove` **DELETES the encryption key**. Re-apply configuration; never remove
|
||||
and re-add a PBS storage to change it.
|
||||
2. **Secret on stdin.** A token secret is passed on stdin, never as an argv the process table shows.
|
||||
3. **Verify the pin BEFORE consuming the secret.** A fingerprint check after the secret has been sent
|
||||
protects nothing.
|
||||
|
||||
## Verify is server-side, and its default skips the work
|
||||
|
||||
The agent drives verification **remotely** via the PBS API; `proxmox-backup-client` has **no** verify
|
||||
subcommand. `POST .../verify` defaults to **`ignore-verified=true`, which SKIPS already-verified
|
||||
snapshots** — send `ignore-verified=false` to actually re-read and detect corruption. A verify that
|
||||
skipped everything reports success.
|
||||
|
||||
## Presence is not success
|
||||
|
||||
A timestamp recording an **attempt** is not evidence of a **result**. Where a status field travels
|
||||
beside a timestamp, the verdict must consult **both** — or the timestamp must record only successes.
|
||||
Ask of any timestamp: *what exactly must have happened for this to be set?* If the answer is "we
|
||||
tried", it cannot answer "did it work".
|
||||
|
||||
**Corollary:** when a verdict changes which field it counts from, the alarm text changes with it.
|
||||
Leaving a message reading `last run 8h ago` while alarming on a six-day-old **success** turns a true
|
||||
alarm into one the operator dismisses.
|
||||
|
||||
## Prune is server-side now
|
||||
|
||||
`DatastoreBackup` carries **no** `Datastore.Prune`. Boxes set `keep_last: 0` and the off-site endpoint
|
||||
runs the prune jobs. **Box tokens stay write-only — never widen that grant** (R-89).
|
||||
|
||||
<!--
|
||||
The ignore-verified default is the sharpest instance of the "absent log line" class in this repo: a
|
||||
verify that silently skipped every snapshot completes fast, exits clean, and reports the same shape
|
||||
as one that read every byte.
|
||||
-->
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
paths: ["internal/capability/**", "internal/storage/**", "internal/localapi/**", "internal/hub/**", "internal/guesthook/**"]
|
||||
---
|
||||
|
||||
# A health check issues no block I/O
|
||||
|
||||
No `statfs`, no `getdents`, no read, write or `fsync` — **not even behind a timeout**.
|
||||
|
||||
A probe that touches a wedged device enters uninterruptible sleep, survives `SIGKILL`, and cannot be
|
||||
recovered until the device returns or the host reboots — so `systemctl restart` hangs too. A timeout
|
||||
protects the caller's control flow and nothing else: the blocked thread remains.
|
||||
|
||||
**Liveness is decided from `/proc` and the kernel's own state**, never by reading or writing the
|
||||
filesystem.
|
||||
|
||||
<!--
|
||||
Measured, R-117 spike §6.3 (felhom.eu/documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md):
|
||||
a probe stayed in D state 3m50s after kill -9; a buffered write with no fsync blocked too (O_CREAT
|
||||
needs journal access); and statfs/getdents returned HEALTHY on a namespace that EIOs every byte —
|
||||
fast, and wrong.
|
||||
|
||||
This rule used to be duplicated verbatim in felhom-agent/CLAUDE.md with a note explaining that
|
||||
felhom.eu/CLAUDE.md "does not load in an agent-only session". That reasoning was correct before
|
||||
path-scoped rules existed. The single source is now felhom.eu/CLAUDE.md "Code quality rules"; this
|
||||
file is the scoped copy that loads exactly where health checks are written. (2026-08-06)
|
||||
-->
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
paths: ["internal/localapi/**", "internal/authz/**", "internal/guesthook/**"]
|
||||
---
|
||||
|
||||
# Local API, authz and guest hooks — the per-guest blast radius
|
||||
|
||||
`internal/localapi/` is the narrow per-guest local API: token store, disks/format, guest binds,
|
||||
controller swap, stale-lock recovery, pinned self-signed leaf. `internal/authz/` is the operator
|
||||
signed-op verifier (SSHSIG) plus the durable nonce store. `internal/guesthook/` installs the
|
||||
pre-start self-heal hookscript.
|
||||
|
||||
> **Overlap note:** `health-checks.md` also matches `internal/localapi/**` and
|
||||
> `internal/guesthook/**`. That is deliberate — both rules apply there and both load. Neither
|
||||
> supersedes the other.
|
||||
|
||||
## Scoping is the whole security property
|
||||
|
||||
This API is reachable **from inside a customer guest**. Every route must be scoped to the guest that
|
||||
called it — a route that can name another guest's id has escaped its blast radius. Fail **safe to
|
||||
protected**: an unrecognised or unresolvable caller gets less access, never more.
|
||||
|
||||
## Replay protection must survive a restart
|
||||
|
||||
**`authz.MemoryNonceStore` on a real host is a defect** — replay protection dies on restart. Use
|
||||
`authz.FileNonceStore`. The memory store exists for tests.
|
||||
|
||||
## The token is a hash on disk, plaintext only at mint
|
||||
|
||||
The store keeps **hashes**. The plaintext token exists in exactly one place, `bootstrap.json` on the
|
||||
PVE host — so a "read the token" step means reading that file, and a lost token is re-minted, never
|
||||
recovered.
|
||||
|
||||
## Binds can brick guest boot
|
||||
|
||||
| Do not | Because | Use |
|
||||
|---|---|---|
|
||||
| `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 | a boolean cannot converge stacked double-binds (the `/mnt` doubling bug) | `countHostMounts` normalization inside `AttachDrive` |
|
||||
|
||||
<!--
|
||||
Why fail-safe-to-protected rather than fail-closed: this API also carries the recovery paths. A hard
|
||||
refusal on an unresolvable caller would make a half-broken guest unrecoverable through the very
|
||||
interface built to recover it. Less access, never none.
|
||||
-->
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
paths: ["internal/proxmox/**", "internal/reconcile/**", "internal/signedjobs/**"]
|
||||
---
|
||||
|
||||
# Proxmox — the API contract, and how destructive work is gated
|
||||
|
||||
`internal/proxmox/` is the API-first `Client` plus the fenced root-CLI `Privileged`.
|
||||
`internal/reconcile/` is the reconcile engine, reversibility gate, op journal and crash recovery.
|
||||
`internal/signedjobs/` holds the operator-signed destructive executors (wipe, decommission).
|
||||
|
||||
## A 200 on the POST is not success
|
||||
|
||||
**Every mutating op is async**: it returns a **UPID**, and `WaitTask` must assert
|
||||
`exitstatus == "OK"`. Authorization can fail at *task execution* long after the HTTP call returned
|
||||
200. Treating the POST's status as the result is how a failed destroy reads as a successful one.
|
||||
|
||||
## The privsep token gotcha
|
||||
|
||||
A `--privsep 1` token's rights are the **intersection** of the backing user's permissions **and** the
|
||||
token's own ACLs. The role must be granted on **both** or every call 403s. The same intersection rule
|
||||
bites on PBS (`token ∩ user`).
|
||||
|
||||
## TLS
|
||||
|
||||
**SHA-256 leaf-cert pinning** against the self-signed host cert. **No insecure default**, ever. The
|
||||
pin is the raw leaf-DER sha — the SAN is never checked, so a cert rotation changes the pin and the
|
||||
agent must be re-pinned.
|
||||
|
||||
## The destructive path — never the direct call
|
||||
|
||||
| Do not | Because | Use |
|
||||
|---|---|---|
|
||||
| `Client.DestroyLXC` / `Vzdump` / `SetConfig` ad-hoc | skips classification, signature, per-guest serialization, crash recovery | `reconcile.Engine` paths / `RunSignedJob`; queue via `Queue.Submit` |
|
||||
| add a method to `proxmox.Privileged` | breaks the 3-exception root-CLI fence (`routing_test.go`) | `proxmox.Runner` + a new sudoers `Cmnd_Alias` + `validate.go`-style checks |
|
||||
| treat `ListLXC` output as "guests we own" | audit A1 — pre-v0.62.0 the stale-lock reaper did exactly this, contained only by the pool-scoped token | intersect with `Client.Pool` membership (`staleLockController.Guests()`); **fail safe on read failure** |
|
||||
|
||||
Full trap table: `REUSE.md` §3. Every guest joins the `felhom` pool — `VM.Audit` comes from the
|
||||
`/pool` grant, not from a per-guest ACL.
|
||||
|
||||
<!--
|
||||
The fence is not stylistic. It is what makes this component auditable: two types, one of which can
|
||||
only speak HTTP and one of which can only shell out, with a test asserting neither crosses. A single
|
||||
convenience method on Privileged that also makes an HTTP call would end that property silently.
|
||||
-->
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
paths: ["internal/storage/**", "internal/escrow/**"]
|
||||
---
|
||||
|
||||
# Storage and escrow — format safety and zero-knowledge recovery
|
||||
|
||||
`internal/storage/` is the storage observer, durable IDs, role/claim classifiers, `SudoHostOps` and
|
||||
the watchdog. `internal/escrow/` is the PBS-key escrow with its zero-knowledge recovery code.
|
||||
|
||||
> **Overlap note:** `health-checks.md` also matches `internal/storage/**`. Deliberate — both rules
|
||||
> apply there and both load.
|
||||
|
||||
## Never format the device you inspected
|
||||
|
||||
**AGENT-001 is a TOCTOU:** acting on the caller's `req.Device` (or any remembered `/dev` path) after
|
||||
inspection lets `/dev` re-enumeration retarget the node to a **different physical disk**. Format the
|
||||
**re-resolved** device — `Server.reresolveWipe` / `reresolveBlank`.
|
||||
|
||||
**Never exec raw `mkfs.*`** (including `Binaries.MkfsExt4`/`MkfsXfs`): sudoers no longer allowlists
|
||||
raw mkfs, and going direct bypasses the claim filter and the wrapper's re-checks. Use
|
||||
`SudoHostOps.Format`, which routes through `felhom-mkfs-guarded`.
|
||||
|
||||
## The two durable-ID schemes refuse each other
|
||||
|
||||
They are not interchangeable, and each returns a `binding_mismatch` for the other's scheme:
|
||||
|
||||
| Purpose | Scheme | Resolver |
|
||||
|---|---|---|
|
||||
| wipe confirmation | `byid:` / `byuuid:` | `ResolveDurableDevice`, `DiskInfo.WipeDurableID` |
|
||||
| enrolled-storage remount | `uuid:` | `ResolveStorageDevice` |
|
||||
|
||||
Using `DiskInfo.DurableID` (a `uuid:`) as a wipe-confirmation id is F20-BUG2.
|
||||
|
||||
## Drive data is never taken by force
|
||||
|
||||
Plain `umount` only — **never `-l`, never `-f`**, and never any format operation under
|
||||
`/mnt/felhom-drives`.
|
||||
|
||||
## Escrow is zero-knowledge, and a fetch failure is not a wrong code
|
||||
|
||||
The server holds no client key; a no-key restore fails with `missing key`. **A fetch failure must
|
||||
never be reported as a wrong recovery code** — that told a customer their correct code was bad, in
|
||||
hundredths of a second, when checking a code actually takes about one. Distinguish "we could not
|
||||
reach the store" from "the code did not match", always.
|
||||
|
||||
<!--
|
||||
The escrow recovery-code "flake" was a REAL defect, not a flake. "Known flake, re-run" needs evidence
|
||||
before it is said out loud — that phrase cost this project a real finding once.
|
||||
-->
|
||||
@@ -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
+82
@@ -0,0 +1,82 @@
|
||||
#!/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
|
||||
|
||||
# ── WORKSPACE-ROOT ASSERTION (2026-08-05, R-204 rider) ───────────────────────────────────────────
|
||||
# Refuse a push from a clone outside the felhom workspace.
|
||||
#
|
||||
# WHY THIS IS A HOOK AND NOT A LINE IN A DOCUMENT: the workspace root is ALREADY written down, in
|
||||
# documentation/runbooks/workspace-CLAUDE.md and in the workspace-root CLAUDE.md ("stay inside it"),
|
||||
# and work drifted into a home directory anyway. A rule that has failed once as a reminder is not
|
||||
# fixed by writing it down again — it has to be asserted where it can bite.
|
||||
#
|
||||
# A PUSH IS THE RIGHT TRIGGER, deliberately: throwaway clones under /tmp for probes and red-proofs
|
||||
# never push, so nothing legitimate breaks. Reads and builds elsewhere stay unaffected.
|
||||
#
|
||||
# Symlinks are resolved on BOTH sides before comparison, so a symlinked path neither falsely passes
|
||||
# nor falsely fails. If the workspace root does not exist on this machine the check is SKIPPED, not
|
||||
# failed — this hook must not brick a legitimate clone on a different host.
|
||||
#
|
||||
# The only bypass is the documented `git push --no-verify`, whose use is already reportable.
|
||||
FELHOM_WORKSPACE_ROOT=/mnt/5_hdd/felhom.eu
|
||||
if [ -d "$FELHOM_WORKSPACE_ROOT" ]; then
|
||||
ws_real=$(cd "$FELHOM_WORKSPACE_ROOT" 2>/dev/null && pwd -P) || ws_real=""
|
||||
root_real=$(pwd -P) || root_real=""
|
||||
if [ -n "$ws_real" ] && [ -n "$root_real" ]; then
|
||||
case "$root_real/" in
|
||||
"$ws_real"/*) : ;; # inside the workspace — proceed
|
||||
*)
|
||||
echo "pre-push: PUSH REFUSED - this clone is OUTSIDE the felhom workspace." >&2
|
||||
echo " clone: $root_real" >&2
|
||||
echo " expected: under $ws_real (repos live in $ws_real/git/<repo>)" >&2
|
||||
echo " Work in the workspace clone, or bypass with 'git push --no-verify'" >&2
|
||||
echo " and state that you did in the session report." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
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.
|
||||
+5391
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
# CLAUDE.md — `felhom-agent`
|
||||
|
||||
> Stable orientation only — **current state lives in `CONTEXT.md` and the top of `CHANGELOG.md`**,
|
||||
> never here. Cross-repo conventions (artifact taxonomy, access, clean-tree gate, secrets,
|
||||
> CHANGELOG/REPORT): workspace-root `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`. Path-scoped detail:
|
||||
> `.claude/rules/`.
|
||||
|
||||
## What this repo is
|
||||
|
||||
The operator-tier **host agent**, one per Proxmox host, owning **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 in the system**.
|
||||
|
||||
- Renamed from `proxmox-controller`.
|
||||
- **Distinct from `felhom-controller`** — that is the *in-guest* controller, Docker-only, holding no
|
||||
Proxmox credentials. Do not confuse them.
|
||||
- **Control plane, not data plane:** if the agent dies, apps keep serving; only management degrades.
|
||||
- Pure Go stdlib + `golang.org/x/crypto`. No web frameworks.
|
||||
|
||||
## Doing X → read Y
|
||||
|
||||
| Doing | Read |
|
||||
|---|---|
|
||||
| writing any new code | `REUSE.md` — helpers, format-safety guards, traps, seams |
|
||||
| needing current state / open threads | `CONTEXT.md` + the top `CHANGELOG.md` entry |
|
||||
| Proxmox, reconcile or signed jobs | loads itself: `.claude/rules/proxmox.md` |
|
||||
| local API, authz or guest hooks | loads itself: `.claude/rules/localapi.md` |
|
||||
| backup, PBS or DR | loads itself: `.claude/rules/backup.md` |
|
||||
| storage or escrow | loads itself: `.claude/rules/storage.md` |
|
||||
| writing a health check | loads itself: `.claude/rules/health-checks.md` |
|
||||
| **release, build, publish, deploy, verify a version** | the **`felhom-build-deploy`** skill — **never hand-roll it** |
|
||||
| writing or reviewing a test, fixing a bug | the **`felhom-testing`** skill |
|
||||
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.md` — never restate them |
|
||||
| which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
|
||||
| what version is live anywhere | ask the hub (`/hosts`, `/configs`) or the box — **never a doc** |
|
||||
| the authoritative design | `felhom.eu/documentation/architecture/03-host-agent.md` (locked) |
|
||||
|
||||
## The root-CLI fence — API-first, exactly three exceptions
|
||||
|
||||
This is in the core because breaching it is how this component stops being auditable.
|
||||
|
||||
- **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`.
|
||||
Adding a method to `proxmox.Privileged` breaks the fence; use `proxmox.Runner` plus a new sudoers
|
||||
`Cmnd_Alias` and `validate.go`-style checks (`REUSE.md` §3).
|
||||
- **Destructive ops go through the reconcile gate / signed-jobs path.** Never call
|
||||
`Client.DestroyLXC` / `Vzdump` / `SetConfig` ad-hoc — that skips classification, signature,
|
||||
per-guest serialization and crash recovery.
|
||||
- **Ownership must be PROVEN, never assumed.** A raw `ListLXC` list is not "guests the agent owns";
|
||||
intersect with `Client.Pool` membership and fail safe on a read failure (audit A1).
|
||||
|
||||
## Gates — ONE entry point
|
||||
|
||||
**Run `python3 scripts/agent_gates.py` from the repo root after ANY change here.** It runs this
|
||||
repo's gates — `reuse_refs_check` and `instructions_gate`, both the **shared** copies in
|
||||
`felhom.eu/scripts/`, never copied into this repo (a copy recreates the drift they detect; an absent
|
||||
sibling clone FAILS). `--fast` selects the gates touching no network and no container runtime; today
|
||||
that is all of them. **A missing gate is a FAILURE, never a skip.**
|
||||
|
||||
**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** — CI re-runs the same entry point on every push and **emails the operator on
|
||||
failure**, so a bypass is noticed even though it is not blocked (R-168, CLOSED 2026-08-02).
|
||||
|
||||
<!--
|
||||
WHY ONE ENTRY POINT (2026-08-02, R-29): a census of all gates across the four repos found every check
|
||||
a CLAUDE.md names was passing, and two of the four nobody is told to run were failing. This repo was
|
||||
the extreme case — nothing ran against it at all, and 90 cited paths were checked by no one.
|
||||
-->
|
||||
|
||||
## Live validation — the fence
|
||||
|
||||
Exercise the **SERVER-SIDE PIPELINE** a real user triggers, end-to-end. **The forbidden shortcut is
|
||||
BYPASSING it** — the F9 episode was a raw guest-attach with hand-set state, and it proved nothing.
|
||||
|
||||
`claude-in-chrome` is NOT available on DooPlex. Invoking the exact endpoint the UI invokes is an
|
||||
acceptable proxy — **say which method was used**. Low-level mechanism tests where the direct call IS
|
||||
the mechanism are exempt.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Trunk-based — no branches.** All shippable work commits directly to `main`; `main` equals what is
|
||||
deployed. Report-only artifacts (audits, findings, fixspecs) go to `felhom.eu/documentation/`.
|
||||
- **Unattended escape hatch:** if a fix cannot be cleanly verified and shipped, **revert and report**
|
||||
— never park it on a branch.
|
||||
- **Logging**: the slog logger fans out to journald (configured level) plus the always-DEBUG
|
||||
`applog.Ring` (remote pulls). English, keys-never-values, durations on outcomes. Full rules:
|
||||
`felhom.eu/documentation/runbooks/logging-conventions.md`.
|
||||
- Update `REUSE.md` in the same commit that adds, changes or deprecates a shared helper or pattern.
|
||||
|
||||
## End-of-session checklist
|
||||
|
||||
- **`CHANGELOG.md`** (cumulative, newest on top) and **`REPORT.md`** (overwritten with this run only)
|
||||
— in every repo touched.
|
||||
- **`CONTEXT.md`** — decisions, state, what is next.
|
||||
- **`REUSE.md`** — if a shared helper or pattern moved.
|
||||
- **A finding goes in `felhom.eu/documentation/backlog/OPEN-ITEMS.md` first**, never only in a report
|
||||
or an audit.
|
||||
- **Confirm your own last push's CI run went green, by run ID** — CI mails on failure, which is a PUSH
|
||||
signal; this is the PULL check that catches a lost or unread mail. An unchecked green is an
|
||||
assumption, not an observation.
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
# 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`.
|
||||
|
||||
## R-199 (v0.125.0) — links 6–8 of the recovery chain, assembled and walked
|
||||
|
||||
`POST /escrow/recover-offsite-password` (pinned local API, `withGuest`): the controller supplies the
|
||||
customer's recovery code, the agent fetches THIS host's own sealed blob from the hub
|
||||
(`hub.Client.FetchIdentityEscrow` → `GET /hosts/{id}/escrow`, hub >= v0.94.0, self-scoped by the
|
||||
per-host key), unseals it via `escrow.OffsiteKeyRecoverer`, and returns **only** the offsite restic
|
||||
repository password plus its sha256.
|
||||
|
||||
**Rules that must not erode:**
|
||||
- **Only that field.** Not the tunnel token, not the PBS token, not the WG key — the controller is a
|
||||
trust tier down and needs none of them. Narrowing cost nothing and is not recoverable later.
|
||||
- **The unseal stays in the agent.** `age` is an agent runtime dependency (`/usr/bin/age` — hardcoded,
|
||||
no config override; 1.2.1 on demo-felhom) and is deliberately absent from the controller image.
|
||||
- **R:** in memory for one call, cleared on the success path AND every failure path, never on disk,
|
||||
never in argv, never logged at any level including inside an error, never echoed. Verified live: 0
|
||||
log lines, 0 files, 0 leftover `felhom-idesc-*` dirs, with a positive control proving the search worked.
|
||||
- **Three distinct outcomes**, not one generic failure: no blob (404), a bundle that opens but predates
|
||||
the field (409 — pre-fork-4, cannot be retro-fitted), a code that does not open it (400 — fail-closed
|
||||
at age's KDF, nothing written).
|
||||
- **The wiring is pinned by an AST walk** (`cmd/felhom-agent/escrow_recover_wiring_test.go`):
|
||||
`main` → `runDaemon` → `buildLocalAPIServer`, an `escrow.OffsiteKeyRecoverer` constructed there, the
|
||||
`Options.EscrowRecovery` field present, and the fetcher calling the DAEMON's own `hubClient` (the
|
||||
self-scoping that makes cross-host retrieval impossible is a property of WHICH key is used).
|
||||
Links 6 and 7 were two of this project's six built-but-never-wired instances.
|
||||
|
||||
**Proven live on demo-felhom 2026-08-04:** recovered sha256 == on-disk sha256 == the hub's stored hash.
|
||||
A wrong code five minutes earlier failed closed. **The chain stops at link 8** — nothing installs a
|
||||
recovered password, reopens a repository, or restores a file.
|
||||
|
||||
**§8.6, fixed while here:** `runSelftestIdentityConsume`'s success line used to recite
|
||||
"tunnel_token + pbs_token", which became a misstatement when v0.77.0 sealed the repository password
|
||||
into the same bundle — anyone reading it would conclude the password was not there. It now names what
|
||||
THIS bundle carried and what it did not.
|
||||
|
||||
## Current
|
||||
|
||||
- **2026-08-03 — v0.123.0 (R-185): a tier the box cannot READ now says so.** The agent's token had
|
||||
`FelhomAgentStore` on `local`, `local-lvm`, `felhom-pbs` and **not** on `felhom-backup` — the
|
||||
storage both demo boxes configure as `local_backup_target`. That storage answered `{"data":[]}`
|
||||
through the token while root listed three archives, and `pickForThisRun` skipped it as *"no settled
|
||||
archive yet"* — **which is what a brand-new tier reports**, so the host tier was never
|
||||
restore-testable and nothing said so.
|
||||
- **The permission question is asked directly**, because unlike the listing it has a definite
|
||||
answer: `Client.Permissions` reads `/access/permissions?path=/storage/<target>` **as the agent's
|
||||
own token**, and `storeGrantStatuses` emits one `capability.Status` per configured tier. It
|
||||
composes AROUND the sudo prober, the way `poolReadStatus` already does — an API read does not
|
||||
belong inside a sudo-policy probe. `Status`'s wire shape is untouched, so the hub's critical
|
||||
degraded alert applies with **no hub change**.
|
||||
- **MEASURED FIRST, and the obvious reading is wrong:** an ungranted path answers neither empty nor
|
||||
403 — it carries the privileges INHERITED from the box-wide `/` grant
|
||||
(`Sys.Audit, SDN.Use, Datastore.Audit`). Checking path-presence, or `Datastore.Audit`, reports a
|
||||
blinded storage HEALTHY. The probe tests **`Datastore.AllocateSpace`**; re-measure before ever
|
||||
changing that constant (`storeGrantRequiredPriv`, red-proved).
|
||||
- **The probed set comes from `BackupTiers()`, never a fixed list** — a hardcoded probe list is the
|
||||
defect reproduced inside the fix. Critical, EXCEPT the `local` fallback target (reported, but it
|
||||
does not page). It never consults content, so it cannot alarm on a newborn tier; it never reports
|
||||
ok when it could not ask.
|
||||
- **LIVE:** degraded observed on the still-blind box (hub emailed `agent_capability_degraded`) →
|
||||
grant applied on **both** demo boxes → token lists 3 and 4 archives → `ok=70 total=70 degraded=0`
|
||||
and `degraded → ok` at the hub → **the host tier became a due-check candidate for the first time**,
|
||||
correctly picking the 08-02 archive (08-03 had not settled 24 h).
|
||||
- **The installer's real defect was NOT `PVE_STORAGES`** — see `felhom.eu` CONTEXT S-22: Case A
|
||||
grants, the Scenario-F reuse arm did not. Fixed in installer **1.24.0** with a gate.
|
||||
|
||||
- **2026-08-03 — v0.122.0 (R-189 · R-188 · R-186): three signals that lied about their own work.**
|
||||
None touches data; all three cost attention, which every other signal depends on.
|
||||
- **R-189 — a passing restore-test no longer vanishes on a restart.** `restore_tests[]` came only
|
||||
from the in-memory `backup.Store` (*"lost on restart; the cadence re-populates"* — true under a
|
||||
timer, FALSE since R-86, because the agent will not re-test a proven archive). **Observed live:**
|
||||
a 14.5 GB offsite PASS at 15:25:14, agent restarted 2 m 43 s later, hub logged `0 restore-tests`
|
||||
twice. `RestoreTestState` now stores `tier` + `verified` beside the archive (v3 shape; v1/v2
|
||||
still read, and a record missing archive-or-tier is NOT reported), exposes
|
||||
`ProvenRestoreTests`, and `Collector.SetProvenRestoreTests` merges it — **one entry per tier,
|
||||
newest by `TestedAt` wins**, so a fresh failure beats a stored success and a tier never appears
|
||||
twice. Wiring pinned by an AST test: the method this replaces (`Snapshot`) claimed a
|
||||
"host-report gauge" in its doc comment and had **no caller** for weeks.
|
||||
- **ONLY SUCCESSES ARE PERSISTED, and the reason is now in the code:** a success *suppresses*
|
||||
future work (a proven archive is never re-tested, so a lost proof leaves the box quietly less
|
||||
tested than it believes); a failure *causes* future work and heals itself at the next evaluation.
|
||||
- **R-188 — the release stopped emailing false failures.** Only the tag PUSH moved (build → tag
|
||||
locally → publish → push tag): the push is what wakes CI, and a tag visible before its package
|
||||
made the gate correctly fail a correct release ~half the time. The old order's invariant is now
|
||||
asserted directly — `check-published-versions.py` refuses a **published version with no tag**, as
|
||||
a bounded, printed probe (the package listing api is still 401 without a token, re-measured).
|
||||
- **R-186 — a released binary is verifiable.** `-trimpath -buildvcs=false`: same source → same
|
||||
bytes whether or not the tag exists. Measured. `publish-agent.sh`'s fallback also forced
|
||||
`CGO_ENABLED=0` and built a **74 KB different** binary for the same version — both paths now
|
||||
identical. The verification command is in `CLAUDE.md`.
|
||||
|
||||
- **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.
|
||||
- **v0.121.1 — a quiet evaluation is AUDIBLE.** "Nothing is due" is now the NORMAL outcome, and at
|
||||
DEBUG it was silent: an empty journal would have been equally consistent with a healthy loop and
|
||||
a dead goroutine (standing rule 3 — the shape the R-88 watcher was retired for). A not-due
|
||||
evaluation logs ONE INFO line naming every tier's verdict; an unlistable tier reads `UNKNOWN`
|
||||
with its error in that same line.
|
||||
- **PROVEN LIVE 2026-08-03 on demo-felhom:** due-triggered offsite restore-test of a 14.5 GB
|
||||
encrypted PBS archive — restored, booted, verified, scratch destroyed, **635 s**; the state then
|
||||
named that archive, a second evaluation ran nothing, and an agent restart ran nothing.
|
||||
- **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,50 @@
|
||||
# REPORT — agent v0.129.0: a correct code for an earlier package (R-311, 2026-08-12)
|
||||
|
||||
## What changed and why
|
||||
|
||||
Yesterday's drill proved a retained escrow package **works** — unsealed with the old recovery code, it
|
||||
opened a set-aside store and restored planted files byte-identical — while this agent answered that
|
||||
same correct code with *"the recovery code did not open the sealed bundle"*. Nothing had ever tried
|
||||
the retained packages, so a correct-but-earlier code and a mistype were genuinely indistinguishable.
|
||||
|
||||
- `internal/hub/client.go` — `FetchRetainedIdentityEscrow` → `GET /api/v1/hosts/<id>/escrow/retained`
|
||||
(hub ≥ v0.103.0). **A 404 is a clean "none"**, not a fault: an older hub must not turn into a failed
|
||||
recovery.
|
||||
- `internal/escrow/recover.go` — optional `FetchRetained`, `ErrCodeOpensRetained` +
|
||||
`RetainedOpenedError{SupersededAt, KeyFingerprint, Index, HasResticPassword}`. Consulted **only**
|
||||
after the current package refuses.
|
||||
- `internal/localapi/escrow_recover.go` — a **fifth** case on the R-224 switch: **422**, with
|
||||
`opens_retained`, `superseded_at`, `retained_has_restic_pw`. Added to the switch, not a restructure.
|
||||
- `cmd/felhom-agent/main.go` — the retained fetcher wired on the same self-scoped hub client.
|
||||
|
||||
## Fail-safe, in every direction
|
||||
|
||||
nil fetcher · hub without the route (404) · transport failure · malformed package → **the original
|
||||
refusal stands, unchanged**. The worst outcome of this feature breaking is the behaviour before it.
|
||||
Attempts bounded (`MaxRetainedTried`, default 6) — each unwrap is ~1 s of scrypt, so an unbounded loop
|
||||
would turn one wrong code into a minutes-long hang.
|
||||
|
||||
## Tests — 7, with REAL age crypto
|
||||
|
||||
Real crypto because the two situations are indistinguishable **at the unwrap**; a faked unwrap would
|
||||
prove nothing about what was broken. Full suite green (`go build`/`vet`/`test ./...`), agent gates OK.
|
||||
|
||||
**Red-proof, mutation asserted applied before the run:** remove the `tryRetained` block from
|
||||
`RecoverOffsiteRepoPassword` →
|
||||
`err = escrow: the recovery code did not unwrap the identity escrow (wrong recovery code…)` →
|
||||
`TestRecover_CodeOpensRetainedPackage_IsNotAWrongCode` FAILS. **The lie returns, in those words.**
|
||||
That is the layer the lie actually lives in: removing the *controller's* case yields the neutral
|
||||
message instead, because R-224's safe default catches it.
|
||||
|
||||
## Released and deployed
|
||||
|
||||
`release-agent.sh 0.129.0` — tagged `v0.129.0`, published, **verified by independent download**,
|
||||
sha256 `53a54f0620afbd6d…`. Installed on `felhom-pve`, `felhom-agent --version` = 0.129.0, unit active,
|
||||
journal clean (normal PBS verify cycle). **NOT VOUCHED** — that stays the operator's act.
|
||||
|
||||
## Bypass, stated as required
|
||||
|
||||
`git push --no-verify` was used **once** for the code push. The `release-complete` gate refuses a
|
||||
CHANGELOG entry whose tag and package do not exist, and `release-agent.sh` refuses a tree that is not
|
||||
pushed — circular by construction. The bypass was immediately followed by the real release; gates were
|
||||
re-run afterwards and are **green**, and the tag+package now exist.
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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). |
|
||||
| `capability` store-grant probe (`storeGrantStatuses` / `storeGrantVerdict` / `Client.Permissions`) | cmd/felhom-agent/main.go, internal/proxmox/query.go | *"may the agent READ this backup tier?"*, one `capability.Status` per configured tier | R-185. **Never infer permission from an empty content listing** — `{"data":[]}` is what a FORBIDDEN tier and a NEWBORN tier both return, and that ambiguity hid an unreadable host tier on both demo boxes. Ask `/access/permissions` **as the agent's own token** (root always says yes). **The ungranted answer is not empty and not a 403** — it carries the privileges inherited from the box-wide `/` grant, so test for **`Datastore.AllocateSpace`** specifically; path-presence or `Datastore.Audit` reports a blinded storage healthy. Probed set comes from `BackupTiers()`, never a fixed list. Critical except the `local` fallback. Composes AROUND the sudo prober (the `poolReadStatus` precedent); `Status`'s wire shape is untouched so the hub alert is free. Unreachable PVE ⇒ degraded, never ok. |
|
||||
| `backup.RestoreTestState` | internal/backup/restoretest_state.go | `RecordSuccess(target,archive,tier,verified,t)` / `ProvenArchive(target)` / `ProvenRestoreTests(ctx)` / `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. **R-189: it is also the REPORTABLE half of the restore-test signal.** The in-memory `backup.Store` holds only this process's latest run, and under per-archive due-ness the agent will not re-test a proven archive — so a proof lost to a restart is not repeated for a whole archive generation (observed live: a passing 14.5 GB offsite restore reached no host-report). `ProvenRestoreTests` renders the stored proofs as `hub.RestoreTest` entries and the collector merges them; a record missing the archive or the tier is NOT emitted, because an unproven tier reading as proven is worse than the defect. **Only successes are stored, deliberately:** a success suppresses future work, a failure causes it. |
|
||||
| `hub.ProvenRestoreTestReporter` + `Collector.SetProvenRestoreTests` | internal/hub/collect.go | the DURABLE restore-test source, merged with the in-memory one | R-189. Merge rule: **one entry per tier, newest by `TestedAt` wins** — a fresh failure beats a stored success (the failure is the news, and it lives nowhere else), a stored success beats a stale in-memory entry after a restart, and a tier never appears twice (the hub would read two tests). An unparseable timestamp counts as OLDER, so a malformed entry cannot displace a good one. **The wiring is pinned by an AST test** — the method this replaced (`RestoreTestState.Snapshot`) carried a doc comment naming a host-report gauge and had no caller for weeks. |
|
||||
| `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,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Scenario H — THE SEAM IS WIRED IN THE PRODUCTION PATH, proven by walking the AST rather than by
|
||||
// grepping for a string.
|
||||
//
|
||||
// WHY THIS TEST EXISTS AND WHY IT IS AN AST WALK. This project's built-but-never-wired count is six,
|
||||
// and links 6 and 7 of the recovery chain were TWO of them: `UnwrapIdentityBundle` sat in the tree
|
||||
// for two months with no caller but a `--selftest`, and the hub's blob-serving endpoints have no
|
||||
// client to this day. The fix must not become the seventh. `strings.Contains` on the file would pass
|
||||
// against a commented-out line, a line inside a test helper, or a line in dead code behind a flag
|
||||
// nobody sets — so this resolves the call graph instead: `Options{EscrowRecovery: …}` must be
|
||||
// constructed inside a function that `runDaemon` reaches, and `runDaemon` must be reached by `main`.
|
||||
|
||||
func parseMain(t *testing.T) (*token.FileSet, *ast.File) {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing main.go: %v", err)
|
||||
}
|
||||
return fset, f
|
||||
}
|
||||
|
||||
// callsWithin returns the set of function names called (directly, by identifier or selector) inside
|
||||
// the named top-level function.
|
||||
func callsWithin(f *ast.File, fnName string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Name == nil || fd.Name.Name != fnName || fd.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
||||
ce, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch fn := ce.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
out[fn.Name] = true
|
||||
case *ast.SelectorExpr:
|
||||
if x, ok := fn.X.(*ast.Ident); ok {
|
||||
out[x.Name+"."+fn.Sel.Name] = true
|
||||
}
|
||||
out[fn.Sel.Name] = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestEscrowRecoveryIsWiredIntoTheDaemon asserts the whole chain from func main() to the field.
|
||||
func TestEscrowRecoveryIsWiredIntoTheDaemon(t *testing.T) {
|
||||
_, f := parseMain(t)
|
||||
|
||||
// 1. main() reaches runDaemon.
|
||||
if !callsWithin(f, "main")["runDaemon"] {
|
||||
t.Fatal("func main() does not call runDaemon — the daemon path this test asserts is not the live one")
|
||||
}
|
||||
// 2. runDaemon reaches buildLocalAPIServer.
|
||||
if !callsWithin(f, "runDaemon")["buildLocalAPIServer"] {
|
||||
t.Fatal("runDaemon does not call buildLocalAPIServer — the local API is not built on the daemon path")
|
||||
}
|
||||
|
||||
// 3. Inside buildLocalAPIServer, a localapi.Options composite literal carries EscrowRecovery, and
|
||||
// an escrow.OffsiteKeyRecoverer is constructed there.
|
||||
var optionsHasField, recovererConstructed bool
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Name == nil || fd.Name.Name != "buildLocalAPIServer" || fd.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
||||
cl, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := cl.Type.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
pkg, _ := sel.X.(*ast.Ident)
|
||||
if pkg == nil {
|
||||
return true
|
||||
}
|
||||
switch pkg.Name + "." + sel.Sel.Name {
|
||||
case "localapi.Options":
|
||||
for _, el := range cl.Elts {
|
||||
kv, ok := el.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "EscrowRecovery" {
|
||||
optionsHasField = true
|
||||
}
|
||||
}
|
||||
case "escrow.OffsiteKeyRecoverer":
|
||||
recovererConstructed = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !recovererConstructed {
|
||||
t.Error("no escrow.OffsiteKeyRecoverer is constructed in buildLocalAPIServer — links 6→8 have no " +
|
||||
"production assembly point (the built-but-never-wired shape, seventh instance)")
|
||||
}
|
||||
if !optionsHasField {
|
||||
t.Error("localapi.Options in buildLocalAPIServer carries no EscrowRecovery field — the recoverer " +
|
||||
"exists and the route would answer 503 forever")
|
||||
}
|
||||
}
|
||||
|
||||
// The hub fetch must be the DAEMON's own hub client, not a freshly constructed one with different
|
||||
// credentials — the self-scoping that makes cross-host retrieval impossible is a property of WHICH
|
||||
// key is used.
|
||||
func TestEscrowRecoveryUsesTheDaemonHubClient(t *testing.T) {
|
||||
fset, f := parseMain(t)
|
||||
var fetchUsesHubClient bool
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Name == nil || fd.Name.Name != "buildLocalAPIServer" || fd.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
||||
ce, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := ce.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "FetchIdentityEscrow" {
|
||||
return true
|
||||
}
|
||||
if x, ok := sel.X.(*ast.Ident); ok && x.Name == "hubClient" {
|
||||
fetchUsesHubClient = true
|
||||
} else {
|
||||
t.Errorf("FetchIdentityEscrow at %s is called on something other than the injected hub client",
|
||||
fset.Position(ce.Pos()))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !fetchUsesHubClient {
|
||||
t.Fatal("the recoverer's fetcher does not call hubClient.FetchIdentityEscrow — either the fetch is " +
|
||||
"not wired, or it uses a client whose credentials are not this host's")
|
||||
}
|
||||
}
|
||||
|
||||
// The route itself must be registered on the local API. A handler with no route is the same defect
|
||||
// one layer down, and it has shipped here before.
|
||||
func TestRecoverRouteIsRegistered(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "../../internal/localapi/server.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing localapi/server.go: %v", err)
|
||||
}
|
||||
var registered bool
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
ce, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(ce.Args) < 2 {
|
||||
return true
|
||||
}
|
||||
sel, ok := ce.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "HandleFunc" {
|
||||
return true
|
||||
}
|
||||
lit, ok := ce.Args[0].(*ast.BasicLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lit.Value, "/escrow/recover-offsite-password") {
|
||||
registered = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !registered {
|
||||
t.Fatal("POST /escrow/recover-offsite-password is not registered on the local API mux — the handler " +
|
||||
"exists and nothing can reach it")
|
||||
}
|
||||
}
|
||||
@@ -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,156 @@
|
||||
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
|
||||
}
|
||||
|
||||
// R-189 Scenario I — the DURABLE proof source must actually be wired into the collector.
|
||||
//
|
||||
// This test exists because the method it feeds is the project's own cautionary tale:
|
||||
// `RestoreTestState.Snapshot` carried the doc comment "for the host-report gauge" from the day it
|
||||
// was written and **had no caller at all** — a seam built, documented and never connected, found
|
||||
// only when a live restore-test's PASS reached no host-report. The fix must not become the next
|
||||
// instance, so the wiring is asserted rather than trusted.
|
||||
//
|
||||
// AST, not grep: a commented-out call still contains the string (proven yesterday, when commenting
|
||||
// out the tier-picker line failed this test while a `strings.Contains` check would have passed).
|
||||
func TestMainWiresTheDurableRestoreTestProof(t *testing.T) {
|
||||
f := parseMainForWiring(t)
|
||||
|
||||
var wired, feedsState bool
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "SetProvenRestoreTests" {
|
||||
return true
|
||||
}
|
||||
wired = true
|
||||
// ...and it must be fed the PERSISTED state, not the in-memory store.
|
||||
if len(call.Args) == 1 {
|
||||
if id, ok := call.Args[0].(*ast.Ident); ok && id.Name == "rtState" {
|
||||
feedsState = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if !wired {
|
||||
t.Error("main.go never calls collector.SetProvenRestoreTests — the persisted proof would never " +
|
||||
"reach the hub, which is the R-189 defect exactly: a passing restore-test that vanishes on restart")
|
||||
}
|
||||
if wired && !feedsState {
|
||||
t.Error("collector.SetProvenRestoreTests is not fed rtState — the in-memory store is the thing " +
|
||||
"that does NOT survive a restart, so wiring it here would fix nothing")
|
||||
}
|
||||
}
|
||||
@@ -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,417 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"go/ast"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
|
||||
)
|
||||
|
||||
// R-185 — a tier the box cannot READ must say so.
|
||||
//
|
||||
// THE OBSERVATION (demo-felhom, 2026-08-03, reproduced at the start of this session): root lists
|
||||
// three archives on `felhom-backup`; the agent's own token gets `{"data":[]}` from the same
|
||||
// endpoint; and `local`, which has the grant, lists through that same token. The token is the
|
||||
// variable, not the storage.
|
||||
//
|
||||
// The defect is NOT the missing grant — that is one command. It is that an empty content listing is
|
||||
// what a FORBIDDEN tier and a NEWBORN tier both return, so the box could not tell them apart and
|
||||
// said nothing. These tests pin the distinction.
|
||||
|
||||
// permAnswer is the shape /access/permissions really returns, taken from the live measurement:
|
||||
// an UNGRANTED path answers with the privileges inherited from the box-wide grant — NOT empty, and
|
||||
// NOT a 403.
|
||||
var (
|
||||
permGranted = map[string]int{"Datastore.Allocate": 1, "Datastore.AllocateSpace": 1}
|
||||
permUngranted = map[string]int{"Sys.Audit": 1, "SDN.Use": 1, "Datastore.Audit": 1}
|
||||
)
|
||||
|
||||
// probeWith calls the PRODUCTION decision with a permissions answer. **Naming the seam:** everything
|
||||
// below is true up to `storeGrantVerdict`; that the live call feeds it the real API answer is what
|
||||
// Part 0's measurement established and what the live run on the box demonstrates. An earlier draft
|
||||
// of this file re-implemented the branch here — it passed, and would have kept passing while
|
||||
// production diverged, which is the hollow shape this project keeps catching in its own tests.
|
||||
func probeWith(privs map[string]int, targetID string, critical bool) capability.Status {
|
||||
return storeGrantVerdict(targetID, critical, privs, nil)
|
||||
}
|
||||
|
||||
// ── SCENARIO A — a forbidden storage is REPORTED, not passed over ────────────────────────────
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-08-03): delete the store-grant probes from `probeAll` in
|
||||
// main.go — i.e. restore `append(capProber.Probe(ctx), poolReadStatus(ctx, px))` — and
|
||||
// TestMainWiresTheStoreGrantProbe fails with "main.go never calls storeGrantStatuses". That is
|
||||
// today's behaviour on the live box: complete silence about a tier it cannot read.
|
||||
func TestStoreGrant_ForbiddenStorageIsDegradedAndNamed(t *testing.T) {
|
||||
s := probeWith(permUngranted, "felhom-backup", true)
|
||||
|
||||
if s.Status != capability.StatusDegraded {
|
||||
t.Fatalf("a storage the agent may not read must be DEGRADED, not %q — silence is the defect", s.Status)
|
||||
}
|
||||
if !s.Critical {
|
||||
t.Fatal("it must be CRITICAL: the hub alerts only on critical, so a non-critical entry is the same silence with extra steps")
|
||||
}
|
||||
if !strings.Contains(s.Reason, "felhom-backup") {
|
||||
t.Fatalf("the reason must NAME the storage — 'a grant is missing' costs a diagnosis at 07:00; got %q", s.Reason)
|
||||
}
|
||||
if !strings.Contains(s.Reason, "FelhomAgentStore") {
|
||||
t.Fatalf("the reason must name the ROLE to grant, so the fix is in the alert; got %q", s.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// THE TRAP THE LIVE MEASUREMENT CAUGHT, pinned so it cannot be re-introduced: the ungranted answer
|
||||
// is not empty and not a 403 — it carries the INHERITED box-wide privileges. A probe that asked
|
||||
// "did the path come back?" or "does it have Datastore.Audit?" would report the blinded storage
|
||||
// healthy.
|
||||
func TestStoreGrant_InheritedPrivilegesAreNotAGrant(t *testing.T) {
|
||||
if len(permUngranted) == 0 {
|
||||
t.Fatal("fixture wrong: the ungranted answer is NOT empty — that is the whole trap")
|
||||
}
|
||||
if permUngranted["Datastore.Audit"] != 1 {
|
||||
t.Fatal("fixture wrong: the ungranted path DOES carry Datastore.Audit, inherited box-wide")
|
||||
}
|
||||
if s := probeWith(permUngranted, "felhom-backup", true); s.Status != capability.StatusDegraded {
|
||||
t.Fatalf("checking for the wrong privilege reports a blinded storage healthy; got %q", s.Status)
|
||||
}
|
||||
// ...and the privilege actually checked is the one whose absence was measured to blind listing.
|
||||
if storeGrantRequiredPriv != "Datastore.AllocateSpace" {
|
||||
t.Fatalf("the probed privilege changed to %q — re-measure before trusting it", storeGrantRequiredPriv)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO B — a newborn tier is still silent ──────────────────────────────────────────────
|
||||
//
|
||||
// A storage the agent IS allowed to read but which simply holds no archives yet is HEALTHY. The
|
||||
// probe must not look at content at all, or every freshly provisioned box alarms and the signal dies.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed): make the probe degrade on an empty content listing instead of on
|
||||
// the permission — a granted-but-empty storage then reports degraded, i.e. every newborn box alarms.
|
||||
func TestStoreGrant_GrantedButEmptyIsHealthy(t *testing.T) {
|
||||
s := probeWith(permGranted, "felhom-pbs", true)
|
||||
if s.Status != capability.StatusOK {
|
||||
t.Fatalf("a readable tier is healthy whether or not it holds archives yet; got %q (%s)", s.Status, s.Reason)
|
||||
}
|
||||
if s.Reason != "" {
|
||||
t.Fatalf("a healthy probe carries no reason; got %q", s.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO C — the two states are distinguishable at a glance ──────────────────────────────
|
||||
func TestStoreGrant_ForbiddenAndNewbornAreDistinguishable(t *testing.T) {
|
||||
forbidden := probeWith(permUngranted, "felhom-backup", true)
|
||||
newborn := probeWith(permGranted, "felhom-pbs", true)
|
||||
|
||||
if forbidden.Status == newborn.Status {
|
||||
t.Fatalf("the two states must differ — today both read as 'no settled archive yet'; got %q for both", forbidden.Status)
|
||||
}
|
||||
if forbidden.Name == newborn.Name {
|
||||
t.Fatalf("each tier needs its own capability id, or one tier's fault hides another's; got %q twice", forbidden.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// §8.3, weighed once and pinned: a box with NO dedicated target ("local" — host-install's own
|
||||
// DEGRADED fallback) must not turn an ordinary configuration into an operator page. It is still
|
||||
// probed and still reported; only the paging differs.
|
||||
func TestStoreGrant_TheFallbackTargetIsNotCritical(t *testing.T) {
|
||||
if storeGrantCritical("local") {
|
||||
t.Fatal("a box whose backup target is the 'local' fallback must not page the operator about " +
|
||||
"an ordinary, documented configuration")
|
||||
}
|
||||
for _, dedicated := range []string{"felhom-backup", "felhom-pbs", "some-nvme"} {
|
||||
if !storeGrantCritical(dedicated) {
|
||||
t.Fatalf("a DEDICATED target that cannot be read is user-facing and must be critical; %q was not", dedicated)
|
||||
}
|
||||
}
|
||||
// The fallback is still reported — silence for it would be the original defect, scoped smaller.
|
||||
if s := probeWith(permUngranted, "local", storeGrantCritical("local")); s.Status != capability.StatusDegraded {
|
||||
t.Fatalf("the fallback target must still report degraded when unreadable; got %q", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A probe that cannot ask must never answer "ok" — unknown reported as healthy is worse than no
|
||||
// probe, because it looks like coverage.
|
||||
func TestStoreGrant_UnreachablePVEIsDegradedNotOK(t *testing.T) {
|
||||
s := storeGrantStatus(context.Background(), nil, "felhom-backup", true, nil)
|
||||
if s.Status != capability.StatusDegraded {
|
||||
t.Fatalf("an unaskable probe must be DEGRADED, never ok; got %q", s.Status)
|
||||
}
|
||||
if s.Reason == "" {
|
||||
t.Fatal("it must say why it could not ask")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO H — the seam ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// This project's "built but never wired" count reached six last week. The fix for a SILENCE must not
|
||||
// itself be silent. AST, not grep: a commented-out call still contains the string.
|
||||
func TestMainWiresTheStoreGrantProbe(t *testing.T) {
|
||||
f := parseMainForWiring(t)
|
||||
|
||||
var wired bool
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "storeGrantStatuses" {
|
||||
wired = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !wired {
|
||||
t.Error("main.go never calls storeGrantStatuses — the probe would exist and report to nobody, " +
|
||||
"which is precisely the silence R-185 is about")
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-190 — the grant repairs itself, and the repair is VISIBLE ──────────────────────────────
|
||||
//
|
||||
// R-190 is a storage grant that demonstrably worked at 04:44 on 2026-08-03 and was gone by 09:24,
|
||||
// with a host reinstall, logged `pveum` activity and cluster-log entries all ruled out. The cause is
|
||||
// open; the resilience is not conditional on it.
|
||||
//
|
||||
// The half that matters is the RECORD. R-190's own words: the probe sees the state, nothing sees the
|
||||
// transition. A self-repair that leaves only "ok" behind destroys the only evidence a loss happened,
|
||||
// so a recurring loss becomes undetectable forever — strictly worse than the fault it fixes.
|
||||
|
||||
// fakeRepairRunner records wrapper invocations and can be made to fail.
|
||||
type fakeRepairRunner struct {
|
||||
calls [][]string
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *fakeRepairRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
f.calls = append(f.calls, append([]string{name}, args...))
|
||||
if f.fail {
|
||||
return nil, []byte("pveum: refused"), errors.New("exit status 2")
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func newRepairer(f *fakeRepairRunner) *storeGrantRepairer {
|
||||
return &storeGrantRepairer{run: f.Run, log: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
}
|
||||
|
||||
// ── SCENARIO F — the repair is BOUNDED ───────────────────────────────────────────────────────
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-08-04): make mayAttempt always return true (drop the
|
||||
// storeGrantRepairMinInterval check) →
|
||||
//
|
||||
// --- FAIL: TestGrantRepair_IsBounded
|
||||
// storegrant_test.go: a repair must not run on every cycle; 5 cycles produced 5 attempt(s)
|
||||
//
|
||||
// which is a re-grant every report cycle, forever, against a fault an ACL cannot fix. Restored.
|
||||
func TestGrantRepair_IsBounded(t *testing.T) {
|
||||
f := &fakeRepairRunner{}
|
||||
r := newRepairer(f)
|
||||
// Jittered, so the series never lands exactly on the interval boundary — a perfectly regular
|
||||
// series is how a threshold test passes its own mutation, which has happened here before.
|
||||
base := time.Date(2026, 8, 4, 9, 17, 43, 0, time.UTC)
|
||||
offsets := []time.Duration{0, 13*time.Minute + 7*time.Second, 27*time.Minute + 51*time.Second,
|
||||
41*time.Minute + 19*time.Second, 55*time.Minute + 3*time.Second}
|
||||
attempts := 0
|
||||
for _, off := range offsets {
|
||||
if r.mayAttempt("felhom-backup", base.Add(off)) {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("a repair must not run on every cycle; %d cycles produced %d attempt(s) within %s",
|
||||
len(offsets), attempts, storeGrantRepairMinInterval)
|
||||
}
|
||||
// ...and once the interval has genuinely passed, it may try again — a bound is not a ban.
|
||||
if !r.mayAttempt("felhom-backup", base.Add(storeGrantRepairMinInterval+2*time.Minute+11*time.Second)) {
|
||||
t.Fatal("after the interval a repair must be allowed again — otherwise one failure disables the repair forever")
|
||||
}
|
||||
// A DIFFERENT tier is not throttled by this one's attempt.
|
||||
if !r.mayAttempt("felhom-pbs", base.Add(time.Minute)) {
|
||||
t.Fatal("the bound must be per tier — one tier's attempt must not suppress another's")
|
||||
}
|
||||
}
|
||||
|
||||
// A nil repairer (or one with no runner) never attempts, and never panics.
|
||||
func TestGrantRepair_NilIsSafe(t *testing.T) {
|
||||
var r *storeGrantRepairer
|
||||
if r.mayAttempt("felhom-backup", time.Now()) {
|
||||
t.Fatal("a nil repairer must never claim an attempt")
|
||||
}
|
||||
if (&storeGrantRepairer{}).mayAttempt("felhom-backup", time.Now()) {
|
||||
t.Fatal("a repairer with no runner must never claim an attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// The repair calls the EXISTING wrapper verb, with the storage id — no new privileged surface.
|
||||
func TestGrantRepair_CallsTheExistingWrapperVerb(t *testing.T) {
|
||||
f := &fakeRepairRunner{}
|
||||
r := newRepairer(f)
|
||||
if err := r.repair(context.Background(), "felhom-backup"); err != nil {
|
||||
t.Fatalf("repair should succeed with a healthy runner: %v", err)
|
||||
}
|
||||
if len(f.calls) != 1 {
|
||||
t.Fatalf("exactly one wrapper invocation expected; got %d", len(f.calls))
|
||||
}
|
||||
got := f.calls[0]
|
||||
want := []string{"/usr/local/sbin/felhom-backup-target-apply", "grant", "felhom-backup"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("wrapper argv = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("wrapper argv = %v, want %v — the sudoers vector is `grant *`; anything else is a policy change", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A repair that FAILS must surface the failure, not swallow it (Scenario E's precondition).
|
||||
func TestGrantRepair_FailureIsReturned(t *testing.T) {
|
||||
f := &fakeRepairRunner{fail: true}
|
||||
if err := newRepairer(f).repair(context.Background(), "felhom-backup"); err == nil {
|
||||
t.Fatal("a failed wrapper run must return its error — a repair that cannot run must never read as done")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO D (the half that matters) — the REPAIR MUST BE VISIBLE ──────────────────────────
|
||||
//
|
||||
// A repair that leaves only "ok" behind is worse than the fault: the tier works, and the fact that a
|
||||
// permission vanished is gone with it. R-190 exists because nothing saw the transition.
|
||||
//
|
||||
// The channel is the hub's EXISTING ok→degraded→ok edge (§8.5) — nothing new was built. That only
|
||||
// works if the agent deliberately reports ONE degraded cycle after repairing, and if the explanation
|
||||
// rides the field the hub actually puts in the operator's e-mail. The hub's message is built from the
|
||||
// capability NAME and FEATURE (`internal/monitor/host_capability.go` emitTransition) — **not** from
|
||||
// Reason — so the Feature must carry it.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-08-04): after a successful repair, report ok instead —
|
||||
//
|
||||
// s.Status = capability.StatusOK; s.Feature unchanged
|
||||
//
|
||||
// → --- FAIL: TestGrantRepair_ARepairedGrantIsReportedAsATransition
|
||||
//
|
||||
// storegrant_test.go: a self-repair must still report DEGRADED for one cycle so the hub raises
|
||||
// its edge; got "ok" — the loss would be invisible
|
||||
//
|
||||
// i.e. exactly the silence R-190 is about. Restored.
|
||||
func TestGrantRepair_ARepairedGrantIsReportedAsATransition(t *testing.T) {
|
||||
// THE PRODUCTION verdict, not a copy of it. An earlier draft of this test built the Status
|
||||
// itself and asserted its own construction — it would have passed while production reported ok,
|
||||
// which is precisely the silence being guarded against.
|
||||
if pre := probeWith(permUngranted, "felhom-backup", true); pre.Status != capability.StatusDegraded {
|
||||
t.Fatalf("precondition: a missing grant is degraded; got %q", pre.Status)
|
||||
}
|
||||
s := storeGrantRepairedVerdict("felhom-backup", true)
|
||||
|
||||
if s.Status != capability.StatusDegraded {
|
||||
t.Fatalf("a self-repair must still report DEGRADED for one cycle so the hub raises its edge; "+
|
||||
"got %q — the loss would be invisible", s.Status)
|
||||
}
|
||||
// The hub e-mails the FEATURE text. If the explanation is not there, the operator is told a
|
||||
// capability was degraded and never learns it repaired itself or that anything vanished.
|
||||
for _, want := range []string{"MISSING", "RESTORED", "felhom-backup", "R-190"} {
|
||||
if !strings.Contains(s.Feature, want) {
|
||||
t.Fatalf("the Feature text is what the hub puts in the operator's e-mail; it must contain %q. Got: %s", want, s.Feature)
|
||||
}
|
||||
}
|
||||
if !s.Critical {
|
||||
t.Fatal("the transition must be CRITICAL or the hub does not alert on it at all")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO H — the seam ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The wrapper's `grant` verb is itself a "built but never wired" example: it exists, is
|
||||
// sudoers-permitted for any id, and had only ever been called at storage CREATION. The repair must
|
||||
// not become the seventh instance. AST, not grep — a commented-out call still contains the string.
|
||||
func TestMainWiresTheGrantRepair(t *testing.T) {
|
||||
f := parseMainForWiring(t)
|
||||
|
||||
var built, passed bool
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
switch node := n.(type) {
|
||||
case *ast.CompositeLit:
|
||||
if id, ok := node.Type.(*ast.Ident); ok && id.Name == "storeGrantRepairer" {
|
||||
built = true
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
if id, ok := node.Fun.(*ast.Ident); ok && id.Name == "storeGrantStatuses" && len(node.Args) == 4 {
|
||||
if a, ok := node.Args[3].(*ast.Ident); ok && a.Name == "grantRepairer" {
|
||||
passed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !built {
|
||||
t.Error("main.go never constructs a storeGrantRepairer — nothing would ever repair a lost grant")
|
||||
}
|
||||
if !passed {
|
||||
t.Error("storeGrantStatuses is not passed the repairer — the probe would detect the loss and " +
|
||||
"leave it, which is v0.123.0's behaviour and not R-190's mitigation")
|
||||
}
|
||||
}
|
||||
|
||||
// The transition must survive a probe that is NOT the one feeding the hub.
|
||||
//
|
||||
// MEASURED LIVE 2026-08-04, and this test exists because the first implementation failed it in
|
||||
// production while every unit test passed: `probeAll` is called independently by the self-check LOG
|
||||
// and by the collector building a host-report. The repairing call was the log's; the report three
|
||||
// seconds later found the grant present and reported `ok`. The agent's journal had the record and the
|
||||
// hub had nothing — the exact silence R-190 is about, re-created inside its own mitigation.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed): delete the `recentlyRepaired` branch from the healthy path →
|
||||
//
|
||||
// --- FAIL: TestGrantRepair_TransitionSurvivesALaterProbe
|
||||
// storegrant_test.go: a probe AFTER the repair must still report the transition; got "ok" —
|
||||
// the host-report would carry ok and the operator would never learn the grant vanished
|
||||
//
|
||||
// Restored.
|
||||
func TestGrantRepair_TransitionSurvivesALaterProbe(t *testing.T) {
|
||||
r := newRepairer(&fakeRepairRunner{})
|
||||
// Jittered, never landing on the window boundary.
|
||||
repairedAt := time.Date(2026, 8, 4, 9, 39, 34, 0, time.UTC)
|
||||
r.noteRepaired("felhom-backup", repairedAt)
|
||||
|
||||
// The DECISION a later probe makes — the production function, not the helper it calls. An
|
||||
// earlier draft asserted `recentlyRepaired` directly and its red-proof PASSED, because removing
|
||||
// the latch's USE left the helper untouched.
|
||||
healthy := probeWith(permGranted, "felhom-backup", true)
|
||||
if healthy.Status != capability.StatusOK {
|
||||
t.Fatalf("precondition: a granted tier is ok; got %q", healthy.Status)
|
||||
}
|
||||
got := storeGrantHealthyVerdict("felhom-backup", true,
|
||||
healthy, r.recentlyRepaired("felhom-backup", repairedAt.Add(3*time.Second)))
|
||||
if got.Status != capability.StatusDegraded {
|
||||
t.Fatalf("a probe AFTER the repair must still report the transition; got %q — the host-report "+
|
||||
"would carry ok and the operator would never learn the grant vanished", got.Status)
|
||||
}
|
||||
if !strings.Contains(got.Feature, "RESTORED") {
|
||||
t.Fatalf("the later probe must carry the explanation into the hub's e-mail; got: %s", got.Feature)
|
||||
}
|
||||
// Outside the window it reports plain ok again.
|
||||
late := storeGrantHealthyVerdict("felhom-backup", true,
|
||||
healthy, r.recentlyRepaired("felhom-backup", repairedAt.Add(storeGrantRepairReportWindow+time.Minute)))
|
||||
if late.Status != capability.StatusOK {
|
||||
t.Fatalf("outside the window a healthy tier reports ok; got %q — a permanent degraded state "+
|
||||
"would be its own false alarm", late.Status)
|
||||
}
|
||||
if !r.recentlyRepaired("felhom-backup", repairedAt.Add(14*time.Minute+37*time.Second)) {
|
||||
t.Fatal("the latch must outlast the 900s hub report interval, or the record never reaches the hub")
|
||||
}
|
||||
// ...and it clears on its own rather than latching a box degraded forever.
|
||||
if r.recentlyRepaired("felhom-backup", repairedAt.Add(storeGrantRepairReportWindow+time.Minute+7*time.Second)) {
|
||||
t.Fatal("the latch must clear — a permanent degraded state would be its own false alarm")
|
||||
}
|
||||
// It is per tier.
|
||||
if r.recentlyRepaired("felhom-pbs", repairedAt.Add(time.Second)) {
|
||||
t.Fatal("one tier's repair must not latch another tier's status")
|
||||
}
|
||||
// The window MUST exceed the report interval — the property, asserted rather than assumed.
|
||||
if storeGrantRepairReportWindow <= 15*time.Minute {
|
||||
t.Fatalf("the report window (%s) must exceed the 900s hub report interval, or a transition can "+
|
||||
"be missed entirely", storeGrantRepairReportWindow)
|
||||
}
|
||||
}
|
||||
@@ -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,593 @@
|
||||
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", "pbs", "boot+running", 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", "local", "boot+running", 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-189 — the persisted proof must be REPORTABLE, and must refuse to lie ───────────────────
|
||||
//
|
||||
// A proof held only in the in-memory store dies with the process, and under per-archive due-ness the
|
||||
// agent will not repeat the work. So the persisted record has to be able to become a host-report
|
||||
// entry — without inventing anything it does not know.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-08-03): drop the `reportable()` filter from
|
||||
// ProvenRestoreTests, so a pre-R-189 record (archive but no tier) is emitted →
|
||||
//
|
||||
// --- FAIL: TestProvenRestoreTests_RefusesToReportWhatItCannotDescribe
|
||||
// restoretest_due_test.go: a record with no TIER must not be reported (the hub keys its
|
||||
// per-tier proof on it); got [{... SourceTier: ...}]
|
||||
//
|
||||
// Restored.
|
||||
func TestProvenRestoreTests_RefusesToReportWhatItCannotDescribe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rt.json")
|
||||
// v1 (a bare time), v2 (archive, no tier) and v3 (complete) side by side — every shape this
|
||||
// file has ever had, which is what a real box carries after two upgrades.
|
||||
legacy := `{
|
||||
"old-v1": "2026-07-30T02:11:07Z",
|
||||
"old-v2": {"archive":"felhom-backup:backup/vzdump-lxc-9201-a.tar.zst","proven_at":"2026-08-01T04:41:58Z"},
|
||||
"felhom-pbs": {"archive":"felhom-pbs:backup/ct/9201/2026-07-28T04:49:43Z","tier":"pbs","verified":"boot+running","proven_at":"2026-08-03T13:25:14Z"}
|
||||
}`
|
||||
if err := writeFileForTest(path, legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := NewRestoreTestState(path).ProvenRestoreTests(context.Background())
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("only the record that can be described honestly may be reported; got %d: %+v", len(got), got)
|
||||
}
|
||||
e := got[0]
|
||||
if e.SourceTier != "pbs" {
|
||||
t.Fatalf("a record with no TIER must not be reported (the hub keys its per-tier proof on it); got %+v", got)
|
||||
}
|
||||
if e.SourceArchive != "felhom-pbs:backup/ct/9201/2026-07-28T04:49:43Z" || !e.Pass {
|
||||
t.Fatalf("the reported entry must be the stored proof, unchanged; got %+v", e)
|
||||
}
|
||||
if e.TestedAt != "2026-08-03T13:25:14Z" {
|
||||
t.Fatalf("the entry must carry the time the run passed, not now(); got %q", e.TestedAt)
|
||||
}
|
||||
if e.Verified != "boot+running" {
|
||||
t.Fatalf("what the run verified must survive the round trip; got %q", e.Verified)
|
||||
}
|
||||
// Run mechanics are NOT invented: an absent duration is not a claim, a fabricated one would be.
|
||||
if e.DurationSeconds != 0 || e.ScratchVMID != 0 {
|
||||
t.Fatalf("the re-report must not invent run mechanics it never stored; got duration=%v scratch=%d",
|
||||
e.DurationSeconds, e.ScratchVMID)
|
||||
}
|
||||
// The legacy records still serve the DUE-check, which is a separate question from reporting.
|
||||
if _, ok := NewRestoreTestState(path).ProvenArchive("old-v2"); !ok {
|
||||
t.Fatal("a v2 record must still answer the due-check even though it cannot be reported")
|
||||
}
|
||||
}
|
||||
|
||||
// A tier proved through the SCHEDULER (not by hand) lands in the state complete enough to report —
|
||||
// the production path, not a hand-built fixture.
|
||||
func TestScheduler_ProofIsRecordedReportably(t *testing.T) {
|
||||
ts := &tierStorage{archives: map[string][]archiveStub{"felhom-pbs": {{volid: "felhom-pbs:backup/ct/9201/w0", landed: day0}}}}
|
||||
h := newDueHarness(t, day0.AddDate(0, 0, 1).Add(97*time.Minute), 24*time.Hour, true, []string{"felhom-pbs"}, ts)
|
||||
// The fake runner echoes the spec's tier; give the spec a tier the way main.go does.
|
||||
h.s.spec = func(_ context.Context, archive string) reconcile.RestoreTestSpec {
|
||||
return reconcile.RestoreTestSpec{RestoreStorage: "local-lvm", ScratchMin: 990000, ScratchMax: 990009, SourceTier: "pbs"}
|
||||
}
|
||||
h.s.tick(context.Background())
|
||||
|
||||
got := h.st.ProvenRestoreTests(context.Background())
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("a scheduled pass must leave a REPORTABLE proof; got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].SourceTier != "pbs" || got[0].SourceArchive != "felhom-pbs:backup/ct/9201/w0" {
|
||||
t.Fatalf("the proof must name the tier and the archive the run used; got %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A FAILED run leaves nothing to report — the asymmetry of §8.1, asserted rather than assumed.
|
||||
func TestScheduler_AFailureLeavesNoPersistedProof(t *testing.T) {
|
||||
ts := &tierStorage{archives: map[string][]archiveStub{"felhom-pbs": {{volid: "felhom-pbs:backup/ct/9201/w0", landed: day0}}}}
|
||||
h := newDueHarness(t, day0.AddDate(0, 0, 1).Add(97*time.Minute), 24*time.Hour, false, []string{"felhom-pbs"}, ts)
|
||||
h.s.tick(context.Background())
|
||||
if got := h.st.ProvenRestoreTests(context.Background()); len(got) != 0 {
|
||||
t.Fatalf("a FAILED run must persist nothing — a failing tier is retried, and a stored failure "+
|
||||
"would outlive the fault; got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
)
|
||||
|
||||
// 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, which tier it was, what was verified,
|
||||
// and when.
|
||||
//
|
||||
// R-189 added `Tier` and `Verified`. Until then this record could answer the DUE-check but could not
|
||||
// be REPORTED, and being reportable is what closes R-189: a proof held only in the in-memory result
|
||||
// store vanishes on restart, and under per-archive due-ness the box will not repeat the work, so the
|
||||
// hub can stay ignorant of a real success until the next archive generation.
|
||||
//
|
||||
// `Tier` is stored rather than derived because it is known for certain at proof time (the run's own
|
||||
// spec used it to choose the restore timeout) and deriving it later would need a storage-type lookup
|
||||
// at report-building time — a network call that can fail, on a path where failing means mis-labelling
|
||||
// a proof. Store what you knew when you knew it.
|
||||
type provenTier struct {
|
||||
Archive string // volid of the archive that PASSED; "" = a legacy record with no archive
|
||||
Tier string // "local" | "pbs" — as the run reported it; "" = pre-R-189 record
|
||||
Verified string // what the run verified (e.g. "boot+running"); "" = pre-R-189 record
|
||||
At time.Time // when that run passed (UTC)
|
||||
}
|
||||
|
||||
// reportable reports whether this record can be re-reported to the hub as a restore-test result.
|
||||
//
|
||||
// It needs BOTH the archive and the tier: the hub keys its edge-triggered failure state on the
|
||||
// archive and its per-tier proof lookup on the tier, so an entry missing either is not a usable
|
||||
// proof — and emitting one anyway would be a report the hub cannot act on, dressed as evidence.
|
||||
// A pre-R-189 record is therefore silently not reported; the tier's next real proof fills it in.
|
||||
func (p provenTier) reportable() bool { return p.Archive != "" && p.Tier != "" }
|
||||
|
||||
// provenTierJSON is the on-disk shape. Two older shapes are read and neither is written:
|
||||
//
|
||||
// v1 (pre-R-86) "<target>": "<RFC3339>" — a time, no archive
|
||||
// v2 (R-86) "<target>": {archive, proven_at} — due-check usable, not reportable
|
||||
// v3 (R-189) "<target>": {archive, tier, verified, …} — both
|
||||
//
|
||||
// Fields absent in an older file unmarshal to "", which is exactly the "no usable proof" signal the
|
||||
// readers above test for — the migration needs no version number because the absence IS the answer.
|
||||
type provenTierJSON struct {
|
||||
Archive string `json:"archive"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Verified string `json:"verified,omitempty"`
|
||||
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, Tier: cur.Tier, Verified: cur.Verified, At: t.UTC()}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RecordSuccess stamps a tier as proven at t, naming the ARCHIVE that passed, the TIER the run
|
||||
// reported, and what it verified. 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.
|
||||
//
|
||||
// ONLY SUCCESSES ARE PERSISTED, AND THE ASYMMETRY IS DELIBERATE (R-189 §8.1). Say it here because
|
||||
// the next reader will notice failures are absent and try to "fix" it:
|
||||
//
|
||||
// a SUCCESS suppresses future work — a proven archive is never re-tested, so a lost proof leaves
|
||||
// the system quietly less tested than it believes. It must survive a restart.
|
||||
//
|
||||
// a FAILURE causes future work — a failing tier stays due and is retried at the next evaluation,
|
||||
// so a lost failure heals itself within one interval. Persisting it would do the opposite of
|
||||
// helping: a healed tier would keep reporting a failure that is no longer true.
|
||||
func (s *RestoreTestState) RecordSuccess(target, archive, tier, verified string, t time.Time) error {
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.last[target] = provenTier{Archive: archive, Tier: tier, Verified: verified, 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.
|
||||
//
|
||||
// It carried the comment "for the host-report gauge" from the day it was written and **had no caller
|
||||
// at all** until R-189 — a seam built and never wired, and an invariant asserted in a comment with
|
||||
// nothing pinning it, in one method. The host report is now fed by ProvenRestoreTests below, which
|
||||
// carries the archive and the tier that a bare timestamp cannot. This stays for callers that want
|
||||
// only the times; if it acquires none, delete it rather than let it claim a purpose again.
|
||||
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
|
||||
}
|
||||
|
||||
// ProvenRestoreTests renders the persisted proofs as host-report entries — the R-189 fix.
|
||||
//
|
||||
// It satisfies hub.RestoreTestReporter's shape, so the collector can merge these with the in-memory
|
||||
// results. What it emits is a RE-REPORT of a run that really happened, not a synthesis:
|
||||
//
|
||||
// - `Pass` is true because ONLY successes are stored (RecordSuccess is the sole writer);
|
||||
// - `SourceArchive`, `SourceTier`, `Verified` and `TestedAt` are the values that run reported;
|
||||
// - the run mechanics (scratch VMID, duration, warnings) are NOT re-invented. An absent duration
|
||||
// is not a claim; a fabricated one would be.
|
||||
//
|
||||
// A record that cannot be reported honestly is omitted rather than padded — see provenTier.reportable.
|
||||
// **A tier with no usable proof produces NO entry**: an unproven tier reading as proven would be a
|
||||
// worse defect than the one this fixes.
|
||||
func (s *RestoreTestState) ProvenRestoreTests(context.Context) []hub.RestoreTest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]hub.RestoreTest, 0, len(s.last))
|
||||
for _, p := range s.last {
|
||||
if !p.reportable() {
|
||||
continue
|
||||
}
|
||||
out = append(out, hub.RestoreTest{
|
||||
SourceArchive: p.Archive,
|
||||
SourceTier: p.Tier,
|
||||
Pass: true,
|
||||
Verified: p.Verified,
|
||||
TestedAt: p.At.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
// Deterministic order: the report is compared byte-wise by the contract test, and Go's map
|
||||
// iteration is randomised.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].SourceTier < out[j].SourceTier })
|
||||
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, Tier: p.Tier, Verified: p.Verified,
|
||||
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", "local", "boot+running", 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", "pbs", "boot+running", 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", "local", "boot+running", now)
|
||||
_ = st.RecordSuccess("a-tier", "a:archive", "local", "boot+running", 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", "pbs", "boot+running", 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,297 @@
|
||||
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.
|
||||
// R-189: the TIER and what was VERIFIED go with it, so the proof can be RE-REPORTED after a
|
||||
// restart. Both come from the run's own result, never re-derived — `rt.SourceTier` is what
|
||||
// this run was actually judged as, and deriving it later would need a storage lookup that
|
||||
// can fail on the one path where failing means mislabelling a proof.
|
||||
if err := s.rtState.RecordSuccess(target, archive, rt.SourceTier, rt.Verified, 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,75 @@
|
||||
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 and mutex-guarded for the concurrent
|
||||
// collector vs scheduler access.
|
||||
//
|
||||
// **"lost on restart; the cadence re-populates" — that sentence used to be here and it is now
|
||||
// FALSE for restore-tests (R-189, 2026-08-03).** It was true while a timer re-tested every tier
|
||||
// daily. Under R-86's per-archive due-check the agent will NOT re-test an archive it has already
|
||||
// proven, so a proof lost to a restart is not repeated until the next archive generation — a week on
|
||||
// the offsite tier — and the hub reports that tier unproven throughout. Observed, not predicted: a
|
||||
// real 14.5 GB offsite restore passed, the agent was restarted 2 m 43 s later for a deploy, and two
|
||||
// consecutive host-reports carried `0 restore-tests`.
|
||||
//
|
||||
// The durable half is `RestoreTestState` (on disk, per tier, with the archive) and the collector
|
||||
// merges the two — see hub.ProvenRestoreTestReporter. This store remains the ONLY place a FAILURE is
|
||||
// recorded, and that asymmetry is deliberate: a failing tier stays due and is retried, so a lost
|
||||
// failure heals itself, while a lost success leaves the system quietly less tested than it believes.
|
||||
// Backups are unaffected — their freshness has a ground truth on the storage (R-84).
|
||||
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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user