feat(hub): Direction-2 immediate-sync wait channel (v0.58.0)

GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub
completes it the instant any operator intent bumps that customer's in-memory
generation, then the box fires its ordinary report and the ACK delivers
everything through the unchanged machinery. 240s hold with a 25s heartbeat
newline defeats the nginx 60s proxy_read_timeout with no ingress annotation;
WriteTimeout lifted per-connection via ResponseController.

- internal/intent: per-customer generation counter + waiter registry
  (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design.
  Red-proofs: counter-vs-queue + race-closer (run-fail-reverted).
- api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B).
- web bumps after every intent write (fire-after-commit): config CRUD, claim
  resend, offsite re-issue/freeze, password regen, block/unblock, floors
  (global bumps all config-managed), controller log-tail + log-bundle.
- main.go: one intent hub shared by web+api; Close() before server.Shutdown.

Pairs with controller v0.140.0 (the long-poll client). Grounding:
documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
This commit is contained in:
2026-07-16 20:44:22 +02:00
parent 10e07f5747
commit 60244727ad
11 changed files with 790 additions and 0 deletions
+13
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/pbsdrheal"
@@ -308,6 +309,15 @@ func main() {
// unconfigured or the customer has no offsite tier.
apiHandler.SetOffsiteReissuer(webServer.ReissueOffsiteForCustomer)
// Direction-2 immediate-sync (v0.58.0): one in-memory operator-intent notifier, shared by the
// web handlers (which Bump it after every intent write) and the API handler (which long-polls it
// at GET /api/v1/wait). In-memory BY DESIGN — a restart resets generations to zero; the box
// compares with != , so a restart costs exactly one harmless full-state report. Closed before
// server.Shutdown (below) so held waits complete instantly instead of eating the grace window.
intentHub := intent.New()
apiHandler.SetIntentHub(intentHub)
webServer.SetIntentHub(intentHub)
// Build HTTP mux
mux := http.NewServeMux()
@@ -515,6 +525,9 @@ func main() {
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
cancel()
// Complete every held long-poll before Shutdown so they don't consume the grace window.
intentHub.Close()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()