Files
meshcore-analyzer/cmd/server/decoder.go
you db1122ef4b fix: align Go packet decoder with MeshCore firmware spec
Match the C++ firmware wire format (Packet::writeTo/readFrom):

1. Field order: transport codes are parsed BEFORE path_length byte,
   matching firmware's header → transport_codes → path_len → path → payload

2. ACK payload: just 4-byte CRC checksum, not dest+src+ackHash.
   Firmware createAck() writes only ack_crc (4 bytes).

3. TRACE payload: tag(4) + authCode(4) + flags(1) + pathData,
   matching firmware createTrace() and onRecvPacket() TRACE handler.

4. ADVERT features: parse feat1 (0x20) and feat2 (0x40) optional
   2-byte fields between location and name, matching AdvertDataBuilder
   and AdvertDataParser in the firmware.

5. Transport code naming: code1/code2 instead of nextHop/lastHop,
   matching firmware's transport_codes[0]/transport_codes[1] naming.

Fixes applied to both cmd/ingestor/decoder.go and cmd/server/decoder.go.
Tests updated to match new behavior.
2026-03-29 14:14:50 +00:00

523 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)
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")
}