add routes api

any route observer where all hops are high confidence
are now stored and available at /routes

you can also search for a src dest route and it will
return any recored route subset that can link the src
and dest hash
This commit is contained in:
Enot (ded) Skelly
2026-06-05 15:57:08 -07:00
parent f0c57fde45
commit 65cb8c9d61
11 changed files with 407 additions and 0 deletions
+18
View File
@@ -335,6 +335,24 @@ CREATE TABLE channel_messages (
CREATE INDEX idx_channel_messages_channel ON channel_messages(channel_id, sent_at DESC);
CREATE INDEX idx_channel_messages_sent_brin ON channel_messages USING BRIN (sent_at);
-- ============================================================
-- ROUTES
-- ============================================================
CREATE TABLE known_routes (
id BIGSERIAL PRIMARY KEY,
node_ids UUID[] NOT NULL, -- resolved node UUIDs in hop order
hash_prefix BYTEA[] NOT NULL, -- raw hash bytes in hop order for prefix matching
iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE,
hop_count INT NOT NULL,
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (node_ids, iata)
);
CREATE INDEX idx_known_routes_iata ON known_routes(iata);
CREATE INDEX idx_known_routes_hop_count ON known_routes(iata, hop_count);
-- ============================================================
-- MATERIALIZED VIEWS
-- ============================================================
+33
View File
@@ -821,6 +821,39 @@ GROUP BY p.trace_tag
ORDER BY MAX(p.last_heard_at) DESC
LIMIT $6;
-- ============================================================
-- ROUTES
-- ============================================================
-- name: UpsertKnownRoute :exec
-- Inserts or updates a known route (all hops resolved to high confidence).
-- node_ids and hash_prefix are ordered arrays of the resolved node UUIDs and
-- their hash bytes. last_seen is bumped on conflict.
INSERT INTO known_routes (node_ids, hash_prefix, iata, hop_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (node_ids, iata) DO UPDATE SET
last_seen = NOW();
-- name: ListKnownRoutes :many
-- Returns known routes filtered by IATA, ordered by most recently seen.
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
FROM known_routes
WHERE ($1 = '' OR iata = $1)
AND ($2 = 0 OR hop_count = $2)
AND ($3 = 0 OR id < $3)
ORDER BY last_seen DESC
LIMIT $4;
-- name: SearchKnownRoutes :many
-- Returns known routes containing a subsequence from source to destination hash prefix.
-- Matches routes where source hash appears before destination hash in the hash_prefix array.
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
FROM known_routes
WHERE iata = $1
AND hash_prefix @> ARRAY[$2::bytea]
AND hash_prefix @> ARRAY[$3::bytea]
ORDER BY hop_count ASC, last_seen DESC;
-- ============================================================
-- HELPERS
-- ============================================================
+77
View File
@@ -0,0 +1,77 @@
package db
import (
"context"
"encoding/hex"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
)
func (s *Store) UpsertKnownRoute(ctx context.Context, nodeIDs []uuid.UUID, hashPrefix [][]byte, iata string, hopCount int32) error {
return s.q.UpsertKnownRoute(ctx, sqlc.UpsertKnownRouteParams{
NodeIds: nodeIDs,
HashPrefix: hashPrefix,
Iata: iata,
HopCount: int32(hopCount),
})
}
func (s *Store) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor int64, limit int32) ([]api.KnownRoute, error) {
rows, err := s.q.ListKnownRoutes(ctx, sqlc.ListKnownRoutesParams{
Column1: iata,
Column2: hopCount,
Column3: cursor,
Limit: limit,
})
if err != nil {
return nil, err
}
return toKnownRoutes(rows), nil
}
func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
fromBytes, err := hex.DecodeString(fromHash)
if err != nil {
return nil, err
}
toBytes, err := hex.DecodeString(toHash)
if err != nil {
return nil, err
}
rows, err := s.q.SearchKnownRoutes(ctx, sqlc.SearchKnownRoutesParams{
Iata: iata,
Column2: fromBytes,
Column3: toBytes,
})
if err != nil {
return nil, err
}
return toKnownRoutes(rows), nil
}
func toKnownRoutes(rows []sqlc.KnownRoute) []api.KnownRoute {
items := make([]api.KnownRoute, 0, len(rows))
for _, r := range rows {
hops := make([]api.RouteHop, 0, len(r.NodeIds))
for i, nodeID := range r.NodeIds {
hop := api.RouteHop{
NodeID: nodeID,
}
if i < len(r.HashPrefix) {
hop.HashBytes = hex.EncodeToString(r.HashPrefix[i])
}
hops = append(hops, hop)
}
items = append(items, api.KnownRoute{
ID: r.ID,
IATA: r.Iata,
HopCount: r.HopCount,
Hops: hops,
FirstSeen: r.FirstSeen.Time.UnixMilli(),
LastSeen: r.LastSeen.Time.UnixMilli(),
})
}
return items
}
+10
View File
@@ -49,6 +49,16 @@ type IataCode struct {
AddedAt pgtype.Timestamptz `json:"added_at"`
}
type KnownRoute struct {
ID int64 `json:"id"`
NodeIds []uuid.UUID `json:"node_ids"`
HashPrefix [][]byte `json:"hash_prefix"`
Iata string `json:"iata"`
HopCount int32 `json:"hop_count"`
FirstSeen pgtype.Timestamptz `json:"first_seen"`
LastSeen pgtype.Timestamptz `json:"last_seen"`
}
type MvHourlyIataStat struct {
Iata string `json:"iata"`
Hour pgtype.Timestamptz `json:"hour"`
+127
View File
@@ -1713,6 +1713,57 @@ func (q *Queries) ListIATAs(ctx context.Context) ([]IataCode, error) {
return items, nil
}
const listKnownRoutes = `-- name: ListKnownRoutes :many
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
FROM known_routes
WHERE ($1 = '' OR iata = $1)
AND ($2 = 0 OR hop_count = $2)
AND ($3 = 0 OR id < $3)
ORDER BY last_seen DESC
LIMIT $4
`
type ListKnownRoutesParams struct {
Column1 interface{} `json:"column_1"`
Column2 interface{} `json:"column_2"`
Column3 interface{} `json:"column_3"`
Limit int32 `json:"limit"`
}
// Returns known routes filtered by IATA, ordered by most recently seen.
func (q *Queries) ListKnownRoutes(ctx context.Context, arg ListKnownRoutesParams) ([]KnownRoute, error) {
rows, err := q.db.Query(ctx, listKnownRoutes,
arg.Column1,
arg.Column2,
arg.Column3,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []KnownRoute{}
for rows.Next() {
var i KnownRoute
if err := rows.Scan(
&i.ID,
&i.NodeIds,
&i.HashPrefix,
&i.Iata,
&i.HopCount,
&i.FirstSeen,
&i.LastSeen,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listMessagesAfterID = `-- name: ListMessagesAfterID :many
SELECT DISTINCT ON (cm.id) cm.id, cm.channel_id, cm.packet_hash, cm.sender_name, cm.sender_pubkey, cm.content, cm.sent_at, encode(cm.packet_hash, 'hex') as packet_hash_hex, c.channel_hash,
(SELECT COUNT(*) FROM packet_observations po2 WHERE po2.packet_hash = cm.packet_hash) AS observation_count
@@ -2650,6 +2701,51 @@ func (q *Queries) ResolvePathHashes(ctx context.Context, arg ResolvePathHashesPa
return items, nil
}
const searchKnownRoutes = `-- name: SearchKnownRoutes :many
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
FROM known_routes
WHERE iata = $1
AND hash_prefix @> ARRAY[$2::bytea]
AND hash_prefix @> ARRAY[$3::bytea]
ORDER BY hop_count ASC, last_seen DESC
`
type SearchKnownRoutesParams struct {
Iata string `json:"iata"`
Column2 []byte `json:"column_2"`
Column3 []byte `json:"column_3"`
}
// Returns known routes containing a subsequence from source to destination hash prefix.
// Matches routes where source hash appears before destination hash in the hash_prefix array.
func (q *Queries) SearchKnownRoutes(ctx context.Context, arg SearchKnownRoutesParams) ([]KnownRoute, error) {
rows, err := q.db.Query(ctx, searchKnownRoutes, arg.Iata, arg.Column2, arg.Column3)
if err != nil {
return nil, err
}
defer rows.Close()
items := []KnownRoute{}
for rows.Next() {
var i KnownRoute
if err := rows.Scan(
&i.ID,
&i.NodeIds,
&i.HashPrefix,
&i.Iata,
&i.HopCount,
&i.FirstSeen,
&i.LastSeen,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setChannelKeyKnown = `-- name: SetChannelKeyKnown :exec
UPDATE channels SET key_known = TRUE
WHERE channel_hash = $1 AND key_fingerprint = $2
@@ -2874,6 +2970,37 @@ func (q *Queries) UpsertIATADetails(ctx context.Context, arg UpsertIATADetailsPa
return err
}
const upsertKnownRoute = `-- name: UpsertKnownRoute :exec
INSERT INTO known_routes (node_ids, hash_prefix, iata, hop_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (node_ids, iata) DO UPDATE SET
last_seen = NOW()
`
type UpsertKnownRouteParams struct {
NodeIds []uuid.UUID `json:"node_ids"`
HashPrefix [][]byte `json:"hash_prefix"`
Iata string `json:"iata"`
HopCount int32 `json:"hop_count"`
}
// ============================================================
// ROUTES
// ============================================================
// Inserts or updates a known route (all hops resolved to high confidence).
// node_ids and hash_prefix are ordered arrays of the resolved node UUIDs and
// their hash bytes. last_seen is bumped on conflict.
func (q *Queries) UpsertKnownRoute(ctx context.Context, arg UpsertKnownRouteParams) error {
_, err := q.db.Exec(ctx, upsertKnownRoute,
arg.NodeIds,
arg.HashPrefix,
arg.Iata,
arg.HopCount,
)
return err
}
const upsertNode = `-- name: UpsertNode :one
INSERT INTO nodes (public_key, node_type, name, latitude, longitude, location_source, last_advert_at, last_seen, radio_freq_mhz, radio_sf, radio_bw_khz)
+92
View File
@@ -0,0 +1,92 @@
package handlers
import (
"net/http"
"strconv"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
)
// RoutesRouter mounts all /routes routes onto a subrouter.
//
// GET /routes → listKnownRoutes
// GET /routes/search → searchKnownRoutes
func RoutesRouter(reader api.Reader) http.Handler {
r := chi.NewRouter()
r.Get("/", listKnownRoutes(reader))
r.Get("/search", searchKnownRoutes(reader))
return r
}
// listKnownRoutes godoc
//
// @Summary List known routes
// @Tags Routes
// @Produce json
// @Param iata query string false "Filter by IATA code"
// @Param hopCount query int false "Filter by exact hop count"
// @Param cursor query int false "Route ID of last item for pagination"
// @Param limit query int false "Max results (default 50)"
// @Success 200 {object} []api.KnownRoute
// @Failure 500 {object} handlers.APIError
// @Router /routes [get]
func listKnownRoutes(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
iata := r.URL.Query().Get("iata")
var hopCount int32
if v := r.URL.Query().Get("hopCount"); v != "" {
if h, err := strconv.ParseInt(v, 10, 32); err == nil {
hopCount = int32(h)
}
}
var cursor int64
if v := r.URL.Query().Get("cursor"); v != "" {
if c, err := strconv.ParseInt(v, 10, 64); err == nil {
cursor = c
}
}
var limit int32 = 50
if v := r.URL.Query().Get("limit"); v != "" {
if l, err := strconv.ParseInt(v, 10, 32); err == nil {
limit = int32(l)
}
}
routes, err := reader.ListKnownRoutes(r.Context(), iata, hopCount, cursor, limit)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respond(w, http.StatusOK, routes)
}
}
// 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]
func searchKnownRoutes(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
iata := r.URL.Query().Get("iata")
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
if iata == "" || from == "" || to == "" {
respondError(w, http.StatusBadRequest, "iata, 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")
return
}
respond(w, http.StatusOK, routes)
}
}
+4
View File
@@ -137,4 +137,8 @@ type Reader interface {
ListTraceTags(ctx context.Context, iatas []string, scope string, since, until time.Time, cursor time.Time, limit int32) ([]TraceTagSummary, error)
// GetTraceByTag returns all packets for a given trace tag with resolved routes.
GetTraceByTag(ctx context.Context, tag string) (*TraceDetail, error)
// ListKnownRoutes returns known routes filtered by IATA and optional hop count.
ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor 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)
}
+1
View File
@@ -71,6 +71,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP
r.Mount("/messages", handlers.MessagesRouter(reader))
r.Mount("/iatas", handlers.IATAsRouter(reader))
r.Mount("/regions", handlers.RegionsRouter(reader))
r.Mount("/routes", handlers.RoutesRouter(reader))
r.Mount("/scopes", handlers.ScopesRouter(reader))
r.Mount("/stats", handlers.StatsRouter(reader))
r.Mount("/traces", handlers.TracesRouter(reader))
+21
View File
@@ -0,0 +1,21 @@
package api
import "github.com/google/uuid"
// RouteHop is a single resolved hop in a known route.
type RouteHop struct {
NodeID uuid.UUID `json:"nodeId"`
HashBytes string `json:"hashBytes"` // hex-encoded hash prefix
Node *ResolvedNode `json:"node,omitempty"` // populated when node details are available
}
// KnownRoute is a fully resolved path through the mesh where all hops
// have been confirmed as high confidence.
type KnownRoute struct {
ID int64 `json:"id"`
IATA string `json:"iata"`
HopCount int32 `json:"hopCount"`
Hops []RouteHop `json:"hops"`
FirstSeen int64 `json:"firstSeen"` // epoch ms
LastSeen int64 `json:"lastSeen"` // epoch ms
}
+3
View File
@@ -142,6 +142,9 @@ type DB interface {
// Called when a TRANSPORT_FLOOD packet is observed, linking the observer to
// the matched regional transport scope.
UpsertObserverScope(ctx context.Context, observerID uuid.UUID, scopeID int32) error
// UpsertKnownRoute stores a fully resolved path where all hops have high confidence.
UpsertKnownRoute(ctx context.Context, nodeIDs []uuid.UUID, hashPrefix [][]byte, iata string, hopCount int32) error
}
// ChannelKeyStore is a read-only view of the channel keys loaded from config.
+21
View File
@@ -596,6 +596,27 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
resolvedIDs = append(resolvedIDs, e.NodeID)
}
}
hashes := packet.PathHashes()
if len(hashes) > 0 && resolved != nil {
allHigh := true
nodeIDs := make([]uuid.UUID, 0, len(hashes))
hashPrefixes := make([][]byte, 0, len(hashes))
for _, hash := range hashes {
key := hex.EncodeToString(hash)
entries := resolved[key]
if len(entries) != 1 {
allHigh = false
break
}
nodeIDs = append(nodeIDs, entries[0].NodeID)
hashPrefixes = append(hashPrefixes, hash)
}
if allHigh && len(nodeIDs) > 0 {
if err := w.db.UpsertKnownRoute(ctx, nodeIDs, hashPrefixes, iata, int32(len(nodeIDs))); err != nil {
log.Printf("ingest[%s]: failed to upsert known route: %v", w.cfg.BrokerName, err)
}
}
}
w.runCapabilityDetection(ctx, packet.PayloadType(), packet.PathHashSize(), resolvedIDs)
if inserted {
w.handlePayloadTypeSideEffects(ctx, packet, iata, packetHash[:], radio, scopeID)