mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-01 02:15:42 +00:00
First step of Go rewrite — separates MQTT ingestion from the Node.js web server. Single static binary (no CGO) that connects to MQTT brokers, decodes MeshCore packets, and writes to the shared SQLite DB. Ported from JS: - decoder.js → decoder.go (header, path, all payload types, adverts) - computeContentHash → Go (SHA-256, path-independent) - db.js v3 schema → db.go (transmissions, observations, nodes, observers) - server.js MQTT logic → main.go (multi-broker, reconnect, IATA filter) 25 Go tests passing (golden fixtures from production + schema compat). No existing JS files modified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
496 lines
13 KiB
Go
496 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
// 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",
|
|
}
|
|
|
|
var payloadTypeNames = map[int]string{
|
|
0x00: "REQ",
|
|
0x01: "RESPONSE",
|
|
0x02: "TXT_MSG",
|
|
0x03: "ACK",
|
|
0x04: "ADVERT",
|
|
0x05: "GRP_TXT",
|
|
0x06: "GRP_DATA",
|
|
0x07: "ANON_REQ",
|
|
0x08: "PATH",
|
|
0x09: "TRACE",
|
|
0x0A: "MULTIPART",
|
|
0x0B: "CONTROL",
|
|
0x0F: "RAW_CUSTOM",
|
|
}
|
|
|
|
// 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 {
|
|
NextHop string `json:"nextHop"`
|
|
LastHop string `json:"lastHop"`
|
|
}
|
|
|
|
// 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"`
|
|
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"`
|
|
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) < 6 {
|
|
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
|
}
|
|
return Payload{
|
|
Type: "ACK",
|
|
DestHash: hex.EncodeToString(buf[0:1]),
|
|
SrcHash: hex.EncodeToString(buf[1:2]),
|
|
ExtraHash: hex.EncodeToString(buf[2:6]),
|
|
}
|
|
}
|
|
|
|
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)
|
|
p.Flags = &AdvertFlags{
|
|
Raw: int(flags),
|
|
Type: advType,
|
|
Chat: advType == 1,
|
|
Repeater: advType == 2,
|
|
Room: advType == 3,
|
|
Sensor: advType == 4,
|
|
HasLocation: flags&0x10 != 0,
|
|
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 p.Flags.HasName {
|
|
name := string(appdata[off:])
|
|
// Trim trailing null bytes
|
|
name = strings.TrimRight(name, "\x00")
|
|
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) < 12 {
|
|
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
|
}
|
|
return Payload{
|
|
Type: "TRACE",
|
|
DestHash: hex.EncodeToString(buf[5:11]),
|
|
SrcHash: hex.EncodeToString(buf[11:12]),
|
|
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
|
}
|
|
}
|
|
|
|
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])
|
|
pathByte := buf[1]
|
|
offset := 2
|
|
|
|
var tc *TransportCodes
|
|
if isTransportRoute(header.RouteType) {
|
|
if len(buf) < offset+4 {
|
|
return nil, fmt.Errorf("packet too short for transport codes")
|
|
}
|
|
tc = &TransportCodes{
|
|
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
|
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
|
}
|
|
offset += 4
|
|
}
|
|
|
|
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).
|
|
// It hashes the header byte + payload (skipping path bytes) to produce a
|
|
// path-independent identifier for the same transmission.
|
|
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
|
|
}
|
|
|
|
pathByte := buf[1]
|
|
hashSize := int((pathByte>>6)&0x3) + 1
|
|
hashCount := int(pathByte & 0x3F)
|
|
pathBytes := hashSize * hashCount
|
|
|
|
headerByte := buf[0]
|
|
payloadStart := 2 + pathBytes
|
|
if isTransportRoute(int(headerByte & 0x03)) {
|
|
payloadStart += 4
|
|
}
|
|
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, ""
|
|
}
|
|
|
|
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 {
|
|
// Go time from Unix epoch
|
|
t := unixTime(int64(epoch))
|
|
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
|
}
|