f76e1797c2
Manually apply changes from maestro/tsk_rBPr0Xuy10bz that could not be auto-merged due to uncommitted changes on main at merge time. Added: - server/internal/scheduler/probe/types.go – frozen schema types (ReportRequest, VantagePoint, NodeReport, L1/L2/L3Result, ProbeSnapshot) - server/internal/scheduler/probe/store.go – Redis Store: SaveReports, CheckAndMarkSeen, SnapshotsByNode, AliveProbes with TTL constants and key-schema docs - server/internal/scheduler/probe/ingest.go – IngestHandler (POST /probe/report), HMAC-SHA256 auth + timestamp window + replay prevention via SetNX - server/internal/scheduler/probe/ingest_test.go – 17 tests (auth failures, integration, Store unit tests); all pass with miniredis Updated: - server/cmd/server/main.go – register /probe/report route when PROBE_SECRETS env is set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
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)
|
||
}
|