# REPORT — S3: agent WG tunnel — keygen + registration + wg-quick@wg-felhom + escrow join (v0.64.0) **Date:** 2026-07-04 · **Class:** implementation + live validation on felhom-pve (the expendable demo box; Peti's box untouched by construction — `wg_tunnel.enabled` defaults FALSE). **Design of record:** `felhom.eu/documentation/architecture/06-offsite-connectivity.md` row S3 (SHIPPED). ## 1. Baselines & commits Start: felhom-agent @ `4ba1b14` (v0.63.0); felhom.eu @ `4fe895e` (hub v0.33.0, code read-only). Commits pushed to `main`: 1. `0daae92` wgtunnel keygen + hub wire (WireWireguard/report stanza/RegisterWG) + byte-identical golden copy 2. `fb24896` manager state machine + loop + desired raw-consumer seam 3. `e2b6c63` FELHOM_WG sudoers + capabilities + config (DEFAULT OFF) + main wiring + escrow join 4. (docs commit) CHANGELOG v0.64.0 + CONTEXT + this REPORT ## 2. Files - NEW `internal/wgtunnel/{key,manager,loop}.go` + tests (key vectors, state-machine Groups A/B) - `internal/hub/{report,client,collect}.go` (+`wg_contract_test.go`, `wg_client_test.go`, NEW `testdata/desired-state-wireguard.golden.json` — **byte-identical hub copy**, diff-verified) - `internal/desired/syncer.go` (`AddConsumer` + panic containment) + `consumer_test.go` - `internal/escrow/identity.go` (`WGPrivateKey` + `AttachWGKey`) + `wgkey_test.go` - `internal/config/config.go` (`WGTunnelConfig`, **Enabled default FALSE**) - `cmd/felhom-agent/main.go` (daemon wiring, escrow-create auto-inject, `--selftest=wgtunnel`) - `configs/felhom-agent.sudoers` (`Cmnd_Alias FELHOM_WG`), `internal/capability/manifest.go` (6 entries) ## 3. Tests + the five §10 red-proof outcomes `go build ./... && go vet ./... && go test ./...` — all green (20 pkgs; 18 with tests). `visudo -cf` on felhom-pve: `parsed OK` (gated BEFORE install; binary restarted after). Red-proofs, each run → FAILED as required → reverted: - (a) marker gate dropped → `re-registration after revocation: 5 calls` - (b) hash comparison dropped → `steady-state tick ran execs: [install…, systemctl restart…]` - (c) clamp removed → **honest adjustment discovered mid-proof:** x/crypto's X25519 clamps the scalar internally (RFC 7748), so the DERIVED pubkey is clamp-invariant and the spec's fixed-vector red-proof cannot fail. The clamp's real observable property is the **stored key file's canonical form** (external `wg pubkey < private.key` must agree with what the agent registered) — asserted by `TestEnsureKey_StoredKeyIsClamped`, which DOES fail with the clamp removed (`stored key is not clamped: byte0=10101010`). Both pubkey vectors were generated with the real `wg pubkey` (provenance in the test). - (d) teardown-on-absent-data → `absent-data tick ran execs: [systemctl disable…]×3` - (e) escrow auto-inject removed → `bundle key = ""` Plus: the log-scan test greps the captured buffer for the actual private key (padded + unpadded) — absent; the pubkey IS logged (intended). ## 4. Deploy + live validation (felhom-pve; journal excerpts verbatim; keys never logged by construction — the session-log grep for the key material was run and clean) Deploy order per spec: sudoers first (`visudo -cf` → install → `parsed OK`), binary 0.63.0→0.64.0 (backup kept), config: `wg_tunnel.enabled=true, interval_seconds=60`. - **Poll-interval lever (the spec's step-0 diagnosis, learned live):** agent-side `poll_seconds=120` was adopted for exactly ONE cycle — the hub envelope's `poll_interval_seconds` is a hub-side CONSTANT (`defaultHostPollSeconds = 900`, `hub/internal/api/handler.go`) silently adopted on the first cycle (the "changed" log fires only on later cycles). Hub code is out of S3 scope, so validation ran on 900 s beats with background watchers. Agent config restored to `poll_seconds=900` + the diagnosis SIGQUIT restart documented below. **Observation for the hub backlog: make the constant configurable.** - **Step 1 — bring-up (one shot, 3 s):** `07:15:48 wgtunnel: generated new WG keypair pubkey=yV5hFF…TCg=` → `07:15:49 wgtunnel: registered with hub … assigned_ip=10.77.0.2/32 existed=false generation=5 sync=ok` → `07:15:49 desired: updated from hub generation=5` → `07:15:50 wgtunnel: tunnel conf applied endpoint=ep0.felhom.eu:443 … action=enable`. Perms verified: key 0600 in 0700 dir (felhom-agent), installed conf 0600 root. - **Step 2 — data path:** `wg-quick@wg-felhom` active; in-tunnel ping 10.77.0.1 0% loss ~32 ms; `curl https://10.77.0.1:8007/` = PBS login page. Hub `/admin/wg/peers` shows the peer BOUND to `demo-felhom-01`. Report stanza in the hub's stored report_json: `{"pubkey":"yV5hFF…","registered":true,"active":true,"last_handshake_age_s":28,"assigned_ip":"10.77.0.2/32"}`. All 6 `wg-*` capability entries probe `status:ok`. - **Agent-restart tolerance (bonus, via the diagnosis SIGQUIT):** on restart — key kept, NO re-registration (marker gate), tunnel stayed active, desired refetched clean. - **Step 3 — host reboot:** `wg-quick@wg-felhom` active from boot (unit persistence, before/without the agent), tunnel pings, guest 9201 running, agent's first tick ran ZERO wgtunnel execs. - **Step 4 — revocation drill:** admin DELETE 05:57:30Z → next poll `08:10:52 desired: updated generation=6` + `WARN wgtunnel: … hub revoked this peer; disabling the tunnel (marker kept; NO re-registration)` → service inactive, marker present. Over the following 2 polls: **0** `wgtunnel: registered` lines, **0** wg-related sudo execs (the load-bearing negatives — verbatim counts in §5). Re-add (via the REGISTRATION endpoint — see §5's path insight) → next poll: `08:55:52 desired: updated generation=8` + `wgtunnel: tunnel conf applied … action=enable`, tunnel pings, **0 registration calls since the re-add** — same key, same /32, marker untouched. Config lever restored (`poll_seconds` back to 900; restart clean — no keygen/register lines). - **Step 5 — escrow:** `--selftest=escrow-create -storage felhom-pbs` (as root — the PBS key file is root-readable only, the documented BUNDLE-slice reality) → `escrow: identity bundle: +wg_private_key` (field name only), `identity=true`, blob 450 B age-wrapped, self-verify OK. **Deviation from the spec's "blob uploaded":** run WITHOUT `--upload` — uploading would overwrite the operator's real drill escrow with one whose R was displayed into THIS session log (R was sed-redacted, but an unrecorded-R escrow is unrecoverable and clobbering the prior blob breaks the operator's held R). The upload path is pre-S3 code, unchanged by this slice — no coverage lost. - **Step 6 — soak:** 30-min idle → handshake age **104 s** (<180 s; keepalive holding), active. ## 5. Revocation-drill negatives (verbatim) Over the 31 minutes following the teardown (05:57:30Z DELETE → 06:10:52Z teardown → checked 06:42Z), with **2 heartbeats landed hub-side** in the window (count from `host_reports`): ``` 'wgtunnel: registered' lines since teardown: 0 'desired: updated' lines since teardown: 0 ← correct: generation never advanced (nothing changed hub-side), so heartbeats were cached no-ops — the polls happened, the fetch didn't wg-related sudo execs since teardown: 0 wg-quick@wg-felhom: inactive (stayed down) ``` **Re-add path insight (found live, load-bearing for operator docs):** re-adding via the S1 registry endpoint (`POST /admin/wg/peers` with `host_id`) binds the peer but does NOT bump the host's generation — the agent never learns the block returned. The correct operator re-add is the S2 **registration endpoint with the global key** (`POST /hosts/{id}/wg`), which allocates, bumps (gen 8 in the drill), and syncs in one step. The drill was redone through it. Backlog note for S6: either make the admin registry add bump the bound owner (symmetric with the S2 delete-bump) or mark it registry-plumbing in the UI. ## 6. NOT yet live-validated — awaiting later work - **CGNAT/mobile-hotspot smoke** — the operator-assisted appendix (physical hotspot needed); deferred non-blocking per spec. On failure it re-opens the DERP/Headscale fallback, not S3's code. - PBS backup over the tunnel (**S4** — this slice deliberately left backup config untouched). - DR consume of the escrowed WG key (**S5**). - Tunnel-health surfacing/alerting in the hub UI (**S6** — the stanza ships now). - Endpoint re-IP re-resolve watchdog beyond restart-time resolution (S6, noted in 06-doc S3 row). - Gitea publish + Day-0 manifest vouch of 0.64.0 (**operator follow-ups** — publish-agent.sh + the password-gated UI). ## 7. Observations (noticed, not acted on) - The hub's `defaultHostPollSeconds = 900` constant silently overrides any agent-side cadence on cycle 1 with no log line (first-cycle adoption is unlogged) — worth a hub-side config knob + an INFO log for the first adoption. - The pre-existing `pbs: cannot read token secret` WARN (non-root vs `/etc/pve/priv/...`) still fires each cycle — S4's tunnel-target PBS with per-customer tokens resolves it structurally. - `git add -A` hygiene: the sudoers deploy leaves `/root/felhom-agent.sudoers.bak-pre064` + `agent.json.bak-pre064` on felhom-pve (intentional rollback artifacts, listed for the operator).