60244727ad
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.
106 lines
4.4 KiB
Go
106 lines
4.4 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// waitMaxHold / waitHeartbeat are effectively constants — vars only so tests can shrink them.
|
|
var (
|
|
// waitMaxHold is how long the hub holds a wait before completing it with the (unchanged)
|
|
// generation. Kept well under the ingress/NAT idle ceilings the transport spike measured
|
|
// (SPIKE-immediate-sync-transport-2026-07-16.md): the nginx 60 s proxy_read_timeout is defeated
|
|
// by the heartbeat below, and the operator NAT held an idle hold > 600 s. 240 s → a box
|
|
// reconnects roughly every 4 min in steady state (negligible fleet load).
|
|
waitMaxHold = 240 * time.Second
|
|
// waitHeartbeat is the spacing of keepalive newline bytes written while holding. nginx's
|
|
// proxy_read_timeout is measured BETWEEN successive reads from the upstream, so a byte every
|
|
// 25 s keeps the default 60 s from ever firing — no ingress annotation / manifest change is
|
|
// needed (§13 proves this live; the annotation is the documented fallback if it doesn't hold).
|
|
// 25 s mirrors the WG PersistentKeepalive cadence and doubles as NAT keepalive.
|
|
waitHeartbeat = 25 * time.Second
|
|
)
|
|
|
|
// handleWait is the Direction-2 immediate-sync long-poll (option b of the transport spike). A box
|
|
// holds this authenticated GET carrying its last-seen generation (?gen=N); the hub completes it the
|
|
// instant any operator intent lands for the box's customer (intent.Hub.Bump from the web handlers),
|
|
// or after waitMaxHold, whichever comes first. The response is CONTENTLESS — a single {"gen":N}
|
|
// line preceded by heartbeat newlines. The box reacts by firing its ordinary out-of-cycle report;
|
|
// the report ACK then delivers config/escrow/claim/floor through the UNCHANGED machinery. The hub
|
|
// never connects inbound and never rides state on this channel (ground truth #1).
|
|
func (h *Handler) handleWait(w http.ResponseWriter, r *http.Request) {
|
|
customerID, isGlobal, ok := h.checkAuthCustomer(r)
|
|
if !ok {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// Per-customer only: the key RESOLVES the customer and no customer_id parameter is accepted,
|
|
// so customer A can never observe B's generation. A global operator key has no single customer
|
|
// to wait on → 400 (the controller always holds a per-customer key).
|
|
if isGlobal {
|
|
http.Error(w, "wait is per-customer (present a customer key)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if h.intentHub == nil {
|
|
http.Error(w, "wait channel unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
var lastSeen uint64
|
|
if s := r.URL.Query().Get("gen"); s != "" {
|
|
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
|
|
lastSeen = v
|
|
}
|
|
}
|
|
|
|
// Lift THIS connection's write deadline past the hold. The global http.Server.WriteTimeout is
|
|
// 60 s (main.go — deliberately untouched); http.NewResponseController overrides it per-connection
|
|
// (Go 1.20+). Without this the held response is cut at 60 s regardless of the ingress.
|
|
rc := http.NewResponseController(w)
|
|
_ = rc.SetWriteDeadline(time.Now().Add(waitMaxHold + 30*time.Second))
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("X-Accel-Buffering", "no") // ask nginx not to buffer the streamed hold
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := rc.Flush(); err != nil {
|
|
return // client already gone
|
|
}
|
|
|
|
deadline := time.Now().Add(waitMaxHold)
|
|
for {
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
// Hold elapsed with no change: complete with the current generation (Scenario B).
|
|
h.writeWaitGen(w, rc, h.intentHub.Generation(customerID))
|
|
return
|
|
}
|
|
sub := remaining
|
|
if sub > waitHeartbeat {
|
|
sub = waitHeartbeat
|
|
}
|
|
gen := h.intentHub.Wait(r.Context(), customerID, lastSeen, sub)
|
|
if gen != lastSeen {
|
|
h.writeWaitGen(w, rc, gen) // intent moved → complete immediately
|
|
return
|
|
}
|
|
// No change within this heartbeat window: emit a keepalive byte so the ingress read-timeout
|
|
// never fires. A write/flush failure means the client is gone — return quietly (no log
|
|
// noise, no goroutine leak; the intent.Wait above already deregistered).
|
|
if _, err := w.Write([]byte("\n")); err != nil {
|
|
return
|
|
}
|
|
if err := rc.Flush(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeWaitGen emits the single contentless completion line and flushes.
|
|
func (h *Handler) writeWaitGen(w http.ResponseWriter, rc *http.ResponseController, gen uint64) {
|
|
fmt.Fprintf(w, "{\"gen\":%d}\n", gen)
|
|
_ = rc.Flush()
|
|
}
|