From 3b548900a08d4bde6fded82eb89d80af07b5d31d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 30 Sep 2026 10:28:03 -0400 Subject: [PATCH] fix(api): tolerate client clock skew and drop the dead route evidence cursor --- db/route_evidence.go | 2 +- internal/api/handlers/limits.go | 3 +++ internal/api/handlers/observers.go | 2 +- internal/api/handlers/observers_test.go | 9 ++++++++ internal/api/handlers/route_evidence.go | 4 ++-- internal/api/handlers/route_evidence_test.go | 11 +++++++++- internal/api/route_evidence.go | 23 ++++++++++---------- 7 files changed, 38 insertions(+), 16 deletions(-) diff --git a/db/route_evidence.go b/db/route_evidence.go index fc7209e..e7017ea 100644 --- a/db/route_evidence.go +++ b/db/route_evidence.go @@ -51,7 +51,7 @@ func (s *Store) GetRouteEvidence(ctx context.Context, iata, key string, query ap } route := toKnownRoutes([]knownRouteRow{{ID: row.ID, NodeIds: row.NodeIds, HashPrefix: row.HashPrefix, Iata: row.Iata, HopCount: row.HopCount, FirstSeen: row.FirstSeen, LastSeen: row.LastSeen, ObservationCount: row.ObservationCount}}, nodes)[0] route.PathKey = hex.EncodeToString(row.PathKey) - out := &api.RouteEvidence{Page: api.Page[api.RouteObservation]{Items: []api.RouteObservation{}}, Route: route, WindowStart: query.Since.UnixMilli(), WindowEnd: query.Until.UnixMilli(), GeneratedAt: time.Now().UnixMilli(), MatchType: "saved_path_prefixes"} + out := &api.RouteEvidence{Items: []api.RouteObservation{}, Route: route, WindowStart: query.Since.UnixMilli(), WindowEnd: query.Until.UnixMilli(), GeneratedAt: time.Now().UnixMilli(), MatchType: "saved_path_prefixes"} width, path, valid := savedRoutePath(row.HashPrefix, row.HopCount) if !valid { return out, nil diff --git a/internal/api/handlers/limits.go b/internal/api/handlers/limits.go index dfc4823..80bda95 100644 --- a/internal/api/handlers/limits.go +++ b/internal/api/handlers/limits.go @@ -8,10 +8,13 @@ import ( "log/slog" "net/http" "strconv" + "time" ) const maxListLimit int32 = 200 +const clockSkewTolerance = 5 * time.Minute // client clocks run ahead; accept a small future 'until' + // parseLimit caps the requested result size while retaining each endpoint's default. func parseLimit(r *http.Request, defaultLimit int32) (int32, error) { value := r.URL.Query().Get("limit") diff --git a/internal/api/handlers/observers.go b/internal/api/handlers/observers.go index 66bddd8..89fddd6 100644 --- a/internal/api/handlers/observers.go +++ b/internal/api/handlers/observers.go @@ -300,7 +300,7 @@ func getObserverActivity(reader api.Reader) http.HandlerFunc { if values, ok := r.URL.Query()["until"]; ok { n, err := strconv.ParseInt(values[0], 10, 64) now := time.Now() - if len(values) != 1 || err != nil || n < 0 || n > now.UnixMilli() || n < now.Add(-720*time.Hour).UnixMilli() { + if len(values) != 1 || err != nil || n < 0 || n > now.Add(clockSkewTolerance).UnixMilli() || n < now.Add(-720*time.Hour).UnixMilli() { respondError(w, http.StatusBadRequest, "until must be one past epoch-millisecond timestamp within 30 days") return } diff --git a/internal/api/handlers/observers_test.go b/internal/api/handlers/observers_test.go index cfef1c9..7344526 100644 --- a/internal/api/handlers/observers_test.go +++ b/internal/api/handlers/observers_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -225,6 +226,14 @@ func TestGetObserverActivity_Defaults(t *testing.T) { if body.Range != "24h" || body.Interval != "15m" { t.Errorf("expected range 24h interval 15m, got %q/%q", body.Range, body.Interval) } + + skewed := time.Now().Add(time.Minute).UnixMilli() + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/observers/%s/activity?until=%d", observerID.String(), skewed), nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for until within clock skew, got %d (%s)", w.Code, w.Body.String()) + } } func TestGetObserverActivity_CustomRangeAndInterval(t *testing.T) { diff --git a/internal/api/handlers/route_evidence.go b/internal/api/handlers/route_evidence.go index 6c156fe..a194306 100644 --- a/internal/api/handlers/route_evidence.go +++ b/internal/api/handlers/route_evidence.go @@ -67,7 +67,7 @@ func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.R return q, api.ErrRouteEvidenceInput } } - if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now) { + if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now.Add(clockSkewTolerance)) { return q, api.ErrRouteEvidenceInput } return q, nil @@ -83,7 +83,7 @@ func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.R // @Param pathKey path string true "Stable 32-hex pathKey from a known-route response" // @Param range query string false "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor" // @Param since query int false "Window start epoch ms; provide with until (default last 24h)" -// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, no future end" +// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, end may be up to 5 minutes ahead of server time (clock skew tolerance)" // @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; window is pinned" // @Param limit query int false "Default 50; positive, capped at 200" minimum(1) maximum(200) // @Success 200 {object} api.RouteEvidence diff --git a/internal/api/handlers/route_evidence_test.go b/internal/api/handlers/route_evidence_test.go index 8340d85..f2268d8 100644 --- a/internal/api/handlers/route_evidence_test.go +++ b/internal/api/handlers/route_evidence_test.go @@ -5,6 +5,7 @@ package handlers import ( "context" "errors" + "fmt" "net/http/httptest" "net/url" "testing" @@ -44,6 +45,14 @@ func TestRouteEvidenceQuery(t *testing.T) { t.Errorf("accepted %s", query) } } + ahead := now.Add(time.Minute) + if _, err := routeEvidenceQuery(httptest.NewRequest("GET", fmt.Sprintf("/?since=%d&until=%d", ahead.Add(-time.Hour).UnixMilli(), ahead.UnixMilli()), nil), "YOW", evidenceKey, now); err != nil { + t.Fatalf("rejected until within clock skew: %v", err) + } + far := now.Add(10 * time.Minute) + if _, err := routeEvidenceQuery(httptest.NewRequest("GET", fmt.Sprintf("/?since=%d&until=%d", far.Add(-time.Hour).UnixMilli(), far.UnixMilli()), nil), "YOW", evidenceKey, now); err == nil { + t.Fatal("accepted until far in the future") + } } func TestRouteEvidenceHandler(t *testing.T) { @@ -64,7 +73,7 @@ func TestRouteEvidenceHandler(t *testing.T) { if iata != "YOW" || key != evidenceKey || q.Limit != 50 { t.Fatal("wrong reader scope") } - return &api.RouteEvidence{Page: api.Page[api.RouteObservation]{Items: []api.RouteObservation{}}}, tc.err + return &api.RouteEvidence{Items: []api.RouteObservation{}}, tc.err }}) w := httptest.NewRecorder() router.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil)) diff --git a/internal/api/route_evidence.go b/internal/api/route_evidence.go index cadc056..d320ade 100644 --- a/internal/api/route_evidence.go +++ b/internal/api/route_evidence.go @@ -30,18 +30,19 @@ type RouteObservation struct { SNR *float32 `json:"snr,omitempty"` } -// RouteEvidence uses nextPageCursor rather than the lossy legacy numeric cursor. +// RouteEvidence pages by nextPageCursor, which keeps full timestamp precision. type RouteEvidence struct { - Page[RouteObservation] - Route KnownRoute `json:"route"` - WindowStart int64 `json:"windowStart"` - WindowEnd int64 `json:"windowEnd"` - GeneratedAt int64 `json:"generatedAt"` - MatchType string `json:"matchType"` - MatchAvailable bool `json:"matchAvailable"` - HashSize int16 `json:"hashSize"` - PathBytes string `json:"pathBytes"` - NextPageCursor *string `json:"nextPageCursor,omitempty"` + Items []RouteObservation `json:"items"` + HasMore bool `json:"hasMore"` + Route KnownRoute `json:"route"` + WindowStart int64 `json:"windowStart"` + WindowEnd int64 `json:"windowEnd"` + GeneratedAt int64 `json:"generatedAt"` + MatchType string `json:"matchType"` + MatchAvailable bool `json:"matchAvailable"` + HashSize int16 `json:"hashSize"` + PathBytes string `json:"pathBytes"` + NextPageCursor *string `json:"nextPageCursor,omitempty"` } type RouteEvidenceQuery struct {