mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 12:18:47 +00:00
Fixes #1268. Frequency, spreading factor, bandwidth and coding rate are per-device settings that never travel in a packet. The public MeshCore map is the only source. This PR adds them to the node detail views, opt-in, and answers the open policy questions from the triage comments. ## Policy decisions | Question (triage) | Answer in this PR | |---|---| | Opt-out for self-hosted instances | Opt-**in**: `rfParams.nodesUrl` empty (default) = nothing fetched, `/api/rf-params` returns `enabled: false`. Same model as `knownChannelsUrl`. | | 33 MB every 6 h, thundering herd | Random 0-60 s delay before the first fetch, then every `refreshHours` (default 6). The map sends gzip (17 MB on the wire). It sends no ETag or Last-Modified, so conditional requests are not possible. | | Size cap | 128 MB on the decoded body (it is 51.5 MB today). Exceeding it fails the fetch instead of parsing a truncated body. | | Field shapes at the parse boundary | 64-hex key; 100-1100 MHz; SF 5-12; bandwidth from the LoRa set (7.8 to 500 kHz); CR 5-8. Invalid rows are dropped. A body that is not a well-formed array fails the fetch. | | Keep stale cache on anomaly | Any failure keeps the previous snapshot; a failed presets fetch keeps the previous presets. `fetched_at` is returned so an outage is visible rather than hidden. | | Freshness | Only adverts within `maxAgeDays` (default 7) are kept at fetch time and re-checked at serve time. | | TLS pinning | Not done: Go's certificate validation plus the field validation above. | ## What changes - `cmd/server/rf_params_cache.go`: stream decoder, validation, cap reader, preset parser (presets with identical values are merged, titles joined), background refresh. In memory only, no DB writes, so the read-only server invariant holds. - `GET /api/rf-params`: `{enabled, fetched_at, source, presets, nodes}`; `nodes` holds only nodes this instance knows, keyed by lower-case pubkey. `/api/nodes` is unchanged, so its payload does not grow for every consumer and the 500-row paging is unaffected. - Node detail (side panel and full page): `LoRa` row with the four values, the preset name when all four match one, and the age of the map advert. Shown only when the feature is on and the server has fetched the map at least once. - No column in the Nodes table, on purpose: on our network such a column showed one preset title in 814 of 1,366 rows and nothing in 443. Showing only the nodes that differ from their region is the follow-up PR. - `config.example.json` documents the block with the two URLs to use. ## Cost - Measured on the real map payload (51.5 MB, 66,336 nodes): parse 0.52 s, 15 MB allocated in total, 26,815 nodes kept (advert within 7 days). Runs in a background goroutine every 6 h. - The endpoint walks the cached node list once with map lookups: O(nodes). - Frontend: one bulk fetch of `/api/rf-params` when a node detail opens (client TTL 5 min), not one per node. ## Tests - Go (`rf_params_cache_test.go`): parse with every rejection reason, malformed bodies (not an array, truncated, wrong type, empty), preset merge and invalid presets, cap reader (one byte over refused, exactly at the cap accepted), fail-soft against an `httptest` upstream (503, HTML body, presets-only failure), config defaults, route (disabled shape, only known nodes, entry aged past max age dropped at serve time). Full `cmd/server` suite passes locally (skipping `TestSaveGeoFilterPreservesFileMode`, a Windows-only failure on clean master). - Frontend unit: `tests/unit/test-issue-1268-rf-params.js` (7 assertions on the real file in `vm`), in `test-all.sh`; the full suite passes. - E2E: `tests/e2e/test-issue-1268-rf-params-e2e.js` (registered in `deploy.yml` and `scripts/non-unit-tests.json`). It checks the real disabled state on the fixture server (no detail row), then serves `/api/rf-params` through `page.route`: no table column, full detail page with preset, side panel with raw values, and the hint for a node that is not on the map. 4/4 locally, plus the existing node table and node detail E2E files. ## Validation On a staging instance with `rfParams` set to the map and preset URLs: - Log: `[rf-params] 26821 nodes within max age, 20 presets`, about 6 s after start. - `/api/rf-params` returned 1,115 of our nodes: 988 on 869.618/SF8/BW62.5/CR8, 111 on the same with CR5, 15 on 433.65 MHz, 1 on SF7. - In the browser the detail row shows in the side panel and on the full page, and the Nodes table keeps its original columns. ## Not done - Highlighting nodes whose settings differ from their region's majority (a badge in the Nodes list plus a filter) is the follow-up (separate PR). In that follow-up a CR-only difference stays informational until it is confirmed whether MeshCore radios use the explicit LoRa header (then CR travels in the header and a mismatch still decodes). - `maxAgeDays` is config-only; the customizer is a later step. - No data is written to the DB, so the RF parameters are gone after a restart until the next fetch (at most 60 s plus the fetch time). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
12 KiB
Go
399 lines
12 KiB
Go
package main
|
|
|
|
// LoRa RF parameters per node from the MeshCore map (issue #1268).
|
|
//
|
|
// Frequency, spreading factor, bandwidth and coding rate are per-device
|
|
// settings that never travel in a packet; the public map
|
|
// (map.meshcore.io/api/v1/nodes) is the only source. OPT-IN: an empty
|
|
// rfParams.nodesUrl starts no fetch and /api/rf-params serves an empty,
|
|
// disabled response. Same shape as the known-channels cache (#1323):
|
|
// in-memory atomic snapshot, background refresh, fail-soft to the last good
|
|
// snapshot, no DB and no disk.
|
|
//
|
|
// The map payload is ~50 MB of JSON for ~66k nodes, so it is decoded as a
|
|
// stream, capped, validated field by field, and only entries whose advert
|
|
// is within maxAgeDays are kept.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultRfRefresh = 6 * time.Hour
|
|
defaultRfMaxAge = 7 * 24 * time.Hour
|
|
// maxRfNodesBytes caps the decoded map payload (about 50 MB in
|
|
// October 2026), so a runaway or hostile upstream cannot exhaust memory.
|
|
maxRfNodesBytes = 128 << 20
|
|
maxRfPresetsBytes = 1 << 20
|
|
// rfStartupJitter spreads the first fetch of many instances that start
|
|
// together (a fleet redeploy) over a minute.
|
|
rfStartupJitter = time.Minute
|
|
// rfAdvertFutureSkew is how far ahead of now an advert may be dated.
|
|
// last_advert comes from the node's own clock: a few minutes fast is
|
|
// drift and stays, a wrong time zone (whole hours) or a wrong year
|
|
// (about 20% of the map in October 2026, some dated 2096) does not.
|
|
rfAdvertFutureSkew = 10 * time.Minute
|
|
// rfRetryBase and rfRetryMax bound the retry after a failed fetch while
|
|
// there is no data yet: 1, 2, 4, 8, 16 min, then every 30 min.
|
|
rfRetryBase = time.Minute
|
|
rfRetryMax = 30 * time.Minute
|
|
)
|
|
|
|
// RfParamsConfig is the rfParams block in config.json.
|
|
type RfParamsConfig struct {
|
|
NodesURL string `json:"nodesUrl"`
|
|
PresetsURL string `json:"presetsUrl"`
|
|
RefreshHours int `json:"refreshHours"`
|
|
MaxAgeDays int `json:"maxAgeDays"`
|
|
}
|
|
|
|
func (c *RfParamsConfig) enabled() bool { return c != nil && c.NodesURL != "" }
|
|
|
|
func (c *RfParamsConfig) refresh() time.Duration {
|
|
if c != nil && c.RefreshHours > 0 {
|
|
return time.Duration(c.RefreshHours) * time.Hour
|
|
}
|
|
return defaultRfRefresh
|
|
}
|
|
|
|
func (c *RfParamsConfig) maxAge() time.Duration {
|
|
if c != nil && c.MaxAgeDays > 0 {
|
|
return time.Duration(c.MaxAgeDays) * 24 * time.Hour
|
|
}
|
|
return defaultRfMaxAge
|
|
}
|
|
|
|
// RfParams is one node's radio settings as last advertised to the map.
|
|
type RfParams struct {
|
|
Freq float64 `json:"freq"`
|
|
SF int `json:"sf"`
|
|
BW float64 `json:"bw"`
|
|
CR int `json:"cr"`
|
|
LastAdvert string `json:"last_advert"`
|
|
}
|
|
|
|
// RfPreset is a named radio setting. Presets that share all four values
|
|
// are merged into one entry with their titles joined.
|
|
type RfPreset struct {
|
|
Title string `json:"title"`
|
|
Freq float64 `json:"freq"`
|
|
SF int `json:"sf"`
|
|
BW float64 `json:"bw"`
|
|
CR int `json:"cr"`
|
|
}
|
|
|
|
type rfParamsSnapshot struct {
|
|
FetchedAt time.Time
|
|
Source string
|
|
Nodes map[string]RfParams // lower-case pubkey
|
|
Presets []RfPreset
|
|
}
|
|
|
|
// RfParamsResponse is the body of GET /api/rf-params.
|
|
type RfParamsResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
FetchedAt *time.Time `json:"fetched_at"`
|
|
Source string `json:"source,omitempty"`
|
|
Presets []RfPreset `json:"presets"`
|
|
Nodes map[string]RfParams `json:"nodes"`
|
|
}
|
|
|
|
// loraBandwidthsKHz are the bandwidths SX126x/SX127x radios support.
|
|
var loraBandwidthsKHz = []float64{7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500}
|
|
|
|
var pubkeyHex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// rfAdvertFresh reports whether an advert dated at is no older than maxAge
|
|
// and not dated more than rfAdvertFutureSkew ahead of now.
|
|
func rfAdvertFresh(at, now time.Time, maxAge time.Duration) bool {
|
|
return !at.After(now.Add(rfAdvertFutureSkew)) && now.Sub(at) <= maxAge
|
|
}
|
|
|
|
func validLoRa(freq float64, sf int, bw float64, cr int) bool {
|
|
if freq < 100 || freq > 1100 || sf < 5 || sf > 12 || cr < 5 || cr > 8 {
|
|
return false
|
|
}
|
|
for _, b := range loraBandwidthsKHz {
|
|
if math.Abs(bw-b) < 0.01 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var errRfTooLarge = errors.New("rf params payload exceeds size cap")
|
|
|
|
// capReader hands out at most max bytes and fails with errRfTooLarge when
|
|
// asked for more while the source still has data, so an oversize payload
|
|
// is refused instead of being cut into something that might still parse.
|
|
type capReader struct {
|
|
r io.Reader
|
|
left int64
|
|
tripped bool
|
|
}
|
|
|
|
func newCapReader(r io.Reader, max int64) *capReader { return &capReader{r: r, left: max} }
|
|
|
|
func (c *capReader) Read(p []byte) (int, error) {
|
|
if c.tripped {
|
|
return 0, errRfTooLarge
|
|
}
|
|
if c.left <= 0 {
|
|
var one [1]byte
|
|
n, err := c.r.Read(one[:])
|
|
if n > 0 {
|
|
c.tripped = true
|
|
return 0, errRfTooLarge
|
|
}
|
|
return 0, err
|
|
}
|
|
if int64(len(p)) > c.left {
|
|
p = p[:c.left]
|
|
}
|
|
n, err := c.r.Read(p)
|
|
c.left -= int64(n)
|
|
return n, err
|
|
}
|
|
|
|
type mapNode struct {
|
|
PublicKey string `json:"public_key"`
|
|
LastAdvert string `json:"last_advert"`
|
|
Params *struct {
|
|
Freq float64 `json:"freq"`
|
|
BW float64 `json:"bw"`
|
|
CR int `json:"cr"`
|
|
SF int `json:"sf"`
|
|
} `json:"params"`
|
|
}
|
|
|
|
// parseRfNodes stream-decodes the map's node array. Entries that do not
|
|
// decode, lack params, have a malformed key, out-of-range values or an
|
|
// advert that is not fresh (rfAdvertFresh) are skipped; a payload that is
|
|
// not a well-formed array is an error.
|
|
func parseRfNodes(r io.Reader, now time.Time, maxAge time.Duration) (map[string]RfParams, error) {
|
|
dec := json.NewDecoder(r)
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode map nodes: %w", err)
|
|
}
|
|
if d, ok := tok.(json.Delim); !ok || d != '[' {
|
|
return nil, errors.New("decode map nodes: not a JSON array")
|
|
}
|
|
out := make(map[string]RfParams)
|
|
for dec.More() {
|
|
var raw json.RawMessage
|
|
if err := dec.Decode(&raw); err != nil {
|
|
return nil, fmt.Errorf("decode map node: %w", err)
|
|
}
|
|
var n mapNode
|
|
if err := json.Unmarshal(raw, &n); err != nil || n.Params == nil {
|
|
continue
|
|
}
|
|
pk := strings.ToLower(n.PublicKey)
|
|
if !pubkeyHex.MatchString(pk) {
|
|
continue
|
|
}
|
|
p := n.Params
|
|
if !validLoRa(p.Freq, p.SF, p.BW, p.CR) {
|
|
continue
|
|
}
|
|
at, err := time.Parse(time.RFC3339, n.LastAdvert)
|
|
if err != nil || !rfAdvertFresh(at, now, maxAge) {
|
|
continue
|
|
}
|
|
out[pk] = RfParams{Freq: p.Freq, SF: p.SF, BW: p.BW, CR: p.CR, LastAdvert: at.UTC().Format(time.RFC3339)}
|
|
}
|
|
if _, err := dec.Token(); err != nil {
|
|
return nil, fmt.Errorf("decode map nodes: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type presetsPayload struct {
|
|
Config struct {
|
|
SuggestedRadioSettings struct {
|
|
Entries []struct {
|
|
Title string `json:"title"`
|
|
Frequency string `json:"frequency"`
|
|
SpreadingFactor string `json:"spreading_factor"`
|
|
Bandwidth string `json:"bandwidth"`
|
|
CodingRate string `json:"coding_rate"`
|
|
} `json:"entries"`
|
|
} `json:"suggested_radio_settings"`
|
|
} `json:"config"`
|
|
}
|
|
|
|
// parseRfPresets reads the suggested radio settings from the MeshCore app
|
|
// config (api.meshcore.nz/api/v1/config). Invalid or untitled entries are
|
|
// dropped; entries with identical values are merged, first title first.
|
|
func parseRfPresets(r io.Reader) ([]RfPreset, error) {
|
|
var p presetsPayload
|
|
if err := json.NewDecoder(r).Decode(&p); err != nil {
|
|
return nil, fmt.Errorf("decode presets: %w", err)
|
|
}
|
|
out := []RfPreset{}
|
|
index := map[string]int{}
|
|
for _, e := range p.Config.SuggestedRadioSettings.Entries {
|
|
title := strings.TrimSpace(e.Title)
|
|
freq, err1 := strconv.ParseFloat(e.Frequency, 64)
|
|
sf, err2 := strconv.Atoi(e.SpreadingFactor)
|
|
bw, err3 := strconv.ParseFloat(e.Bandwidth, 64)
|
|
cr, err4 := strconv.Atoi(e.CodingRate)
|
|
if title == "" || err1 != nil || err2 != nil || err3 != nil || err4 != nil || !validLoRa(freq, sf, bw, cr) {
|
|
continue
|
|
}
|
|
key := fmt.Sprintf("%.3f/%d/%g/%d", freq, sf, bw, cr)
|
|
if i, ok := index[key]; ok {
|
|
out[i].Title += " / " + title
|
|
continue
|
|
}
|
|
index[key] = len(out)
|
|
out = append(out, RfPreset{Title: title, Freq: freq, SF: sf, BW: bw, CR: cr})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type rfParamsCache struct {
|
|
ptr atomic.Pointer[rfParamsSnapshot]
|
|
cfg RfParamsConfig
|
|
client *http.Client
|
|
now func() time.Time
|
|
// lastLogged is only touched by the run goroutine.
|
|
lastLogged time.Time
|
|
}
|
|
|
|
func newRfParamsCache(cfg *RfParamsConfig) *rfParamsCache {
|
|
return &rfParamsCache{
|
|
cfg: *cfg,
|
|
client: &http.Client{Timeout: 2 * time.Minute},
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
func (c *rfParamsCache) load() *rfParamsSnapshot { return c.ptr.Load() }
|
|
|
|
func (c *rfParamsCache) get(ctx context.Context, url string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "CoreScope-RfParams/1.0 (+https://github.com/Kpa-clawbot/CoreScope)")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("%s: upstream status %s", url, resp.Status)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *rfParamsCache) fetchNodes(ctx context.Context, now time.Time) (map[string]RfParams, error) {
|
|
resp, err := c.get(ctx, c.cfg.NodesURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return parseRfNodes(newCapReader(resp.Body, maxRfNodesBytes), now, c.cfg.maxAge())
|
|
}
|
|
|
|
func (c *rfParamsCache) fetchPresets(ctx context.Context) ([]RfPreset, error) {
|
|
resp, err := c.get(ctx, c.cfg.PresetsURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return parseRfPresets(newCapReader(resp.Body, maxRfPresetsBytes))
|
|
}
|
|
|
|
// fetchOnce refreshes the snapshot. A failed node fetch keeps the previous
|
|
// snapshot whole; a failed preset fetch keeps the previous presets.
|
|
func (c *rfParamsCache) fetchOnce(ctx context.Context) error {
|
|
now := c.now()
|
|
nodes, err := c.fetchNodes(ctx, now)
|
|
if err != nil {
|
|
return fmt.Errorf("nodes fetch failed, keeping previous data: %w", err)
|
|
}
|
|
prev := c.load()
|
|
var presets []RfPreset
|
|
if prev != nil {
|
|
presets = prev.Presets
|
|
}
|
|
var presetErr error
|
|
if c.cfg.PresetsURL != "" {
|
|
if p, err := c.fetchPresets(ctx); err == nil {
|
|
presets = p
|
|
} else {
|
|
presetErr = fmt.Errorf("presets fetch failed, keeping previous presets: %w", err)
|
|
}
|
|
}
|
|
c.ptr.Store(&rfParamsSnapshot{FetchedAt: now, Source: c.cfg.NodesURL, Nodes: nodes, Presets: presets})
|
|
return presetErr
|
|
}
|
|
|
|
// rfNextWait is the wait before the next fetch: the refresh interval once
|
|
// there is data, otherwise a backoff after the failures-th failed attempt in
|
|
// a row, from rfRetryBase doubling up to rfRetryMax and never past refresh.
|
|
func rfNextWait(hasData bool, failures int, refresh time.Duration) time.Duration {
|
|
if hasData || failures < 1 {
|
|
return refresh
|
|
}
|
|
wait := rfRetryMax
|
|
if failures <= 5 {
|
|
wait = rfRetryBase << (failures - 1)
|
|
}
|
|
if wait > refresh {
|
|
wait = refresh
|
|
}
|
|
return wait
|
|
}
|
|
|
|
// run starts the background refresh loop after a random startup delay.
|
|
// Until the first fetch succeeds it retries with a backoff (rfNextWait).
|
|
// Never blocks the caller.
|
|
func (c *rfParamsCache) run(ctx context.Context, logf func(string, ...interface{})) {
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(time.Duration(rand.Int63n(int64(rfStartupJitter)))):
|
|
}
|
|
failures := 0
|
|
for {
|
|
err := c.fetchOnce(ctx)
|
|
if err != nil {
|
|
logf("[rf-params] %v", err)
|
|
}
|
|
if c.load() == nil {
|
|
failures++
|
|
} else {
|
|
failures = 0
|
|
}
|
|
if s := c.load(); s != nil && !s.FetchedAt.Equal(c.lastLogged) {
|
|
c.lastLogged = s.FetchedAt
|
|
logf("[rf-params] %d nodes within max age, %d presets", len(s.Nodes), len(s.Presets))
|
|
}
|
|
t := time.NewTimer(rfNextWait(c.load() != nil, failures, c.cfg.refresh()))
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Stop()
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}()
|
|
}
|