mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:27:57 +00:00
Implements #1727. ## What this adds **Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage feature. A roaming MeshCore **companion** radio (driven by the open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA, GPLv3) reports which nodes it heard directly, tagged with the phone's GPS and the packet's SNR/RSSI. CoreScope ingests these into a new `client_receptions` table and renders per-node **hex coverage** on the Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`) with a top-mobile-observers leaderboard. Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by the companion app for friendly names. ## Opt-in — default OFF (zero impact on existing deployments) The whole feature is gated behind one config flag, **disabled by default**: ```jsonc "clientRxCoverage": { "enabled": false } ``` When disabled (the default): the ingestor writes **no** `client_receptions`; the three coverage endpoints return a clean **404**; the UI hides the Coverage nav link, the `#/rx-coverage` route, and the Reach-page toggle. `/api/nodes/resolve` is always available (not coverage-specific). ## How it works ``` companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets │ ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key) │ server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard ``` - **Direct-only capture:** records only what the companion heard itself and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder) for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded. - **No new deps:** hexbins are a pure-Go pointy-top grid over Web Mercator (`cmd/server/hexgrid.go`) computed at query time (`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the existing Leaflet. - **Trust:** companion pubkey = identity; an EMQX ACL binds each client to publish only to its own `meshcore/client/{pubkey}/packets` topic. Payload contract in `docs/client-rx-coverage.md`. ## How to enable / try it 1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and restart server + ingestor. 2. Point an EMQX (or any broker) listener so a client can publish to `meshcore/client/<pubkey>/packets`; the ingestor already subscribes under `meshcore/#`. 3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on an Android phone paired (BLE) to a MeshCore companion — it captures heard nodes + GPS and publishes. 4. View results: per-node Reach page → toggle **coverage**, or the **Coverage** dashboard at `#/rx-coverage`. ## What's where - **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go` (`client_receptions` + `client_observers` schema), `main.go` (gated dispatch), `config.go` (flag). - **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go` (endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid), `node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go` (wiring + flag + `/api/config/client` field). - **Frontend:** `public/rx-coverage.js` (dashboard), `node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach toggle, flag-gated), `roles.js` (reads the flag, hides nav when off). - **Docs:** `docs/client-rx-coverage.md`. ## Testing - Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test ./...` — green, including new gate tests (`coverage_gate_test.go` in both: off → no rows / 404, on → works) and the rx-coverage / resolve / hexgrid suites. - JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js` (wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is wired into the e2e job and **skips when `clientRxCoverage` is disabled**, so it's safe under the default-off config. ## Notes for reviewers - The four new routes are registered in `cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness ratchet), matching how other not-yet-spec'd routes are tracked. Happy to write full OpenAPI spec entries instead if you prefer. - Commits are split per layer (ingestor / server endpoints / resolve / frontend / CI) for review. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/meshcore-analyzer/dbconfig"
|
|
"github.com/meshcore-analyzer/geofilter"
|
|
)
|
|
|
|
// MQTTSource represents a single MQTT broker connection.
|
|
type MQTTSource struct {
|
|
Name string `json:"name"`
|
|
Broker string `json:"broker"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
RejectUnauthorized *bool `json:"rejectUnauthorized,omitempty"`
|
|
Topics []string `json:"topics"`
|
|
IATAFilter []string `json:"iataFilter,omitempty"`
|
|
ConnectTimeoutSec int `json:"connectTimeoutSec,omitempty"`
|
|
Region string `json:"region,omitempty"`
|
|
}
|
|
|
|
// ConnectTimeoutOrDefault returns the per-source connect timeout in seconds,
|
|
// or 30 if not set (matching the WaitTimeout default from #926).
|
|
func (s MQTTSource) ConnectTimeoutOrDefault() int {
|
|
if s.ConnectTimeoutSec > 0 {
|
|
return s.ConnectTimeoutSec
|
|
}
|
|
return 30
|
|
}
|
|
|
|
// MQTTLegacy is the old single-broker config format.
|
|
type MQTTLegacy struct {
|
|
Broker string `json:"broker"`
|
|
Topic string `json:"topic"`
|
|
}
|
|
|
|
// Config holds the ingestor configuration, compatible with the Node.js config.json format.
|
|
type Config struct {
|
|
DBPath string `json:"dbPath"`
|
|
MQTT *MQTTLegacy `json:"mqtt,omitempty"`
|
|
MQTTSources []MQTTSource `json:"mqttSources,omitempty"`
|
|
LogLevel string `json:"logLevel,omitempty"`
|
|
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
|
|
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
|
HashChannels []string `json:"hashChannels,omitempty"`
|
|
HashRegions []string `json:"hashRegions,omitempty"`
|
|
Retention *RetentionConfig `json:"retention,omitempty"`
|
|
Metrics *MetricsConfig `json:"metrics,omitempty"`
|
|
Runtime *RuntimeConfig `json:"runtime,omitempty"`
|
|
ClientRxCoverage *ClientRxCoverageConfig `json:"clientRxCoverage,omitempty"`
|
|
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
|
ForeignAdverts *ForeignAdvertConfig `json:"foreignAdverts,omitempty"`
|
|
ValidateSignatures *bool `json:"validateSignatures,omitempty"`
|
|
DB *DBConfig `json:"db,omitempty"`
|
|
|
|
// ObserverIATAWhitelist restricts which observer IATA regions are processed.
|
|
// When non-empty, only observers whose IATA code (from the MQTT topic) matches
|
|
// one of these entries are accepted. Case-insensitive. An empty list means all
|
|
// IATA codes are allowed. This applies globally, unlike the per-source iataFilter.
|
|
ObserverIATAWhitelist []string `json:"observerIATAWhitelist,omitempty"`
|
|
|
|
// obsIATAWhitelistCached is the lazily-built uppercase set for O(1) lookups.
|
|
obsIATAWhitelistCached map[string]bool
|
|
obsIATAWhitelistOnce sync.Once
|
|
|
|
// ObserverBlacklist is a list of observer public keys to drop at ingest.
|
|
// Messages from blacklisted observers are silently discarded — no DB writes,
|
|
// no UpsertObserver, no observations, no metrics.
|
|
ObserverBlacklist []string `json:"observerBlacklist,omitempty"`
|
|
|
|
// obsBlacklistSetCached is the lazily-built lowercase set for O(1) lookups.
|
|
obsBlacklistSetCached map[string]bool
|
|
obsBlacklistOnce sync.Once
|
|
|
|
// NeighborEdgesMaxAgeDays controls neighbor_edges row retention
|
|
// (#1287 — moved from cmd/server). 0 = default 5.
|
|
NeighborEdgesMaxAgeDays int `json:"neighborEdgesMaxAgeDays,omitempty"`
|
|
|
|
// IngestBufferSize caps the in-memory queue (number of MQTT messages) held
|
|
// while the single SQLite writer is blocked by startup migrations/prunes
|
|
// (#1608). Received messages are drained once the write path is ready.
|
|
// 0 / unset => default. Bounded memory.
|
|
IngestBufferSize int `json:"ingestBufferSize,omitempty"`
|
|
}
|
|
|
|
// NeighborEdgesDaysOrDefault returns the configured pruning window or 5.
|
|
func (c *Config) NeighborEdgesDaysOrDefault() int {
|
|
if c == nil || c.NeighborEdgesMaxAgeDays <= 0 {
|
|
return 5
|
|
}
|
|
return c.NeighborEdgesMaxAgeDays
|
|
}
|
|
|
|
// IngestBufferSizeOrDefault returns the ingest buffer capacity. Default 50000:
|
|
// at typical mesh rates (~1-2 msg/s) that is many minutes of headroom while a
|
|
// startup migration holds the writer; each queued item is a small closure, so
|
|
// worst-case memory stays in the tens of MB.
|
|
func (c *Config) IngestBufferSizeOrDefault() int {
|
|
if c.IngestBufferSize > 0 {
|
|
return c.IngestBufferSize
|
|
}
|
|
return 50000
|
|
}
|
|
|
|
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
|
type GeoFilterConfig = geofilter.Config
|
|
|
|
// ForeignAdvertConfig controls how the ingestor handles ADVERTs whose GPS lies
|
|
// outside the configured geofilter polygon (#730). Modes:
|
|
// - "flag" (default): store the advert/node and tag it foreign for visibility.
|
|
// - "drop": silently discard the advert (legacy behavior).
|
|
type ForeignAdvertConfig struct {
|
|
Mode string `json:"mode,omitempty"`
|
|
}
|
|
|
|
// IsDropMode reports whether the foreign-advert config is set to "drop".
|
|
// Defaults to false ("flag" mode) when nil or unset.
|
|
func (f *ForeignAdvertConfig) IsDropMode() bool {
|
|
if f == nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(f.Mode), "drop")
|
|
}
|
|
|
|
// ClientRxCoverageConfig controls the opt-in mobile client-RX coverage feature.
|
|
type ClientRxCoverageConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// ClientRxCoverageEnabled reports whether the opt-in mobile client-RX coverage
|
|
// feature is on. Absent/nil ⇒ off (the safe default).
|
|
func (c *Config) ClientRxCoverageEnabled() bool {
|
|
return c.ClientRxCoverage != nil && c.ClientRxCoverage.Enabled
|
|
}
|
|
|
|
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
|
type RetentionConfig struct {
|
|
NodeDays int `json:"nodeDays"`
|
|
ObserverDays int `json:"observerDays"`
|
|
MetricsDays int `json:"metricsDays"`
|
|
// PacketDays is the retention window for transmissions (#1283).
|
|
// Ownership moved from cmd/server to cmd/ingestor; 0 disables.
|
|
PacketDays int `json:"packetDays"`
|
|
// ClientRxDays is the retention window (by rx_at) for mobile client-RX
|
|
// coverage rows in client_receptions / client_observers; 0 disables. Bounds
|
|
// the table the opt-in coverage feature would otherwise grow without limit.
|
|
ClientRxDays int `json:"clientRxDays"`
|
|
}
|
|
|
|
// PacketDaysOrZero returns the configured retention.packetDays or 0
|
|
// (disabled) if not set.
|
|
func (c *Config) PacketDaysOrZero() int {
|
|
if c.Retention != nil && c.Retention.PacketDays > 0 {
|
|
return c.Retention.PacketDays
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ClientRxDaysOrZero returns the configured retention.clientRxDays or 0
|
|
// (disabled) if not set.
|
|
func (c *Config) ClientRxDaysOrZero() int {
|
|
if c.Retention != nil && c.Retention.ClientRxDays > 0 {
|
|
return c.Retention.ClientRxDays
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// MetricsConfig controls observer metrics collection.
|
|
type MetricsConfig struct {
|
|
SampleIntervalSec int `json:"sampleIntervalSec"`
|
|
}
|
|
|
|
// RuntimeConfig holds Go runtime tuning knobs (#1010).
|
|
type RuntimeConfig struct {
|
|
// MaxMemoryMB is the soft memory limit (GOMEMLIMIT) in MiB applied via
|
|
// runtime/debug.SetMemoryLimit at startup. The GOMEMLIMIT environment
|
|
// variable, when set, takes precedence over this value. 0/unset means
|
|
// no limit is applied and default Go runtime behavior is preserved.
|
|
MaxMemoryMB int `json:"maxMemoryMB"`
|
|
}
|
|
|
|
// DBConfig is the shared SQLite vacuum/maintenance config (#919, #921).
|
|
type DBConfig = dbconfig.DBConfig
|
|
|
|
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
|
|
func (c *Config) IncrementalVacuumPages() int {
|
|
if c.DB != nil && c.DB.IncrementalVacuumPages > 0 {
|
|
return c.DB.IncrementalVacuumPages
|
|
}
|
|
return 1024
|
|
}
|
|
|
|
// ShouldValidateSignatures returns true (default) unless explicitly disabled.
|
|
func (c *Config) ShouldValidateSignatures() bool {
|
|
if c.ValidateSignatures != nil {
|
|
return *c.ValidateSignatures
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MetricsSampleInterval returns the configured sample interval or 300s default.
|
|
func (c *Config) MetricsSampleInterval() int {
|
|
if c.Metrics != nil && c.Metrics.SampleIntervalSec > 0 {
|
|
return c.Metrics.SampleIntervalSec
|
|
}
|
|
return 300
|
|
}
|
|
|
|
// MetricsRetentionDays returns configured metrics retention or 30 days default.
|
|
func (c *Config) MetricsRetentionDays() int {
|
|
if c.Retention != nil && c.Retention.MetricsDays > 0 {
|
|
return c.Retention.MetricsDays
|
|
}
|
|
return 30
|
|
}
|
|
|
|
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
|
|
func (c *Config) NodeDaysOrDefault() int {
|
|
if c.Retention != nil && c.Retention.NodeDays > 0 {
|
|
return c.Retention.NodeDays
|
|
}
|
|
return 7
|
|
}
|
|
|
|
// ObserverDaysOrDefault returns the configured retention.observerDays or 14 if not set.
|
|
// A value of -1 means observers are never removed.
|
|
func (c *Config) ObserverDaysOrDefault() int {
|
|
if c.Retention != nil && c.Retention.ObserverDays != 0 {
|
|
return c.Retention.ObserverDays
|
|
}
|
|
return 14
|
|
}
|
|
|
|
// IsObserverBlacklisted returns true if the given observer ID is in the observerBlacklist.
|
|
func (c *Config) IsObserverBlacklisted(id string) bool {
|
|
if c == nil || len(c.ObserverBlacklist) == 0 {
|
|
return false
|
|
}
|
|
c.obsBlacklistOnce.Do(func() {
|
|
m := make(map[string]bool, len(c.ObserverBlacklist))
|
|
for _, pk := range c.ObserverBlacklist {
|
|
trimmed := strings.ToLower(strings.TrimSpace(pk))
|
|
if trimmed != "" {
|
|
m[trimmed] = true
|
|
}
|
|
}
|
|
c.obsBlacklistSetCached = m
|
|
})
|
|
return c.obsBlacklistSetCached[strings.ToLower(strings.TrimSpace(id))]
|
|
}
|
|
|
|
// IsObserverIATAAllowed returns true if the given IATA code is permitted.
|
|
// When ObserverIATAWhitelist is empty, all codes are allowed.
|
|
func (c *Config) IsObserverIATAAllowed(iata string) bool {
|
|
if c == nil || len(c.ObserverIATAWhitelist) == 0 {
|
|
return true
|
|
}
|
|
c.obsIATAWhitelistOnce.Do(func() {
|
|
m := make(map[string]bool, len(c.ObserverIATAWhitelist))
|
|
for _, code := range c.ObserverIATAWhitelist {
|
|
trimmed := strings.ToUpper(strings.TrimSpace(code))
|
|
if trimmed != "" {
|
|
m[trimmed] = true
|
|
}
|
|
}
|
|
c.obsIATAWhitelistCached = m
|
|
})
|
|
return c.obsIATAWhitelistCached[strings.ToUpper(strings.TrimSpace(iata))]
|
|
}
|
|
|
|
// LoadConfig reads configuration from a JSON file, with env var overrides.
|
|
// If the config file does not exist, sensible defaults are used (zero-config startup).
|
|
func LoadConfig(path string) (*Config, error) {
|
|
var cfg Config
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("reading config %s: %w", path, err)
|
|
}
|
|
// Config file doesn't exist — use defaults (zero-config mode)
|
|
log.Printf("config file %s not found, using sensible defaults", path)
|
|
} else {
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parsing config %s: %w", path, err)
|
|
}
|
|
}
|
|
|
|
// Env var overrides
|
|
if v := os.Getenv("DB_PATH"); v != "" {
|
|
cfg.DBPath = v
|
|
}
|
|
if v := os.Getenv("MQTT_BROKER"); v != "" {
|
|
// Single broker from env — create a source
|
|
topic := os.Getenv("MQTT_TOPIC")
|
|
if topic == "" {
|
|
topic = "meshcore/#"
|
|
}
|
|
cfg.MQTTSources = []MQTTSource{{
|
|
Name: "env",
|
|
Broker: v,
|
|
Topics: []string{topic},
|
|
}}
|
|
}
|
|
|
|
// Default DB path
|
|
if cfg.DBPath == "" {
|
|
cfg.DBPath = "data/meshcore.db"
|
|
}
|
|
|
|
// Normalize: convert legacy single mqtt config to mqttSources
|
|
if len(cfg.MQTTSources) == 0 && cfg.MQTT != nil && cfg.MQTT.Broker != "" {
|
|
cfg.MQTTSources = []MQTTSource{{
|
|
Name: "default",
|
|
Broker: cfg.MQTT.Broker,
|
|
Topics: []string{cfg.MQTT.Topic, "meshcore/#"},
|
|
}}
|
|
}
|
|
|
|
// Default MQTT source: connect to localhost broker when no sources configured
|
|
if len(cfg.MQTTSources) == 0 {
|
|
cfg.MQTTSources = []MQTTSource{{
|
|
Name: "local",
|
|
Broker: "mqtt://localhost:1883",
|
|
Topics: []string{"meshcore/#"},
|
|
}}
|
|
log.Printf("no MQTT sources configured, defaulting to mqtt://localhost:1883")
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// ResolvedSources returns the final list of MQTT sources to connect to.
|
|
//
|
|
// Scheme mapping:
|
|
//
|
|
// mqtt:// → tcp:// (paho plain TCP)
|
|
// mqtts:// → ssl:// (paho TLS over TCP)
|
|
// ws:// (paho WebSocket — passed through, no mapping needed)
|
|
// wss:// (paho WebSocket TLS — passed through, no mapping needed)
|
|
func (c *Config) ResolvedSources() []MQTTSource {
|
|
for i := range c.MQTTSources {
|
|
// paho uses tcp:// and ssl:// for plain MQTT; ws:// and wss:// are accepted natively.
|
|
b := c.MQTTSources[i].Broker
|
|
if strings.HasPrefix(b, "mqtt://") {
|
|
c.MQTTSources[i].Broker = "tcp://" + b[7:]
|
|
} else if strings.HasPrefix(b, "mqtts://") {
|
|
c.MQTTSources[i].Broker = "ssl://" + b[8:]
|
|
}
|
|
// ws:// and wss:// pass through unchanged — paho handles WebSocket
|
|
// connections natively via gorilla/websocket.
|
|
}
|
|
return c.MQTTSources
|
|
}
|