From 9bbf8efee553fbd67371b4e48cf0aae88b4f9834 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:17:23 -0400 Subject: [PATCH] release: v2.0.4 (#217) * perf(routes): batch cross-IATA and hash route search (#216) One search fired a /routes/cross request per IATA pair (138k on 2026-10-08). Route search now takes an optional iatas list and runs a fixed number of index-backed queries. /info advertises built-in minimum client versions. * chore: bump version to 2.0.4 --- cmd/beacon/main.go | 6 +- config.yaml.example | 11 +- db/baseline_integration_test.go | 18 +- db/migrate.go | 18 +- db/migrate_integration_test.go | 4 +- db/migrate_test.go | 21 + .../002_known_routes_node_ids_gin.sql | 5 + db/queries/queries.sql | 86 ++- db/routes.go | 517 ++++++++++++------ db/routes_cross_integration_test.go | 192 +++++++ db/routes_test.go | 46 -- db/sqlc/mock/querier.go | 118 +++- db/sqlc/querier.go | 24 +- db/sqlc/queries.sql.go | 333 ++++++++--- docs/docs.go | 84 ++- docs/swagger.json | 84 ++- docs/swagger.yaml | 69 ++- go.mod | 16 +- go.sum | 28 +- internal/api/admin.go | 7 +- internal/api/handlers/info.go | 7 +- internal/api/handlers/info_test.go | 6 +- internal/api/handlers/routes.go | 178 ++++-- internal/api/handlers/routes_test.go | 107 +++- internal/api/handlers/stub_reader_test.go | 28 +- internal/api/info.go | 39 +- internal/api/info_test.go | 35 ++ internal/api/reader.go | 17 +- internal/api/router/admin_config_test.go | 2 +- internal/api/router/options.go | 1 + internal/api/router/router.go | 6 +- internal/api/router/router_test.go | 5 +- internal/api/routes.go | 18 +- internal/cache/cache_test.go | 12 +- internal/cache/reader.go | 18 +- internal/config/config.go | 24 +- internal/config/config_test.go | 26 + 37 files changed, 1625 insertions(+), 591 deletions(-) create mode 100644 db/migrations/002_known_routes_node_ids_gin.sql create mode 100644 db/routes_cross_integration_test.go create mode 100644 internal/api/info_test.go diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 640a882..311037a 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -45,7 +45,7 @@ import ( var version = "dev" // @title MeshCore Beacon API -// @version 2.0.3 +// @version 2.0.4 // @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket. // @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget. // @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open. @@ -434,7 +434,9 @@ func main() { WSAllowedOrigins: cfg.WebSocket.AllowedOrigins, CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit, Scopes: scopes, RollupRetention: resolved.RollupRetention, - MinAppVersion: cfg.Mobile.MinAppVersion, ServerVersion: docs.SwaggerInfo.Version, + MinAppVersion: api.HigherVersion(api.MinAppVersion, cfg.Mobile.MinAppVersion), + MinWebVersion: api.HigherVersion(api.MinWebVersion, cfg.Web.MinWebVersion), + ServerVersion: docs.SwaggerInfo.Version, AdminRoutes: map[string]http.Handler{ "/accounts": handlers.AccountsRouter(store), "/backup": handlers.BackupRouter(backupOpts, ctx), diff --git a/config.yaml.example b/config.yaml.example index a09604a..1f1add3 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -320,8 +320,11 @@ cache: # reconfirm: 1h # default: 1h # cleanup: 1h # default: 1h -# Minimum BEACON Mobile version, served at GET /api/v1/info. Older apps show -# "update required" for this server. Strict X.Y.Z (no v, no suffix); anything -# else prevents startup. Default: empty, no requirement. +# Minimum client versions, served at GET /api/v1/info. Older clients show +# "update required" for this server. Each release has a built-in floor; these +# can raise it, never lower it. Strict X.Y.Z (no v, no suffix); anything else +# prevents startup. Default: empty, the built-in floor only. #mobile: -# min_app_version: "0.1.1" +# min_app_version: "0.1.1" # BEACON Mobile +#web: +# min_web_version: "2.0.3" # Beacon Web diff --git a/db/baseline_integration_test.go b/db/baseline_integration_test.go index 27a8a60..a4c7665 100644 --- a/db/baseline_integration_test.go +++ b/db/baseline_integration_test.go @@ -6,7 +6,9 @@ package db import ( "context" "errors" + "io/fs" "os" + "slices" "strings" "testing" "time" @@ -42,6 +44,20 @@ func applyBaseline(t *testing.T, ctx context.Context, tx pgx.Tx) { } } +// embeddedMigrations lists the migration files RunMigrations applies, in order. +func embeddedMigrations(t *testing.T) []string { + t.Helper() + entries, err := fs.ReadDir(migrationFiles, "migrations") + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + // schemaPool returns a pool whose connections all use a fresh, dropped-on-cleanup schema. func schemaPool(t *testing.T) (context.Context, *pgxpool.Pool) { t.Helper() @@ -92,7 +108,7 @@ func TestRunMigrationsBaselinePostgres(t *testing.T) { t.Fatal(err) } ledger, err := pgx.CollectRows(rows, pgx.RowTo[string]) - if err != nil || len(ledger) != 1 || ledger[0] != baselineMigration { + if err != nil || !slices.Equal(ledger, embeddedMigrations(t)) { t.Fatalf("ledger %v, %v", ledger, err) } // Stats refresh uses CONCURRENTLY, which needs populated views. diff --git a/db/migrate.go b/db/migrate.go index 06f2a63..0265f75 100644 --- a/db/migrate.go +++ b/db/migrate.go @@ -13,6 +13,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -130,8 +131,21 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error { return fmt.Errorf("failed to acquire migration connection: %w", err) } defer conn.Release() - if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil { - return fmt.Errorf("failed to take migration lock: %w", err) + // Poll, don't block: a waiter in pg_advisory_lock holds a snapshot that the + // holder's CREATE INDEX CONCURRENTLY waits on, which deadlocks. + for { + var locked bool + if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", migrationLockKey).Scan(&locked); err != nil { + return fmt.Errorf("failed to take migration lock: %w", err) + } + if locked { + break + } + select { + case <-ctx.Done(): + return fmt.Errorf("failed to take migration lock: %w", ctx.Err()) + case <-time.After(250 * time.Millisecond): + } } defer func() { // A session lock outlives Release; drop the connection if unlock fails. diff --git a/db/migrate_integration_test.go b/db/migrate_integration_test.go index cc01ebf..8d4a728 100644 --- a/db/migrate_integration_test.go +++ b/db/migrate_integration_test.go @@ -26,8 +26,8 @@ func TestRunMigrationsConcurrentStartPostgres(t *testing.T) { } } var n int - if err := pool.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil || n != 1 { - t.Errorf("ledger rows = %d, %v; want 1", n, err) + if want := len(embeddedMigrations(t)); pool.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n) != nil || n != want { + t.Errorf("ledger rows = %d; want %d", n, want) } } diff --git a/db/migrate_test.go b/db/migrate_test.go index 0c6d3e6..b20ecdd 100644 --- a/db/migrate_test.go +++ b/db/migrate_test.go @@ -6,6 +6,7 @@ package db import ( "errors" "fmt" + "regexp" "testing" "github.com/jackc/pgx/v5/pgconn" @@ -75,3 +76,23 @@ func TestIsDuplicateRelation(t *testing.T) { }) } } + +var ifNotExistsRe = regexp.MustCompile(`(?i)\bIF\s+NOT\s+EXISTS\b`) + +// IF NOT EXISTS turns a retry over an interrupted build's invalid index into a silent +// success, skipping applyMigration's drop-and-rebuild. +func TestConcurrentMigrationsAvoidIfNotExists(t *testing.T) { + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + sql, err := migrationFiles.ReadFile("migrations/" + e.Name()) + if err != nil { + t.Fatal(err) + } + if _, ok := concurrentIndexName(string(sql)); ok && ifNotExistsRe.MatchString(lineCommentRe.ReplaceAllString(string(sql), "")) { + t.Errorf("%s: CREATE INDEX CONCURRENTLY must not use IF NOT EXISTS", e.Name()) + } + } +} diff --git a/db/migrations/002_known_routes_node_ids_gin.sql b/db/migrations/002_known_routes_node_ids_gin.sql new file mode 100644 index 0000000..707f9ba --- /dev/null +++ b/db/migrations/002_known_routes_node_ids_gin.sql @@ -0,0 +1,5 @@ +-- Copyright 2026 Beacon Contributors +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- Cross-IATA route search looks routes up by member node. +CREATE INDEX CONCURRENTLY idx_known_routes_node_ids ON known_routes USING gin (node_ids); diff --git a/db/queries/queries.sql b/db/queries/queries.sql index 2a2c470..703ef01 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -1486,23 +1486,28 @@ LIMIT $4 ORDER BY date_trunc('milliseconds', r.last_seen, 'UTC') DESC, r.id DESC LIMIT $4; --- name: SearchKnownRoutes :many --- Returns known routes containing a subsequence from source to destination hash prefix. --- Verifies source appears before destination in the route. +-- name: GetKnownRoutesByNodes :many +-- Routes containing @nodes[i] in IATA @iatas[i] (aligned, one node per IATA). The && +-- drives the node_ids GIN index; the indexed check keeps LIMIT counting real matches. SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count -FROM known_routes -WHERE iata = $1 - AND array_position(hash_prefix, $2::bytea) IS NOT NULL - AND array_position(hash_prefix, $3::bytea) IS NOT NULL - AND array_position(hash_prefix, $2::bytea) < array_position(hash_prefix, $3::bytea) -ORDER BY hop_count ASC, last_seen DESC; +FROM known_routes kr +WHERE kr.node_ids && @nodes::uuid[] + AND kr.iata = ANY(@iatas::bpchar[]) + AND (@nodes::uuid[])[array_position(@iatas::bpchar[], kr.iata)] = ANY(kr.node_ids) +ORDER BY hop_count ASC, last_seen DESC +LIMIT @row_limit; --- name: GetKnownRoutesByNode :many +-- name: SearchKnownRoutesByNodes :many +-- Routes holding a source and a destination candidate; callers check order and IATA. +-- A route's hops resolve in its own IATA, so candidates from other IATAs can't match. +-- Both && predicates use the node_ids GIN index. SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count -FROM known_routes -WHERE iata = $1 - AND $2::uuid = ANY(node_ids) -ORDER BY hop_count ASC, last_seen DESC; +FROM known_routes kr +WHERE kr.node_ids && @from_nodes::uuid[] + AND kr.node_ids && @to_nodes::uuid[] + AND kr.iata = ANY(@iatas::bpchar[]) +ORDER BY hop_count ASC, last_seen DESC +LIMIT @row_limit; -- ============================================================ -- NEIGHBORS @@ -1541,16 +1546,13 @@ JOIN nodes n ON n.id = nn.neighbor_id WHERE nn.node_id = $1 ORDER BY nn.last_seen DESC; --- name: GetCrossIATANeighbors :many --- Returns neighbors of a node that are in a different IATA. -SELECT - n.id, n.name, n.node_type, n.latitude, n.longitude, - nn.iata AS neighbor_iata, nn.observation_count, nn.last_seen, nn.snr -FROM node_neighbors nn -JOIN nodes n ON n.id = nn.neighbor_id -WHERE nn.node_id = $1 - AND nn.iata != $2 -ORDER BY nn.last_seen DESC; +-- name: GetNeighborLinks :many +-- Neighbor rows between @a_nodes and @b_nodes in either orientation: ingest writes +-- adverts as (sender, forwarder) but traces as (receiver, sender). Callers normalise. +SELECT node_id, neighbor_id, last_seen +FROM node_neighbors +WHERE (node_id = ANY(@a_nodes::uuid[]) AND neighbor_id = ANY(@b_nodes::uuid[])) + OR (node_id = ANY(@b_nodes::uuid[]) AND neighbor_id = ANY(@a_nodes::uuid[])); -- ============================================================ -- HELPERS @@ -1620,6 +1622,42 @@ WHERE ns.iata = $1 AND n.node_type IN (2, 3) AND ns.prefix_4 = ANY($2::bytea[]); +-- name: ResolveRelayHashPairsP1 :many +-- Batch form of ResolvePathHashesP1 across IATAs; callers check uniqueness per IATA. +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY(@iatas::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_1 = ANY(@hashes::bytea[]); + +-- name: ResolveRelayHashPairsP2 :many +-- Batch form of ResolvePathHashesP2 across IATAs; callers check uniqueness per IATA. +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY(@iatas::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_2 = ANY(@hashes::bytea[]); + +-- name: ResolveRelayHashPairsP3 :many +-- Batch form of ResolvePathHashesP3 across IATAs; callers check uniqueness per IATA. +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY(@iatas::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_3 = ANY(@hashes::bytea[]); + +-- name: ResolveRelayHashPairsP4 :many +-- Batch form of ResolvePathHashesP4 across IATAs; callers check uniqueness per IATA. +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY(@iatas::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_4 = ANY(@hashes::bytea[]); + -- name: RefreshRadioPresets :exec REFRESH MATERIALIZED VIEW CONCURRENTLY mv_radio_presets; diff --git a/db/routes.go b/db/routes.go index 73b17c5..d2fb299 100644 --- a/db/routes.go +++ b/db/routes.go @@ -4,9 +4,11 @@ package db import ( + "cmp" "context" "crypto/md5" "encoding/hex" + "slices" "strings" "time" @@ -64,57 +66,77 @@ func (s *Store) ListKnownRoutes(ctx context.Context, iata string, hopCount int32 return toKnownRoutes(rows, nodes), nil } -func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) { - fromBytes, err := hex.DecodeString(fromHash) +// Work bounds for one route search; the query count is fixed, these cap row volume. +const ( + routeSearchLimit = 500 // routes either search returns + crossMaxEndpointIATAs = 50 // IATAs a cross endpoint may resolve uniquely in + searchMaxCandidates = 2000 // relays a search endpoint may resolve to + crossRouteRowLimit = 500 // routes loaded per cross endpoint + crossMaxHops = 2000 // hops per side fed to the neighbor query + crossMaxStitched = 5000 // cross routes collected before sort and truncate +) + +func (s *Store) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) { + fromBytes, toBytes, err := decodeHashPair(fromHash, toHash) if err != nil { return nil, err } - toBytes, err := hex.DecodeString(toHash) + if iatas, err = s.scopeIATAs(ctx, iatas); err != nil { + return nil, err + } + from, err := s.resolveRelays(ctx, iatas, fromBytes) if err != nil { return nil, err } - sqlRows, err := s.q.SearchKnownRoutes(ctx, sqlc.SearchKnownRoutesParams{ - Iata: iata, - Column2: fromBytes, - Column3: toBytes, + to, err := s.resolveRelays(ctx, iatas, toBytes) + if err != nil { + return nil, err + } + if from.count() > searchMaxCandidates || to.count() > searchMaxCandidates { + return nil, api.ErrRouteSearchTooBroad + } + var shared []string + for iata := range from { + if _, ok := to[iata]; ok { + shared = append(shared, iata) + } + } + items := []api.KnownRoute{} + if len(shared) == 0 { + return items, nil + } + sqlRows, err := s.q.SearchKnownRoutesByNodes(ctx, sqlc.SearchKnownRoutesByNodesParams{ + FromNodes: from.nodes(), ToNodes: to.nodes(), Iatas: shared, RowLimit: routeSearchLimit, }) if err != nil { return nil, err } rows := make([]knownRouteRow, len(sqlRows)) for i, r := range sqlRows { - rows[i] = knownRouteRow{ID: r.ID, NodeIds: r.NodeIds, HashPrefix: r.HashPrefix, Iata: r.Iata, HopCount: r.HopCount, FirstSeen: r.FirstSeen, LastSeen: r.LastSeen, ObservationCount: r.ObservationCount} + rows[i] = knownRouteRow(r) } - ids := collectNodeIDs(rows) - nodes, err := s.GetNodesByIDs(ctx, ids) + nodes, err := s.GetNodesByIDs(ctx, collectNodeIDs(rows)) if err != nil { return nil, err } - items := make([]api.KnownRoute, 0, len(rows)) for _, r := range rows { fromPos, toPos := -1, -1 - for i, h := range r.HashPrefix { - if fromPos == -1 && hex.EncodeToString(h) == fromHash { + for i, id := range r.NodeIds { + if fromPos == -1 && slices.Contains(from[r.Iata], id) { fromPos = i - } - if fromPos != -1 && hex.EncodeToString(h) == toHash { + } else if fromPos != -1 && slices.Contains(to[r.Iata], id) { toPos = i break } } - if fromPos == -1 || toPos == -1 { + if toPos == -1 { continue } - nodeIDs := r.NodeIds[fromPos : toPos+1] - hashPrefix := r.HashPrefix[fromPos : toPos+1] - hops := make([]api.RouteHop, 0, len(nodeIDs)) - for i, nodeID := range nodeIDs { - hop := api.RouteHop{ - NodeID: nodeID, - Node: nodes[nodeID], - } - if i < len(hashPrefix) { - hop.HashBytes = hex.EncodeToString(hashPrefix[i]) + hops := make([]api.RouteHop, 0, toPos-fromPos+1) + for i := fromPos; i <= toPos; i++ { + hop := api.RouteHop{NodeID: r.NodeIds[i], Node: nodes[r.NodeIds[i]]} + if i < len(r.HashPrefix) { + hop.HashBytes = hex.EncodeToString(r.HashPrefix[i]) } hops = append(hops, hop) } @@ -132,160 +154,317 @@ func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash st return items, nil } -func (s *Store) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) { - sqlRows, err := s.q.GetKnownRoutesByNode(ctx, sqlc.GetKnownRoutesByNodeParams{ - Iata: iata, - Column2: nodeID, +// crossRoute is a route containing a searched endpoint at index end. +type crossRoute struct { + api.KnownRoute + end int +} + +func (s *Store) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) { + fromBytes, toBytes, err := decodeHashPair(q.FromHash, q.ToHash) + if err != nil { + return nil, err + } + + // 1. resolve each hash per IATA, keeping only IATAs where it names exactly one relay + fromIATAs, err := s.scopeIATAs(ctx, q.FromIATAs) + if err != nil { + return nil, err + } + toIATAs, err := s.scopeIATAs(ctx, q.ToIATAs) + if err != nil { + return nil, err + } + fromCands, err := s.resolveRelays(ctx, fromIATAs, fromBytes) + if err != nil { + return nil, err + } + toCands, err := s.resolveRelays(ctx, toIATAs, toBytes) + if err != nil { + return nil, err + } + fromNodes, toNodes := fromCands.unique(), toCands.unique() + if len(fromNodes) > crossMaxEndpointIATAs || len(toNodes) > crossMaxEndpointIATAs { + return nil, api.ErrRouteSearchTooBroad + } + if len(fromNodes) == 0 || len(toNodes) == 0 { + return nil, nil + } + + // 2. routes through each endpoint, in the IATA it resolved in + sourceRows, err := s.routesThrough(ctx, fromNodes) + if err != nil { + return nil, err + } + targetRows, err := s.routesThrough(ctx, toNodes) + if err != nil { + return nil, err + } + if len(sourceRows) == 0 || len(targetRows) == 0 { + return nil, nil + } + nodes, err := s.GetNodesByIDs(ctx, collectNodeIDs(slices.Concat(sourceRows, targetRows))) + if err != nil { + return nil, err + } + sources := toCrossRoutes(sourceRows, nodes, fromNodes) + targets := toCrossRoutes(targetRows, nodes, toNodes) + + // 3. one neighbor query linking source hops from the source endpoint on + // to target hops up to the destination endpoint + type targetRef struct{ route, hop int } + targetsByNode := make(map[uuid.UUID][]targetRef) + var tgtHops []uuid.UUID + for ti, tr := range targets { + for j := 0; j <= tr.end; j++ { + id := tr.Hops[j].NodeID + if _, ok := targetsByNode[id]; !ok { + if len(tgtHops) >= crossMaxHops { + continue + } + tgtHops = append(tgtHops, id) + } + targetsByNode[id] = append(targetsByNode[id], targetRef{ti, j}) + } + } + srcSet := make(map[uuid.UUID]struct{}) + var srcHops []uuid.UUID + for _, sr := range sources { + for _, hop := range sr.Hops[sr.end:] { + if _, ok := srcSet[hop.NodeID]; !ok && len(srcHops) < crossMaxHops { + srcSet[hop.NodeID] = struct{}{} + srcHops = append(srcHops, hop.NodeID) + } + } + } + links, err := s.q.GetNeighborLinks(ctx, sqlc.GetNeighborLinksParams{ANodes: srcHops, BNodes: tgtHops}) + if err != nil { + return nil, err + } + // adjacency source hop → target hop → newest last_seen, whichever way the row was written + adj := make(map[uuid.UUID]map[uuid.UUID]int64) + link := func(src, tgt uuid.UUID, seen int64) { + _, isSrc := srcSet[src] + _, isTgt := targetsByNode[tgt] + if !isSrc || !isTgt { + return + } + if adj[src] == nil { + adj[src] = make(map[uuid.UUID]int64) + } + adj[src][tgt] = max(adj[src][tgt], seen) + } + for _, l := range links { + seen := l.LastSeen.Time.UnixMilli() + link(l.NodeID, l.NeighborID, seen) + link(l.NeighborID, l.NodeID, seen) + } + + // 4. stitch source segment → cross hop → target segment + type stitched struct { + route api.CrossIATARoute + key string + } + var found []stitched + dedupe := make(map[string]struct{}) +stitch: + for _, sr := range sources { + for i := sr.end; i < len(sr.Hops); i++ { + hop := sr.Hops[i] + for tgt, seen := range adj[hop.NodeID] { + for _, ref := range targetsByNode[tgt] { + tr := targets[ref.route] + if tr.IATA == sr.IATA { + continue + } + source := sr.Hops[sr.end : i+1] + target := tr.Hops[ref.hop : tr.end+1] + key := crossRouteKey(sr.IATA, tr.IATA, source, target) + if _, dup := dedupe[key]; dup { + continue + } + dedupe[key] = struct{}{} + found = append(found, stitched{key: key, route: api.CrossIATARoute{ + SourceSegment: source, + CrossHop: api.CrossIATAHop{ + FromNode: resolvedOrID(hop.Node, hop.NodeID), + ToNode: resolvedOrID(nodes[tgt], tgt), + FromIATA: sr.IATA, + ToIATA: tr.IATA, + LastSeen: seen, + }, + TargetSegment: target, + TotalHops: len(source) + len(target), + }}) + if len(found) >= crossMaxStitched { + break stitch + } + } + } + } + } + slices.SortFunc(found, func(a, b stitched) int { + return cmp.Or( + cmp.Compare(a.route.TotalHops, b.route.TotalHops), + cmp.Compare(b.route.CrossHop.LastSeen, a.route.CrossHop.LastSeen), + strings.Compare(a.key, b.key), + ) + }) + results := make([]api.CrossIATARoute, 0, min(len(found), routeSearchLimit)) + for _, f := range found[:min(len(found), routeSearchLimit)] { + results = append(results, f.route) + } + return results, nil +} + +// relayCandidates maps each IATA to the relays a hash prefix names there. +type relayCandidates map[string][]uuid.UUID + +func (c relayCandidates) count() int { + n := 0 + for _, ids := range c { + n += len(ids) + } + return n +} + +// unique keeps the IATAs where the hash names exactly one relay. +func (c relayCandidates) unique() map[string]uuid.UUID { + out := make(map[string]uuid.UUID) + for iata, ids := range c { + if len(ids) == 1 { + out[iata] = ids[0] + } + } + return out +} + +func (c relayCandidates) nodes() []uuid.UUID { + var nodes []uuid.UUID + for _, ids := range c { + nodes = append(nodes, ids...) + } + return nodes +} + +func decodeHashPair(from, to string) ([]byte, []byte, error) { + f, err := hex.DecodeString(from) + if err != nil { + return nil, nil, err + } + t, err := hex.DecodeString(to) + return f, t, err +} + +// scopeIATAs returns iatas, or every known IATA when none are given. +func (s *Store) scopeIATAs(ctx context.Context, iatas []string) ([]string, error) { + if len(iatas) > 0 { + return iatas, nil + } + return s.ListKnownIATAs(ctx) +} + +// resolveRelays maps each IATA to the relays whose hash starts with hash there. +func (s *Store) resolveRelays(ctx context.Context, iatas []string, hash []byte) (relayCandidates, error) { + type pair struct { + iata string + node uuid.UUID + } + var rows []pair + hashes := [][]byte{hash} + switch len(hash) { + case 1: + rs, err := s.q.ResolveRelayHashPairsP1(ctx, sqlc.ResolveRelayHashPairsP1Params{Iatas: iatas, Hashes: hashes}) + if err != nil { + return nil, err + } + for _, r := range rs { + rows = append(rows, pair{r.Iata, r.NodeID}) + } + case 2: + rs, err := s.q.ResolveRelayHashPairsP2(ctx, sqlc.ResolveRelayHashPairsP2Params{Iatas: iatas, Hashes: hashes}) + if err != nil { + return nil, err + } + for _, r := range rs { + rows = append(rows, pair{r.Iata, r.NodeID}) + } + case 3: + rs, err := s.q.ResolveRelayHashPairsP3(ctx, sqlc.ResolveRelayHashPairsP3Params{Iatas: iatas, Hashes: hashes}) + if err != nil { + return nil, err + } + for _, r := range rs { + rows = append(rows, pair{r.Iata, r.NodeID}) + } + case 4: + rs, err := s.q.ResolveRelayHashPairsP4(ctx, sqlc.ResolveRelayHashPairsP4Params{Iatas: iatas, Hashes: hashes}) + if err != nil { + return nil, err + } + for _, r := range rs { + rows = append(rows, pair{r.Iata, r.NodeID}) + } + default: + return nil, nil + } + c := make(relayCandidates) + for _, r := range rows { + if !slices.Contains(c[r.iata], r.node) { + c[r.iata] = append(c[r.iata], r.node) + } + } + return c, nil +} + +// routesThrough returns routes containing endpoints[iata] in that same IATA. +func (s *Store) routesThrough(ctx context.Context, endpoints map[string]uuid.UUID) ([]knownRouteRow, error) { + iatas := make([]string, 0, len(endpoints)) + nodes := make([]uuid.UUID, 0, len(endpoints)) + for iata, id := range endpoints { + iatas = append(iatas, iata) + nodes = append(nodes, id) + } + sqlRows, err := s.q.GetKnownRoutesByNodes(ctx, sqlc.GetKnownRoutesByNodesParams{ + Nodes: nodes, Iatas: iatas, RowLimit: crossRouteRowLimit, }) if err != nil { return nil, err } rows := make([]knownRouteRow, len(sqlRows)) for i, r := range sqlRows { - rows[i] = knownRouteRow{ID: r.ID, NodeIds: r.NodeIds, HashPrefix: r.HashPrefix, Iata: r.Iata, HopCount: r.HopCount, FirstSeen: r.FirstSeen, LastSeen: r.LastSeen, ObservationCount: r.ObservationCount} + rows[i] = knownRouteRow(r) } - ids := collectNodeIDs(rows) - nodes, err := s.GetNodesByIDs(ctx, ids) - if err != nil { - return nil, err - } - return toKnownRoutes(rows, nodes), nil + return rows, nil } -func (s *Store) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) { - rows, err := s.q.GetCrossIATANeighbors(ctx, sqlc.GetCrossIATANeighborsParams{ - NodeID: nodeID, - Iata: iata, - }) - if err != nil { - return nil, err +func toCrossRoutes(rows []knownRouteRow, nodes map[uuid.UUID]*api.ResolvedNode, endpoints map[string]uuid.UUID) []crossRoute { + routes := toKnownRoutes(rows, nodes) + out := make([]crossRoute, len(routes)) + for i, r := range routes { + out[i] = crossRoute{KnownRoute: r, end: slices.Index(rows[i].NodeIds, endpoints[r.IATA])} } - items := make([]api.NodeNeighbor, 0, len(rows)) - for _, r := range rows { - items = append(items, api.NodeNeighbor{ - ID: r.ID, - Name: r.Name, - NodeType: r.NodeType, - NodeTypeName: api.NodeTypeName(r.NodeType), - Latitude: r.Latitude, - Longitude: r.Longitude, - IATA: r.NeighborIata, - ObservationCount: r.ObservationCount, - LastSeen: r.LastSeen.Time.UnixMilli(), - SNR: r.Snr, - }) - } - return items, nil + return out } -func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) { - // 1. resolve fromHash in fromIATA - fromBytes, err := hex.DecodeString(fromHash) - if err != nil { - return nil, err +func crossRouteKey(fromIATA, toIATA string, source, target []api.RouteHop) string { + var b strings.Builder + b.WriteString(fromIATA + toIATA) + for _, h := range source { + b.WriteString(h.NodeID.String()) } - fromResolved, err := s.ResolvePathHashes(ctx, fromIATA, [][]byte{fromBytes}) - if err != nil { - return nil, err + b.WriteByte('|') + for _, h := range target { + b.WriteString(h.NodeID.String()) } - fromEntries := fromResolved[fromHash] - if len(fromEntries) != 1 { - return nil, nil // not found or ambiguous - } - fromNodeID := fromEntries[0].NodeID + return b.String() +} - // 2. resolve toHash in toIATA - toBytes, err := hex.DecodeString(toHash) - if err != nil { - return nil, err +func resolvedOrID(n *api.ResolvedNode, id uuid.UUID) api.ResolvedNode { + if n == nil { + return api.ResolvedNode{ID: id} } - toResolved, err := s.ResolvePathHashes(ctx, toIATA, [][]byte{toBytes}) - if err != nil { - return nil, err - } - toEntries := toResolved[toHash] - if len(toEntries) != 1 { - return nil, nil // not found or ambiguous - } - toNodeID := toEntries[0].NodeID - - // 3. find routes in source IATA containing fromNode - sourceRoutes, err := s.GetKnownRoutesByNode(ctx, fromIATA, fromNodeID) - if err != nil { - return nil, err - } - - // 4. find routes in target IATA containing toNode - targetRoutes, err := s.GetKnownRoutesByNode(ctx, toIATA, toNodeID) - if err != nil { - return nil, err - } - - if len(sourceRoutes) == 0 || len(targetRoutes) == 0 { - return nil, nil - } - - // 5. find cross-IATA links — nodes at the boundary of source routes - // that have neighbors in the target IATA at the start of target routes - var results []api.CrossIATARoute - - // build a set of node IDs that appear in target routes - targetNodeSet := make(map[uuid.UUID][]api.RouteHop) - for _, tr := range targetRoutes { - for _, hop := range tr.Hops { - if _, ok := targetNodeSet[hop.NodeID]; !ok { - targetNodeSet[hop.NodeID] = tr.Hops - } - } - } - - // for each source route, check if any node has a cross-IATA neighbor in targetNodeSet - for _, sr := range sourceRoutes { - for i, hop := range sr.Hops { - crossNeighbors, err := s.GetCrossIATANeighbors(ctx, hop.NodeID, fromIATA) - if err != nil { - continue - } - for _, neighbor := range crossNeighbors { - if neighbor.IATA != toIATA { - continue - } - if targetHops, ok := targetNodeSet[neighbor.ID]; ok { - // found a cross-IATA link — build the route - sourceSegment := sr.Hops[:i+1] - targetSegment := extractFromNode(targetHops, neighbor.ID) - - fromNode := api.ResolvedNode{ - ID: hop.NodeID, - Latitude: fromEntries[0].Latitude, - Longitude: fromEntries[0].Longitude, - PublicKey: hex.EncodeToString(fromEntries[0].PublicKey), - } - toNode := api.ResolvedNode{ - ID: neighbor.ID, - Name: neighbor.Name, - Latitude: neighbor.Latitude, - Longitude: neighbor.Longitude, - } - - results = append(results, api.CrossIATARoute{ - SourceSegment: sourceSegment, - CrossHop: api.CrossIATAHop{ - FromNode: fromNode, - ToNode: toNode, - FromIATA: fromIATA, - ToIATA: toIATA, - LastSeen: neighbor.LastSeen, - }, - TargetSegment: targetSegment, - TotalHops: len(sourceSegment) + 1 + len(targetSegment), - }) - } - } - } - } - - return results, nil + return *n } // AmbiguousPrefixes is the per-IATA set of hop prefixes that resolve to more than one node. @@ -336,16 +515,6 @@ func (s *Store) DeleteOldRoutes(ctx context.Context, retentionCutoff time.Time, }) } -// extractFromNode returns the portion of a route starting at the given node. -func extractFromNode(hops []api.RouteHop, nodeID uuid.UUID) []api.RouteHop { - for i, hop := range hops { - if hop.NodeID == nodeID { - return hops[i:] - } - } - return hops -} - // knownRouteRow normalizes the per-query sqlc row structs (identical // columns, distinct generated types) so the helpers below share one body. type knownRouteRow struct { diff --git a/db/routes_cross_integration_test.go b/db/routes_cross_integration_test.go new file mode 100644 index 0000000..c93748c --- /dev/null +++ b/db/routes_cross_integration_test.go @@ -0,0 +1,192 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package db + +import ( + "context" + "errors" + "os" + "testing" + "time" + + sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func TestSearchCrossIATARoutesPostgres(t *testing.T) { + dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set BEACON_TEST_POSTGRES_DSN for the PostgreSQL regression test") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + applyBaseline(t, ctx, tx) + + // YVR route P→S→A→B, B heard by X in YYJ, YYJ route X→C→T→Z. S is aa, T is bb. + // P also links to X (before the source, must be ignored); YYJ route T→X has X after T. + ids := map[string]uuid.UUID{} + for _, n := range []string{"P", "S", "A", "B", "Q", "X", "C", "T", "Z", "D1", "D2"} { + ids[n] = uuid.New() + } + order := []string{"P", "S", "A", "B", "Q", "X", "C", "T", "Z", "D1", "D2"} + nodeIDs, lats := make([]uuid.UUID, len(order)), make([]float64, len(order)) + for i, n := range order { + nodeIDs[i], lats[i] = ids[n], float64(i+1) + } + id := func(n string) uuid.UUID { return ids[n] } + for _, st := range []struct { + sql string + args []any + }{ + {`INSERT INTO nodes (id, public_key, node_type, name, latitude, longitude) +SELECT id, decode(md5(id::text), 'hex'), 2, name, lat, 0 +FROM unnest($1::uuid[], $2::text[], $3::float8[]) AS t(id, name, lat)`, []any{nodeIDs, order, lats}}, + {`INSERT INTO node_short_ids (node_id, iata, prefix_4) +SELECT id, iata, prefix FROM unnest($1::uuid[], $2::bpchar[], $3::bytea[]) AS t(id, iata, prefix)`, []any{nodeIDs, + []string{"YVR", "YVR", "YVR", "YVR", "YVR", "YYJ", "YYJ", "YYJ", "YYJ", "YYZ", "YYZ"}, + [][]byte{{1, 0, 0, 1}, {0xaa, 0, 0, 1}, {2, 0, 0, 1}, {3, 0, 0, 1}, {4, 0, 0, 1}, {5, 0, 0, 1}, {6, 0, 0, 1}, {0xbb, 0, 0, 1}, {7, 0, 0, 1}, {0xaa, 0, 0, 2}, {0xaa, 0, 0, 3}}}}, + {`INSERT INTO node_neighbors (node_id, neighbor_id, iata, last_seen) VALUES + ($1, $2, 'YYJ', '2026-01-01 00:00:00+00'), ($3, $2, 'YYJ', '2026-01-01 00:00:00+00')`, []any{id("B"), id("X"), id("P")}}, + {`INSERT INTO known_routes (path_key, node_ids, hash_prefix, iata, hop_count) VALUES + ('\x01', ARRAY[$1, $2, $3, $4]::uuid[], ARRAY['\x01','\xaa','\x02','\x03']::bytea[], 'YVR', 4), + ('\x02', ARRAY[$2, $3, $4, $5]::uuid[], ARRAY['\xaa','\x02','\x03','\x04']::bytea[], 'YVR', 4), + ('\x03', ARRAY[$6, $7, $8, $9]::uuid[], ARRAY['\x05','\x06','\xbb','\x07']::bytea[], 'YYJ', 4), + ('\x04', ARRAY[$8, $6]::uuid[], ARRAY['\xbb','\x05']::bytea[], 'YYJ', 2)`, + []any{id("P"), id("S"), id("A"), id("B"), id("Q"), id("X"), id("C"), id("T"), id("Z")}}, + } { + if _, err := tx.Exec(ctx, st.sql, st.args...); err != nil { + t.Fatal(err) + } + } + store := &Store{q: sqlc.New(tx)} + + names := func(hops []api.RouteHop) []string { + out := make([]string, len(hops)) + for i, h := range hops { + for n, id := range ids { + if id == h.NodeID { + out[i] = n + } + } + } + return out + } + want := func(t *testing.T, got []api.CrossIATARoute) { + t.Helper() + if len(got) != 1 { + t.Fatalf("got %d routes, want 1: %+v", len(got), got) + } + r := got[0] + if s, d := names(r.SourceSegment), names(r.TargetSegment); len(s) != 3 || s[0] != "S" || s[2] != "B" || len(d) != 3 || d[0] != "X" || d[2] != "T" { + t.Errorf("segments = %v → %v, want [S A B] → [X C T]", s, d) + } + h := r.CrossHop + if h.FromNode.ID != ids["B"] || h.FromNode.Latitude == nil || *h.FromNode.Latitude != 4 || h.ToNode.ID != ids["X"] || + h.FromIATA != "YVR" || h.ToIATA != "YYJ" || r.TotalHops != 6 { + t.Errorf("cross hop = %+v total %d", h, r.TotalHops) + } + } + + for _, tc := range []struct { + name string + q api.CrossRouteSearch + }{ + {"legacy pair", api.CrossRouteSearch{FromHash: "aa", ToHash: "bb", FromIATAs: []string{"YVR"}, ToIATAs: []string{"YYJ"}}}, + {"iatas", api.CrossRouteSearch{FromHash: "aa", ToHash: "bb", FromIATAs: []string{"YVR", "YYJ", "YYZ"}, ToIATAs: []string{"YVR", "YYJ", "YYZ"}}}, + {"all iatas", api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"}}, + {"wider hash", api.CrossRouteSearch{FromHash: "aa000001", ToHash: "bb00"}}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := store.SearchCrossIATARoutes(ctx, tc.q) + if err != nil { + t.Fatal(err) + } + want(t, got) + }) + } + + t.Run("within-IATA search", func(t *testing.T) { + for _, tc := range []struct { + name, from, to string + iatas []string + want int + iata string + }{ + {"two iatas", "aa", "03", []string{"YVR", "YYJ"}, 2, "YVR"}, + {"all iatas", "aa", "03", nil, 2, "YVR"}, + {"mixed widths", "aa00", "0300", nil, 2, "YVR"}, + {"destination before source", "05", "bb", nil, 1, "YYJ"}, + {"wrong order", "03", "aa", nil, 0, ""}, + {"other iata only", "aa", "03", []string{"YYJ"}, 0, ""}, + } { + got, err := store.SearchKnownRoutes(ctx, tc.iatas, tc.from, tc.to) + if err != nil || len(got) != tc.want { + t.Fatalf("%s: %d routes, err %v; want %d", tc.name, len(got), err, tc.want) + } + for _, r := range got { + if h := names(r.Hops); r.IATA != tc.iata || len(h) != 3 || int(r.HopCount) != len(h) { + t.Errorf("%s: route %s %v hopCount %d", tc.name, r.IATA, h, r.HopCount) + } + } + } + }) + + t.Run("neighbor row written receiver-first", func(t *testing.T) { + if _, err := tx.Exec(ctx, `UPDATE node_neighbors SET node_id = neighbor_id, neighbor_id = node_id, iata = 'YVR' WHERE node_id = $1`, ids["B"]); err != nil { + t.Fatal(err) + } + got, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"}) + if err != nil { + t.Fatal(err) + } + want(t, got) + }) + + t.Run("same path in two target iatas", func(t *testing.T) { + if _, err := tx.Exec(ctx, `INSERT INTO node_short_ids (node_id, iata, prefix_4) +SELECT node_id, 'YOW', prefix_4 FROM node_short_ids WHERE iata = 'YYJ'`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, `INSERT INTO known_routes (path_key, node_ids, hash_prefix, iata, hop_count) +SELECT path_key, node_ids, hash_prefix, 'YOW', hop_count FROM known_routes WHERE iata = 'YYJ'`); err != nil { + t.Fatal(err) + } + got, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"}) + if err != nil || len(got) != 2 || got[0].CrossHop.ToIATA == got[1].CrossHop.ToIATA { + t.Fatalf("got %d routes, err %v; want one each to YYJ and YOW", len(got), err) + } + }) + + t.Run("reverse direction", func(t *testing.T) { + got, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb", FromIATAs: []string{"YYJ"}, ToIATAs: []string{"YVR"}}) + if err != nil || len(got) != 0 { + t.Fatalf("got %d routes, err %v; want none", len(got), err) + } + }) + + t.Run("too broad", func(t *testing.T) { + if _, err := tx.Exec(ctx, `INSERT INTO iata_codes (iata) SELECT 'Q' || lpad(i::text, 2, '0') FROM generate_series(1, 60) i`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, `INSERT INTO node_short_ids (node_id, iata, prefix_4) +SELECT $1, 'Q' || lpad(i::text, 2, '0'), '\xaa000001'::bytea FROM generate_series(1, 60) i`, ids["S"]); err != nil { + t.Fatal(err) + } + if _, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"}); !errors.Is(err, api.ErrRouteSearchTooBroad) { + t.Fatalf("err = %v, want ErrRouteSearchTooBroad", err) + } + }) +} diff --git a/db/routes_test.go b/db/routes_test.go index 034d57f..50dfa21 100644 --- a/db/routes_test.go +++ b/db/routes_test.go @@ -12,7 +12,6 @@ import ( sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" mockdb "github.com/MeshCore-Beacon/beacon-server/db/sqlc/mock" - "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/google/uuid" "go.uber.org/mock/gomock" ) @@ -68,48 +67,3 @@ func TestDeleteOldRoutes_PassesCutoffs(t *testing.T) { t.Fatal(err) } } - -func TestExtractFromNode_Found(t *testing.T) { - a, b, c := uuid.New(), uuid.New(), uuid.New() - hops := []api.RouteHop{ - {NodeID: a}, - {NodeID: b}, - {NodeID: c}, - } - result := extractFromNode(hops, b) - if len(result) != 2 { - t.Fatalf("expected 2 hops, got %d", len(result)) - } - if result[0].NodeID != b { - t.Errorf("expected first hop to be b, got %s", result[0].NodeID) - } - if result[1].NodeID != c { - t.Errorf("expected second hop to be c, got %s", result[1].NodeID) - } -} - -func TestExtractFromNode_FirstNode(t *testing.T) { - a, b := uuid.New(), uuid.New() - hops := []api.RouteHop{{NodeID: a}, {NodeID: b}} - result := extractFromNode(hops, a) - if len(result) != 2 { - t.Fatalf("expected 2 hops, got %d", len(result)) - } -} - -func TestExtractFromNode_NotFound(t *testing.T) { - a, b := uuid.New(), uuid.New() - hops := []api.RouteHop{{NodeID: a}} - result := extractFromNode(hops, b) - // not found returns full slice - if len(result) != 1 { - t.Fatalf("expected full slice returned, got %d hops", len(result)) - } -} - -func TestExtractFromNode_Empty(t *testing.T) { - result := extractFromNode(nil, uuid.New()) - if len(result) != 0 { - t.Errorf("expected empty result for nil hops") - } -} diff --git a/db/sqlc/mock/querier.go b/db/sqlc/mock/querier.go index 114058a..6d5593c 100644 --- a/db/sqlc/mock/querier.go +++ b/db/sqlc/mock/querier.go @@ -432,21 +432,6 @@ func (mr *MockQuerierMockRecorder) GetChannelByID(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByID", reflect.TypeOf((*MockQuerier)(nil).GetChannelByID), ctx, id) } -// GetCrossIATANeighbors mocks base method. -func (m *MockQuerier) GetCrossIATANeighbors(ctx context.Context, arg db.GetCrossIATANeighborsParams) ([]db.GetCrossIATANeighborsRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetCrossIATANeighbors", ctx, arg) - ret0, _ := ret[0].([]db.GetCrossIATANeighborsRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetCrossIATANeighbors indicates an expected call of GetCrossIATANeighbors. -func (mr *MockQuerierMockRecorder) GetCrossIATANeighbors(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCrossIATANeighbors", reflect.TypeOf((*MockQuerier)(nil).GetCrossIATANeighbors), ctx, arg) -} - // GetEarliestCompleteRollupHour mocks base method. func (m *MockQuerier) GetEarliestCompleteRollupHour(ctx context.Context) (pgtype.Timestamptz, error) { m.ctrl.T.Helper() @@ -507,19 +492,19 @@ func (mr *MockQuerierMockRecorder) GetIATABorder(ctx, iata any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIATABorder", reflect.TypeOf((*MockQuerier)(nil).GetIATABorder), ctx, iata) } -// GetKnownRoutesByNode mocks base method. -func (m *MockQuerier) GetKnownRoutesByNode(ctx context.Context, arg db.GetKnownRoutesByNodeParams) ([]db.GetKnownRoutesByNodeRow, error) { +// GetKnownRoutesByNodes mocks base method. +func (m *MockQuerier) GetKnownRoutesByNodes(ctx context.Context, arg db.GetKnownRoutesByNodesParams) ([]db.GetKnownRoutesByNodesRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetKnownRoutesByNode", ctx, arg) - ret0, _ := ret[0].([]db.GetKnownRoutesByNodeRow) + ret := m.ctrl.Call(m, "GetKnownRoutesByNodes", ctx, arg) + ret0, _ := ret[0].([]db.GetKnownRoutesByNodesRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetKnownRoutesByNode indicates an expected call of GetKnownRoutesByNode. -func (mr *MockQuerierMockRecorder) GetKnownRoutesByNode(ctx, arg any) *gomock.Call { +// GetKnownRoutesByNodes indicates an expected call of GetKnownRoutesByNodes. +func (mr *MockQuerierMockRecorder) GetKnownRoutesByNodes(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKnownRoutesByNode", reflect.TypeOf((*MockQuerier)(nil).GetKnownRoutesByNode), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKnownRoutesByNodes", reflect.TypeOf((*MockQuerier)(nil).GetKnownRoutesByNodes), ctx, arg) } // GetLatestCompleteRollupHour mocks base method. @@ -537,6 +522,21 @@ func (mr *MockQuerierMockRecorder) GetLatestCompleteRollupHour(ctx any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCompleteRollupHour", reflect.TypeOf((*MockQuerier)(nil).GetLatestCompleteRollupHour), ctx) } +// GetNeighborLinks mocks base method. +func (m *MockQuerier) GetNeighborLinks(ctx context.Context, arg db.GetNeighborLinksParams) ([]db.GetNeighborLinksRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNeighborLinks", ctx, arg) + ret0, _ := ret[0].([]db.GetNeighborLinksRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNeighborLinks indicates an expected call of GetNeighborLinks. +func (mr *MockQuerierMockRecorder) GetNeighborLinks(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNeighborLinks", reflect.TypeOf((*MockQuerier)(nil).GetNeighborLinks), ctx, arg) +} + // GetNodeByID mocks base method. func (m *MockQuerier) GetNodeByID(ctx context.Context, id uuid.UUID) (db.GetNodeByIDRow, error) { m.ctrl.T.Helper() @@ -1911,6 +1911,66 @@ func (mr *MockQuerierMockRecorder) ResolvePathHashesP4(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolvePathHashesP4", reflect.TypeOf((*MockQuerier)(nil).ResolvePathHashesP4), ctx, arg) } +// ResolveRelayHashPairsP1 mocks base method. +func (m *MockQuerier) ResolveRelayHashPairsP1(ctx context.Context, arg db.ResolveRelayHashPairsP1Params) ([]db.ResolveRelayHashPairsP1Row, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveRelayHashPairsP1", ctx, arg) + ret0, _ := ret[0].([]db.ResolveRelayHashPairsP1Row) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveRelayHashPairsP1 indicates an expected call of ResolveRelayHashPairsP1. +func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP1(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP1", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP1), ctx, arg) +} + +// ResolveRelayHashPairsP2 mocks base method. +func (m *MockQuerier) ResolveRelayHashPairsP2(ctx context.Context, arg db.ResolveRelayHashPairsP2Params) ([]db.ResolveRelayHashPairsP2Row, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveRelayHashPairsP2", ctx, arg) + ret0, _ := ret[0].([]db.ResolveRelayHashPairsP2Row) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveRelayHashPairsP2 indicates an expected call of ResolveRelayHashPairsP2. +func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP2(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP2", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP2), ctx, arg) +} + +// ResolveRelayHashPairsP3 mocks base method. +func (m *MockQuerier) ResolveRelayHashPairsP3(ctx context.Context, arg db.ResolveRelayHashPairsP3Params) ([]db.ResolveRelayHashPairsP3Row, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveRelayHashPairsP3", ctx, arg) + ret0, _ := ret[0].([]db.ResolveRelayHashPairsP3Row) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveRelayHashPairsP3 indicates an expected call of ResolveRelayHashPairsP3. +func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP3(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP3", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP3), ctx, arg) +} + +// ResolveRelayHashPairsP4 mocks base method. +func (m *MockQuerier) ResolveRelayHashPairsP4(ctx context.Context, arg db.ResolveRelayHashPairsP4Params) ([]db.ResolveRelayHashPairsP4Row, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveRelayHashPairsP4", ctx, arg) + ret0, _ := ret[0].([]db.ResolveRelayHashPairsP4Row) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveRelayHashPairsP4 indicates an expected call of ResolveRelayHashPairsP4. +func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP4(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP4", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP4), ctx, arg) +} + // RollAdvertHearings mocks base method. func (m *MockQuerier) RollAdvertHearings(ctx context.Context, hour pgtype.Timestamptz) error { m.ctrl.T.Helper() @@ -2178,19 +2238,19 @@ func (mr *MockQuerierMockRecorder) SaveZoneList(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveZoneList", reflect.TypeOf((*MockQuerier)(nil).SaveZoneList), ctx, arg) } -// SearchKnownRoutes mocks base method. -func (m *MockQuerier) SearchKnownRoutes(ctx context.Context, arg db.SearchKnownRoutesParams) ([]db.SearchKnownRoutesRow, error) { +// SearchKnownRoutesByNodes mocks base method. +func (m *MockQuerier) SearchKnownRoutesByNodes(ctx context.Context, arg db.SearchKnownRoutesByNodesParams) ([]db.SearchKnownRoutesByNodesRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchKnownRoutes", ctx, arg) - ret0, _ := ret[0].([]db.SearchKnownRoutesRow) + ret := m.ctrl.Call(m, "SearchKnownRoutesByNodes", ctx, arg) + ret0, _ := ret[0].([]db.SearchKnownRoutesByNodesRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// SearchKnownRoutes indicates an expected call of SearchKnownRoutes. -func (mr *MockQuerierMockRecorder) SearchKnownRoutes(ctx, arg any) *gomock.Call { +// SearchKnownRoutesByNodes indicates an expected call of SearchKnownRoutesByNodes. +func (mr *MockQuerierMockRecorder) SearchKnownRoutesByNodes(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchKnownRoutes", reflect.TypeOf((*MockQuerier)(nil).SearchKnownRoutes), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchKnownRoutesByNodes", reflect.TypeOf((*MockQuerier)(nil).SearchKnownRoutesByNodes), ctx, arg) } // SetNodeDefaultScope mocks base method. diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 8bb7be5..f8b2796 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -64,8 +64,6 @@ type Querier interface { // Both counters only grow, so their sum changes whenever rolled content or coverage does. GetAnalyticsRevision(ctx context.Context) (int64, error) GetChannelByID(ctx context.Context, id int32) (Channel, error) - // Returns neighbors of a node that are in a different IATA. - GetCrossIATANeighbors(ctx context.Context, arg GetCrossIATANeighborsParams) ([]GetCrossIATANeighborsRow, error) GetEarliestCompleteRollupHour(ctx context.Context) (pgtype.Timestamptz, error) // ============================================================ // STATS @@ -76,8 +74,13 @@ type Querier interface { // when neither exists; a missing row (unknown IATA) is sql.ErrNoRows, same // not-found distinction GetIATA already makes. GetIATABorder(ctx context.Context, iata string) (json.RawMessage, error) - GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesByNodeParams) ([]GetKnownRoutesByNodeRow, error) + // Routes containing @nodes[i] in IATA @iatas[i] (aligned, one node per IATA). The && + // drives the node_ids GIN index; the indexed check keeps LIMIT counting real matches. + GetKnownRoutesByNodes(ctx context.Context, arg GetKnownRoutesByNodesParams) ([]GetKnownRoutesByNodesRow, error) GetLatestCompleteRollupHour(ctx context.Context) (pgtype.Timestamptz, error) + // Neighbor rows between @a_nodes and @b_nodes in either orientation: ingest writes + // adverts as (sender, forwarder) but traces as (receiver, sender). Callers normalise. + GetNeighborLinks(ctx context.Context, arg GetNeighborLinksParams) ([]GetNeighborLinksRow, error) GetNodeByID(ctx context.Context, id uuid.UUID) (GetNodeByIDRow, error) GetNodeByPubkey(ctx context.Context, publicKey []byte) (uuid.UUID, error) // Returns the neighbors of a node with details, ordered by most recently seen. @@ -317,6 +320,14 @@ type Querier interface { ResolvePathHashesP2(ctx context.Context, arg ResolvePathHashesP2Params) ([]ResolvePathHashesP2Row, error) ResolvePathHashesP3(ctx context.Context, arg ResolvePathHashesP3Params) ([]ResolvePathHashesP3Row, error) ResolvePathHashesP4(ctx context.Context, arg ResolvePathHashesP4Params) ([]ResolvePathHashesP4Row, error) + // Batch form of ResolvePathHashesP1 across IATAs; callers check uniqueness per IATA. + ResolveRelayHashPairsP1(ctx context.Context, arg ResolveRelayHashPairsP1Params) ([]ResolveRelayHashPairsP1Row, error) + // Batch form of ResolvePathHashesP2 across IATAs; callers check uniqueness per IATA. + ResolveRelayHashPairsP2(ctx context.Context, arg ResolveRelayHashPairsP2Params) ([]ResolveRelayHashPairsP2Row, error) + // Batch form of ResolvePathHashesP3 across IATAs; callers check uniqueness per IATA. + ResolveRelayHashPairsP3(ctx context.Context, arg ResolveRelayHashPairsP3Params) ([]ResolveRelayHashPairsP3Row, error) + // Batch form of ResolvePathHashesP4 across IATAs; callers check uniqueness per IATA. + ResolveRelayHashPairsP4(ctx context.Context, arg ResolveRelayHashPairsP4Params) ([]ResolveRelayHashPairsP4Row, error) RollAdvertHearings(ctx context.Context, hour pgtype.Timestamptz) error // Each distinct ADVERT packet counts once, under the exact set of IATAs that heard it this hour. RollAdvertSets(ctx context.Context, hour pgtype.Timestamptz) error @@ -347,9 +358,10 @@ type Querier interface { SaveZoneBoundary(ctx context.Context, arg SaveZoneBoundaryParams) error // NULL payload/etag/fetched_at retain the last good list after an error or 304. SaveZoneList(ctx context.Context, arg SaveZoneListParams) error - // Returns known routes containing a subsequence from source to destination hash prefix. - // Verifies source appears before destination in the route. - SearchKnownRoutes(ctx context.Context, arg SearchKnownRoutesParams) ([]SearchKnownRoutesRow, error) + // Routes holding a source and a destination candidate; callers check order and IATA. + // A route's hops resolve in its own IATA, so candidates from other IATAs can't match. + // Both && predicates use the node_ids GIN index. + SearchKnownRoutesByNodes(ctx context.Context, arg SearchKnownRoutesByNodesParams) ([]SearchKnownRoutesByNodesRow, error) SetNodeDefaultScope(ctx context.Context, arg SetNodeDefaultScopeParams) error SetNodeMultibytePaths(ctx context.Context, id uuid.UUID) error SetNodeMultibyteTraces(ctx context.Context, id uuid.UUID) error diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 46b13b5..a795f18 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -403,65 +403,6 @@ func (q *Queries) GetChannelByID(ctx context.Context, id int32) (Channel, error) return i, err } -const getCrossIATANeighbors = `-- name: GetCrossIATANeighbors :many -SELECT - n.id, n.name, n.node_type, n.latitude, n.longitude, - nn.iata AS neighbor_iata, nn.observation_count, nn.last_seen, nn.snr -FROM node_neighbors nn -JOIN nodes n ON n.id = nn.neighbor_id -WHERE nn.node_id = $1 - AND nn.iata != $2 -ORDER BY nn.last_seen DESC -` - -type GetCrossIATANeighborsParams struct { - NodeID uuid.UUID `json:"node_id"` - Iata string `json:"iata"` -} - -type GetCrossIATANeighborsRow struct { - ID uuid.UUID `json:"id"` - Name *string `json:"name"` - NodeType int16 `json:"node_type"` - Latitude *float64 `json:"latitude"` - Longitude *float64 `json:"longitude"` - NeighborIata string `json:"neighbor_iata"` - ObservationCount int64 `json:"observation_count"` - LastSeen pgtype.Timestamptz `json:"last_seen"` - Snr *float32 `json:"snr"` -} - -// Returns neighbors of a node that are in a different IATA. -func (q *Queries) GetCrossIATANeighbors(ctx context.Context, arg GetCrossIATANeighborsParams) ([]GetCrossIATANeighborsRow, error) { - rows, err := q.db.Query(ctx, getCrossIATANeighbors, arg.NodeID, arg.Iata) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetCrossIATANeighborsRow{} - for rows.Next() { - var i GetCrossIATANeighborsRow - if err := rows.Scan( - &i.ID, - &i.Name, - &i.NodeType, - &i.Latitude, - &i.Longitude, - &i.NeighborIata, - &i.ObservationCount, - &i.LastSeen, - &i.Snr, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getHourlyStats = `-- name: GetHourlyStats :many SELECT iata, hour, observation_count @@ -540,20 +481,23 @@ func (q *Queries) GetIATABorder(ctx context.Context, iata string) (json.RawMessa return border, err } -const getKnownRoutesByNode = `-- name: GetKnownRoutesByNode :many +const getKnownRoutesByNodes = `-- name: GetKnownRoutesByNodes :many SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count -FROM known_routes -WHERE iata = $1 - AND $2::uuid = ANY(node_ids) +FROM known_routes kr +WHERE kr.node_ids && $1::uuid[] + AND kr.iata = ANY($2::bpchar[]) + AND ($1::uuid[])[array_position($2::bpchar[], kr.iata)] = ANY(kr.node_ids) ORDER BY hop_count ASC, last_seen DESC +LIMIT $3 ` -type GetKnownRoutesByNodeParams struct { - Iata string `json:"iata"` - Column2 uuid.UUID `json:"column_2"` +type GetKnownRoutesByNodesParams struct { + Nodes []uuid.UUID `json:"nodes"` + Iatas []string `json:"iatas"` + RowLimit int32 `json:"row_limit"` } -type GetKnownRoutesByNodeRow struct { +type GetKnownRoutesByNodesRow struct { ID int64 `json:"id"` NodeIds []uuid.UUID `json:"node_ids"` HashPrefix [][]byte `json:"hash_prefix"` @@ -564,15 +508,17 @@ type GetKnownRoutesByNodeRow struct { ObservationCount int64 `json:"observation_count"` } -func (q *Queries) GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesByNodeParams) ([]GetKnownRoutesByNodeRow, error) { - rows, err := q.db.Query(ctx, getKnownRoutesByNode, arg.Iata, arg.Column2) +// Routes containing @nodes[i] in IATA @iatas[i] (aligned, one node per IATA). The && +// drives the node_ids GIN index; the indexed check keeps LIMIT counting real matches. +func (q *Queries) GetKnownRoutesByNodes(ctx context.Context, arg GetKnownRoutesByNodesParams) ([]GetKnownRoutesByNodesRow, error) { + rows, err := q.db.Query(ctx, getKnownRoutesByNodes, arg.Nodes, arg.Iatas, arg.RowLimit) if err != nil { return nil, err } defer rows.Close() - items := []GetKnownRoutesByNodeRow{} + items := []GetKnownRoutesByNodesRow{} for rows.Next() { - var i GetKnownRoutesByNodeRow + var i GetKnownRoutesByNodesRow if err := rows.Scan( &i.ID, &i.NodeIds, @@ -593,6 +539,46 @@ func (q *Queries) GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesBy return items, nil } +const getNeighborLinks = `-- name: GetNeighborLinks :many +SELECT node_id, neighbor_id, last_seen +FROM node_neighbors +WHERE (node_id = ANY($1::uuid[]) AND neighbor_id = ANY($2::uuid[])) + OR (node_id = ANY($2::uuid[]) AND neighbor_id = ANY($1::uuid[])) +` + +type GetNeighborLinksParams struct { + ANodes []uuid.UUID `json:"a_nodes"` + BNodes []uuid.UUID `json:"b_nodes"` +} + +type GetNeighborLinksRow struct { + NodeID uuid.UUID `json:"node_id"` + NeighborID uuid.UUID `json:"neighbor_id"` + LastSeen pgtype.Timestamptz `json:"last_seen"` +} + +// Neighbor rows between @a_nodes and @b_nodes in either orientation: ingest writes +// adverts as (sender, forwarder) but traces as (receiver, sender). Callers normalise. +func (q *Queries) GetNeighborLinks(ctx context.Context, arg GetNeighborLinksParams) ([]GetNeighborLinksRow, error) { + rows, err := q.db.Query(ctx, getNeighborLinks, arg.ANodes, arg.BNodes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetNeighborLinksRow{} + for rows.Next() { + var i GetNeighborLinksRow + if err := rows.Scan(&i.NodeID, &i.NeighborID, &i.LastSeen); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getNodeByID = `-- name: GetNodeByID :one SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.location_source, n.last_advert_at, n.supports_multibyte_paths, n.supports_multibyte_traces, n.default_scope_id, n.min_firmware_version, n.first_seen, n.last_seen, n.radio_freq_mhz, n.radio_sf, n.radio_bw_khz, n.metadata, n.device_clock_drift_seconds, ts.name AS default_scope_name, EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer, @@ -4856,6 +4842,170 @@ func (q *Queries) ResolvePathHashesP4(ctx context.Context, arg ResolvePathHashes return items, nil } +const resolveRelayHashPairsP1 = `-- name: ResolveRelayHashPairsP1 :many +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY($1::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_1 = ANY($2::bytea[]) +` + +type ResolveRelayHashPairsP1Params struct { + Iatas []string `json:"iatas"` + Hashes [][]byte `json:"hashes"` +} + +type ResolveRelayHashPairsP1Row struct { + Iata string `json:"iata"` + Hash []byte `json:"hash"` + NodeID uuid.UUID `json:"node_id"` +} + +// Batch form of ResolvePathHashesP1 across IATAs; callers check uniqueness per IATA. +func (q *Queries) ResolveRelayHashPairsP1(ctx context.Context, arg ResolveRelayHashPairsP1Params) ([]ResolveRelayHashPairsP1Row, error) { + rows, err := q.db.Query(ctx, resolveRelayHashPairsP1, arg.Iatas, arg.Hashes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ResolveRelayHashPairsP1Row{} + for rows.Next() { + var i ResolveRelayHashPairsP1Row + if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveRelayHashPairsP2 = `-- name: ResolveRelayHashPairsP2 :many +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY($1::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_2 = ANY($2::bytea[]) +` + +type ResolveRelayHashPairsP2Params struct { + Iatas []string `json:"iatas"` + Hashes [][]byte `json:"hashes"` +} + +type ResolveRelayHashPairsP2Row struct { + Iata string `json:"iata"` + Hash []byte `json:"hash"` + NodeID uuid.UUID `json:"node_id"` +} + +// Batch form of ResolvePathHashesP2 across IATAs; callers check uniqueness per IATA. +func (q *Queries) ResolveRelayHashPairsP2(ctx context.Context, arg ResolveRelayHashPairsP2Params) ([]ResolveRelayHashPairsP2Row, error) { + rows, err := q.db.Query(ctx, resolveRelayHashPairsP2, arg.Iatas, arg.Hashes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ResolveRelayHashPairsP2Row{} + for rows.Next() { + var i ResolveRelayHashPairsP2Row + if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveRelayHashPairsP3 = `-- name: ResolveRelayHashPairsP3 :many +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY($1::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_3 = ANY($2::bytea[]) +` + +type ResolveRelayHashPairsP3Params struct { + Iatas []string `json:"iatas"` + Hashes [][]byte `json:"hashes"` +} + +type ResolveRelayHashPairsP3Row struct { + Iata string `json:"iata"` + Hash []byte `json:"hash"` + NodeID uuid.UUID `json:"node_id"` +} + +// Batch form of ResolvePathHashesP3 across IATAs; callers check uniqueness per IATA. +func (q *Queries) ResolveRelayHashPairsP3(ctx context.Context, arg ResolveRelayHashPairsP3Params) ([]ResolveRelayHashPairsP3Row, error) { + rows, err := q.db.Query(ctx, resolveRelayHashPairsP3, arg.Iatas, arg.Hashes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ResolveRelayHashPairsP3Row{} + for rows.Next() { + var i ResolveRelayHashPairsP3Row + if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveRelayHashPairsP4 = `-- name: ResolveRelayHashPairsP4 :many +SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id +FROM node_short_ids ns +JOIN nodes n ON n.id = ns.node_id +WHERE ns.iata = ANY($1::bpchar[]) + AND n.node_type IN (2, 3) + AND ns.prefix_4 = ANY($2::bytea[]) +` + +type ResolveRelayHashPairsP4Params struct { + Iatas []string `json:"iatas"` + Hashes [][]byte `json:"hashes"` +} + +type ResolveRelayHashPairsP4Row struct { + Iata string `json:"iata"` + Hash []byte `json:"hash"` + NodeID uuid.UUID `json:"node_id"` +} + +// Batch form of ResolvePathHashesP4 across IATAs; callers check uniqueness per IATA. +func (q *Queries) ResolveRelayHashPairsP4(ctx context.Context, arg ResolveRelayHashPairsP4Params) ([]ResolveRelayHashPairsP4Row, error) { + rows, err := q.db.Query(ctx, resolveRelayHashPairsP4, arg.Iatas, arg.Hashes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ResolveRelayHashPairsP4Row{} + for rows.Next() { + var i ResolveRelayHashPairsP4Row + if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const saveChannelCatalogue = `-- name: SaveChannelCatalogue :exec INSERT INTO meshmapper_channel_catalogues (iata, url, payload, etag, checked_at, attempted_at, next_attempt, last_error) VALUES ($1, $2, $3::jsonb, $4::text, @@ -5026,23 +5176,24 @@ func (q *Queries) SaveZoneList(ctx context.Context, arg SaveZoneListParams) erro return err } -const searchKnownRoutes = `-- name: SearchKnownRoutes :many +const searchKnownRoutesByNodes = `-- name: SearchKnownRoutesByNodes :many SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count -FROM known_routes -WHERE iata = $1 - AND array_position(hash_prefix, $2::bytea) IS NOT NULL - AND array_position(hash_prefix, $3::bytea) IS NOT NULL - AND array_position(hash_prefix, $2::bytea) < array_position(hash_prefix, $3::bytea) +FROM known_routes kr +WHERE kr.node_ids && $1::uuid[] + AND kr.node_ids && $2::uuid[] + AND kr.iata = ANY($3::bpchar[]) ORDER BY hop_count ASC, last_seen DESC +LIMIT $4 ` -type SearchKnownRoutesParams struct { - Iata string `json:"iata"` - Column2 []byte `json:"column_2"` - Column3 []byte `json:"column_3"` +type SearchKnownRoutesByNodesParams struct { + FromNodes []uuid.UUID `json:"from_nodes"` + ToNodes []uuid.UUID `json:"to_nodes"` + Iatas []string `json:"iatas"` + RowLimit int32 `json:"row_limit"` } -type SearchKnownRoutesRow struct { +type SearchKnownRoutesByNodesRow struct { ID int64 `json:"id"` NodeIds []uuid.UUID `json:"node_ids"` HashPrefix [][]byte `json:"hash_prefix"` @@ -5053,17 +5204,23 @@ type SearchKnownRoutesRow struct { ObservationCount int64 `json:"observation_count"` } -// Returns known routes containing a subsequence from source to destination hash prefix. -// Verifies source appears before destination in the route. -func (q *Queries) SearchKnownRoutes(ctx context.Context, arg SearchKnownRoutesParams) ([]SearchKnownRoutesRow, error) { - rows, err := q.db.Query(ctx, searchKnownRoutes, arg.Iata, arg.Column2, arg.Column3) +// Routes holding a source and a destination candidate; callers check order and IATA. +// A route's hops resolve in its own IATA, so candidates from other IATAs can't match. +// Both && predicates use the node_ids GIN index. +func (q *Queries) SearchKnownRoutesByNodes(ctx context.Context, arg SearchKnownRoutesByNodesParams) ([]SearchKnownRoutesByNodesRow, error) { + rows, err := q.db.Query(ctx, searchKnownRoutesByNodes, + arg.FromNodes, + arg.ToNodes, + arg.Iatas, + arg.RowLimit, + ) if err != nil { return nil, err } defer rows.Close() - items := []SearchKnownRoutesRow{} + items := []SearchKnownRoutesByNodesRow{} for rows.Next() { - var i SearchKnownRoutesRow + var i SearchKnownRoutesByNodesRow if err := rows.Scan( &i.ID, &i.NodeIds, diff --git a/docs/docs.go b/docs/docs.go index 4260553..802cbd8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -899,14 +899,14 @@ const docTemplate = `{ }, "/info": { "get": { - "description": "minAppVersion is null when the server sets no requirement.", + "description": "minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the\nserver sets no requirement. Each is the higher of the built-in floor and config.", "produces": [ "application/json" ], "tags": [ "Info" ], - "summary": "Server info and minimum mobile app version", + "summary": "Server info and minimum client versions", "responses": { "200": { "description": "OK", @@ -2201,6 +2201,7 @@ const docTemplate = `{ }, "/routes/cross": { "get": { + "description": "Pass iatas (comma-separated, optional; omitted searches every IATA) or the\nsingle directed pair fromIata/toIata, not both.", "produces": [ "application/json" ], @@ -2211,31 +2212,35 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Source node hash prefix (hex)", + "description": "Source node hash prefix (hex, 1-4 bytes)", "name": "fromHash", "in": "query", "required": true }, { "type": "string", - "description": "Source IATA code", - "name": "fromIata", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Destination node hash prefix (hex)", + "description": "Destination node hash prefix (hex, 1-4 bytes)", "name": "toHash", "in": "query", "required": true }, { "type": "string", - "description": "Destination IATA code", + "description": "Comma-separated IATA codes to search (2-100)", + "name": "iatas", + "in": "query" + }, + { + "type": "string", + "description": "Source IATA code (with toIata)", + "name": "fromIata", + "in": "query" + }, + { + "type": "string", + "description": "Destination IATA code (with fromIata)", "name": "toIata", - "in": "query", - "required": true + "in": "query" } ], "responses": { @@ -2259,12 +2264,19 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/internal_api_handlers.APIError" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } } } } }, "/routes/search": { "get": { + "description": "Routes within one IATA that run from the source hash to the destination hash,\ntrimmed to that span. iatas is optional (omitted searches every IATA); the single\niata is still accepted. At most 500, shortest first.", "produces": [ "application/json" ], @@ -2275,21 +2287,26 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "IATA code to search within", - "name": "iata", - "in": "query", - "required": true + "description": "Comma-separated IATA codes to search (max 100)", + "name": "iatas", + "in": "query" }, { "type": "string", - "description": "Source node hash prefix (hex)", + "description": "Single IATA code (when iatas is omitted)", + "name": "iata", + "in": "query" + }, + { + "type": "string", + "description": "Source node hash prefix (hex, 1-4 bytes)", "name": "from", "in": "query", "required": true }, { "type": "string", - "description": "Destination node hash prefix (hex)", + "description": "Destination node hash prefix (hex, 1-4 bytes)", "name": "to", "in": "query", "required": true @@ -2316,6 +2333,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/internal_api_handlers.APIError" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } } } } @@ -3662,6 +3685,9 @@ const docTemplate = `{ }, "mobile": { "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig" + }, + "web": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig" } } }, @@ -3678,7 +3704,16 @@ const docTemplate = `{ "type": "object", "properties": { "min_app_version": { - "description": "empty means no requirement", + "description": "effective floor; empty means no requirement", + "type": "string" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig": { + "type": "object", + "properties": { + "min_web_version": { + "description": "effective floor; empty means no requirement", "type": "string" } } @@ -3987,6 +4022,11 @@ const docTemplate = `{ "type": "string", "example": "0.1.1" }, + "minWebVersion": { + "description": "MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); null means no requirement.", + "type": "string", + "example": "2.0.3" + }, "serverVersion": { "type": "string", "example": "2.0.2" @@ -6086,7 +6126,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "2.0.3", + Version: "2.0.4", Host: "localhost:8080", BasePath: "/api/v1", Schemes: []string{"http", "https"}, diff --git a/docs/swagger.json b/docs/swagger.json index fbd00b5..7f494d2 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -15,7 +15,7 @@ "license": { "name": "AGPL-3-or-later" }, - "version": "2.0.3" + "version": "2.0.4" }, "host": "localhost:8080", "basePath": "/api/v1", @@ -897,14 +897,14 @@ }, "/info": { "get": { - "description": "minAppVersion is null when the server sets no requirement.", + "description": "minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the\nserver sets no requirement. Each is the higher of the built-in floor and config.", "produces": [ "application/json" ], "tags": [ "Info" ], - "summary": "Server info and minimum mobile app version", + "summary": "Server info and minimum client versions", "responses": { "200": { "description": "OK", @@ -2199,6 +2199,7 @@ }, "/routes/cross": { "get": { + "description": "Pass iatas (comma-separated, optional; omitted searches every IATA) or the\nsingle directed pair fromIata/toIata, not both.", "produces": [ "application/json" ], @@ -2209,31 +2210,35 @@ "parameters": [ { "type": "string", - "description": "Source node hash prefix (hex)", + "description": "Source node hash prefix (hex, 1-4 bytes)", "name": "fromHash", "in": "query", "required": true }, { "type": "string", - "description": "Source IATA code", - "name": "fromIata", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Destination node hash prefix (hex)", + "description": "Destination node hash prefix (hex, 1-4 bytes)", "name": "toHash", "in": "query", "required": true }, { "type": "string", - "description": "Destination IATA code", + "description": "Comma-separated IATA codes to search (2-100)", + "name": "iatas", + "in": "query" + }, + { + "type": "string", + "description": "Source IATA code (with toIata)", + "name": "fromIata", + "in": "query" + }, + { + "type": "string", + "description": "Destination IATA code (with fromIata)", "name": "toIata", - "in": "query", - "required": true + "in": "query" } ], "responses": { @@ -2257,12 +2262,19 @@ "schema": { "$ref": "#/definitions/internal_api_handlers.APIError" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } } } } }, "/routes/search": { "get": { + "description": "Routes within one IATA that run from the source hash to the destination hash,\ntrimmed to that span. iatas is optional (omitted searches every IATA); the single\niata is still accepted. At most 500, shortest first.", "produces": [ "application/json" ], @@ -2273,21 +2285,26 @@ "parameters": [ { "type": "string", - "description": "IATA code to search within", - "name": "iata", - "in": "query", - "required": true + "description": "Comma-separated IATA codes to search (max 100)", + "name": "iatas", + "in": "query" }, { "type": "string", - "description": "Source node hash prefix (hex)", + "description": "Single IATA code (when iatas is omitted)", + "name": "iata", + "in": "query" + }, + { + "type": "string", + "description": "Source node hash prefix (hex, 1-4 bytes)", "name": "from", "in": "query", "required": true }, { "type": "string", - "description": "Destination node hash prefix (hex)", + "description": "Destination node hash prefix (hex, 1-4 bytes)", "name": "to", "in": "query", "required": true @@ -2314,6 +2331,12 @@ "schema": { "$ref": "#/definitions/internal_api_handlers.APIError" } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } } } } @@ -3660,6 +3683,9 @@ }, "mobile": { "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig" + }, + "web": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig" } } }, @@ -3676,7 +3702,16 @@ "type": "object", "properties": { "min_app_version": { - "description": "empty means no requirement", + "description": "effective floor; empty means no requirement", + "type": "string" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig": { + "type": "object", + "properties": { + "min_web_version": { + "description": "effective floor; empty means no requirement", "type": "string" } } @@ -3985,6 +4020,11 @@ "type": "string", "example": "0.1.1" }, + "minWebVersion": { + "description": "MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); null means no requirement.", + "type": "string", + "example": "2.0.3" + }, "serverVersion": { "type": "string", "example": "2.0.2" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8477b1a..80bec47 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -54,6 +54,8 @@ definitions: $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig' mobile: $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig' + web: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig' type: object github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig: properties: @@ -66,7 +68,13 @@ definitions: github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig: properties: min_app_version: - description: empty means no requirement + description: effective floor; empty means no requirement + type: string + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig: + properties: + min_web_version: + description: effective floor; empty means no requirement type: string type: object github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation: @@ -279,6 +287,11 @@ definitions: means no requirement. example: 0.1.1 type: string + minWebVersion: + description: MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); + null means no requirement. + example: 2.0.3 + type: string serverVersion: example: 2.0.2 type: string @@ -1727,7 +1740,7 @@ info: name: AGPL-3-or-later termsOfService: https://github.com/MeshCore-Beacon/beacon-server title: MeshCore Beacon API - version: 2.0.3 + version: 2.0.4 paths: /admin/accounts: get: @@ -2322,7 +2335,9 @@ paths: - IATAs /info: get: - description: minAppVersion is null when the server sets no requirement. + description: |- + minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the + server sets no requirement. Each is the higher of the built-in floor and config. produces: - application/json responses: @@ -2330,7 +2345,7 @@ paths: description: OK schema: $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info' - summary: Server info and minimum mobile app version + summary: Server info and minimum client versions tags: - Info /messages: @@ -3275,26 +3290,31 @@ paths: - Routes /routes/cross: get: + description: |- + Pass iatas (comma-separated, optional; omitted searches every IATA) or the + single directed pair fromIata/toIata, not both. parameters: - - description: Source node hash prefix (hex) + - description: Source node hash prefix (hex, 1-4 bytes) in: query name: fromHash required: true type: string - - description: Source IATA code - in: query - name: fromIata - required: true - type: string - - description: Destination node hash prefix (hex) + - description: Destination node hash prefix (hex, 1-4 bytes) in: query name: toHash required: true type: string - - description: Destination IATA code + - description: Comma-separated IATA codes to search (2-100) + in: query + name: iatas + type: string + - description: Source IATA code (with toIata) + in: query + name: fromIata + type: string + - description: Destination IATA code (with fromIata) in: query name: toIata - required: true type: string produces: - application/json @@ -3313,23 +3333,34 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/internal_api_handlers.APIError' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/internal_api_handlers.APIError' summary: Search for routes that cross IATA boundaries tags: - Routes /routes/search: get: + description: |- + Routes within one IATA that run from the source hash to the destination hash, + trimmed to that span. iatas is optional (omitted searches every IATA); the single + iata is still accepted. At most 500, shortest first. parameters: - - description: IATA code to search within + - description: Comma-separated IATA codes to search (max 100) + in: query + name: iatas + type: string + - description: Single IATA code (when iatas is omitted) in: query name: iata - required: true type: string - - description: Source node hash prefix (hex) + - description: Source node hash prefix (hex, 1-4 bytes) in: query name: from required: true type: string - - description: Destination node hash prefix (hex) + - description: Destination node hash prefix (hex, 1-4 bytes) in: query name: to required: true @@ -3351,6 +3382,10 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/internal_api_handlers.APIError' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/internal_api_handlers.APIError' summary: Search known routes by source and destination hash tags: - Routes diff --git a/go.mod b/go.mod index 0fc10fa..1a83855 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/MeshCore-Beacon/beacon-server -go 1.26.6 +go 1.26.9 require ( github.com/alicebob/miniredis/v2 v2.38.0 @@ -18,7 +18,7 @@ require ( github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 go.uber.org/mock v0.6.0 - golang.org/x/sync v0.21.0 + golang.org/x/sync v0.24.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -43,11 +43,11 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/tools v0.51.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index b4d9df2..11d58bd 100644 --- a/go.sum +++ b/go.sum @@ -100,26 +100,26 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/admin.go b/internal/api/admin.go index 7d81654..583e50e 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -10,10 +10,15 @@ type AdminConfig struct { CORS AdminCORSConfig `json:"cors"` Ingest AdminIngestConfig `json:"ingest"` Mobile AdminMobileConfig `json:"mobile"` + Web AdminWebConfig `json:"web"` } type AdminMobileConfig struct { - MinAppVersion string `json:"min_app_version"` // empty means no requirement + MinAppVersion string `json:"min_app_version"` // effective floor; empty means no requirement +} + +type AdminWebConfig struct { + MinWebVersion string `json:"min_web_version"` // effective floor; empty means no requirement } type AdminAuthConfig struct { diff --git a/internal/api/handlers/info.go b/internal/api/handlers/info.go index f0a1985..5259e11 100644 --- a/internal/api/handlers/info.go +++ b/internal/api/handlers/info.go @@ -10,7 +10,7 @@ import ( "github.com/go-chi/chi/v5" ) -// InfoRouter mounts GET /info. The mobile app treats a 404 as "no requirement", +// InfoRouter mounts GET /info. Clients treat a 404 as "no requirement", // so this route must stay mounted once shipped. func InfoRouter(info api.Info) http.Handler { r := chi.NewRouter() @@ -20,8 +20,9 @@ func InfoRouter(info api.Info) http.Handler { // getInfo godoc // -// @Summary Server info and minimum mobile app version -// @Description minAppVersion is null when the server sets no requirement. +// @Summary Server info and minimum client versions +// @Description minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the +// @Description server sets no requirement. Each is the higher of the built-in floor and config. // @Tags Info // @Produce json // @Success 200 {object} api.Info diff --git a/internal/api/handlers/info_test.go b/internal/api/handlers/info_test.go index a074e42..6de3beb 100644 --- a/internal/api/handlers/info_test.go +++ b/internal/api/handlers/info_test.go @@ -13,14 +13,14 @@ import ( ) func TestGetInfo(t *testing.T) { - version := "0.1.1" + version, web := "0.1.1", "2.0.3" for _, tc := range []struct { name string info api.Info want string }{ - {"unset", api.Info{ServerVersion: "2.0.2"}, `{"minAppVersion":null,"serverVersion":"2.0.2"}`}, - {"set", api.Info{MinAppVersion: &version, ServerVersion: "2.0.2"}, `{"minAppVersion":"0.1.1","serverVersion":"2.0.2"}`}, + {"unset", api.Info{ServerVersion: "2.0.2"}, `{"minAppVersion":null,"minWebVersion":null,"serverVersion":"2.0.2"}`}, + {"set", api.Info{MinAppVersion: &version, MinWebVersion: &web, ServerVersion: "2.0.2"}, `{"minAppVersion":"0.1.1","minWebVersion":"2.0.3","serverVersion":"2.0.2"}`}, } { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() diff --git a/internal/api/handlers/routes.go b/internal/api/handlers/routes.go index 86a7c86..ee075bc 100644 --- a/internal/api/handlers/routes.go +++ b/internal/api/handlers/routes.go @@ -4,6 +4,10 @@ package handlers import ( + "context" + "encoding/hex" + "errors" + "fmt" "net/http" "strconv" "strings" @@ -83,30 +87,47 @@ func listKnownRoutes(reader api.Reader) http.HandlerFunc { } } +// maxRouteSearchIATAs bounds the iatas list; the searches themselves are bounded by row limits. +const maxRouteSearchIATAs = 100 + // searchKnownRoutes godoc // -// @Summary Search known routes by source and destination hash -// @Tags Routes -// @Produce json -// @Param iata query string true "IATA code to search within" -// @Param from query string true "Source node hash prefix (hex)" -// @Param to query string true "Destination node hash prefix (hex)" -// @Success 200 {object} []api.KnownRoute -// @Failure 400 {object} handlers.APIError -// @Failure 500 {object} handlers.APIError -// @Router /routes/search [get] +// @Summary Search known routes by source and destination hash +// @Description Routes within one IATA that run from the source hash to the destination hash, +// @Description trimmed to that span. iatas is optional (omitted searches every IATA); the single +// @Description iata is still accepted. At most 500, shortest first. +// @Tags Routes +// @Produce json +// @Param iatas query string false "Comma-separated IATA codes to search (max 100)" +// @Param iata query string false "Single IATA code (when iatas is omitted)" +// @Param from query string true "Source node hash prefix (hex, 1-4 bytes)" +// @Param to query string true "Destination node hash prefix (hex, 1-4 bytes)" +// @Success 200 {object} []api.KnownRoute +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Failure 503 {object} handlers.APIError +// @Router /routes/search [get] func searchKnownRoutes(reader api.Reader) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - iata := strings.ToUpper(r.URL.Query().Get("iata")) + iatas, iataErr := routeIATAs(r) from := strings.ToLower(r.URL.Query().Get("from")) to := strings.ToLower(r.URL.Query().Get("to")) - if iata == "" || from == "" || to == "" { - respondError(w, http.StatusBadRequest, "iata, from and to are required") + if from == "" || to == "" { + respondError(w, http.StatusBadRequest, "from and to are required") return } - routes, err := reader.SearchKnownRoutes(r.Context(), iata, from, to) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") + if !validHopHash(from) || !validHopHash(to) { + respondError(w, http.StatusBadRequest, "from and to must be 1-4 bytes of hex") + return + } + if iataErr != nil { + respondError(w, http.StatusBadRequest, iataErr.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + routes, err := reader.SearchKnownRoutes(ctx, iatas, from, to) + if respondRouteSearchError(w, ctx, err) { return } respond(w, http.StatusOK, routes) @@ -115,30 +136,32 @@ func searchKnownRoutes(reader api.Reader) http.HandlerFunc { // searchCrossIATARoutes godoc // -// @Summary Search for routes that cross IATA boundaries -// @Tags Routes -// @Produce json -// @Param fromHash query string true "Source node hash prefix (hex)" -// @Param fromIata query string true "Source IATA code" -// @Param toHash query string true "Destination node hash prefix (hex)" -// @Param toIata query string true "Destination IATA code" -// @Success 200 {object} []api.CrossIATARoute -// @Failure 400 {object} handlers.APIError -// @Failure 500 {object} handlers.APIError -// @Router /routes/cross [get] +// @Summary Search for routes that cross IATA boundaries +// @Description Pass iatas (comma-separated, optional; omitted searches every IATA) or the +// @Description single directed pair fromIata/toIata, not both. +// @Tags Routes +// @Produce json +// @Param fromHash query string true "Source node hash prefix (hex, 1-4 bytes)" +// @Param toHash query string true "Destination node hash prefix (hex, 1-4 bytes)" +// @Param iatas query string false "Comma-separated IATA codes to search (2-100)" +// @Param fromIata query string false "Source IATA code (with toIata)" +// @Param toIata query string false "Destination IATA code (with fromIata)" +// @Success 200 {object} []api.CrossIATARoute +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Failure 503 {object} handlers.APIError +// @Router /routes/cross [get] func searchCrossIATARoutes(reader api.Reader) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - fromHash := strings.ToLower(r.URL.Query().Get("fromHash")) - fromIATA := strings.ToUpper(r.URL.Query().Get("fromIata")) - toHash := strings.ToLower(r.URL.Query().Get("toHash")) - toIATA := strings.ToUpper(r.URL.Query().Get("toIata")) - if fromHash == "" || fromIATA == "" || toHash == "" || toIATA == "" { - respondError(w, http.StatusBadRequest, "fromHash, fromIata, toHash and toIata are required") + q, err := parseCrossRouteSearch(r) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) return } - routes, err := reader.SearchCrossIATARoutes(r.Context(), fromHash, fromIATA, toHash, toIATA) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + routes, err := reader.SearchCrossIATARoutes(ctx, q) + if respondRouteSearchError(w, ctx, err) { return } if routes == nil { @@ -147,3 +170,86 @@ func searchCrossIATARoutes(reader api.Reader) http.HandlerFunc { respond(w, http.StatusOK, routes) } } + +func parseCrossRouteSearch(r *http.Request) (api.CrossRouteSearch, error) { + v := r.URL.Query() + q := api.CrossRouteSearch{ + FromHash: strings.ToLower(v.Get("fromHash")), + ToHash: strings.ToLower(v.Get("toHash")), + } + if q.FromHash == "" || q.ToHash == "" { + return q, errors.New("fromHash and toHash are required") + } + if !validHopHash(q.FromHash) || !validHopHash(q.ToHash) { + return q, errors.New("fromHash and toHash must be 1-4 bytes of hex") + } + fromIATA, toIATA := strings.ToUpper(v.Get("fromIata")), strings.ToUpper(v.Get("toIata")) + if fromIATA != "" || toIATA != "" { + if fromIATA == "" || toIATA == "" { + return q, errors.New("fromIata and toIata must be given together") + } + if v.Has("iatas") { + return q, errors.New("use iatas or fromIata/toIata, not both") + } + q.FromIATAs, q.ToIATAs = []string{fromIATA}, []string{toIATA} + return q, nil + } + if !v.Has("iatas") { + return q, nil + } + iatas, err := routeIATAs(r) + if err != nil { + return q, err + } + if len(iatas) < 2 { + return q, fmt.Errorf("iatas must list 2-%d distinct codes", maxRouteSearchIATAs) + } + q.FromIATAs, q.ToIATAs = iatas, iatas + return q, nil +} + +// respondRouteSearchError writes the response for a failed route search and reports whether it did. +func respondRouteSearchError(w http.ResponseWriter, ctx context.Context, err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, api.ErrRouteSearchTooBroad): + respondError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded): + respondError(w, http.StatusServiceUnavailable, "route search timed out; use a longer hash or fewer iatas") + case errors.Is(err, context.Canceled): + // client went away; nothing to write + default: + respondError(w, http.StatusInternalServerError, "internal server error") + } + return true +} + +// routeIATAs dedupes the iatas (or iata) list, failing as soon as it passes the cap so a +// huge list costs no more than parsing it. +func routeIATAs(r *http.Request) ([]string, error) { + var out []string + seen := make(map[string]struct{}) + for _, c := range parseIATAs(r) { + if len(c) != 3 { + return nil, errors.New("iatas must be 3-letter codes") + } + if _, dup := seen[c]; dup { + continue + } + if len(out) == maxRouteSearchIATAs { + return nil, fmt.Errorf("iatas must list at most %d codes", maxRouteSearchIATAs) + } + seen[c] = struct{}{} + out = append(out, c) + } + return out, nil +} + +func validHopHash(h string) bool { + if len(h) < 2 || len(h) > 8 { + return false + } + _, err := hex.DecodeString(h) + return err == nil +} diff --git a/internal/api/handlers/routes_test.go b/internal/api/handlers/routes_test.go index 9860d6d..c173cdb 100644 --- a/internal/api/handlers/routes_test.go +++ b/internal/api/handlers/routes_test.go @@ -5,8 +5,11 @@ package handlers import ( "context" + "fmt" "net/http" "net/http/httptest" + "slices" + "strings" "testing" "time" @@ -25,7 +28,10 @@ func TestSearchKnownRoutes_MissingParams(t *testing.T) { {"missing all", ""}, {"missing from and to", "?iata=YVR"}, {"missing to", "?iata=YVR&from=aa"}, - {"missing iata", "?from=aa&to=bb"}, + {"bad hex", "?iata=YVR&from=zz&to=bb"}, + {"too many iatas", "?from=aa&to=bb&iatas=" + manyIATAs(maxRouteSearchIATAs+1)}, + {"long code", "?from=aa&to=bb&iatas=YVRX"}, + {"huge list", "?from=aa&to=bb&iatas=" + manyIATAs(200000)}, } for _, tt := range tests { req := httptest.NewRequest(http.MethodGet, "/routes/search"+tt.query, nil) @@ -37,18 +43,25 @@ func TestSearchKnownRoutes_MissingParams(t *testing.T) { } } -func TestSearchCrossIATARoutes_MissingParams(t *testing.T) { +func TestSearchCrossIATARoutes_BadParams(t *testing.T) { r := chi.NewRouter() r.Get("/routes/cross", searchCrossIATARoutes(stubReader{})) + many := manyIATAs(maxRouteSearchIATAs + 1) tests := []struct { name string query string }{ {"missing all", ""}, - {"missing toHash and toIata", "?fromHash=aa&fromIata=YVR"}, + {"missing toHash", "?fromHash=aa&fromIata=YVR&toIata=YYJ"}, {"missing fromIata", "?fromHash=aa&toHash=bb&toIata=YYJ"}, {"missing fromHash", "?fromIata=YVR&toHash=bb&toIata=YYJ"}, + {"bad hex", "?fromHash=zz&toHash=bb"}, + {"odd hex", "?fromHash=aab&toHash=bb"}, + {"hash too long", "?fromHash=aabbccddee&toHash=bb"}, + {"iatas and pair", "?fromHash=aa&toHash=bb&fromIata=YVR&toIata=YYJ&iatas=YVR,YYJ"}, + {"one iata", "?fromHash=aa&toHash=bb&iatas=YVR,yvr"}, + {"too many iatas", "?fromHash=aa&toHash=bb&iatas=" + many}, } for _, tt := range tests { req := httptest.NewRequest(http.MethodGet, "/routes/cross"+tt.query, nil) @@ -110,31 +123,91 @@ func TestListKnownRoutes_OK(t *testing.T) { } func TestSearchKnownRoutes_OK(t *testing.T) { + var got []string r := chi.NewRouter() r.Get("/routes/search", searchKnownRoutes(stubReader{ - searchKnownRoutes: func(_ context.Context, _, _, _ string) ([]api.KnownRoute, error) { + searchKnownRoutes: func(_ context.Context, iatas []string, _, _ string) ([]api.KnownRoute, error) { + got = iatas return []api.KnownRoute{{IATA: "YVR"}}, nil }, })) - req := httptest.NewRequest(http.MethodGet, "/routes/search?iata=YVR&from=aa&to=bb", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d", w.Code) + for _, tt := range []struct { + query string + want []string + }{ + {"?iata=yvr&from=aa&to=bb", []string{"YVR"}}, + {"?iatas=yvr,YYJ,YVR&from=aa&to=bbcc", []string{"YVR", "YYJ"}}, + {"?from=aa&to=bb", nil}, + } { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/routes/search"+tt.query, nil)) + if w.Code != http.StatusOK || !slices.Equal(got, tt.want) { + t.Errorf("%s: got %d, iatas %v", tt.query, w.Code, got) + } } } -func TestSearchCrossIATARoutes_OK(t *testing.T) { +func TestSearchCrossIATARoutes_Query(t *testing.T) { + var got api.CrossRouteSearch r := chi.NewRouter() r.Get("/routes/cross", searchCrossIATARoutes(stubReader{ - searchCrossIATARoutes: func(_ context.Context, _, _, _, _ string) ([]api.CrossIATARoute, error) { - return []api.CrossIATARoute{}, nil + searchCrossIATARoutes: func(_ context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) { + got = q + return nil, nil }, })) - req := httptest.NewRequest(http.MethodGet, "/routes/cross?fromHash=aa&fromIata=YVR&toHash=bb&toIata=YYJ", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d", w.Code) + for _, tt := range []struct { + query string + from, to []string + }{ + {"?fromHash=AA&fromIata=yvr&toHash=bb&toIata=YYJ", []string{"YVR"}, []string{"YYJ"}}, + {"?fromHash=aa&toHash=bb&iatas=yvr,YYJ,YVR", []string{"YVR", "YYJ"}, []string{"YVR", "YYJ"}}, + {"?fromHash=aa&toHash=bbcc", nil, nil}, + } { + req := httptest.NewRequest(http.MethodGet, "/routes/cross"+tt.query, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != "[]" { + t.Errorf("%s: got %d %q", tt.query, w.Code, w.Body.String()) + } + if got.FromHash != "aa" || !slices.Equal(got.FromIATAs, tt.from) || !slices.Equal(got.ToIATAs, tt.to) { + t.Errorf("%s: reader got %+v", tt.query, got) + } + } +} + +func TestSearchCrossIATARoutes_TooBroad(t *testing.T) { + r := chi.NewRouter() + r.Get("/routes/cross", searchCrossIATARoutes(stubReader{ + searchCrossIATARoutes: func(context.Context, api.CrossRouteSearch) ([]api.CrossIATARoute, error) { + return nil, api.ErrRouteSearchTooBroad + }, + })) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/routes/cross?fromHash=aa&toHash=bb", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func manyIATAs(n int) string { + codes := make([]string, n) + for i := range codes { + codes[i] = fmt.Sprintf("%c%c%c", 'A'+i/676%26, 'A'+i/26%26, 'A'+i%26) + } + return strings.Join(codes, ",") +} + +func TestRouteSearch_ClientGoneWritesNothing(t *testing.T) { + r := chi.NewRouter() + r.Get("/routes/search", searchKnownRoutes(stubReader{ + searchKnownRoutes: func(context.Context, []string, string, string) ([]api.KnownRoute, error) { + return nil, context.Canceled + }, + })) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/routes/search?from=aa&to=bb", nil)) + if w.Body.Len() != 0 { + t.Errorf("expected no body, got %d %q", w.Code, w.Body.String()) } } diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index 941937e..7027203 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -61,10 +61,8 @@ type stubReader struct { listTraceTags func(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, cursorTag string, limit int32) ([]api.TraceTagSummary, error) getTraceByTag func(ctx context.Context, tag string) (*api.TraceDetail, error) listKnownRoutes func(ctx context.Context, iata string, hopCount int32, cursor time.Time, cursorID int64, limit int32) ([]api.KnownRoute, error) - searchKnownRoutes func(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) - getKnownRoutesByNode func(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) - getCrossIATANeighbors func(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) - searchCrossIATARoutes func(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) + searchKnownRoutes func(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) + searchCrossIATARoutes func(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) getNodesByIDs func(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) } @@ -377,30 +375,16 @@ func (s stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount i return nil, nil } -func (s stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) { +func (s stubReader) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) { if s.searchKnownRoutes != nil { - return s.searchKnownRoutes(ctx, iata, fromHash, toHash) + return s.searchKnownRoutes(ctx, iatas, fromHash, toHash) } return nil, nil } -func (s stubReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) { - if s.getKnownRoutesByNode != nil { - return s.getKnownRoutesByNode(ctx, iata, nodeID) - } - return nil, nil -} - -func (s stubReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) { - if s.getCrossIATANeighbors != nil { - return s.getCrossIATANeighbors(ctx, nodeID, iata) - } - return nil, nil -} - -func (s stubReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) { +func (s stubReader) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) { if s.searchCrossIATARoutes != nil { - return s.searchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA) + return s.searchCrossIATARoutes(ctx, q) } return nil, nil } diff --git a/internal/api/info.go b/internal/api/info.go index 2a6b65d..03ba200 100644 --- a/internal/api/info.go +++ b/internal/api/info.go @@ -3,9 +3,46 @@ package api -// Info is the public server description the mobile app checks before using a server. +import ( + "strconv" + "strings" +) + +// Client floors this server code needs. Bump the matching one when a release breaks older +// clients; config can raise them, never lower them. Empty means no requirement. +const ( + MinAppVersion = "" // BEACON Mobile + MinWebVersion = "2.0.3" // Beacon Web: batched route search +) + +// Info is the public server description clients check before using a server. type Info struct { // MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement. MinAppVersion *string `json:"minAppVersion" example:"0.1.1"` + // MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); null means no requirement. + MinWebVersion *string `json:"minWebVersion" example:"2.0.3"` ServerVersion string `json:"serverVersion" example:"2.0.2"` } + +// HigherVersion returns the newer of two X.Y.Z versions; empty sorts oldest. +func HigherVersion(a, b string) string { + if versionLess(a, b) { + return b + } + return a +} + +func versionLess(a, b string) bool { + if a == "" || b == "" { + return a == "" && b != "" + } + pa, pb := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + x, _ := strconv.Atoi(pa[i]) + y, _ := strconv.Atoi(pb[i]) + if x != y { + return x < y + } + } + return len(pa) < len(pb) +} diff --git a/internal/api/info_test.go b/internal/api/info_test.go new file mode 100644 index 0000000..067dea1 --- /dev/null +++ b/internal/api/info_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package api + +import ( + "regexp" + "testing" +) + +func TestHigherVersion(t *testing.T) { + for _, tc := range []struct{ a, b, want string }{ + {"", "", ""}, + {"2.0.3", "", "2.0.3"}, + {"", "2.0.3", "2.0.3"}, + {"2.0.3", "2.0.10", "2.0.10"}, + {"2.1.0", "2.0.9", "2.1.0"}, + {"10.0.0", "9.9.9", "10.0.0"}, + {"2.0.3", "2.0.3", "2.0.3"}, + } { + if got := HigherVersion(tc.a, tc.b); got != tc.want { + t.Errorf("HigherVersion(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want) + } + } +} + +// Clients reject anything but plain X.Y.Z, so a typo here would lock them all out. +func TestClientFloorsAreValid(t *testing.T) { + re := regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + for name, v := range map[string]string{"MinAppVersion": MinAppVersion, "MinWebVersion": MinWebVersion} { + if v != "" && !re.MatchString(v) { + t.Errorf("%s = %q, want X.Y.Z or empty", name, v) + } + } +} diff --git a/internal/api/reader.go b/internal/api/reader.go index 15b8693..2bd817e 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -229,21 +229,14 @@ type Reader interface { // A nonzero cursorID breaks ties within the cursor's millisecond. ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, cursorID int64, limit int32) ([]KnownRoute, error) - // SearchKnownRoutes returns known routes containing a path from source to destination hash. - SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]KnownRoute, error) + // SearchKnownRoutes returns known routes in any of iatas (empty means all) running from a + // source to a destination hash prefix, trimmed to that span. + SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]KnownRoute, error) // GetNodeNeighbors returns the neighbors of a node ordered by most recently seen. GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]NodeNeighbor, error) - // GetKnownRoutesByNode returns all known routes in a given IATA that contain - // the specified node UUID anywhere in their hop sequence. - GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]KnownRoute, error) - - // GetCrossIATANeighbors returns neighbors of a node that were observed in a - // different IATA — indicating a potential cross-IATA radio link. - GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]NodeNeighbor, error) - // SearchCrossIATARoutes finds routes that cross IATA boundaries between - // a source node/IATA and a destination node/IATA. - SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]CrossIATARoute, error) + // a source and destination hash; see CrossRouteSearch. + SearchCrossIATARoutes(ctx context.Context, q CrossRouteSearch) ([]CrossIATARoute, error) } diff --git a/internal/api/router/admin_config_test.go b/internal/api/router/admin_config_test.go index 9b57b51..9d57628 100644 --- a/internal/api/router/admin_config_test.go +++ b/internal/api/router/admin_config_test.go @@ -56,7 +56,7 @@ func TestAdminConfig(t *testing.T) { if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { t.Fatal(err) } - expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}, "mobile": map[string]any{"min_app_version": ""}} + expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}, "mobile": map[string]any{"min_app_version": ""}, "web": map[string]any{"min_web_version": ""}} if !reflect.DeepEqual(body, expected) { t.Fatalf("unexpected config shape or values: %v", body) } diff --git a/internal/api/router/options.go b/internal/api/router/options.go index 9145091..971a144 100644 --- a/internal/api/router/options.go +++ b/internal/api/router/options.go @@ -24,6 +24,7 @@ type Options struct { Scopes handlers.ScopeMembership // region-filtered scope lists; nil lists none RollupRetention time.Duration // longest historical stats window MinAppVersion string // empty means no mobile app requirement + MinWebVersion string // empty means no Beacon Web requirement ServerVersion string // AdminRoutes mounts operator-only subrouters at literal paths such as // "/accounts". Each feature owns its dependencies, methods and handlers. diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 45d7a88..f9d1f53 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -27,7 +27,7 @@ import ( // // /ws → WebSocket (public in v1) // /api/v1/ → public group -// /info → server info for the mobile app +// /info → server info and minimum client versions // /packets → packets subrouter // /nodes → nodes subrouter // /brokers → brokers subrouter @@ -71,11 +71,15 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options) }, Ingest: api.AdminIngestConfig{BrokerCount: len(workers)}, Mobile: api.AdminMobileConfig{MinAppVersion: opts.MinAppVersion}, + Web: api.AdminWebConfig{MinWebVersion: opts.MinWebVersion}, } info := api.Info{ServerVersion: opts.ServerVersion} if opts.MinAppVersion != "" { info.MinAppVersion = &opts.MinAppVersion } + if opts.MinWebVersion != "" { + info.MinWebVersion = &opts.MinWebVersion + } runtimeConfig := mw.NewRuntimeConfig(adminConfig, []string{"Retry-After"}) r.Use(runtimeConfig.CORS) diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go index bcc1b70..7ee62bc 100644 --- a/internal/api/router/router_test.go +++ b/internal/api/router/router_test.go @@ -93,10 +93,11 @@ func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string, // Old servers 404 here and the app reads that as "no requirement", so the route must stay public. func TestInfoRoute(t *testing.T) { - handler := New(nil, nil, nil, Options{MinAppVersion: "0.1.1", ServerVersion: "2.0.2"}) + handler := New(nil, nil, nil, Options{MinAppVersion: "0.1.1", MinWebVersion: "2.0.3", ServerVersion: "2.0.2"}) response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/info", nil)) - if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"minAppVersion":"0.1.1"`) { + if body := response.Body.String(); response.Code != http.StatusOK || !strings.Contains(body, `"minAppVersion":"0.1.1"`) || + !strings.Contains(body, `"minWebVersion":"2.0.3"`) { t.Fatalf("info: %d %s", response.Code, response.Body.String()) } } diff --git a/internal/api/routes.go b/internal/api/routes.go index 3f1bee1..1516b55 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -3,7 +3,23 @@ package api -import "github.com/google/uuid" +import ( + "errors" + + "github.com/google/uuid" +) + +// ErrRouteSearchTooBroad means an endpoint hash matches too widely to search. +var ErrRouteSearchTooBroad = errors.New("hash matches too many areas; use a longer hash or fewer iatas") + +// CrossRouteSearch finds routes from FromHash (resolved in FromIATAs) to ToHash +// (resolved in ToIATAs) that cross between two different IATAs. Empty lists mean every IATA. +type CrossRouteSearch struct { + FromHash string + ToHash string + FromIATAs []string + ToIATAs []string +} // RouteHop is a single resolved hop in a known route. type RouteHop struct { diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index e00b124..65da714 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -160,14 +160,6 @@ func (s *stubReader) GetTraceByTag(_ context.Context, _ string) (*api.TraceDetai return nil, nil } -func (s *stubReader) GetKnownRoutesByNode(_ context.Context, _ string, _ uuid.UUID) ([]api.KnownRoute, error) { - return nil, nil -} - -func (s *stubReader) GetCrossIATANeighbors(_ context.Context, _ uuid.UUID, _ string) ([]api.NodeNeighbor, error) { - return nil, nil -} - func (s *stubReader) ListChannels(_ context.Context, _ int32, _ []byte, _ []string, _ *bool, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) { return api.ChannelPage{}, nil } @@ -212,11 +204,11 @@ func (s *stubReader) ListKnownRoutes(_ context.Context, _ string, _ int32, _ tim return nil, nil } -func (s *stubReader) SearchKnownRoutes(_ context.Context, _, _, _ string) ([]api.KnownRoute, error) { +func (s *stubReader) SearchKnownRoutes(_ context.Context, _ []string, _, _ string) ([]api.KnownRoute, error) { return nil, nil } -func (s *stubReader) SearchCrossIATARoutes(_ context.Context, _, _, _, _ string) ([]api.CrossIATARoute, error) { +func (s *stubReader) SearchCrossIATARoutes(_ context.Context, _ api.CrossRouteSearch) ([]api.CrossIATARoute, error) { return nil, nil } diff --git a/internal/cache/reader.go b/internal/cache/reader.go index e032daf..47b0e2d 100644 --- a/internal/cache/reader.go +++ b/internal/cache/reader.go @@ -294,16 +294,6 @@ func (cr *CachedReader) GetTraceByTag(ctx context.Context, tag string) (*api.Tra return cr.inner.GetTraceByTag(ctx, tag) } -// GetKnownRoutesByNode implements [api.Reader]. -func (cr *CachedReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) { - return cr.inner.GetKnownRoutesByNode(ctx, iata, nodeID) -} - -// GetCrossIATANeighbors implements [api.Reader]. -func (cr *CachedReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) { - return cr.inner.GetCrossIATANeighbors(ctx, nodeID, iata) -} - // ListChannels implements [api.Reader]. func (cr *CachedReader) ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) { return cr.inner.ListChannels(ctx, limit, hash, iatas, keyKnown, cursor, pageCursor) @@ -360,8 +350,8 @@ func (cr *CachedReader) ListKnownRoutes(ctx context.Context, iata string, hopCou } // SearchKnownRoutes implements [api.Reader]. -func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) { - return cr.inner.SearchKnownRoutes(ctx, iata, fromHash, toHash) +func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) { + return cr.inner.SearchKnownRoutes(ctx, iatas, fromHash, toHash) } // Precise cursor/window combinations are intentionally passed through, like route lists. @@ -370,8 +360,8 @@ func (cr *CachedReader) GetRouteEvidence(ctx context.Context, iata, key string, } // SearchCrossIATARoutes implements [api.Reader]. -func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) { - return cr.inner.SearchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA) +func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) { + return cr.inner.SearchCrossIATARoutes(ctx, q) } // ListTraceTags implements [api.Reader]. diff --git a/internal/config/config.go b/internal/config/config.go index d51d0cc..70985d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { Observers ObserversConfig `yaml:"observers"` Analytics AnalyticsConfig `yaml:"analytics"` Mobile MobileConfig `yaml:"mobile"` + Web WebConfig `yaml:"web"` } // MobileConfig holds settings the BEACON Mobile app reads from /info. @@ -53,6 +54,12 @@ type MobileConfig struct { MinAppVersion string `yaml:"min_app_version"` } +// WebConfig holds settings Beacon Web reads from /info. +type WebConfig struct { + // MinWebVersion raises the built-in Beacon Web floor (X.Y.Z); it can't lower it. + MinWebVersion string `yaml:"min_web_version"` +} + // BackupConfig enables protected downloads. Disabled by default. type BackupConfig struct { Enabled bool `yaml:"enabled"` @@ -458,7 +465,7 @@ func Load(path string) (*Config, error) { if err := cfg.validateScopes(); err != nil { return nil, err } - if err := cfg.validateMobile(); err != nil { + if err := cfg.validateClientVersions(); err != nil { return nil, err } if cfg.Nodes.MarkForeign && !cfg.MeshMapper.Zones.Enabled && !cfg.hasBorderFile() { @@ -492,11 +499,16 @@ func (c *Config) validateAPIKey() error { var appVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) -// validateMobile keeps min_app_version to plain X.Y.Z so every app can compare it. -func (c *Config) validateMobile() error { - c.Mobile.MinAppVersion = strings.TrimSpace(c.Mobile.MinAppVersion) - if v := c.Mobile.MinAppVersion; v != "" && !appVersionPattern.MatchString(v) { - return fmt.Errorf("mobile.min_app_version must be X.Y.Z (digits only), got %q", v) +// validateClientVersions keeps minimum client versions to plain X.Y.Z so every client can compare them. +func (c *Config) validateClientVersions() error { + for _, f := range []struct { + key string + v *string + }{{"mobile.min_app_version", &c.Mobile.MinAppVersion}, {"web.min_web_version", &c.Web.MinWebVersion}} { + *f.v = strings.TrimSpace(*f.v) + if *f.v != "" && !appVersionPattern.MatchString(*f.v) { + return fmt.Errorf("%s must be X.Y.Z (digits only), got %q", f.key, *f.v) + } } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5d7098..85e0080 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -332,3 +332,29 @@ func TestLoadMinAppVersion(t *testing.T) { }) } } + +func TestLoadMinWebVersion(t *testing.T) { + for _, tc := range []struct { + name, yaml, want string + wantError bool + }{ + {"omitted", "{}", "", false}, + {"valid", "web: {min_web_version: ' 2.0.4 '}", "2.0.4", false}, + {"v prefix", "web: {min_web_version: 'v2.0.4'}", "", true}, + {"two parts", "web: {min_web_version: '2.0'}", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if (err != nil) != tc.wantError { + t.Fatalf("Load error = %v, want error = %v", err, tc.wantError) + } + if err == nil && cfg.Web.MinWebVersion != tc.want { + t.Fatalf("min_web_version = %q, want %q", cfg.Web.MinWebVersion, tc.want) + } + }) + } +}