Files
n30nex a9b8c60a97 feat: stage current preview on dev for dev1 evaluation
Port the qualified server preview at 84ac3c8781 onto
org dev c7209b7043. Preserve upstream changes and
branch policies; keep the independent private Collector service separate.

Preview-Version: 2.2.0-n30nex.1
Preview-Revision: 84ac3c8781
2026-10-04 16:31:48 -04:00

543 lines
21 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package ingest subscribes to a single MeshCore MQTT broker and drives the
// observation pipeline described in the design doc.
//
// Call Start() once per broker in a dedicated goroutine. Both broker instances
// share the same *hub.Hub and *DB handle so dedup and fan-out are centralised.
//
// Pipeline per incoming /packets message:
// 1. Parse topic → extract IATA + publisher pubkey
// 2. Decode hex payload via meshcore-go PacketFromBytes
// 3. Compute content-based packet hash (PacketHash)
// 4. Upsert observers + observer_brokers + iata_codes
// 5. Upsert packets row (ON CONFLICT bump last_heard_at)
// 6. Insert packet_observations (ON CONFLICT DO NOTHING for cross-broker dedup)
// 7. Match transport codes against known scopes (TRANSPORT_FLOOD/DIRECT only)
// 8. If INSERT succeeded: capability detection, payload-type side effects, fan-out
//
// Pipeline per incoming /status message:
// 1. Parse topic → extract publisher pubkey
// 2. Upsert observers row (status_metadata, last_status_at, observer_type, etc.)
// 3. Fan out observerStatus event to hub
//
// Pipeline per incoming /neighbors message (sent every 12-368h if mqtt.neighbors
// is enabled on the observer):
// 1. Parse topic → extract IATA + publisher pubkey
// 2. Record the observer's own "self" region scope (always known, unconditional write)
// 3. For each reported zero-hop neighbor: resolve both sides via GetNodeByPubkey
// (skip the entry if either the observer or the neighbor has no node row yet --
// i.e. hasn't advertised) and upsert a node_neighbors edge. Absence from the
// report means nothing (10KB message cap can truncate a highly-connected node's
// list), so this never deletes existing edges. A neighbor's region_scope is only
// written when status == "responded"; "timeout" (OTA scope queries are flaky)
// leaves any previously known scope untouched.
package ingest
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"sync/atomic"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/google/uuid"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/borders"
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
"github.com/MeshCore-Beacon/beacon-server/internal/keystore"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
// Config holds the connection parameters for one broker.
type Config struct {
// LocalBorders is the same live classifier used by node API reads.
LocalBorders *borders.Live
// BrokerName is a short human-readable label ("mqtt1", "mqtt2") used in
// log messages and stored in packet_observations.source_broker.
BrokerName string
// URL is the full broker WebSocket URL, e.g. "wss://mqtt1.meshcore.ca/mqtt"
URL string
Username string
Password string
// AllowedIATAs is a pre-computed set of IATA codes derived from the ingest
// filter config. If non-nil, packets from IATAs not in this set are dropped.
// Build this set at startup from IngestFilterConfig using iatadb.
AllowedIATAs map[string]struct{}
// TelemetryResolution controls how frequently telemetry snapshots are stored.
// Status messages within the same window are deduplicated via ON CONFLICT.
// Defaults to 1 hour if zero.
TelemetryResolution time.Duration
}
// DB is the minimal database interface the ingest pipeline depends on.
// Wire in your real *pgxpool.Pool implementation here.
type DB interface {
// UpsertObserver upserts the observers row keyed on pubkey, and returns
// the Observer ID, Display Name and an error if any.
UpsertObserver(ctx context.Context, pubkey []byte, iata string) (uuid.UUID, string, error)
// UpsertObserverBroker records that this observer was seen on brokerName.
UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string, isPacket bool) error
// UpsertIATA auto-creates an iata_codes row if it doesn't exist yet.
UpsertIATA(ctx context.Context, iata string) error
// UpsertPacket inserts or bumps the packets row. Returns (isNew, error).
UpsertPacket(ctx context.Context, p UpsertPacketParams) (bool, error)
// SetPacketDecrypted marks a packet as decrypted in the DB
SetPacketDecrypted(ctx context.Context, hash []byte) error
// InsertObservation inserts a packet_observations row and returns the packet's
// observation count; inserted=false means ON CONFLICT DO NOTHING fired.
InsertObservation(ctx context.Context, o InsertObservationParams) (inserted bool, observationCount int64, err error)
// SetNodeCapability flips supports_multibyte_paths or supports_multibyte_traces
// for a node, never downgrading an existing TRUE.
SetNodeCapability(ctx context.Context, nodeID uuid.UUID, paths, traces bool) error
// SetNodeDefaultScope records the most recent scope attached to a node advert
// scopes are matched against configured regional transport scopes
SetNodeDefaultScope(ctx context.Context, nodeID uuid.UUID, scopeID int32) error
// UpsertNode upserts a nodes row from an advert payload.
UpsertNode(ctx context.Context, n UpsertNodeParams, r RadioSettings) (uuid.UUID, error)
// GetNodeByPubkey returns the node ID for a given public key, or an
// error (sql.ErrNoRows-equivalent) if no node exists yet for that key.
// Used for observer-sourced neighbor writes (e.g. DISCOVER_RESP), where
// the observer's own node may not exist until it has advertised.
GetNodeByPubkey(ctx context.Context, pubkey []byte) (uuid.UUID, error)
// GetNodesByIDs returns resolved node details (name, pubkey, coords) for a set of node
// IDs. Used with GetNodeByPubkey to resolve an ADVERT's exact-match source endpoint.
GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error)
// UpsertNodeIATA upserts a node_iatas row.
UpsertNodeIATA(ctx context.Context, nodeID uuid.UUID, iata string) error
// UpsertNodeShortID upserts a node_short_ids row for path resolution.
UpsertNodeShortID(ctx context.Context, nodeID uuid.UUID, iata string, prefix4 []byte) error
// InsertChannelMessage stores a decrypted group text message. Returns the inserted message, nil for a duplicate, and an error.
InsertChannelMessage(ctx context.Context, m InsertChannelMessageParams) (*InsertedChannelMessage, error)
// UpdateObserverStatus updates the observer row from a /status message. Returns the OberserID
// and any error.
UpdateObserverStatus(ctx context.Context, p UpdateObserverStatusParams) (uuid.UUID, error)
// GetObserverLastIATA returns the IATA of the observer's most recent packet, or "" if none.
GetObserverLastIATA(ctx context.Context, observerID uuid.UUID) (string, error)
// InsertObserverTelemetry stores a telemetry snapshot for an observer.
// The caller should truncate reportedAt to the configured resolution before calling.
InsertObserverTelemetry(ctx context.Context, observerID uuid.UUID, reportedAt time.Time, batteryMV *int32, txAirSecs, rxAirSecs *float32, noiseFloor float32, uptimeSeconds int64, queueLen, debugFlags, recvErrors *int32) error
// GetObserverRadio returns the current radio settings for the given observer.
GetObserverRadio(ctx context.Context, observerID uuid.UUID) (RadioSettings, error)
// IsObserverByPubkey returns true if the given public key belongs to a known observer.
IsObserverByPubkey(ctx context.Context, pubkey []byte) bool
// GetObserverScopes returns the list of scope names associated with the given observer.
GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error)
// ResolvePathHashes returns a list of node UUIDs for the given path hash prefixes and IATA.
ResolvePathHashes(ctx context.Context, iata string, hashes [][]byte) (map[string][]api.ResolvedPathEntry, error)
ResolveTracePathHashes(ctx context.Context, iata string, hashes [][]byte) (map[string][]api.ResolvedPathEntry, error)
// ResolveEndpointHashes matches one-byte logical endpoints, including companions.
ResolveEndpointHashes(ctx context.Context, iata string, hashes [][]byte) (map[string][]api.ResolvedPathEntry, error)
// UpsertChannel upserts a channel row by (hash, keyFingerprint) and returns its integer ID.
// Pass nil keyFingerprint to record a hash-only row when the key is unknown.
UpsertChannel(ctx context.Context, channelHash []byte, keyFingerprint []byte, name string, hashtag string) (int, error)
// UpsertChannelHashOnly upserts a hash-only channel row for cases where the
// channel key is unknown. Uses the partial unique index to ensure only one
// hash-only row exists per channel hash. The return value is the channel ID
// but can be safely ignored since unknown-key channels have no messages.
UpsertChannelHashOnly(ctx context.Context, channelHash []byte) (int, error)
// ListUndecryptedGroupTextPackets returns GRP_TXT packets never successfully decrypted --
// used by BackfillChannelMessages to retry them against the current keystore at boot.
ListUndecryptedGroupTextPackets(ctx context.Context) ([]UndecryptedPacket, error)
// ListUndecryptedGroupTextPacketsByHash limits that scan to channels that just gained a key.
ListUndecryptedGroupTextPacketsByHash(ctx context.Context, hashes [][]byte) ([]UndecryptedPacket, error)
// UpsertChannelIATA upserts a channel_iatas row.
UpsertChannelIATA(ctx context.Context, channelHash []byte, iata string, heardAt time.Time) error
// RecordTrace records one hearing of a TRACE packet in trace_iatas and trace_tags.
RecordTrace(ctx context.Context, h TraceHearing) error
// GetTransportScopeByName returns the ID of a transport scope by its normalized name.
GetTransportScopeByName(ctx context.Context, name string) (int32, error)
// UpsertObserverScope records or updates a scope association for an observer.
// 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
// UpsertNodeNeighbor records or updates a neighbor relationship between two nodes.
// nodeID is the advertising node, neighborID is the first-hop forwarder.
// snr is optional (nil when no signal reading is available, the common case).
// regionScope is optional (nil when there's no fresh OTA-queried scope to
// record for this neighbor, e.g. a /neighbors report entry with a failed query).
UpsertNodeNeighbor(ctx context.Context, nodeID, neighborID uuid.UUID, iata string, snr *float32, regionScope *string) error
// UpdateObserverRegionScope records the observer's own OTA-reported region
// scope, from the "self" field of a /neighbors report.
UpdateObserverRegionScope(ctx context.Context, observerID uuid.UUID, regionScope string) error
}
// ChannelKeyStore is a read-only view of the channel keys loaded from config.
type ChannelKeyStore interface {
// GetKey returns all known key entries for the given channel hash byte.
// Returns nil if no keys are known for this hash.
GetKey(channelHash []byte) []keystore.Entry
}
// ScopeStore provides transport scope key lookup for matching TRANSPORT_FLOOD packets.
type ScopeStore interface {
Entries() []scopestore.Entry
}
// Worker holds the dependencies for one broker's ingest loop.
type Worker struct {
log *slog.Logger
cfg Config
db DB
hub *hub.Hub
keys ChannelKeyStore
scopes ScopeStore
client atomic.Pointer[mqtt.Client] // read by /brokers while Start sets it
capabilities capabilityCache
onNodeUpsert func(ctx context.Context, nodeID uuid.UUID)
onObserverUpsert func(ctx context.Context, observerID uuid.UUID)
}
// New creates an ingest Worker. Call Start() to connect and begin processing.
func New(cfg Config, db DB, h *hub.Hub, keys ChannelKeyStore, scopes ScopeStore) *Worker {
return &Worker{cfg: cfg, db: db, hub: h, keys: keys, scopes: scopes,
log: slog.Default().With("component", "ingest", "broker", cfg.BrokerName)}
}
// Start connects to the broker and blocks until ctx is cancelled. It
// reconnects automatically on transient failures using paho's built-in
// reconnect logic.
//
// Intended usage: go worker.Start(ctx)
func (w *Worker) Start(ctx context.Context) {
if w.cfg.URL == "" {
return
}
queue := newMessageQueue(8, 2048, 32<<20, func(ctx context.Context, msg mqtt.Message) {
w.handleMessageContext(ctx, msg)
})
reportDrops := func() {
if n := queue.takeDropped(); n > 0 {
w.log.Error("ingest queue full, messages dropped", "count", n)
}
}
defer func() {
drainCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := queue.close(drainCtx); err != nil {
w.log.Warn("ingest shutdown timed out", "error", err)
}
reportDrops()
w.log.Info("stopped")
}()
// Isolate workers across deployments; Paho reuses this ID on reconnect.
// Keep it alphanumeric and within MQTT 3.1's 23-character client ID limit.
// Auto-ack: sessions are clean, so deferring acks only throttled the broker's inflight window.
opts := mqtt.NewClientOptions().
AddBroker(w.cfg.URL).
SetClientID(rand.Text()[:23]).
SetUsername(w.cfg.Username).
SetPassword(w.cfg.Password).
SetAutoReconnect(true).
SetMaxReconnectInterval(30 * time.Second).
SetKeepAlive(30 * time.Second).
SetPingTimeout(10 * time.Second).
SetConnectTimeout(15 * time.Second).
SetWriteTimeout(10 * time.Second).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetOnConnectHandler(func(c mqtt.Client) {
w.log.Info("connected")
w.subscribe(c, queue)
}).
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
w.log.Warn("connection lost, will reconnect", "error", err)
})
client := mqtt.NewClient(opts)
w.client.Store(&client)
token := client.Connect()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
connected := token.Done()
for {
select {
case <-connected:
if err := token.Error(); err != nil {
w.log.Error("initial connect failed", "error", err)
}
connected = nil
case <-ticker.C:
reportDrops()
case <-ctx.Done():
client.Disconnect(500)
return
}
}
}
func (w *Worker) BrokerName() string {
return w.cfg.BrokerName
}
func (w *Worker) IsConnected() bool {
c := w.client.Load()
if c == nil {
return false
}
return (*c).IsConnected()
}
func (w *Worker) SetCacheInvalidators(onNode, onObserver func(ctx context.Context, id uuid.UUID)) {
w.onNodeUpsert = onNode
w.onObserverUpsert = onObserver
}
// subscribe registers the wildcard topic handler after (re)connect.
func (w *Worker) subscribe(client mqtt.Client, queue *messageQueue) {
// meshcore/{IATA}/{pubkey}/packets
// meshcore/{IATA}/{pubkey}/status
// We do NOT subscribe to /internal (Role 2 access).
tok := client.Subscribe("meshcore/#", 1, func(_ mqtt.Client, msg mqtt.Message) {
queue.enqueue(msg)
})
if tok.Wait() && tok.Error() != nil {
w.log.Error("subscribe error", "error", tok.Error())
}
}
// isValidIATA reports whether s is 3 uppercase ASCII letters, matching the
// iata_codes.iata CHAR(3) column. MQTT topic segments are attacker/observer
// controlled and must be validated before touching the DB.
func isValidIATA(s string) bool {
if len(s) != 3 {
return false
}
for i := 0; i < 3; i++ {
if s[i] < 'A' || s[i] > 'Z' {
return false
}
}
return true
}
// isValidPubkeyHex reports whether s is a 32-byte public key in hex.
func isValidPubkeyHex(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// handleMessage dispatches incoming MQTT messages by subtopic.
// Each message is processed with a 30s timeout to prevent slow DB calls
// from holding a processing worker indefinitely.
func (w *Worker) handleMessage(msg mqtt.Message) {
w.handleMessageContext(context.Background(), msg)
}
func (w *Worker) handleMessageContext(parent context.Context, msg mqtt.Message) {
// Topic shape: meshcore/{IATA}/{pubkey}/{subtopic}
parts := strings.SplitN(msg.Topic(), "/", 4)
if len(parts) != 4 || parts[0] != "meshcore" {
return
}
iata, pubkeyHex, subtopic := parts[1], parts[2], parts[3]
// iata_codes.iata is CHAR(3); anything else would fail the DB insert
// downstream, so reject malformed topic segments here instead.
if !isValidIATA(iata) {
w.log.Debug("dropped packet with malformed IATA", "iata", iata, "topic", msg.Topic())
return
}
if !isValidPubkeyHex(pubkeyHex) {
w.log.Debug("dropped message with malformed observer pubkey", "pubkey", pubkeyHex, "topic", msg.Topic())
return
}
// Drop packets from IATAs outside the configured geographic filter.
if w.cfg.AllowedIATAs != nil {
if _, ok := w.cfg.AllowedIATAs[iata]; !ok {
if w.log.Enabled(context.Background(), slog.LevelDebug) {
w.log.Debug(fmt.Sprintf("dropped packet from %s (not in allowed IATAs)", iata))
}
return
}
}
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
switch subtopic {
case "packets":
w.handlePacket(ctx, iata, pubkeyHex, msg.Payload())
case "status":
w.handleStatus(ctx, pubkeyHex, msg.Payload())
case "neighbors":
w.handleNeighbors(ctx, iata, pubkeyHex, msg.Payload())
// "internal" is intentionally not handled (Role 2 access)
}
}
func (w *Worker) broadcast(eventType hub.EventType, iata string, payloadType uint8, channelHash string, payload any) {
b, err := json.Marshal(payload)
if err != nil {
w.log.Error(fmt.Sprintf("failed to marshal %s event", eventType), "error", err)
return
}
w.hub.Broadcast(hub.Event{
Type: eventType,
Payload: b,
IATA: iata,
PayloadType: payloadType,
ChannelHash: channelHash,
})
}
// broadcastPacketObservation marshals evt once as-is (the default
// payload every packetObservation subscriber gets) and once with
// resolvedPath populated (delivered only to connections that opted in via
// the "configure" WS message; see hub.Client.ResolvePath). resolvedPath is
// passed in rather than computed here because the caller already has the
// path-hash resolution results in hand from other per-packet work (known
// route detection, capability detection) — this adds no extra DB calls.
// Repeats go through the hub's drop-first path.
func (w *Worker) broadcastPacketObservation(iata string, payloadType uint8, evt packetObservationEvent, resolvedPath []api.ResolvedHop, observerKey string, repeat bool) {
base, err := json.Marshal(evt)
if err != nil {
w.log.Error("failed to marshal packetObservation event", "error", err)
return
}
out := hub.Event{
Type: hub.EventPacketObservation,
Payload: base,
IATA: iata,
PayloadType: payloadType,
RouteType: evt.Packet.RouteType,
ObserverID: evt.Observation.ObserverID,
}
evt.Observation.ResolvedPath = resolvedPath
if out.PayloadResolved, err = json.Marshal(evt); err != nil {
w.log.Error("failed to marshal packetObservation event (resolved variant)", "error", err)
out.PayloadResolved = nil
}
if w.hub.ObserverKeyWanted() {
evt.Observation.ObserverPublicKey = observerKey
if out.PayloadResolvedWithKey, err = json.Marshal(evt); err != nil {
w.log.Error("failed to marshal packetObservation event (resolved key variant)", "error", err)
out.PayloadResolvedWithKey = nil
}
evt.Observation.ResolvedPath = nil
if out.PayloadWithKey, err = json.Marshal(evt); err != nil {
w.log.Error("failed to marshal packetObservation event (key variant)", "error", err)
out.PayloadWithKey = nil
}
}
if repeat {
w.hub.BroadcastRepeat(out)
return
}
w.hub.Broadcast(out)
}
// parseNumber handles RSSI and SNR fields that different observer types send as
// either a bare JSON number (e.g. -108) or a quoted string (e.g. "-108").
// Returns 0 if the value is missing or unparseable.
func parseNumber(raw json.RawMessage) float64 {
if len(raw) == 0 {
return 0
}
// try unquoted number first
var f float64
if err := json.Unmarshal(raw, &f); err == nil {
return f
}
// try quoted string
var s string
if err := json.Unmarshal(raw, &s); err == nil {
f, _ = strconv.ParseFloat(s, 64)
return f
}
return 0
}
func inferObserverType(source, clientVersion string) string {
if source != "" {
return normalizeObserverType(source)
}
if clientVersion != "" {
return clientVersion // use raw version string for custom firmware observers
}
return ""
}
func normalizeObserverType(source string) string {
if source == "" {
return ""
}
s := source
// strip org/path prefix e.g. "meshcore-dev/meshcore-ha" → "meshcore-ha"
if i := strings.LastIndex(s, "/"); i >= 0 {
s = s[i+1:]
}
// strip version suffix e.g. "meshcoretomqtt:1.1.0" → "meshcoretomqtt"
if i := strings.Index(s, ":"); i >= 0 {
s = s[:i]
}
// strip build suffix e.g. "meshcoretomqtt/1.1.0.0-622ce04" → "meshcoretomqtt"
if i := strings.Index(s, "/"); i >= 0 {
s = s[:i]
}
if s == "" {
return source // fall back to raw if parsing produced nothing
}
return s
}
func uint32ToBytes(v uint32) []byte {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, v)
return b
}