mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 11:29:56 +00:00
Follow-up to #2072, which closed #2058 but left one gap named in its own description: the refresh ticker waits 2 minutes before its first run, and a query arriving in that window against a database with no statistics gets the bad plan. Deployed to staging to measure it rather than reason about it, with `sqlite_stat1` dropped first so the build path actually ran. That changed two of the numbers in #2072, both in the expensive direction. ## The gap is once per database, not once per restart `sqlite_stat1` is an ordinary table, so once `ANALYZE` has written it the statistics stay in the file. Checked four ways: - they survive closing the connection that wrote them - a `mode=ro` handle reads them back, which is how `cmd/server` opens the database - reopening the same path through a second `OpenStore` finds them and skips the rebuild (`TestPlannerStatsSurviveReopen_Issue2058`) - on staging they survived a full redeploy to a different build that has no refresh ticker at all, and that build still gets the good plan So the window opens once, on the first start after this lands, and never again for that database. ## The cost, corrected #2072 said 2.0s. Observed on staging, 9.4 GB, commit `4500cfa6`: ``` 13:51:26 [analyze] planner statistics refresh scheduled every 24h (analysis_limit=10000) 13:55:10 [analyze] planner statistics built in 3m43.874s (analysis_limit=10000, first run against this database) ``` **3m43.9s.** Every `ANALYZE` duration in #2072's ladder was timed warm, run after run; cold, on a freshly started container, the same statement takes nearly four minutes. That is the same warm/cold split #2058 work already established for the query itself, 56.7s against 0.80s, and I then repeated it for the `ANALYZE`. Every `2.0s` in the tree is now marked warm and points at the cold figure. It holds the single write connection throughout, so ingest stalls and buffers. Per minute in `observations`: | minute | rows | |---|---| | 13:49 | 220 | | 13:50 | 106 | | 13:51 | 0 | | 13:52 | 0 | | 13:53 | 0 | | 13:54 | 0 | | 13:55 | **1027** | | 13:56 | 154 | Nothing was dropped. The burst is about four minutes of traffic at the surrounding rate, and the only ingest-buffer line in the log is the startup one reporting `0 dropped`. The cost is a four-minute write stall, once, not data loss. ## This cost is not introduced here The ticker merged in #2072 pays the identical 3m43.9s two minutes later on any database with no statistics. **Live has none, so #2072 as merged will stall live ingest for about four minutes on its first run, with or without this branch.** This only moves it earlier, into the startup burst the ingest buffer is already sized for. Flagging it on #2072 as well. ## The change `Store.EnsurePlannerStats(analysisLimit)` checks before it builds: - database has statistics: one `sqlite_master` query. This is every restart after the first. - database has none: one `ANALYZE`, and a warning first. The warning is the part that earns its place operationally. Four minutes of stalled ingest with no explanation in the log looks exactly like a hang, so `EnsurePlannerStats` now says why the write path is about to pause, what it measured on 9.4 GB, and that it happens once per database. It stays silent on a restart, because a warning on every boot would be worse than none. It runs on the refresh goroutine, not the startup path, so no boot step waits for it. `hasPlannerStats` now gates a decision instead of only wording a log line, so its comment says what the swallowed error costs: a query failure reads as "no stats", which spends one unnecessary `ANALYZE` rather than skipping a necessary one. ## Verification on staging - before: plan drove from `idx_transmissions_payload_type`, no `sqlite_stat1` - after: 50 rows in `sqlite_stat1`, plan drives from `idx_tx_channel_hash` - dropping the table first flipped the plan back, so the causality holds in both directions ## Tests 13 in the file. New here: builds when absent, skips when present, disabled on a negative limit, survives close-and-reopen, warns before building, stays quiet when statistics exist. The reopen test is the guard on the whole design: if statistics ever stopped living in the file, `EnsurePlannerStats` would quietly run a four-minute `ANALYZE` on every restart and nothing else would notice. Run locally: 13/13 on the `Issue2058` tests, `go vet` clean, `gofmt` clean, and the rest of `cmd/ingestor` green apart from `TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails on `os.Symlink` with "A required privilege is not held by the client" on Windows without elevation, in a file this branch does not touch. ## Not done - No query rewrite, same as #2058 and #2072. - **No live deploy.** Live still has no statistics, so the four-minute stall is ahead of it whenever #2072 ships there. Worth picking the moment. - Staging has been returned to its own fork build; the statistics it built remain, so its next start exercises the skip path rather than the build path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013YAR8fdNTzqjtsggq4xCX6 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
642 lines
24 KiB
Go
642 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/dbconfig"
|
|
"github.com/meshcore-analyzer/geofilter"
|
|
"github.com/meshcore-analyzer/packetpath"
|
|
)
|
|
|
|
// 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"`
|
|
// ClientID is the MQTT ClientID; empty means corescope-<name>-<random>.
|
|
ClientID string `json:"clientId,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"`
|
|
AutoRegionKeys *AutoRegionKeysConfig `json:"autoRegionKeys,omitempty"`
|
|
Retention *RetentionConfig `json:"retention,omitempty"`
|
|
Metrics *MetricsConfig `json:"metrics,omitempty"`
|
|
Runtime *RuntimeConfig `json:"runtime,omitempty"`
|
|
ClientRxCoverage *ClientRxCoverageConfig `json:"clientRxCoverage,omitempty"`
|
|
ClientRxObservations *ClientRxObservationsConfig `json:"clientRxObservations,omitempty"`
|
|
ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"`
|
|
ClientRegions *ClientRegionsConfig `json:"clientRegions,omitempty"`
|
|
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
|
// PathTrust configures the minimum path-hash prefix length trusted as
|
|
// mapping/topology evidence (issue #1784).
|
|
PathTrust *PathTrustConfig `json:"pathTrust,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
|
|
|
|
// IATAWarnIntervalSec throttles the one-line-per-region warning emitted when
|
|
// ObserverIATAWhitelist rejects a region. 0 => defaultIATAWarnIntervalSec.
|
|
IATAWarnIntervalSec int `json:"iataWarnIntervalSec,omitempty"`
|
|
|
|
// iataWarnLast tracks when each dropped region was last logged.
|
|
iataWarnMu sync.Mutex
|
|
iataWarnLast map[string]time.Time
|
|
// iataWarnOverflowLast throttles the shared warning used once
|
|
// iataWarnLast has reached iataWarnMaxTracked.
|
|
iataWarnOverflowLast time.Time
|
|
|
|
// 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
|
|
|
|
// PathTrustConfig is an alias for the shared packetpath.TrustConfig type
|
|
// (issue #1784). See packetpath.TrustConfig for the full doc comment.
|
|
type PathTrustConfig = packetpath.TrustConfig
|
|
|
|
// 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
|
|
}
|
|
|
|
// ClientRxObservationsConfig controls the opt-in diagnostic RF observation
|
|
// table. Separate from clientRxCoverage: a deployment may well want coverage
|
|
// without the far higher-volume diagnostic stream.
|
|
type ClientRxObservationsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// ClientRxObservationsEnabled reports whether diagnostic client RF observations
|
|
// are recorded. Default false.
|
|
func (c *Config) ClientRxObservationsEnabled() bool {
|
|
return c.ClientRxObservations != nil && c.ClientRxObservations.Enabled
|
|
}
|
|
|
|
// ClientRfSamplesConfig controls the opt-in RF environment sample stream.
|
|
type ClientRfSamplesConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// ClientRfSamplesEnabled reports whether RF samples are recorded. Default false.
|
|
func (c *Config) ClientRfSamplesEnabled() bool {
|
|
return c.ClientRfSamples != nil && c.ClientRfSamples.Enabled
|
|
}
|
|
|
|
// ClientRegionsConfig controls the opt-in region-discovery answer stream.
|
|
type ClientRegionsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// ClientRegionsEnabled reports whether declared-region answers are recorded.
|
|
// Default false.
|
|
func (c *Config) ClientRegionsEnabled() bool {
|
|
return c.ClientRegions != nil && c.ClientRegions.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"`
|
|
// ObserverPurgeDays hard-deletes observers that observerDays already
|
|
// soft-deleted, once they are older than this and no observation,
|
|
// metric or dropped-packet row still references them; 0 disables.
|
|
// Only meaningful above observerDays and packetDays — below those the
|
|
// reference guards keep every candidate row anyway.
|
|
ObserverPurgeDays int `json:"observerPurgeDays"`
|
|
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"`
|
|
// ClientRxObsDays is the retention window (by rx_at) for the diagnostic
|
|
// client_rx_observations table; 0 disables. Shorter than clientRxDays —
|
|
// this table is diagnostic, not archival.
|
|
ClientRxObsDays int `json:"clientRxObsDays"`
|
|
// ClientRfDays is the retention window (by sampled_at) for the
|
|
// client_rf_samples table; 0 disables. Bounds the table the opt-in RF
|
|
// sample stream would otherwise grow without limit.
|
|
ClientRfDays int `json:"clientRfDays"`
|
|
// ClientRegionsDays is the retention window (by observed_at) for the
|
|
// node_declared_regions table; 0 disables. Bounds the table the opt-in
|
|
// region-discovery stream would otherwise grow without limit. Consumed by
|
|
// Task 6's pruner.
|
|
ClientRegionsDays int `json:"clientRegionsDays"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ObserverPurgeDaysOrZero returns the configured retention.observerPurgeDays
|
|
// or 0 (disabled) if not set.
|
|
func (c *Config) ObserverPurgeDaysOrZero() int {
|
|
if c.Retention != nil && c.Retention.ObserverPurgeDays > 0 {
|
|
return c.Retention.ObserverPurgeDays
|
|
}
|
|
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
|
|
}
|
|
|
|
// ClientRxObsDaysOrZero returns retention.clientRxObsDays or 0 (disabled).
|
|
func (c *Config) ClientRxObsDaysOrZero() int {
|
|
if c.Retention != nil && c.Retention.ClientRxObsDays > 0 {
|
|
return c.Retention.ClientRxObsDays
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ClientRfDaysOrZero returns retention.clientRfDays or 0 (disabled).
|
|
func (c *Config) ClientRfDaysOrZero() int {
|
|
if c.Retention != nil && c.Retention.ClientRfDays > 0 {
|
|
return c.Retention.ClientRfDays
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ClientRegionsDaysOrZero returns retention.clientRegionsDays or 0
|
|
// (disabled) if not set.
|
|
func (c *Config) ClientRegionsDaysOrZero() int {
|
|
if c.Retention != nil && c.Retention.ClientRegionsDays > 0 {
|
|
return c.Retention.ClientRegionsDays
|
|
}
|
|
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
|
|
}
|
|
|
|
// AnalysisLimit returns the per-index row cap for the planner stats refresh
|
|
// (#2058). A negative setting disables the refresh; zero means unset, matching
|
|
// IncrementalVacuumPages above.
|
|
//
|
|
// 10000 rather than the 400 SQLite's documentation offers: measured on the
|
|
// 9.4 GB staging database, 400 and 1000 leave the channel-query plan exactly as
|
|
// it was, 10000 produces the same plan as an unbounded ANALYZE, and it costs 2.0s
|
|
// against that ANALYZE's 242.9s, both timed warm. The full ladder, and the cold
|
|
// figure that matters at startup, are in Store.RefreshPlannerStats and
|
|
// Store.EnsurePlannerStats.
|
|
func (c *Config) AnalysisLimit() int {
|
|
if c.DB != nil && c.DB.AnalysisLimit != 0 {
|
|
return c.DB.AnalysisLimit
|
|
}
|
|
return 10000
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetPathTrust returns the effective path-trust config, applying
|
|
// DefaultMinHashBytesForMapping when unset (issue #1784).
|
|
func (c *Config) GetPathTrust() PathTrustConfig {
|
|
if c != nil && c.PathTrust != nil {
|
|
return *c.PathTrust
|
|
}
|
|
return PathTrustConfig{MinHashBytesForMapping: packetpath.DefaultMinHashBytesForMapping}
|
|
}
|
|
|
|
// 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))]
|
|
}
|
|
|
|
// iataWarnMaxTracked bounds the per-region throttle map. The key is
|
|
// publisher-controlled, so it cannot be allowed to grow with traffic. 512 is
|
|
// far above any real deployment's region count (the reference instance has 43
|
|
// observers across a handful of regions) and small enough that a hostile feed
|
|
// buys nothing: the map stops growing and the warning keeps coming.
|
|
const iataWarnMaxTracked = 512
|
|
|
|
// defaultIATAWarnIntervalSec is the re-log interval for whitelist drops (6h).
|
|
const defaultIATAWarnIntervalSec = 21600
|
|
|
|
// IATAWarnInterval returns how often a dropped region is re-logged.
|
|
func (c *Config) IATAWarnInterval() time.Duration {
|
|
if c == nil || c.IATAWarnIntervalSec <= 0 {
|
|
return defaultIATAWarnIntervalSec * time.Second
|
|
}
|
|
return time.Duration(c.IATAWarnIntervalSec) * time.Second
|
|
}
|
|
|
|
// ShouldWarnIATADrop reports whether a whitelist drop for this region should be
|
|
// logged now, recording the decision when it returns true.
|
|
//
|
|
// Logging every dropped message is not an option: a foreign feed runs to
|
|
// thousands of messages a day and would flood the container log. But dropping
|
|
// in silence is worse — an allow-list fails in the dangerous direction, where a
|
|
// legitimate but unlisted region simply vanishes with nothing to show for it.
|
|
// So: one line per region, re-logged at most every IATAWarnInterval for as long
|
|
// as that region keeps arriving. The re-log is deliberate — a strict log-once
|
|
// would emit a single edge event that any scrape window eventually rolls past,
|
|
// leaving an actively-dropping region looking identical to a healthy one.
|
|
func (c *Config) ShouldWarnIATADrop(iata string) bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
code := strings.ToUpper(strings.TrimSpace(iata))
|
|
if code == "" {
|
|
return false
|
|
}
|
|
interval := c.IATAWarnInterval()
|
|
now := time.Now()
|
|
|
|
c.iataWarnMu.Lock()
|
|
defer c.iataWarnMu.Unlock()
|
|
if last, ok := c.iataWarnLast[code]; ok && now.Sub(last) < interval {
|
|
return false
|
|
}
|
|
if c.iataWarnLast == nil {
|
|
c.iataWarnLast = make(map[string]time.Time)
|
|
}
|
|
// The key comes from a topic segment the publisher controls, so an
|
|
// unbounded map here is a remote memory sink: 200k distinct codes retained
|
|
// 200k entries and 15.1 MB of heap when this was measured. Nothing in the
|
|
// codebase constrains an IATA code's shape (it is uppercased and trimmed,
|
|
// never validated), so rejecting by shape would invent a rule operators
|
|
// have not agreed to. Bound the map instead.
|
|
//
|
|
// Past the cap the drop is still logged, throttled on one shared timestamp
|
|
// rather than a per-code one. That keeps the flood-protection the feature
|
|
// exists for while making the overflow itself visible: silently dropping
|
|
// the warning would reintroduce the bug this PR fixes, one level up.
|
|
if len(c.iataWarnLast) >= iataWarnMaxTracked {
|
|
// Sweep first. An entry older than the interval holds nothing back:
|
|
// the code would be re-logged on its next sighting anyway, so dropping
|
|
// it changes no behaviour and frees the slot. Without this the first
|
|
// iataWarnMaxTracked codes ever seen own the map forever, and a
|
|
// legitimate region that starts arriving later is stuck sharing the
|
|
// overflow throttle with whatever transient junk got there first.
|
|
// Only runs at the cap, so the normal path pays nothing.
|
|
for k, t := range c.iataWarnLast {
|
|
if now.Sub(t) >= interval {
|
|
delete(c.iataWarnLast, k)
|
|
}
|
|
}
|
|
}
|
|
if len(c.iataWarnLast) >= iataWarnMaxTracked {
|
|
if _, known := c.iataWarnLast[code]; !known {
|
|
if now.Sub(c.iataWarnOverflowLast) < interval {
|
|
return false
|
|
}
|
|
c.iataWarnOverflowLast = now
|
|
return true
|
|
}
|
|
}
|
|
c.iataWarnLast[code] = now
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// AutoRegionKeysConfig controls the opt-in derivation of region keys from the
|
|
// names repeaters declare over RF (node_declared_regions), on top of the
|
|
// explicit hashRegions list.
|
|
//
|
|
// TOP-LEVEL BLOCK, a sibling of hashRegions — not nested inside it. Config
|
|
// loading is plain json.Unmarshal with no DisallowUnknownFields, so a
|
|
// mis-nested key is silently ignored and derivation stays off with no error,
|
|
// the same trap clientRxObservations documents.
|
|
type AutoRegionKeysConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
MaxDerived int `json:"maxDerived"`
|
|
RefreshMinutes int `json:"refreshMinutes"`
|
|
}
|
|
|
|
// autoRegionKeysDefaultMaxDerived bounds the derived tier. Every added key
|
|
// raises the random ambiguity rate by 1/65536 per scoped packet (code1 is two
|
|
// bytes) and costs one more HMAC per transport-scoped packet — the match is
|
|
// O(keys) and cannot be indexed, because the code depends on the payload. 256
|
|
// on top of a typical explicit set puts the ambiguity rate near 0.5%, which is
|
|
// the ceiling this design accepts.
|
|
const autoRegionKeysDefaultMaxDerived = 256
|
|
|
|
// autoRegionKeysDefaultRefreshMinutes is how often the derived tier is rebuilt
|
|
// from the database. Declared-region answers arrive at human pace (a drive-by
|
|
// with a companion app, or a 24h observer report), so minutes-scale staleness
|
|
// is irrelevant and a tighter interval only burns queries.
|
|
const autoRegionKeysDefaultRefreshMinutes = 15
|
|
|
|
// AutoRegionKeysEnabled reports whether region keys may be derived from
|
|
// declared-region answers. Default false.
|
|
func (c *Config) AutoRegionKeysEnabled() bool {
|
|
return c.AutoRegionKeys != nil && c.AutoRegionKeys.Enabled
|
|
}
|
|
|
|
// AutoRegionKeysMaxDerived returns the derived-tier cap, falling back to the
|
|
// default for absent, zero, or negative values — a zero is indistinguishable
|
|
// from "key omitted" after json.Unmarshal, and neither should silently mean
|
|
// "derive nothing" when the operator has switched the feature on.
|
|
func (c *Config) AutoRegionKeysMaxDerived() int {
|
|
if c.AutoRegionKeys == nil || c.AutoRegionKeys.MaxDerived <= 0 {
|
|
return autoRegionKeysDefaultMaxDerived
|
|
}
|
|
return c.AutoRegionKeys.MaxDerived
|
|
}
|
|
|
|
// AutoRegionKeysRefreshMinutes returns the refresh interval in minutes,
|
|
// falling back to the default for absent, zero, or negative values — a zero
|
|
// here would otherwise panic time.NewTicker.
|
|
func (c *Config) AutoRegionKeysRefreshMinutes() int {
|
|
if c.AutoRegionKeys == nil || c.AutoRegionKeys.RefreshMinutes <= 0 {
|
|
return autoRegionKeysDefaultRefreshMinutes
|
|
}
|
|
return c.AutoRegionKeys.RefreshMinutes
|
|
}
|