Files
meshcore-analyzer/cmd/server/decoder.go
efiten 8f833f64ae fix: parse TRACE packet path hops from payload instead of header (#277)
Fixes #276

## Root cause

TRACE packets store hop IDs in the payload (bytes 9+) rather than in the
header path field. The header path field is overloaded in TRACE packets
to carry RSSI values instead of repeater IDs (as noted in the issue
comments). This meant `Path.Hops` was always empty for TRACE packets —
the raw bytes ended up as an opaque `PathData` hex string with no
structure.

The hashSize encoded in the header path byte (bits 6–7) is still valid
for TRACE and is used to split the payload path bytes into individual
hop prefixes.

## Fix

After decoding a TRACE payload, if `PathData` is non-empty, parse it
into individual hops using `path.HashSize`:

```go
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
    pathBytes, err := hex.DecodeString(payload.PathData)
    if err == nil && path.HashSize > 0 {
        for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
            path.Hops = append(path.Hops, ...)
        }
    }
}
```

Applied to both `cmd/ingestor/decoder.go` and `cmd/server/decoder.go`.

## Verification

Packet from the issue: `260001807dca00000000007d547d`

| | Before | After |
|---|---|---|
| `Path.Hops` | `[]` | `["7D", "54", "7D"]` |
| `Path.HashCount` | `0` | `3` |

New test `TestDecodeTracePathParsing` covers this exact packet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:27:50 +00:00

538 lines
14 KiB
Go

package main
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strings"
"time"
)
// Route type constants (header bits 1-0)
const (
RouteTransportFlood = 0
RouteFlood = 1
RouteDirect = 2
RouteTransportDirect = 3
)
// Payload type constants (header bits 5-2)
const (
PayloadREQ = 0x00
PayloadRESPONSE = 0x01
PayloadTXT_MSG = 0x02
PayloadACK = 0x03
PayloadADVERT = 0x04
PayloadGRP_TXT = 0x05
PayloadGRP_DATA = 0x06
PayloadANON_REQ = 0x07
PayloadPATH = 0x08
PayloadTRACE = 0x09
PayloadMULTIPART = 0x0A
PayloadCONTROL = 0x0B
PayloadRAW_CUSTOM = 0x0F
)
var routeTypeNames = map[int]string{
0: "TRANSPORT_FLOOD",
1: "FLOOD",
2: "DIRECT",
3: "TRANSPORT_DIRECT",
}
// Header is the decoded packet header.
type Header struct {
RouteType int `json:"routeType"`
RouteTypeName string `json:"routeTypeName"`
PayloadType int `json:"payloadType"`
PayloadTypeName string `json:"payloadTypeName"`
PayloadVersion int `json:"payloadVersion"`
}
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
type TransportCodes struct {
Code1 string `json:"code1"`
Code2 string `json:"code2"`
}
// Path holds decoded path/hop information.
type Path struct {
HashSize int `json:"hashSize"`
HashCount int `json:"hashCount"`
Hops []string `json:"hops"`
}
// AdvertFlags holds decoded advert flag bits.
type AdvertFlags struct {
Raw int `json:"raw"`
Type int `json:"type"`
Chat bool `json:"chat"`
Repeater bool `json:"repeater"`
Room bool `json:"room"`
Sensor bool `json:"sensor"`
HasLocation bool `json:"hasLocation"`
HasFeat1 bool `json:"hasFeat1"`
HasFeat2 bool `json:"hasFeat2"`
HasName bool `json:"hasName"`
}
// Payload is a generic decoded payload. Fields are populated depending on type.
type Payload struct {
Type string `json:"type"`
DestHash string `json:"destHash,omitempty"`
SrcHash string `json:"srcHash,omitempty"`
MAC string `json:"mac,omitempty"`
EncryptedData string `json:"encryptedData,omitempty"`
ExtraHash string `json:"extraHash,omitempty"`
PubKey string `json:"pubKey,omitempty"`
Timestamp uint32 `json:"timestamp,omitempty"`
TimestampISO string `json:"timestampISO,omitempty"`
Signature string `json:"signature,omitempty"`
Flags *AdvertFlags `json:"flags,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"`
ChannelHash int `json:"channelHash,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
}
// DecodedPacket is the full decoded result.
type DecodedPacket struct {
Header Header `json:"header"`
TransportCodes *TransportCodes `json:"transportCodes"`
Path Path `json:"path"`
Payload Payload `json:"payload"`
Raw string `json:"raw"`
}
func decodeHeader(b byte) Header {
rt := int(b & 0x03)
pt := int((b >> 2) & 0x0F)
pv := int((b >> 6) & 0x03)
rtName := routeTypeNames[rt]
if rtName == "" {
rtName = "UNKNOWN"
}
ptName := payloadTypeNames[pt]
if ptName == "" {
ptName = "UNKNOWN"
}
return Header{
RouteType: rt,
RouteTypeName: rtName,
PayloadType: pt,
PayloadTypeName: ptName,
PayloadVersion: pv,
}
}
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
totalBytes := hashSize * hashCount
hops := make([]string, 0, hashCount)
for i := 0; i < hashCount; i++ {
start := offset + i*hashSize
end := start + hashSize
if end > len(buf) {
break
}
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
}
return Path{
HashSize: hashSize,
HashCount: hashCount,
Hops: hops,
}, totalBytes
}
func isTransportRoute(routeType int) bool {
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: typeName,
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
EncryptedData: hex.EncodeToString(buf[4:]),
}
}
func decodeAck(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
checksum := binary.LittleEndian.Uint32(buf[0:4])
return Payload{
Type: "ACK",
ExtraHash: fmt.Sprintf("%08x", checksum),
}
}
func decodeAdvert(buf []byte) Payload {
if len(buf) < 100 {
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
}
pubKey := hex.EncodeToString(buf[0:32])
timestamp := binary.LittleEndian.Uint32(buf[32:36])
signature := hex.EncodeToString(buf[36:100])
appdata := buf[100:]
p := Payload{
Type: "ADVERT",
PubKey: pubKey,
Timestamp: timestamp,
TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)),
Signature: signature,
}
if len(appdata) > 0 {
flags := appdata[0]
advType := int(flags & 0x0F)
hasFeat1 := flags&0x20 != 0
hasFeat2 := flags&0x40 != 0
p.Flags = &AdvertFlags{
Raw: int(flags),
Type: advType,
Chat: advType == 1,
Repeater: advType == 2,
Room: advType == 3,
Sensor: advType == 4,
HasLocation: flags&0x10 != 0,
HasFeat1: hasFeat1,
HasFeat2: hasFeat2,
HasName: flags&0x80 != 0,
}
off := 1
if p.Flags.HasLocation && len(appdata) >= off+8 {
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
lat := float64(latRaw) / 1e6
lon := float64(lonRaw) / 1e6
p.Lat = &lat
p.Lon = &lon
off += 8
}
if hasFeat1 && len(appdata) >= off+2 {
off += 2 // skip feat1 bytes (reserved for future use)
}
if hasFeat2 && len(appdata) >= off+2 {
off += 2 // skip feat2 bytes (reserved for future use)
}
if p.Flags.HasName {
name := string(appdata[off:])
name = strings.TrimRight(name, "\x00")
name = sanitizeName(name)
p.Name = name
}
}
return p
}
func decodeGrpTxt(buf []byte) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "GRP_TXT",
ChannelHash: int(buf[0]),
MAC: hex.EncodeToString(buf[1:3]),
EncryptedData: hex.EncodeToString(buf[3:]),
}
}
func decodeAnonReq(buf []byte) Payload {
if len(buf) < 35 {
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "ANON_REQ",
DestHash: hex.EncodeToString(buf[0:1]),
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
MAC: hex.EncodeToString(buf[33:35]),
EncryptedData: hex.EncodeToString(buf[35:]),
}
}
func decodePathPayload(buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "PATH",
DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
MAC: hex.EncodeToString(buf[2:4]),
PathData: hex.EncodeToString(buf[4:]),
}
}
func decodeTrace(buf []byte) Payload {
if len(buf) < 9 {
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
tag := binary.LittleEndian.Uint32(buf[0:4])
authCode := binary.LittleEndian.Uint32(buf[4:8])
flags := int(buf[8])
p := Payload{
Type: "TRACE",
Tag: tag,
AuthCode: authCode,
TraceFlags: &flags,
}
if len(buf) > 9 {
p.PathData = hex.EncodeToString(buf[9:])
}
return p
}
func decodePayload(payloadType int, buf []byte) Payload {
switch payloadType {
case PayloadREQ:
return decodeEncryptedPayload("REQ", buf)
case PayloadRESPONSE:
return decodeEncryptedPayload("RESPONSE", buf)
case PayloadTXT_MSG:
return decodeEncryptedPayload("TXT_MSG", buf)
case PayloadACK:
return decodeAck(buf)
case PayloadADVERT:
return decodeAdvert(buf)
case PayloadGRP_TXT:
return decodeGrpTxt(buf)
case PayloadANON_REQ:
return decodeAnonReq(buf)
case PayloadPATH:
return decodePathPayload(buf)
case PayloadTRACE:
return decodeTrace(buf)
default:
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
}
}
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
buf, err := hex.DecodeString(hexString)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(buf) < 2 {
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
}
header := decodeHeader(buf[0])
offset := 1
var tc *TransportCodes
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return nil, fmt.Errorf("packet too short for transport codes")
}
tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
}
offset += 4
}
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
// we use to split the payload path data into individual hop prefixes.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
}
path.Hops = hops
path.HashCount = len(hops)
}
}
return &DecodedPacket{
Header: header,
TransportCodes: tc,
Path: path,
Payload: payload,
Raw: strings.ToUpper(hexString),
}, nil
}
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
func ComputeContentHash(rawHex string) string {
buf, err := hex.DecodeString(rawHex)
if err != nil || len(buf) < 2 {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
headerByte := buf[0]
offset := 1
if isTransportRoute(int(headerByte & 0x03)) {
offset += 4
}
if offset >= len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
pathByte := buf[offset]
offset++
hashSize := int((pathByte>>6)&0x3) + 1
hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount
payloadStart := offset + pathBytes
if payloadStart > len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
payload := buf[payloadStart:]
toHash := append([]byte{headerByte}, payload...)
h := sha256.Sum256(toHash)
return hex.EncodeToString(h[:])[:16]
}
// PayloadJSON serializes the payload to JSON for DB storage.
func PayloadJSON(p *Payload) string {
b, err := json.Marshal(p)
if err != nil {
return "{}"
}
return string(b)
}
// ValidateAdvert checks decoded advert data before DB insertion.
func ValidateAdvert(p *Payload) (bool, string) {
if p == nil || p.Error != "" {
reason := "null advert"
if p != nil {
reason = p.Error
}
return false, reason
}
pk := p.PubKey
if len(pk) < 16 {
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
}
allZero := true
for _, c := range pk {
if c != '0' {
allZero = false
break
}
}
if allZero {
return false, "pubkey is all zeros"
}
if p.Lat != nil {
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
}
}
if p.Lon != nil {
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
}
}
if p.Name != "" {
for _, c := range p.Name {
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
return false, "name contains control characters"
}
}
if len(p.Name) > 64 {
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
}
}
if p.Flags != nil {
role := advertRole(p.Flags)
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
if !validRoles[role] {
return false, fmt.Sprintf("unknown role: %s", role)
}
}
return true, ""
}
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
func sanitizeName(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, c := range s {
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
b.WriteRune(c)
}
}
return b.String()
}
func advertRole(f *AdvertFlags) string {
if f.Repeater {
return "repeater"
}
if f.Room {
return "room"
}
if f.Sensor {
return "sensor"
}
return "companion"
}
func epochToISO(epoch uint32) string {
t := time.Unix(int64(epoch), 0)
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}