mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 14:01:04 +00:00
## Summary Adds MQTT source status visibility per #1043 acceptance criteria: - **Ingestor:** per-source counter registry (`cmd/ingestor/source_status.go`) tracking `connected`, `lastConnectUnix`, `lastDisconnectUnix`, `lastPacketUnix`, `connectCount`, `disconnectCount`, `packetsTotal`, `packetsLast5m` (sliding 5-min window via per-second buckets keyed by unix second — no stale-leak), `lastError`. Wired at the existing OnConnect / ConnectionLost / DefaultPublish callsites alongside the liveness watchdog. Idempotent registration so counters survive reconnects. Snapshot emitted in the existing stats file under `source_statuses` (additive, `omitempty`). - **Backend:** new `GET /api/mqtt/status` handler reads the ingestor stats file and returns the per-source list. **Broker passwords are masked** via a regex over the `scheme://user:pass@host` form (covers mqtt/mqtts/tcp/ssl/ws/wss). Mask is also applied to `lastError` as defense-in-depth (broker libs occasionally quote the failing URL). OpenAPI completeness gate satisfied with a `routeDescriptions` entry. - **Frontend:** small self-contained panel (`public/mqtt-status-panel.js`) mounted above the Observers table. Auto-refreshes every 10s, color-codes each row (green = connected + recent packet, yellow = connected idle, red = disconnected), and tears down its timer on SPA route change. ## TDD - Red commit `f19a93b5` — stub `/api/mqtt/status` handler + assertion test that the broker password is `****`-redacted. Test fails on the assertion (handler passes the URL through verbatim). Compile-clean — assertion-fail, not build-fail. - Green commit `77042e41` — `maskBrokerURL` helper + table-driven unit tests across all schemes + handler rewires to mask both `Broker` and `LastError`. - Subsequent commits land the ingestor wiring and the frontend panel. ## Tests ``` $ cd cmd/server && go test -run 'TestMqttStatus|TestMaskBrokerURL' -v ./... PASS: TestMqttStatus_MasksBrokerPassword PASS: TestMqttStatus_EmptyWhenNoStatsFile PASS: TestMaskBrokerURL_Patterns (10 subtests) $ cd cmd/ingestor && go test -run 'TestSourceStatus|TestSnapshotSourceStatuses' -v ./... PASS: TestSourceStatus_BasicLifecycle PASS: TestSourceStatus_Disconnect PASS: TestSnapshotSourceStatuses_ReturnsAll $ node test-mqtt-status-panel.js 7 passed, 0 failed ``` Full `go test ./...` clean in both `cmd/server` and `cmd/ingestor`. ## Preflight overrides - `cross-stack`: justified — issue #1043 is intrinsically full-stack (ingestor stats → server endpoint → observers panel). Per-stack split would land an unreachable endpoint or a fetch with no backend. - `check-xss-sinks` (public/mqtt-status-panel.js:55): justified — the flagged `innerHTML=` is a fully-static literal (empty-state placeholder, no payload data interpolated). All payload-bearing `innerHTML=` sites in this file run through `escapeHTML` (defined in the same file); the test `renderPanel never echoes a plaintext password (defense-in-depth)` exercises the rendered HTML against payload strings. ## Acceptance criteria - [x] `/api/mqtt/status` returns per-source connection state — `cmd/server/mqtt_status.go` - [x] UI panel shows all configured sources with live status — `public/mqtt-status-panel.js` - [x] Connection state updates on reconnect/disconnect events — `MarkConnect` / `MarkDisconnect` wired in `cmd/ingestor/main.go` - [x] Broker URLs don't expose passwords in the API response — `maskBrokerURL` + 13 test cases - [x] Works with 1-N sources — registry is keyed per-source, snapshot iterates the map **Partial fix for #1043** — per-packet `mqtt_source` attribution (the issue's "Follow-up" section) is **deferred** per the `mc-bot-triaged:v1` triage and the autofix comment ("Per-packet attribution deferred to follow-up issue"). That work requires a new observation-row column and DB schema migration, both explicitly out of scope for this PR. Refs #1043 --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
3651 lines
113 KiB
Go
3651 lines
113 KiB
Go
package main
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"regexp"
|
||
"runtime"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/gorilla/mux"
|
||
"github.com/meshcore-analyzer/packetpath"
|
||
"github.com/meshcore-analyzer/prunequeue"
|
||
)
|
||
|
||
// Server holds shared state for route handlers.
|
||
type Server struct {
|
||
db *DB
|
||
cfg *Config
|
||
hub *Hub
|
||
store *PacketStore // in-memory packet store (nil = fallback to DB)
|
||
configDir string // directory containing config.json (for write-back via PUT /api/config/geo-filter)
|
||
startedAt time.Time
|
||
perfStats *PerfStats
|
||
version string
|
||
commit string
|
||
buildTime string
|
||
|
||
// Cached runtime.MemStats to avoid stop-the-world pauses on every health check
|
||
memStatsMu sync.Mutex
|
||
memStatsCache runtime.MemStats
|
||
memStatsCachedAt time.Time
|
||
|
||
// Cached /api/stats response — recomputed at most once every 10s
|
||
statsMu sync.Mutex
|
||
statsCache *StatsResponse
|
||
statsCachedAt time.Time
|
||
|
||
// Guards s.cfg.GeoFilter — read by ingest/handler goroutines, written by PUT handler
|
||
cfgMu sync.RWMutex
|
||
|
||
// Serializes concurrent PUT /api/config/geo-filter disk writes so requests
|
||
// can't race on the .tmp file or interleave disk/memory updates.
|
||
saveMu sync.Mutex
|
||
|
||
// Neighbor affinity graph (lazy-built, cached with TTL)
|
||
neighborMu sync.Mutex
|
||
neighborGraph *NeighborGraph
|
||
|
||
// Cached /api/scope-stats response — per-window, recomputed at most once every 30s
|
||
scopeStatsMu sync.Mutex
|
||
scopeStatsCache map[string]*ScopeStatsResponse
|
||
scopeStatsCachedAt map[string]time.Time
|
||
|
||
// Router reference for OpenAPI spec generation
|
||
router *mux.Router
|
||
|
||
// Cached default (no-filter) /api/observers response, served from an
|
||
// atomic-pointer snapshot. Refilled via singleflight on TTL boundary
|
||
// to prevent thundering-herd SQL stampedes. Issue #1481 P0-3 +
|
||
// #1483 follow-up (singleflight + monotonic time).
|
||
observersCacheV2 observersCacheField
|
||
|
||
// Cached default-shape /api/analytics/neighbor-graph response,
|
||
// recomputed every 5 min in a background goroutine. Issue #1481 P0-1.
|
||
neighborGraphCache neighborGraphCacheField
|
||
|
||
// Counter for rebuild-panic events on the neighbor-graph cache
|
||
// background recomputer. Surfaced via /api/stats. #1483 follow-up.
|
||
neighborGraphCacheRebuildFailures uint64
|
||
|
||
// Test injection: when non-nil, replaces the real
|
||
// computeNeighborGraphResponse pipeline so tests can assert the
|
||
// bypass branch was exercised without standing up a full DB/store.
|
||
// Production code MUST leave this nil. #1483 follow-up.
|
||
computeNeighborGraphResponseFn func(minCount int, minScore float64, region, role string) NeighborGraphResponse
|
||
|
||
// Per-server state for /api/nodes/{pk}/reach: TTL cache + singleflight
|
||
// + cached neighbor_edges degree snapshot. Lives on *Server (not as
|
||
// package globals) so multiple instances don't share observable
|
||
// state. Initialised lazily on first use; see node_reach.go.
|
||
reach reachState
|
||
|
||
// Known-channels catalogue cache (issue #1323). Nil until configured;
|
||
// when nil the /api/known-channels endpoint returns an empty snapshot.
|
||
knownChannels *knownChannelsCache
|
||
}
|
||
|
||
// PerfStats tracks request performance.
|
||
type PerfStats struct {
|
||
mu sync.Mutex
|
||
Requests int64
|
||
TotalMs float64
|
||
Endpoints map[string]*EndpointPerf
|
||
SlowQueries []SlowQuery
|
||
StartedAt time.Time
|
||
}
|
||
|
||
type EndpointPerf struct {
|
||
Count int
|
||
TotalMs float64
|
||
MaxMs float64
|
||
Recent []float64
|
||
}
|
||
|
||
func NewPerfStats() *PerfStats {
|
||
return &PerfStats{
|
||
Endpoints: make(map[string]*EndpointPerf),
|
||
SlowQueries: make([]SlowQuery, 0),
|
||
StartedAt: time.Now(),
|
||
}
|
||
}
|
||
|
||
func NewServer(db *DB, cfg *Config, hub *Hub) *Server {
|
||
if cfg != nil {
|
||
cfg.applyListLimitsDefaults()
|
||
}
|
||
return &Server{
|
||
db: db,
|
||
cfg: cfg,
|
||
hub: hub,
|
||
startedAt: time.Now(),
|
||
perfStats: NewPerfStats(),
|
||
version: resolveVersion(),
|
||
commit: resolveCommit(),
|
||
buildTime: resolveBuildTime(),
|
||
}
|
||
}
|
||
|
||
const memStatsTTL = 5 * time.Second
|
||
|
||
// getMemStats returns cached runtime.MemStats, refreshing at most every 5 seconds.
|
||
// runtime.ReadMemStats() stops the world; caching prevents per-request GC pauses.
|
||
func (s *Server) getMemStats() runtime.MemStats {
|
||
s.memStatsMu.Lock()
|
||
defer s.memStatsMu.Unlock()
|
||
if time.Since(s.memStatsCachedAt) > memStatsTTL {
|
||
runtime.ReadMemStats(&s.memStatsCache)
|
||
s.memStatsCachedAt = time.Now()
|
||
}
|
||
return s.memStatsCache
|
||
}
|
||
|
||
// getGeoFilter returns a pointer to the current geo_filter config under read lock.
|
||
// Callers MUST NOT mutate the returned struct.
|
||
// isPubkeyHidden returns true if the node with the given pubkey has a name
|
||
// matching an operator-configured hidden prefix (#1181). Mirrors the
|
||
// IsBlacklisted check used at the top of per-pubkey handlers: per-pubkey
|
||
// endpoints should 404 on hidden nodes so callers learn nothing about
|
||
// whether the row exists. Returns false on DB error / missing row (the
|
||
// downstream handler's own 404 covers those).
|
||
func (s *Server) isPubkeyHidden(pubkey string) bool {
|
||
if s == nil || s.cfg == nil || len(s.cfg.HiddenNamePrefixes) == 0 {
|
||
return false
|
||
}
|
||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||
if err != nil || node == nil {
|
||
return false
|
||
}
|
||
name, _ := node["name"].(string)
|
||
return s.cfg.IsNameHidden(name)
|
||
}
|
||
|
||
func (s *Server) getGeoFilter() *GeoFilterConfig {
|
||
s.cfgMu.RLock()
|
||
defer s.cfgMu.RUnlock()
|
||
return s.cfg.GeoFilter
|
||
}
|
||
|
||
// setGeoFilter atomically swaps the geo_filter config; used by PUT /api/config/geo-filter.
|
||
func (s *Server) setGeoFilter(gf *GeoFilterConfig) {
|
||
s.cfgMu.Lock()
|
||
defer s.cfgMu.Unlock()
|
||
s.cfg.GeoFilter = gf
|
||
}
|
||
|
||
// RegisterRoutes sets up all HTTP routes on the given router.
|
||
func (s *Server) RegisterRoutes(r *mux.Router) {
|
||
s.router = r
|
||
// CORS middleware (must run before route handlers)
|
||
r.Use(s.corsMiddleware)
|
||
|
||
// Performance instrumentation middleware
|
||
r.Use(s.perfMiddleware)
|
||
|
||
// /api/* responses must not be cached by upstream CDNs (#1551).
|
||
// Cloudflare/nginx/Varnish default zone policies cache
|
||
// application/json for 15min–4h when no Cache-Control is set,
|
||
// causing operators behind a CDN to serve stale observers/packets/
|
||
// stats data. Scope: /api/ prefix only — static assets stay
|
||
// CDN-cacheable (their headers are set by spaHandler).
|
||
r.Use(noStoreAPIMiddleware)
|
||
|
||
// Detect CDN-fronted deployments and warn the operator ONCE if
|
||
// any CDN-typical header (CF-Ray, CF-Connecting-IP, etc.) is
|
||
// observed. See #1561: no-store alone isn't sufficient on
|
||
// Cloudflare zones with Cache Rules / Page Rules that ignore
|
||
// origin Cache-Control. Operator must add a Bypass Cache rule
|
||
// for /api/* — see docs/deployment-behind-cdn.md.
|
||
r.Use(cdnDetectionMiddleware)
|
||
|
||
// Config endpoints
|
||
r.HandleFunc("/api/config/cache", s.handleConfigCache).Methods("GET")
|
||
r.HandleFunc("/api/config/client", s.handleConfigClient).Methods("GET")
|
||
r.HandleFunc("/api/config/regions", s.handleConfigRegions).Methods("GET")
|
||
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
|
||
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
|
||
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
|
||
r.HandleFunc("/api/config/areas", s.handleConfigAreas).Methods("GET")
|
||
r.HandleFunc("/api/config/areas/polygons", s.handleConfigAreasPolygons).Methods("GET")
|
||
r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")
|
||
|
||
// Readiness endpoint (gated on background init completion)
|
||
r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET")
|
||
|
||
// System endpoints
|
||
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
|
||
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
||
r.HandleFunc("/api/scope-stats", s.handleScopeStats).Methods("GET")
|
||
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
|
||
r.HandleFunc("/api/perf/io", s.handlePerfIO).Methods("GET")
|
||
r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET")
|
||
r.HandleFunc("/api/perf/write-sources", s.handlePerfWriteSources).Methods("GET")
|
||
r.HandleFunc("/api/mqtt/status", s.handleMqttStatus).Methods("GET")
|
||
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
|
||
// /api/admin/prune removed in #1283 — pruning is owned by the
|
||
// ingestor process (scheduled tickers + startup pass). Operators
|
||
// who want an ad-hoc prune can restart the ingestor.
|
||
//
|
||
// /api/admin/prune-geo-filter (#669 M4 / PR #738): server enqueues a
|
||
// marker file; the ingestor (which holds the writable DB handle)
|
||
// runs the DELETE. /status reports completion.
|
||
r.Handle("/api/admin/prune-geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST")
|
||
r.Handle("/api/admin/prune-geo-filter/status", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilterStatus))).Methods("GET")
|
||
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
|
||
r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET")
|
||
r.Handle("/api/backup", s.requireAPIKey(http.HandlerFunc(s.handleBackup))).Methods("GET")
|
||
|
||
// Packet endpoints
|
||
r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST")
|
||
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")
|
||
|
||
// Decode endpoint
|
||
r.HandleFunc("/api/decode", s.handleDecode).Methods("POST")
|
||
|
||
// Node endpoints — fixed routes BEFORE parameterized
|
||
r.HandleFunc("/api/nodes/search", s.handleNodeSearch).Methods("GET")
|
||
r.HandleFunc("/api/nodes/bulk-health", s.handleBulkHealth).Methods("GET")
|
||
r.HandleFunc("/api/nodes/network-status", s.handleNetworkStatus).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}/battery", s.handleNodeBattery).Methods("GET")
|
||
r.HandleFunc("/api/nodes/clock-skew", s.handleFleetClockSkew).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}/clock-skew", s.handleNodeClockSkew).Methods("GET")
|
||
r.HandleFunc("/api/observers/clock-skew", s.handleObserverClockSkew).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
|
||
// Keep specific sub-routes (…/reach) registered BEFORE the catch-all
|
||
// /api/nodes/{pubkey} — mux matches in registration order, so reordering
|
||
// this below the catch-all would shadow it and break the route.
|
||
r.HandleFunc("/api/nodes/{pubkey}/reach", s.handleNodeReach).Methods("GET")
|
||
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
|
||
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
|
||
|
||
// Analytics endpoints
|
||
r.HandleFunc("/api/analytics/roles", s.handleAnalyticsRoles).Methods("GET")
|
||
r.HandleFunc("/api/analytics/rf", s.handleAnalyticsRF).Methods("GET")
|
||
r.HandleFunc("/api/analytics/topology", s.handleAnalyticsTopology).Methods("GET")
|
||
r.HandleFunc("/api/analytics/channels", s.handleAnalyticsChannels).Methods("GET")
|
||
r.HandleFunc("/api/analytics/distance", s.handleAnalyticsDistance).Methods("GET")
|
||
r.HandleFunc("/api/analytics/hash-sizes", s.handleAnalyticsHashSizes).Methods("GET")
|
||
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
|
||
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
|
||
r.HandleFunc("/api/analytics/subpaths-bulk", s.handleAnalyticsSubpathsBulk).Methods("GET")
|
||
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
|
||
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
|
||
r.HandleFunc("/api/analytics/relay-airtime-share", s.handleAnalyticsRelayAirtimeShare).Methods("GET")
|
||
|
||
// Other endpoints
|
||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||
r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET")
|
||
r.HandleFunc("/api/channels", s.handleChannels).Methods("GET")
|
||
r.HandleFunc("/api/known-channels", s.handleKnownChannels).Methods("GET")
|
||
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
|
||
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
|
||
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
|
||
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
|
||
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
|
||
r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET")
|
||
r.HandleFunc("/api/paths/inspect", s.handlePathInspect).Methods("POST")
|
||
r.HandleFunc("/api/iata-coords", s.handleIATACoords).Methods("GET")
|
||
r.HandleFunc("/api/audio-lab/buckets", s.handleAudioLabBuckets).Methods("GET")
|
||
|
||
// OpenAPI spec + Swagger UI
|
||
r.HandleFunc("/api/spec", s.handleOpenAPISpec).Methods("GET")
|
||
r.HandleFunc("/api/docs", s.handleSwaggerUI).Methods("GET")
|
||
}
|
||
|
||
// noStoreAPIMiddleware sets Cache-Control: no-store on every response
|
||
// whose request path starts with /api/. See #1551 — CDNs cache JSON
|
||
// for minutes when no Cache-Control is present, which causes observers/
|
||
// packets/stats responses to go stale through Cloudflare/nginx/Varnish.
|
||
//
|
||
// Why no-store (not private,max-age=0):
|
||
// - no-store is the most conservative directive; forbids ANY cache
|
||
// (CDN, browser, intermediary) from storing the response.
|
||
// - private,max-age=0 still permits short browser caches and some
|
||
// intermediaries; we don't gain anything from it because the data
|
||
// is fresh-on-every-request semantics by contract (WS pushes diff
|
||
// against REST GETs).
|
||
//
|
||
// Scope: /api/ prefix only. Static assets (HTML/JS/CSS) keep their
|
||
// existing headers from spaHandler and remain CDN-cacheable on
|
||
// hashed URLs.
|
||
func noStoreAPIMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func (s *Server) perfMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
start := time.Now()
|
||
next.ServeHTTP(w, r)
|
||
ms := float64(time.Since(start).Microseconds()) / 1000.0
|
||
|
||
// Normalize key outside lock (no shared state needed)
|
||
key := r.URL.Path
|
||
if route := mux.CurrentRoute(r); route != nil {
|
||
if tmpl, err := route.GetPathTemplate(); err == nil {
|
||
key = muxBraceParam.ReplaceAllString(tmpl, ":$1")
|
||
}
|
||
}
|
||
if key == r.URL.Path {
|
||
key = perfHexFallback.ReplaceAllString(key, ":id")
|
||
}
|
||
|
||
s.perfStats.mu.Lock()
|
||
s.perfStats.Requests++
|
||
s.perfStats.TotalMs += ms
|
||
|
||
if _, ok := s.perfStats.Endpoints[key]; !ok {
|
||
s.perfStats.Endpoints[key] = &EndpointPerf{Recent: make([]float64, 0, 100)}
|
||
}
|
||
ep := s.perfStats.Endpoints[key]
|
||
ep.Count++
|
||
ep.TotalMs += ms
|
||
if ms > ep.MaxMs {
|
||
ep.MaxMs = ms
|
||
}
|
||
ep.Recent = append(ep.Recent, ms)
|
||
if len(ep.Recent) > 100 {
|
||
ep.Recent = ep.Recent[1:]
|
||
}
|
||
if ms > 100 {
|
||
slow := SlowQuery{
|
||
Path: r.URL.Path,
|
||
Ms: round(ms, 1),
|
||
Time: time.Now().UTC().Format(time.RFC3339),
|
||
Status: 200,
|
||
}
|
||
s.perfStats.SlowQueries = append(s.perfStats.SlowQueries, slow)
|
||
if len(s.perfStats.SlowQueries) > 50 {
|
||
s.perfStats.SlowQueries = s.perfStats.SlowQueries[1:]
|
||
}
|
||
}
|
||
s.perfStats.mu.Unlock()
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
key := r.Header.Get("X-API-Key")
|
||
if !constantTimeEqual(key, s.cfg.APIKey) {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||
return
|
||
}
|
||
if IsWeakAPIKey(key) {
|
||
writeError(w, http.StatusForbidden, "forbidden")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// --- Config Handlers ---
|
||
|
||
func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
|
||
ct := s.cfg.CacheTTL
|
||
if ct == nil {
|
||
ct = map[string]interface{}{}
|
||
}
|
||
writeJSON(w, ct) // CacheTTL is user-provided opaque config — map is appropriate
|
||
}
|
||
|
||
func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||
// #1508 — surface the operator-side customizer knobs. The frontend
|
||
// (public/customize-v2.js _renderTabs) reads disabledTabs to hide
|
||
// admin-only tabs from end users. Always return a non-nil slice so
|
||
// the JSON shape is `[]` (not `null`) and the client can call
|
||
// `.includes()` without an undefined guard.
|
||
disabledTabs := []string{}
|
||
if s.cfg.Customizer != nil && s.cfg.Customizer.DisabledTabs != nil {
|
||
disabledTabs = s.cfg.Customizer.DisabledTabs
|
||
}
|
||
writeJSON(w, ClientConfigResponse{
|
||
Roles: s.cfg.Roles,
|
||
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
|
||
Map: s.cfg.Map,
|
||
SnrThresholds: s.cfg.SnrThresholds,
|
||
DistThresholds: s.cfg.DistThresholds,
|
||
MaxHopDist: s.cfg.MaxHopDist,
|
||
Limits: s.cfg.Limits,
|
||
PerfSlowMs: s.cfg.PerfSlowMs,
|
||
WsReconnectMs: s.cfg.WsReconnectMs,
|
||
CacheInvalidateMs: s.cfg.CacheInvalidMs,
|
||
ExternalUrls: s.cfg.ExternalUrls,
|
||
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
|
||
LiveMapMaxNodes: s.cfg.LiveMapMaxNodes(),
|
||
Timestamps: s.cfg.GetTimestampConfig(),
|
||
DebugAffinity: s.cfg.DebugAffinity,
|
||
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
|
||
Tiles: s.cfg.Tiles,
|
||
Customizer: CustomizerClientConfig{DisabledTabs: disabledTabs},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) {
|
||
type areaListEntry struct {
|
||
Key string `json:"key"`
|
||
Label string `json:"label"`
|
||
}
|
||
result := make([]areaListEntry, 0, len(s.cfg.Areas))
|
||
for k, v := range s.cfg.Areas {
|
||
if v.Label == "" {
|
||
continue // skip comment/invalid entries (e.g. "_comment" keys in config)
|
||
}
|
||
result = append(result, areaListEntry{Key: k, Label: v.Label})
|
||
}
|
||
writeJSON(w, result)
|
||
}
|
||
|
||
func (s *Server) handleConfigAreasPolygons(w http.ResponseWriter, r *http.Request) {
|
||
type areaDebugEntry struct {
|
||
Key string `json:"key"`
|
||
Label string `json:"label"`
|
||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||
LatMin *float64 `json:"latMin,omitempty"`
|
||
LatMax *float64 `json:"latMax,omitempty"`
|
||
LonMin *float64 `json:"lonMin,omitempty"`
|
||
LonMax *float64 `json:"lonMax,omitempty"`
|
||
}
|
||
result := make([]areaDebugEntry, 0, len(s.cfg.Areas))
|
||
for k, v := range s.cfg.Areas {
|
||
result = append(result, areaDebugEntry{
|
||
Key: k,
|
||
Label: v.Label,
|
||
Polygon: v.Polygon,
|
||
LatMin: v.LatMin,
|
||
LatMax: v.LatMax,
|
||
LonMin: v.LonMin,
|
||
LonMax: v.LonMax,
|
||
})
|
||
}
|
||
writeJSON(w, result)
|
||
}
|
||
|
||
func (s *Server) handleConfigRegions(w http.ResponseWriter, r *http.Request) {
|
||
regions := make(map[string]string)
|
||
for k, v := range s.cfg.Regions {
|
||
regions[k] = v
|
||
}
|
||
codes, _ := s.db.GetDistinctIATAs()
|
||
for _, c := range codes {
|
||
if _, ok := regions[c]; !ok {
|
||
regions[c] = c
|
||
}
|
||
}
|
||
writeJSON(w, regions)
|
||
}
|
||
|
||
func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
|
||
theme := LoadTheme(".")
|
||
|
||
branding := mergeMap(map[string]interface{}{
|
||
"siteName": "CoreScope",
|
||
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
|
||
}, s.cfg.Branding, theme.Branding)
|
||
|
||
themeColors := mergeMap(map[string]interface{}{
|
||
"accent": "#4a9eff",
|
||
"accentHover": "#6db3ff",
|
||
"navBg": "#0f0f23",
|
||
"navBg2": "#1a1a2e",
|
||
"navText": "#ffffff",
|
||
"navTextMuted": "#cbd5e1",
|
||
"background": "#f4f5f7",
|
||
"text": "#1a1a2e",
|
||
"textMuted": "#5b6370",
|
||
"border": "#e2e5ea",
|
||
"surface1": "#ffffff",
|
||
"surface2": "#ffffff",
|
||
"surface3": "#ffffff",
|
||
"sectionBg": "#eef2ff",
|
||
"cardBg": "#ffffff",
|
||
"contentBg": "#f4f5f7",
|
||
"detailBg": "#ffffff",
|
||
"inputBg": "#ffffff",
|
||
"rowStripe": "#f9fafb",
|
||
"rowHover": "#eef2ff",
|
||
"selectedBg": "#dbeafe",
|
||
"statusGreen": "#22c55e",
|
||
"statusYellow": "#eab308",
|
||
"statusRed": "#ef4444",
|
||
}, s.cfg.Theme, theme.Theme)
|
||
|
||
nodeColors := mergeMap(map[string]interface{}{
|
||
"repeater": "#dc2626",
|
||
"companion": "#2563eb",
|
||
"room": "#16a34a",
|
||
"sensor": "#d97706",
|
||
"observer": "#8b5cf6",
|
||
}, s.cfg.NodeColors, theme.NodeColors)
|
||
|
||
themeDark := mergeMap(map[string]interface{}{
|
||
"accent": "#4a9eff",
|
||
"accentHover": "#6db3ff",
|
||
"navBg": "#0f0f23",
|
||
"navBg2": "#1a1a2e",
|
||
"navText": "#ffffff",
|
||
"navTextMuted": "#cbd5e1",
|
||
"background": "#0f0f23",
|
||
"text": "#e2e8f0",
|
||
"textMuted": "#a8b8cc",
|
||
"border": "#334155",
|
||
"surface1": "#1a1a2e",
|
||
"surface2": "#232340",
|
||
"cardBg": "#1a1a2e",
|
||
"contentBg": "#0f0f23",
|
||
"detailBg": "#232340",
|
||
"inputBg": "#1e1e34",
|
||
"rowStripe": "#1e1e34",
|
||
"rowHover": "#2d2d50",
|
||
"selectedBg": "#1e3a5f",
|
||
"statusGreen": "#22c55e",
|
||
"statusYellow": "#eab308",
|
||
"statusRed": "#ef4444",
|
||
"surface3": "#2d2d50",
|
||
"sectionBg": "#1e1e34",
|
||
}, s.cfg.ThemeDark, theme.ThemeDark)
|
||
typeColors := mergeMap(map[string]interface{}{
|
||
"ADVERT": "#22c55e",
|
||
"GRP_TXT": "#3b82f6",
|
||
"TXT_MSG": "#f59e0b",
|
||
"ACK": "#6b7280",
|
||
"REQUEST": "#a855f7",
|
||
"RESPONSE": "#06b6d4",
|
||
"TRACE": "#ec4899",
|
||
"PATH": "#14b8a6",
|
||
"ANON_REQ": "#f43f5e",
|
||
"UNKNOWN": "#6b7280",
|
||
}, s.cfg.TypeColors, theme.TypeColors)
|
||
|
||
defaultHome := map[string]interface{}{
|
||
"heroTitle": "CoreScope",
|
||
"heroSubtitle": "Real-time MeshCore LoRa mesh network analyzer",
|
||
"steps": []interface{}{
|
||
// #1648 M5: defaults use 'ph:<name>' Phosphor sprite tokens.
|
||
// The frontend render path (home.js _renderHomeGlyph, customize-v2.js
|
||
// renderConfigGlyph) ALSO accepts legacy emoji strings, so any
|
||
// operator config.json that still stores raw emoji values continues
|
||
// to render as-is — this changes ONLY the built-in default set.
|
||
map[string]interface{}{"emoji": "ph:bluetooth", "title": "Connect via Bluetooth", "description": "Flash **BLE companion** firmware from [MeshCore Flasher](https://flasher.meshcore.io/).\n- Screenless devices: default PIN `123456`\n- Screen devices: random PIN shown on display\n- If pairing fails: forget device, reboot, re-pair"},
|
||
map[string]interface{}{"emoji": "ph:radio", "title": "Set the right frequency preset", "description": "**US Recommended:**\n`910.525 MHz · BW 62.5 kHz · SF 7 · CR 5`\nSelect **\"US Recommended\"** in the app or flasher."},
|
||
map[string]interface{}{"emoji": "ph:broadcast", "title": "Advertise yourself", "description": "Tap the signal icon → **Flood** to broadcast your node to the mesh. Companions only advert when you trigger it manually."},
|
||
map[string]interface{}{"emoji": "ph:repeat", "title": "Check \"Heard N repeats\"", "description": "- **\"Sent\"** = transmitted, no confirmation\n- **\"Heard 0 repeats\"** = no repeater picked it up\n- **\"Heard 1+ repeats\"** = you're on the mesh!"},
|
||
},
|
||
"footerLinks": []interface{}{
|
||
map[string]interface{}{"label": "ph:package Packets", "url": "#/packets"},
|
||
map[string]interface{}{"label": "ph:map-trifold Network Map", "url": "#/map"},
|
||
},
|
||
}
|
||
home := mergeMap(defaultHome, s.cfg.Home, theme.Home)
|
||
|
||
// #1488 — marker stroke overlay. Defaults mirror the :root values in
|
||
// public/style.css so a fresh visitor with no config + no override
|
||
// still gets the same painted outline as the static CSS fallback.
|
||
markerStroke := mergeMap(map[string]interface{}{
|
||
"color": "rgba(255,255,255,0.85)",
|
||
"width": 1,
|
||
"opacity": 1,
|
||
}, s.cfg.MarkerStroke, theme.MarkerStroke)
|
||
|
||
writeJSON(w, ThemeResponse{
|
||
Branding: branding,
|
||
Theme: themeColors,
|
||
ThemeDark: themeDark,
|
||
NodeColors: nodeColors,
|
||
TypeColors: typeColors,
|
||
Home: home,
|
||
MarkerStroke: markerStroke,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) {
|
||
center := s.cfg.MapDefaults.Center
|
||
if len(center) == 0 {
|
||
center = []float64{37.45, -122.0}
|
||
}
|
||
zoom := s.cfg.MapDefaults.Zoom
|
||
if zoom == 0 {
|
||
zoom = 9
|
||
}
|
||
writeJSON(w, MapConfigResponse{Center: center, Zoom: zoom})
|
||
}
|
||
|
||
func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
|
||
gf := s.getGeoFilter()
|
||
// writeEnabled signals to clients (e.g. the customizer UI) whether a
|
||
// strong API key is configured. Low-sensitivity by design.
|
||
writeEnabled := s.cfg != nil && s.cfg.APIKey != "" && !IsWeakAPIKey(s.cfg.APIKey)
|
||
if gf == nil || len(gf.Polygon) == 0 {
|
||
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0, "writeEnabled": writeEnabled})
|
||
return
|
||
}
|
||
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm, "writeEnabled": writeEnabled})
|
||
}
|
||
|
||
// --- System Handlers ---
|
||
|
||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
m := s.getMemStats()
|
||
uptime := time.Since(s.startedAt).Seconds()
|
||
|
||
wsClients := 0
|
||
if s.hub != nil {
|
||
wsClients = s.hub.ClientCount()
|
||
}
|
||
|
||
// Real packet store stats
|
||
pktCount := 0
|
||
var pktEstMB float64
|
||
var pktTrackedMB float64
|
||
if s.store != nil {
|
||
ps := s.store.GetPerfStoreStatsTyped()
|
||
pktCount = ps.TotalLoaded
|
||
pktEstMB = ps.EstimatedMB
|
||
pktTrackedMB = ps.TrackedMB
|
||
}
|
||
|
||
// Real cache stats
|
||
cs := CacheStats{}
|
||
if s.store != nil {
|
||
cs = s.store.GetCacheStatsTyped()
|
||
}
|
||
|
||
// Build eventLoop-equivalent from GC pause data (matches Node.js shape)
|
||
var gcPauses []float64
|
||
n := int(m.NumGC)
|
||
if n > 256 {
|
||
n = 256
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
idx := (int(m.NumGC) - n + i) % 256
|
||
gcPauses = append(gcPauses, float64(m.PauseNs[idx])/1e6)
|
||
}
|
||
sortedPauses := sortedCopy(gcPauses)
|
||
var lastPauseMs float64
|
||
if m.NumGC > 0 {
|
||
lastPauseMs = float64(m.PauseNs[(m.NumGC+255)%256]) / 1e6
|
||
}
|
||
|
||
// Build slow queries list (copy under lock)
|
||
s.perfStats.mu.Lock()
|
||
recentSlow := make([]SlowQuery, 0)
|
||
sliceEnd := s.perfStats.SlowQueries
|
||
if len(sliceEnd) > 5 {
|
||
sliceEnd = sliceEnd[len(sliceEnd)-5:]
|
||
}
|
||
for _, sq := range sliceEnd {
|
||
recentSlow = append(recentSlow, sq)
|
||
}
|
||
perfRequests := s.perfStats.Requests
|
||
perfTotalMs := s.perfStats.TotalMs
|
||
perfSlowCount := len(s.perfStats.SlowQueries)
|
||
s.perfStats.mu.Unlock()
|
||
|
||
writeJSON(w, HealthResponse{
|
||
Status: "ok",
|
||
Engine: "go",
|
||
Version: s.version,
|
||
Commit: s.commit,
|
||
BuildTime: s.buildTime,
|
||
Uptime: int(uptime),
|
||
UptimeHuman: fmt.Sprintf("%dh %dm", int(uptime)/3600, (int(uptime)%3600)/60),
|
||
Memory: MemoryStats{
|
||
RSS: int(m.Sys / 1024 / 1024),
|
||
HeapUsed: int(m.HeapAlloc / 1024 / 1024),
|
||
HeapTotal: int(m.HeapSys / 1024 / 1024),
|
||
External: 0,
|
||
},
|
||
EventLoop: EventLoopStats{
|
||
CurrentLagMs: round(lastPauseMs, 1),
|
||
MaxLagMs: round(percentile(sortedPauses, 1.0), 1),
|
||
P50Ms: round(percentile(sortedPauses, 0.5), 1),
|
||
P95Ms: round(percentile(sortedPauses, 0.95), 1),
|
||
P99Ms: round(percentile(sortedPauses, 0.99), 1),
|
||
},
|
||
Cache: cs,
|
||
WebSocket: WebSocketStatsResp{Clients: wsClients},
|
||
PacketStore: HealthPacketStoreStats{
|
||
Packets: pktCount,
|
||
EstimatedMB: pktEstMB,
|
||
TrackedMB: pktTrackedMB,
|
||
},
|
||
Perf: HealthPerfStats{
|
||
TotalRequests: int(perfRequests),
|
||
AvgMs: safeAvg(perfTotalMs, float64(perfRequests)),
|
||
SlowQueries: perfSlowCount,
|
||
RecentSlow: recentSlow,
|
||
},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||
const statsTTL = 10 * time.Second
|
||
|
||
s.statsMu.Lock()
|
||
if s.statsCache != nil && time.Since(s.statsCachedAt) < statsTTL {
|
||
cached := s.statsCache
|
||
s.statsMu.Unlock()
|
||
writeJSON(w, cached)
|
||
return
|
||
}
|
||
s.statsMu.Unlock()
|
||
|
||
var stats *Stats
|
||
var err error
|
||
if s.store != nil {
|
||
stats, err = s.store.GetStoreStats()
|
||
} else {
|
||
stats, err = s.db.GetStats()
|
||
}
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
counts := s.db.GetRoleCounts()
|
||
|
||
// Memory accounting (#832). storeDataMB is the in-store packet byte
|
||
// estimate (the old "trackedMB"); processRSSMB / goHeapInuseMB / goSysMB
|
||
// give ops the breakdown needed to reason about real RSS. All values
|
||
// share a single 1s-cached snapshot to amortize ReadMemStats cost.
|
||
var storeDataMB float64
|
||
if s.store != nil {
|
||
storeDataMB = s.store.trackedMemoryMB()
|
||
}
|
||
mem := s.getMemorySnapshot(storeDataMB)
|
||
|
||
resp := &StatsResponse{
|
||
TotalPackets: stats.TotalPackets,
|
||
TotalTransmissions: &stats.TotalTransmissions,
|
||
TotalObservations: stats.TotalObservations,
|
||
TotalNodes: stats.TotalNodes,
|
||
TotalNodesAllTime: stats.TotalNodesAllTime,
|
||
TotalObservers: stats.TotalObservers,
|
||
PacketsLastHour: stats.PacketsLastHour,
|
||
PacketsLast24h: stats.PacketsLast24h,
|
||
Engine: "go",
|
||
Version: s.version,
|
||
Commit: s.commit,
|
||
BuildTime: s.buildTime,
|
||
Counts: RoleCounts{
|
||
Repeaters: counts["repeaters"],
|
||
Rooms: counts["rooms"],
|
||
Companions: counts["companions"],
|
||
Sensors: counts["sensors"],
|
||
},
|
||
SignatureDrops: s.db.GetSignatureDropCount(),
|
||
HashMigrationComplete: s.store != nil && s.store.hashMigrationComplete.Load(),
|
||
|
||
TrackedMB: mem.StoreDataMB, // deprecated alias
|
||
StoreDataMB: mem.StoreDataMB,
|
||
ProcessRSSMB: mem.ProcessRSSMB,
|
||
GoHeapInuseMB: mem.GoHeapInuseMB,
|
||
GoSysMB: mem.GoSysMB,
|
||
|
||
NeighborGraphCacheRebuildFailures: atomic.LoadUint64(&s.neighborGraphCacheRebuildFailures),
|
||
}
|
||
|
||
s.statsMu.Lock()
|
||
s.statsCache = resp
|
||
s.statsCachedAt = time.Now()
|
||
s.statsMu.Unlock()
|
||
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
func (s *Server) handlePerf(w http.ResponseWriter, r *http.Request) {
|
||
// Copy perfStats under lock to avoid data races
|
||
s.perfStats.mu.Lock()
|
||
type epSnapshot struct {
|
||
path string
|
||
count int
|
||
totalMs float64
|
||
maxMs float64
|
||
recent []float64
|
||
}
|
||
epSnapshots := make([]epSnapshot, 0, len(s.perfStats.Endpoints))
|
||
for path, ep := range s.perfStats.Endpoints {
|
||
recentCopy := make([]float64, len(ep.Recent))
|
||
copy(recentCopy, ep.Recent)
|
||
epSnapshots = append(epSnapshots, epSnapshot{path, ep.Count, ep.TotalMs, ep.MaxMs, recentCopy})
|
||
}
|
||
uptimeSec := int(time.Since(s.perfStats.StartedAt).Seconds())
|
||
totalRequests := s.perfStats.Requests
|
||
totalMs := s.perfStats.TotalMs
|
||
slowQueries := make([]SlowQuery, 0)
|
||
sliceEnd := s.perfStats.SlowQueries
|
||
if len(sliceEnd) > 20 {
|
||
sliceEnd = sliceEnd[len(sliceEnd)-20:]
|
||
}
|
||
for _, sq := range sliceEnd {
|
||
slowQueries = append(slowQueries, sq)
|
||
}
|
||
s.perfStats.mu.Unlock()
|
||
|
||
// Process snapshots outside lock
|
||
type epEntry struct {
|
||
path string
|
||
data *EndpointStatsResp
|
||
}
|
||
var entries []epEntry
|
||
for _, snap := range epSnapshots {
|
||
sorted := sortedCopy(snap.recent)
|
||
d := &EndpointStatsResp{
|
||
Count: snap.count,
|
||
AvgMs: safeAvg(snap.totalMs, float64(snap.count)),
|
||
P50Ms: round(percentile(sorted, 0.5), 1),
|
||
P95Ms: round(percentile(sorted, 0.95), 1),
|
||
MaxMs: round(snap.maxMs, 1),
|
||
}
|
||
entries = append(entries, epEntry{snap.path, d})
|
||
}
|
||
// Sort by total time spent (count * avg) descending, matching Node.js
|
||
sort.Slice(entries, func(i, j int) bool {
|
||
ti := float64(entries[i].data.Count) * entries[i].data.AvgMs
|
||
tj := float64(entries[j].data.Count) * entries[j].data.AvgMs
|
||
return ti > tj
|
||
})
|
||
summary := make(map[string]*EndpointStatsResp)
|
||
for _, e := range entries {
|
||
summary[e.path] = e.data
|
||
}
|
||
|
||
// Cache stats from packet store
|
||
var perfCS PerfCacheStats
|
||
if s.store != nil {
|
||
cs := s.store.GetCacheStatsTyped()
|
||
perfCS = PerfCacheStats{
|
||
Size: cs.Entries,
|
||
Hits: cs.Hits,
|
||
Misses: cs.Misses,
|
||
StaleHits: cs.StaleHits,
|
||
Recomputes: cs.Recomputes,
|
||
HitRate: cs.HitRate,
|
||
}
|
||
}
|
||
|
||
// Packet store stats
|
||
var pktStoreStats *PerfPacketStoreStats
|
||
if s.store != nil {
|
||
ps := s.store.GetPerfStoreStatsTyped()
|
||
pktStoreStats = &ps
|
||
}
|
||
|
||
// SQLite stats
|
||
var sqliteStats *SqliteStats
|
||
if s.db != nil {
|
||
ss := s.db.GetDBSizeStatsTyped()
|
||
sqliteStats = &ss
|
||
}
|
||
|
||
writeJSON(w, PerfResponse{
|
||
Uptime: uptimeSec,
|
||
TotalRequests: totalRequests,
|
||
AvgMs: safeAvg(totalMs, float64(totalRequests)),
|
||
Endpoints: summary,
|
||
SlowQueries: slowQueries,
|
||
Cache: perfCS,
|
||
PacketStore: pktStoreStats,
|
||
Sqlite: sqliteStats,
|
||
GoRuntime: func() *GoRuntimeStats {
|
||
ms := s.getMemStats()
|
||
return &GoRuntimeStats{
|
||
Goroutines: runtime.NumGoroutine(),
|
||
NumGC: ms.NumGC,
|
||
PauseTotalMs: float64(ms.PauseTotalNs) / 1e6,
|
||
LastPauseMs: float64(ms.PauseNs[(ms.NumGC+255)%256]) / 1e6,
|
||
HeapAllocMB: float64(ms.HeapAlloc) / 1024 / 1024,
|
||
HeapSysMB: float64(ms.HeapSys) / 1024 / 1024,
|
||
HeapInuseMB: float64(ms.HeapInuse) / 1024 / 1024,
|
||
HeapIdleMB: float64(ms.HeapIdle) / 1024 / 1024,
|
||
NumCPU: runtime.NumCPU(),
|
||
}
|
||
}(),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) {
|
||
s.perfStats.mu.Lock()
|
||
s.perfStats.Requests = 0
|
||
s.perfStats.TotalMs = 0
|
||
s.perfStats.Endpoints = make(map[string]*EndpointPerf)
|
||
s.perfStats.SlowQueries = make([]SlowQuery, 0)
|
||
s.perfStats.StartedAt = time.Now()
|
||
s.perfStats.mu.Unlock()
|
||
writeJSON(w, OkResp{Ok: true})
|
||
}
|
||
|
||
// --- Packet Handlers ---
|
||
|
||
func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
|
||
// Multi-node filter: comma-separated pubkeys (Node.js parity)
|
||
if nodesParam := r.URL.Query().Get("nodes"); nodesParam != "" {
|
||
pubkeys := strings.Split(nodesParam, ",")
|
||
var cleaned []string
|
||
for _, pk := range pubkeys {
|
||
pk = strings.TrimSpace(pk)
|
||
if pk != "" {
|
||
cleaned = append(cleaned, pk)
|
||
}
|
||
}
|
||
order := "DESC"
|
||
if r.URL.Query().Get("order") == "asc" {
|
||
order = "ASC"
|
||
}
|
||
lim := queryLimit(r, 50, s.cfg.ListLimits.PacketsMax)
|
||
var result *PacketResult
|
||
var err error
|
||
if s.store != nil {
|
||
result = s.store.QueryMultiNodePackets(cleaned,
|
||
lim, queryInt(r, "offset", 0),
|
||
order, r.URL.Query().Get("since"), r.URL.Query().Get("until"))
|
||
} else {
|
||
result, err = s.db.QueryMultiNodePackets(cleaned,
|
||
lim, queryInt(r, "offset", 0),
|
||
order, r.URL.Query().Get("since"), r.URL.Query().Get("until"))
|
||
}
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
result.Limit = lim
|
||
result.Offset = queryInt(r, "offset", 0)
|
||
writeJSON(w, result)
|
||
return
|
||
}
|
||
|
||
q := PacketQuery{
|
||
Limit: queryLimit(r, 50, s.cfg.ListLimits.PacketsMax),
|
||
Offset: queryInt(r, "offset", 0),
|
||
Observer: r.URL.Query().Get("observer"),
|
||
Hash: r.URL.Query().Get("hash"),
|
||
Since: r.URL.Query().Get("since"),
|
||
Until: r.URL.Query().Get("until"),
|
||
Region: r.URL.Query().Get("region"),
|
||
Node: r.URL.Query().Get("node"),
|
||
Channel: r.URL.Query().Get("channel"),
|
||
Area: r.URL.Query().Get("area"),
|
||
Order: "DESC",
|
||
ExpandObservations: r.URL.Query().Get("expand") == "observations",
|
||
}
|
||
if r.URL.Query().Get("order") == "asc" {
|
||
q.Order = "ASC"
|
||
}
|
||
if v := r.URL.Query().Get("type"); v != "" {
|
||
t, _ := strconv.Atoi(v)
|
||
q.Type = &t
|
||
}
|
||
if v := r.URL.Query().Get("route"); v != "" {
|
||
t, _ := strconv.Atoi(v)
|
||
q.Route = &t
|
||
}
|
||
|
||
if r.URL.Query().Get("groupByHash") == "true" {
|
||
var result *PacketResult
|
||
var err error
|
||
if s.store != nil {
|
||
result = s.store.QueryGroupedPackets(q)
|
||
} else {
|
||
result, err = s.db.QueryGroupedPackets(q)
|
||
}
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
result.Limit = q.Limit
|
||
result.Offset = q.Offset
|
||
writeJSON(w, result)
|
||
return
|
||
}
|
||
|
||
var result *PacketResult
|
||
var err error
|
||
if s.store != nil {
|
||
result = s.store.QueryPackets(q)
|
||
} else {
|
||
result, err = s.db.QueryPackets(q)
|
||
}
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
|
||
result.Limit = q.Limit
|
||
result.Offset = q.Offset
|
||
writeJSON(w, result)
|
||
}
|
||
|
||
func (s *Server) handlePacketTimestamps(w http.ResponseWriter, r *http.Request) {
|
||
since := r.URL.Query().Get("since")
|
||
if since == "" {
|
||
writeError(w, 400, "since required")
|
||
return
|
||
}
|
||
if s.store != nil {
|
||
writeJSON(w, s.store.GetTimestamps(since))
|
||
return
|
||
}
|
||
writeJSON(w, []string{})
|
||
}
|
||
|
||
var hashPattern = regexp.MustCompile(`^[0-9a-f]{16}$`)
|
||
|
||
// muxBraceParam matches {param} in gorilla/mux route templates for normalization.
|
||
var muxBraceParam = regexp.MustCompile(`\{([^}]+)\}`)
|
||
|
||
// perfHexFallback matches hex IDs for perf path normalization fallback.
|
||
var perfHexFallback = regexp.MustCompile(`[0-9a-f]{8,}`)
|
||
|
||
// handleBatchObservations returns observations for multiple hashes in a single request.
|
||
// POST /api/packets/observations with JSON body: {"hashes": ["abc123", "def456", ...]}
|
||
// Response: {"results": {"abc123": [...observations...], "def456": [...], ...}}
|
||
// Limited to 200 hashes per request to prevent abuse.
|
||
func (s *Server) handleBatchObservations(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Hashes []string `json:"hashes"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, 400, "invalid JSON body")
|
||
return
|
||
}
|
||
const maxHashes = 200
|
||
if len(body.Hashes) > maxHashes {
|
||
writeError(w, 400, fmt.Sprintf("too many hashes (max %d)", maxHashes))
|
||
return
|
||
}
|
||
if len(body.Hashes) == 0 {
|
||
writeJSON(w, map[string]interface{}{"results": map[string]interface{}{}})
|
||
return
|
||
}
|
||
|
||
results := make(map[string][]ObservationResp, len(body.Hashes))
|
||
if s.store != nil {
|
||
for _, hash := range body.Hashes {
|
||
obs := s.store.GetObservationsForHash(hash)
|
||
results[hash] = mapSliceToObservations(obs)
|
||
}
|
||
}
|
||
writeJSON(w, map[string]interface{}{"results": results})
|
||
}
|
||
|
||
func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) {
|
||
param := mux.Vars(r)["id"]
|
||
var packet map[string]interface{}
|
||
fromDB := false
|
||
|
||
isHash := hashPattern.MatchString(strings.ToLower(param))
|
||
if s.store != nil {
|
||
if isHash {
|
||
packet = s.store.GetPacketByHash(param)
|
||
}
|
||
if packet == nil {
|
||
id, parseErr := strconv.Atoi(param)
|
||
if parseErr == nil {
|
||
packet = s.store.GetTransmissionByID(id)
|
||
if packet == nil {
|
||
packet = s.store.GetPacketByID(id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// DB fallback: in-memory PacketStore prunes old entries, but the SQLite
|
||
// DB retains them and is the source for /api/nodes recentAdverts. Without
|
||
// this fallback, links from node-detail pages 404 once the packet ages out.
|
||
if packet == nil && s.db != nil {
|
||
if isHash {
|
||
if dbPkt, err := s.db.GetPacketByHash(param); err == nil && dbPkt != nil {
|
||
packet = dbPkt
|
||
fromDB = true
|
||
}
|
||
}
|
||
if packet == nil {
|
||
if id, parseErr := strconv.Atoi(param); parseErr == nil {
|
||
if dbPkt, err := s.db.GetTransmissionByID(id); err == nil && dbPkt != nil {
|
||
packet = dbPkt
|
||
fromDB = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if packet == nil {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
|
||
hash, _ := packet["hash"].(string)
|
||
var observations []map[string]interface{}
|
||
if s.store != nil {
|
||
observations = s.store.GetObservationsForHash(hash)
|
||
}
|
||
if len(observations) == 0 && fromDB && s.db != nil && hash != "" {
|
||
observations = s.db.GetObservationsForHash(hash)
|
||
}
|
||
observationCount := len(observations)
|
||
if observationCount == 0 {
|
||
observationCount = 1
|
||
}
|
||
|
||
var pathHops []interface{}
|
||
if pj, ok := packet["path_json"]; ok && pj != nil {
|
||
if pjStr, ok := pj.(string); ok && pjStr != "" {
|
||
json.Unmarshal([]byte(pjStr), &pathHops)
|
||
}
|
||
}
|
||
if pathHops == nil {
|
||
pathHops = []interface{}{}
|
||
}
|
||
|
||
writeJSON(w, PacketDetailResponse{
|
||
Packet: packet,
|
||
Path: pathHops,
|
||
ObservationCount: observationCount,
|
||
Observations: mapSliceToObservations(observations),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Hex string `json:"hex"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, 400, "invalid JSON body")
|
||
return
|
||
}
|
||
hexStr := strings.TrimSpace(body.Hex)
|
||
if hexStr == "" {
|
||
writeError(w, 400, "hex is required")
|
||
return
|
||
}
|
||
decoded, err := DecodePacket(hexStr, true)
|
||
if err != nil {
|
||
writeError(w, 400, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, DecodeResponse{
|
||
Decoded: map[string]interface{}{
|
||
"header": decoded.Header,
|
||
"path": decoded.Path,
|
||
"payload": decoded.Payload,
|
||
},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Hex string `json:"hex"`
|
||
Observer *string `json:"observer"`
|
||
Snr *float64 `json:"snr"`
|
||
Rssi *float64 `json:"rssi"`
|
||
Region *string `json:"region"`
|
||
Hash *string `json:"hash"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, 400, "invalid JSON body")
|
||
return
|
||
}
|
||
hexStr := strings.TrimSpace(body.Hex)
|
||
if hexStr == "" {
|
||
writeError(w, 400, "hex is required")
|
||
return
|
||
}
|
||
decoded, err := DecodePacket(hexStr, false)
|
||
if err != nil {
|
||
writeError(w, 400, err.Error())
|
||
return
|
||
}
|
||
|
||
contentHash := ComputeContentHash(hexStr)
|
||
pathJSON := "[]"
|
||
// For TRACE packets, path_json must be the payload-decoded route hops
|
||
// (decoded.Path.Hops), NOT the raw_hex header bytes which are SNR values.
|
||
// For all other packet types, derive path from raw_hex (#886).
|
||
if !packetpath.PathBytesAreHops(byte(decoded.Header.PayloadType)) {
|
||
if len(decoded.Path.Hops) > 0 {
|
||
if pj, e := json.Marshal(decoded.Path.Hops); e == nil {
|
||
pathJSON = string(pj)
|
||
}
|
||
}
|
||
} else if hops, err := packetpath.DecodePathFromRawHex(hexStr); err == nil && len(hops) > 0 {
|
||
if pj, e := json.Marshal(hops); e == nil {
|
||
pathJSON = string(pj)
|
||
}
|
||
}
|
||
decodedJSON := PayloadJSON(&decoded.Payload)
|
||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||
|
||
var obsID, obsName interface{}
|
||
if body.Observer != nil {
|
||
obsID = *body.Observer
|
||
}
|
||
var snr, rssi interface{}
|
||
if body.Snr != nil {
|
||
snr = *body.Snr
|
||
}
|
||
if body.Rssi != nil {
|
||
rssi = *body.Rssi
|
||
}
|
||
|
||
res, dbErr := s.db.conn.Exec(`INSERT INTO transmissions (hash, raw_hex, route_type, payload_type, payload_version, path_json, decoded_json, first_seen)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
contentHash, strings.ToUpper(hexStr), decoded.Header.RouteType, decoded.Header.PayloadType,
|
||
decoded.Header.PayloadVersion, pathJSON, decodedJSON, now)
|
||
|
||
var insertedID int64
|
||
if dbErr == nil {
|
||
insertedID, _ = res.LastInsertId()
|
||
s.db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, timestamp)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
insertedID, obsID, obsName, snr, rssi, now)
|
||
}
|
||
|
||
writeJSON(w, PacketIngestResponse{
|
||
ID: insertedID,
|
||
Decoded: map[string]interface{}{
|
||
"header": decoded.Header,
|
||
"path": decoded.Path,
|
||
"payload": decoded.Payload,
|
||
},
|
||
})
|
||
}
|
||
|
||
// --- Node Handlers ---
|
||
|
||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
nodes, total, counts, err := s.db.GetNodes(
|
||
queryLimit(r, 50, s.cfg.ListLimits.NodesMax),
|
||
queryInt(r, "offset", 0),
|
||
q.Get("role"), q.Get("search"), q.Get("before"),
|
||
q.Get("lastHeard"), q.Get("sortBy"), q.Get("region"),
|
||
)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
if s.store != nil {
|
||
hashInfo := s.store.GetNodeHashSizeInfo()
|
||
relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours
|
||
// #1257: bulk-compute relay info + usefulness scores ONCE per
|
||
// request (cached 15s) instead of calling the per-node helpers
|
||
// inside the loop. The per-node calls each grabbed their own
|
||
// RLock and walked byPathHop[pk] + byPayloadType, blowing
|
||
// /api/nodes up to 30+s on busy networks.
|
||
var relayMap map[string]RepeaterRelayInfo
|
||
var usefulMap map[string]float64
|
||
needsRelay := false
|
||
for _, node := range nodes {
|
||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||
needsRelay = true
|
||
break
|
||
}
|
||
}
|
||
if needsRelay {
|
||
relayMap = s.store.GetRepeaterRelayInfoMap(relayWindow)
|
||
usefulMap = s.store.GetRepeaterUsefulnessScoreMap()
|
||
}
|
||
// Bridge axis (#672 axis 2 of 4). Snapshot is an atomic load
|
||
// — safe to call regardless of needsRelay, and we want the
|
||
// score on repeater rows specifically.
|
||
bridgeMap := s.store.GetBridgeScoreMap()
|
||
for _, node := range nodes {
|
||
if pk, ok := node["public_key"].(string); ok {
|
||
EnrichNodeWithHashSize(node, hashInfo[pk])
|
||
mbEntry, _ := s.store.GetMultibyteCapFor(pk)
|
||
EnrichNodeWithMultiByte(node, mbEntry)
|
||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||
info, _ := lookupRelayInfo(relayMap, pk)
|
||
info.WindowHours = relayWindow
|
||
if info.LastRelayed != "" {
|
||
node["last_relayed"] = info.LastRelayed
|
||
}
|
||
node["relay_active"] = info.RelayActive
|
||
node["relay_count_1h"] = info.RelayCount1h
|
||
node["relay_count_24h"] = info.RelayCount24h
|
||
// usefulness_score retained for API compat; new
|
||
// consumers should read traffic_share_score
|
||
// (issue #1456). When the #672 composite ships
|
||
// usefulness_score will become the composite
|
||
// and traffic_share_score will keep the
|
||
// per-axis value.
|
||
us := lookupUsefulnessScore(usefulMap, pk)
|
||
node["usefulness_score"] = us
|
||
node["traffic_share_score"] = us
|
||
node["bridge_score"] = lookupUsefulnessScore(bridgeMap, pk)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if s.cfg.GeoFilter != nil {
|
||
filtered := nodes[:0]
|
||
for _, node := range nodes {
|
||
// Foreign-flagged nodes (#730) are kept even when their GPS lies
|
||
// outside the geofilter polygon — that's the whole point of the
|
||
// flag: operators need to SEE bridged/leaked nodes, not have them
|
||
// filtered away. The ingestor sets foreign_advert=1 when its
|
||
// configured geo_filter rejected the advert; the server must
|
||
// surface those.
|
||
if isForeign, _ := node["foreign"].(bool); isForeign {
|
||
filtered = append(filtered, node)
|
||
continue
|
||
}
|
||
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
total = len(filtered)
|
||
nodes = filtered
|
||
}
|
||
// Filter blacklisted nodes
|
||
if len(s.cfg.NodeBlacklist) > 0 {
|
||
filtered := nodes[:0]
|
||
for _, node := range nodes {
|
||
if pk, ok := node["public_key"].(string); !ok || !s.cfg.IsBlacklisted(pk) {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
total = len(filtered)
|
||
nodes = filtered
|
||
}
|
||
// Filter nodes whose name starts with a hidden prefix (#1181). DB rows
|
||
// are preserved — this only drops them from the API surface so observer
|
||
// history (paths, hops, distances) remains intact for analytics.
|
||
if len(s.cfg.HiddenNamePrefixes) > 0 {
|
||
filtered := nodes[:0]
|
||
for _, node := range nodes {
|
||
name, _ := node["name"].(string)
|
||
if !s.cfg.IsNameHidden(name) {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
total = len(filtered)
|
||
nodes = filtered
|
||
}
|
||
// Filter by area
|
||
if area := q.Get("area"); area != "" {
|
||
var areaNodes map[string]bool
|
||
if s.store != nil {
|
||
areaNodes = s.store.resolveAreaNodes(area)
|
||
} else if s.cfg != nil && s.cfg.Areas != nil {
|
||
if entry, ok := s.cfg.Areas[area]; ok {
|
||
pks, err := s.db.GetNodePubkeysInArea(entry)
|
||
if err == nil {
|
||
areaNodes = make(map[string]bool, len(pks))
|
||
for _, pk := range pks {
|
||
areaNodes[pk] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if areaNodes != nil {
|
||
filtered := make([]map[string]interface{}, 0, len(nodes))
|
||
for _, n := range nodes {
|
||
pk, _ := n["public_key"].(string)
|
||
if areaNodes[pk] {
|
||
filtered = append(filtered, n)
|
||
}
|
||
}
|
||
nodes = filtered
|
||
total = len(filtered)
|
||
}
|
||
}
|
||
writeJSON(w, NodeListResponse{Nodes: nodes, Total: total, Counts: counts})
|
||
}
|
||
|
||
func (s *Server) handleNodeSearch(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query().Get("q")
|
||
if strings.TrimSpace(q) == "" {
|
||
writeJSON(w, NodeSearchResponse{Nodes: []map[string]interface{}{}})
|
||
return
|
||
}
|
||
nodes, err := s.db.SearchNodes(strings.TrimSpace(q), 10)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
// Filter blacklisted nodes from search results
|
||
if len(s.cfg.NodeBlacklist) > 0 {
|
||
filtered := make([]map[string]interface{}, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
if pk, ok := node["public_key"].(string); !ok || !s.cfg.IsBlacklisted(pk) {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
nodes = filtered
|
||
}
|
||
// Drop hidden-prefix nodes from search results (#1181).
|
||
if len(s.cfg.HiddenNamePrefixes) > 0 {
|
||
filtered := make([]map[string]interface{}, 0, len(nodes))
|
||
for _, node := range nodes {
|
||
name, _ := node["name"].(string)
|
||
if !s.cfg.IsNameHidden(name) {
|
||
filtered = append(filtered, node)
|
||
}
|
||
}
|
||
nodes = filtered
|
||
}
|
||
writeJSON(w, NodeSearchResponse{Nodes: nodes})
|
||
}
|
||
|
||
func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||
pubkey := mux.Vars(r)["pubkey"]
|
||
if s.cfg.IsBlacklisted(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
// Issue #772: short-URL fallback. If exact pubkey lookup misses and the
|
||
// path looks like a hex prefix (>=8 chars, <64), try prefix resolution.
|
||
if node == nil && len(pubkey) >= 8 && len(pubkey) < 64 {
|
||
resolved, ambiguous, perr := s.db.GetNodeByPrefix(pubkey)
|
||
if perr != nil {
|
||
writeError(w, 500, perr.Error())
|
||
return
|
||
}
|
||
if ambiguous {
|
||
writeError(w, http.StatusConflict, "Ambiguous prefix: multiple nodes match. Use a longer prefix.")
|
||
return
|
||
}
|
||
if resolved != nil {
|
||
if pk, _ := resolved["public_key"].(string); pk != "" && s.cfg.IsBlacklisted(pk) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
node = resolved
|
||
}
|
||
}
|
||
if node == nil {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
// Hide the node when its name matches an operator-configured prefix
|
||
// (#1181). 404 mirrors the blacklist behaviour above — callers learn
|
||
// nothing about whether the row exists.
|
||
if name, _ := node["name"].(string); s.cfg.IsNameHidden(name) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
if pk, _ := node["public_key"].(string); pk != "" {
|
||
pubkey = pk
|
||
}
|
||
|
||
if s.store != nil {
|
||
hashInfo := s.store.GetNodeHashSizeInfo()
|
||
EnrichNodeWithHashSize(node, hashInfo[pubkey])
|
||
mbEntry, _ := s.store.GetMultibyteCapFor(pubkey)
|
||
EnrichNodeWithMultiByte(node, mbEntry)
|
||
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
|
||
ht := s.cfg.GetHealthThresholds()
|
||
info := s.store.GetRepeaterRelayInfo(pubkey, ht.RelayActiveHours)
|
||
if info.LastRelayed != "" {
|
||
node["last_relayed"] = info.LastRelayed
|
||
}
|
||
node["relay_active"] = info.RelayActive
|
||
node["relay_window_hours"] = info.WindowHours
|
||
node["relay_count_1h"] = info.RelayCount1h
|
||
node["relay_count_24h"] = info.RelayCount24h
|
||
// usefulness_score retained for API compat; new
|
||
// consumers should read traffic_share_score (#1456).
|
||
us := s.store.GetRepeaterUsefulnessScore(pubkey)
|
||
node["usefulness_score"] = us
|
||
node["traffic_share_score"] = us
|
||
node["bridge_score"] = s.store.GetBridgeScore(pubkey)
|
||
}
|
||
}
|
||
|
||
// #1143: GetRecentTransmissionsForNode no longer accepts a name fallback;
|
||
// attribution is strict exact-match on the indexed from_pubkey column.
|
||
recentAdverts, _ := s.db.GetRecentTransmissionsForNode(pubkey, 20)
|
||
|
||
writeJSON(w, NodeDetailResponse{
|
||
Node: node,
|
||
RecentAdverts: recentAdverts,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) {
|
||
pubkey := mux.Vars(r)["pubkey"]
|
||
if s.cfg.IsBlacklisted(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
if s.isPubkeyHidden(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
if s.store != nil {
|
||
result, err := s.store.GetNodeHealth(pubkey)
|
||
if err != nil || result == nil {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
writeJSON(w, result)
|
||
return
|
||
}
|
||
writeError(w, 404, "Not found")
|
||
}
|
||
|
||
func (s *Server) handleBulkHealth(w http.ResponseWriter, r *http.Request) {
|
||
lim := queryLimit(r, 50, s.cfg.ListLimits.BulkHealthMax)
|
||
|
||
if s.store != nil {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
results := s.store.GetBulkHealth(lim, region, area)
|
||
// Filter blacklisted nodes + hidden-prefix nodes (#1181).
|
||
needsBlacklist := len(s.cfg.NodeBlacklist) > 0
|
||
needsHidden := len(s.cfg.HiddenNamePrefixes) > 0
|
||
if needsBlacklist || needsHidden {
|
||
filtered := make([]map[string]interface{}, 0, len(results))
|
||
for _, entry := range results {
|
||
if pk, ok := entry["public_key"].(string); ok && needsBlacklist && s.cfg.IsBlacklisted(pk) {
|
||
continue
|
||
}
|
||
if needsHidden {
|
||
name, _ := entry["name"].(string)
|
||
if s.cfg.IsNameHidden(name) {
|
||
continue
|
||
}
|
||
}
|
||
filtered = append(filtered, entry)
|
||
}
|
||
writeJSON(w, filtered)
|
||
return
|
||
}
|
||
writeJSON(w, results)
|
||
return
|
||
}
|
||
|
||
writeJSON(w, []BulkHealthEntry{})
|
||
}
|
||
|
||
func (s *Server) handleNetworkStatus(w http.ResponseWriter, r *http.Request) {
|
||
ht := s.cfg.GetHealthThresholds()
|
||
result, err := s.db.GetNetworkStatus(ht)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, result)
|
||
}
|
||
|
||
func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||
pubkey := mux.Vars(r)["pubkey"]
|
||
if s.cfg.IsBlacklisted(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
if s.isPubkeyHidden(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
node, err := s.db.GetNodeByPubkey(pubkey)
|
||
if err != nil || node == nil {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
if s.store == nil {
|
||
writeError(w, 503, "Packet store unavailable")
|
||
return
|
||
}
|
||
if !s.store.PathHopIndexReady() {
|
||
writeIndexLoading503(w)
|
||
return
|
||
}
|
||
|
||
// Use the precomputed byPathHop index instead of scanning all packets.
|
||
// Look up by full pubkey (resolved hops) and by short prefixes (raw hops).
|
||
lowerPK := strings.ToLower(pubkey)
|
||
prefix2 := lowerPK
|
||
if len(prefix2) > 4 {
|
||
prefix2 = prefix2[:4]
|
||
}
|
||
prefix1 := lowerPK
|
||
if len(prefix1) > 2 {
|
||
prefix1 = prefix1[:2]
|
||
}
|
||
|
||
s.store.mu.RLock()
|
||
_, pm := s.store.getCachedNodesAndPM()
|
||
|
||
// Collect candidate transmissions from the index, deduplicating by tx ID.
|
||
// confirmedByFullKey tracks TXs found via the full-pubkey index key — these are
|
||
// already resolved_path-confirmed and bypass the hop-level check below.
|
||
confirmedByFullKey := make(map[int]bool)
|
||
seen := make(map[int]bool)
|
||
var candidates []*StoreTx
|
||
addCandidates := func(key string, confirmed bool) {
|
||
for _, tx := range s.store.byPathHop[key] {
|
||
if !seen[tx.ID] {
|
||
seen[tx.ID] = true
|
||
if confirmed {
|
||
confirmedByFullKey[tx.ID] = true
|
||
}
|
||
candidates = append(candidates, tx)
|
||
}
|
||
}
|
||
}
|
||
addCandidates(lowerPK, true) // full pubkey match (from resolved_path) → confirmed
|
||
addCandidates(prefix1, false) // 2-char raw hop match
|
||
addCandidates(prefix2, false) // 4-char raw hop match
|
||
// Also check any raw hops that start with prefix2 (longer prefixes).
|
||
// Raw hops are typically 2 chars, so iterate only keys with HasPrefix
|
||
// on the small set of index keys rather than all packets.
|
||
for key := range s.store.byPathHop {
|
||
if len(key) > 4 && len(key) < len(lowerPK) && strings.HasPrefix(key, prefix2) {
|
||
addCandidates(key, false)
|
||
}
|
||
}
|
||
|
||
// Post-filter: verify target node actually appears in each candidate's resolved_path.
|
||
// The byPathHop index uses short prefixes which can collide (e.g. "c0" matches multiple nodes).
|
||
// We lean on resolved_path (from neighbor affinity graph) to disambiguate.
|
||
//
|
||
// Collect candidate IDs and index membership under the read lock, then release
|
||
// the lock before running SQL queries (confirmResolvedPathContains does disk I/O).
|
||
type candidateCheck struct {
|
||
tx *StoreTx
|
||
hasReverse bool
|
||
inIndex bool
|
||
}
|
||
checks := make([]candidateCheck, len(candidates))
|
||
for i, tx := range candidates {
|
||
cc := candidateCheck{tx: tx}
|
||
if !s.store.useResolvedPathIndex {
|
||
cc.inIndex = true // flag off — keep all
|
||
} else if _, hasRev := s.store.resolvedPubkeyReverse[tx.ID]; !hasRev {
|
||
cc.inIndex = true // no indexed pubkeys — keep (conservative)
|
||
} else {
|
||
h := resolvedPubkeyHash(lowerPK)
|
||
for _, id := range s.store.resolvedPubkeyIndex[h] {
|
||
if id == tx.ID {
|
||
cc.hasReverse = true // needs SQL confirmation
|
||
break
|
||
}
|
||
}
|
||
// If not in index at all, it's a definite no
|
||
}
|
||
checks[i] = cc
|
||
}
|
||
s.store.mu.RUnlock()
|
||
|
||
// Now run SQL checks outside the lock for candidates that need confirmation.
|
||
confirmedBySQL := make(map[int]bool)
|
||
filtered := candidates[:0]
|
||
for _, cc := range checks {
|
||
if cc.inIndex {
|
||
filtered = append(filtered, cc.tx)
|
||
} else if cc.hasReverse {
|
||
if s.store.confirmResolvedPathContains(cc.tx.ID, lowerPK) {
|
||
filtered = append(filtered, cc.tx)
|
||
confirmedBySQL[cc.tx.ID] = true
|
||
}
|
||
}
|
||
// else: not in index → exclude
|
||
}
|
||
candidates = filtered
|
||
|
||
// #1278: Read the CANONICAL persisted resolved_path for each surviving
|
||
// candidate OUTSIDE s.mu (fetchResolvedPathForTxBest takes lruMu; the
|
||
// lock-ordering contract forbids acquiring lruMu under s.mu).
|
||
//
|
||
// Option A from the issue: the packets page renders each tx via
|
||
// fetchResolvedPathForTxBest. For /api/nodes/{pk}/paths to stay
|
||
// CONSISTENT with the packets page, BOTH the containsTarget membership
|
||
// decision AND the displayed hop names must come from that same
|
||
// canonical resolved_path — not a re-resolution biased by passing the
|
||
// queried node as hopContext anchor.
|
||
//
|
||
// Falls back to biased re-resolve only when a tx has no persisted
|
||
// resolved_path (older data / async backfill incomplete); in that case
|
||
// there's no canonical answer to be consistent with.
|
||
canonicalRP := make(map[int][]*string, len(candidates))
|
||
for _, tx := range candidates {
|
||
if rp := s.store.fetchResolvedPathForTxBest(tx); rp != nil {
|
||
canonicalRP[tx.ID] = rp
|
||
}
|
||
}
|
||
|
||
// Re-acquire read lock for the aggregation phase that reads store data.
|
||
s.store.mu.RLock()
|
||
|
||
type pathAgg struct {
|
||
Hops []PathHopResp
|
||
Count int
|
||
LastSeen string
|
||
SampleHash string
|
||
}
|
||
pathGroups := map[string]*pathAgg{}
|
||
totalTransmissions := 0
|
||
hopCache := make(map[string]*nodeInfo)
|
||
// Anchor the resolver with the node being queried so tier-1/2 hop-context
|
||
// resolution lights up when a hop prefix matches the destination node
|
||
// (handleNodePaths aggregates paths terminating at lowerPK). Passing nil
|
||
// here re-introduced regression #1197 in production. See
|
||
// resolve_context_callsites_test.go.
|
||
//
|
||
// NOTE (#1278): this biased resolver is only consulted for the FALLBACK
|
||
// path — txs with no persisted resolved_path. Txs with a canonical
|
||
// resolved_path use the persisted pubkeys directly (see canonicalRP),
|
||
// which keeps results consistent with the packets page.
|
||
hopContext := []string{lowerPK}
|
||
resolveHop := func(hop string) *nodeInfo {
|
||
if cached, ok := hopCache[hop]; ok {
|
||
return cached
|
||
}
|
||
r, _, _ := pm.resolveWithContext(hop, hopContext, s.store.graph.Load())
|
||
hopCache[hop] = r
|
||
return r
|
||
}
|
||
// nodeByPK caches pubkey → *nodeInfo lookups when rendering canonical
|
||
// resolved_path entries. Cheap O(1) hit against pm.m (the prefix map
|
||
// stores the full pubkey as a key for pubkeys >= maxPrefixLen).
|
||
nodeByPK := make(map[string]*nodeInfo)
|
||
lookupNode := func(pk string) *nodeInfo {
|
||
key := strings.ToLower(pk)
|
||
if cached, ok := nodeByPK[key]; ok {
|
||
return cached
|
||
}
|
||
// Use plain resolve(); we have the full pubkey, no ambiguity.
|
||
n := pm.resolve(key)
|
||
if n == nil || !strings.EqualFold(n.PublicKey, key) {
|
||
// Full pubkey may not be present in pm (role filter, eviction).
|
||
// Fall through with nil; caller renders prefix-only entry.
|
||
nodeByPK[key] = nil
|
||
return nil
|
||
}
|
||
nodeByPK[key] = n
|
||
return n
|
||
}
|
||
for _, tx := range candidates {
|
||
hops := txGetParsedPath(tx)
|
||
resolvedHops := make([]PathHopResp, len(hops))
|
||
sigParts := make([]string, len(hops))
|
||
containsTarget := false
|
||
|
||
if rp, ok := canonicalRP[tx.ID]; ok {
|
||
// Option A: render hops + decide membership from the CANONICAL
|
||
// persisted resolved_path. resolved_path is parallel to the
|
||
// best-obs path_json which may be longer than tx.PathJSON used by
|
||
// txGetParsedPath; align by the shorter length.
|
||
rpLen := len(rp)
|
||
for i, hop := range hops {
|
||
entry := PathHopResp{Prefix: hop, Name: hop}
|
||
var resolvedPK string
|
||
if i < rpLen && rp[i] != nil {
|
||
resolvedPK = strings.ToLower(*rp[i])
|
||
}
|
||
if resolvedPK != "" {
|
||
if n := lookupNode(resolvedPK); n != nil {
|
||
entry.Name = n.Name
|
||
entry.Pubkey = n.PublicKey
|
||
if n.HasGPS {
|
||
entry.Lat = n.Lat
|
||
entry.Lon = n.Lon
|
||
}
|
||
sigParts[i] = n.PublicKey
|
||
} else {
|
||
entry.Pubkey = resolvedPK
|
||
sigParts[i] = resolvedPK
|
||
}
|
||
if resolvedPK == lowerPK {
|
||
containsTarget = true
|
||
}
|
||
} else {
|
||
sigParts[i] = hop
|
||
}
|
||
resolvedHops[i] = entry
|
||
}
|
||
} else {
|
||
// Fallback: no canonical resolved_path persisted (older data /
|
||
// async backfill incomplete). Use biased re-resolve and the
|
||
// legacy containsTarget heuristics (preserves #1197 behavior
|
||
// and the #929 prefix-collision exclusion test).
|
||
//
|
||
// #1352: When a hop prefix has MULTIPLE candidates (sibling
|
||
// prefix collisions), the biased resolver — anchored on the
|
||
// queried target via hopContext=[lowerPK] — will preferentially
|
||
// resolve to the target via tier-2 geo / tier-3 GPS. This
|
||
// causes the SAME tx to be attributed to every prefix sibling
|
||
// when each is queried in turn. To prevent wrong-node
|
||
// attribution, we ONLY accept a resolver match as evidence of
|
||
// target membership when:
|
||
// (a) the tx was already pre-confirmed via
|
||
// confirmedByFullKey (resolved_path index hit) or
|
||
// confirmedBySQL (verified pubkey in resolved_path), OR
|
||
// (b) the hop's prefix candidate set is UNIQUE — no
|
||
// collision, so the resolver had no choice to bias.
|
||
// Multi-candidate hops with no SQL/index confirmation are
|
||
// treated as ambiguous and excluded from paths-through.
|
||
containsTarget = confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]
|
||
// preconfirmed: SNAPSHOT of containsTarget BEFORE the per-hop
|
||
// loop runs. Captures only the SQL/full-key index pre-confirmation
|
||
// signal (independent of biased-resolver output). MUST NOT be
|
||
// reassigned inside the loop — doing so would let a biased-
|
||
// resolver match in hop[i] silently authorize a later ambiguous
|
||
// hop[j], re-opening the #1352 wrong-node attribution path.
|
||
//
|
||
// Note: today the loop only ever transitions containsTarget
|
||
// false → true, so the snapshot is functionally redundant for
|
||
// the preconfirmed==true case (containsTarget is already true).
|
||
// We keep the snapshot + the `preconfirmed ||` clauses below
|
||
// as a structural invariant: future edits that flip
|
||
// containsTarget back to false inside the loop (e.g. an
|
||
// "exclude if last hop doesn't match" tweak) would otherwise
|
||
// silently lose the SQL/index confirmation. The snapshot is
|
||
// the documented contract.
|
||
preconfirmed := containsTarget
|
||
for i, hop := range hops {
|
||
resolved := resolveHop(hop)
|
||
entry := PathHopResp{Prefix: hop, Name: hop}
|
||
lowerHop := strings.ToLower(hop)
|
||
// #1352 guard helper. We treat as "unique/safe" when the
|
||
// hop's prefix candidate set has EXACTLY ONE member: no
|
||
// sibling collision, so the biased resolver had no choice
|
||
// to bias. len(pm.m[lowerHop]) == 0 is also accepted as
|
||
// safe-by-default in the resolvable arm because the
|
||
// resolver returned a non-nil candidate from somewhere
|
||
// (e.g. a full-pubkey hop longer than maxPrefixLen, or a
|
||
// hop indexed under a different prefix length); there's
|
||
// no collision to resolve away. In the unresolvable arm
|
||
// below, len==0 is the ONLY reachable case (resolveHop
|
||
// returns nil iff pm.m[lowerHop] is empty — see
|
||
// resolveWithContext priority chain), so the guard there
|
||
// is intentionally permissive on len==0 and the
|
||
// `preconfirmed ||` clause is the meaningful gate.
|
||
uniquePrefix := len(pm.m[lowerHop]) <= 1
|
||
if resolved != nil {
|
||
entry.Name = resolved.Name
|
||
entry.Pubkey = resolved.PublicKey
|
||
if resolved.HasGPS {
|
||
entry.Lat = resolved.Lat
|
||
entry.Lon = resolved.Lon
|
||
}
|
||
sigParts[i] = resolved.PublicKey
|
||
if strings.ToLower(resolved.PublicKey) == lowerPK {
|
||
// #1352: only attribute when unambiguous OR
|
||
// already pre-confirmed via SQL/full-key index.
|
||
if preconfirmed || uniquePrefix {
|
||
containsTarget = true
|
||
}
|
||
}
|
||
} else {
|
||
sigParts[i] = hop
|
||
// Unresolvable hop: keep conservative if prefix could
|
||
// be the target AND there's no sibling collision.
|
||
// If multiple candidates share this prefix, attribution
|
||
// is ambiguous — don't claim membership without SQL
|
||
// confirmation (#1352). See comment on uniquePrefix
|
||
// above re: why len==0 is treated as safe here.
|
||
if strings.HasPrefix(lowerPK, lowerHop) {
|
||
if preconfirmed || uniquePrefix {
|
||
containsTarget = true
|
||
}
|
||
}
|
||
}
|
||
resolvedHops[i] = entry
|
||
}
|
||
}
|
||
|
||
if !containsTarget {
|
||
continue
|
||
}
|
||
totalTransmissions++
|
||
|
||
sig := strings.Join(sigParts, "→")
|
||
agg := pathGroups[sig]
|
||
if agg == nil {
|
||
pathGroups[sig] = &pathAgg{
|
||
Hops: resolvedHops,
|
||
Count: 1,
|
||
LastSeen: tx.FirstSeen,
|
||
SampleHash: tx.Hash,
|
||
}
|
||
continue
|
||
}
|
||
agg.Count++
|
||
if tx.FirstSeen > agg.LastSeen {
|
||
agg.LastSeen = tx.FirstSeen
|
||
agg.SampleHash = tx.Hash
|
||
}
|
||
}
|
||
s.store.mu.RUnlock()
|
||
|
||
paths := make([]PathEntryResp, 0, len(pathGroups))
|
||
for _, agg := range pathGroups {
|
||
var lastSeen interface{}
|
||
if agg.LastSeen != "" {
|
||
lastSeen = agg.LastSeen
|
||
}
|
||
paths = append(paths, PathEntryResp{
|
||
Hops: agg.Hops,
|
||
Count: agg.Count,
|
||
LastSeen: lastSeen,
|
||
SampleHash: agg.SampleHash,
|
||
})
|
||
}
|
||
sort.Slice(paths, func(i, j int) bool {
|
||
li := ""
|
||
lj := ""
|
||
if paths[i].LastSeen != nil {
|
||
li = fmt.Sprintf("%v", paths[i].LastSeen)
|
||
}
|
||
if paths[j].LastSeen != nil {
|
||
lj = fmt.Sprintf("%v", paths[j].LastSeen)
|
||
}
|
||
if li != lj {
|
||
return li > lj
|
||
}
|
||
return paths[i].Count > paths[j].Count
|
||
})
|
||
if len(paths) > 50 {
|
||
paths = paths[:50]
|
||
}
|
||
|
||
writeJSON(w, NodePathsResponse{
|
||
Node: map[string]interface{}{
|
||
"public_key": node["public_key"],
|
||
"name": node["name"],
|
||
"lat": node["lat"],
|
||
"lon": node["lon"],
|
||
},
|
||
Paths: paths,
|
||
TotalPaths: len(pathGroups),
|
||
TotalTransmissions: totalTransmissions,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleNodeAnalytics(w http.ResponseWriter, r *http.Request) {
|
||
pubkey := mux.Vars(r)["pubkey"]
|
||
if s.cfg.IsBlacklisted(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
if s.isPubkeyHidden(pubkey) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
days := queryInt(r, "days", 7)
|
||
if days < 1 {
|
||
days = 1
|
||
}
|
||
if days > 365 {
|
||
days = 365
|
||
}
|
||
|
||
if s.store != nil {
|
||
result, err := s.store.GetNodeAnalytics(pubkey, days)
|
||
if err != nil || result == nil {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
writeJSON(w, result)
|
||
return
|
||
}
|
||
|
||
writeError(w, 404, "Not found")
|
||
}
|
||
|
||
func (s *Server) handleNodeClockSkew(w http.ResponseWriter, r *http.Request) {
|
||
pubkey := mux.Vars(r)["pubkey"]
|
||
if s.store == nil {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
result := s.store.GetNodeClockSkew(pubkey)
|
||
if result == nil {
|
||
writeError(w, 404, "No clock skew data for this node")
|
||
return
|
||
}
|
||
writeJSON(w, result)
|
||
}
|
||
|
||
func (s *Server) handleObserverClockSkew(w http.ResponseWriter, r *http.Request) {
|
||
if s.store == nil {
|
||
writeJSON(w, []ObserverCalibration{})
|
||
return
|
||
}
|
||
writeJSON(w, s.store.GetObserverCalibrations())
|
||
}
|
||
|
||
func (s *Server) handleFleetClockSkew(w http.ResponseWriter, r *http.Request) {
|
||
if s.store == nil {
|
||
writeJSON(w, []*NodeClockSkew{})
|
||
return
|
||
}
|
||
area := r.URL.Query().Get("area")
|
||
writeJSON(w, s.store.GetFleetClockSkew(area))
|
||
}
|
||
|
||
// --- Analytics Handlers ---
|
||
|
||
func (s *Server) handleAnalyticsRF(w http.ResponseWriter, r *http.Request) {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
window := ParseTimeWindow(r)
|
||
if s.store != nil {
|
||
writeJSON(w, s.store.GetAnalyticsRFWithWindow(region, area, window))
|
||
return
|
||
}
|
||
writeJSON(w, RFAnalyticsResponse{
|
||
SNR: SignalStats{},
|
||
RSSI: SignalStats{},
|
||
SnrValues: Histogram{Bins: []HistogramBin{}, Min: 0, Max: 0},
|
||
RssiValues: Histogram{Bins: []HistogramBin{}, Min: 0, Max: 0},
|
||
PacketSizes: Histogram{Bins: []HistogramBin{}, Min: 0, Max: 0},
|
||
PacketsPerHour: []HourlyCount{},
|
||
PayloadTypes: []PayloadTypeEntry{},
|
||
SnrByType: []PayloadTypeSignal{},
|
||
SignalOverTime: []SignalOverTimeEntry{},
|
||
ScatterData: []ScatterPoint{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsRelayAirtimeShare(w http.ResponseWriter, r *http.Request) {
|
||
window := ParseTimeWindow(r)
|
||
if s.store != nil {
|
||
writeJSON(w, s.store.GetRelayAirtimeShareWithWindow(window))
|
||
return
|
||
}
|
||
writeJSON(w, map[string]interface{}{
|
||
"rows": []map[string]interface{}{},
|
||
"total_count": 0,
|
||
"total_score": 0,
|
||
"window": "",
|
||
"cached": false,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsTopology(w http.ResponseWriter, r *http.Request) {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
window := ParseTimeWindow(r)
|
||
if s.store != nil {
|
||
data := s.store.GetAnalyticsTopologyWithWindow(region, area, window)
|
||
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
|
||
data = s.filterBlacklistedFromTopology(data)
|
||
}
|
||
writeJSON(w, data)
|
||
return
|
||
}
|
||
writeJSON(w, TopologyResponse{
|
||
HopDistribution: []TopologyHopDist{},
|
||
TopRepeaters: []TopRepeater{},
|
||
TopPairs: []TopPair{},
|
||
HopsVsSnr: []HopsVsSnr{},
|
||
Observers: []ObserverRef{},
|
||
PerObserverReach: map[string]*ObserverReach{},
|
||
MultiObsNodes: []MultiObsNode{},
|
||
BestPathList: []BestPathEntry{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsChannels(w http.ResponseWriter, r *http.Request) {
|
||
if s.store != nil {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
window := ParseTimeWindow(r)
|
||
writeJSON(w, s.store.GetAnalyticsChannelsWithWindow(region, area, window))
|
||
return
|
||
}
|
||
channels, _ := s.db.GetChannels()
|
||
if channels == nil {
|
||
channels = make([]map[string]interface{}, 0)
|
||
}
|
||
writeJSON(w, ChannelAnalyticsResponse{
|
||
ActiveChannels: len(channels),
|
||
Decryptable: len(channels),
|
||
Channels: []ChannelAnalyticsSummary{},
|
||
TopSenders: []TopSender{},
|
||
ChannelTimeline: []ChannelTimelineEntry{},
|
||
MsgLengths: []int{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsDistance(w http.ResponseWriter, r *http.Request) {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
if s.store != nil {
|
||
// Lazy build (#1011): distance index is not built at startup.
|
||
// First request triggers an async build and gets 202 +
|
||
// Retry-After; concurrent requests during the build window
|
||
// also get 202. Cached results (after the build completes)
|
||
// are served as 200 from the analytics recomputer / TTL cache.
|
||
if !s.store.DistanceIndexBuilt() {
|
||
s.store.TriggerDistanceIndexBuild()
|
||
w.Header().Set("Retry-After", "5")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusAccepted)
|
||
_, _ = w.Write([]byte(`{"status":"building","retry_after_seconds":5,"detail":"distance index is being computed (lazy build, #1011). Retry after Retry-After seconds."}`))
|
||
return
|
||
}
|
||
writeJSON(w, s.store.GetAnalyticsDistance(region, area))
|
||
return
|
||
}
|
||
writeJSON(w, DistanceAnalyticsResponse{
|
||
Summary: DistanceSummary{},
|
||
TopHops: []DistanceHop{},
|
||
TopPaths: []DistancePath{},
|
||
CatStats: map[string]*CategoryDistStats{},
|
||
DistHistogram: nil,
|
||
DistOverTime: []DistOverTimeEntry{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsHashSizes(w http.ResponseWriter, r *http.Request) {
|
||
if s.store != nil {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
writeJSON(w, s.store.GetAnalyticsHashSizes(region, area))
|
||
return
|
||
}
|
||
writeJSON(w, map[string]interface{}{
|
||
"total": 0,
|
||
"distribution": map[string]int{"1": 0, "2": 0, "3": 0},
|
||
"distributionByRepeaters": map[string]int{"1": 0, "2": 0, "3": 0},
|
||
"hourly": []HashSizeHourly{},
|
||
"topHops": []HashSizeHop{},
|
||
"multiByteNodes": []MultiByteNode{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsHashCollisions(w http.ResponseWriter, r *http.Request) {
|
||
if s.store != nil {
|
||
region := r.URL.Query().Get("region")
|
||
area := r.URL.Query().Get("area")
|
||
writeJSON(w, s.store.GetAnalyticsHashCollisions(region, area))
|
||
return
|
||
}
|
||
writeJSON(w, map[string]interface{}{
|
||
"inconsistent_nodes": []interface{}{},
|
||
"by_size": map[string]interface{}{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request) {
|
||
if s.store != nil {
|
||
if !s.store.SubpathIndexReady() {
|
||
writeIndexLoading503(w)
|
||
return
|
||
}
|
||
region := r.URL.Query().Get("region")
|
||
minLen := queryInt(r, "minLen", 2)
|
||
if minLen < 2 {
|
||
minLen = 2
|
||
}
|
||
maxLen := queryInt(r, "maxLen", 8)
|
||
limit := queryLimit(r, 100, s.cfg.ListLimits.AnalyticsMax)
|
||
// Issue #1217: honor the Time window filter on Route Patterns.
|
||
window := ParseTimeWindow(r)
|
||
data := s.store.GetAnalyticsSubpathsWithWindow(region, minLen, maxLen, limit, window)
|
||
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
|
||
data = s.filterBlacklistedFromSubpaths(data)
|
||
}
|
||
writeJSON(w, data)
|
||
return
|
||
}
|
||
writeJSON(w, SubpathsResponse{
|
||
Subpaths: []SubpathResp{},
|
||
TotalPaths: 0,
|
||
})
|
||
}
|
||
|
||
// handleAnalyticsSubpathsBulk returns multiple length-range buckets in a single
|
||
// response, avoiding repeated scans of the same packet data. Query format:
|
||
//
|
||
// ?groups=2-2:50,3-3:30,4-4:20,5-8:15 (minLen-maxLen:limit per group)
|
||
func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Request) {
|
||
if s.store != nil && !s.store.SubpathIndexReady() {
|
||
writeIndexLoading503(w)
|
||
return
|
||
}
|
||
region := r.URL.Query().Get("region")
|
||
groupsParam := r.URL.Query().Get("groups")
|
||
if groupsParam == "" {
|
||
writeJSON(w, ErrorResp{Error: "groups parameter required (e.g. groups=2-2:50,3-3:30)"})
|
||
return
|
||
}
|
||
|
||
var groups []subpathGroup
|
||
for _, g := range strings.Split(groupsParam, ",") {
|
||
parts := strings.SplitN(g, ":", 2)
|
||
if len(parts) != 2 {
|
||
writeJSON(w, ErrorResp{Error: "invalid group format: " + g})
|
||
return
|
||
}
|
||
rangeParts := strings.SplitN(parts[0], "-", 2)
|
||
if len(rangeParts) != 2 {
|
||
writeJSON(w, ErrorResp{Error: "invalid range format: " + parts[0]})
|
||
return
|
||
}
|
||
mn, err1 := strconv.Atoi(rangeParts[0])
|
||
mx, err2 := strconv.Atoi(rangeParts[1])
|
||
lim, err3 := strconv.Atoi(parts[1])
|
||
if err1 != nil || err2 != nil || err3 != nil || mn < 2 || mx < mn || lim < 1 {
|
||
writeJSON(w, ErrorResp{Error: "invalid group: " + g})
|
||
return
|
||
}
|
||
// Uniform clamp per audit-input-vulns-20260603 (MEDIUM): match the
|
||
// /api/analytics/subpaths ceiling so a single bulk request can't
|
||
// allocate more than the per-group endpoint allows.
|
||
if lim > 200 {
|
||
lim = 200
|
||
}
|
||
groups = append(groups, subpathGroup{mn, mx, lim})
|
||
}
|
||
|
||
if s.store == nil {
|
||
results := make([]map[string]interface{}, len(groups))
|
||
for i := range groups {
|
||
results[i] = map[string]interface{}{"subpaths": []interface{}{}, "totalPaths": 0}
|
||
}
|
||
writeJSON(w, map[string]interface{}{"results": results})
|
||
return
|
||
}
|
||
|
||
results := s.store.GetAnalyticsSubpathsBulkWithWindow(region, groups, ParseTimeWindow(r))
|
||
if s.cfg != nil && len(s.cfg.NodeBlacklist) > 0 {
|
||
for i, r := range results {
|
||
results[i] = s.filterBlacklistedFromSubpaths(r)
|
||
}
|
||
}
|
||
writeJSON(w, map[string]interface{}{"results": results})
|
||
}
|
||
|
||
// subpathGroup defines a length-range + limit for the bulk subpaths endpoint.
|
||
type subpathGroup struct {
|
||
MinLen, MaxLen, Limit int
|
||
}
|
||
|
||
func (s *Server) handleAnalyticsSubpathDetail(w http.ResponseWriter, r *http.Request) {
|
||
hops := r.URL.Query().Get("hops")
|
||
if hops == "" {
|
||
writeJSON(w, ErrorResp{Error: "Need at least 2 hops"})
|
||
return
|
||
}
|
||
rawHops := strings.Split(hops, ",")
|
||
if len(rawHops) < 2 {
|
||
writeJSON(w, ErrorResp{Error: "Need at least 2 hops"})
|
||
return
|
||
}
|
||
// Reject if any hop is a blacklisted or hidden-prefix node (#1181).
|
||
if s.cfg != nil && (len(s.cfg.NodeBlacklist) > 0 || len(s.cfg.HiddenNamePrefixes) > 0) {
|
||
for _, hop := range rawHops {
|
||
if s.cfg.IsBlacklisted(hop) || s.isPubkeyHidden(hop) {
|
||
writeError(w, 404, "Not found")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
if s.store != nil {
|
||
if !s.store.SubpathIndexReady() {
|
||
writeIndexLoading503(w)
|
||
return
|
||
}
|
||
writeJSON(w, s.store.GetSubpathDetail(rawHops))
|
||
return
|
||
}
|
||
writeJSON(w, SubpathDetailResponse{
|
||
Hops: rawHops,
|
||
Nodes: []SubpathNode{},
|
||
TotalMatches: 0,
|
||
FirstSeen: nil,
|
||
LastSeen: nil,
|
||
Signal: SubpathSignal{AvgSnr: nil, AvgRssi: nil, Samples: 0},
|
||
HourDistribution: make([]int, 24),
|
||
ParentPaths: []ParentPath{},
|
||
Observers: []SubpathObserver{},
|
||
})
|
||
}
|
||
|
||
// --- Other Handlers ---
|
||
|
||
func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||
hopsParam := r.URL.Query().Get("hops")
|
||
if hopsParam == "" {
|
||
writeJSON(w, ResolveHopsResponse{Resolved: map[string]*HopResolution{}})
|
||
return
|
||
}
|
||
hops := strings.Split(hopsParam, ",")
|
||
resolved := map[string]*HopResolution{}
|
||
|
||
// Context for affinity-based disambiguation.
|
||
fromNode := r.URL.Query().Get("from_node")
|
||
observer := r.URL.Query().Get("observer")
|
||
var contextPubkeys []string
|
||
if fromNode != "" {
|
||
contextPubkeys = append(contextPubkeys, fromNode)
|
||
}
|
||
if observer != "" {
|
||
contextPubkeys = append(contextPubkeys, observer)
|
||
}
|
||
|
||
// Get the neighbor graph for affinity scoring (may be nil).
|
||
var graph *NeighborGraph
|
||
if len(contextPubkeys) > 0 {
|
||
graph = s.getNeighborGraph()
|
||
}
|
||
|
||
// Get the server's prefix map for resolveWithContext.
|
||
var pm *prefixMap
|
||
if s.store != nil {
|
||
s.store.mu.RLock()
|
||
_, pm = s.store.getCachedNodesAndPM()
|
||
s.store.mu.RUnlock()
|
||
}
|
||
|
||
for _, hop := range hops {
|
||
if hop == "" {
|
||
continue
|
||
}
|
||
hopLower := strings.ToLower(hop)
|
||
|
||
// Resolve candidates from the in-memory prefix map instead of
|
||
// issuing per-hop DB queries (fixes N+1 pattern, see #369).
|
||
var candidates []HopCandidate
|
||
if pm != nil {
|
||
if matched, ok := pm.m[hopLower]; ok {
|
||
for _, ni := range matched {
|
||
// Skip blacklisted nodes from resolution results.
|
||
if s.cfg != nil && s.cfg.IsBlacklisted(ni.PublicKey) {
|
||
continue
|
||
}
|
||
// #1181: skip hidden-prefix nodes too. We have the
|
||
// name on ni so no extra DB lookup is needed.
|
||
if s.cfg != nil && s.cfg.IsNameHidden(ni.Name) {
|
||
continue
|
||
}
|
||
c := HopCandidate{Pubkey: ni.PublicKey}
|
||
if ni.Name != "" {
|
||
c.Name = ni.Name
|
||
}
|
||
if ni.HasGPS {
|
||
c.Lat = ni.Lat
|
||
c.Lon = ni.Lon
|
||
}
|
||
candidates = append(candidates, c)
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(candidates) == 0 {
|
||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "no_match"}
|
||
} else if len(candidates) == 1 {
|
||
resolved[hop] = &HopResolution{
|
||
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
|
||
Candidates: candidates, Conflicts: []interface{}{},
|
||
Confidence: "unique_prefix",
|
||
}
|
||
} else {
|
||
// Compute affinity scores for each candidate if we have context.
|
||
if graph != nil && len(contextPubkeys) > 0 {
|
||
now := time.Now()
|
||
for i := range candidates {
|
||
candPK := strings.ToLower(candidates[i].Pubkey)
|
||
bestScore := 0.0
|
||
for _, ctxPK := range contextPubkeys {
|
||
edges := graph.Neighbors(strings.ToLower(ctxPK))
|
||
for _, e := range edges {
|
||
if e.Ambiguous {
|
||
continue
|
||
}
|
||
otherPK := e.NodeA
|
||
if strings.EqualFold(otherPK, ctxPK) {
|
||
otherPK = e.NodeB
|
||
}
|
||
if strings.EqualFold(otherPK, candPK) {
|
||
sc := e.Score(now)
|
||
if sc > bestScore {
|
||
bestScore = sc
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if bestScore > 0 {
|
||
s := bestScore
|
||
candidates[i].AffinityScore = &s
|
||
}
|
||
}
|
||
}
|
||
|
||
// Use resolveWithContext for 4-tier disambiguation.
|
||
var best *nodeInfo
|
||
var confidence string
|
||
if pm != nil {
|
||
best, confidence, _ = pm.resolveWithContext(hopLower, contextPubkeys, graph)
|
||
}
|
||
|
||
ambig := true
|
||
hr := &HopResolution{
|
||
Name: candidates[0].Name, Pubkey: candidates[0].Pubkey,
|
||
Ambiguous: &ambig, Candidates: candidates, Conflicts: hopCandidatesToConflicts(candidates),
|
||
Confidence: "ambiguous",
|
||
}
|
||
|
||
// Use the resolved node as the default (best-effort pick).
|
||
// Skip if the best pick is blacklisted or has a hidden-prefix
|
||
// name (#1181).
|
||
if best != nil && !(s.cfg != nil && (s.cfg.IsBlacklisted(best.PublicKey) || s.cfg.IsNameHidden(best.Name))) {
|
||
hr.Name = best.Name
|
||
hr.Pubkey = best.PublicKey
|
||
}
|
||
|
||
// Only promote to bestCandidate when affinity is confident.
|
||
if confidence == "neighbor_affinity" && best != nil {
|
||
pk := best.PublicKey
|
||
hr.BestCandidate = &pk
|
||
hr.Confidence = "neighbor_affinity"
|
||
} else if (confidence == "geo_proximity" || confidence == "gps_preference" || confidence == "observation_count_fallback") && best != nil {
|
||
// Propagate lower-priority tiers so the API reflects the actual
|
||
// resolution strategy used, rather than collapsing everything to "ambiguous".
|
||
hr.Confidence = confidence
|
||
}
|
||
|
||
resolved[hop] = hr
|
||
}
|
||
}
|
||
writeJSON(w, ResolveHopsResponse{Resolved: resolved})
|
||
}
|
||
|
||
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||
region := r.URL.Query().Get("region")
|
||
includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true"
|
||
// Prefer DB for full history (in-memory store has limited retention)
|
||
if s.db != nil {
|
||
channels, err := s.db.GetChannels(region)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
if includeEncrypted {
|
||
encrypted, err := s.db.GetEncryptedChannels(region)
|
||
if err != nil {
|
||
log.Printf("WARN GetEncryptedChannels: %v", err)
|
||
} else {
|
||
channels = append(channels, encrypted...)
|
||
}
|
||
}
|
||
writeJSON(w, ChannelListResponse{Channels: channels})
|
||
return
|
||
}
|
||
if s.store != nil {
|
||
channels := s.store.GetChannels(region)
|
||
if includeEncrypted {
|
||
channels = append(channels, s.store.GetEncryptedChannels(region)...)
|
||
}
|
||
writeJSON(w, ChannelListResponse{Channels: channels})
|
||
return
|
||
}
|
||
writeJSON(w, ChannelListResponse{Channels: []map[string]interface{}{}})
|
||
}
|
||
|
||
func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
|
||
hash := mux.Vars(r)["hash"]
|
||
limit := queryLimit(r, 100, s.cfg.ListLimits.ChannelMessagesMax)
|
||
offset := queryInt(r, "offset", 0)
|
||
region := r.URL.Query().Get("region")
|
||
// Prefer DB for full history (in-memory store has limited retention)
|
||
if s.db != nil {
|
||
messages, total, err := s.db.GetChannelMessages(hash, limit, offset, region)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
|
||
return
|
||
}
|
||
if s.store != nil {
|
||
messages, total := s.store.GetChannelMessages(hash, limit, offset, region)
|
||
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
|
||
return
|
||
}
|
||
writeJSON(w, ChannelMessagesResponse{Messages: []map[string]interface{}{}, Total: 0})
|
||
}
|
||
|
||
func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
|
||
// #1481 P0-3 + #1483: serve from 30s atomic-pointer cache for the
|
||
// default (no-filter) query shape. Refill is collapsed via
|
||
// singleflight so concurrent TTL-boundary requests do not stampede
|
||
// the 1.9M-row observations table.
|
||
isDefault := r.URL.RawQuery == ""
|
||
if isDefault {
|
||
if e, ok := s.loadObserversCache(); ok && !s.observersCacheExpired(e.at) {
|
||
w.Header().Set("X-Cache-Age-Seconds", cacheAgeSecondsHeader(time.Since(e.at)))
|
||
writeJSON(w, e.resp)
|
||
return
|
||
}
|
||
}
|
||
|
||
if isDefault {
|
||
v, err, _ := s.observersCacheV2.sf.Do(observersCacheFlightKey, func() (interface{}, error) {
|
||
// Double-check inside the singleflight: another winner
|
||
// may have just stored a fresh entry.
|
||
if e, ok := s.loadObserversCache(); ok && !s.observersCacheExpired(e.at) {
|
||
return e, nil
|
||
}
|
||
resp, herr := s.buildObserversDefaultResponse()
|
||
if herr != nil {
|
||
return nil, herr
|
||
}
|
||
s.observersCacheV2.fillCount.Add(1)
|
||
entry := &observersCacheEntry{resp: resp, at: time.Now()}
|
||
s.observersCacheV2.ptr.Store(entry)
|
||
return entry, nil
|
||
})
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
entry := v.(*observersCacheEntry)
|
||
w.Header().Set("X-Cache-Age-Seconds", cacheAgeSecondsHeader(time.Since(entry.at)))
|
||
writeJSON(w, entry.resp)
|
||
return
|
||
}
|
||
|
||
// Non-default queries bypass the cache entirely (filters not yet wired).
|
||
resp, err := s.buildObserversDefaultResponse()
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
// buildObserversDefaultResponse runs the underlying SQL pipeline for
|
||
// the default-shape /api/observers payload. Extracted so the cache
|
||
// refill path can be wrapped in singleflight and counted by tests.
|
||
// #1483 follow-up.
|
||
func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) {
|
||
observers, err := s.db.GetObservers()
|
||
if err != nil {
|
||
return ObserverListResponse{}, err
|
||
}
|
||
|
||
// Batch lookup: packetsLastHour per observer
|
||
oneHourAgo := time.Now().Add(-1 * time.Hour).Unix()
|
||
pktCounts := s.db.GetObserverPacketCounts(oneHourAgo)
|
||
|
||
// Batch lookup: node locations only for observer IDs (not all nodes)
|
||
observerIDs := make([]string, len(observers))
|
||
for i, o := range observers {
|
||
observerIDs[i] = o.ID
|
||
}
|
||
nodeLocations := s.db.GetNodeLocationsByKeys(observerIDs)
|
||
|
||
result := make([]ObserverResp, 0, len(observers))
|
||
nowTime := time.Now().UTC()
|
||
for i := range observers {
|
||
o := &observers[i]
|
||
// Defense in depth: skip observers that are in the blacklist
|
||
if s.cfg != nil && s.cfg.IsObserverBlacklisted(o.ID) {
|
||
continue
|
||
}
|
||
plh := 0
|
||
if c, ok := pktCounts[o.ID]; ok {
|
||
plh = c
|
||
}
|
||
var lat, lon, nodeRole interface{}
|
||
if nodeLoc, ok := nodeLocations[strings.ToLower(o.ID)]; ok {
|
||
lat = nodeLoc["lat"]
|
||
lon = nodeLoc["lon"]
|
||
nodeRole = nodeLoc["role"]
|
||
}
|
||
|
||
resp := ObserverResp{
|
||
ID: o.ID, Name: o.Name, IATA: o.IATA,
|
||
LastSeen: o.LastSeen, FirstSeen: o.FirstSeen,
|
||
PacketCount: o.PacketCount,
|
||
Model: o.Model, Firmware: o.Firmware,
|
||
ClientVersion: o.ClientVersion, Radio: o.Radio,
|
||
BatteryMv: o.BatteryMv, UptimeSecs: o.UptimeSecs,
|
||
NoiseFloor: o.NoiseFloor,
|
||
LastPacketAt: o.LastPacketAt,
|
||
PacketsLastHour: plh,
|
||
Lat: lat, Lon: lon, NodeRole: nodeRole,
|
||
CanRelay: o.CanRelay,
|
||
}
|
||
applyObserverNaiveClock(&resp, o, nowTime)
|
||
result = append(result, resp)
|
||
}
|
||
return ObserverListResponse{
|
||
Observers: result,
|
||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||
}, nil
|
||
}
|
||
|
||
func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
|
||
id := mux.Vars(r)["id"]
|
||
|
||
// Defense in depth: reject blacklisted observer
|
||
if s.cfg != nil && s.cfg.IsObserverBlacklisted(id) {
|
||
writeError(w, 404, "Observer not found")
|
||
return
|
||
}
|
||
|
||
obs, err := s.db.GetObserverByID(id)
|
||
if err != nil || obs == nil {
|
||
writeError(w, 404, "Observer not found")
|
||
return
|
||
}
|
||
|
||
// Compute packetsLastHour from observations
|
||
oneHourAgo := time.Now().Add(-1 * time.Hour).Unix()
|
||
pktCounts := s.db.GetObserverPacketCounts(oneHourAgo)
|
||
plh := 0
|
||
if c, ok := pktCounts[id]; ok {
|
||
plh = c
|
||
}
|
||
|
||
writeJSON(w, func() ObserverResp {
|
||
resp := ObserverResp{
|
||
ID: obs.ID, Name: obs.Name, IATA: obs.IATA,
|
||
LastSeen: obs.LastSeen, FirstSeen: obs.FirstSeen,
|
||
PacketCount: obs.PacketCount,
|
||
Model: obs.Model, Firmware: obs.Firmware,
|
||
ClientVersion: obs.ClientVersion, Radio: obs.Radio,
|
||
BatteryMv: obs.BatteryMv, UptimeSecs: obs.UptimeSecs,
|
||
NoiseFloor: obs.NoiseFloor,
|
||
LastPacketAt: obs.LastPacketAt,
|
||
PacketsLastHour: plh,
|
||
CanRelay: obs.CanRelay,
|
||
}
|
||
applyObserverNaiveClock(&resp, obs, time.Now().UTC())
|
||
return resp
|
||
}())
|
||
}
|
||
|
||
func (s *Server) handleObserverAnalytics(w http.ResponseWriter, r *http.Request) {
|
||
id := mux.Vars(r)["id"]
|
||
days := queryInt(r, "days", 7)
|
||
if days < 1 {
|
||
days = 1
|
||
}
|
||
if days > 365 {
|
||
days = 365
|
||
}
|
||
if s.store == nil {
|
||
writeError(w, 503, "Packet store unavailable")
|
||
return
|
||
}
|
||
|
||
since := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||
s.store.mu.RLock()
|
||
obsList := s.store.byObserver[id]
|
||
// #1481 P0-2: snapshot pointer slice and release RLock immediately —
|
||
// don't iterate + json-decode + time-parse under the lock.
|
||
obsSnapshot := make([]*StoreObs, len(obsList))
|
||
copy(obsSnapshot, obsList)
|
||
s.store.mu.RUnlock()
|
||
filtered := make([]*StoreObs, 0, len(obsSnapshot))
|
||
for _, obs := range obsSnapshot {
|
||
t, ok := obs.ParsedTime()
|
||
if !ok {
|
||
continue
|
||
}
|
||
if t.Equal(since) || t.After(since) {
|
||
filtered = append(filtered, obs)
|
||
}
|
||
}
|
||
sort.Slice(filtered, func(i, j int) bool { return filtered[i].Timestamp > filtered[j].Timestamp })
|
||
|
||
bucketDur := 24 * time.Hour
|
||
if days <= 1 {
|
||
bucketDur = time.Hour
|
||
} else if days <= 7 {
|
||
bucketDur = 4 * time.Hour
|
||
}
|
||
formatLabel := func(t time.Time) string {
|
||
if days <= 1 {
|
||
return t.UTC().Format("15:04")
|
||
}
|
||
if days <= 7 {
|
||
return t.UTC().Format("Mon 15:04")
|
||
}
|
||
return t.UTC().Format("Jan 02")
|
||
}
|
||
|
||
packetTypes := map[string]int{}
|
||
timelineCounts := map[int64]int{}
|
||
nodeBucketSets := map[int64]map[string]struct{}{}
|
||
snrBuckets := map[int]*SnrDistributionEntry{}
|
||
recentPackets := make([]map[string]interface{}, 0, 20)
|
||
|
||
for i, obs := range filtered {
|
||
ts, ok := obs.ParsedTime()
|
||
if !ok {
|
||
continue
|
||
}
|
||
bucketStart := ts.UTC().Truncate(bucketDur).Unix()
|
||
timelineCounts[bucketStart]++
|
||
if nodeBucketSets[bucketStart] == nil {
|
||
nodeBucketSets[bucketStart] = map[string]struct{}{}
|
||
}
|
||
|
||
enriched := s.store.enrichObs(obs)
|
||
if pt, ok := enriched["payload_type"].(int); ok {
|
||
packetTypes[strconv.Itoa(pt)]++
|
||
}
|
||
if decodedRaw, ok := enriched["decoded_json"].(string); ok && decodedRaw != "" {
|
||
var decoded map[string]interface{}
|
||
if json.Unmarshal([]byte(decodedRaw), &decoded) == nil {
|
||
for _, k := range []string{"pubKey", "srcHash", "destHash"} {
|
||
if v, ok := decoded[k].(string); ok && v != "" {
|
||
nodeBucketSets[bucketStart][v] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for _, hop := range parsePathJSON(obs.PathJSON) {
|
||
if hop != "" {
|
||
nodeBucketSets[bucketStart][hop] = struct{}{}
|
||
}
|
||
}
|
||
if obs.SNR != nil {
|
||
bucket := int(*obs.SNR) / 2 * 2
|
||
if *obs.SNR < 0 && int(*obs.SNR) != bucket {
|
||
bucket -= 2
|
||
}
|
||
if snrBuckets[bucket] == nil {
|
||
snrBuckets[bucket] = &SnrDistributionEntry{Range: fmt.Sprintf("%d to %d", bucket, bucket+2)}
|
||
}
|
||
snrBuckets[bucket].Count++
|
||
}
|
||
if i < 20 {
|
||
recentPackets = append(recentPackets, enriched)
|
||
}
|
||
}
|
||
// #1481 P0-2: RLock was released earlier after snapshotting the
|
||
// observation pointer slice; no Unlock needed here.
|
||
|
||
buildTimeline := func(counts map[int64]int) []TimeBucket {
|
||
keys := make([]int64, 0, len(counts))
|
||
for k := range counts {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
||
out := make([]TimeBucket, 0, len(keys))
|
||
for _, k := range keys {
|
||
lbl := formatLabel(time.Unix(k, 0))
|
||
out = append(out, TimeBucket{Label: &lbl, Count: counts[k]})
|
||
}
|
||
return out
|
||
}
|
||
|
||
nodeCounts := make(map[int64]int, len(nodeBucketSets))
|
||
for k, nodes := range nodeBucketSets {
|
||
nodeCounts[k] = len(nodes)
|
||
}
|
||
snrKeys := make([]int, 0, len(snrBuckets))
|
||
for k := range snrBuckets {
|
||
snrKeys = append(snrKeys, k)
|
||
}
|
||
sort.Ints(snrKeys)
|
||
snrDistribution := make([]SnrDistributionEntry, 0, len(snrKeys))
|
||
for _, k := range snrKeys {
|
||
snrDistribution = append(snrDistribution, *snrBuckets[k])
|
||
}
|
||
|
||
writeJSON(w, ObserverAnalyticsResponse{
|
||
Timeline: buildTimeline(timelineCounts),
|
||
PacketTypes: packetTypes,
|
||
NodesTimeline: buildTimeline(nodeCounts),
|
||
SnrDistribution: snrDistribution,
|
||
RecentPackets: recentPackets,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleTraces(w http.ResponseWriter, r *http.Request) {
|
||
hash := mux.Vars(r)["hash"]
|
||
traces, err := s.db.GetTraces(hash)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, TraceResponse{Traces: traces})
|
||
}
|
||
|
||
var iataCoords = map[string]IataCoord{
|
||
"SJC": {Lat: 37.3626, Lon: -121.929},
|
||
"SFO": {Lat: 37.6213, Lon: -122.379},
|
||
"OAK": {Lat: 37.7213, Lon: -122.2208},
|
||
"SEA": {Lat: 47.4502, Lon: -122.3088},
|
||
"PDX": {Lat: 45.5898, Lon: -122.5951},
|
||
"LAX": {Lat: 33.9425, Lon: -118.4081},
|
||
"SAN": {Lat: 32.7338, Lon: -117.1933},
|
||
"SMF": {Lat: 38.6954, Lon: -121.5908},
|
||
"MRY": {Lat: 36.587, Lon: -121.843},
|
||
"EUG": {Lat: 44.1246, Lon: -123.2119},
|
||
"RDD": {Lat: 40.509, Lon: -122.2934},
|
||
"MFR": {Lat: 42.3742, Lon: -122.8735},
|
||
"FAT": {Lat: 36.7762, Lon: -119.7181},
|
||
"SBA": {Lat: 34.4262, Lon: -119.8405},
|
||
"RNO": {Lat: 39.4991, Lon: -119.7681},
|
||
"BOI": {Lat: 43.5644, Lon: -116.2228},
|
||
"LAS": {Lat: 36.084, Lon: -115.1537},
|
||
"PHX": {Lat: 33.4373, Lon: -112.0078},
|
||
"SLC": {Lat: 40.7884, Lon: -111.9778},
|
||
"DEN": {Lat: 39.8561, Lon: -104.6737},
|
||
"DFW": {Lat: 32.8998, Lon: -97.0403},
|
||
"IAH": {Lat: 29.9844, Lon: -95.3414},
|
||
"AUS": {Lat: 30.1975, Lon: -97.6664},
|
||
"MSP": {Lat: 44.8848, Lon: -93.2223},
|
||
"ATL": {Lat: 33.6407, Lon: -84.4277},
|
||
"ORD": {Lat: 41.9742, Lon: -87.9073},
|
||
"JFK": {Lat: 40.6413, Lon: -73.7781},
|
||
"EWR": {Lat: 40.6895, Lon: -74.1745},
|
||
"BOS": {Lat: 42.3656, Lon: -71.0096},
|
||
"MIA": {Lat: 25.7959, Lon: -80.287},
|
||
"IAD": {Lat: 38.9531, Lon: -77.4565},
|
||
"CLT": {Lat: 35.2144, Lon: -80.9473},
|
||
"DTW": {Lat: 42.2124, Lon: -83.3534},
|
||
"MCO": {Lat: 28.4312, Lon: -81.3081},
|
||
"BNA": {Lat: 36.1263, Lon: -86.6774},
|
||
"RDU": {Lat: 35.8801, Lon: -78.788},
|
||
"YVR": {Lat: 49.1967, Lon: -123.1815},
|
||
"YYZ": {Lat: 43.6777, Lon: -79.6248},
|
||
"YYC": {Lat: 51.1215, Lon: -114.0076},
|
||
"YEG": {Lat: 53.3097, Lon: -113.58},
|
||
"YOW": {Lat: 45.3225, Lon: -75.6692},
|
||
"LHR": {Lat: 51.47, Lon: -0.4543},
|
||
"CDG": {Lat: 49.0097, Lon: 2.5479},
|
||
"FRA": {Lat: 50.0379, Lon: 8.5622},
|
||
"AMS": {Lat: 52.3105, Lon: 4.7683},
|
||
"MUC": {Lat: 48.3537, Lon: 11.775},
|
||
"SOF": {Lat: 42.6952, Lon: 23.4062},
|
||
"NRT": {Lat: 35.772, Lon: 140.3929},
|
||
"HND": {Lat: 35.5494, Lon: 139.7798},
|
||
"ICN": {Lat: 37.4602, Lon: 126.4407},
|
||
"SYD": {Lat: -33.9461, Lon: 151.1772},
|
||
"MEL": {Lat: -37.669, Lon: 144.841},
|
||
}
|
||
|
||
func (s *Server) handleIATACoords(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, IataCoordsResponse{Coords: iataCoords})
|
||
}
|
||
|
||
func (s *Server) handleAudioLabBuckets(w http.ResponseWriter, r *http.Request) {
|
||
buckets := map[string][]AudioLabPacket{}
|
||
|
||
if s.store != nil {
|
||
// Use in-memory store (matches Node.js pktStore.packets approach)
|
||
s.store.mu.RLock()
|
||
byType := map[string][]*StoreTx{}
|
||
for _, tx := range s.store.packets {
|
||
if tx.RawHex == "" {
|
||
continue
|
||
}
|
||
typeName := "UNKNOWN"
|
||
if tx.DecodedJSON != "" {
|
||
var d map[string]interface{}
|
||
if err := json.Unmarshal([]byte(tx.DecodedJSON), &d); err == nil {
|
||
if t, ok := d["type"].(string); ok && t != "" {
|
||
typeName = t
|
||
}
|
||
}
|
||
}
|
||
if typeName == "UNKNOWN" && tx.PayloadType != nil {
|
||
if name, ok := payloadTypeNames[*tx.PayloadType]; ok {
|
||
typeName = name
|
||
}
|
||
}
|
||
byType[typeName] = append(byType[typeName], tx)
|
||
}
|
||
s.store.mu.RUnlock()
|
||
|
||
for typeName, pkts := range byType {
|
||
sort.Slice(pkts, func(i, j int) bool {
|
||
return len(pkts[i].RawHex) < len(pkts[j].RawHex)
|
||
})
|
||
count := min(8, len(pkts))
|
||
picked := make([]AudioLabPacket, 0, count)
|
||
for i := 0; i < count; i++ {
|
||
idx := (i * len(pkts)) / count
|
||
tx := pkts[idx]
|
||
pt := 0
|
||
if tx.PayloadType != nil {
|
||
pt = *tx.PayloadType
|
||
}
|
||
picked = append(picked, AudioLabPacket{
|
||
Hash: strOrNil(tx.Hash),
|
||
RawHex: strOrNil(tx.RawHex),
|
||
DecodedJSON: strOrNil(tx.DecodedJSON),
|
||
ObservationCount: max(tx.ObservationCount, 1),
|
||
PayloadType: pt,
|
||
PathJSON: strOrNil(tx.PathJSON),
|
||
ObserverID: strOrNil(tx.ObserverID),
|
||
Timestamp: strOrNil(tx.FirstSeen),
|
||
})
|
||
}
|
||
buckets[typeName] = picked
|
||
}
|
||
}
|
||
|
||
writeJSON(w, AudioLabBucketsResponse{Buckets: buckets})
|
||
}
|
||
|
||
// --- Helpers ---
|
||
|
||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||
log.Printf("[routes] JSON encode error: %v", err)
|
||
}
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(code)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||
}
|
||
|
||
func queryInt(r *http.Request, key string, def int) int {
|
||
v := r.URL.Query().Get(key)
|
||
if v == "" {
|
||
return def
|
||
}
|
||
n, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func mergeMap(base map[string]interface{}, overlays ...map[string]interface{}) map[string]interface{} {
|
||
result := make(map[string]interface{})
|
||
for k, v := range base {
|
||
result[k] = v
|
||
}
|
||
for _, o := range overlays {
|
||
if o == nil {
|
||
continue
|
||
}
|
||
for k, v := range o {
|
||
result[k] = v
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func safeAvg(total, count float64) float64 {
|
||
if count == 0 {
|
||
return 0
|
||
}
|
||
return round(total/count, 1)
|
||
}
|
||
|
||
func round(val float64, places int) float64 {
|
||
m := 1.0
|
||
for i := 0; i < places; i++ {
|
||
m *= 10
|
||
}
|
||
return float64(int(val*m+0.5)) / m
|
||
}
|
||
|
||
func percentile(sorted []float64, p float64) float64 {
|
||
if len(sorted) == 0 {
|
||
return 0
|
||
}
|
||
idx := int(float64(len(sorted)) * p)
|
||
if idx >= len(sorted) {
|
||
idx = len(sorted) - 1
|
||
}
|
||
return sorted[idx]
|
||
}
|
||
|
||
func sortedCopy(arr []float64) []float64 {
|
||
cp := make([]float64, len(arr))
|
||
copy(cp, arr)
|
||
sort.Float64s(cp)
|
||
return cp
|
||
}
|
||
|
||
func lastN(arr []map[string]interface{}, n int) []map[string]interface{} {
|
||
if len(arr) <= n {
|
||
return arr
|
||
}
|
||
return arr[len(arr)-n:]
|
||
}
|
||
|
||
// mapSliceToTransmissions converts []map[string]interface{} to []TransmissionResp
|
||
// for type-safe JSON encoding. Used during transition from map-based to struct-based responses.
|
||
func mapSliceToTransmissions(maps []map[string]interface{}) []TransmissionResp {
|
||
result := make([]TransmissionResp, 0, len(maps))
|
||
for _, m := range maps {
|
||
tx := TransmissionResp{
|
||
Hash: strVal(m["hash"]),
|
||
FirstSeen: strVal(m["first_seen"]),
|
||
Timestamp: strVal(m["first_seen"]),
|
||
}
|
||
if v, ok := m["id"].(int); ok {
|
||
tx.ID = v
|
||
}
|
||
tx.RawHex = m["raw_hex"]
|
||
tx.RouteType = m["route_type"]
|
||
tx.PayloadType = m["payload_type"]
|
||
tx.PayloadVersion = m["payload_version"]
|
||
tx.DecodedJSON = m["decoded_json"]
|
||
if v, ok := m["observation_count"].(int); ok {
|
||
tx.ObservationCount = v
|
||
}
|
||
tx.ObserverID = m["observer_id"]
|
||
tx.ObserverName = m["observer_name"]
|
||
tx.ObserverIATA = m["observer_iata"]
|
||
tx.SNR = m["snr"]
|
||
tx.RSSI = m["rssi"]
|
||
tx.PathJSON = m["path_json"]
|
||
tx.Direction = m["direction"]
|
||
tx.Score = m["score"]
|
||
result = append(result, tx)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// mapSliceToObservations converts []map[string]interface{} to []ObservationResp.
|
||
func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp {
|
||
result := make([]ObservationResp, 0, len(maps))
|
||
for _, m := range maps {
|
||
obs := ObservationResp{}
|
||
if v, ok := m["id"].(int); ok {
|
||
obs.ID = v
|
||
}
|
||
obs.TransmissionID = m["transmission_id"]
|
||
obs.Hash = m["hash"]
|
||
obs.ObserverID = m["observer_id"]
|
||
obs.ObserverName = m["observer_name"]
|
||
obs.ObserverIATA = m["observer_iata"]
|
||
obs.SNR = m["snr"]
|
||
obs.RSSI = m["rssi"]
|
||
obs.PathJSON = m["path_json"]
|
||
obs.ResolvedPath = m["resolved_path"]
|
||
obs.Direction = m["direction"]
|
||
obs.RawHex = m["raw_hex"]
|
||
obs.Timestamp = m["timestamp"]
|
||
result = append(result, obs)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func strVal(v interface{}) string {
|
||
if v == nil {
|
||
return ""
|
||
}
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
// hopCandidatesToConflicts converts typed candidates to interface slice for JSON.
|
||
func hopCandidatesToConflicts(candidates []HopCandidate) []interface{} {
|
||
result := make([]interface{}, len(candidates))
|
||
for i, c := range candidates {
|
||
result[i] = c
|
||
}
|
||
return result
|
||
}
|
||
|
||
// nullFloatVal extracts float64 from sql.NullFloat64, returning 0 if null.
|
||
func nullFloatVal(n sql.NullFloat64) float64 {
|
||
if n.Valid {
|
||
return n.Float64
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (s *Server) handleObserverMetrics(w http.ResponseWriter, r *http.Request) {
|
||
id := mux.Vars(r)["id"]
|
||
since := r.URL.Query().Get("since")
|
||
until := r.URL.Query().Get("until")
|
||
resolution := r.URL.Query().Get("resolution")
|
||
|
||
// Default to last 24h if no since provided
|
||
if since == "" {
|
||
since = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
||
}
|
||
|
||
// Validate resolution
|
||
if resolution == "" {
|
||
resolution = "5m"
|
||
}
|
||
switch resolution {
|
||
case "5m", "1h", "1d":
|
||
// valid
|
||
default:
|
||
writeError(w, 400, "invalid resolution: "+resolution+". Must be 5m, 1h, or 1d")
|
||
return
|
||
}
|
||
|
||
// Sample interval (default 300s = 5min)
|
||
sampleInterval := 300
|
||
|
||
metrics, reboots, err := s.db.GetObserverMetrics(id, since, until, resolution, sampleInterval)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
if metrics == nil {
|
||
metrics = []MetricsSample{}
|
||
}
|
||
if reboots == nil {
|
||
reboots = []string{}
|
||
}
|
||
|
||
// Get observer name
|
||
obs, _ := s.db.GetObserverByID(id)
|
||
var name *string
|
||
if obs != nil {
|
||
name = obs.Name
|
||
}
|
||
|
||
writeJSON(w, map[string]interface{}{
|
||
"observer_id": id,
|
||
"observer_name": name,
|
||
"reboots": reboots,
|
||
"metrics": metrics,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleMetricsSummary(w http.ResponseWriter, r *http.Request) {
|
||
window := r.URL.Query().Get("window")
|
||
if window == "" {
|
||
window = "24h"
|
||
}
|
||
region := r.URL.Query().Get("region")
|
||
|
||
// Parse window duration
|
||
dur, err := parseWindowDuration(window)
|
||
if err != nil {
|
||
writeError(w, 400, "invalid window: "+window)
|
||
return
|
||
}
|
||
|
||
since := time.Now().UTC().Add(-dur).Format(time.RFC3339)
|
||
summary, err := s.db.GetMetricsSummary(since)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
if summary == nil {
|
||
summary = []MetricsSummaryRow{}
|
||
}
|
||
|
||
// Filter by region if specified
|
||
if region != "" {
|
||
filtered := make([]MetricsSummaryRow, 0)
|
||
for _, row := range summary {
|
||
if strings.EqualFold(row.IATA, region) {
|
||
filtered = append(filtered, row)
|
||
}
|
||
}
|
||
summary = filtered
|
||
}
|
||
|
||
writeJSON(w, map[string]interface{}{
|
||
"observers": summary,
|
||
})
|
||
}
|
||
|
||
// parseWindowDuration parses strings like "24h", "3d", "7d", "30d".
|
||
func parseWindowDuration(window string) (time.Duration, error) {
|
||
if strings.HasSuffix(window, "d") {
|
||
daysStr := strings.TrimSuffix(window, "d")
|
||
days, err := strconv.Atoi(daysStr)
|
||
if err != nil || days <= 0 {
|
||
return 0, fmt.Errorf("invalid days: %s", daysStr)
|
||
}
|
||
return time.Duration(days) * 24 * time.Hour, nil
|
||
}
|
||
return time.ParseDuration(window)
|
||
}
|
||
|
||
// handleAdminPrune was removed in #1283. Prune now runs in the ingestor
|
||
// process (server is read-only). The function and route are gone.
|
||
|
||
// constantTimeEqual compares two strings in constant time to prevent timing attacks.
|
||
func constantTimeEqual(a, b string) bool {
|
||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||
}
|
||
|
||
// filterBlacklistedFromTopology removes blacklisted + hidden-prefix node
|
||
// references (#1181) from the topology analytics response (TopRepeaters,
|
||
// TopPairs, BestPathList, MultiObsNodes, PerObserverReach).
|
||
func (s *Server) filterBlacklistedFromTopology(data map[string]interface{}) map[string]interface{} {
|
||
// Filter TopRepeaters
|
||
if repeaters, ok := data["topRepeaters"]; ok {
|
||
if arr, ok := repeaters.([]TopRepeater); ok {
|
||
var filtered []TopRepeater
|
||
for _, r := range arr {
|
||
if pk, ok := r.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||
continue
|
||
}
|
||
if name, ok := r.Name.(string); ok && s.cfg.IsNameHidden(name) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, r)
|
||
}
|
||
data["topRepeaters"] = filtered
|
||
}
|
||
}
|
||
|
||
// Filter TopPairs
|
||
if pairs, ok := data["topPairs"]; ok {
|
||
if arr, ok := pairs.([]TopPair); ok {
|
||
var filtered []TopPair
|
||
for _, p := range arr {
|
||
if pkA, ok := p.PubkeyA.(string); ok && s.cfg.IsBlacklisted(pkA) {
|
||
continue
|
||
}
|
||
if pkB, ok := p.PubkeyB.(string); ok && s.cfg.IsBlacklisted(pkB) {
|
||
continue
|
||
}
|
||
if nameA, ok := p.NameA.(string); ok && s.cfg.IsNameHidden(nameA) {
|
||
continue
|
||
}
|
||
if nameB, ok := p.NameB.(string); ok && s.cfg.IsNameHidden(nameB) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, p)
|
||
}
|
||
data["topPairs"] = filtered
|
||
}
|
||
}
|
||
|
||
// Filter BestPathList
|
||
if paths, ok := data["bestPathList"]; ok {
|
||
if arr, ok := paths.([]BestPathEntry); ok {
|
||
var filtered []BestPathEntry
|
||
for _, p := range arr {
|
||
if pk, ok := p.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||
continue
|
||
}
|
||
if pk, ok := p.Pubkey.(string); ok && s.isPubkeyHidden(pk) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, p)
|
||
}
|
||
data["bestPathList"] = filtered
|
||
}
|
||
}
|
||
|
||
// Filter MultiObsNodes
|
||
if nodes, ok := data["multiObsNodes"]; ok {
|
||
if arr, ok := nodes.([]MultiObsNode); ok {
|
||
var filtered []MultiObsNode
|
||
for _, n := range arr {
|
||
if pk, ok := n.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||
continue
|
||
}
|
||
if name, ok := n.Name.(string); ok && s.cfg.IsNameHidden(name) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, n)
|
||
}
|
||
data["multiObsNodes"] = filtered
|
||
}
|
||
}
|
||
|
||
// Filter PerObserverReach
|
||
if reach, ok := data["perObserverReach"]; ok {
|
||
if m, ok := reach.(map[string]*ObserverReach); ok {
|
||
for k, v := range m {
|
||
for ri := range v.Rings {
|
||
var filteredNodes []ReachNode
|
||
for _, rn := range v.Rings[ri].Nodes {
|
||
if pk, ok := rn.Pubkey.(string); ok && s.cfg.IsBlacklisted(pk) {
|
||
continue
|
||
}
|
||
if name, ok := rn.Name.(string); ok && s.cfg.IsNameHidden(name) {
|
||
continue
|
||
}
|
||
filteredNodes = append(filteredNodes, rn)
|
||
}
|
||
v.Rings[ri].Nodes = filteredNodes
|
||
}
|
||
m[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
return data
|
||
}
|
||
|
||
// filterBlacklistedFromSubpaths removes blacklisted node references from
|
||
// the subpaths analytics response.
|
||
func (s *Server) filterBlacklistedFromSubpaths(data map[string]interface{}) map[string]interface{} {
|
||
if subpaths, ok := data["subpaths"]; ok {
|
||
if arr, ok := subpaths.([]interface{}); ok {
|
||
var filtered []interface{}
|
||
for _, item := range arr {
|
||
if m, ok := item.(map[string]interface{}); ok {
|
||
if hops, ok := m["hops"].([]interface{}); ok {
|
||
skip := false
|
||
for _, h := range hops {
|
||
if hp, ok := h.(string); ok && (s.cfg.IsBlacklisted(hp) || s.isPubkeyHidden(hp)) {
|
||
skip = true
|
||
break
|
||
}
|
||
}
|
||
if skip {
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
filtered = append(filtered, item)
|
||
}
|
||
data["subpaths"] = filtered
|
||
}
|
||
}
|
||
return data
|
||
}
|
||
|
||
// handleDroppedPackets returns recently dropped packets for investigation.
|
||
func (s *Server) handleDroppedPackets(w http.ResponseWriter, r *http.Request) {
|
||
limit := queryLimit(r, 100, s.cfg.ListLimits.PacketsMax)
|
||
observerID := r.URL.Query().Get("observer")
|
||
nodePubkey := r.URL.Query().Get("pubkey")
|
||
|
||
results, err := s.db.GetDroppedPackets(limit, observerID, nodePubkey)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, results)
|
||
}
|
||
|
||
func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) {
|
||
const scopeStatsTTL = 30 * time.Second
|
||
|
||
window := r.URL.Query().Get("window")
|
||
if window == "" {
|
||
window = "24h"
|
||
}
|
||
if window != "1h" && window != "24h" && window != "7d" {
|
||
writeError(w, 400, "window must be 1h, 24h, or 7d")
|
||
return
|
||
}
|
||
|
||
s.scopeStatsMu.Lock()
|
||
if s.scopeStatsCache != nil {
|
||
if cached, ok := s.scopeStatsCache[window]; ok && time.Since(s.scopeStatsCachedAt[window]) < scopeStatsTTL {
|
||
s.scopeStatsMu.Unlock()
|
||
writeJSON(w, cached)
|
||
return
|
||
}
|
||
}
|
||
s.scopeStatsMu.Unlock()
|
||
|
||
resp, err := s.db.GetScopeStats(window)
|
||
if err != nil {
|
||
writeError(w, 500, err.Error())
|
||
return
|
||
}
|
||
|
||
s.scopeStatsMu.Lock()
|
||
if s.scopeStatsCache == nil {
|
||
s.scopeStatsCache = make(map[string]*ScopeStatsResponse)
|
||
s.scopeStatsCachedAt = make(map[string]time.Time)
|
||
}
|
||
s.scopeStatsCache[window] = resp
|
||
s.scopeStatsCachedAt[window] = time.Now()
|
||
s.scopeStatsMu.Unlock()
|
||
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
// handlePruneGeoFilter identifies (dry_run=true, default) or enqueues (confirm=true)
|
||
// deletion of nodes whose GPS coordinates fall outside the currently configured
|
||
// geo_filter. Nodes with no GPS fix are always kept. Requires geo_filter to be
|
||
// configured.
|
||
//
|
||
// Since #1283/#1289 the server opens SQLite read-only, so the actual DELETE is
|
||
// performed by the ingestor. The server writes a request marker file (see
|
||
// internal/prunequeue); the ingestor's maintenance loop consumes it and writes a
|
||
// result marker. The confirm response is 202 Accepted with a request id;
|
||
// clients poll GET /api/admin/prune-geo-filter/status?id=<id> for completion.
|
||
//
|
||
// Confirm requires the pubkeys from the preview in the request body to prevent
|
||
// TOCTOU races: only nodes that were shown in preview AND are still outside the
|
||
// filter are enqueued.
|
||
func (s *Server) handlePruneGeoFilter(w http.ResponseWriter, r *http.Request) {
|
||
gf := s.getGeoFilter()
|
||
if gf == nil || len(gf.Polygon) < 3 {
|
||
writeError(w, http.StatusBadRequest, "no geo_filter configured")
|
||
return
|
||
}
|
||
|
||
nodes, err := s.db.GetNodesForGeoPrune()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "db error")
|
||
return
|
||
}
|
||
|
||
type nodeResult struct {
|
||
PubKey string `json:"pubkey"`
|
||
Name string `json:"name"`
|
||
Lat *float64 `json:"lat"`
|
||
Lon *float64 `json:"lon"`
|
||
}
|
||
|
||
var outside []nodeResult
|
||
for _, n := range nodes {
|
||
if n.Lat == nil || n.Lon == nil {
|
||
continue // no GPS — always keep
|
||
}
|
||
if !NodePassesGeoFilter(*n.Lat, *n.Lon, gf) {
|
||
outside = append(outside, nodeResult{PubKey: n.PubKey, Name: n.Name, Lat: n.Lat, Lon: n.Lon})
|
||
}
|
||
}
|
||
|
||
if r.URL.Query().Get("confirm") != "true" {
|
||
// Dry run — return preview without enqueueing anything.
|
||
writeJSON(w, map[string]interface{}{
|
||
"dryRun": true,
|
||
"count": len(outside),
|
||
"nodes": outside,
|
||
})
|
||
return
|
||
}
|
||
|
||
// Confirmed enqueue — require pubkeys from the preview to prevent TOCTOU:
|
||
// only nodes that were shown in preview AND are still outside the filter
|
||
// at this exact moment are scheduled for deletion. (The ingestor honors
|
||
// the list verbatim; it does NOT re-evaluate geo_filter membership.)
|
||
var body struct {
|
||
Pubkeys []string `json:"pubkeys"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Pubkeys) == 0 {
|
||
writeError(w, http.StatusBadRequest, "confirm requires pubkeys from preview in request body")
|
||
return
|
||
}
|
||
allowed := make(map[string]bool, len(body.Pubkeys))
|
||
for _, pk := range body.Pubkeys {
|
||
allowed[pk] = true
|
||
}
|
||
|
||
var toDelete []nodeResult
|
||
for _, n := range outside {
|
||
if allowed[n.PubKey] {
|
||
toDelete = append(toDelete, n)
|
||
}
|
||
}
|
||
|
||
pubkeys := make([]string, 0, len(toDelete))
|
||
for _, n := range toDelete {
|
||
pubkeys = append(pubkeys, n.PubKey)
|
||
}
|
||
|
||
id := prunequeue.NewID()
|
||
req := prunequeue.Request{
|
||
ID: id,
|
||
RequestedAt: time.Now().UTC(),
|
||
Reason: "geo-prune",
|
||
Pubkeys: pubkeys,
|
||
}
|
||
if err := prunequeue.WriteRequest(s.db.path, req); err != nil {
|
||
log.Printf("[geo-prune] failed to enqueue request %s: %v", id, err)
|
||
writeError(w, http.StatusInternalServerError, "failed to enqueue prune request")
|
||
return
|
||
}
|
||
log.Printf("[geo-prune] enqueued request %s for %d node(s) (queue dir=%s)",
|
||
id, len(pubkeys), prunequeue.QueueDir(s.db.path))
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusAccepted)
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"dryRun": false,
|
||
"accepted": true,
|
||
"requestId": id,
|
||
"count": len(pubkeys),
|
||
"nodes": toDelete,
|
||
"statusUrl": "/api/admin/prune-geo-filter/status?id=" + id,
|
||
})
|
||
}
|
||
|
||
// handlePruneGeoFilterStatus reports the state of a previously-enqueued
|
||
// geo-prune request. While the request marker is still present the response is
|
||
// {"status":"pending"}. Once the ingestor writes a result, the response is
|
||
// {"status":"done","deleted":N,"completedAt":...} (or "error" if the ingestor
|
||
// failed). Returns 404 when neither marker nor result is found.
|
||
func (s *Server) handlePruneGeoFilterStatus(w http.ResponseWriter, r *http.Request) {
|
||
id := r.URL.Query().Get("id")
|
||
if id == "" {
|
||
writeError(w, http.StatusBadRequest, "missing id")
|
||
return
|
||
}
|
||
|
||
res, err := prunequeue.ReadResult(s.db.path, id)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "invalid prune request id") {
|
||
writeError(w, http.StatusBadRequest, "invalid id")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "status read failed")
|
||
return
|
||
}
|
||
if res != nil {
|
||
status := "done"
|
||
if res.Error != "" {
|
||
status = "error"
|
||
}
|
||
writeJSON(w, map[string]interface{}{
|
||
"requestId": res.ID,
|
||
"status": status,
|
||
"deleted": res.Deleted,
|
||
"requestedAt": res.RequestedAt,
|
||
"completedAt": res.CompletedAt,
|
||
"error": res.Error,
|
||
})
|
||
return
|
||
}
|
||
|
||
pending, err := prunequeue.RequestExists(s.db.path, id)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "invalid prune request id") {
|
||
writeError(w, http.StatusBadRequest, "invalid id")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "status read failed")
|
||
return
|
||
}
|
||
if pending {
|
||
writeJSON(w, map[string]interface{}{
|
||
"requestId": id,
|
||
"status": "pending",
|
||
})
|
||
return
|
||
}
|
||
writeError(w, http.StatusNotFound, "unknown request id")
|
||
}
|
||
|
||
// handlePutConfigGeoFilter writes the geo_filter config to disk and updates the
|
||
// in-memory pointer atomically. Empty/missing polygon clears the filter.
|
||
//
|
||
// Backstop validation: ≤1000 points, every point in [-90,90]/[-180,180], no
|
||
// NaN/Inf; bufferKm finite, non-negative, ≤ 20000 km. Concurrent PUTs are
|
||
// serialized via s.saveMu so they cannot race on the .tmp file.
|
||
func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap
|
||
|
||
var body struct {
|
||
Polygon [][2]float64 `json:"polygon"`
|
||
BufferKm float64 `json:"bufferKm"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||
return
|
||
}
|
||
|
||
if len(body.Polygon) > 0 && len(body.Polygon) < 3 {
|
||
writeError(w, http.StatusBadRequest, "polygon must have at least 3 points")
|
||
return
|
||
}
|
||
if len(body.Polygon) > 1000 {
|
||
writeError(w, http.StatusBadRequest, "polygon must have at most 1000 points")
|
||
return
|
||
}
|
||
for _, pt := range body.Polygon {
|
||
if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) || math.IsInf(pt[0], 0) || math.IsInf(pt[1], 0) ||
|
||
pt[0] < -90 || pt[0] > 90 || pt[1] < -180 || pt[1] > 180 {
|
||
writeError(w, http.StatusBadRequest, "polygon point out of range: lat must be in [-90,90], lon in [-180,180]")
|
||
return
|
||
}
|
||
}
|
||
if math.IsNaN(body.BufferKm) || math.IsInf(body.BufferKm, 0) ||
|
||
body.BufferKm < 0 || body.BufferKm > 20000 {
|
||
writeError(w, http.StatusBadRequest, "bufferKm must be a finite number in [0, 20000]")
|
||
return
|
||
}
|
||
|
||
var gf *GeoFilterConfig
|
||
if len(body.Polygon) >= 3 {
|
||
gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm}
|
||
}
|
||
|
||
s.saveMu.Lock()
|
||
if s.configDir != "" {
|
||
if err := SaveGeoFilter(s.configDir, gf); err != nil {
|
||
s.saveMu.Unlock()
|
||
log.Printf("[geofilter] save failed: %v", err)
|
||
writeError(w, http.StatusInternalServerError, "failed to save config")
|
||
return
|
||
}
|
||
}
|
||
s.setGeoFilter(gf)
|
||
s.saveMu.Unlock()
|
||
|
||
if gf != nil {
|
||
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
|
||
} else {
|
||
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
|
||
}
|
||
}
|