package probe import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "strconv" "time" ) const ( // maxBodySize caps the request body at 1 MiB to prevent DoS. maxBodySize = 1 << 20 // timeWindow is the symmetric ±window for timestamp validation. // Requests whose X-Probe-Ts falls outside now±300s are rejected. timeWindow = 300 * time.Second ) // ProbeRegistry maps probe IDs to their per-probe HMAC secrets. // Implementations must be safe for concurrent use. type ProbeRegistry interface { // LookupSecret returns the HMAC secret for probeID. // ok is false when the probe ID is not registered. LookupSecret(probeID string) (secret string, ok bool) } // MapRegistry is an in-memory ProbeRegistry backed by a plain map. // Suitable for static configuration loaded at startup. type MapRegistry struct { secrets map[string]string // probeID → HMAC secret } // NewMapRegistry creates a MapRegistry from a probeID→secret map. // The map is copied; subsequent mutation of the original has no effect. func NewMapRegistry(m map[string]string) *MapRegistry { cp := make(map[string]string, len(m)) for k, v := range m { cp[k] = v } return &MapRegistry{secrets: cp} } // LookupSecret implements ProbeRegistry. func (r *MapRegistry) LookupSecret(probeID string) (string, bool) { s, ok := r.secrets[probeID] return s, ok } // IngestHandler handles POST /probe/report. // // # Security model // // Each probe agent has its own identity (probeID + secret) registered in the // ProbeRegistry. Every request must carry three headers: // // X-Probe-Id – the probe agent's identifier // X-Probe-Ts – Unix timestamp (seconds, string) // X-Probe-Sign – HMAC-SHA256 hex digest: HMAC(secret, ts+"\n"+rawBody) // // Validation steps (all failures return 401 with no diagnostic detail): // 1. Presence of all three headers. // 2. ProbeID exists in the registry. // 3. |now − ts| ≤ 300 s (replay window). // 4. Constant-time HMAC comparison. // 5. Replay check via Redis SetNX on (probeID, ts). // // # Statelessness // // The handler only verifies the signature and writes to Redis. // It carries no in-process state and is safe to run behind a CDN or across // horizontally-scaled instances sharing the same Redis. type IngestHandler struct { registry ProbeRegistry store *Store } // NewIngestHandler creates an IngestHandler. func NewIngestHandler(reg ProbeRegistry, store *Store) *IngestHandler { return &IngestHandler{registry: reg, store: store} } // ServeHTTP implements http.Handler for POST /probe/report. func (h *IngestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Read body first – HMAC is computed over the raw bytes. body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize)) if err != nil { w.WriteHeader(http.StatusUnauthorized) return } probeID := r.Header.Get("X-Probe-Id") tsStr := r.Header.Get("X-Probe-Ts") sign := r.Header.Get("X-Probe-Sign") // Reject immediately if any required auth header is missing. // All auth failures are 401 – no detail leak. if probeID == "" || tsStr == "" || sign == "" { w.WriteHeader(http.StatusUnauthorized) return } // Probe must be registered. secret, ok := h.registry.LookupSecret(probeID) if !ok { w.WriteHeader(http.StatusUnauthorized) return } // Validate timestamp window: |now − ts| ≤ timeWindow. tsUnix, err := strconv.ParseInt(tsStr, 10, 64) if err != nil { w.WriteHeader(http.StatusUnauthorized) return } diff := time.Since(time.Unix(tsUnix, 0)) if diff < 0 { diff = -diff } if diff > timeWindow { w.WriteHeader(http.StatusUnauthorized) return } // Verify HMAC-SHA256(secret, ts + "\n" + rawBody). // Constant-time comparison prevents timing side-channels. mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(tsStr)) mac.Write([]byte("\n")) mac.Write(body) expected := mac.Sum(nil) gotBytes, err := hex.DecodeString(sign) if err != nil { w.WriteHeader(http.StatusUnauthorized) return } if !hmac.Equal(gotBytes, expected) { w.WriteHeader(http.StatusUnauthorized) return } // Replay check: (probeID, ts) pair must not have been seen before. // Uses Redis SetNX for atomicity across multiple handler instances. // On replay we return 202 (idempotent acknowledgement) rather than an // error, so that a network-retrying probe agent is not penalised. ctx := r.Context() isReplay, err := h.store.CheckAndMarkSeen(ctx, probeID, tsStr) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if isReplay { w.WriteHeader(http.StatusAccepted) return } // Parse the request body. var req ReportRequest if err := json.Unmarshal(body, &req); err != nil { w.WriteHeader(http.StatusBadRequest) return } if len(req.Reports) == 0 { w.WriteHeader(http.StatusBadRequest) return } // Persist snapshots and heartbeat to Redis. if err := h.store.SaveReports(ctx, probeID, req.Vantage, req.Reports); err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusAccepted) }