mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 07:19:58 +00:00
fix(api): tolerate client clock skew and drop the dead route evidence cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user