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
This commit is contained in:
MrAlders0n
2026-10-09 22:17:23 -04:00
committed by GitHub
parent 768889243c
commit 9bbf8efee5
37 changed files with 1625 additions and 591 deletions
+4 -2
View File
@@ -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),
+7 -4
View File
@@ -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
+17 -1
View File
@@ -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.
+16 -2
View File
@@ -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.
+2 -2
View File
@@ -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)
}
}
+21
View File
@@ -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())
}
}
}
@@ -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);
+62 -24
View File
@@ -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;
+343 -174
View File
@@ -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 {
+192
View File
@@ -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)
}
})
}
-46
View File
@@ -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")
}
}
+89 -29
View File
@@ -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.
+18 -6
View File
@@ -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
+245 -88
View File
@@ -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,
+62 -22
View File
@@ -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"},
+62 -22
View File
@@ -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"
+52 -17
View File
@@ -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
+8 -8
View File
@@ -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
)
+14 -14
View File
@@ -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=
+6 -1
View File
@@ -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 {
+4 -3
View File
@@ -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
+3 -3
View File
@@ -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()
+142 -36
View File
@@ -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
}
+90 -17
View File
@@ -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())
}
}
+6 -22
View File
@@ -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
}
+38 -1
View File
@@ -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)
}
+35
View File
@@ -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)
}
}
}
+5 -12
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+1
View File
@@ -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.
+5 -1
View File
@@ -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)
+3 -2
View File
@@ -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())
}
}
+17 -1
View File
@@ -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 {
+2 -10
View File
@@ -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
}
+4 -14
View File
@@ -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].
+18 -6
View File
@@ -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
}
+26
View File
@@ -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)
}
})
}
}