docs: hub v0.59.0 + agent v0.89.0 — REPORT/CONTEXT overwrite, capability map (poke shipped, R-22 F4 closed, escrow live-reload), ROADMAP (R-22 shipped, R-13 first slice, R-3 deletion lifecycle)

This commit is contained in:
2026-07-16 23:02:30 +02:00
parent 319ba10d9f
commit 9d68d52fea
4 changed files with 66 additions and 68 deletions
+47 -62
View File
@@ -2,69 +2,54 @@
> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md).
## Direction-2 immediate-sync — hub→box wait channel (hub v0.58.0) — 2026-07-16
## Direction-2a agent-plane immediate-sync — poke sender + ep0 felhom-poke surface (hub v0.59.0) — 2026-07-16
Implements option (b) of `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md`. An
operator action on the hub now reaches the box in **seconds**. Pairs with controller **v0.140.0**
(the long-poll client). Baselines: felhom.eu `10e07f57` → hub **v0.58.0** (deployed); controller
`f665bbed`**v0.140.0**.
**Baseline:** hub v0.58.0 → **v0.59.0** (LIVE on prod, ArgoCD Synced/Healthy). Pairs with felhom-agent
v0.89.0 (the poke listener). Green gate `go build ./... && go vet ./... && go test ./...` passes.
Design contract: `documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md` option (a).
### What shipped (hub)
- **`internal/intent` — in-memory operator-intent notifier.** Per-customer generation counter +
waiter registry: `Bump` advances + wakes (coalesce to LATEST — counter, not queue); `Wait`
returns on gen-change / ctx / maxHold / Close, with a pre-register gen-check closing the
bump-before-connect race; `Close` wakes all. In-memory BY DESIGN (a restart resets generations →
the box's `!=` compare costs exactly one harmless full-state report, never a storm). No
persistence, no schema.
- **`GET /api/v1/wait` (api/wait.go).** `checkAuthCustomer`; per-customer only (global key → 400;
customer resolved from the key, no `customer_id` param → A can't observe B). 240 s hold with a
**25 s heartbeat newline** — nginx's `proxy_read_timeout` is measured between upstream reads, so
the heartbeat keeps the default 60 s from firing → **no ingress annotation / manifest change**.
Contentless `{"gen":N}` completion; per-connection write-deadline via `http.NewResponseController`
(global `http.Server` timeouts untouched).
- **Intent bumps (web), fire-after-commit.** config create/update/delete, claim resend, offsite
re-issue (UI + re-enroll seam), offsite freeze/unfreeze, retrieval-password regen, block/unblock,
per-customer floor, global floor (bumps every config-managed customer), controller log-tail
request, controller log-bundle request (the AGENT ring rides the heartbeat envelope — deliberately
not bumped).
- **Wiring + shutdown.** One `intent.New()` shared by web + API; `intentHub.Close()` before
`server.Shutdown`. nil-safe (unset hub → wait 503, bumps no-op).
### What shipped
- **`internal/poke`** — the SSH poke SENDER (third sibling of `internal/wgsync`/`internal/tenantsync`):
a pinned-host-key (algorithm-pinned `ssh.FixedHostKey`) in-process client reusing the peersync
endpoint + host key, its OWN forced-command key. `Client.Poke(ctx, boxWGIP)` refuses any target
outside `10.77.0.0/24` BEFORE dialing, then SSHes to ep0's `felhom-poke` with the box WG /32 as the
command string (→ `$SSH_ORIGINAL_COMMAND`) which sends one empty UDP datagram to `<ip>:51822`.
`Notifier.PokeHost`/`PokeAllHosts` are fire-and-forget (detached goroutine, nil-receiver-safe) — a
poke never blocks or fails the operator save; a missing peer / SSH error is logged, the report cycle
reconciles. Tests: resolved-IP send, no-peer / store-error no-send, nil no-op, `Poke` pre-dial
non-WG refusal.
- **Wiring:** `Server.SetPoke`; `applyPBSDR` fires `PokeHost(host.HostID)` after each generation-bumping
descriptor save; `handleSetArtifacts` (MinAgent-floor save) fires `PokeAllHosts()`. `cmd/hub/main.go`
env-configures the sender (`POKE_SSH_KEY_FILE`, reusing `WG_ENDPOINT_SSH_ADDR`/`_HOSTKEY`/user).
- **ep0 surface:** `scripts/felhom-poke.sh` v1.0.0 — a NON-root (`felhom-peersync`, no sudoers grant)
forced command that validates `$SSH_ORIGINAL_COMMAND` to the WG /24 and sends one empty datagram
from wg0. Contentless + confined (the WG kernel refuses non-peer /32s — spike P1 EKEYREJECTED).
Runbook `documentation/runbooks/offsite-endpoint.md` §11. Port **51822** = shared cross-repo constant.
- **`manifests/hub.yaml`:** `POKE_SSH_KEY_FILE` env + optional `Secret/agent-poke` mounted 0400; image
tag `0.58.0``0.59.0`.
### Tests + red-proofs (all run-fail-reverted)
- intent: coalesce-to-latest (return the as-of-register snapshot → `TestWait_CoalescesBurstToLatestGen`
fails) and race-closer (drop the pre-register check → `TestWait_RaceCloser_BumpBeforeWaitNotLost`
fails). Plus timeout/ctx/close/isolation/stress. api/wait: 401/400/503, completes-on-bump,
timeout-with-heartbeats, race-closer, customer isolation. Full hub suite green (`go build/vet/test
./...`); intent race-tested on the build server.
### Landmarks as found at source
- **Poke wiring is host-keyed** (`store.GetWGPeerForHost` → the box WG /32), the sibling of the
customer-keyed `internal/intent` long-poll (Direction-2b / controller plane). `SetHostDesired` bumps
the poked host's generation; the poked immediate report carries the advanced envelope generation →
the agent fetches the new descriptor.
- **Spec-vs-source note (source wins):** the MinAgent-floor / artifact-manifest save
(`handleSetArtifacts`) does NOT itself bump per-host desired generation — the agent self-update
dispatches via signed-ops on the next report — so `PokeAllHosts()` there accelerates the next report
cycle where the floor is applied, rather than delivering a desired-state delta. Kept the poke there
(the spec names this handler) and documented the semantics.
### Deploy (GitOps)
Image `felhom-hub:0.58.0` built + pushed on 180; `manifests/hub.yaml` bumped 0.57.0→0.58.0 (commit
`59eb1f8`); ArgoCD hard-refresh + patch-sync → **Synced/Healthy**, rollout complete, live image
`:0.58.0`, listening. Route smoke: `/api/v1/wait` → 401 (no/bad auth), 404 (wrong method).
### Live validation (demo arc, customer `demo-felhom`)
- **Completion-on-difference path (the code path a bump triggers):** a box-side curl with a
mismatched `?gen=999999` returned `{"gen":0}` in **0.047 s** through the real ingress — sub-100 ms
wake delivery, DNS-only nginx path.
- **Hold proof / no-annotation claim:** a box-side curl at `?gen=0` (matching the current
generation) held cleanly for **240.04 s** (HTTP 200 → `{"gen":0}`, **10 heartbeat newlines**
observed) — **surviving well past nginx's 60 s default with the 25 s heartbeat, no ingress
annotation applied**. The controller's
own Waiter logged **zero wait-channel errors over the observation window** (it holds + reconnects
silently), a second confirmation. **No-annotation claim: HELD; the ingress fallback was NOT needed.**
- **Scenario C — hub restart resilience (live):** `kubectl rollout restart deploy/hub` mid-hold →
the box's Waiter logged **exactly one** WARN (`wait channel error: unexpected EOF — backing off`),
reconnected cleanly, and fired **0 out-of-cycle reports** (the generation was 0 before and after,
so there was correctly nothing to deliver — no restart storm). The immediate-probe endpoint
returned 200 the moment the pod was ready. (Cosmetic: the "recovered" INFO is deferred until the
reconnected 240 s hold completes, since `pollOnce` blocks — the reconnect itself is immediate.)
The "exactly one fire" restart case needs a prior non-zero generation, i.e. an operator bump.
- **Round-trip save→applied via the operator UI** requires an operator login (CC lacks the operator
password by standing constraint) — the mechanism is proven by the unit tests + the live
completion-path proof, and the delivery chain (report ACK → config_version → `ConfigRefresher`
re-pull + self-restart) is the already-live v0.139.0 / v0.26.0 path (unchanged).
### Not changed / parked
Report ACK schema, the 15-min cycle, the 900 s agent poll, and the global `http.Server` timeouts are
byte-identical. The agent-plane ep0-relayed poke stays PARKED in the OOB / mutual-repair arc
(Viktor's ruling + spike P4).
### Live validation
- **Deployed:** hub 0.59.0 built + pushed on 180, manifest tag bumped + ArgoCD synced (Synced/Healthy,
rollout complete, image `:0.59.0`); after the Secret was created it logs `agent-plane poke enabled`.
- **ep0 poke surface installed live** (additive; `felhom-peersync` authorized_keys 2→3 lines,
peersync/tenantsync UNCHANGED; `/usr/local/bin/felhom-poke` 0755; `Secret/agent-poke` holds the
private key, the build-server copy shredded).
- **ep0 leg live-validated with the poke key:** a valid WG target (`10.77.0.2`) → `poke-fired`;
`8.8.8.8` AND `rm -rf /` as the SSH command → `refused non-WG target` (the forced command NEVER
executes client input, only reads it as an IP candidate). This proves the full hub→ep0→box datagram
path and the credential-theft blast radius (≤ "make a registered box tick").
- **Not yet fired end-to-end from an operator save** (the config/floor save UIs are password-gated; CC
can't log in) and the box-side tick awaits the demo agent reaching v0.89.0 via the floor train
(operator vouch) — see the felhom-agent REPORT. Scenario 3's measured save-to-tick latency is the
operator follow-up.