From f07edf9fb60189d3f9855e200e332a4436bf63a9 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 23 Jul 2026 09:44:27 -0700 Subject: [PATCH] feat: include source and destination in path data where available realted to #85 --- db/packets.go | 61 +++++++++++++++++++++++++++++++++++++++ internal/api/packets.go | 19 ++++++++++++ internal/ingest/ingest.go | 4 +++ internal/ingest/packet.go | 43 +++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) diff --git a/db/packets.go b/db/packets.go index 33a5392..2b38740 100644 --- a/db/packets.go +++ b/db/packets.go @@ -337,6 +337,48 @@ func (s *Store) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, } } } + // 1-byte source/destination hashes for the payload types that carry a resolvable + // endpoint (REQUEST, RESPONSE, TEXT_MESSAGE, PATH, ANON_REQ's destination). Constant + // for this packet hash, so parsed once; resolution itself still happens per-observation + // below since candidates depend on the observation's IATA, same as the intermediate + // hop resolution already does. + var sourceHashByte, destHashByte []byte + switch row.PayloadType { + case int16(meshcore.PayloadTypeAnonReq): + if anonReq, err := meshcore.AnonReqFromBytes(row.RawPayload); err == nil { + destHashByte = []byte{anonReq.Destination} + } + case int16(meshcore.PayloadTypeReq): + if req, err := meshcore.RequestFromBytes(row.RawPayload); err == nil { + sourceHashByte = []byte{req.Source} + destHashByte = []byte{req.Destination} + } + case int16(meshcore.PayloadTypeResponse): + if resp, err := meshcore.ResponseFromBytes(row.RawPayload); err == nil { + sourceHashByte = []byte{resp.Source} + destHashByte = []byte{resp.Destination} + } + case int16(meshcore.PayloadTypeTxtMsg): + if txt, err := meshcore.TextMessageFromBytes(row.RawPayload); err == nil { + sourceHashByte = []byte{txt.Source} + destHashByte = []byte{txt.Destination} + } + case int16(meshcore.PayloadTypePath): + if path, err := meshcore.PathFromBytes(row.RawPayload); err == nil { + sourceHashByte = []byte{path.Source} + destHashByte = []byte{path.Destination} + } + } + // ADVERT's source is an exact pubkey match, not ambiguous like the above -- and unlike + // them it doesn't depend on IATA, so resolve it once here rather than per observation. + var resolvedAdvertSource *api.ResolvedNode + if row.PayloadType == int16(meshcore.PayloadTypeAdvert) && row.OriginPubkey != nil { + if nodeID, err := s.GetNodeByPubkey(ctx, row.OriginPubkey); err == nil { + if nodes, err := s.GetNodesByIDs(ctx, []uuid.UUID{nodeID}); err == nil { + resolvedAdvertSource = nodes[nodeID] + } + } + } for _, v := range obsRows { obs := api.PacketObservationDetail{ ID: v.ID, @@ -379,6 +421,25 @@ func (s *Store) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, } } obs.ResolvedPath = resolvedPath + if row.PayloadType == int16(meshcore.PayloadTypeAdvert) { + hop := api.ResolveExactNode(resolvedAdvertSource) + obs.ResolvedSource = &hop + } else if len(sourceHashByte) == 1 { + if r, err := s.ResolvePathHashes(ctx, v.Iata, [][]byte{sourceHashByte}); err != nil { + log.Printf("store: source resolution failed for observation %d: %v", v.ID, err) + } else { + hop := api.BuildResolvedPath([][]byte{sourceHashByte}, r)[0] + obs.ResolvedSource = &hop + } + } + if len(destHashByte) == 1 { + if r, err := s.ResolvePathHashes(ctx, v.Iata, [][]byte{destHashByte}); err != nil { + log.Printf("store: destination resolution failed for observation %d: %v", v.ID, err) + } else { + hop := api.BuildResolvedPath([][]byte{destHashByte}, r)[0] + obs.ResolvedDestination = &hop + } + } if row.PayloadType == int16(meshcore.PayloadTypeTrace) && len(traceRawHashes) > 0 { // Swap in the trace's own path hashes so PathData's hop-block split (driven by // pathBytes + hashSize) lines up 1:1 with resolvedPath -- the raw SNR bytes diff --git a/internal/api/packets.go b/internal/api/packets.go index 6e25cf4..516be02 100644 --- a/internal/api/packets.go +++ b/internal/api/packets.go @@ -58,6 +58,14 @@ type PacketObservationDetail struct { Radio *PacketRadio `json:"radio,omitempty"` SourceBroker string `json:"sourceBroker"` ResolvedPath []ResolvedHop `json:"resolvedPath"` // per-observation resolved path hashes + // ResolvedSource/ResolvedDestination are the packet's endpoints, when the payload type + // carries a resolvable one: an exact match for ADVERT's full pubkey, an ambiguous + // hash-prefix match (like intermediate hops) for TEXT_MESSAGE/PATH/ANON_REQ's 1-byte + // source/destination hashes. Nil when the payload type doesn't carry one at all (e.g. + // GRP_TXT/GRP_DATA/TRACE aren't node-to-node addressed) -- see BuildResolvedPath and + // ResolveExactNode for how each is built. + ResolvedSource *ResolvedHop `json:"resolvedSource,omitempty"` + ResolvedDestination *ResolvedHop `json:"resolvedDestination,omitempty"` } // PacketRadio holds the radio settings copied from the observer at observation time. @@ -278,3 +286,14 @@ func BuildResolvedPath(hashes [][]byte, resolved map[string][]ResolvedPathEntry) } return path } + +// ResolveExactNode builds a ResolvedHop for an endpoint resolved by an exact, unambiguous +// key -- e.g. an ADVERT's full public key already resolved to a single node -- as opposed +// to BuildResolvedPath's hash-prefix matching, which can be ambiguous. Confidence is always +// "high" when a node was found, "none" when it wasn't (unknown node, or lookup failed). +func ResolveExactNode(node *ResolvedNode) ResolvedHop { + if node == nil { + return ResolvedHop{Confidence: "none", Nodes: []ResolvedNode{}} + } + return ResolvedHop{Confidence: "high", Nodes: []ResolvedNode{*node}} +} diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index fd703e8..5bf7453 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -105,6 +105,10 @@ type DB interface { // 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 diff --git a/internal/ingest/packet.go b/internal/ingest/packet.go index b4c6277..bf64f29 100644 --- a/internal/ingest/packet.go +++ b/internal/ingest/packet.go @@ -94,6 +94,10 @@ type packetObservationEvent struct { } `json:"pathLength"` PropagationTimeMs int32 `json:"propagationTimeMs"` ResolvedPath []api.ResolvedHop `json:"resolvedPath"` // only present in the resolvePath-opted-in variant; see hub.Event.PayloadResolved + // ResolvedSource/ResolvedDestination mirror api.PacketObservationDetail's fields of + // the same name -- nil when this payload type has no resolvable endpoint. + ResolvedSource *api.ResolvedHop `json:"resolvedSource,omitempty"` + ResolvedDestination *api.ResolvedHop `json:"resolvedDestination,omitempty"` } `json:"observation"` } @@ -340,6 +344,10 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ // below), so the "physical route" hashes for resolvedPath/known-route purposes come // instead from the TRACE payload's own embedded PathHashes (the path being probed). var traceRawHashes [][]byte + // 1-byte source/destination hashes for ambiguous prefix resolution (REQUEST, RESPONSE, + // TEXT_MESSAGE, PATH, ANON_REQ's destination). GRP_TXT/GRP_DATA/TRACE have no such + // fields and are left nil. + var sourceHashByte, destHashByte []byte switch packet.PayloadType() { case meshcore.PayloadTypeGrpTxt: @@ -438,6 +446,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ anonReq, err := meshcore.AnonReqFromBytes(packet.Payload) if err == nil { originPubkey = anonReq.EphemeralPubKey[:] + destHashByte = []byte{anonReq.Destination} par := parsedAnonReq{ Raw: hex.EncodeToString(packet.Payload), Type: "ANON_REQUEST", @@ -450,6 +459,8 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ case meshcore.PayloadTypeReq: req, err := meshcore.RequestFromBytes(packet.Payload) if err == nil { + sourceHashByte = []byte{req.Source} + destHashByte = []byte{req.Destination} pe := parsedEnvelope{ Raw: hex.EncodeToString(packet.Payload), Type: "REQUEST", @@ -465,6 +476,8 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ case meshcore.PayloadTypeResponse: resp, err := meshcore.ResponseFromBytes(packet.Payload) if err == nil { + sourceHashByte = []byte{resp.Source} + destHashByte = []byte{resp.Destination} pe := parsedEnvelope{ Raw: hex.EncodeToString(packet.Payload), Type: "RESPONSE", @@ -480,6 +493,8 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ case meshcore.PayloadTypeTxtMsg: txt, err := meshcore.TextMessageFromBytes(packet.Payload) if err == nil { + sourceHashByte = []byte{txt.Source} + destHashByte = []byte{txt.Destination} pe := parsedEnvelope{ Raw: hex.EncodeToString(packet.Payload), Type: "TEXT_MESSAGE", @@ -495,6 +510,8 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ case meshcore.PayloadTypePath: path, err := meshcore.PathFromBytes(packet.Payload) if err == nil { + sourceHashByte = []byte{path.Source} + destHashByte = []byte{path.Destination} pe := parsedEnvelope{ Raw: hex.EncodeToString(packet.Payload), Type: "PATH", @@ -805,6 +822,30 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ } } w.runCapabilityDetection(ctx, packet.PayloadType(), packet.PathHashSize(), resolvedIDs) + + var resolvedSource, resolvedDestination *api.ResolvedHop + if packet.PayloadType() == meshcore.PayloadTypeAdvert && originPubkey != nil { + // Exact match: ADVERT carries the sender's real identity pubkey, not a + // short ambiguous hash prefix like the other resolvable payload types. + if nodeID, err := w.db.GetNodeByPubkey(ctx, originPubkey); err == nil { + if nodes, err := w.db.GetNodesByIDs(ctx, []uuid.UUID{nodeID}); err == nil { + hop := api.ResolveExactNode(nodes[nodeID]) + resolvedSource = &hop + } + } + } else if len(sourceHashByte) == 1 { + if r, err := w.db.ResolvePathHashes(ctx, iata, [][]byte{sourceHashByte}); err == nil { + hop := api.BuildResolvedPath([][]byte{sourceHashByte}, r)[0] + resolvedSource = &hop + } + } + if len(destHashByte) == 1 { + if r, err := w.db.ResolvePathHashes(ctx, iata, [][]byte{destHashByte}); err == nil { + hop := api.BuildResolvedPath([][]byte{destHashByte}, r)[0] + resolvedDestination = &hop + } + } + if inserted { w.handlePayloadTypeSideEffects(ctx, packet, iata, packetHash[:], radio, scopeID, matchedScope, pubkeyBytes, float32(parseNumber(envelope.SNR))) evt := packetObservationEvent{} @@ -836,6 +877,8 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [ evt.Packet.Scope = matchedScope } resolvedPath := api.BuildResolvedPath(hashes, resolved) + evt.Observation.ResolvedSource = resolvedSource + evt.Observation.ResolvedDestination = resolvedDestination w.broadcastPacketObservation(iata, packet.PayloadType(), evt, resolvedPath) } }