mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 11:39:53 +00:00
A short topic pubkey panicked a lane worker and killed the process; keys must now be 32 bytes and lane workers recover. Also reject non-finite radio values before airtime, strip NULs from advert names and neighbour scopes, guard the MQTT client pointer, and never stream a broker copy of a hearing as a repeat.
241 lines
9.4 KiB
Go
241 lines
9.4 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package ingest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
|
|
"github.com/meshcore-go/meshcore-go"
|
|
)
|
|
|
|
// UpsertNodeParams carries the fields extracted from a payload type 0x04 advert.
|
|
type UpsertNodeParams struct {
|
|
PublicKey []byte
|
|
Name string
|
|
NodeType uint8 // 1=companion, 2=repeater, 3=room server
|
|
Latitude *float64
|
|
Longitude *float64
|
|
// ClearLocation drops the stored position; set by an explicit 0/0 advert.
|
|
ClearLocation bool
|
|
// AdvertTimestamp is the device's self-reported wall-clock time (epoch seconds) from the
|
|
// signed advert body. Used to derive clock drift for repeaters/room servers; see
|
|
// api.Node.ClockDriftSeconds.
|
|
AdvertTimestamp uint32
|
|
}
|
|
|
|
// InsertChannelMessageParams carries a decrypted group text message.
|
|
type InsertChannelMessageParams struct {
|
|
ChannelID int
|
|
PacketHash []byte
|
|
SenderName string
|
|
Content string
|
|
SentAt time.Time
|
|
Historical bool // backfilled: its hours may already be rolled, so queue them for a re-roll
|
|
}
|
|
|
|
// InsertedChannelMessage is returned only for a new message, using its stored packet evidence.
|
|
type InsertedChannelMessage struct {
|
|
ID int64
|
|
Scope *string
|
|
ScopeStatus api.ChannelScopeStatus
|
|
}
|
|
|
|
// channelMessageEvent is the JSON payload for a channelMessage WS event.
|
|
type channelMessageEvent struct {
|
|
ID int64 `json:"id"`
|
|
ChannelID int `json:"channelId"`
|
|
ChannelHash string `json:"channelHash"` // hex-encoded single byte
|
|
PacketHash string `json:"packetHash"` // hex-encoded
|
|
SenderName string `json:"senderName"`
|
|
Content string `json:"content"`
|
|
SentAt int64 `json:"sentAt"` // epoch ms
|
|
Scope *string `json:"scope"`
|
|
ScopeStatus api.ChannelScopeStatus `json:"scopeStatus" enums:"matched,unscoped,unknown,unavailable"`
|
|
}
|
|
|
|
// nodeUpdateEvent is the JSON payload for a nodeUpdate WS event.
|
|
type nodeUpdateEvent struct {
|
|
NodeID string `json:"nodeId"`
|
|
PublicKey string `json:"publicKey"`
|
|
Name string `json:"name"`
|
|
NodeType uint8 `json:"nodeType"`
|
|
NodeTypeName string `json:"nodeTypeName"`
|
|
IATA string `json:"iata"`
|
|
Lat **float64 `json:"lat,omitempty"` // Omitted keeps the client's position; explicit null clears it.
|
|
Lng **float64 `json:"lng,omitempty"`
|
|
IsObserver bool `json:"isObserver"`
|
|
IATAs []api.NodeIATA `json:"iatas"`
|
|
DefaultScope *string `json:"defaultScope,omitempty"`
|
|
Radio *string `json:"radio,omitempty"`
|
|
// Omission retains the previous classification (no advertised position).
|
|
// The outer pointer allows explicit null to clear an unknown/reset position.
|
|
PossiblyForeign **bool `json:"possiblyForeign,omitempty"`
|
|
}
|
|
|
|
// handlePayloadTypeSideEffects runs payload-type-specific processing after a
|
|
// new observation is confirmed inserted. Currently handles:
|
|
// - PayloadTypeAdvert (0x04): upsert node and node_iatas
|
|
// - PayloadTypeGrpTxt (0x05): decrypt and store channel message if key is known
|
|
func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshcore.Packet, iata string, packetHash []byte, radio RadioSettings, scopeID *int32, matchedScope *string, observerPubkey []byte, rxSNR float32) {
|
|
if packet.PayloadType() == meshcore.PayloadTypeAdvert {
|
|
advert, err := meshcore.AdvertFromBytes(packet.Payload)
|
|
if err != nil {
|
|
w.log.Warn("error decoding advert payload", "error", err)
|
|
return
|
|
}
|
|
if !advert.Verify() {
|
|
w.log.Warn(fmt.Sprintf("dropped advert with invalid signature from pubkey %s", hex.EncodeToString(advert.PublicKey.PublicKeyBytes())))
|
|
return
|
|
}
|
|
var lat, lon *float64
|
|
present := advert.Flags()&meshcore.AdvertLatLonMask != 0
|
|
// Presence decides: omitted keeps the stored position, an explicit 0/0 clears it.
|
|
cleared := present && advert.AppData().Lat == 0 && advert.AppData().Lon == 0
|
|
if present && !cleared {
|
|
la := float64(advert.AppData().Lat) / 1e6
|
|
lo := float64(advert.AppData().Lon) / 1e6
|
|
lat = &la
|
|
lon = &lo
|
|
}
|
|
params := UpsertNodeParams{
|
|
PublicKey: advert.PublicKey.PublicKeyBytes(),
|
|
Name: cleanText(advert.AppData().Name),
|
|
NodeType: advert.Type(),
|
|
Latitude: lat,
|
|
Longitude: lon,
|
|
ClearLocation: cleared,
|
|
AdvertTimestamp: advert.Timestamp,
|
|
}
|
|
var nodeRadio RadioSettings
|
|
if packet.PathHashCount() == 0 {
|
|
nodeRadio = radio
|
|
}
|
|
nodeID, err := w.db.UpsertNode(ctx, params, nodeRadio)
|
|
if err != nil {
|
|
w.log.Error("db: upsert node failed", "error", err)
|
|
return
|
|
}
|
|
// invalidate cache for this node
|
|
if w.onNodeUpsert != nil {
|
|
w.onNodeUpsert(ctx, nodeID)
|
|
}
|
|
// record advertiser neighbors
|
|
// if the advert was forwarded, the first hop is a neighbor
|
|
if packet.PathHashCount() > 0 && (advert.Type() == meshcore.AdvertTypeRepeater || advert.Type() == meshcore.AdvertTypeRoom) {
|
|
firstHop := packet.PathHashes()
|
|
if len(firstHop) > 0 {
|
|
resolved, err := w.db.ResolvePathHashes(ctx, iata, firstHop[:1])
|
|
if err == nil {
|
|
key := hex.EncodeToString(firstHop[0])
|
|
if entries := resolved[key]; len(entries) == 1 {
|
|
if err := w.db.UpsertNodeNeighbor(ctx, nodeID, entries[0].NodeID, iata, nil, nil); err != nil {
|
|
w.log.Error("failed to upsert node neighbor", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// record observer neighbors
|
|
// if heard directly (zero-hop), record the observer's own RX SNR
|
|
// of hearing this advertiser as a node_neighbors edge. Skipped if
|
|
// the observer has no node row yet (hasn't advertised itself).
|
|
if packet.PathHashCount() == 0 && (advert.Type() == meshcore.AdvertTypeRepeater || advert.Type() == meshcore.AdvertTypeRoom) {
|
|
observerNodeID, oErr := w.db.GetNodeByPubkey(ctx, observerPubkey)
|
|
if oErr == nil && observerNodeID != nodeID {
|
|
snr := rxSNR
|
|
if err := w.db.UpsertNodeNeighbor(ctx, observerNodeID, nodeID, iata, &snr, nil); err != nil {
|
|
w.log.Error("failed to upsert observer-advert neighbor", "error", err)
|
|
}
|
|
}
|
|
}
|
|
if err := w.db.UpsertNodeIATA(ctx, nodeID, iata); err != nil {
|
|
w.log.Error("db: upsert node IATA failed", "error", err)
|
|
}
|
|
if scopeID != nil && (packet.RouteType() == meshcore.RouteTypeTransportFlood || packet.RouteType() == meshcore.RouteTypeTransportDirect) {
|
|
if err := w.db.SetNodeDefaultScope(ctx, nodeID, *scopeID); err != nil {
|
|
w.log.Error(fmt.Sprintf("failed to set default scope for node %s", hex.EncodeToString(advert.PublicKey.PublicKeyBytes())), "error", err)
|
|
}
|
|
}
|
|
prefix4 := advert.PublicKey.PublicKeyBytes()[:4]
|
|
if err := w.db.UpsertNodeShortID(ctx, nodeID, iata, prefix4); err != nil {
|
|
w.log.Error(fmt.Sprintf("failed to upsert node short ID for %s", hex.EncodeToString(prefix4)), "error", err)
|
|
}
|
|
pubkeyHex := hex.EncodeToString(advert.PublicKey.PublicKeyBytes())
|
|
isObserver := w.db.IsObserverByPubkey(ctx, advert.PublicKey.PublicKeyBytes())
|
|
var defaultScope *string
|
|
if matchedScope != nil {
|
|
defaultScope = matchedScope
|
|
}
|
|
var radioStr *string
|
|
if radio.FreqMHz != 0 {
|
|
s := fmt.Sprintf("%g,%g,%d", radio.FreqMHz, radio.BWKHz, radio.SF)
|
|
radioStr = &s
|
|
}
|
|
evt := nodeUpdateEvent{
|
|
NodeID: nodeID.String(),
|
|
PublicKey: pubkeyHex,
|
|
Name: cleanText(advert.AppData().Name),
|
|
NodeType: advert.Type(),
|
|
NodeTypeName: api.NodeTypeName(int16(advert.Type())),
|
|
IATA: iata,
|
|
IsObserver: isObserver,
|
|
IATAs: []api.NodeIATA{{IATA: iata, LastHeard: time.Now().UnixMilli()}},
|
|
DefaultScope: defaultScope,
|
|
Radio: radioStr,
|
|
}
|
|
if present {
|
|
evt.Lat, evt.Lng = &lat, &lon
|
|
}
|
|
if w.cfg.LocalBorders != nil && (present || advert.Type() != meshcore.AdvertTypeRepeater) {
|
|
foreign := w.cfg.LocalBorders.PossiblyForeign(int16(advert.Type()), lat, lon)
|
|
evt.PossiblyForeign = &foreign
|
|
}
|
|
w.broadcast(hub.EventNodeUpdate, iata, meshcore.PayloadTypeAdvert, "", evt)
|
|
return
|
|
}
|
|
if packet.PayloadType() == meshcore.PayloadTypeGrpTxt {
|
|
grpTxt, err := meshcore.GroupTextFromBytes(packet.Payload)
|
|
if err != nil {
|
|
w.log.Warn("error decoding group text payload", "error", err)
|
|
return
|
|
}
|
|
channelHashBytes := []byte{grpTxt.ChannelHash}
|
|
|
|
result, err := DecryptGroupText(ctx, w.db, w.keys, packetHash, packet.Payload, false)
|
|
if err != nil {
|
|
w.log.Error("decrypt group text failed", "error", err)
|
|
return
|
|
}
|
|
if result == nil {
|
|
// none of the known keys worked for this hash (or none are known at all);
|
|
// record a hash-only row so the channel is still visible. If a matching key
|
|
// gets added to the config later, BackfillChannelMessages retries this packet
|
|
// at the next boot.
|
|
_, _ = w.db.UpsertChannelHashOnly(ctx, channelHashBytes)
|
|
return
|
|
}
|
|
|
|
if result.Message != nil {
|
|
evt := channelMessageEvent{
|
|
ID: result.Message.ID,
|
|
Scope: result.Message.Scope,
|
|
ScopeStatus: result.Message.ScopeStatus,
|
|
ChannelID: result.ChannelID,
|
|
ChannelHash: hex.EncodeToString(channelHashBytes),
|
|
PacketHash: hex.EncodeToString(packetHash),
|
|
SenderName: cleanText(result.Payload.Sender),
|
|
Content: cleanText(result.Payload.Text),
|
|
SentAt: time.Unix(int64(result.Payload.Timestamp), 0).UnixMilli(),
|
|
}
|
|
w.broadcast(hub.EventChannelMessage, iata, 0, fmt.Sprintf("%02x", grpTxt.ChannelHash), evt)
|
|
}
|
|
return
|
|
}
|
|
}
|