mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 12:11:38 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b609d1de | |||
| d2e16e4a51 | |||
| 1cd5ce873a | |||
| b40f5fbb75 | |||
| d02a0eb035 | |||
| 4d6c60d14f | |||
| 827b2a3b8a | |||
| 1df01bf9a7 | |||
| e835819db3 | |||
| daf375b458 | |||
| db7a50e408 | |||
| 457d38132e | |||
| 9b78a6da5b | |||
| cf650c889f |
+44
-46
@@ -1,46 +1,44 @@
|
||||
# MeshCore Analyzer — Environment Configuration
|
||||
# Copy to .env and customize. All values have sensible defaults.
|
||||
#
|
||||
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
|
||||
# manage.sh setup negotiates and updates only these production managed keys:
|
||||
# PROD_DATA_DIR, PROD_HTTP_PORT, PROD_HTTPS_PORT, PROD_MQTT_PORT
|
||||
# Each environment keeps config + data together in one directory:
|
||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||
|
||||
# --- Production ---
|
||||
# Data directory (database, theme, etc.)
|
||||
# Default: ~/meshcore-data
|
||||
# Used by: docker compose, manage.sh
|
||||
PROD_DATA_DIR=~/meshcore-data
|
||||
|
||||
# HTTP port for web UI
|
||||
# Default: 80
|
||||
# Used by: docker compose
|
||||
PROD_HTTP_PORT=80
|
||||
|
||||
# HTTPS port for web UI (TLS via Caddy)
|
||||
# Default: 443
|
||||
# Used by: docker compose
|
||||
PROD_HTTPS_PORT=443
|
||||
|
||||
# MQTT port for observer connections
|
||||
# Default: 1883
|
||||
# Used by: docker compose
|
||||
PROD_MQTT_PORT=1883
|
||||
|
||||
# --- Staging (HTTP only, no HTTPS) ---
|
||||
# Data directory
|
||||
# Default: ~/meshcore-staging-data
|
||||
# Used by: docker compose
|
||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
||||
|
||||
# HTTP port
|
||||
# Default: 81
|
||||
# Used by: docker compose
|
||||
STAGING_HTTP_PORT=81
|
||||
|
||||
# MQTT port
|
||||
# Default: 1884
|
||||
# Used by: docker compose
|
||||
STAGING_MQTT_PORT=1884
|
||||
# MeshCore Analyzer — Environment Configuration
|
||||
# Copy to .env and customize. All values have sensible defaults.
|
||||
#
|
||||
# This file is read by BOTH docker compose AND manage.sh — one source of truth.
|
||||
# Each environment keeps config + data together in one directory:
|
||||
# ~/meshcore-data/config.json, meshcore.db, Caddyfile, theme.json
|
||||
# ~/meshcore-staging-data/config.json, meshcore.db, Caddyfile
|
||||
|
||||
# --- Production ---
|
||||
# Data directory (database, theme, etc.)
|
||||
# Default: ~/meshcore-data
|
||||
# Used by: docker compose, manage.sh
|
||||
PROD_DATA_DIR=~/meshcore-data
|
||||
|
||||
# HTTP port for web UI
|
||||
# Default: 80
|
||||
# Used by: docker compose
|
||||
PROD_HTTP_PORT=80
|
||||
|
||||
# HTTPS port for web UI (TLS via Caddy)
|
||||
# Default: 443
|
||||
# Used by: docker compose
|
||||
PROD_HTTPS_PORT=443
|
||||
|
||||
# MQTT port for observer connections
|
||||
# Default: 1883
|
||||
# Used by: docker compose
|
||||
PROD_MQTT_PORT=1883
|
||||
|
||||
# --- Staging (HTTP only, no HTTPS) ---
|
||||
# Data directory
|
||||
# Default: ~/meshcore-staging-data
|
||||
# Used by: docker compose
|
||||
STAGING_DATA_DIR=~/meshcore-staging-data
|
||||
|
||||
# HTTP port
|
||||
# Default: 81
|
||||
# Used by: docker compose
|
||||
STAGING_HTTP_PORT=81
|
||||
|
||||
# MQTT port
|
||||
# Default: 1884
|
||||
# Used by: docker compose
|
||||
STAGING_MQTT_PORT=1884
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
# Force LF line endings for all text files (prevents CRLF churn from Windows agents)
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly mark binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.db binary
|
||||
|
||||
# Squad: union merge for append-only team state files
|
||||
.squad/decisions.md merge=union
|
||||
.squad/agents/*/history.md merge=union
|
||||
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -265,7 +264,8 @@ jobs:
|
||||
|
||||
- name: Deploy staging
|
||||
run: |
|
||||
# Stop old container and release memory
|
||||
# Use docker compose down (not just stop/rm) to properly clean up
|
||||
# the old container, network, and release memory before starting new one
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --timeout 30 2>/dev/null || true
|
||||
|
||||
# Wait for container to be fully gone and OS to reclaim memory (3GB limit)
|
||||
@@ -277,14 +277,14 @@ jobs:
|
||||
done
|
||||
sleep 5 # extra pause for OS memory reclaim
|
||||
|
||||
# Ensure staging data dir exists (config.json lives here, no separate file mount)
|
||||
# Ensure staging config exists (docker creates a directory if bind mount source missing)
|
||||
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
mkdir -p "$STAGING_DATA"
|
||||
|
||||
# If no config exists, copy the example (CI doesn't have a real prod config)
|
||||
# Remove directory-masquerading-as-file left by failed docker mount
|
||||
[ -d "$STAGING_DATA/config.json" ] && rm -rf "$STAGING_DATA/config.json"
|
||||
if [ ! -f "$STAGING_DATA/config.json" ]; then
|
||||
echo "Staging config missing — copying config.example.json"
|
||||
cp config.example.json "$STAGING_DATA/config.json" 2>/dev/null || true
|
||||
echo "Staging config missing — copying from repo config.json"
|
||||
cp config.json "$STAGING_DATA/config.json" 2>/dev/null || cp config.example.json "$STAGING_DATA/config.json"
|
||||
fi
|
||||
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
|
||||
|
||||
@@ -91,12 +91,6 @@ Never use `git add -A` or `git add .`. Always list files explicitly: `git add fi
|
||||
### 10. Don't regress performance
|
||||
The packets page loads 30K+ packets. Don't add per-packet API calls. Don't add O(n²) loops. Client-side filtering is preferred over server-side. If you need data from the server, fetch it once and cache it.
|
||||
|
||||
### 11. PR descriptions must be clean markdown
|
||||
When opening a pull request, the description must be **valid, readable markdown**. Use real newlines (not `\n` literals), proper code fences, and correct heading syntax. Write it using `--body-file -` (piped from a heredoc or file), never inline `--body` with escaped characters. If the description renders as garbage, fix it before requesting review. This is the first thing reviewers see.
|
||||
|
||||
### 12. Post a follow-up comment when review feedback is addressed
|
||||
When you push fixes for review comments, post a comment on the PR listing what was changed and the commit hash. Reviewers should not have to dig through commits to find what was fixed. Format: "Review feedback addressed (commit `abc1234`)" followed by a numbered list of what was done.
|
||||
|
||||
## MeshCore Firmware — Source of Truth
|
||||
|
||||
The MeshCore firmware source is cloned at `firmware/` (gitignored — not part of this repo). This is THE authoritative reference for anything related to the protocol, packet format, device behavior, advert structure, flags, hash sizes, route types, or how repeaters/companions/rooms/sensors behave.
|
||||
|
||||
+1
-4
@@ -9,8 +9,7 @@ ARG BUILD_TIME=unknown
|
||||
# Build server
|
||||
WORKDIR /build/server
|
||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||
COPY go.mod /build/go.mod
|
||||
COPY internal/decoder/ /build/internal/decoder/
|
||||
COPY internal/geofilter/ ../../internal/geofilter/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||
@@ -18,8 +17,6 @@ RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMI
|
||||
# Build ingestor
|
||||
WORKDIR /build/ingestor
|
||||
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
|
||||
COPY go.mod /build/go.mod
|
||||
COPY internal/decoder/ /build/internal/decoder/
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN go build -o /corescope-ingestor .
|
||||
|
||||
+2
-8
@@ -7,20 +7,14 @@ ARG GIT_COMMIT=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
# Build server
|
||||
WORKDIR /build
|
||||
COPY go.mod ./go.mod
|
||||
COPY internal/decoder/ ./internal/decoder/
|
||||
WORKDIR /build/cmd/server
|
||||
WORKDIR /build/server
|
||||
COPY cmd/server/go.mod cmd/server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
|
||||
|
||||
# Build ingestor
|
||||
WORKDIR /build
|
||||
COPY go.mod ./go.mod
|
||||
COPY internal/decoder/ ./internal/decoder/
|
||||
WORKDIR /build/cmd/ingestor
|
||||
WORKDIR /build/ingestor
|
||||
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/meshcore-analyzer/geofilter"
|
||||
)
|
||||
|
||||
// MQTTSource represents a single MQTT broker connection.
|
||||
@@ -34,8 +36,12 @@ type Config struct {
|
||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||
HashChannels []string `json:"hashChannels,omitempty"`
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
}
|
||||
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
type GeoFilterConfig = geofilter.Config
|
||||
|
||||
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
|
||||
+14
-35
@@ -15,12 +15,12 @@ import (
|
||||
|
||||
// DBStats tracks operational metrics for the ingestor database.
|
||||
type DBStats struct {
|
||||
TransmissionsInserted atomic.Int64
|
||||
ObservationsInserted atomic.Int64
|
||||
TransmissionsInserted atomic.Int64
|
||||
ObservationsInserted atomic.Int64
|
||||
DuplicateTransmissions atomic.Int64
|
||||
NodeUpserts atomic.Int64
|
||||
ObserverUpserts atomic.Int64
|
||||
WriteErrors atomic.Int64
|
||||
NodeUpserts atomic.Int64
|
||||
ObserverUpserts atomic.Int64
|
||||
WriteErrors atomic.Int64
|
||||
}
|
||||
|
||||
// Store wraps the SQLite database for packet ingestion.
|
||||
@@ -35,8 +35,8 @@ type Store struct {
|
||||
stmtUpsertNode *sql.Stmt
|
||||
stmtIncrementAdvertCount *sql.Stmt
|
||||
stmtUpsertObserver *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
}
|
||||
|
||||
// OpenStore opens or creates a SQLite DB at the given path, applying the
|
||||
@@ -333,17 +333,13 @@ func (s *Store) prepareStatements() error {
|
||||
}
|
||||
|
||||
s.stmtUpsertObserver, err = s.db.Prepare(`
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, model, firmware, client_version, radio, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count, battery_mv, uptime_secs, noise_floor)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(?, name),
|
||||
iata = COALESCE(?, iata),
|
||||
last_seen = ?,
|
||||
packet_count = packet_count + 1,
|
||||
model = COALESCE(?, model),
|
||||
firmware = COALESCE(?, firmware),
|
||||
client_version = COALESCE(?, client_version),
|
||||
radio = COALESCE(?, radio),
|
||||
battery_mv = COALESCE(?, battery_mv),
|
||||
uptime_secs = COALESCE(?, uptime_secs),
|
||||
noise_floor = COALESCE(?, noise_floor)
|
||||
@@ -489,34 +485,17 @@ func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC
|
||||
|
||||
// ObserverMeta holds optional observer hardware metadata.
|
||||
type ObserverMeta struct {
|
||||
Model *string // e.g., L1
|
||||
Firmware *string // firmware version string
|
||||
ClientVersion *string // client app version string
|
||||
Radio *string // radio chipset/platform string
|
||||
BatteryMv *int // millivolts, always integer
|
||||
UptimeSecs *int64 // seconds, always integer
|
||||
NoiseFloor *float64 // dBm, may have decimals
|
||||
BatteryMv *int // millivolts, always integer
|
||||
UptimeSecs *int64 // seconds, always integer
|
||||
NoiseFloor *float64 // dBm, may have decimals
|
||||
}
|
||||
|
||||
// UpsertObserver inserts or updates an observer with optional hardware metadata.
|
||||
func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var model, firmware, clientVersion, radio interface{}
|
||||
var batteryMv, uptimeSecs, noiseFloor interface{}
|
||||
if meta != nil {
|
||||
if meta.Model != nil {
|
||||
model = *meta.Model
|
||||
}
|
||||
if meta.Firmware != nil {
|
||||
firmware = *meta.Firmware
|
||||
}
|
||||
if meta.ClientVersion != nil {
|
||||
clientVersion = *meta.ClientVersion
|
||||
}
|
||||
if meta.Radio != nil {
|
||||
radio = *meta.Radio
|
||||
}
|
||||
if meta.BatteryMv != nil {
|
||||
batteryMv = *meta.BatteryMv
|
||||
}
|
||||
@@ -529,8 +508,8 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
|
||||
}
|
||||
|
||||
_, err := s.stmtUpsertObserver.Exec(
|
||||
id, name, iata, now, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
name, iata, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
id, name, iata, now, now, batteryMv, uptimeSecs, noiseFloor,
|
||||
name, iata, now, batteryMv, uptimeSecs, noiseFloor,
|
||||
)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
|
||||
+1225
-1315
File diff suppressed because it is too large
Load Diff
+739
-55
@@ -1,55 +1,739 @@
|
||||
package main
|
||||
|
||||
import dec "github.com/corescope/internal/decoder"
|
||||
|
||||
const (
|
||||
RouteTransportFlood = dec.RouteTransportFlood
|
||||
RouteFlood = dec.RouteFlood
|
||||
RouteDirect = dec.RouteDirect
|
||||
RouteTransportDirect = dec.RouteTransportDirect
|
||||
|
||||
PayloadREQ = dec.PayloadREQ
|
||||
PayloadRESPONSE = dec.PayloadRESPONSE
|
||||
PayloadTXT_MSG = dec.PayloadTXT_MSG
|
||||
PayloadACK = dec.PayloadACK
|
||||
PayloadADVERT = dec.PayloadADVERT
|
||||
PayloadGRP_TXT = dec.PayloadGRP_TXT
|
||||
PayloadGRP_DATA = dec.PayloadGRP_DATA
|
||||
PayloadANON_REQ = dec.PayloadANON_REQ
|
||||
PayloadPATH = dec.PayloadPATH
|
||||
PayloadTRACE = dec.PayloadTRACE
|
||||
PayloadMULTIPART = dec.PayloadMULTIPART
|
||||
PayloadCONTROL = dec.PayloadCONTROL
|
||||
PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM
|
||||
)
|
||||
|
||||
type Header = dec.Header
|
||||
type TransportCodes = dec.TransportCodes
|
||||
type Path = dec.Path
|
||||
type AdvertFlags = dec.AdvertFlags
|
||||
type Payload = dec.Payload
|
||||
type DecodedPacket = dec.DecodedPacket
|
||||
|
||||
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
|
||||
return dec.DecodePacket(hexString, channelKeys)
|
||||
}
|
||||
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
return dec.ComputeContentHash(rawHex)
|
||||
}
|
||||
|
||||
func PayloadJSON(p *Payload) string {
|
||||
return dec.PayloadJSON(p)
|
||||
}
|
||||
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
return dec.ValidateAdvert(p)
|
||||
}
|
||||
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
return dec.AdvertRole(f)
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
return dec.EpochToISO(epoch)
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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"`
|
||||
Feat1 *int `json:"feat1,omitempty"`
|
||||
Feat2 *int `json:"feat2,omitempty"`
|
||||
BatteryMv *int `json:"battery_mv,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
ChannelHash int `json:"channelHash,omitempty"`
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Sender string `json:"sender,omitempty"`
|
||||
SenderTimestamp uint32 `json:"sender_timestamp,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 {
|
||||
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat1 = &feat1
|
||||
off += 2
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat2 = &feat2
|
||||
off += 2
|
||||
}
|
||||
if p.Flags.HasName {
|
||||
// Find null terminator to separate name from trailing telemetry bytes
|
||||
nameEnd := len(appdata)
|
||||
for i := off; i < len(appdata); i++ {
|
||||
if appdata[i] == 0x00 {
|
||||
nameEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
name := string(appdata[off:nameEnd])
|
||||
name = sanitizeName(name)
|
||||
p.Name = name
|
||||
off = nameEnd
|
||||
// Skip null terminator(s)
|
||||
for off < len(appdata) && appdata[off] == 0x00 {
|
||||
off++
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
|
||||
// Only sensor nodes (advType=4) carry telemetry bytes.
|
||||
if p.Flags.Sensor && off+4 <= len(appdata) {
|
||||
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
|
||||
tempC := float64(tempRaw) / 100.0
|
||||
if batteryMv > 0 && batteryMv <= 10000 {
|
||||
p.BatteryMv = &batteryMv
|
||||
}
|
||||
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
|
||||
if tempRaw >= -5000 && tempRaw <= 10000 {
|
||||
p.TemperatureC = &tempC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// channelDecryptResult holds the decrypted channel message fields.
|
||||
type channelDecryptResult struct {
|
||||
Timestamp uint32
|
||||
Flags byte
|
||||
Sender string
|
||||
Message string
|
||||
}
|
||||
|
||||
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t).
|
||||
func countNonPrintable(s string) int {
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
if r < 0x20 && r != '\n' && r != '\t' {
|
||||
count++
|
||||
} else if r == utf8.RuneError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// decryptChannelMessage implements MeshCore channel decryption:
|
||||
// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption.
|
||||
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
|
||||
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
|
||||
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calculatedMac := h.Sum(nil)
|
||||
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt (block-by-block, no padding)
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AES cipher: %w", err)
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
|
||||
if len(plaintext) < 5 {
|
||||
return nil, fmt.Errorf("decrypted content too short")
|
||||
}
|
||||
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
flags := plaintext[4]
|
||||
messageText := string(plaintext[5:])
|
||||
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
|
||||
messageText = messageText[:idx]
|
||||
}
|
||||
|
||||
// Validate decrypted text is printable UTF-8 (not binary garbage)
|
||||
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
|
||||
return nil, fmt.Errorf("decrypted text contains non-printable characters")
|
||||
}
|
||||
|
||||
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
|
||||
|
||||
// Parse "sender: message" format
|
||||
colonIdx := strings.Index(messageText, ": ")
|
||||
if colonIdx > 0 && colonIdx < 50 {
|
||||
potentialSender := messageText[:colonIdx]
|
||||
if !strings.ContainsAny(potentialSender, ":[]") {
|
||||
result.Sender = potentialSender
|
||||
result.Message = messageText[colonIdx+2:]
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
channelHash := int(buf[0])
|
||||
channelHashHex := fmt.Sprintf("%02X", buf[0])
|
||||
mac := hex.EncodeToString(buf[1:3])
|
||||
encryptedData := hex.EncodeToString(buf[3:])
|
||||
|
||||
hasKeys := len(channelKeys) > 0
|
||||
// Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars)
|
||||
if hasKeys && len(encryptedData) >= 10 {
|
||||
for name, key := range channelKeys {
|
||||
result, err := decryptChannelMessage(encryptedData, mac, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := result.Message
|
||||
if result.Sender != "" && result.Message != "" {
|
||||
text = result.Sender + ": " + result.Message
|
||||
}
|
||||
return Payload{
|
||||
Type: "CHAN",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
Sender: result.Sender,
|
||||
Text: text,
|
||||
SenderTimestamp: result.Timestamp,
|
||||
}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decryption_failed",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "no_key",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
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, channelKeys map[string]string) 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, channelKeys)
|
||||
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, channelKeys map[string]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, channelKeys)
|
||||
|
||||
// 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).
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
// Go time from Unix epoch
|
||||
t := unixTime(int64(epoch))
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "github.com/meshcore-analyzer/geofilter"
|
||||
|
||||
// NodePassesGeoFilter returns true if the node should be kept.
|
||||
// Nodes with no GPS coordinates are always allowed.
|
||||
func NodePassesGeoFilter(lat, lon *float64, gf *GeoFilterConfig) bool {
|
||||
if gf == nil {
|
||||
return true
|
||||
}
|
||||
if lat == nil || lon == nil {
|
||||
return true
|
||||
}
|
||||
return geofilter.PassesFilter(*lat, *lon, gf)
|
||||
}
|
||||
+3
-3
@@ -3,11 +3,13 @@ module github.com/corescope/ingestor
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/corescope v0.0.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -22,5 +24,3 @@ require (
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/corescope => ../..
|
||||
|
||||
+36
-51
@@ -136,7 +136,7 @@ func main() {
|
||||
// Capture source for closure
|
||||
src := source
|
||||
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
|
||||
handleMessage(store, tag, src, m, channelKeys)
|
||||
handleMessage(store, tag, src, m, channelKeys, cfg.GeoFilter)
|
||||
})
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
@@ -170,7 +170,7 @@ func main() {
|
||||
log.Println("Done.")
|
||||
}
|
||||
|
||||
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string) {
|
||||
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, geoFilter *GeoFilterConfig) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
|
||||
@@ -251,33 +251,43 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
mqttMsg.Origin = v
|
||||
}
|
||||
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
isNew, err := store.InsertTransmission(pktData)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
|
||||
// Process ADVERT → upsert node
|
||||
// For ADVERT packets with known coordinates, enforce geo_filter before
|
||||
// storing anything — drop the entire message if outside the area.
|
||||
if decoded.Header.PayloadTypeName == "ADVERT" && decoded.Payload.PubKey != "" {
|
||||
ok, reason := ValidateAdvert(&decoded.Payload)
|
||||
if ok {
|
||||
role := advertRole(decoded.Payload.Flags)
|
||||
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
|
||||
}
|
||||
if isNew {
|
||||
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
|
||||
log.Printf("MQTT [%s] advert count error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
// Update telemetry if present in advert
|
||||
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
|
||||
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
|
||||
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !ok {
|
||||
log.Printf("MQTT [%s] skipping corrupted ADVERT: %s", tag, reason)
|
||||
return
|
||||
}
|
||||
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, geoFilter) {
|
||||
return
|
||||
}
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
isNew, err := store.InsertTransmission(pktData)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
role := advertRole(decoded.Payload.Flags)
|
||||
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
|
||||
}
|
||||
if isNew {
|
||||
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
|
||||
log.Printf("MQTT [%s] advert count error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
// Update telemetry if present in advert
|
||||
if decoded.Payload.BatteryMv != nil || decoded.Payload.TemperatureC != nil {
|
||||
if err := store.UpdateNodeTelemetry(decoded.Payload.PubKey, decoded.Payload.BatteryMv, decoded.Payload.TemperatureC); err != nil {
|
||||
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-ADVERT packets: store normally (routing/channel messages from
|
||||
// in-area observers are relevant regardless of relay hop origin).
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
if _, err := store.InsertTransmission(pktData); err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,31 +486,6 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
||||
meta := &ObserverMeta{}
|
||||
hasData := false
|
||||
|
||||
if v, ok := msg["model"].(string); ok && v != "" {
|
||||
meta.Model = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["firmware"].(string); ok && v != "" {
|
||||
meta.Firmware = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["firmware_version"].(string); ok && v != "" {
|
||||
meta.Firmware = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["client_version"].(string); ok && v != "" {
|
||||
meta.ClientVersion = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["clientVersion"].(string); ok && v != "" {
|
||||
meta.ClientVersion = &v
|
||||
hasData = true
|
||||
}
|
||||
if v, ok := msg["radio"].(string); ok && v != "" {
|
||||
meta.Radio = &v
|
||||
hasData = true
|
||||
}
|
||||
|
||||
if v, ok := msg["battery_mv"]; ok {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int(math.Round(f))
|
||||
|
||||
+22
-55
@@ -124,7 +124,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -141,7 +141,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// Should create a node from the ADVERT
|
||||
var count int
|
||||
@@ -163,7 +163,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
|
||||
|
||||
// Should not panic
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -177,13 +177,13 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
source := MQTTSource{Name: "test"}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"origin":"MyObserver","model":"L1","firmware_version":"v1.2.3","client_version":"2.4.1","radio":"SX1262"}`),
|
||||
payload: []byte(`{"origin":"MyObserver"}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var name, iata, model, firmware, clientVersion, radio string
|
||||
err := store.db.QueryRow("SELECT name, iata, model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").Scan(&name, &iata, &model, &firmware, &clientVersion, &radio)
|
||||
var name, iata string
|
||||
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,39 +193,6 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
if iata != "SJC" {
|
||||
t.Errorf("iata=%s, want SJC", iata)
|
||||
}
|
||||
if model != "L1" {
|
||||
t.Errorf("model=%s, want L1", model)
|
||||
}
|
||||
if firmware != "v1.2.3" {
|
||||
t.Errorf("firmware=%s, want v1.2.3", firmware)
|
||||
}
|
||||
if clientVersion != "2.4.1" {
|
||||
t.Errorf("client_version=%s, want 2.4.1", clientVersion)
|
||||
}
|
||||
if radio != "SX1262" {
|
||||
t.Errorf("radio=%s, want SX1262", radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageStatusTopicMissingIdentityFields(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
msg := &mockMessage{
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"origin":"MyObserver","battery_mv":3500}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
|
||||
var model, firmware, clientVersion, radio interface{}
|
||||
err := store.db.QueryRow("SELECT model, firmware, client_version, radio FROM observers WHERE id = 'obs1'").
|
||||
Scan(&model, &firmware, &clientVersion, &radio)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model != nil || firmware != nil || clientVersion != nil || radio != nil {
|
||||
t.Errorf("identity fields should remain NULL when absent: model=%v firmware=%v client_version=%v radio=%v", model, firmware, clientVersion, radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
||||
@@ -234,11 +201,11 @@ func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
||||
|
||||
// meshcore/status should be skipped
|
||||
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
|
||||
handleMessage(store, "test", source, msg1, nil)
|
||||
handleMessage(store, "test", source, msg1, nil, nil)
|
||||
|
||||
// meshcore/events/connection should be skipped
|
||||
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
|
||||
handleMessage(store, "test", source, msg2, nil)
|
||||
handleMessage(store, "test", source, msg2, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -257,7 +224,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -270,7 +237,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
||||
topic: "meshcore/LAX/obs2/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg2, nil)
|
||||
handleMessage(store, "test", source, msg2, nil, nil)
|
||||
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
if count != 1 {
|
||||
@@ -288,7 +255,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
|
||||
topic: "meshcore",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// No region part → filter doesn't apply, message goes through
|
||||
// Actually the code checks len(parts) > 1 for IATA filter
|
||||
@@ -304,7 +271,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"type":"companion","data":"something"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -322,7 +289,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"ZZZZ"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -339,7 +306,7 @@ func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
@@ -358,7 +325,7 @@ func TestHandleMessageMinimalTopic(t *testing.T) {
|
||||
topic: "meshcore/SJC",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -379,7 +346,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// Transmission should be inserted (even if advert is invalid)
|
||||
var count int
|
||||
@@ -405,7 +372,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
|
||||
topic: "packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -427,7 +394,7 @@ func TestHandleMessageSNRNotFloat(t *testing.T) {
|
||||
// SNR as a string value — should not parse as float
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -443,7 +410,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
// Verify origin was extracted to observer name
|
||||
var name string
|
||||
@@ -466,7 +433,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should not panic — the defer/recover should catch it
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
}
|
||||
|
||||
func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||
@@ -478,7 +445,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"type":"status"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil)
|
||||
handleMessage(store, "test", source, msg, nil, nil)
|
||||
|
||||
var name string
|
||||
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
||||
|
||||
+6
-79
@@ -2,10 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/meshcore-analyzer/geofilter"
|
||||
)
|
||||
|
||||
// Config mirrors the Node.js config.json structure (read-only fields).
|
||||
@@ -51,8 +51,6 @@ type Config struct {
|
||||
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
|
||||
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
|
||||
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
|
||||
}
|
||||
|
||||
// PacketStoreConfig controls in-memory packet store limits.
|
||||
@@ -61,35 +59,12 @@ type PacketStoreConfig struct {
|
||||
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
|
||||
}
|
||||
|
||||
type GeoFilterConfig struct {
|
||||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||||
BufferKm float64 `json:"bufferKm,omitempty"`
|
||||
LatMin *float64 `json:"latMin,omitempty"`
|
||||
LatMax *float64 `json:"latMax,omitempty"`
|
||||
LonMin *float64 `json:"lonMin,omitempty"`
|
||||
LonMax *float64 `json:"lonMax,omitempty"`
|
||||
}
|
||||
|
||||
type TimestampConfig struct {
|
||||
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
|
||||
Timezone string `json:"timezone"` // "local" | "utc"
|
||||
FormatPreset string `json:"formatPreset"` // "iso" | "iso-seconds" | "locale"
|
||||
CustomFormat string `json:"customFormat"` // freeform, only used when AllowCustomFormat=true
|
||||
AllowCustomFormat bool `json:"allowCustomFormat"` // admin gate
|
||||
}
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
type GeoFilterConfig = geofilter.Config
|
||||
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
}
|
||||
|
||||
func defaultTimestampConfig() TimestampConfig {
|
||||
return TimestampConfig{
|
||||
DefaultMode: "ago",
|
||||
Timezone: "local",
|
||||
FormatPreset: "iso",
|
||||
CustomFormat: "",
|
||||
AllowCustomFormat: false,
|
||||
}
|
||||
NodeDays int `json:"nodeDays"`
|
||||
PacketDays int `json:"packetDays"`
|
||||
}
|
||||
|
||||
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
|
||||
@@ -136,10 +111,8 @@ func LoadConfig(baseDirs ...string) (*Config, error) {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
cfg.NormalizeTimestampConfig()
|
||||
return cfg, nil
|
||||
}
|
||||
cfg.NormalizeTimestampConfig()
|
||||
return cfg, nil // defaults
|
||||
}
|
||||
|
||||
@@ -227,49 +200,3 @@ func (c *Config) PropagationBufferMs() int {
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeTimestampConfig() {
|
||||
defaults := defaultTimestampConfig()
|
||||
if c.Timestamps == nil {
|
||||
log.Printf("[config] timestamps not configured — using defaults (ago/local/iso)")
|
||||
c.Timestamps = &defaults
|
||||
return
|
||||
}
|
||||
|
||||
origMode := c.Timestamps.DefaultMode
|
||||
mode := strings.ToLower(strings.TrimSpace(origMode))
|
||||
switch mode {
|
||||
case "ago", "absolute":
|
||||
c.Timestamps.DefaultMode = mode
|
||||
default:
|
||||
log.Printf("[config] warning: timestamps.defaultMode=%q is invalid, using %q", origMode, defaults.DefaultMode)
|
||||
c.Timestamps.DefaultMode = defaults.DefaultMode
|
||||
}
|
||||
|
||||
origTimezone := c.Timestamps.Timezone
|
||||
timezone := strings.ToLower(strings.TrimSpace(origTimezone))
|
||||
switch timezone {
|
||||
case "local", "utc":
|
||||
c.Timestamps.Timezone = timezone
|
||||
default:
|
||||
log.Printf("[config] warning: timestamps.timezone=%q is invalid, using %q", origTimezone, defaults.Timezone)
|
||||
c.Timestamps.Timezone = defaults.Timezone
|
||||
}
|
||||
|
||||
origPreset := c.Timestamps.FormatPreset
|
||||
formatPreset := strings.ToLower(strings.TrimSpace(origPreset))
|
||||
switch formatPreset {
|
||||
case "iso", "iso-seconds", "locale":
|
||||
c.Timestamps.FormatPreset = formatPreset
|
||||
default:
|
||||
log.Printf("[config] warning: timestamps.formatPreset=%q is invalid, using %q", origPreset, defaults.FormatPreset)
|
||||
c.Timestamps.FormatPreset = defaults.FormatPreset
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetTimestampConfig() TimestampConfig {
|
||||
if c == nil || c.Timestamps == nil {
|
||||
return defaultTimestampConfig()
|
||||
}
|
||||
return *c.Timestamps
|
||||
}
|
||||
|
||||
@@ -31,13 +31,6 @@ func TestLoadConfigValidJSON(t *testing.T) {
|
||||
"liveMap": map[string]interface{}{
|
||||
"propagationBufferMs": 3000,
|
||||
},
|
||||
"timestamps": map[string]interface{}{
|
||||
"defaultMode": "absolute",
|
||||
"timezone": "utc",
|
||||
"formatPreset": "iso-seconds",
|
||||
"customFormat": "2006-01-02 15:04:05",
|
||||
"allowCustomFormat": true,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(cfgData)
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
||||
@@ -55,18 +48,6 @@ func TestLoadConfigValidJSON(t *testing.T) {
|
||||
if cfg.MapDefaults.Zoom != 12 {
|
||||
t.Errorf("expected zoom 12, got %d", cfg.MapDefaults.Zoom)
|
||||
}
|
||||
if cfg.Timestamps == nil {
|
||||
t.Fatal("expected timestamps config")
|
||||
}
|
||||
if cfg.Timestamps.DefaultMode != "absolute" {
|
||||
t.Errorf("expected defaultMode absolute, got %s", cfg.Timestamps.DefaultMode)
|
||||
}
|
||||
if cfg.Timestamps.Timezone != "utc" {
|
||||
t.Errorf("expected timezone utc, got %s", cfg.Timestamps.Timezone)
|
||||
}
|
||||
if cfg.Timestamps.FormatPreset != "iso-seconds" {
|
||||
t.Errorf("expected formatPreset iso-seconds, got %s", cfg.Timestamps.FormatPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromDataSubdir(t *testing.T) {
|
||||
@@ -95,10 +76,6 @@ func TestLoadConfigNoFiles(t *testing.T) {
|
||||
if cfg.Port != 3000 {
|
||||
t.Errorf("expected default port 3000, got %d", cfg.Port)
|
||||
}
|
||||
ts := cfg.GetTimestampConfig()
|
||||
if ts.DefaultMode != "ago" || ts.Timezone != "local" || ts.FormatPreset != "iso" {
|
||||
t.Errorf("expected default timestamp config ago/local/iso, got %s/%s/%s", ts.DefaultMode, ts.Timezone, ts.FormatPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInvalidJSON(t *testing.T) {
|
||||
@@ -125,36 +102,6 @@ func TestLoadConfigNoArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigTimestampNormalization(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgData := map[string]interface{}{
|
||||
"timestamps": map[string]interface{}{
|
||||
"defaultMode": "banana",
|
||||
"timezone": "mars",
|
||||
"formatPreset": "weird",
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(cfgData)
|
||||
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Timestamps == nil {
|
||||
t.Fatal("expected timestamps to be set")
|
||||
}
|
||||
if cfg.Timestamps.DefaultMode != "ago" {
|
||||
t.Errorf("expected normalized defaultMode ago, got %s", cfg.Timestamps.DefaultMode)
|
||||
}
|
||||
if cfg.Timestamps.Timezone != "local" {
|
||||
t.Errorf("expected normalized timezone local, got %s", cfg.Timestamps.Timezone)
|
||||
}
|
||||
if cfg.Timestamps.FormatPreset != "iso" {
|
||||
t.Errorf("expected normalized formatPreset iso, got %s", cfg.Timestamps.FormatPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadThemeValidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
themeData := map[string]interface{}{
|
||||
|
||||
@@ -1569,3 +1569,39 @@ func nullInt(ni sql.NullInt64) interface{} {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneOldPackets deletes transmissions and their observations older than the
|
||||
// given number of days. Nodes and observers are never touched.
|
||||
// Returns the number of transmissions deleted.
|
||||
// Opens a separate read-write connection since the main connection is read-only.
|
||||
func (db *DB) PruneOldPackets(days int) (int64, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
defer rw.Close()
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete observations linked to old transmissions first (no CASCADE in SQLite)
|
||||
_, err = tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
|
||||
SELECT id FROM transmissions WHERE first_seen < ?
|
||||
)`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, tx.Commit()
|
||||
}
|
||||
|
||||
+537
-47
@@ -1,47 +1,537 @@
|
||||
package main
|
||||
|
||||
import dec "github.com/corescope/internal/decoder"
|
||||
|
||||
const (
|
||||
RouteTransportFlood = dec.RouteTransportFlood
|
||||
RouteFlood = dec.RouteFlood
|
||||
RouteDirect = dec.RouteDirect
|
||||
RouteTransportDirect = dec.RouteTransportDirect
|
||||
|
||||
PayloadREQ = dec.PayloadREQ
|
||||
PayloadRESPONSE = dec.PayloadRESPONSE
|
||||
PayloadTXT_MSG = dec.PayloadTXT_MSG
|
||||
PayloadACK = dec.PayloadACK
|
||||
PayloadADVERT = dec.PayloadADVERT
|
||||
PayloadGRP_TXT = dec.PayloadGRP_TXT
|
||||
PayloadGRP_DATA = dec.PayloadGRP_DATA
|
||||
PayloadANON_REQ = dec.PayloadANON_REQ
|
||||
PayloadPATH = dec.PayloadPATH
|
||||
PayloadTRACE = dec.PayloadTRACE
|
||||
PayloadMULTIPART = dec.PayloadMULTIPART
|
||||
PayloadCONTROL = dec.PayloadCONTROL
|
||||
PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM
|
||||
)
|
||||
|
||||
type Header = dec.Header
|
||||
type TransportCodes = dec.TransportCodes
|
||||
type Path = dec.Path
|
||||
type AdvertFlags = dec.AdvertFlags
|
||||
type Payload = dec.Payload
|
||||
type DecodedPacket = dec.DecodedPacket
|
||||
|
||||
func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
return dec.DecodePacket(hexString, nil)
|
||||
}
|
||||
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
return dec.ComputeContentHash(rawHex)
|
||||
}
|
||||
|
||||
func PayloadJSON(p *Payload) string {
|
||||
return dec.PayloadJSON(p)
|
||||
}
|
||||
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
return dec.ValidateAdvert(p)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import "github.com/meshcore-analyzer/geofilter"
|
||||
|
||||
// NodePassesGeoFilter returns true if the node should be included in responses.
|
||||
// Nodes with no GPS coordinates are always allowed.
|
||||
// lat and lon are interface{} because they come from DB row maps.
|
||||
func NodePassesGeoFilter(lat, lon interface{}, gf *GeoFilterConfig) bool {
|
||||
if gf == nil {
|
||||
return true
|
||||
}
|
||||
latF, ok1 := toFloat64(lat)
|
||||
lonF, ok2 := toFloat64(lon)
|
||||
if !ok1 || !ok2 {
|
||||
return true
|
||||
}
|
||||
return geofilter.PassesFilter(latF, lonF, gf)
|
||||
}
|
||||
|
||||
func toFloat64(v interface{}) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
+3
-3
@@ -3,12 +3,14 @@ module github.com/corescope/server
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/corescope v0.0.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/meshcore-analyzer/geofilter v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -20,5 +22,3 @@ require (
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/corescope => ../..
|
||||
|
||||
+21
-3
@@ -100,9 +100,6 @@ func main() {
|
||||
if dbPath != "" {
|
||||
cfg.DBPath = dbPath
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
log.Printf("[security] WARNING: no apiKey configured — write endpoints are BLOCKED (set apiKey in config.json to enable them)")
|
||||
}
|
||||
|
||||
// Resolve DB path
|
||||
resolvedDB := cfg.ResolveDBPath(configDir)
|
||||
@@ -171,6 +168,27 @@ func main() {
|
||||
stopEviction := store.StartEvictionTicker()
|
||||
defer stopEviction()
|
||||
|
||||
// Auto-prune old packets if retention.packetDays is configured
|
||||
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
|
||||
days := cfg.Retention.PacketDays
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
for range time.Tick(24 * time.Hour) {
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
|
||||
+48
-27
@@ -30,13 +30,13 @@ type Server struct {
|
||||
buildTime string
|
||||
|
||||
// Cached runtime.MemStats to avoid stop-the-world pauses on every health check
|
||||
memStatsMu sync.Mutex
|
||||
memStatsCache runtime.MemStats
|
||||
memStatsMu sync.Mutex
|
||||
memStatsCache runtime.MemStats
|
||||
memStatsCachedAt time.Time
|
||||
|
||||
// Cached /api/stats response — recomputed at most once every 10s
|
||||
statsMu sync.Mutex
|
||||
statsCache *StatsResponse
|
||||
statsMu sync.Mutex
|
||||
statsCache *StatsResponse
|
||||
statsCachedAt time.Time
|
||||
}
|
||||
|
||||
@@ -108,13 +108,14 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
|
||||
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
||||
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
||||
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
|
||||
r.HandleFunc("/api/perf/reset", s.handlePerfReset).Methods("POST")
|
||||
r.HandleFunc("/api/admin/prune", s.handleAdminPrune).Methods("POST")
|
||||
|
||||
// Packet endpoints
|
||||
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
|
||||
r.HandleFunc("/api/packets/{id}", s.handlePacketDetail).Methods("GET")
|
||||
r.HandleFunc("/api/packets", s.handlePackets).Methods("GET")
|
||||
r.Handle("/api/packets", s.requireAPIKey(http.HandlerFunc(s.handlePostPacket))).Methods("POST")
|
||||
r.HandleFunc("/api/packets", s.handlePostPacket).Methods("POST")
|
||||
|
||||
// Decode endpoint
|
||||
r.HandleFunc("/api/decode", s.handleDecode).Methods("POST")
|
||||
@@ -201,20 +202,6 @@ func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg == nil || s.cfg.APIKey == "" {
|
||||
writeError(w, http.StatusForbidden, "write endpoints disabled — set apiKey in config.json")
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-API-Key") != s.cfg.APIKey {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Config Handlers ---
|
||||
|
||||
func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -239,7 +226,6 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||
CacheInvalidateMs: s.cfg.CacheInvalidMs,
|
||||
ExternalUrls: s.cfg.ExternalUrls,
|
||||
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
|
||||
Timestamps: s.cfg.GetTimestampConfig(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,6 +548,31 @@ func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, OkResp{Ok: true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.APIKey != "" && r.Header.Get("X-API-Key") != s.cfg.APIKey {
|
||||
writeError(w, 401, "invalid or missing API key")
|
||||
return
|
||||
}
|
||||
days := 0
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
fmt.Sscanf(d, "%d", &days)
|
||||
}
|
||||
if days <= 0 && s.cfg.Retention != nil {
|
||||
days = s.cfg.Retention.PacketDays
|
||||
}
|
||||
if days <= 0 {
|
||||
writeError(w, 400, "days parameter required (or set retention.packetDays in config)")
|
||||
return
|
||||
}
|
||||
n, err := s.db.PruneOldPackets(days)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
writeJSON(w, map[string]interface{}{"deleted": n, "days": days})
|
||||
}
|
||||
|
||||
// --- Packet Handlers ---
|
||||
|
||||
func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -855,6 +866,16 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.GeoFilter != nil {
|
||||
filtered := nodes[:0]
|
||||
for _, node := range nodes {
|
||||
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
}
|
||||
total = len(filtered)
|
||||
nodes = filtered
|
||||
}
|
||||
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
|
||||
}
|
||||
|
||||
@@ -1181,7 +1202,7 @@ func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"total": 0,
|
||||
"total": 0,
|
||||
"distribution": map[string]int{"1": 0, "2": 0, "3": 0},
|
||||
"distributionByRepeaters": map[string]int{"1": 0, "2": 0, "3": 0},
|
||||
"hourly": []HashSizeHourly{},
|
||||
@@ -1352,12 +1373,12 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
|
||||
ID: o.ID, Name: o.Name, IATA: o.IATA,
|
||||
LastSeen: o.LastSeen, FirstSeen: o.FirstSeen,
|
||||
PacketCount: o.PacketCount,
|
||||
Model: o.Model, Firmware: o.Firmware,
|
||||
Model: o.Model, Firmware: o.Firmware,
|
||||
ClientVersion: o.ClientVersion, Radio: o.Radio,
|
||||
BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs,
|
||||
NoiseFloor: o.NoiseFloor,
|
||||
NoiseFloor: o.NoiseFloor,
|
||||
PacketsLastHour: plh,
|
||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||||
})
|
||||
}
|
||||
writeJSON(w, ObserverListResponse{
|
||||
@@ -1386,10 +1407,10 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ID: obs.ID, Name: obs.Name, IATA: obs.IATA,
|
||||
LastSeen: obs.LastSeen, FirstSeen: obs.FirstSeen,
|
||||
PacketCount: obs.PacketCount,
|
||||
Model: obs.Model, Firmware: obs.Firmware,
|
||||
Model: obs.Model, Firmware: obs.Firmware,
|
||||
ClientVersion: obs.ClientVersion, Radio: obs.Radio,
|
||||
BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs,
|
||||
NoiseFloor: obs.NoiseFloor,
|
||||
NoiseFloor: obs.NoiseFloor,
|
||||
PacketsLastHour: plh,
|
||||
})
|
||||
}
|
||||
|
||||
+162
-359
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -28,94 +27,6 @@ func setupTestServer(t *testing.T) (*Server, *mux.Router) {
|
||||
return srv, router
|
||||
}
|
||||
|
||||
func setupTestServerWithAPIKey(t *testing.T, apiKey string) (*Server, *mux.Router) {
|
||||
t.Helper()
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
cfg := &Config{Port: 3000, APIKey: apiKey}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
return srv, router
|
||||
}
|
||||
|
||||
func TestWriteEndpointsRequireAPIKey(t *testing.T) {
|
||||
_, router := setupTestServerWithAPIKey(t, "test-secret")
|
||||
|
||||
t.Run("perf reset missing key returns 401", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/perf/reset", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["error"] != "unauthorized" {
|
||||
t.Fatalf("expected unauthorized error, got %v", body["error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("packets post missing key returns 401", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/packets", bytes.NewBufferString(`{"raw":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode succeeds without key", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteEndpointsBlockWhenAPIKeyEmpty(t *testing.T) {
|
||||
_, router := setupTestServerWithAPIKey(t, "")
|
||||
|
||||
t.Run("perf reset blocked when api key unset", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/perf/reset", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("packets post blocked when api key unset", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/packets", bytes.NewBufferString(`{"raw":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 with empty apiKey, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode remains open when api key unset", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/decode", bytes.NewBufferString(`{"hex":"0200"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/health", nil)
|
||||
@@ -1276,19 +1187,6 @@ func TestConfigClientEndpoint(t *testing.T) {
|
||||
if body["propagationBufferMs"] == nil {
|
||||
t.Error("expected propagationBufferMs")
|
||||
}
|
||||
tsRaw, ok := body["timestamps"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected timestamps object")
|
||||
}
|
||||
if tsRaw["defaultMode"] != "ago" {
|
||||
t.Errorf("expected timestamps.defaultMode=ago, got %v", tsRaw["defaultMode"])
|
||||
}
|
||||
if tsRaw["timezone"] != "local" {
|
||||
t.Errorf("expected timestamps.timezone=local, got %v", tsRaw["timezone"])
|
||||
}
|
||||
if tsRaw["formatPreset"] != "iso" {
|
||||
t.Errorf("expected timestamps.formatPreset=iso, got %v", tsRaw["formatPreset"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRegionsEndpoint(t *testing.T) {
|
||||
@@ -1641,6 +1539,7 @@ func TestHandlerErrorPaths(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
|
||||
t.Run("stats error", func(t *testing.T) {
|
||||
db.conn.Exec("DROP TABLE IF EXISTS transmissions")
|
||||
req := httptest.NewRequest("GET", "/api/stats", nil)
|
||||
@@ -1861,239 +1760,197 @@ func TestHandlerErrorBulkHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestAnalyticsChannelsNoNullArrays(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
raw := w.Body.String()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
raw := w.Body.String()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
|
||||
for _, field := range arrayFields {
|
||||
val, exists := body[field]
|
||||
if !exists {
|
||||
t.Errorf("missing field %q", field)
|
||||
continue
|
||||
}
|
||||
if val == nil {
|
||||
t.Errorf("field %q is null, expected empty array []", field)
|
||||
continue
|
||||
}
|
||||
if _, ok := val.([]interface{}); !ok {
|
||||
t.Errorf("field %q is not an array, got %T", field, val)
|
||||
}
|
||||
}
|
||||
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
|
||||
for _, field := range arrayFields {
|
||||
val, exists := body[field]
|
||||
if !exists {
|
||||
t.Errorf("missing field %q", field)
|
||||
continue
|
||||
}
|
||||
if val == nil {
|
||||
t.Errorf("field %q is null, expected empty array []", field)
|
||||
continue
|
||||
}
|
||||
if _, ok := val.([]interface{}); !ok {
|
||||
t.Errorf("field %q is not an array, got %T", field, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsChannelsNoStoreFallbackNoNulls(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/channels", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
|
||||
for _, field := range arrayFields {
|
||||
if body[field] == nil {
|
||||
t.Errorf("field %q is null in DB fallback, expected []", field)
|
||||
}
|
||||
}
|
||||
arrayFields := []string{"channels", "topSenders", "channelTimeline", "msgLengths"}
|
||||
for _, field := range arrayFields {
|
||||
if body[field] == nil {
|
||||
t.Errorf("field %q is null in DB fallback, expected []", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeHashSizeEnrichment(t *testing.T) {
|
||||
t.Run("nil info leaves defaults", func(t *testing.T) {
|
||||
node := map[string]interface{}{
|
||||
"public_key": "abc123",
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
}
|
||||
EnrichNodeWithHashSize(node, nil)
|
||||
if node["hash_size"] != nil {
|
||||
t.Error("expected hash_size to remain nil with nil info")
|
||||
}
|
||||
})
|
||||
t.Run("nil info leaves defaults", func(t *testing.T) {
|
||||
node := map[string]interface{}{
|
||||
"public_key": "abc123",
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
}
|
||||
EnrichNodeWithHashSize(node, nil)
|
||||
if node["hash_size"] != nil {
|
||||
t.Error("expected hash_size to remain nil with nil info")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enriches with computed data", func(t *testing.T) {
|
||||
node := map[string]interface{}{
|
||||
"public_key": "abc123",
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
}
|
||||
info := &hashSizeNodeInfo{
|
||||
HashSize: 2,
|
||||
AllSizes: map[int]bool{1: true, 2: true},
|
||||
Seq: []int{1, 2, 1, 2},
|
||||
Inconsistent: true,
|
||||
}
|
||||
EnrichNodeWithHashSize(node, info)
|
||||
if node["hash_size"] != 2 {
|
||||
t.Errorf("expected hash_size 2, got %v", node["hash_size"])
|
||||
}
|
||||
if node["hash_size_inconsistent"] != true {
|
||||
t.Error("expected hash_size_inconsistent true")
|
||||
}
|
||||
sizes, ok := node["hash_sizes_seen"].([]int)
|
||||
if !ok {
|
||||
t.Fatal("expected hash_sizes_seen to be []int")
|
||||
}
|
||||
if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 2 {
|
||||
t.Errorf("expected [1,2], got %v", sizes)
|
||||
}
|
||||
})
|
||||
t.Run("enriches with computed data", func(t *testing.T) {
|
||||
node := map[string]interface{}{
|
||||
"public_key": "abc123",
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
}
|
||||
info := &hashSizeNodeInfo{
|
||||
HashSize: 2,
|
||||
AllSizes: map[int]bool{1: true, 2: true},
|
||||
Seq: []int{1, 2, 1, 2},
|
||||
Inconsistent: true,
|
||||
}
|
||||
EnrichNodeWithHashSize(node, info)
|
||||
if node["hash_size"] != 2 {
|
||||
t.Errorf("expected hash_size 2, got %v", node["hash_size"])
|
||||
}
|
||||
if node["hash_size_inconsistent"] != true {
|
||||
t.Error("expected hash_size_inconsistent true")
|
||||
}
|
||||
sizes, ok := node["hash_sizes_seen"].([]int)
|
||||
if !ok {
|
||||
t.Fatal("expected hash_sizes_seen to be []int")
|
||||
}
|
||||
if len(sizes) != 2 || sizes[0] != 1 || sizes[1] != 2 {
|
||||
t.Errorf("expected [1,2], got %v", sizes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single size omits sizes_seen", func(t *testing.T) {
|
||||
node := map[string]interface{}{
|
||||
"public_key": "abc123",
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
}
|
||||
info := &hashSizeNodeInfo{
|
||||
HashSize: 3,
|
||||
AllSizes: map[int]bool{3: true},
|
||||
Seq: []int{3, 3, 3},
|
||||
}
|
||||
EnrichNodeWithHashSize(node, info)
|
||||
if node["hash_size"] != 3 {
|
||||
t.Errorf("expected hash_size 3, got %v", node["hash_size"])
|
||||
}
|
||||
if node["hash_size_inconsistent"] != false {
|
||||
t.Error("expected hash_size_inconsistent false")
|
||||
}
|
||||
if _, exists := node["hash_sizes_seen"]; exists {
|
||||
t.Error("hash_sizes_seen should not be set for single size")
|
||||
}
|
||||
})
|
||||
t.Run("single size omits sizes_seen", func(t *testing.T) {
|
||||
node := map[string]interface{}{
|
||||
"public_key": "abc123",
|
||||
"hash_size": nil,
|
||||
"hash_size_inconsistent": false,
|
||||
}
|
||||
info := &hashSizeNodeInfo{
|
||||
HashSize: 3,
|
||||
AllSizes: map[int]bool{3: true},
|
||||
Seq: []int{3, 3, 3},
|
||||
}
|
||||
EnrichNodeWithHashSize(node, info)
|
||||
if node["hash_size"] != 3 {
|
||||
t.Errorf("expected hash_size 3, got %v", node["hash_size"])
|
||||
}
|
||||
if node["hash_size_inconsistent"] != false {
|
||||
t.Error("expected hash_size_inconsistent false")
|
||||
}
|
||||
if _, exists := node["hash_sizes_seen"]; exists {
|
||||
t.Error("hash_sizes_seen should not be set for single size")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoFlipFlop(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"TestNode","pubKey":"` + pk + `"}`
|
||||
raw1 := "04" + "00" + "aabb"
|
||||
raw2 := "04" + "40" + "aabb"
|
||||
|
||||
payloadType := 4
|
||||
for i := 0; i < 3; i++ {
|
||||
rawHex := raw1
|
||||
if i%2 == 1 {
|
||||
rawHex = raw2
|
||||
}
|
||||
tx := &StoreTx{
|
||||
ID: 9000 + i,
|
||||
RawHex: rawHex,
|
||||
Hash: "testhash" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if len(ni.AllSizes) != 2 {
|
||||
t.Errorf("expected 2 unique sizes, got %d", len(ni.AllSizes))
|
||||
}
|
||||
if !ni.Inconsistent {
|
||||
t.Error("expected inconsistent flag to be true for flip-flop pattern")
|
||||
}
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
func TestGetNodeHashSizeInfoDominant(t *testing.T) {
|
||||
// A node that sends mostly 2-byte adverts but occasionally 1-byte (pathByte=0x00
|
||||
// on direct sends) should report HashSize=2, not 1.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk)
|
||||
|
||||
pk := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'Repeater2B', 'repeater')", pk)
|
||||
decoded := `{"name":"TestNode","pubKey":"` + pk + `"}`
|
||||
raw1 := "04" + "00" + "aabb"
|
||||
raw2 := "04" + "40" + "aabb"
|
||||
|
||||
decoded := `{"name":"Repeater2B","pubKey":"` + pk + `"}`
|
||||
raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, no hops)
|
||||
raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2
|
||||
payloadType := 4
|
||||
for i := 0; i < 3; i++ {
|
||||
rawHex := raw1
|
||||
if i%2 == 1 {
|
||||
rawHex = raw2
|
||||
}
|
||||
tx := &StoreTx{
|
||||
ID: 9000 + i,
|
||||
RawHex: rawHex,
|
||||
Hash: "testhash" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
payloadType := 4
|
||||
// 1 packet with hashSize=1, 4 packets with hashSize=2
|
||||
raws := []string{raw1byte, raw2byte, raw2byte, raw2byte, raw2byte}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 8000 + i,
|
||||
RawHex: raw,
|
||||
Hash: "dominant" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if ni.HashSize != 2 {
|
||||
t.Errorf("HashSize=%d, want 2 (dominant size should win over occasional 1-byte)", ni.HashSize)
|
||||
}
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni == nil {
|
||||
t.Fatal("expected hash info for test node")
|
||||
}
|
||||
if len(ni.AllSizes) != 2 {
|
||||
t.Errorf("expected 2 unique sizes, got %d", len(ni.AllSizes))
|
||||
}
|
||||
if !ni.Inconsistent {
|
||||
t.Error("expected inconsistent flag to be true for flip-flop pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsHashSizesNoNullArrays(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/hash-sizes", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
arrayFields := []string{"hourly", "topHops", "multiByteNodes"}
|
||||
for _, field := range arrayFields {
|
||||
if body[field] == nil {
|
||||
t.Errorf("field %q is null, expected []", field)
|
||||
}
|
||||
arrayFields := []string{"hourly", "topHops", "multiByteNodes"}
|
||||
for _, field := range arrayFields {
|
||||
if body[field] == nil {
|
||||
t.Errorf("field %q is null, expected []", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestObserverAnalyticsNoStore(t *testing.T) {
|
||||
@@ -2106,60 +1963,6 @@ func TestObserverAnalyticsNoStore(t *testing.T) {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
func TestConfigGeoFilterEndpoint(t *testing.T) {
|
||||
t.Run("no geo filter configured", func(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["polygon"] != nil {
|
||||
t.Errorf("expected polygon to be nil when no geo filter configured, got %v", body["polygon"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with polygon configured", func(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
lat0, lat1 := 50.0, 51.5
|
||||
lon0, lon1 := 3.0, 5.5
|
||||
cfg := &Config{
|
||||
Port: 3000,
|
||||
GeoFilter: &GeoFilterConfig{
|
||||
Polygon: [][2]float64{{lat0, lon0}, {lat1, lon0}, {lat1, lon1}, {lat0, lon1}},
|
||||
BufferKm: 20,
|
||||
},
|
||||
}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = NewPacketStore(db, nil)
|
||||
srv.store.Load()
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/config/geo-filter", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["polygon"] == nil {
|
||||
t.Error("expected polygon in response when geo filter is configured")
|
||||
}
|
||||
if body["bufferKm"] == nil {
|
||||
t.Error("expected bufferKm in response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
+3
-21
@@ -4067,32 +4067,14 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
|
||||
ni = &hashSizeNodeInfo{AllSizes: make(map[int]bool)}
|
||||
info[pk] = ni
|
||||
}
|
||||
ni.HashSize = hs
|
||||
ni.AllSizes[hs] = true
|
||||
ni.Seq = append(ni.Seq, hs)
|
||||
}
|
||||
|
||||
// Post-process: compute dominant hash size (mode) and flip-flop flag.
|
||||
// Using the last-seen value would misreport nodes that occasionally send
|
||||
// with pathByte=0x00 (hashSize=1) when transmitting directly with no
|
||||
// relay hops, even though their true hash size is 2 or 3.
|
||||
// Compute flip-flop (inconsistent) flag: need >= 3 observations,
|
||||
// >= 2 unique sizes, and >= 2 transitions in the sequence.
|
||||
for _, ni := range info {
|
||||
// Dominant hash size: pick the most frequently observed size.
|
||||
// On a tie, prefer the larger value (more specific).
|
||||
counts := make(map[int]int, len(ni.AllSizes))
|
||||
for _, hs := range ni.Seq {
|
||||
counts[hs]++
|
||||
}
|
||||
best, bestCount := 1, 0
|
||||
for hs, cnt := range counts {
|
||||
if cnt > bestCount || (cnt == bestCount && hs > best) {
|
||||
best = hs
|
||||
bestCount = cnt
|
||||
}
|
||||
}
|
||||
ni.HashSize = best
|
||||
|
||||
// Flip-flop (inconsistent) flag: need >= 3 observations,
|
||||
// >= 2 unique sizes, and >= 2 transitions in the sequence.
|
||||
if len(ni.Seq) < 3 || len(ni.AllSizes) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
+1
-2
@@ -919,8 +919,7 @@ type ClientConfigResponse struct {
|
||||
WsReconnectMs interface{} `json:"wsReconnectMs"`
|
||||
CacheInvalidateMs interface{} `json:"cacheInvalidateMs"`
|
||||
ExternalUrls interface{} `json:"externalUrls"`
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
Timestamps TimestampConfig `json:"timestamps"`
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
}
|
||||
|
||||
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-8
@@ -3,7 +3,8 @@
|
||||
"apiKey": "your-secret-api-key-here",
|
||||
"retention": {
|
||||
"nodeDays": 7,
|
||||
"_comment": "Nodes not seen in this many days are moved to inactive_nodes table. Default 7."
|
||||
"packetDays": 30,
|
||||
"_comment": "nodeDays: nodes not seen in N days are moved to inactive_nodes (default 7). packetDays: transmissions+observations older than N days are deleted daily (0 = disabled)."
|
||||
},
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
@@ -144,13 +145,6 @@
|
||||
"propagationBufferMs": 5000,
|
||||
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
|
||||
},
|
||||
"timestamps": {
|
||||
"defaultMode": "ago",
|
||||
"timezone": "local",
|
||||
"formatPreset": "iso",
|
||||
"customFormat": "",
|
||||
"allowCustomFormat": false
|
||||
},
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
- "6060:6060" # pprof server
|
||||
- "6061:6061" # pprof ingestor
|
||||
volumes:
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}/config.json:/app/config.json:ro
|
||||
- ${STAGING_DATA_DIR:-~/meshcore-staging-data}:/app/data
|
||||
- caddy-data-staging-go:/data/caddy
|
||||
environment:
|
||||
|
||||
+4
-3
@@ -17,10 +17,11 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${PROD_HTTP_PORT:-80}:80"
|
||||
- "${PROD_HTTPS_PORT:-443}:443"
|
||||
- "${PROD_HTTP_PORT:-80}:${PROD_HTTP_PORT:-80}"
|
||||
- "${PROD_HTTPS_PORT:-443}:${PROD_HTTPS_PORT:-443}"
|
||||
- "${PROD_MQTT_PORT:-1883}:1883"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./caddy-config/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ${PROD_DATA_DIR:-~/meshcore-data}:/app/data
|
||||
- caddy-data:/data/caddy
|
||||
@@ -34,4 +35,4 @@ services:
|
||||
|
||||
volumes:
|
||||
# Named volumes for Caddy TLS certificates (not user data — managed by Caddy internally)
|
||||
caddy-data:
|
||||
caddy-data:
|
||||
|
||||
+11
-7
@@ -1,12 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Config lives in the data directory (bind-mounted from host)
|
||||
# The Go server already searches /app/data/config.json via LoadConfig
|
||||
# but the ingestor expects a direct path — symlink for compatibility
|
||||
if [ -f /app/data/config.json ]; then
|
||||
ln -sf /app/data/config.json /app/config.json
|
||||
elif [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found in /app/data/ — using built-in defaults"
|
||||
# Fix: Docker creates a directory when bind-mounting a non-existent file.
|
||||
# If config.json is a directory (from a failed mount), remove it and use the example.
|
||||
if [ -d /app/config.json ]; then
|
||||
echo "[entrypoint] WARNING: config.json is a directory (broken bind mount) — removing and using example"
|
||||
rm -rf /app/config.json
|
||||
fi
|
||||
|
||||
# Copy example config if no config.json exists (not bind-mounted)
|
||||
if [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found, copying from config.example.json"
|
||||
cp /app/config.example.json /app/config.json
|
||||
fi
|
||||
|
||||
# theme.json: check data/ volume (admin-editable on host)
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
package decoder
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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"`
|
||||
Feat1 *int `json:"feat1,omitempty"`
|
||||
Feat2 *int `json:"feat2,omitempty"`
|
||||
BatteryMv *int `json:"battery_mv,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
ChannelHash int `json:"channelHash,omitempty"`
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Sender string `json:"sender,omitempty"`
|
||||
SenderTimestamp uint32 `json:"sender_timestamp,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 {
|
||||
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat1 = &feat1
|
||||
off += 2
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat2 = &feat2
|
||||
off += 2
|
||||
}
|
||||
if p.Flags.HasName {
|
||||
// Find null terminator to separate name from trailing telemetry bytes
|
||||
nameEnd := len(appdata)
|
||||
for i := off; i < len(appdata); i++ {
|
||||
if appdata[i] == 0x00 {
|
||||
nameEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
name := string(appdata[off:nameEnd])
|
||||
name = sanitizeName(name)
|
||||
p.Name = name
|
||||
off = nameEnd
|
||||
// Skip null terminator(s)
|
||||
for off < len(appdata) && appdata[off] == 0x00 {
|
||||
off++
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
|
||||
// Only sensor nodes (advType=4) carry telemetry bytes.
|
||||
if p.Flags.Sensor && off+4 <= len(appdata) {
|
||||
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
|
||||
tempC := float64(tempRaw) / 100.0
|
||||
if batteryMv > 0 && batteryMv <= 10000 {
|
||||
p.BatteryMv = &batteryMv
|
||||
}
|
||||
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
|
||||
if tempRaw >= -5000 && tempRaw <= 10000 {
|
||||
p.TemperatureC = &tempC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// channelDecryptResult holds the decrypted channel message fields.
|
||||
type channelDecryptResult struct {
|
||||
Timestamp uint32
|
||||
Flags byte
|
||||
Sender string
|
||||
Message string
|
||||
}
|
||||
|
||||
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t).
|
||||
func countNonPrintable(s string) int {
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
if r < 0x20 && r != '\n' && r != '\t' {
|
||||
count++
|
||||
} else if r == utf8.RuneError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// decryptChannelMessage implements MeshCore channel decryption:
|
||||
// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption.
|
||||
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
|
||||
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
|
||||
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calculatedMac := h.Sum(nil)
|
||||
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt (block-by-block, no padding)
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AES cipher: %w", err)
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
|
||||
if len(plaintext) < 5 {
|
||||
return nil, fmt.Errorf("decrypted content too short")
|
||||
}
|
||||
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
flags := plaintext[4]
|
||||
messageText := string(plaintext[5:])
|
||||
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
|
||||
messageText = messageText[:idx]
|
||||
}
|
||||
|
||||
// Validate decrypted text is printable UTF-8 (not binary garbage)
|
||||
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
|
||||
return nil, fmt.Errorf("decrypted text contains non-printable characters")
|
||||
}
|
||||
|
||||
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
|
||||
|
||||
// Parse "sender: message" format
|
||||
colonIdx := strings.Index(messageText, ": ")
|
||||
if colonIdx > 0 && colonIdx < 50 {
|
||||
potentialSender := messageText[:colonIdx]
|
||||
if !strings.ContainsAny(potentialSender, ":[]") {
|
||||
result.Sender = potentialSender
|
||||
result.Message = messageText[colonIdx+2:]
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
channelHash := int(buf[0])
|
||||
channelHashHex := fmt.Sprintf("%02X", buf[0])
|
||||
mac := hex.EncodeToString(buf[1:3])
|
||||
encryptedData := hex.EncodeToString(buf[3:])
|
||||
|
||||
hasKeys := len(channelKeys) > 0
|
||||
// Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars)
|
||||
if hasKeys && len(encryptedData) >= 10 {
|
||||
for name, key := range channelKeys {
|
||||
result, err := decryptChannelMessage(encryptedData, mac, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := result.Message
|
||||
if result.Sender != "" && result.Message != "" {
|
||||
text = result.Sender + ": " + result.Message
|
||||
}
|
||||
return Payload{
|
||||
Type: "CHAN",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
Sender: result.Sender,
|
||||
Text: text,
|
||||
SenderTimestamp: result.Timestamp,
|
||||
}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decryption_failed",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "no_key",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
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, channelKeys map[string]string) 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, channelKeys)
|
||||
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, channelKeys map[string]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, channelKeys)
|
||||
|
||||
// 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).
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
// Go time from Unix epoch
|
||||
t := time.Unix(int64(epoch), 0)
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package geofilter provides the shared geographic filter configuration and
|
||||
// geometry used by both the server and ingestor packages.
|
||||
package geofilter
|
||||
|
||||
import "math"
|
||||
|
||||
// Config defines the geographic filter polygon or bounding box.
|
||||
// Shared between the server and ingestor packages.
|
||||
type Config struct {
|
||||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||||
BufferKm float64 `json:"bufferKm,omitempty"`
|
||||
LatMin *float64 `json:"latMin,omitempty"`
|
||||
LatMax *float64 `json:"latMax,omitempty"`
|
||||
LonMin *float64 `json:"lonMin,omitempty"`
|
||||
LonMax *float64 `json:"lonMax,omitempty"`
|
||||
}
|
||||
|
||||
// PassesFilter returns true if the coordinates fall within the filter area.
|
||||
// Nodes with no GPS fix (0,0) are always allowed.
|
||||
func PassesFilter(lat, lon float64, gf *Config) bool {
|
||||
if gf == nil {
|
||||
return true
|
||||
}
|
||||
if lat == 0 && lon == 0 {
|
||||
return true
|
||||
}
|
||||
if len(gf.Polygon) >= 3 {
|
||||
if PointInPolygon(lat, lon, gf.Polygon) {
|
||||
return true
|
||||
}
|
||||
if gf.BufferKm > 0 {
|
||||
n := len(gf.Polygon)
|
||||
for i := 0; i < n; i++ {
|
||||
j := (i + 1) % n
|
||||
if DistToSegmentKm(lat, lon, gf.Polygon[i], gf.Polygon[j]) <= gf.BufferKm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Legacy bounding box fallback
|
||||
if gf.LatMin != nil && gf.LatMax != nil && gf.LonMin != nil && gf.LonMax != nil {
|
||||
return lat >= *gf.LatMin && lat <= *gf.LatMax && lon >= *gf.LonMin && lon <= *gf.LonMax
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// PointInPolygon uses the ray-casting algorithm.
|
||||
func PointInPolygon(lat, lon float64, polygon [][2]float64) bool {
|
||||
inside := false
|
||||
n := len(polygon)
|
||||
j := n - 1
|
||||
for i := 0; i < n; i++ {
|
||||
yi, xi := polygon[i][0], polygon[i][1]
|
||||
yj, xj := polygon[j][0], polygon[j][1]
|
||||
if (yi > lat) != (yj > lat) {
|
||||
if lon < (xj-xi)*(lat-yi)/(yj-yi)+xi {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
j = i
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// DistToSegmentKm returns the approximate distance in km from point (lat,lon)
|
||||
// to line segment a→b using a flat-earth projection.
|
||||
func DistToSegmentKm(lat, lon float64, a, b [2]float64) float64 {
|
||||
lat1, lon1 := a[0], a[1]
|
||||
lat2, lon2 := b[0], b[1]
|
||||
cosLat := math.Cos((lat1+lat2) / 2.0 * math.Pi / 180.0)
|
||||
ax := (lon1 - lon) * 111.0 * cosLat
|
||||
ay := (lat1 - lat) * 111.0
|
||||
bx := (lon2 - lon) * 111.0 * cosLat
|
||||
by := (lat2 - lat) * 111.0
|
||||
abx, aby := bx-ax, by-ay
|
||||
abSq := abx*abx + aby*aby
|
||||
if abSq == 0 {
|
||||
return math.Sqrt(ax*ax + ay*ay)
|
||||
}
|
||||
t := math.Max(0, math.Min(1, -(ax*abx+ay*aby)/abSq))
|
||||
px := ax + t*abx
|
||||
py := ay + t*aby
|
||||
return math.Sqrt(px*px + py*py)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/geofilter
|
||||
|
||||
go 1.22
|
||||
+84
-391
@@ -960,23 +960,13 @@
|
||||
</div>
|
||||
|
||||
<div class="analytics-card" id="hashMatrixSection">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h3 style="margin:0" id="hashMatrixTitle">🔢 Hash Usage Matrix</h3>
|
||||
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
|
||||
<div class="hash-byte-selector" id="hashByteSelector" style="display:flex;gap:4px">
|
||||
<button class="hash-byte-btn active" data-bytes="1">1-Byte</button>
|
||||
<button class="hash-byte-btn" data-bytes="2">2-Byte</button>
|
||||
<button class="hash-byte-btn" data-bytes="3">3-Byte</button>
|
||||
</div>
|
||||
<p class="text-muted" id="hashMatrixDesc" style="margin:0;font-size:0.8em">Click a cell to see which nodes share that prefix.</p>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">🔢 1-Byte Hash Usage Matrix</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
|
||||
<div id="hashMatrix"></div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-card" id="collisionRiskSection">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle">💥 Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">💥 1-Byte Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1013,43 +1003,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
|
||||
// Only repeaters matter for routing — filter out non-repeaters for collision analysis
|
||||
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
|
||||
const nullRoleNodes = allNodes.filter(n => !n.role);
|
||||
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
|
||||
|
||||
let currentBytes = 1;
|
||||
function refreshHashViews(bytes) {
|
||||
currentBytes = bytes;
|
||||
hideMatrixTip();
|
||||
// Update selector button states
|
||||
document.querySelectorAll('.hash-byte-btn').forEach(b => {
|
||||
b.classList.toggle('active', Number(b.dataset.bytes) === bytes);
|
||||
});
|
||||
// Update titles and description
|
||||
const matrixTitle = document.getElementById('hashMatrixTitle');
|
||||
const matrixDesc = document.getElementById('hashMatrixDesc');
|
||||
const riskTitle = document.getElementById('collisionRiskTitle');
|
||||
if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`;
|
||||
if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`;
|
||||
if (matrixDesc) {
|
||||
if (bytes === 1) matrixDesc.textContent = 'Click a cell to see which nodes share that 1-byte prefix.';
|
||||
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
|
||||
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
|
||||
}
|
||||
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
|
||||
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
|
||||
const riskCard = document.getElementById('collisionRiskSection');
|
||||
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
|
||||
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
|
||||
}
|
||||
|
||||
// Wire up selector
|
||||
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes)));
|
||||
});
|
||||
|
||||
refreshHashViews(1);
|
||||
renderHashMatrix(data.topHops, repeaterNodes);
|
||||
renderCollisions(data.topHops, repeaterNodes);
|
||||
}
|
||||
|
||||
function renderHashTimeline(hourly) {
|
||||
@@ -1076,341 +1033,93 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
// Shared hover tooltip for hash matrix cells.
|
||||
// Called once per container — reads content from data-tip on each <td>.
|
||||
// Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch
|
||||
let _matrixTip = null;
|
||||
function getMatrixTip() {
|
||||
if (!_matrixTip) {
|
||||
_matrixTip = document.createElement('div');
|
||||
_matrixTip.className = 'hash-matrix-tooltip';
|
||||
_matrixTip.style.display = 'none';
|
||||
document.body.appendChild(_matrixTip);
|
||||
}
|
||||
return _matrixTip;
|
||||
}
|
||||
function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; }
|
||||
|
||||
function initMatrixTooltip(el) {
|
||||
if (el._matrixTipInit) return;
|
||||
el._matrixTipInit = true;
|
||||
el.addEventListener('mouseover', e => {
|
||||
const td = e.target.closest('td[data-tip]');
|
||||
if (!td) return;
|
||||
const tip = getMatrixTip();
|
||||
tip.innerHTML = td.dataset.tip;
|
||||
tip.style.display = 'block';
|
||||
});
|
||||
el.addEventListener('mousemove', e => {
|
||||
if (!_matrixTip || _matrixTip.style.display === 'none') return;
|
||||
const x = e.clientX + 14, y = e.clientY + 14;
|
||||
_matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px';
|
||||
_matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px';
|
||||
});
|
||||
el.addEventListener('mouseout', e => {
|
||||
if (e.target.closest('td[data-tip]') && !e.relatedTarget?.closest('td[data-tip]')) hideMatrixTip();
|
||||
});
|
||||
el.addEventListener('mouseleave', hideMatrixTip);
|
||||
}
|
||||
|
||||
// Pure data helpers — extracted for testability
|
||||
|
||||
function buildOneBytePrefixMap(nodes) {
|
||||
const map = {};
|
||||
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
|
||||
for (const n of nodes) {
|
||||
const hex = n.public_key.slice(0, 2).toUpperCase();
|
||||
if (map[hex]) map[hex].push(n);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildTwoBytePrefixInfo(nodes) {
|
||||
const info = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const h = i.toString(16).padStart(2, '0').toUpperCase();
|
||||
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const firstHex = n.public_key.slice(0, 2).toUpperCase();
|
||||
const twoHex = n.public_key.slice(0, 4).toUpperCase();
|
||||
const entry = info[firstHex];
|
||||
if (!entry) continue;
|
||||
entry.groupNodes.push(n);
|
||||
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
|
||||
entry.twoByteMap[twoHex].push(n);
|
||||
}
|
||||
for (const entry of Object.values(info)) {
|
||||
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
|
||||
entry.collisionCount = collisions.length;
|
||||
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function buildCollisionHops(allNodes, bytes) {
|
||||
const map = {};
|
||||
for (const n of allNodes) {
|
||||
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
|
||||
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
|
||||
map[p].count++;
|
||||
}
|
||||
return Object.values(map).filter(h => h.count > 1);
|
||||
}
|
||||
|
||||
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
|
||||
bytes = bytes || 1;
|
||||
totalNodes = totalNodes || allNodes;
|
||||
async function renderHashMatrix(topHops, allNodes) {
|
||||
const el = document.getElementById('hashMatrix');
|
||||
|
||||
// 3-byte: show a summary panel instead of a matrix
|
||||
if (bytes === 3) {
|
||||
const total = totalNodes.length;
|
||||
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
|
||||
const prefixMap = {};
|
||||
for (const n of nodesForByte) {
|
||||
const p = n.public_key.slice(0, 6).toUpperCase();
|
||||
if (!prefixMap[p]) prefixMap[p] = 0;
|
||||
prefixMap[p]++;
|
||||
}
|
||||
const uniquePrefixes = Object.keys(prefixMap).length;
|
||||
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
|
||||
const spaceSize = 16777216; // 2^24
|
||||
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 3-byte ID</div>
|
||||
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
|
||||
return;
|
||||
// Build prefix → node count map
|
||||
const prefixNodes = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const hex = i.toString(16).padStart(2, '0').toUpperCase();
|
||||
prefixNodes[hex] = allNodes.filter(n => n.public_key.toUpperCase().startsWith(hex));
|
||||
}
|
||||
|
||||
const nibbles = '0123456789ABCDEF'.split('');
|
||||
const cellSize = 36;
|
||||
const headerSize = 24;
|
||||
|
||||
if (bytes === 1) {
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
|
||||
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
|
||||
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
|
||||
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
|
||||
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
|
||||
const onePct = ((oneUsed / 256) * 100).toFixed(1);
|
||||
|
||||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 1-byte ID</div>
|
||||
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const nodes = prefixNodes[hex] || [];
|
||||
const count = nodes.length;
|
||||
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
|
||||
const isCollision = count >= 2 && repeaterCount >= 2;
|
||||
const isPossible = count >= 2 && !isCollision;
|
||||
let cellClass, bgStyle;
|
||||
if (count === 0) { cellClass = 'hash-cell-empty'; bgStyle = ''; }
|
||||
else if (count === 1) { cellClass = 'hash-cell-taken'; bgStyle = ''; }
|
||||
else if (isPossible) { cellClass = 'hash-cell-possible'; bgStyle = ''; }
|
||||
else { const t = Math.min((count - 2) / 4, 1); bgStyle = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass = 'hash-cell-collision'; }
|
||||
const nodeLabel = m => `<div style="font-size:11px">${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' <span style="opacity:0.7">(unknown role)</span>' : ''}</div>`;
|
||||
const tip1 = count === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">Available</div>`
|
||||
: count === 1
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">One node — no collision</div><div class="hash-matrix-tooltip-nodes">${nodeLabel(nodes[0])}</div>`
|
||||
: isPossible
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
|
||||
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
|
||||
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
|
||||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
initMatrixTooltip(el);
|
||||
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const matches = prefixNodes[hex] || [];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
|
||||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||||
`<div style="margin-top:8px">${matches.map(m => {
|
||||
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>` : '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
|
||||
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
|
||||
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
|
||||
}).join('')}</div>`;
|
||||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||||
td.classList.add('hash-selected');
|
||||
});
|
||||
});
|
||||
|
||||
} else if (bytes === 2) {
|
||||
// 2-byte mode: 16×16 grid of first-byte groups
|
||||
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
|
||||
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
|
||||
|
||||
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
|
||||
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
|
||||
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
|
||||
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
|
||||
|
||||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Nodes tracked</div>
|
||||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Using 2-byte ID</div>
|
||||
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Prefix space used</div>
|
||||
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">Prefix collisions</div>
|
||||
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
html += '</tr>';
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
|
||||
const nodeCount = info.groupNodes.length;
|
||||
const maxCol = info.maxCollision;
|
||||
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
|
||||
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
|
||||
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
|
||||
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
|
||||
let cellClass2, bgStyle2;
|
||||
if (nodeCount === 0) { cellClass2 = 'hash-cell-empty'; bgStyle2 = ''; }
|
||||
else if (maxCol === 0) { cellClass2 = 'hash-cell-taken'; bgStyle2 = ''; }
|
||||
else if (hasPossible) { cellClass2 = 'hash-cell-possible'; bgStyle2 = ''; }
|
||||
else { const t = Math.min((maxCol - 2) / 4, 1); bgStyle2 = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass2 = 'hash-cell-collision'; }
|
||||
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
|
||||
const tip2 = nodeCount === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
|
||||
: info.collisionCount === 0
|
||||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
|
||||
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
|
||||
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
|
||||
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
|
||||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const info = firstByteInfo[hex];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
|
||||
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
|
||||
if (info.collisionCount === 0) {
|
||||
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
|
||||
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
|
||||
const prefix = m.public_key.slice(0,4).toUpperCase();
|
||||
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
|
||||
}).join('')}</div>`;
|
||||
} else {
|
||||
dhtml += `<div style="margin-top:8px">`;
|
||||
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
|
||||
const isCollision = nodes.length > 1;
|
||||
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
|
||||
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
|
||||
dhtml += nodes.map(m => `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link" style="font-size:0.85em">${esc(m.name || m.public_key.slice(0,12))}</a>`).join(', ');
|
||||
dhtml += `</div>`;
|
||||
}
|
||||
dhtml += '</div>';
|
||||
}
|
||||
detail.innerHTML = dhtml;
|
||||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||||
td.classList.add('hash-selected');
|
||||
});
|
||||
});
|
||||
|
||||
initMatrixTooltip(el);
|
||||
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||||
for (const n of nibbles) {
|
||||
html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
|
||||
for (let hi = 0; hi < 16; hi++) {
|
||||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||||
for (let lo = 0; lo < 16; lo++) {
|
||||
const hex = nibbles[hi] + nibbles[lo];
|
||||
const nodes = prefixNodes[hex] || [];
|
||||
const count = nodes.length;
|
||||
let bg, color;
|
||||
if (count === 0) {
|
||||
bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle
|
||||
} else if (count === 1) {
|
||||
bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision
|
||||
} else {
|
||||
// 2+ nodes: orange→red
|
||||
const t = Math.min((count - 2) / 4, 1);
|
||||
const r = Math.round(220 + 35 * t);
|
||||
const g = Math.round(120 * (1 - t));
|
||||
bg = `rgb(${r},${g},30)`; color = '#fff';
|
||||
}
|
||||
const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`;
|
||||
const cellText = count === 0 ? `<span style="font-size:11px">${hex}</span>` : count >= 2 ? `<strong>${count >= 3 ? '3+' : count}</strong>` : String(count);
|
||||
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:13px;font-weight:${count >= 2 ? '700' : '400'}" title="0x${hex}: ${status}">${cellText}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
|
||||
<span><span class="legend-swatch" style="background:var(--card-bg);border:1px solid var(--border)"></span> 0 — Available</span>
|
||||
<span><span class="legend-swatch" style="background:#dcfce7"></span> 1 — One node</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> 2 — Two nodes (collision)</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> 3+ — Three+ nodes (collision)</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
// Click handler for cells
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
const hex = td.dataset.hex.toUpperCase();
|
||||
const matches = prefixNodes[hex] || [];
|
||||
const detail = document.getElementById('hashDetail');
|
||||
if (!matches.length) {
|
||||
detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`;
|
||||
return;
|
||||
}
|
||||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||||
`<div style="margin-top:8px">${matches.map(m => {
|
||||
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
: '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
|
||||
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
|
||||
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
|
||||
}).join('')}</div>`;
|
||||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||||
td.classList.add('hash-selected');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCollisions(topHops, allNodes, bytes) {
|
||||
bytes = bytes || 1;
|
||||
async function renderCollisions(topHops, allNodes) {
|
||||
const el = document.getElementById('collisionList');
|
||||
const hopsForSize = topHops.filter(h => h.size === bytes);
|
||||
|
||||
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
|
||||
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
|
||||
|
||||
if (!hopsToCheck.length && bytes === 1) {
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
|
||||
return;
|
||||
}
|
||||
const oneByteHops = topHops.filter(h => h.size === 1);
|
||||
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
|
||||
try {
|
||||
const nodes = allNodes;
|
||||
const collisions = [];
|
||||
for (const hop of hopsToCheck) {
|
||||
for (const hop of oneByteHops) {
|
||||
const prefix = hop.hex.toLowerCase();
|
||||
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
|
||||
if (matches.length > 1) {
|
||||
@@ -1436,27 +1145,14 @@
|
||||
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
|
||||
}
|
||||
}
|
||||
if (!collisions.length) {
|
||||
const cleanMsg = bytes === 3
|
||||
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
|
||||
: `✅ No ${bytes}-byte collisions detected`;
|
||||
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collisions.length) { el.innerHTML = '<div class="text-muted" style="padding:8px">No collisions detected</div>'; return; }
|
||||
|
||||
// Sort: local first (most likely to collide), then regional, distant, incomplete
|
||||
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
|
||||
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
|
||||
|
||||
const showAppearances = bytes < 3;
|
||||
el.innerHTML = `<table class="analytics-table">
|
||||
<thead><tr>
|
||||
<th scope="col">Prefix</th>
|
||||
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
|
||||
<th scope="col">Max Distance</th>
|
||||
<th scope="col">Assessment</th>
|
||||
<th scope="col">Colliding Nodes</th>
|
||||
</tr></thead>
|
||||
<thead><tr><th scope="col">Hop</th><th scope="col">Appearances</th><th scope="col">Max Distance</th><th scope="col">Assessment</th><th scope="col">Colliding Nodes</th></tr></thead>
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
@@ -1475,12 +1171,12 @@
|
||||
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
|
||||
return `<tr>
|
||||
<td class="mono">${c.hop}</td>
|
||||
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
|
||||
<td>${c.count.toLocaleString()}</td>
|
||||
<td>${distStr}</td>
|
||||
<td title="${tooltip}">${badge}</td>
|
||||
<td>${c.matches.map(m => {
|
||||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||||
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
|
||||
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
|
||||
}).join('<br>')}</td>
|
||||
@@ -1942,9 +1638,6 @@ function destroy() { _analyticsData = {}; _channelData = null; }
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
window._analyticsChannelTbodyHtml = channelTbodyHtml;
|
||||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||||
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
|
||||
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
|
||||
window._analyticsBuildCollisionHops = buildCollisionHops;
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
|
||||
+7
-122
@@ -88,116 +88,11 @@ window.apiPerf = function() {
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '—';
|
||||
const ms = new Date(iso).getTime();
|
||||
if (!isFinite(ms)) return '—';
|
||||
const s = Math.floor((Date.now() - ms) / 1000);
|
||||
const abs = Math.abs(s);
|
||||
let value;
|
||||
let suffix;
|
||||
if (abs < 60) { value = abs; suffix = 's'; }
|
||||
else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; }
|
||||
else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; }
|
||||
else { value = Math.floor(abs / 86400); suffix = 'd'; }
|
||||
if (s < 0) return 'in ' + value + suffix;
|
||||
return value + suffix + ' ago';
|
||||
}
|
||||
|
||||
function getTimestampMode() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-mode');
|
||||
if (saved === 'ago' || saved === 'absolute') return saved;
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.defaultMode;
|
||||
return serverDefault === 'absolute' ? 'absolute' : 'ago';
|
||||
}
|
||||
|
||||
function getTimestampTimezone() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-timezone');
|
||||
if (saved === 'utc' || saved === 'local') return saved;
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.timezone;
|
||||
return serverDefault === 'utc' ? 'utc' : 'local';
|
||||
}
|
||||
|
||||
function getTimestampFormatPreset() {
|
||||
const saved = localStorage.getItem('meshcore-timestamp-format');
|
||||
if (saved === 'iso' || saved === 'iso-seconds' || saved === 'locale') return saved;
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.formatPreset;
|
||||
return (serverDefault === 'iso' || serverDefault === 'iso-seconds' || serverDefault === 'locale') ? serverDefault : 'iso';
|
||||
}
|
||||
|
||||
function getTimestampCustomFormat() {
|
||||
if (window.SITE_CONFIG?.timestamps?.allowCustomFormat !== true) return '';
|
||||
const saved = localStorage.getItem('meshcore-timestamp-custom-format');
|
||||
if (saved != null) return String(saved);
|
||||
const serverDefault = window.SITE_CONFIG?.timestamps?.customFormat;
|
||||
return serverDefault == null ? '' : String(serverDefault);
|
||||
}
|
||||
|
||||
function pad2(v) { return String(v).padStart(2, '0'); }
|
||||
function pad3(v) { return String(v).padStart(3, '0'); }
|
||||
|
||||
function formatIsoLike(d, timezone, includeMs) {
|
||||
const useUtc = timezone === 'utc';
|
||||
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
|
||||
const month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1;
|
||||
const day = useUtc ? d.getUTCDate() : d.getDate();
|
||||
const hour = useUtc ? d.getUTCHours() : d.getHours();
|
||||
const minute = useUtc ? d.getUTCMinutes() : d.getMinutes();
|
||||
const second = useUtc ? d.getUTCSeconds() : d.getSeconds();
|
||||
const ms = useUtc ? d.getUTCMilliseconds() : d.getMilliseconds();
|
||||
let out = year + '-' + pad2(month) + '-' + pad2(day) + ' ' + pad2(hour) + ':' + pad2(minute) + ':' + pad2(second);
|
||||
if (includeMs) out += '.' + pad3(ms);
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatTimestampCustom(d, formatString, timezone) {
|
||||
if (!/YYYY|MM|DD|HH|mm|ss|SSS|Z/.test(String(formatString))) return '';
|
||||
const useUtc = timezone === 'utc';
|
||||
const replacements = {
|
||||
YYYY: String(useUtc ? d.getUTCFullYear() : d.getFullYear()),
|
||||
MM: pad2((useUtc ? d.getUTCMonth() : d.getMonth()) + 1),
|
||||
DD: pad2(useUtc ? d.getUTCDate() : d.getDate()),
|
||||
HH: pad2(useUtc ? d.getUTCHours() : d.getHours()),
|
||||
mm: pad2(useUtc ? d.getUTCMinutes() : d.getMinutes()),
|
||||
ss: pad2(useUtc ? d.getUTCSeconds() : d.getSeconds()),
|
||||
SSS: pad3(useUtc ? d.getUTCMilliseconds() : d.getMilliseconds()),
|
||||
Z: (timezone === 'utc' ? 'UTC' : 'local')
|
||||
};
|
||||
return String(formatString).replace(/YYYY|MM|DD|HH|mm|ss|SSS|Z/g, token => replacements[token] || token);
|
||||
}
|
||||
|
||||
function formatAbsoluteTimestamp(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (!isFinite(d.getTime())) return '—';
|
||||
const timezone = getTimestampTimezone();
|
||||
const preset = getTimestampFormatPreset();
|
||||
const customFormat = getTimestampCustomFormat().trim();
|
||||
if (customFormat) {
|
||||
const customOut = formatTimestampCustom(d, customFormat, timezone);
|
||||
if (customOut && !/Invalid Date|NaN|undefined|null/.test(customOut)) return customOut;
|
||||
}
|
||||
if (preset === 'iso-seconds') return formatIsoLike(d, timezone, true);
|
||||
if (preset === 'locale') {
|
||||
if (timezone === 'utc') return d.toLocaleString([], { timeZone: 'UTC' });
|
||||
return d.toLocaleString();
|
||||
}
|
||||
return formatIsoLike(d, timezone, false);
|
||||
}
|
||||
|
||||
function formatTimestamp(isoString, mode) {
|
||||
return formatTimestampWithTooltip(isoString, mode).text;
|
||||
}
|
||||
|
||||
function formatTimestampWithTooltip(isoString, mode) {
|
||||
if (!isoString) return { text: '—', tooltip: '—', isFuture: false };
|
||||
const d = new Date(isoString);
|
||||
if (!isFinite(d.getTime())) return { text: '—', tooltip: '—', isFuture: false };
|
||||
const activeMode = mode === 'absolute' || mode === 'ago' ? mode : getTimestampMode();
|
||||
const isFuture = d.getTime() > Date.now();
|
||||
const absolute = formatAbsoluteTimestamp(isoString);
|
||||
const relative = timeAgo(isoString);
|
||||
const text = isFuture ? absolute : (activeMode === 'absolute' ? absolute : relative);
|
||||
const tooltip = isFuture ? relative : (activeMode === 'absolute' ? relative : absolute);
|
||||
return { text, tooltip, isFuture };
|
||||
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
@@ -452,9 +347,6 @@ window.addEventListener('theme-changed', () => {
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
}, 300);
|
||||
});
|
||||
window.addEventListener('timestamp-mode-changed', () => {
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
|
||||
@@ -711,14 +603,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
window.SITE_CONFIG = cfg || {};
|
||||
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
|
||||
const tsCfg = window.SITE_CONFIG.timestamps;
|
||||
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
|
||||
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
|
||||
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
|
||||
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
|
||||
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
|
||||
window.SITE_CONFIG = cfg;
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
@@ -792,7 +677,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
|
||||
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
+6
-60
@@ -9,38 +9,8 @@
|
||||
let autoScroll = true;
|
||||
let nodeCache = {};
|
||||
let selectedNode = null;
|
||||
let observerIataMap = {};
|
||||
var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function getSelectedRegionsSnapshot() {
|
||||
var rp = RegionFilter.getRegionParam();
|
||||
return rp ? rp.split(',').filter(Boolean) : null;
|
||||
}
|
||||
|
||||
function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegions) {
|
||||
if (!selectedRegions || !selectedRegions.length) return true;
|
||||
var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null;
|
||||
if (!observerId) return false;
|
||||
var observerRegion = observerRegions[observerId];
|
||||
if (!observerRegion) return false;
|
||||
return selectedRegions.indexOf(observerRegion) !== -1;
|
||||
}
|
||||
|
||||
async function loadObserverRegions() {
|
||||
try {
|
||||
var data = await api('/observers', { ttl: CLIENT_TTL.observers });
|
||||
var list = data && data.observers ? data.observers : [];
|
||||
var map = {};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var o = list[i];
|
||||
var id = o.id || o.observer_id;
|
||||
if (!id || !o.iata) continue;
|
||||
map[id] = o.iata;
|
||||
}
|
||||
observerIataMap = map;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function lookupNode(name) {
|
||||
var cached = nodeCache[name];
|
||||
if (cached !== undefined) {
|
||||
@@ -281,14 +251,8 @@
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () {
|
||||
loadChannels(true).then(async function () {
|
||||
if (!selectedHash) return;
|
||||
await refreshMessages({ regionSwitch: true, forceNoCache: true });
|
||||
});
|
||||
});
|
||||
regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); });
|
||||
|
||||
loadObserverRegions();
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
});
|
||||
@@ -419,7 +383,6 @@
|
||||
});
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
var selectedRegions = getSelectedRegionsSnapshot();
|
||||
var dominated = msgs.filter(function (m) {
|
||||
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
|
||||
});
|
||||
@@ -431,7 +394,6 @@
|
||||
|
||||
for (var i = 0; i < dominated.length; i++) {
|
||||
var m = dominated[i];
|
||||
if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataMap)) continue;
|
||||
var payload = m.data?.decoded?.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
@@ -631,38 +593,23 @@
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
try {
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : '';
|
||||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
messages = data.messages || [];
|
||||
if (messages.length === 0 && rp) {
|
||||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||||
} else {
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
}
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
msgEl.innerHTML = `<div class="ch-empty">Failed to load messages: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMessages(opts) {
|
||||
async function refreshMessages() {
|
||||
if (!selectedHash) return;
|
||||
opts = opts || {};
|
||||
const msgEl = document.getElementById('chMessages');
|
||||
if (!msgEl) return;
|
||||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||||
try {
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : '';
|
||||
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache });
|
||||
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const newMsgs = data.messages || [];
|
||||
if (opts.regionSwitch && rp && newMsgs.length === 0) {
|
||||
messages = [];
|
||||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// #92: Use message ID/hash for change detection instead of count + timestamp
|
||||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||||
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
|
||||
@@ -718,6 +665,5 @@
|
||||
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion;
|
||||
registerPage('channels', { init, destroy });
|
||||
})();
|
||||
|
||||
+1334
-1461
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
// Shared helper — initialises the geo-filter polygon overlay on a Leaflet map.
|
||||
// Returns the L.layerGroup (or null if no filter is configured / fetch fails).
|
||||
// The returned layer is added to the map when the checkbox is toggled on, and
|
||||
// removed when toggled off. The toggle state is persisted in localStorage
|
||||
// under the key 'meshcore-map-geo-filter'.
|
||||
//
|
||||
// Parameters:
|
||||
// map – Leaflet map instance
|
||||
// checkboxId – id of the <input type="checkbox"> that controls visibility
|
||||
// labelId – id of the <label> wrapper to reveal once data is loaded
|
||||
async function initGeoFilterOverlay(map, checkboxId, labelId) {
|
||||
try {
|
||||
const gf = await api('/config/geo-filter', { ttl: 3600 });
|
||||
if (!gf || !gf.polygon || gf.polygon.length < 3) return null;
|
||||
|
||||
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||||
const innerPoly = L.polygon(latlngs, {
|
||||
color: '#3b82f6', weight: 2, opacity: 0.8,
|
||||
fillColor: '#3b82f6', fillOpacity: 0.08
|
||||
});
|
||||
|
||||
const bufferPoly = gf.bufferKm > 0 ? (function () {
|
||||
let cLat = 0, cLon = 0;
|
||||
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
|
||||
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
|
||||
const cosLat = Math.cos(cLat * Math.PI / 180);
|
||||
const outer = gf.polygon.map(function (p) {
|
||||
const dLatM = (p[0] - cLat) * 111000;
|
||||
const dLonM = (p[1] - cLon) * 111000 * cosLat;
|
||||
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
|
||||
if (dist === 0) return [p[0], p[1]];
|
||||
const scale = (gf.bufferKm * 1000) / dist;
|
||||
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
|
||||
});
|
||||
return L.polygon(outer, {
|
||||
color: '#3b82f6', weight: 1.5, opacity: 0.4, dashArray: '6 4',
|
||||
fillColor: '#3b82f6', fillOpacity: 0.04
|
||||
});
|
||||
})() : null;
|
||||
|
||||
const layer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
|
||||
|
||||
const label = document.getElementById(labelId);
|
||||
if (label) label.style.display = '';
|
||||
const el = document.getElementById(checkboxId);
|
||||
if (el) {
|
||||
const saved = localStorage.getItem('meshcore-map-geo-filter');
|
||||
if (saved === 'true') { el.checked = true; layer.addTo(map); }
|
||||
el.addEventListener('change', function (e) {
|
||||
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
|
||||
if (e.target.checked) { layer.addTo(map); } else { map.removeLayer(layer); }
|
||||
});
|
||||
}
|
||||
return layer;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
+27
-29
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774925610">
|
||||
<link rel="stylesheet" href="home.css?v=1774925610">
|
||||
<link rel="stylesheet" href="live.css?v=1774925610">
|
||||
<link rel="stylesheet" href="style.css?v=1774786038">
|
||||
<link rel="stylesheet" href="home.css?v=1774786038">
|
||||
<link rel="stylesheet" href="live.css?v=1774786038">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,31 +81,29 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774925610"></script>
|
||||
<script src="customize.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774925610"></script>
|
||||
<script src="hop-resolver.js?v=1774925610"></script>
|
||||
<script src="hop-display.js?v=1774925610"></script>
|
||||
<script src="app.js?v=1774925610"></script>
|
||||
<script src="home.js?v=1774925610"></script>
|
||||
<script src="packet-filter.js?v=1774925610"></script>
|
||||
<script src="packets.js?v=1774925610"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774925610"></script>
|
||||
<script src="map.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774925610" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774786038"></script>
|
||||
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774786038"></script>
|
||||
<script src="hop-resolver.js?v=1774786038"></script>
|
||||
<script src="hop-display.js?v=1774786038"></script>
|
||||
<script src="app.js?v=1774786038"></script>
|
||||
<script src="home.js?v=1774786038"></script>
|
||||
<script src="packet-filter.js?v=1774786038"></script>
|
||||
<script src="packets.js?v=1774786038"></script>
|
||||
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774786039" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774786039" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+48
-27
@@ -25,7 +25,6 @@
|
||||
let _lcdClockInterval = null;
|
||||
let _rateCounterInterval = null;
|
||||
let _pruneInterval = null;
|
||||
let activeNodeDetailKey = null;
|
||||
|
||||
// === VCR State Machine ===
|
||||
const VCR = {
|
||||
@@ -52,19 +51,6 @@
|
||||
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
|
||||
};
|
||||
|
||||
function formatLiveTimestampHtml(isoLike) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : '—');
|
||||
}
|
||||
const d = isoLike ? new Date(isoLike) : null;
|
||||
const iso = d && isFinite(d.getTime()) ? d.toISOString() : null;
|
||||
const f = formatTimestampWithTooltip(iso, getTimestampMode());
|
||||
const warn = f.isFuture
|
||||
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
|
||||
: '';
|
||||
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
|
||||
}
|
||||
|
||||
function initResizeHandler() {
|
||||
let resizeTimer = null;
|
||||
_onResize = function() {
|
||||
@@ -817,7 +803,48 @@
|
||||
});
|
||||
|
||||
// Geo filter overlay
|
||||
initGeoFilterOverlay(map, 'liveGeoFilterToggle', 'liveGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
|
||||
(async function () {
|
||||
try {
|
||||
const gf = await api('/config/geo-filter', { ttl: 3600 });
|
||||
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
|
||||
const geoColor = cssVar('--geo-filter-color') || '#3b82f6';
|
||||
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||||
const innerPoly = L.polygon(latlngs, {
|
||||
color: geoColor, weight: 2, opacity: 0.8,
|
||||
fillColor: geoColor, fillOpacity: 0.08
|
||||
});
|
||||
const bufferPoly = gf.bufferKm > 0 ? (function () {
|
||||
let cLat = 0, cLon = 0;
|
||||
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
|
||||
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
|
||||
const cosLat = Math.cos(cLat * Math.PI / 180);
|
||||
const outer = gf.polygon.map(function (p) {
|
||||
const dLatM = (p[0] - cLat) * 111000;
|
||||
const dLonM = (p[1] - cLon) * 111000 * cosLat;
|
||||
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
|
||||
if (dist === 0) return [p[0], p[1]];
|
||||
const scale = (gf.bufferKm * 1000) / dist;
|
||||
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
|
||||
});
|
||||
return L.polygon(outer, {
|
||||
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
|
||||
fillColor: geoColor, fillOpacity: 0.04
|
||||
});
|
||||
})() : null;
|
||||
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
|
||||
const label = document.getElementById('liveGeoFilterLabel');
|
||||
if (label) label.style.display = '';
|
||||
const el = document.getElementById('liveGeoFilterToggle');
|
||||
if (el) {
|
||||
const saved = localStorage.getItem('meshcore-map-geo-filter');
|
||||
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
|
||||
el.addEventListener('change', function (e) {
|
||||
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
|
||||
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
|
||||
});
|
||||
}
|
||||
} catch (e) { /* no geo filter configured */ }
|
||||
})();
|
||||
|
||||
const matrixToggle = document.getElementById('liveMatrixToggle');
|
||||
matrixToggle.checked = matrixMode;
|
||||
@@ -949,7 +976,6 @@
|
||||
const nodeDetailPanel = document.getElementById('liveNodeDetail');
|
||||
const nodeDetailContent = document.getElementById('nodeDetailContent');
|
||||
document.getElementById('nodeDetailClose').addEventListener('click', () => {
|
||||
activeNodeDetailKey = null;
|
||||
nodeDetailPanel.classList.add('hidden');
|
||||
});
|
||||
|
||||
@@ -1174,7 +1200,6 @@
|
||||
}
|
||||
|
||||
async function showNodeDetail(pubkey) {
|
||||
activeNodeDetailKey = pubkey;
|
||||
const panel = document.getElementById('liveNodeDetail');
|
||||
const content = document.getElementById('nodeDetailContent');
|
||||
panel.classList.remove('hidden');
|
||||
@@ -1192,7 +1217,7 @@
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, '');
|
||||
const hasLoc = n.lat != null && n.lon != null;
|
||||
const lastSeen = formatLiveTimestampHtml(n.last_seen);
|
||||
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : '—';
|
||||
const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
|
||||
const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity;
|
||||
const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red';
|
||||
@@ -1233,7 +1258,7 @@
|
||||
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
|
||||
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
|
||||
<span style="color:var(--text-muted)">${p.timestamp ? timeAgo(p.timestamp) : '—'}</span>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
}
|
||||
@@ -1419,7 +1444,7 @@
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
<span class="feed-time">${new Date(group.latestTs || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
@@ -2283,7 +2308,7 @@
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
@@ -2351,7 +2376,7 @@
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.prepend(item);
|
||||
@@ -2454,7 +2479,6 @@
|
||||
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null;
|
||||
stopMatrixRain();
|
||||
nodeMarkers = {}; nodeData = {};
|
||||
activeNodeDetailKey = null;
|
||||
recentPaths = [];
|
||||
packetCount = 0; activeAnims = 0;
|
||||
nodeActivity = {}; pktTimestamps = [];
|
||||
@@ -2466,10 +2490,7 @@
|
||||
|
||||
registerPage('live', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => {
|
||||
rebuildFeedList();
|
||||
if (activeNodeDetailKey) showNodeDetail(activeNodeDetailKey);
|
||||
};
|
||||
_themeRefreshHandler = () => { /* live map rebuilds on next packet */ };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
|
||||
+44
-3
@@ -95,7 +95,7 @@
|
||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||||
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
|
||||
<label for="mcGeoFilter" id="mcGeoFilterLabel" style="display:none"><input type="checkbox" id="mcGeoFilter"> Geo filter area</label>
|
||||
<label id="mcGeoFilterLabel" for="mcGeoFilter" style="display:none"><input type="checkbox" id="mcGeoFilter"> Mesh live area</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Status</legend>
|
||||
@@ -228,7 +228,49 @@
|
||||
});
|
||||
|
||||
// Geo filter overlay
|
||||
initGeoFilterOverlay(map, 'mcGeoFilter', 'mcGeoFilterLabel').then(function (layer) { geoFilterLayer = layer; });
|
||||
(async function () {
|
||||
try {
|
||||
const gf = await api('/config/geo-filter', { ttl: 3600 });
|
||||
if (!gf || !gf.polygon || gf.polygon.length < 3) return;
|
||||
const geoColor = getComputedStyle(document.documentElement).getPropertyValue('--geo-filter-color').trim() || '#3b82f6';
|
||||
const latlngs = gf.polygon.map(function (p) { return [p[0], p[1]]; });
|
||||
const innerPoly = L.polygon(latlngs, {
|
||||
color: geoColor, weight: 2, opacity: 0.8,
|
||||
fillColor: geoColor, fillOpacity: 0.08
|
||||
});
|
||||
// Approximate buffer zone — expand each vertex outward from centroid by bufferKm
|
||||
const bufferPoly = gf.bufferKm > 0 ? (function () {
|
||||
let cLat = 0, cLon = 0;
|
||||
gf.polygon.forEach(function (p) { cLat += p[0]; cLon += p[1]; });
|
||||
cLat /= gf.polygon.length; cLon /= gf.polygon.length;
|
||||
const cosLat = Math.cos(cLat * Math.PI / 180);
|
||||
const outer = gf.polygon.map(function (p) {
|
||||
const dLatM = (p[0] - cLat) * 111000;
|
||||
const dLonM = (p[1] - cLon) * 111000 * cosLat;
|
||||
const dist = Math.sqrt(dLatM * dLatM + dLonM * dLonM);
|
||||
if (dist === 0) return [p[0], p[1]];
|
||||
const scale = (gf.bufferKm * 1000) / dist;
|
||||
return [p[0] + dLatM * scale / 111000, p[1] + dLonM * scale / (111000 * cosLat)];
|
||||
});
|
||||
return L.polygon(outer, {
|
||||
color: geoColor, weight: 1.5, opacity: 0.4, dashArray: '6 4',
|
||||
fillColor: geoColor, fillOpacity: 0.04
|
||||
});
|
||||
})() : null;
|
||||
geoFilterLayer = L.layerGroup(bufferPoly ? [bufferPoly, innerPoly] : [innerPoly]);
|
||||
const label = document.getElementById('mcGeoFilterLabel');
|
||||
if (label) label.style.display = '';
|
||||
const el = document.getElementById('mcGeoFilter');
|
||||
if (el) {
|
||||
const saved = localStorage.getItem('meshcore-map-geo-filter');
|
||||
if (saved === 'true') { el.checked = true; geoFilterLayer.addTo(map); }
|
||||
el.addEventListener('change', function (e) {
|
||||
localStorage.setItem('meshcore-map-geo-filter', e.target.checked);
|
||||
if (e.target.checked) { geoFilterLayer.addTo(map); } else { map.removeLayer(geoFilterLayer); }
|
||||
});
|
||||
}
|
||||
} catch (e) { /* no geo filter configured */ }
|
||||
})();
|
||||
|
||||
// WS for live advert updates
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
@@ -732,7 +774,6 @@
|
||||
markerLayer = null;
|
||||
routeLayer = null;
|
||||
if (heatLayer) { heatLayer = null; }
|
||||
geoFilterLayer = null;
|
||||
}
|
||||
|
||||
function toggleHeatmap(on) {
|
||||
|
||||
+12
-48
@@ -85,24 +85,6 @@
|
||||
{ key: 'sensor', label: 'Sensors' },
|
||||
];
|
||||
|
||||
function renderNodeTimestampHtml(isoString) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
|
||||
}
|
||||
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
|
||||
const warn = f.isFuture
|
||||
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
|
||||
: '';
|
||||
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
|
||||
}
|
||||
|
||||
function renderNodeTimestampText(isoString) {
|
||||
if (typeof formatTimestamp !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return typeof timeAgo === 'function' ? timeAgo(isoString) : '—';
|
||||
}
|
||||
return formatTimestamp(isoString, getTimestampMode());
|
||||
}
|
||||
|
||||
/* === Shared helper functions for node detail rendering === */
|
||||
|
||||
function getStatusTooltip(role, status) {
|
||||
@@ -135,7 +117,7 @@
|
||||
|
||||
let explanation = '';
|
||||
if (status === 'active') {
|
||||
explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown');
|
||||
explanation = 'Last heard ' + (lastHeardTime ? timeAgo(lastHeardTime) : 'unknown');
|
||||
} else {
|
||||
const ageDays = Math.floor(statusAge / 86400000);
|
||||
const ageHours = Math.floor(statusAge / 3600000);
|
||||
@@ -292,8 +274,8 @@
|
||||
|
||||
<table class="node-stats-table" id="node-stats">
|
||||
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
|
||||
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
|
||||
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
|
||||
<tr><td>Last Heard</td><td>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</td></tr>
|
||||
<tr><td>First Seen</td><td>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</td></tr>
|
||||
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
|
||||
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
|
||||
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${Number(stats.avgSnr).toFixed(1)} dB</td></tr>` : ''}
|
||||
@@ -345,7 +327,7 @@
|
||||
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
|
||||
}
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
|
||||
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
|
||||
</div>`;
|
||||
@@ -424,7 +406,7 @@
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -447,7 +429,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
@@ -457,8 +439,6 @@
|
||||
selectedKey = null;
|
||||
}
|
||||
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
let _allNodes = null; // cached full node list
|
||||
|
||||
// Build a map of lowercased name → count of distinct pubkeys sharing that name
|
||||
@@ -697,7 +677,7 @@
|
||||
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}</td>
|
||||
<td class="mono col-pubkey">${truncate(n.public_key, 16)}</td>
|
||||
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
|
||||
<td class="${lastSeenClass}">${renderNodeTimestampHtml(n.last_heard || n.last_seen)}</td>
|
||||
<td class="${lastSeenClass}">${timeAgo(n.last_heard || n.last_seen)}</td>
|
||||
<td>${n.advert_count || 0}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -770,8 +750,8 @@
|
||||
<div class="node-detail-section">
|
||||
<h4>Overview</h4>
|
||||
<dl class="detail-meta">
|
||||
<dt>Last Heard</dt><dd>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</dd>
|
||||
<dt>First Seen</dt><dd>${renderNodeTimestampHtml(n.first_seen)}</dd>
|
||||
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
|
||||
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Total Packets</dt><dd>${totalPackets}</dd>
|
||||
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
|
||||
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${Number(stats.avgSnr).toFixed(1)} dB</dd>` : ''}
|
||||
@@ -809,7 +789,7 @@
|
||||
return `<div class="advert-entry">
|
||||
<span class="advert-dot" style="background:${roleColor}"></span>
|
||||
<div class="advert-info">
|
||||
<strong>${renderNodeTimestampHtml(a.timestamp)}</strong> ${icon} ${pType}${detail}
|
||||
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
|
||||
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
|
||||
${obs ? ' via ' + escapeHtml(obs) : ''}
|
||||
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
|
||||
@@ -883,7 +863,7 @@
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -909,23 +889,7 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
registerPage('nodes', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => {
|
||||
if (directNode) loadFullNode(directNode);
|
||||
else {
|
||||
renderRows();
|
||||
if (selectedKey) selectNode(selectedKey);
|
||||
}
|
||||
};
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
registerPage('nodes', { init, destroy });
|
||||
|
||||
// Test hooks
|
||||
window._nodesIsAdvertMessage = isAdvertMessage;
|
||||
|
||||
+17
-46
@@ -157,40 +157,9 @@
|
||||
let directPacketId = null;
|
||||
let directPacketHash = null;
|
||||
let initGeneration = 0;
|
||||
let _docActionHandler = null;
|
||||
let _docMenuCloseHandler = null;
|
||||
let _docColMenuCloseHandler = null;
|
||||
|
||||
let directObsId = null;
|
||||
|
||||
function removeAllByopOverlays() {
|
||||
document.querySelectorAll('.byop-overlay').forEach(function (el) { el.remove(); });
|
||||
}
|
||||
|
||||
function bindDocumentHandler(kind, eventName, handler) {
|
||||
const prev = kind === 'action'
|
||||
? _docActionHandler
|
||||
: kind === 'menu'
|
||||
? _docMenuCloseHandler
|
||||
: _docColMenuCloseHandler;
|
||||
if (prev) document.removeEventListener(eventName, prev);
|
||||
document.addEventListener(eventName, handler);
|
||||
if (kind === 'action') _docActionHandler = handler;
|
||||
else if (kind === 'menu') _docMenuCloseHandler = handler;
|
||||
else _docColMenuCloseHandler = handler;
|
||||
}
|
||||
|
||||
function renderTimestampCell(isoString) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
|
||||
}
|
||||
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
|
||||
const warn = f.isFuture
|
||||
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
|
||||
: '';
|
||||
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
|
||||
}
|
||||
|
||||
async function init(app, routeParam) {
|
||||
const gen = ++initGeneration;
|
||||
// Parse ?obs=OBSERVER_ID from routeParam
|
||||
@@ -257,7 +226,7 @@
|
||||
}
|
||||
|
||||
// Event delegation for data-action buttons
|
||||
bindDocumentHandler('action', 'click', function (e) {
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
if (btn.dataset.action === 'pkt-refresh') loadPackets();
|
||||
@@ -396,10 +365,6 @@
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (_docActionHandler) { document.removeEventListener('click', _docActionHandler); _docActionHandler = null; }
|
||||
if (_docMenuCloseHandler) { document.removeEventListener('click', _docMenuCloseHandler); _docMenuCloseHandler = null; }
|
||||
if (_docColMenuCloseHandler) { document.removeEventListener('click', _docColMenuCloseHandler); _docColMenuCloseHandler = null; }
|
||||
removeAllByopOverlays();
|
||||
packets = [];
|
||||
hashIndex = new Map(); selectedId = null;
|
||||
filtersBuilt = false;
|
||||
@@ -596,6 +561,7 @@
|
||||
<table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th scope="col"></th><th scope="col" class="col-region">Region</th><th scope="col" class="col-time">Time</th><th scope="col" class="col-hash">Hash</th><th scope="col" class="col-size">Size</th>
|
||||
<th scope="col" class="col-hashsize">HB</th>
|
||||
<th scope="col" class="col-type">Type</th><th scope="col" class="col-observer">Observer</th><th scope="col" class="col-path">Path</th><th scope="col" class="col-rpt">Rpt</th><th scope="col" class="col-details">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
@@ -729,7 +695,7 @@
|
||||
});
|
||||
|
||||
// Close multi-select menus on outside click
|
||||
bindDocumentHandler('menu', 'click', (e) => {
|
||||
document.addEventListener('click', (e) => {
|
||||
const obsWrap = document.getElementById('observerFilterWrap');
|
||||
const typeWrap = document.getElementById('typeFilterWrap');
|
||||
if (obsWrap && !obsWrap.contains(e.target)) { const m = obsWrap.querySelector('.multi-select-menu'); if (m) m.classList.remove('open'); }
|
||||
@@ -844,7 +810,7 @@
|
||||
e.stopPropagation();
|
||||
colMenu.classList.toggle('open');
|
||||
});
|
||||
bindDocumentHandler('colmenu', 'click', () => colMenu.classList.remove('open'));
|
||||
document.addEventListener('click', () => colMenu.classList.remove('open'));
|
||||
applyColVisibility();
|
||||
|
||||
document.getElementById('hexHashToggle').addEventListener('click', function () {
|
||||
@@ -1055,13 +1021,15 @@
|
||||
const groupTypeName = payloadTypeName(p.payload_type);
|
||||
const groupTypeClass = payloadTypeColor(p.payload_type);
|
||||
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const groupHashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const isSingle = p.count <= 1;
|
||||
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="col-time">${timeAgo(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono">${groupHashBytes}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
@@ -1080,15 +1048,17 @@
|
||||
const typeName = payloadTypeName(c.payload_type);
|
||||
const typeClass = payloadTypeColor(c.payload_type);
|
||||
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
|
||||
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
|
||||
let childPath = [];
|
||||
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="col-time">${timeAgo(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
@@ -1111,13 +1081,15 @@
|
||||
const typeName = payloadTypeName(p.payload_type);
|
||||
const typeClass = payloadTypeColor(p.payload_type);
|
||||
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
|
||||
const hashBytes = ((parseInt(p.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
|
||||
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
|
||||
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="col-time">${timeAgo(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td class="col-observer">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
@@ -1365,7 +1337,7 @@
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(pkt.timestamp)}</dd>
|
||||
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}</dd>
|
||||
</dl>
|
||||
@@ -1572,10 +1544,9 @@
|
||||
|
||||
// BYOP modal — decode only, no DB injection
|
||||
function showBYOP() {
|
||||
removeAllByopOverlays();
|
||||
const triggerBtn = document.querySelector('[data-action="pkt-byop"]');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay byop-overlay';
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = '<div class="modal byop-modal" role="dialog" aria-label="Decode a Packet" aria-modal="true">'
|
||||
+ '<div class="byop-header"><h3>📦 Decode a Packet</h3><button class="btn-icon byop-x" title="Close" aria-label="Close dialog">✕</button></div>'
|
||||
+ '<p class="text-muted" style="margin:0 0 12px;font-size:.85rem">Paste raw hex bytes from your radio or MQTT feed:</p>'
|
||||
@@ -1586,7 +1557,7 @@
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const modal = overlay.querySelector('.byop-modal');
|
||||
const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };
|
||||
const close = () => { overlay.remove(); if (triggerBtn) triggerBtn.focus(); };
|
||||
overlay.querySelector('.byop-x').onclick = close;
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
@@ -1622,7 +1593,7 @@
|
||||
|
||||
async function doDecode() {
|
||||
const hex = textarea.value.trim().replace(/[\s\n]/g, '');
|
||||
const result = overlay.querySelector('#byopResult');
|
||||
const result = document.getElementById('byopResult');
|
||||
if (!hex) { result.innerHTML = '<p class="text-muted">Enter hex data</p>'; return; }
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) { result.innerHTML = '<p class="byop-err" role="alert">Invalid hex — only 0-9 and A-F allowed</p>'; return; }
|
||||
result.innerHTML = '<p class="text-muted">Decoding...</p>';
|
||||
|
||||
+3
-20
@@ -6,6 +6,7 @@
|
||||
--nav-text: #ffffff;
|
||||
--nav-text-muted: #cbd5e1;
|
||||
--accent: #4a9eff;
|
||||
--geo-filter-color: #3b82f6;
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
@@ -280,8 +281,6 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
.data-table td.col-details { white-space: normal; word-break: break-word; }
|
||||
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
|
||||
.data-table .col-time { min-width: 108px; white-space: nowrap; }
|
||||
.timestamp-future-icon { margin-left: 4px; cursor: help; }
|
||||
.data-table tbody tr:nth-child(even) { background: var(--row-stripe); }
|
||||
.data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; }
|
||||
.data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
@@ -881,7 +880,6 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.data-table { font-size: 11px; min-width: 0; }
|
||||
.data-table td { padding: 5px 4px; max-width: 100px; }
|
||||
.data-table th { padding: 5px 4px; font-size: 10px; }
|
||||
.data-table .col-time { min-width: 64px; }
|
||||
.panel-left { overflow-x: auto; }
|
||||
|
||||
/* Filters: collapse on mobile */
|
||||
@@ -1099,22 +1097,6 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hash-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; }
|
||||
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
|
||||
.hash-cell-empty { background: var(--card-bg); color: var(--text-muted); }
|
||||
.hash-cell-taken { background: var(--status-green); color: #fff; }
|
||||
.hash-cell-possible { background: var(--status-yellow); color: #fff; }
|
||||
.hash-cell-collision { color: #fff; }
|
||||
.hash-matrix-tooltip {
|
||||
position: fixed; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); padding: 8px 12px;
|
||||
font-size: 12px; min-width: 160px; max-width: 260px; pointer-events: none;
|
||||
}
|
||||
.hash-matrix-tooltip-hex { font-family: var(--mono); font-size: 13px; font-weight: 700; margin-bottom: 4px; color: var(--accent); }
|
||||
.hash-matrix-tooltip-status { color: var(--text-muted); font-size: 11px; }
|
||||
.hash-matrix-tooltip-nodes { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.hash-byte-selector { display: flex; gap: 4px; }
|
||||
.hash-byte-btn { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; }
|
||||
.hash-byte-btn:hover { background: var(--border); color: var(--text); }
|
||||
.hash-byte-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
|
||||
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
|
||||
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
|
||||
@@ -1224,7 +1206,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
|
||||
/* Hide low-value columns on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.col-region, .col-rpt, .col-size, .col-pubkey { display: none; }
|
||||
.col-region, .col-rpt, .col-size, .col-hashsize, .col-pubkey { display: none; }
|
||||
}
|
||||
|
||||
/* Clickable hop links */
|
||||
@@ -1370,6 +1352,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.hide-col-observer .col-observer,
|
||||
.hide-col-path .col-path,
|
||||
.hide-col-rpt .col-rpt,
|
||||
.hide-col-hashsize .col-hashsize,
|
||||
.hide-col-details .col-details { display: none; }
|
||||
|
||||
/* === Home page fixes === */
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Delete nodes from the database that fall outside the configured geo_filter polygon + bufferKm.
|
||||
Nodes with no GPS coordinates are always kept.
|
||||
|
||||
Usage:
|
||||
python3 prune-nodes-outside-geo-filter.py [db_path] [--config config.json] [--dry-run]
|
||||
|
||||
db_path Path to meshcore.db (default: /app/data/meshcore.db)
|
||||
--config PATH Path to config.json (default: /app/config.json)
|
||||
--dry-run Show what would be deleted without making any changes
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import math
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def point_in_polygon(lat, lon, polygon):
|
||||
"""Ray-casting algorithm."""
|
||||
inside = False
|
||||
n = len(polygon)
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
yi, xi = polygon[i] # lat, lon
|
||||
yj, xj = polygon[j]
|
||||
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def dist_to_segment_km(lat, lon, a, b):
|
||||
"""Approximate distance (km) from point to line segment, using flat-earth projection."""
|
||||
lat1, lon1 = a
|
||||
lat2, lon2 = b
|
||||
mid_lat = (lat1 + lat2) / 2.0
|
||||
cos_lat = math.cos(math.radians(mid_lat))
|
||||
km_per_deg_lat = 111.0
|
||||
km_per_deg_lon = 111.0 * cos_lat
|
||||
|
||||
# Translate so point is at origin
|
||||
ax = (lon1 - lon) * km_per_deg_lon
|
||||
ay = (lat1 - lat) * km_per_deg_lat
|
||||
bx = (lon2 - lon) * km_per_deg_lon
|
||||
by = (lat2 - lat) * km_per_deg_lat
|
||||
|
||||
abx, aby = bx - ax, by - ay
|
||||
ab_sq = abx * abx + aby * aby
|
||||
if ab_sq == 0:
|
||||
return math.sqrt(ax * ax + ay * ay)
|
||||
|
||||
t = max(0.0, min(1.0, -(ax * abx + ay * aby) / ab_sq))
|
||||
px = ax + t * abx
|
||||
py = ay + t * aby
|
||||
return math.sqrt(px * px + py * py)
|
||||
|
||||
|
||||
def node_passes_filter(lat, lon, polygon, buffer_km):
|
||||
"""Return True if the node should be kept."""
|
||||
if lat is None or lon is None:
|
||||
return True
|
||||
if lat == 0.0 and lon == 0.0:
|
||||
return True # no GPS fix
|
||||
if point_in_polygon(lat, lon, polygon):
|
||||
return True
|
||||
if buffer_km > 0:
|
||||
n = len(polygon)
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
if dist_to_segment_km(lat, lon, polygon[i], polygon[j]) <= buffer_km:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_geo_filter(config_path):
|
||||
"""Load polygon and bufferKm from config.json geo_filter section."""
|
||||
if not os.path.exists(config_path):
|
||||
print(f"ERROR: config not found at {config_path}")
|
||||
sys.exit(1)
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
gf = cfg.get('geo_filter')
|
||||
if not gf:
|
||||
print("ERROR: no geo_filter section found in config.json")
|
||||
sys.exit(1)
|
||||
polygon = gf.get('polygon', [])
|
||||
if len(polygon) < 3:
|
||||
print("ERROR: geo_filter.polygon must have at least 3 points")
|
||||
sys.exit(1)
|
||||
buffer_km = gf.get('bufferKm', 0.0)
|
||||
print(f"Loaded geo_filter from {config_path}: {len(polygon)} points, bufferKm={buffer_km}")
|
||||
return polygon, buffer_km
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
dry_run = '--dry-run' in args
|
||||
args = [a for a in args if a != '--dry-run']
|
||||
|
||||
config_path = '/app/config.json'
|
||||
if '--config' in args:
|
||||
idx = args.index('--config')
|
||||
config_path = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
|
||||
db_path = args[0] if args else '/app/data/meshcore.db'
|
||||
|
||||
polygon, buffer_km = load_geo_filter(config_path)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"ERROR: database not found at {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute('SELECT public_key, name, lat, lon FROM nodes ORDER BY name')
|
||||
nodes = cur.fetchall()
|
||||
|
||||
keep, remove = [], []
|
||||
for row in nodes:
|
||||
lat = row['lat']
|
||||
lon = row['lon']
|
||||
if node_passes_filter(lat, lon, polygon, buffer_km):
|
||||
keep.append(row)
|
||||
else:
|
||||
remove.append(row)
|
||||
|
||||
print(f"Total nodes in DB : {len(nodes)}")
|
||||
print(f"Nodes to keep : {len(keep)}")
|
||||
print(f"Nodes to delete : {len(remove)}")
|
||||
|
||||
if not remove:
|
||||
print("\nNothing to delete.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("\nNodes that will be DELETED:")
|
||||
for row in remove:
|
||||
lat = row['lat'] or 0
|
||||
lon = row['lon'] or 0
|
||||
name = row['name'] or row['public_key'][:12]
|
||||
print(f" {name:<30} lat={lat:.4f} lon={lon:.4f}")
|
||||
|
||||
if dry_run:
|
||||
print("\n[dry-run] No changes made.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
confirm = input(f"\nDelete {len(remove)} nodes? Type 'yes' to confirm: ").strip()
|
||||
if confirm.lower() != 'yes':
|
||||
print("Aborted.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
pubkeys = [row['public_key'] for row in remove]
|
||||
cur.executemany('DELETE FROM nodes WHERE public_key = ?', [(pk,) for pk in pubkeys])
|
||||
conn.commit()
|
||||
print(f"\nDeleted {cur.rowcount if cur.rowcount >= 0 else len(pubkeys)} nodes.")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+21
-392
@@ -104,75 +104,6 @@ console.log('\n=== app.js: timeAgo ===');
|
||||
const d = new Date(Date.now() - 259200000).toISOString();
|
||||
assert.strictEqual(timeAgo(d), '3d ago');
|
||||
});
|
||||
test('future timestamp returns in-format', () => {
|
||||
const d = new Date(Date.now() + 120000).toISOString();
|
||||
assert.strictEqual(timeAgo(d), 'in 2m');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== app.js: formatTimestamp / formatTimestampWithTooltip ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
const formatTimestamp = ctx.formatTimestamp;
|
||||
const formatTimestampWithTooltip = ctx.formatTimestampWithTooltip;
|
||||
|
||||
test('formatTimestamp null returns dash', () => {
|
||||
assert.strictEqual(formatTimestamp(null, 'ago'), '—');
|
||||
});
|
||||
test('formatTimestamp ago returns relative string', () => {
|
||||
const d = new Date(Date.now() - 300000).toISOString();
|
||||
assert.strictEqual(formatTimestamp(d, 'ago'), '5m ago');
|
||||
});
|
||||
test('formatTimestamp absolute returns formatted timestamp', () => {
|
||||
const d = '2024-01-02T03:04:05.000Z';
|
||||
const out = formatTimestamp(d, 'absolute');
|
||||
assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out));
|
||||
});
|
||||
test('formatTimestamp absolute with timezone utc uses UTC fields', () => {
|
||||
const d = '2024-01-02T03:04:05.123Z';
|
||||
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
|
||||
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
|
||||
assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05');
|
||||
});
|
||||
test('formatTimestamp absolute with timezone local uses local fields', () => {
|
||||
const d = '2024-01-02T03:04:05.123Z';
|
||||
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local');
|
||||
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso');
|
||||
const out = formatTimestamp(d, 'absolute');
|
||||
const expected = d.replace('T', ' ').slice(0, 19);
|
||||
assert.strictEqual(out.length, 19);
|
||||
assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out));
|
||||
if (new Date(d).getTimezoneOffset() === 0) assert.strictEqual(out, expected);
|
||||
else assert.notStrictEqual(out, expected);
|
||||
});
|
||||
test('formatTimestamp absolute iso-seconds includes milliseconds', () => {
|
||||
const d = '2024-01-02T03:04:05.123Z';
|
||||
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'utc');
|
||||
ctx.localStorage.setItem('meshcore-timestamp-format', 'iso-seconds');
|
||||
assert.strictEqual(formatTimestamp(d, 'absolute'), '2024-01-02 03:04:05.123');
|
||||
});
|
||||
test('formatTimestamp absolute locale uses toLocaleString', () => {
|
||||
const d = '2024-01-02T03:04:05.123Z';
|
||||
ctx.localStorage.setItem('meshcore-timestamp-timezone', 'local');
|
||||
ctx.localStorage.setItem('meshcore-timestamp-format', 'locale');
|
||||
assert.strictEqual(formatTimestamp(d, 'absolute'), new Date(d).toLocaleString());
|
||||
});
|
||||
test('formatTimestampWithTooltip future returns isFuture true', () => {
|
||||
const d = new Date(Date.now() + 120000).toISOString();
|
||||
const out = formatTimestampWithTooltip(d, 'ago');
|
||||
assert.strictEqual(out.isFuture, true);
|
||||
assert.ok(typeof out.text === 'string' && out.text.length > 0);
|
||||
assert.strictEqual(out.tooltip, 'in 2m');
|
||||
});
|
||||
test('tooltip is opposite format', () => {
|
||||
const d = '2024-01-02T03:04:05.000Z';
|
||||
const ago = formatTimestampWithTooltip(d, 'ago');
|
||||
const absolute = formatTimestampWithTooltip(d, 'absolute');
|
||||
assert.ok(typeof ago.tooltip === 'string' && ago.tooltip.length > 0);
|
||||
assert.ok(absolute.tooltip.endsWith('ago') || absolute.tooltip.startsWith('in '));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== app.js: escapeHtml ===');
|
||||
@@ -220,7 +151,27 @@ console.log('\n=== app.js: truncate ===');
|
||||
// ===== NODES.JS TESTS =====
|
||||
console.log('\n=== nodes.js: getStatusInfo ===');
|
||||
{
|
||||
// Placeholder header for continuity; actual nodes tests are below using injected exports.
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
// nodes.js is an IIFE that registers a page — we need to mock registerPage and other globals
|
||||
ctx.registerPage = () => {};
|
||||
ctx.api = () => Promise.resolve([]);
|
||||
ctx.timeAgo = vm.runInContext(`(${fs.readFileSync('public/app.js', 'utf8').match(/function timeAgo[^}]+}/)[0]})`, ctx);
|
||||
// Actually, let's load app.js first for its globals
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
loadInCtx(ctx, 'public/nodes.js');
|
||||
|
||||
// getStatusInfo is inside the IIFE, not on window. We need to extract it differently.
|
||||
// Let's use a modified approach - inject a hook before loading
|
||||
}
|
||||
|
||||
// Since nodes.js functions are inside an IIFE, we need to extract them.
|
||||
@@ -1318,29 +1269,6 @@ console.log('\n=== compare.js: comparePacketSets ===');
|
||||
assert.ok(packetsSource.includes("classList.remove('detail-collapsed')"),
|
||||
'selectPacket should remove detail-collapsed class');
|
||||
});
|
||||
|
||||
test('BYOP uses dedicated overlay class and clears existing overlays before opening', () => {
|
||||
assert.ok(packetsSource.includes("overlay.className = 'modal-overlay byop-overlay'"),
|
||||
'BYOP overlay should have byop-overlay class');
|
||||
assert.ok(/function showBYOP\(\)\s*\{\s*removeAllByopOverlays\(\);/m.test(packetsSource),
|
||||
'showBYOP should clear existing overlays before creating a new one');
|
||||
});
|
||||
|
||||
test('BYOP close removes all overlays in one click', () => {
|
||||
assert.ok(packetsSource.includes("const close = () => { removeAllByopOverlays(); if (triggerBtn) triggerBtn.focus(); };"),
|
||||
'close handler should remove all BYOP overlays');
|
||||
});
|
||||
|
||||
test('packets page de-duplicates document click handlers', () => {
|
||||
assert.ok(packetsSource.includes("bindDocumentHandler('action', 'click'"),
|
||||
'action click handler should be bound through bindDocumentHandler');
|
||||
assert.ok(packetsSource.includes("bindDocumentHandler('menu', 'click'"),
|
||||
'menu close handler should be bound through bindDocumentHandler');
|
||||
assert.ok(packetsSource.includes("bindDocumentHandler('colmenu', 'click'"),
|
||||
'column menu close handler should be bound through bindDocumentHandler');
|
||||
assert.ok(packetsSource.includes("if (prev) document.removeEventListener(eventName, prev);"),
|
||||
'bindDocumentHandler should remove previous handler before re-binding');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== APP.JS: formatEngineBadge =====
|
||||
@@ -1674,305 +1602,6 @@ console.log('\n=== analytics.js: sortChannels ===');
|
||||
});
|
||||
}
|
||||
|
||||
// === analytics.js: hash prefix helpers ===
|
||||
console.log('\n=== analytics.js: hash prefix helpers ===');
|
||||
{
|
||||
const ctx = (() => {
|
||||
const c = makeSandbox();
|
||||
c.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
c.registerPage = () => {};
|
||||
c.api = () => Promise.resolve({});
|
||||
c.timeAgo = () => '—';
|
||||
c.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
|
||||
c.onWS = () => {};
|
||||
c.offWS = () => {};
|
||||
c.connectWS = () => {};
|
||||
c.invalidateApiCache = () => {};
|
||||
c.makeColumnsResizable = () => {};
|
||||
c.initTabBar = () => {};
|
||||
c.IATA_COORDS_GEO = {};
|
||||
loadInCtx(c, 'public/roles.js');
|
||||
loadInCtx(c, 'public/app.js');
|
||||
try { loadInCtx(c, 'public/analytics.js'); } catch (e) {
|
||||
for (const k of Object.keys(c.window)) c[k] = c.window[k];
|
||||
}
|
||||
return c;
|
||||
})();
|
||||
|
||||
const buildOne = ctx.window._analyticsBuildOneBytePrefixMap;
|
||||
const buildTwo = ctx.window._analyticsBuildTwoBytePrefixInfo;
|
||||
const buildHops = ctx.window._analyticsBuildCollisionHops;
|
||||
|
||||
const node = (pk, extra) => ({ public_key: pk, name: pk.slice(0, 4), ...(extra || {}) });
|
||||
|
||||
test('buildOneBytePrefixMap exports exist', () => assert.ok(buildOne, 'must be exported'));
|
||||
test('buildTwoBytePrefixInfo exports exist', () => assert.ok(buildTwo, 'must be exported'));
|
||||
test('buildCollisionHops exports exist', () => assert.ok(buildHops, 'must be exported'));
|
||||
|
||||
// --- 1-byte prefix map ---
|
||||
test('1-byte map has 256 keys', () => {
|
||||
const m = buildOne([]);
|
||||
assert.strictEqual(Object.keys(m).length, 256);
|
||||
});
|
||||
|
||||
test('1-byte map places node in correct bucket', () => {
|
||||
const n = node('AABBCC');
|
||||
const m = buildOne([n]);
|
||||
assert.strictEqual(m['AA'].length, 1);
|
||||
assert.strictEqual(m['AA'][0].public_key, 'AABBCC');
|
||||
assert.strictEqual(m['BB'].length, 0);
|
||||
});
|
||||
|
||||
test('1-byte map groups two nodes with same prefix', () => {
|
||||
const a = node('AA1111'), b = node('AA2222');
|
||||
const m = buildOne([a, b]);
|
||||
assert.strictEqual(m['AA'].length, 2);
|
||||
});
|
||||
|
||||
test('1-byte map is case-insensitive for node keys', () => {
|
||||
const n = node('aabbcc');
|
||||
const m = buildOne([n]);
|
||||
assert.strictEqual(m['AA'].length, 1);
|
||||
});
|
||||
|
||||
test('1-byte map: empty input yields all empty buckets', () => {
|
||||
const m = buildOne([]);
|
||||
assert.ok(Object.values(m).every(v => v.length === 0));
|
||||
});
|
||||
|
||||
// --- 2-byte prefix info ---
|
||||
test('2-byte info has 256 first-byte keys', () => {
|
||||
const info = buildTwo([]);
|
||||
assert.strictEqual(Object.keys(info).length, 256);
|
||||
});
|
||||
|
||||
test('2-byte info: no nodes → zero collisions', () => {
|
||||
const info = buildTwo([]);
|
||||
assert.ok(Object.values(info).every(e => e.collisionCount === 0));
|
||||
});
|
||||
|
||||
test('2-byte info: node placed in correct first-byte group', () => {
|
||||
const n = node('AABB1122');
|
||||
const info = buildTwo([n]);
|
||||
assert.strictEqual(info['AA'].groupNodes.length, 1);
|
||||
assert.strictEqual(info['BB'].groupNodes.length, 0);
|
||||
});
|
||||
|
||||
test('2-byte info: same 2-byte prefix = collision', () => {
|
||||
const a = node('AABB0001'), b = node('AABB0002');
|
||||
const info = buildTwo([a, b]);
|
||||
assert.strictEqual(info['AA'].collisionCount, 1);
|
||||
assert.strictEqual(info['AA'].maxCollision, 2);
|
||||
});
|
||||
|
||||
test('2-byte info: different 2-byte prefixes in same group = no collision', () => {
|
||||
const a = node('AA110001'), b = node('AA220002');
|
||||
const info = buildTwo([a, b]);
|
||||
assert.strictEqual(info['AA'].collisionCount, 0);
|
||||
assert.strictEqual(info['AA'].maxCollision, 0);
|
||||
});
|
||||
|
||||
test('2-byte info: twoByteMap built correctly', () => {
|
||||
const a = node('AABB0001'), b = node('AABB0002'), c = node('AACC0003');
|
||||
const info = buildTwo([a, b, c]);
|
||||
assert.strictEqual(Object.keys(info['AA'].twoByteMap).length, 2);
|
||||
assert.strictEqual(info['AA'].twoByteMap['AABB'].length, 2);
|
||||
assert.strictEqual(info['AA'].twoByteMap['AACC'].length, 1);
|
||||
});
|
||||
|
||||
// --- 3-byte stat summary (via buildCollisionHops) ---
|
||||
test('buildCollisionHops: no collisions returns empty array', () => {
|
||||
const nodes = [node('AA000001'), node('BB000002'), node('CC000003')];
|
||||
assert.deepStrictEqual(buildHops(nodes, 1), []);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: detects 1-byte collision', () => {
|
||||
const nodes = [node('AA000001'), node('AA000002')];
|
||||
const hops = buildHops(nodes, 1);
|
||||
assert.strictEqual(hops.length, 1);
|
||||
assert.strictEqual(hops[0].hex, 'AA');
|
||||
assert.strictEqual(hops[0].count, 2);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: detects 2-byte collision', () => {
|
||||
const nodes = [node('AABB0001'), node('AABB0002'), node('AACC0003')];
|
||||
const hops = buildHops(nodes, 2);
|
||||
assert.strictEqual(hops.length, 1);
|
||||
assert.strictEqual(hops[0].hex, 'AABB');
|
||||
assert.strictEqual(hops[0].count, 2);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: detects 3-byte collision', () => {
|
||||
const nodes = [node('AABBCC0001'), node('AABBCC0002')];
|
||||
const hops = buildHops(nodes, 3);
|
||||
assert.strictEqual(hops.length, 1);
|
||||
assert.strictEqual(hops[0].hex, 'AABBCC');
|
||||
});
|
||||
|
||||
test('buildCollisionHops: size field set correctly', () => {
|
||||
const nodes = [node('AABB0001'), node('AABB0002')];
|
||||
const hops = buildHops(nodes, 2);
|
||||
assert.strictEqual(hops[0].size, 2);
|
||||
});
|
||||
|
||||
test('buildCollisionHops: empty input returns empty array', () => {
|
||||
assert.deepStrictEqual(buildHops([], 1), []);
|
||||
assert.deepStrictEqual(buildHops([], 2), []);
|
||||
assert.deepStrictEqual(buildHops([], 3), []);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CUSTOMIZE.JS: initState merge behavior =====
|
||||
console.log('\n=== customize.js: initState merge behavior ===');
|
||||
{
|
||||
function loadCustomizeExports(ctx) {
|
||||
const src = fs.readFileSync('public/customize.js', 'utf8');
|
||||
const withExports = src.replace(
|
||||
/\}\)\(\);\s*$/,
|
||||
'window.__customizeExport = { initState: initState, getState: function () { return state; }, getDefaults: function () { return deepClone(DEFAULTS); } };})();'
|
||||
);
|
||||
vm.runInContext(withExports, ctx);
|
||||
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
||||
return ctx.window.__customizeExport;
|
||||
}
|
||||
|
||||
test('partial local checklist does not wipe steps/footerLinks and keeps server colors', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: '🧪', title: 'Server Step', description: 'from server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
},
|
||||
theme: { accent: '#123456', navBg: '#222222' },
|
||||
nodeColors: { repeater: '#aa0000' }
|
||||
};
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: { checklist: [{ question: 'Local Q', answer: 'Local A' }] }
|
||||
}));
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.checklist[0].question, 'Local Q');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Server Step');
|
||||
assert.strictEqual(state.home.footerLinks[0].label, 'Server Link');
|
||||
assert.strictEqual(state.home.heroTitle, 'Server Hero');
|
||||
assert.strictEqual(state.theme.accent, '#123456');
|
||||
assert.strictEqual(state.nodeColors.repeater, '#aa0000');
|
||||
});
|
||||
|
||||
test('server values survive when localStorage has partial overrides', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: '1️⃣', title: 'Server Step', description: 'server' }],
|
||||
footerLinks: [{ label: 'Server Footer', url: '#/s' }]
|
||||
},
|
||||
theme: { accent: '#111111', navBg: '#222222', navText: '#333333' },
|
||||
typeColors: { ADVERT: '#00aa00', REQUEST: '#aa00aa' }
|
||||
};
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: { heroTitle: 'Local Hero' },
|
||||
theme: { accent: '#999999' },
|
||||
typeColors: { ADVERT: '#ff00ff' }
|
||||
}));
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.heroTitle, 'Local Hero');
|
||||
assert.strictEqual(state.home.heroSubtitle, 'Server Subtitle');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Server Step');
|
||||
assert.strictEqual(state.home.footerLinks[0].label, 'Server Footer');
|
||||
assert.strictEqual(state.theme.accent, '#999999');
|
||||
assert.strictEqual(state.theme.navBg, '#222222');
|
||||
assert.strictEqual(state.typeColors.ADVERT, '#ff00ff');
|
||||
assert.strictEqual(state.typeColors.REQUEST, '#aa00aa');
|
||||
});
|
||||
|
||||
test('full localStorage values override server config', () => {
|
||||
const ctx = makeSandbox();
|
||||
ctx.window.SITE_CONFIG = {
|
||||
home: {
|
||||
heroTitle: 'Server Hero',
|
||||
heroSubtitle: 'Server Subtitle',
|
||||
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
|
||||
checklist: [{ question: 'Server Q', answer: 'Server A' }],
|
||||
footerLinks: [{ label: 'Server Link', url: '#/server' }]
|
||||
},
|
||||
theme: { accent: '#101010' }
|
||||
};
|
||||
ctx.localStorage.setItem('meshcore-user-theme', JSON.stringify({
|
||||
home: {
|
||||
heroTitle: 'Local Hero',
|
||||
heroSubtitle: 'Local Subtitle',
|
||||
steps: [{ emoji: 'L', title: 'Local Step', description: 'local' }],
|
||||
checklist: [{ question: 'Local Q', answer: 'Local A' }],
|
||||
footerLinks: [{ label: 'Local Link', url: '#/local' }]
|
||||
},
|
||||
theme: { accent: '#abcdef', navBg: '#fedcba' }
|
||||
}));
|
||||
const ex = loadCustomizeExports(ctx);
|
||||
ex.initState();
|
||||
const state = ex.getState();
|
||||
assert.strictEqual(state.home.heroTitle, 'Local Hero');
|
||||
assert.strictEqual(state.home.heroSubtitle, 'Local Subtitle');
|
||||
assert.strictEqual(state.home.steps[0].title, 'Local Step');
|
||||
assert.strictEqual(state.home.checklist[0].question, 'Local Q');
|
||||
assert.strictEqual(state.home.footerLinks[0].label, 'Local Link');
|
||||
assert.strictEqual(state.theme.accent, '#abcdef');
|
||||
assert.strictEqual(state.theme.navBg, '#fedcba');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CHANNELS.JS: WS Region Filter helper =====
|
||||
console.log('\n=== channels.js: shouldProcessWSMessageForRegion ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.api = () => Promise.resolve({});
|
||||
ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000 };
|
||||
ctx.history = { replaceState() {} };
|
||||
ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
||||
ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8');
|
||||
loadInCtx(ctx, 'public/channels.js');
|
||||
const shouldProcess = ctx.window._channelsShouldProcessWSMessageForRegion;
|
||||
|
||||
test('helper is exported', () => assert.ok(typeof shouldProcess === 'function'));
|
||||
|
||||
test('allows all when no region selected', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs1' } } };
|
||||
assert.strictEqual(shouldProcess(msg, null, { obs1: 'SJC' }), true);
|
||||
assert.strictEqual(shouldProcess(msg, [], { obs1: 'SJC' }), true);
|
||||
});
|
||||
|
||||
test('allows message when observer region matches selection', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs1' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC', 'SFO'], { obs1: 'SJC' }), true);
|
||||
});
|
||||
|
||||
test('drops message when observer region is outside selection', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs2' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs2: 'LAX' }), false);
|
||||
});
|
||||
|
||||
test('drops message when observer_id is missing under selected region', () => {
|
||||
const msg = { data: {} };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false);
|
||||
});
|
||||
|
||||
test('drops message when observer region lookup missing', () => {
|
||||
const msg = { data: { packet: { observer_id: 'obs9' } } };
|
||||
assert.strictEqual(shouldProcess(msg, ['SJC'], { obs1: 'SJC' }), false);
|
||||
});
|
||||
}
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoFilter Builder</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 12px 16px; background: #0f0f23; border-bottom: 1px solid #333; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 1rem; font-weight: 600; color: #4a9eff; white-space: nowrap; }
|
||||
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; }
|
||||
#btnUndo { background: #333; color: #ccc; }
|
||||
#btnClear { background: #5a2020; color: #ffaaaa; }
|
||||
#btnUndo:hover { background: #444; }
|
||||
#btnClear:hover { background: #7a2020; }
|
||||
.hint { font-size: 0.8rem; color: #888; margin-left: auto; }
|
||||
#map { flex: 1; }
|
||||
#output-panel { background: #0f0f23; border-top: 1px solid #333; padding: 12px 16px; display: flex; gap: 12px; align-items: flex-start; }
|
||||
#output-panel label { font-size: 0.75rem; color: #888; white-space: nowrap; padding-top: 6px; }
|
||||
#output { flex: 1; background: #111; border: 1px solid #333; border-radius: 6px; padding: 10px 12px; font-family: monospace; font-size: 0.78rem; color: #7ec8e3; white-space: pre; overflow-x: auto; min-height: 54px; max-height: 140px; overflow-y: auto; cursor: text; }
|
||||
#output.empty { color: #555; font-style: italic; }
|
||||
#btnCopy { padding: 6px 14px; background: #1a4a7a; color: #7ec8e3; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; white-space: nowrap; align-self: flex-end; }
|
||||
#btnCopy:hover { background: #2a6aaa; }
|
||||
#btnCopy.copied { background: #1a6a3a; color: #7effa0; }
|
||||
#counter { font-size: 0.8rem; color: #888; padding-top: 6px; white-space: nowrap; }
|
||||
.bufferRow { display: flex; align-items: center; gap: 8px; }
|
||||
.bufferRow label { font-size: 0.85rem; color: #aaa; }
|
||||
.bufferRow input { width: 60px; padding: 5px 8px; background: #222; border: 1px solid #444; border-radius: 6px; color: #eee; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>GeoFilter Builder</h1>
|
||||
<div class="controls">
|
||||
<button id="btnUndo">↩ Undo</button>
|
||||
<button id="btnClear">✕ Clear</button>
|
||||
</div>
|
||||
<div class="bufferRow">
|
||||
<label for="bufferKm">Buffer km:</label>
|
||||
<input type="number" id="bufferKm" value="20" min="0" max="500"/>
|
||||
</div>
|
||||
<span class="hint">Click on the map to add polygon points</span>
|
||||
</header>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="output-panel">
|
||||
<label>config.json</label>
|
||||
<div id="output" class="empty">Add at least 3 points to generate config…</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;align-items:flex-end">
|
||||
<span id="counter">0 points</span>
|
||||
<button id="btnCopy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const map = L.map('map').setView([50.5, 4.4], 8);
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
let points = [];
|
||||
let markers = [];
|
||||
let polygon = null;
|
||||
let closingLine = null;
|
||||
|
||||
function latLonPair(latlng) {
|
||||
return [parseFloat(latlng.lat.toFixed(6)), parseFloat(latlng.lng.toFixed(6))];
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Remove existing polygon and closing line
|
||||
if (polygon) { map.removeLayer(polygon); polygon = null; }
|
||||
if (closingLine) { map.removeLayer(closingLine); closingLine = null; }
|
||||
|
||||
if (points.length >= 3) {
|
||||
polygon = L.polygon(points, {
|
||||
color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.12
|
||||
}).addTo(map);
|
||||
} else if (points.length === 2) {
|
||||
closingLine = L.polyline(points, { color: '#4a9eff', weight: 2, dashArray: '5,5' }).addTo(map);
|
||||
}
|
||||
|
||||
updateOutput();
|
||||
}
|
||||
|
||||
function updateOutput() {
|
||||
const el = document.getElementById('output');
|
||||
const counter = document.getElementById('counter');
|
||||
counter.textContent = points.length + ' point' + (points.length !== 1 ? 's' : '');
|
||||
|
||||
if (points.length < 3) {
|
||||
el.textContent = 'Add at least 3 points to generate config…';
|
||||
el.classList.add('empty');
|
||||
return;
|
||||
}
|
||||
el.classList.remove('empty');
|
||||
|
||||
const bufferKm = parseFloat(document.getElementById('bufferKm').value) || 0;
|
||||
const config = { bufferKm, polygon: points };
|
||||
el.textContent = JSON.stringify({ geo_filter: config }, null, 2);
|
||||
}
|
||||
|
||||
map.on('click', function(e) {
|
||||
const pt = latLonPair(e.latlng);
|
||||
points.push(pt);
|
||||
|
||||
const idx = points.length;
|
||||
const marker = L.circleMarker(e.latlng, {
|
||||
radius: 6, color: '#4a9eff', weight: 2, fillColor: '#4a9eff', fillOpacity: 0.9
|
||||
}).addTo(map).bindTooltip(String(idx), { permanent: true, direction: 'top', offset: [0, -8], className: 'pt-label' });
|
||||
markers.push(marker);
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('btnUndo').addEventListener('click', function() {
|
||||
if (!points.length) return;
|
||||
points.pop();
|
||||
const m = markers.pop();
|
||||
if (m) map.removeLayer(m);
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('btnClear').addEventListener('click', function() {
|
||||
points = [];
|
||||
markers.forEach(m => map.removeLayer(m));
|
||||
markers = [];
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('bufferKm').addEventListener('input', updateOutput);
|
||||
|
||||
document.getElementById('btnCopy').addEventListener('click', function() {
|
||||
if (points.length < 3) return;
|
||||
const text = document.getElementById('output').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.getElementById('btnCopy');
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user