mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-09 18:17:20 +00:00
Part A of #2128: optional, off-by-default user accounts. With the feature off nothing changes; with it on, visitors can register and log in, and admins manage users and use the operator actions without the API key. PR #2130 (settings sync) builds on this one. The two are meant to be merged together. ## The situation - Operator actions (geofilter save and prune, backup, perf reset) need the shared `apiKey`. There is no per-person right. - Nothing in CoreScope knows who a visitor is, so the requests in #2128 that need that (#1835, #2092, #1508, #730) have nothing to build on. ## What this PR adds **Two new Go modules** - `internal/users`: a separate `users.db` (SQLite through `modernc.org/sqlite`) with users, sessions, single-use tokens, an audit log and a mail log. Passwords use argon2id. - `internal/mailer`: a `Mailer` interface with a Brevo client (send, delivery events, webhook parsing) and an in-memory fake for tests. **Server (`cmd/server`)**, active only with `userManagement.enabled` - 24 routes, all documented in OpenAPI under the `users` tag ([`auth_routes.go`](https://github.com/efiten/CoreScope/blob/feat/user-management/cmd/server/auth_routes.go)): - auth: register, activate, login, logout, me, forgot, reset; - account: profile, password, email change with confirmation, sessions, self-delete; - admin: list, detail, disable, enable, delete, role, resend activation, manual activation, mail status refresh; - a Brevo webhook, registered only when `mail.webhookSecret` is set. - `requireAdmin` replaces `requireAPIKey` at the 7 operator call sites: the API key **or** an admin session. With the feature off it is the old API-key gate (`TestRequireAdminWithoutUserManagementIsAPIKeyGate`). - `/api/config/client` gets `userManagement: {enabled: true}` only when the service started; with the feature off the response is byte-identical. **Frontend** - `auth.js` (header account control, request helper that adds the CSRF header), `account.js` (login, register, activate, forgot, reset, confirm email, my account), `admin-users.js` (`#/admin/users`, deep-linked filters), `account.css` (theme tokens only). - On phones the top-bar control is hidden, so a conditional entry goes into the bottom-nav "More" sheet and the nav drawer. - The customizer geofilter tab and the Perf "Reset stats" button use the admin session when there is one. **Config.** A `userManagement` block (`config.example.json`, [`docs/user-guide/accounts.md`](https://github.com/efiten/CoreScope/blob/feat/user-management/docs/user-guide/accounts.md)). The Brevo key can come from `CORESCOPE_BREVO_API_KEY`. The server refuses to start when the block is enabled but incomplete. ## Security choices - Session cookie `cs_session`: HttpOnly, SameSite=Lax, Secure when `publicBaseUrl` is https. Every cookie-authenticated state change needs the `X-CS-CSRF` header and a matching Origin. - Activation needs the token **and** the account password. Without the password, an attacker who keeps re-registering a known address could get the owner to activate an account that carries the attacker's password. - Register, forgot and email change answer identically for known and unknown addresses. A password reset ends all sessions, a password change ends all other sessions, and both end outstanding email-change links. - Rate limits: login 10 per 15 minutes, register and forgot 5 per hour, per IP and per address. The bucket count is capped. `trustedProxies` makes the per-IP limits see real client IPs behind a proxy. - Server logs carry `#<user id>`, never addresses, tokens or passwords; mail-provider error texts are redacted before logging. ## Performance No change to an existing hot path with the feature off. With it on: - One `users.db` lookup per authenticated request (session by token hash). - The admin user table rebuilds its `tbody` on each filter change. `users.List` caps the result at 1000 rows (`internal/users/users.go`), which bounds the rebuild. - `map[string]interface{}` in `openapi.go`: 79 before, 78 after. ## Verification - `internal/users`, `internal/mailer` and `cmd/server`: `go vet` and `go test -race` pass locally. 121 new Go tests. - `cmd/server` with `-tags e2etest`: vet and the e2e hook tests pass. - `sh test-all.sh` exits 0. `tests/unit/test-user-management-ui.js`: 67 passing (vm, real modules). - `tests/e2e/test-user-management-e2e.js` (6 steps) passed locally against an `e2etest` build with the fake mailer and against a feature-off build. CI builds the `e2etest` binary and runs the suite on a second server (`deploy.yml`). - On a staging instance with a real Brevo key: register, activation mail delivered, activate, admin table, "Refresh status" showing sent, deferred, delivered, opened and clicked. ## Not in this PR - Settings sync (#2130), the admin dashboard, approval flows and notifications (parts B to E of #2128). - A `requireReadAuth` mode (#1835). Sessions from this PR are what such a mode would accept. - Binary size and build time with `modernc.org/sqlite` linked next to `mattn/go-sqlite3` were not measured. Their driver names do not collide. #1992 discusses the driver choice. - No Brevo webhook was configured on staging; delivery status there came from "Refresh status". --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
453 lines
38 KiB
JSON
453 lines
38 KiB
JSON
{
|
||
"port": 3000,
|
||
"apiKey": "your-secret-api-key-here",
|
||
"nodeBlacklist": [],
|
||
"_comment_nodeBlacklist": "Public keys of nodes to hide from all API responses. Use for trolls, offensive names, or nodes reporting false data that operators refuse to fix.",
|
||
"hiddenNamePrefixes": ["🚫"],
|
||
"_comment_hiddenNamePrefixes": "Node name prefixes that mark a node as hidden from this dashboard (#1181). Mirrors a convention used by other MeshCore map dashboards: an operator who wants their node hidden renames it to start with one of these prefixes and sends an advert; the next advert is dropped from /api/nodes, /api/nodes/search and /api/nodes/{pubkey}. DB rows are preserved so observation history (paths, hops, distances) stays intact for analytics. The node is NOT hidden from the mesh itself — only from this dashboard. Set to [] to disable. Default: [\"🚫\"].",
|
||
"observerIATAWhitelist": [],
|
||
"_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources.",
|
||
"retention": {
|
||
"nodeDays": 7,
|
||
"observerDays": 14,
|
||
"observerPurgeDays": 0,
|
||
"packetDays": 30,
|
||
"clientRxDays": 30,
|
||
"clientRxObsDays": 30,
|
||
"clientRfDays": 30,
|
||
"clientRegionsDays": 30,
|
||
"_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). observerPurgeDays: observers already marked inactive AND not seen in N days are hard-deleted, but only when no observation, observer_metrics or dropped_packets row still references them (0 = disabled, the default). Set it above both observerDays and packetDays — below those the reference guards keep every row anyway. packetDays: transmissions older than N days are deleted (0 = disabled). clientRxDays: mobile client-RX coverage rows (client_receptions/client_observers) older than N days are deleted (0 = disabled) — bounds the opt-in coverage tables (#1727). clientRxObsDays: diagnostic client_rx_observations rows (by rx_at) older than N days are deleted (0 = disabled) — bounds the opt-in clientRxObservations table. clientRfDays: RF environment sample rows (client_rf_samples, by sampled_at) older than N days are deleted (0 = disabled) — bounds the opt-in clientRfSamples table; unset means unbounded growth at roughly 240 rows/hour per driver. clientRegionsDays: region-discovery answer rows (node_declared_regions, by observed_at) older than N days are deleted (0 = disabled) — bounds the opt-in clientRegions table. NOTE (#1283): all retention fields are consumed by the INGESTOR process. The server is read-only and never prunes."
|
||
},
|
||
"db": {
|
||
"vacuumOnStartup": false,
|
||
"incrementalVacuumPages": 1024,
|
||
"load": {
|
||
"chunkSize": 10000,
|
||
"_comment": "chunkSize: rows fetched per chunk by PacketStore.LoadChunked during startup (#1009). Default 10000. Lower values surface the early-HTTP-readiness signal sooner (the listener binds after the first chunk) at the cost of more SQL round-trips. Higher values reduce per-chunk overhead but delay first-chunk readiness. The X-CoreScope-Load-Status response header reports loading|ready and progress=<rows> until the load completes."
|
||
},
|
||
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs. Executed by the INGESTOR at startup, BEFORE the MQTT subscriber starts (#1283), so there is no contention with concurrent writes. Blocks ingestor startup for minutes on large DBs; requires 2x DB file size in free disk space. incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919. load.chunkSize: see nested _comment (#1009).",
|
||
"_comment_slowWriterMs": "#1340 — SQLite writer-lock log threshold (default 500). Any wrapped writer call (tagged neighbor_builder, mqtt_handler, prune_packets, prune_observers, prune_metrics, vacuum) whose hold_ms exceeds this emits a single [db-slow-writer] log line. Configured per-process via the CORESCOPE_DB_SLOW_WRITER_MS environment variable on the INGESTOR (e.g. CORESCOPE_DB_SLOW_WRITER_MS=200 for tighter alerting). Per-component wait_ms / hold_ms / contention_total histograms are surfaced via /api/perf/write-sources under .writer_perf regardless of this threshold."
|
||
},
|
||
"listLimits": {
|
||
"packetsMax": 10000,
|
||
"nodesMax": 2000,
|
||
"analyticsMax": 200,
|
||
"channelMessagesMax": 500,
|
||
"bulkHealthMax": 200,
|
||
"_comment": "Maximum row counts returned by list API endpoints. These enforce a DoS-bounded ceiling for both UI and external requests. Operators with small/embedded deployments can tighten these; operators running large regional meshes can raise them. bulkHealthMax is intentionally separate from nodesMax: /api/nodes/bulk-health is per-row much heavier than /api/nodes (it joins per-node observer health + recent-packet stats) so its ceiling stays low (default 200) even if nodesMax is raised."
|
||
},
|
||
"_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.",
|
||
"corsAllowedOrigins": [],
|
||
"_comment_corsAllowedOrigins": "Cross-origin allowlist for embed scenarios (#1369) AND for /ws WebSocket upgrades (#1793 — CSWSH defense per OWASP WebSocket Security Cheat Sheet). Exact-match origins, e.g. [\"https://blog.example.com\", \"https://embed.example.com\"]. Same-origin requests (Origin host == request Host) and non-browser clients (no Origin header) are always allowed for /ws. When empty (default), no Access-Control-* headers are sent and browsers enforce same-origin; cross-origin /ws upgrades are rejected. When non-empty, only the listed origins receive CORS headers, and Access-Control-Allow-Methods is limited to GET, HEAD, OPTIONS (the cross-domain surface is read-only — same-origin admin writes are unaffected). Use [\"*\"] to allow any origin for CORS XHR (NOT recommended for write-capable deployments); note that \"*\" is deliberately NOT honored for /ws upgrades — list explicit origins to permit cross-origin WebSocket clients (OWASP guidance: avoid wildcards in CSWSH defense). Operators can override per-deployment with the CORS_ALLOWED_ORIGINS environment variable (comma-separated). No credentialed CORS is enabled. To embed the map or channels pages cross-domain, add the embedding origin here and use the URL pattern '/#/map?embed=1' or '/#/channels?embed=1' — embed mode hides the top-nav, bottom-nav, and side drawer for full-bleed iframe rendering.",
|
||
"webSocket": {
|
||
"maxConnsPerIP": 0,
|
||
"_comment_maxConnsPerIP": "#1794 - concurrent /ws connections allowed from one client address. 0 = off, and that is the shipped default ON PURPOSE: carrier-grade NAT puts thousands of unrelated mobile subscribers behind one public IPv4, so a low cap refuses real visitors while a scraper simply rents more addresses. Set it only if you know your audience is not behind shared NAT.",
|
||
"upgradesPerMinPerIP": 30,
|
||
"_comment_upgradesPerMinPerIP": "#1794 - handshakes per minute from one client address. On by default. Safe under CGNAT: a real client upgrades a handful of times per minute even while reconnecting, so 30 leaves ordinary traffic alone while flattening a scraper reconnect loop. Set 0 to disable.",
|
||
"trustedProxies": [],
|
||
"_comment_trustedProxies": "#1794 - addresses or CIDRs of YOUR reverse proxy. REQUIRED for the per-IP limits to do anything behind nginx/Caddy/Traefik/ingress: without it every visitor shares the proxy's address, so the limits are skipped and one warning is logged at startup. X-Forwarded-For is believed only from these addresses, because anyone else can forge it. Never list an address you do not control.",
|
||
"deny": [],
|
||
"_comment_deny": "#1794 - addresses or CIDRs refused at the /ws upgrade, before the handshake. Accepts both \"1.2.3.4\" and \"1.2.3.0/24\". Applies even behind an unconfigured proxy, since it is your explicit instruction. Rejections are counted in /api/stats under websocket.rejectedDeny."
|
||
},
|
||
"https": {
|
||
"cert": "/path/to/cert.pem",
|
||
"key": "/path/to/key.pem",
|
||
"_comment": "TLS cert/key paths for direct HTTPS. Most deployments use Caddy (included in Docker) for auto-TLS instead."
|
||
},
|
||
"branding": {
|
||
"siteName": "CoreScope",
|
||
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
|
||
"logoUrl": null,
|
||
"faviconUrl": null,
|
||
"homeUrl": null,
|
||
"_comment": "Customize site name, tagline, logo, and favicon. logoUrl/faviconUrl can be absolute URLs or relative paths. homeUrl (#1518) overrides the navbar logo link target — set to an absolute http(s):// URL for operators embedding CoreScope inside a larger site, or a '#'-prefixed app route (e.g. '#/home') to keep it in-app. Validator rejects javascript:, data:, vbscript:, file:, about:, protocol-relative '//', and bare paths to block XSS. Cross-origin URLs open in the SAME tab (no target=_blank); wrap with your own anchor if you need new-tab behavior. The mobile bottom-nav 🏠 button is intentionally NOT overridden — it stays in-app to preserve SPA back-stack on phones."
|
||
},
|
||
"theme": {
|
||
"accent": "#4a9eff",
|
||
"accentHover": "#6db3ff",
|
||
"navBg": "#0f0f23",
|
||
"navBg2": "#1a1a2e",
|
||
"navActiveBg": "rgba(74,158,255,0.15)",
|
||
"statusGreen": "#45644c",
|
||
"statusYellow": "#b08b2d",
|
||
"statusRed": "#b54a4a",
|
||
"_comment": "CSS color overrides. Use the in-app Theme Customizer for live preview, then export values here. navActiveBg (#1509) controls the background of the currently-active top-nav link (the 'pill'); accepts any CSS color, typically a translucent rgba() so the nav gradient shows through."
|
||
},
|
||
"nodeColors": {
|
||
"repeater": "#dc2626",
|
||
"companion": "#2563eb",
|
||
"room": "#16a34a",
|
||
"sensor": "#d97706",
|
||
"observer": "#8b5cf6",
|
||
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
|
||
},
|
||
"markerStroke": {
|
||
"color": "#fff",
|
||
"width": 2,
|
||
"opacity": 1,
|
||
"_comment": "#1488/#1506 — outline around each map marker (live + map pages). Defaults restored to v3.7.2 visual (solid white, 2px). 'color' accepts any CSS color (hex, rgb, rgba); use rgba() or drop 'opacity' below ~0.5 to soften the outline when hundreds of nodes feel overwhelming. 'width' is the SVG stroke width (0 hides the outline entirely). Operators can override per-browser via the in-app Theme Customizer (Colors tab → Marker Stroke)."
|
||
},
|
||
"map": {
|
||
"tiles": {
|
||
"darkDefault": "carto-dark",
|
||
"lightDefault": "carto-light",
|
||
"providers": {
|
||
"_comment_carto": "Carto is the default provider. Since 2026-08 CARTO requires an API key on its raster basemaps: without one every tile is served stamped 'API KEY REQUIRED' with a 200 status, so nothing errors and no healthcheck fires - verify by looking at a tile, not at a status code. Get a free key (no account, no card, 5M tile requests/month) at https://carto.com/basemaps/apikey and put it in 'key'. The query parameter is 'key'; 'api_key' is silently ignored and still serves the watermarked tile. WARNING: the key is sent to the browser; restrict it by domain in the CARTO dashboard. Optional: specify 'domain' for Carto enterprise (e.g. 'mycompany' for 'https://{s}.mycompany.cartocdn.com').",
|
||
"carto": {
|
||
"enabled": true,
|
||
"key": "",
|
||
"domain": ""
|
||
},
|
||
"_comment_osm": "OSM providers: 'mapbox', 'thunderforest', 'maptiler'. WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions in your provider dashboard.",
|
||
"osm": {
|
||
"enabled": false,
|
||
"provider": "",
|
||
"token": ""
|
||
},
|
||
"_comment_opentopomap": "OpenTopoMap topographical tile provider. Limited to zoom level 17.",
|
||
"opentopomap": {
|
||
"enabled": false
|
||
},
|
||
"_comment_stamen": "Stamen (hosted by Stadia). WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions.",
|
||
"stamen": {
|
||
"enabled": false,
|
||
"token": ""
|
||
},
|
||
"_comment_usgs": "USGS topographical tile provider. Limited to zoom level 16.",
|
||
"usgs": {
|
||
"enabled": false
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"home": {
|
||
"heroTitle": "CoreScope",
|
||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||
"steps": [
|
||
{
|
||
"emoji": "\ud83d\udce1",
|
||
"title": "Connect",
|
||
"description": "Link your node to the mesh"
|
||
},
|
||
{
|
||
"emoji": "\ud83d\udd0d",
|
||
"title": "Monitor",
|
||
"description": "Watch packets flow in real-time"
|
||
},
|
||
{
|
||
"emoji": "\ud83d\udcca",
|
||
"title": "Analyze",
|
||
"description": "Understand your network's health"
|
||
}
|
||
],
|
||
"checklist": [
|
||
{
|
||
"question": "How do I add my node?",
|
||
"answer": "Search for your node name or paste your public key."
|
||
},
|
||
{
|
||
"question": "What regions are covered?",
|
||
"answer": "Check the map page to see active observers and nodes."
|
||
}
|
||
],
|
||
"footerLinks": [
|
||
{
|
||
"label": "\ud83d\udce6 Packets",
|
||
"url": "#/packets"
|
||
},
|
||
{
|
||
"label": "\ud83d\uddfa\ufe0f Network Map",
|
||
"url": "#/map"
|
||
},
|
||
{
|
||
"label": "\ud83d\udd34 Live",
|
||
"url": "#/live"
|
||
},
|
||
{
|
||
"label": "\ud83d\udce1 All Nodes",
|
||
"url": "#/nodes"
|
||
},
|
||
{
|
||
"label": "\ud83d\udcac Channels",
|
||
"url": "#/channels"
|
||
}
|
||
],
|
||
"_comment": "Customize the landing page hero, onboarding steps, FAQ, and footer links."
|
||
},
|
||
"mqtt": {
|
||
"broker": "mqtt://localhost:1883",
|
||
"topic": "meshcore/+/+/packets",
|
||
"_comment": "Legacy single-broker config. Prefer mqttSources[] for multiple brokers."
|
||
},
|
||
"mqttSources": [
|
||
{
|
||
"name": "local",
|
||
"broker": "mqtt://localhost:1883",
|
||
"topics": [
|
||
"meshcore/+/+/packets",
|
||
"meshcore/#"
|
||
]
|
||
},
|
||
{
|
||
"name": "lincomatic",
|
||
"broker": "mqtts://mqtt.lincomatic.com:8883",
|
||
"username": "your-username",
|
||
"password": "your-password",
|
||
"rejectUnauthorized": false,
|
||
"topics": [
|
||
"meshcore/SJC/#",
|
||
"meshcore/SFO/#",
|
||
"meshcore/OAK/#",
|
||
"meshcore/MRY/#"
|
||
],
|
||
"iataFilter": [
|
||
"SJC",
|
||
"SFO",
|
||
"OAK",
|
||
"MRY"
|
||
],
|
||
"region": "SJC",
|
||
"connectTimeoutSec": 45,
|
||
"_comment_clientId": "Optional \"clientId\": the MQTT ClientID this source connects with. Unset (the default) means corescope-<name>-<random>, with the name reduced to letters, digits and '-' (broker host when name is empty) and a new random suffix on every ingestor start. Set it for a fixed identity in broker logs and ACLs, but never give two running ingestors the same value on one broker: they take over each other's session."
|
||
},
|
||
{
|
||
"_comment": "WebSocket MQTT broker (e.g. meshcore-mqtt-broker). Use ws:// for plain WebSocket or wss:// for TLS. Username/password supported.",
|
||
"name": "wsmqtt",
|
||
"broker": "wss://wsmqtt.example.com/mqtt",
|
||
"username": "corescope",
|
||
"password": "your-password",
|
||
"topics": [
|
||
"meshcore/#"
|
||
]
|
||
}
|
||
],
|
||
"channelKeys": {
|
||
"Public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||
},
|
||
"hashChannels": [
|
||
"#LongFast",
|
||
"#test",
|
||
"#sf",
|
||
"#wardrive",
|
||
"#yo",
|
||
"#bot",
|
||
"#queer",
|
||
"#bookclub",
|
||
"#shtf"
|
||
],
|
||
"healthThresholds": {
|
||
"infraDegradedHours": 24,
|
||
"infraSilentHours": 72,
|
||
"nodeDegradedHours": 1,
|
||
"nodeSilentHours": 24,
|
||
"relayActiveHours": 24,
|
||
"observerOnlineMinutes": 60,
|
||
"observerStaleMinutes": 1440,
|
||
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others. relayActiveHours: a repeater is shown as 'actively relaying' if its pubkey appeared as a path hop in a non-advert packet within this window (issue #662).",
|
||
"_comment_observerThresholds": "Observer health classification. Online: last_seen < observerOnlineMinutes ago. Stale: between Online and observerStaleMinutes. Offline: beyond observerStaleMinutes. Defaults 60 / 1440 (1h / 24h) match the node thresholds for consistency and eliminate flap on low-traffic / CDN-fronted instances (#1552). Operators who want the old aggressive 10-min Online threshold can set observerOnlineMinutes: 10."
|
||
},
|
||
"pathTrust": {
|
||
"minHashBytesForMapping": 1,
|
||
"_comment_pathTrust": "Minimum path-hash prefix length, in bytes, trusted as mapping/topology evidence (issue #1784). MeshCore path hops are hashed pubkey prefixes of 1, 2 or 3 bytes (firmware hash_size = (pathByte>>6)+1); valid range is 1-3. Default 1 keeps the pre-#1784 behaviour, where every prefix length counts. Set 2 to exclude 1-byte prefixes (256-value collision space, so a currently-unique match can still be a false positive on a dense mesh), or 3 to require the strongest evidence. NOTE: there is no UI control for this - it is config-only and needs a restart, and raising it can remove a large share of your neighbour-graph edges and resolved paths with nothing in the UI explaining why. This does not affect raw storage: packets and paths are always stored as received."
|
||
},
|
||
"defaultRegion": "SJC",
|
||
"mapDefaults": {
|
||
"center": [
|
||
37.45,
|
||
-122.0
|
||
],
|
||
"zoom": 9
|
||
},
|
||
"geo_filter": {
|
||
"polygon": [
|
||
[37.80, -122.52],
|
||
[37.80, -121.80],
|
||
[37.20, -121.80],
|
||
[37.20, -122.52]
|
||
],
|
||
"bufferKm": 20,
|
||
"_comment": "Optional. Restricts ingestion and API responses to nodes within the polygon + bufferKm. Polygon is an array of [lat, lon] pairs (minimum 3). Use the GeoFilter tab in the Customizer (requires apiKey) or the GeoFilter Builder (`/geofilter-builder.html`) to draw a polygon visually and export a config snippet. Remove this section to disable filtering. Nodes with no GPS fix are always allowed through."
|
||
},
|
||
"foreignAdverts": {
|
||
"mode": "flag",
|
||
"_comment": "Controls how the ingestor handles ADVERTs whose GPS is OUTSIDE the geo_filter polygon (#730). 'flag' (default): store the advert/node and tag it foreign_advert=1 so operators can see bridged/leaked nodes via the API ('foreign': true on /api/nodes). 'drop': legacy behavior — silently discard the advert (no log, no node row). Only applies when geo_filter is configured; otherwise has no effect."
|
||
},
|
||
"areas": {
|
||
"_comment": "Optional. GPS-based display filter. Each entry defines a geographic area by polygon ([lat, lon] pairs) or bounding box (latMin/latMax/lonMin/lonMax). Packets and nodes are attributed to an area based on the transmitting node's own GPS coordinates — not the observer's location. Areas appear as a filter pill bar in the dashboard. Remove this section to disable the area filter UI.",
|
||
"BAY": {
|
||
"label": "Bay Area",
|
||
"polygon": [
|
||
[37.90, -122.55],
|
||
[37.90, -121.75],
|
||
[37.25, -121.75],
|
||
[37.25, -122.55]
|
||
]
|
||
},
|
||
"SJC": {
|
||
"label": "San Jose",
|
||
"latMin": 37.20,
|
||
"latMax": 37.45,
|
||
"lonMin": -122.05,
|
||
"lonMax": -121.75
|
||
}
|
||
},
|
||
"regions": {
|
||
"SJC": "San Jose, US",
|
||
"SFO": "San Francisco, US",
|
||
"OAK": "Oakland, US",
|
||
"MRY": "Monterey, US"
|
||
},
|
||
"cacheTTL": {
|
||
"stats": 10,
|
||
"nodeDetail": 300,
|
||
"nodeHealth": 300,
|
||
"nodeList": 90,
|
||
"bulkHealth": 600,
|
||
"networkStatus": 600,
|
||
"observers": 300,
|
||
"channels": 15,
|
||
"channelMessages": 10,
|
||
"analyticsRF": 1800,
|
||
"_comment_analyticsRF": "TTL (seconds) for the shared analytics result cache. Backs /api/analytics/rf, /api/analytics/topology, /api/analytics/distance, /api/analytics/hash-sizes, /api/analytics/channels, /api/analytics/subpaths. Default 60s if unset (#1239) — distance analytics is viewed live during active analysis, so the default smooths cold-miss churn without freezing data. Lower with care; sub-15s values can cause repeated multi-second cold computes during heavy ingest.",
|
||
"analyticsTopology": 1800,
|
||
"analyticsChannels": 1800,
|
||
"analyticsHashSizes": 3600,
|
||
"analyticsSubpaths": 3600,
|
||
"analyticsSubpathDetail": 3600,
|
||
"nodeAnalytics": 60,
|
||
"nodeSearch": 10,
|
||
"invalidationDebounce": 30,
|
||
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
|
||
},
|
||
"liveMap": {
|
||
"propagationBufferMs": 5000,
|
||
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh.",
|
||
"maxNodes": 2000,
|
||
"_comment_maxNodes": "Maximum nodes the /live map fetches (and renders) in one page. Default 2000. Raise this on deployments that have measured perf headroom on mid-range mobile (heap + frame-time). Server clamps to [100, 20000]: misconfigured values are coerced silently. Also caps the matching /api/packets?limit fetch the live VCR-rewind code uses. Reporter: #1574."
|
||
},
|
||
"timestamps": {
|
||
"defaultMode": "ago",
|
||
"timezone": "local",
|
||
"formatPreset": "iso",
|
||
"customFormat": "",
|
||
"allowCustomFormat": false,
|
||
"_comment": "defaultMode: ago|local|iso. timezone: local|utc. formatPreset: iso|us|eu. customFormat: strftime-style (requires allowCustomFormat: true)."
|
||
},
|
||
"packetStore": {
|
||
"maxMemoryMB": 1024,
|
||
"estimatedPacketBytes": 450,
|
||
"retentionHours": 168,
|
||
"hotStartupHours": 0,
|
||
"_comment": "In-memory packet store. maxMemoryMB caps RAM usage. retentionHours: only packets younger than this are kept in memory (0 = unlimited). hotStartupHours: hours loaded synchronously at startup; background loader fills the remaining retentionHours window. 0 = disabled (loads full retentionHours synchronously, legacy behavior). Set to a positive value (e.g. 24) to reduce startup time on large DBs.",
|
||
"_comment_gomemlimit": "On startup the server reads GOMEMLIMIT from the environment if set; otherwise it derives a Go runtime soft memory limit of maxMemoryMB * 1.5 and applies it via debug.SetMemoryLimit. This forces aggressive GC under cgroup pressure so the process self-throttles before the kernel SIGKILLs it. To override, set GOMEMLIMIT explicitly (e.g. GOMEMLIMIT=850MiB). See issue #836."
|
||
},
|
||
"runtime": {
|
||
"maxMemoryMB": 0,
|
||
"_comment_runtime_maxMemoryMB": "Go soft memory limit (GOMEMLIMIT) in MiB applied via runtime/debug.SetMemoryLimit at startup. Precedence: GOMEMLIMIT env var > runtime.maxMemoryMB > packetStore.maxMemoryMB-derived (server only). 0 (default) preserves existing behavior. Set on memory-constrained deployments (2 GB Pi, 4 GB VMs) to trigger earlier GC and avoid container OOM-kill. Floor recommendation: ≥1.5× working set; setting below the working set causes a GC death-spiral. Applies to BOTH cmd/server and cmd/ingestor. See #1010."
|
||
},
|
||
"resolvedPath": {
|
||
"backfillHours": 24,
|
||
"_comment": "How far back (hours) the async backfill scans for observations with NULL resolved_path. Default: 24. Set higher to backfill older data, lower to speed up startup."
|
||
},
|
||
"ingestBufferSize": 50000,
|
||
"_comment_ingestBufferSize": "Number of MQTT messages held in memory while the single SQLite writer is blocked by a startup migration/prune (#1608). Drained once the write path is ready; bounded memory (small closure per item). 0 / omit / negative => default 50000 (applied by IngestBufferSizeOrDefault before NewIngestBuffer). A positive value below the default is honored as-is; sub-1 values reaching NewIngestBuffer directly clamp to 1 with a WARN log.",
|
||
"neighborGraph": {
|
||
"maxAgeDays": 5,
|
||
"maxEdgeKm": 500,
|
||
"cacheRecomputeIntervalSeconds": 300,
|
||
"_comment": "maxAgeDays: neighbor edges older than this many days are pruned on startup and daily. Default 5. maxEdgeKm: geo-implausibility filter — when both endpoints have GPS, edges with haversine distance > maxEdgeKm are rejected at build time to prevent disambiguator self-reinforcement on wide-geo MQTT deployments. Default 500 km (well above any plausible terrestrial LoRa hop). 0 ⇒ use default; set negative to disable the filter. Rejected count surfaces in /api/analytics/neighbor-graph stats. Issue #1228. cacheRecomputeIntervalSeconds: how often the background recomputer rebuilds the default-shape /api/analytics/neighbor-graph response (#1481 P0-1). Default 300 (5 min). Lower = fresher data, more CPU per minute. Issue #1483."
|
||
},
|
||
"observersCache": {
|
||
"ttlSeconds": 30,
|
||
"_comment": "TTL for the default-shape /api/observers response cache (#1481 P0-3). Default 30s. Lower = fresher data, more SQL pressure on the 1.9M-row observations table. TTL-boundary refills are collapsed via singleflight so concurrent requests cause exactly one SQL fill. Issue #1483."
|
||
},
|
||
"knownChannelsUrl": "",
|
||
"knownChannelsRefreshMs": 86400000,
|
||
"_comment_knownChannels": "Issue #1323. OPT-IN community-maintained hashtag-channels catalogue. Empty string (default) = DISABLED: no background HTTP fetch is started and GET /api/known-channels returns an empty snapshot. To enable, set knownChannelsUrl to a pinned-SHA URL such as \"https://raw.githubusercontent.com/marcelverdult/meshcore-channels/072bc25b6fc983aa2aa7e9d399a97a5f4899ea71/channels-by-country.json\". The URL should point at the channels-by-country.json shape: {generated_at, license, countries:{cc:[{channel,description,key?}]}}. Always pin to a specific commit SHA (not the moving 'main' branch) so a hostile or compromised future upstream commit cannot be silently fetched by your deployment; periodically bump the pin after reviewing upstream diffs. Fetched in the background every knownChannelsRefreshMs (default 86400000 = 24h). Stored in-memory only — no DB, no disk cache. Surfaced over GET /api/known-channels?region=XX. Cache is fail-soft: a failed refresh keeps the last-known snapshot in place. No credentials are sent.",
|
||
"batteryThresholds": {
|
||
"lowMv": 3300,
|
||
"criticalMv": 3000,
|
||
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
|
||
},
|
||
"_comment_mqttSources": "Each source connects to an MQTT broker. Supported schemes: mqtt:// (plain TCP), mqtts:// (TLS), ws:// (WebSocket), wss:// (WebSocket TLS). topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||
"compression": {
|
||
"gzip": false,
|
||
"websocket": false,
|
||
"level": 6,
|
||
"minSizeBytes": 1024,
|
||
"contentTypes": [
|
||
"application/json",
|
||
"application/javascript",
|
||
"application/xml",
|
||
"text/html",
|
||
"text/css",
|
||
"text/plain",
|
||
"image/svg+xml"
|
||
]
|
||
},
|
||
"_comment_compression": "Opt-in HTTP gzip middleware + WebSocket permessage-deflate. Both default to false — enable ONLY when your upstream reverse proxy is NOT already compressing. gzip: enables the gzipMiddleware wrapper around the HTTP handler. websocket: sets gorilla websocket Upgrader.EnableCompression. level: gzip compression level 1..9 (1=BestSpeed, 9=BestCompression, default 6). minSizeBytes: advisory minimum response size below which compression would not pay off. contentTypes: MIME allow-list — only responses with these Content-Type values are compressed. Already-compressed types (image/*, video/*, audio/*, application/zip, application/x-gzip, application/pdf, application/octet-stream) are always skipped, as are responses whose handler already set Content-Encoding. Omit contentTypes to use the built-in default allow-list.",
|
||
"clientRegions": { "enabled": false },
|
||
"_comment_clientRegions": "Opt-in region-discovery answers: a mobile companion asks each repeater in direct RF range which regions it declares flood-allowed, and relays the answer on meshcore/client/<pubkey>/regions, stored in node_declared_regions — the declared side of a comparison against the OBSERVED transported_scopes already surfaced elsewhere. Default OFF, independent of clientRxCoverage/clientRxObservations/clientRfSamples: a deployment can run any combination. TOP-LEVEL FLAG, same as clientRfSamples. An empty regions list is a valid, stored answer ('nothing flood-allowed'); a repeater that never answers produces no row at all — silence carries no meaning, since the repeater ignores non-DIRECT requests. GPS is optional here (unlike the coverage/RF streams): a declared-regions answer is meaningful even without a position, so a missing fix is stored as NULL lat/lon rather than dropping the row. Requires an ACL-capable broker binding meshcore/client/{pubkey}/regions to that publisher — see docs/client-regions.md. Retention: see retention.clientRegionsDays; leaving it unset means unbounded growth.",
|
||
"clientRxCoverage": { "enabled": false },
|
||
"_comment_clientRxCoverage": "Opt-in mobile client-RX coverage (corescope-rx companions publishing GPS-tagged receptions to meshcore/client/<pubkey>/packets). Default OFF: when disabled the ingestor writes no client_receptions, the /api/rx-coverage|rx-leaderboard|nodes/{pubkey}/rx-coverage endpoints 404, and the UI hides the Coverage dashboard + Reach overlay. Set enabled=true to turn it on. SINGLE FLAG, BOTH PROCESSES: the ingestor and server each parse this same config.json, so this one clientRxCoverage.enabled entry gates both the ingest write path and the read endpoints — set it once, not per-process. TRUST: the feature requires an ACL-capable broker binding meshcore/client/{pubkey}/packets to that publisher; without ACLs the companion GPS is spoofable (see docs/client-rx-coverage.md). Retention: see retention.clientRxDays. Companion app + setup: https://github.com/efiten/corescope-rx.",
|
||
"clientRxObservations": { "enabled": false },
|
||
"_comment_clientRxObservations": "Opt-in diagnostic RF observations: every decodable packet a companion heard, whether or not it is attributable to a directly-heard node (client_rx_observations — never a coverage source; see clientRxCoverage for that). Default OFF. TOP-LEVEL FLAG: this is its own top-level Config field, NOT nested inside clientRxCoverage — config loading is plain json.Unmarshal with no DisallowUnknownFields, so nesting it under clientRxCoverage is silently ignored. Gated behind clientRxCoverage being reachable in the ingest control flow, but the JSON key sits alongside it. Requires the companion app's fullRfLog flag to actually upload unattributable packets, or this table stays empty even when enabled — see corescope-rx README. Retention: see retention.clientRxObsDays.",
|
||
"clientRfSamples": { "enabled": false },
|
||
"_comment_clientRfSamples": "Opt-in RF environment samples: a mobile client's attached companion radio's own counters (noise floor, RX/TX airtime, CRC errors, packet totals) along its GPS track, published on meshcore/client/<pubkey>/rf and stored in client_rf_samples — feeds a noise-floor map, a channel-utilisation map, and a CRC-error-rate map. Default OFF, independent of clientRxCoverage and clientRxObservations: a deployment can run any combination. TOP-LEVEL FLAG, same as clientRxObservations. Requires an ACL-capable broker binding meshcore/client/{pubkey}/rf to that publisher — see docs/client-rf-samples.md. Retention: see retention.clientRfDays; leaving it unset means unbounded growth.",
|
||
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
|
||
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
|
||
"hashRegions": [
|
||
"#belgium",
|
||
"#eu"
|
||
],
|
||
"_comment_hashRegions": "Region names for scope matching on transport-route packets. Key = SHA256('#name')[:16]. Add any region names used by nodes in your network.",
|
||
"autoRegionKeys": { "enabled": false, "maxDerived": 256, "refreshMinutes": 15 },
|
||
"_comment_autoRegionKeys": "Opt-in: derive region keys from the region names nodes are CONFIRMED to be configured for, on top of the explicit hashRegions list above. Default OFF, and an absent block leaves behaviour unchanged. Sources: nodes.configured_scope (written by the observer /neighbors ingestion) and, where a deployment fills it, the optional node_declared_regions table. Solves the case where a repeater forwards a region this instance holds no key for: its traffic is stored unmatched, and anything comparing declared against observed reports the region as absent rather than unnameable. TOP-LEVEL FLAG, a sibling of hashRegions: config loading is plain json.Unmarshal with no DisallowUnknownFields, so nesting it elsewhere is silently ignored and the feature stays off with no error. maxDerived caps the derived tier (default 256): each key costs one HMAC per transport-scoped packet and raises the random 2-byte collision rate by 1/65536, and the match cannot be indexed because the code is an HMAC over the payload. Over the cap, names are kept by how many distinct nodes declare them, so a one-off local name is dropped before a region half the network uses. refreshMinutes is how often the derived tier is rebuilt (default 15).",
|
||
"_comment_defaultRegion": "IATA code shown by default in region filters.",
|
||
"_comment_mapDefaults": "Initial map center [lat, lon] and zoom level.",
|
||
"_comment_regions": "IATA code → display name mapping for the region filter UI. Each key is a 3-letter IATA code that an observer is tagged with (resolved priority: MQTT payload `region` field > topic-derived region > mqttSources.region). Observers without an IATA tag will not appear under any region filter — only under 'All Regions'. The region filter dropdown shows one entry per code listed here PLUS any extra IATA codes the server discovers from observers at runtime (so you can omit codes here and they will still be selectable, just labelled with the bare IATA code instead of a friendly name). Selecting 'All Regions' (or no region) returns results from every observer including those with no IATA tag; selecting one or more codes restricts results to packets observed by observers tagged with those codes. The reserved value 'All' (case-insensitive) is treated as 'no filter' on the server, so the URL ?region=All behaves identically to omitting the param. Issue #770.",
|
||
|
||
"analytics": {
|
||
"defaultIntervalSeconds": 300,
|
||
"recomputeIntervalSeconds": {
|
||
"topology": 300,
|
||
"rf": 300,
|
||
"distance": 300,
|
||
"channels": 300,
|
||
"hashCollisions": 300,
|
||
"hashSizes": 300,
|
||
"roles": 300,
|
||
"observersClockSkew": 300,
|
||
"nodesClockSkew": 300
|
||
},
|
||
"loraPreset": {
|
||
"freq": 869600000,
|
||
"bw": 62.5,
|
||
"sf": 8,
|
||
"cr": 5,
|
||
"_comment_": "Issue #1768. LoRa PHY preset assumed by the Relay Airtime Share metric to compute true Time-on-Air. Share numbers are only meaningful relative to one preset, so operators MUST set this to match their mesh — freq is informational (surfaces in the chart caption) and does not affect ToA; bw is bandwidth in kHz (e.g. 62.5, 125, 250); sf is spreading factor (6..12); cr is the denominator of the 4/N coding rate (5 ⇒ 4/5 … 8 ⇒ 4/8). Defaults reproduce the typical EU MeshCore deployment 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5. CRC=1, IH=0, DE (T_sym ≥ 16 ms), and preamble (32 for SF≤8, 16 otherwise, per firmware preambleLengthForSF) are firmware-fixed and intentionally not exposed as config."
|
||
}
|
||
},
|
||
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.",
|
||
"userManagement": {
|
||
"enabled": false,
|
||
"dbPath": "",
|
||
"adminEmails": ["operator@example.org"],
|
||
"publicBaseUrl": "https://corescope.example.org",
|
||
"sessionDays": 30,
|
||
"trustedProxies": [],
|
||
"mail": {
|
||
"provider": "brevo",
|
||
"brevoApiKey": "",
|
||
"fromEmail": "noreply@example.org",
|
||
"fromName": "CoreScope",
|
||
"webhookSecret": ""
|
||
}
|
||
},
|
||
"_comment_userManagement": "Optional accounts (off by default; see docs/user-guide/accounts.md). When enabled: visitors can register with email + password and activate through a mailed link; addresses in adminEmails become admins; admins manage users at #/admin/users and can use the operator endpoints without the apiKey. The dashboard stays public. dbPath defaults to users.db next to the analyzer database: a separate file, the server never writes measurement data. publicBaseUrl is required and is used for every link in every mail. Mail goes through Brevo's transactional API (a free account is enough); brevoApiKey and webhookSecret may instead come from CORESCOPE_BREVO_API_KEY and CORESCOPE_BREVO_WEBHOOK_SECRET. Set webhookSecret (16+ chars) and point a Brevo transactional webhook at <publicBaseUrl>/api/mail/brevo/webhook with bearer auth to see delivery status per user. trustedProxies (CIDRs) lets login rate limits use X-Forwarded-For behind a reverse proxy. Startup fails if enabled with an incomplete mail setup.",
|
||
"customizer": {
|
||
"disabledTabs": [],
|
||
"_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)."
|
||
}
|
||
}
|