Adam 6d56f55168 fix(nodes): place nodes in regions by zero-hop home regions (#1879) (#2190)
Red commit: cd546b23 (fails on assertions, e.g. `region "SAT": got
[pk_aus pk_edge pk_far], want [pk_edge pk_far]`)
Red commit (fewest-hops fallback): a66a7f36 (fails on assertions, e.g.
`region "AUS": got [pk_cap pk_gulf pk_near pk_tie], want [pk_cap]`)
Red commits (review round 1): d4154ee1 (`region_placement` missing),
862a4510 (`zero: RegionFallbackMaxHops() = 3, want 0`), 16dcd8dd
(`pk_mixed:{fewest_hops [AUS] hops=0}`, want `[SAT] hops=2`), 95f2aca4
(`pk_late not placed after the startup load finished`)

Fixes #1879

## What this does

`/api/nodes?region=` put a node in every region where any observer heard
any of its adverts. Adverts flood, so a node showed up in every region
its adverts got relayed into, not just where it actually is.

Now, as agreed in the issue:

- **Nodes heard zero-hop are placed by their home regions.** Same rule
as #804: a zero-hop DIRECT advert can't have been relayed, so the
observer that heard it is in RF range. This is the tally
`computeNodeHomeRegions()` already does, pulled out into
`nodeZeroHopRegionCounts()` so analytics and the filter share one
definition.
- **It's a set, not a winner.** A node heard zero-hop in two regions is
in both. So per-region counts don't add up to the node total anymore, on
purpose.
- **Everything else goes by fewest hops.** A node never heard zero-hop
is in the region(s) whose observers heard it in the fewest hops, and in
no region when even that is more than `regionFallback.maxHops` (config,
default 3); it still shows unfiltered. The old fallback (every region
that heard it at all) turned out to be most of the leak: see the numbers
at the bottom. Hearings with no `path_json` (legacy NULL rows) are
unknown, not zero hops, and are skipped.
- **Only nodes the last recompute didn't cover** (first advert newer
than it, or no hearing with a path) still use the old observer check.
- **`region_placement` on every node row** (list and detail) says which
rule placed it, so an operator can tell the four outcomes apart:

  ```json
  {"basis": "zero_hop", "regions": ["AUS"]}
  {"basis": "fewest_hops", "regions": ["HOU", "SAT"], "hops": 2}
  {"basis": "none", "hops": 5}
  {"basis": "observer_scan"}
  ```

It's a named struct (`NodeRegionPlacement`), documented as its own
OpenAPI component. It replaces the earlier `home_regions` field.

## How

- `node_home_regions.go`: a recomputer in the `bridge_recomputer.go`
shape (sync prewarm, ticker on `AnalyticsDefaultRecomputeInterval()`,
atomic snapshot, panics swallowed), started from `main.go` next to the
bridge one. It also recomputes once when `StartupLoadDone()` closes,
like `recomputeWhenLoaded` does for the analytics recomputers, because
the prewarm only sees the first chunk.
- Each recompute builds one snapshot, pubkey → `NodeRegionPlacement`,
from `nodeZeroHopRegionCounts` plus `nodeFewestHopsByRegion`. Hop counts
come from `path_json` without decoding (`pathHopCount`: hop hashes hold
no commas).
- `RegionNodePubkeys` loads the snapshot (atomic, no lock), skips placed
nodes in the observer scan, then adds the ones whose regions intersect
the request. The 30 s region cache is unchanged.
- `computeNodeHomeRegions()` keeps its plurality output for analytics;
only the counting moved. The #804 / #1025 tests are unchanged and pass.
- No new `map[string]interface{}`: the node rows set `region_placement`
to the struct, the OpenAPI component uses a small typed `openAPISchema`,
and the old `enrichNodeHomeRegions` helper is gone. Touched files are at
the upstream count.

## Performance

- Request path: one atomic load plus map lookups. `RegionNodePubkeys`
adds one pass over the snapshot per cache miss, so O(nodes), well under
the existing advert scan it sits next to. `BenchmarkRegionNodePubkeys`
stays ~19 ms.
- Recompute, off the request path, once per interval under `s.mu.RLock`:
`BenchmarkRecomputeNodeHomeRegionSets` times the whole function at 220k
adverts × 8 observations over 0-7 hop paths (one in eight zero-hop
direct) plus 1M channel messages: **~54 ms/op** on an Apple M5 (Docker,
linux/arm64), of which the fewest-hops pass
(`BenchmarkNodeFewestHopsByRegion`) is ~40 ms.
- That's after two small speedups in 3dc3cb7d: `nodeZeroHopRegionCounts`
walks `byPayloadType[ADVERT]` instead of every packet (same counts), and
the fewest-hops pass looks up a node's map once per advert instead of
once per observation. Before them the whole recompute was ~72 ms on the
same machine.

## Tests

- `TestRegionNodePubkeysUsesZeroHopHomeRegions`: a node whose relayed
advert reached SAT stays out of SAT; the boundary node is in both; the
never-zero-hop node is placed by the fallback.
- `TestBoundaryNodeStaysInEachHomeRegion`: the two-region case on its
own, so a later refactor can't quietly collapse it back to one winner.
- `TestNodeRegionPlacementsZeroHop`: the snapshot for zero-hop and
fallback nodes.
- `TestHandleNodesRegionPlacement`: `/api/nodes` returns `zero_hop`,
`none` and `observer_scan` placements for seeded nodes.
- `TestRegionFallbackUsesFewestHops`: fewest hops wins, ties keep both,
exactly the cap is placed, past it is in no region (and the observer
scan doesn't re-add it), a closer later advert counts.
- `TestRegionFallbackMaxHopsConfig`: the config accessor (unset or
negative keeps 3, 0 allowed, parsed from JSON) and the recompute
honoring it.
- `TestRegionFallbackIgnoresUnknownPaths`: an empty `path_json` doesn't
count as zero hops; `[]` still does.
- `TestRegionFilterObserverScanForNodesNewerThanRecompute`: the
observer-scan path for a node newer than the snapshot.
- `TestRegionFilterListingEveryRegionOmitsUnplacedNodes`: naming every
region explicitly still leaves out a node in no region.
- `TestNodeRegionsRecomputeWhenStartupLoadDone`: placements cover a
later chunk once the startup load is done.
- `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test
./...` pass (`CGO_ENABLED=1`). `gofmt` and `go vet` are clean.

No UI change; the API change is covered by the handler test above.

## Config

- `regionFallback.maxHops` (default 3), documented in
`config.example.json`.
- Per AGENTS.md rule 8, this should be exposed in the customizer in a
later milestone. Not done here.

## Not in this PR

- **Transport-direct zero-hop adverts aren't counted as zero-hop**
(pre-existing, `nodeZeroHopRegionCounts`): for
`ROUTE_TYPE_TRANSPORT_DIRECT`, `RawHex[2:4]` is the first transport-code
byte, not `path_len`. The fallback still places such nodes (their
`path_json` is `[]`), but as `fewest_hops`, not `zero_hop`. I'll send
that as its own PR since it changes #804 analytics too.
- **Memory window.** The answer only covers adverts still in the packet
store, so quiet nodes fall back. That's the long-tail point from the
thread. A `node_regions` table kept by the ingestor (the
`UpdateNodeDefaultScope` pattern) would remove the window; happy to do
that as a follow-up if you want it.
- **One-off long-range hearings.** A single zero-hop hearing during a
ducting event still adds that region. On our deployment we run the same
rule with a 20% share threshold per region (ignore a region holding
under a fifth of a node's direct hearings), which drops those. Easy to
add here if you'd like it.
- **Flood adverts heard with an empty path** are direct evidence too,
and much more common than zero-hop DIRECT adverts. Left out of the
zero-hop rule to keep one definition with #804; the fallback places them
at 0 hops.
- **Moving #804 / #1025 analytics to the set version**, as you
suggested, after this lands.

**Fallback numbers.** On our network only ~27% of nodes are ever heard
directly, so the fallback decides most of the filter. With "every region
that heard it", our San Antonio filter showed 241 nodes whose GPS puts
them outside SAT against 149 inside, and Austin picked up Louisiana and
Gulf Coast nodes heard 3-10 hops out. We've been running the
fewest-hops/cap-3 rule in production since 2026-10-14; nodes with GPS
outside the selected region went SAT 241 → 7, DFW 76 → 7, HOU 47 → 2,
AUS 226 → 48. About 240 nodes are in no region (they still show
unfiltered).

For context, on our network (Texas, ~1,500 nodes) the same idea with the
broader "heard directly" rule took San Antonio from 543 nodes to 482.
The 61 removed were all Austin-area nodes whose adverts get relayed
south, and only 3 nodes statewide are heard directly in two regions.
2026-10-15 16:31:00 +02:00
2026-04-05 06:36:03 +00:00
2026-03-20 05:38:23 +00:00

CoreScope

Go Server Coverage Go Ingestor Coverage E2E Tests Frontend Coverage Deploy

High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.

Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.

⚡ Performance

The Go backend serves its packet and analytics API from an in-memory packet store with 7 indexes (hash, txID, obsID, observer, node, path hop, payload type). SQLite is for persistence only — reads never touch disk.

Metric Value
Packet queries < 1 ms (in-memory)
All API endpoints < 100 ms
Memory (56K packets) ~300 MB (vs 1.3 GB on Node.js)
WebSocket broadcast Real-time to all connected browsers
Channel decryption AES-128-ECB with rainbow table
GOMEMLIMIT (memory-constrained hosts) set to ≥1.5× working set (e.g. 1536 MiB on a 2 GB Pi for a ~1 GB store). Lower values trigger a GC death-spiral. Configure via the GOMEMLIMIT env var or runtime.maxMemoryMB in config.json; env wins. Applies to both server and ingestor. See #1010.

See PERFORMANCE.md for full benchmarks.

✨ Features

📡 Live Trace Map

Real-time animated map with packet route visualization, VCR-style playback controls, and a retro LCD clock. Replay the last 24 hours of mesh activity, scrub through the timeline, or watch packets flow live at up to 8× speed.

Live VCR playback — watch packets flow across the Bay Area mesh

📦 Packet Feed

Filterable real-time packet stream with byte-level breakdown, Excel-like resizable columns, and a detail pane. Toggle "My Nodes" to focus on your mesh.

Packets view

🗺️ Network Overview

At-a-glance mesh stats — node counts, packet volume, observer coverage.

Network overview

📊 Node Analytics

Per-node deep dive with interactive charts: activity timeline, packet type breakdown, SNR distribution, hop count analysis, peer network graph, and hourly heatmap.

Node analytics

💬 Channel Chat

Decoded group messages with sender names, @mentions, timestamps — like reading a Discord channel for your mesh.

Channels

📱 Mobile Ready

Full experience on your phone — proper touch controls, iOS safe area support, and a compact VCR bar.

Live view on iOS

And More

  • 17 Analytics Tabs — RF, topology, channels, hash stats, distance, route patterns, scopes, and more
  • Node Directory — searchable list with role tabs, detail panel, QR codes, advert timeline
  • Packet Tracing — follow individual packets across observers with SNR/RSSI timeline
  • Observer Status — health monitoring, packet counts, uptime, per-observer analytics
  • Hash Collision Matrix — detect address collisions across the mesh
  • Scope Audit: per repeater, which declared regions show no forwarding and which scopes it forwards without declaring them
  • Channel Key Auto-Derivation — hashtag channels (#channel) keys derived via SHA256
  • Multi-Broker MQTT — connect to multiple brokers with per-source IATA filtering
  • Dark / Light Mode — auto-detects system preference, map tiles swap too
  • Theme Customizer — design your theme in-browser, export as theme.json
  • Global Search — search packets, nodes, and channels (Ctrl+K)
  • Shareable URLs — deep links to packets, channels, and observer detail pages
  • Protobuf API Contract — typed API definitions in proto/
  • Accessible — ARIA patterns, keyboard navigation, screen reader support

Optional Features

Off by default, enabled in config.json:

  • User Accounts: registration with email activation, settings sync, an admin area, hashtag channel proposals, mail notifications for watched nodes, and a personal data export. The dashboard stays public. See docs/user-guide/accounts.md
  • Mobile RX Coverage: crowdsourced coverage from phones connected to a companion radio, drawn as hex bins on the RX Coverage page and on each node's Reach page. See docs/client-rx-coverage.md

Quick Start

No build step required — just run:

docker run -d --name corescope \
  --restart=unless-stopped \
  -p 80:80 -p 1883:1883 \
  -v /your/data:/app/data \
  ghcr.io/kpa-clawbot/corescope:latest

Open http://localhost — done. No config file needed; CoreScope starts with sensible defaults.

For HTTPS with a custom domain, add -p 443:443 and mount your Caddyfile:

docker run -d --name corescope \
  --restart=unless-stopped \
  -p 80:80 -p 443:443 -p 1883:1883 \
  -v /your/data:/app/data \
  -v /your/Caddyfile:/etc/caddy/Caddyfile:ro \
  -v /your/caddy-data:/data/caddy \
  ghcr.io/kpa-clawbot/corescope:latest

Disable built-in services with -e DISABLE_MOSQUITTO=true or -e DISABLE_CADDY=true, or drop a .env file in your data volume. See docs/deployment.md for the full reference.

Build from Source

git clone https://github.com/Kpa-clawbot/CoreScope.git
cd CoreScope
./manage.sh setup

The setup wizard walks you through config, domain, HTTPS, build, and run.

./manage.sh status       # Health check + packet/node counts
./manage.sh logs         # Follow logs
./manage.sh backup       # Backup database
./manage.sh update       # Pull latest + rebuild + restart
./manage.sh mqtt-test    # Check if observer data is flowing
./manage.sh help         # All commands

Configure

Copy config.example.json to config.json and edit:

{
  "port": 3000,
  "mqtt": {
    "broker": "mqtt://localhost:1883",
    "topic": "meshcore/+/+/packets"
  },
  "mqttSources": [
    {
      "name": "remote-feed",
      "broker": "mqtts://remote-broker:8883",
      "topics": ["meshcore/+/+/packets"],
      "username": "user",
      "password": "pass",
      "iataFilter": ["SJC", "SFO", "OAK"]
    }
  ],
  "channelKeys": {
    "public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
  },
  "defaultRegion": "SJC"
}
Field Description
port HTTP server port (default: 3000)
mqtt.broker Local MQTT broker URL ("" to disable)
mqttSources External MQTT broker connections (optional)
channelKeys Channel decryption keys (hex). Hashtag channels auto-derived via SHA256
defaultRegion Default IATA region code for the UI
dbPath SQLite database path (default: data/meshcore.db)

Environment Variables

Variable Description
DB_PATH Override SQLite database path
GOMEMLIMIT Go soft memory limit, wins over runtime.maxMemoryMB

The HTTP port is set with port in config.json or the server's -port flag.

Architecture

                           ┌─────────────────────────────────────────────┐
                           │              Docker Container               │
                           │                                             │
Observer → USB →           │  Mosquitto ──→ Go Ingestor ──→ SQLite DB   │
  meshcoretomqtt → MQTT ──→│                    │                        │
                           │              Go HTTP Server ──→ WebSocket   │
                           │                    │               │        │
                           │              Caddy (HTTPS) ←───────┘        │
                           └────────────────────┼────────────────────────┘
                                                │
                                             Browser

Two-process model: The Go ingestor handles MQTT ingestion and packet decoding. The Go HTTP server loads all packets into an in-memory store on startup (5 indexes for fast lookups) and serves the REST API + WebSocket broadcast. Both are managed by supervisord inside a single container with Caddy for HTTPS and Mosquitto for local MQTT.

MQTT Setup

  1. Flash an observer node with MESH_PACKET_LOGGING=1 build flag
  2. Connect via USB to a host running meshcoretomqtt
  3. Configure meshcoretomqtt with your IATA region code and MQTT broker address
  4. Packets appear on topic meshcore/{IATA}/{PUBKEY}/packets

To decode a single raw hex packet without storing it, use POST /api/decode.

Project Structure

corescope/
├── cmd/
│   ├── server/              # Go HTTP server + WebSocket + REST API
│   │   ├── main.go          # Entry point
│   │   ├── routes.go        # 40+ API endpoint handlers
│   │   ├── store.go         # In-memory packet store (5 indexes)
│   │   ├── db.go            # SQLite persistence layer
│   │   ├── decoder.go       # MeshCore packet decoder
│   │   ├── websocket.go     # WebSocket broadcast
│   │   └── *_test.go        # Server tests
│   └── ingestor/            # Go MQTT ingestor
│       ├── main.go          # MQTT subscription + packet processing
│       ├── decoder.go       # Packet decoder (shared logic)
│       ├── db.go            # SQLite write path
│       └── *_test.go        # Ingestor tests
├── proto/                   # Protobuf API definitions
├── public/                  # Vanilla JS frontend (no build step)
│   ├── index.html           # SPA shell
│   ├── app.js               # Router, WebSocket, utilities
│   ├── packets.js           # Packet feed + hex breakdown
│   ├── map.js               # Leaflet map + route visualization
│   ├── live.js              # Live trace + VCR playback
│   ├── channels.js          # Channel chat
│   ├── nodes.js             # Node directory + detail views
│   ├── analytics.js         # Analytics dashboard
│   └── style.css            # CSS variable theming (light/dark)
├── docker/
│   ├── supervisord-go.conf  # Process manager (server + ingestor)
│   ├── mosquitto.conf       # MQTT broker config
│   ├── Caddyfile            # Reverse proxy + HTTPS
│   └── entrypoint-go.sh     # Container entrypoint
├── Dockerfile               # Multi-stage Go build + Alpine runtime
├── config.example.json      # Example configuration
├── tests/                   # Node.js test suite: unit/ (test-all.sh) and e2e/ (Playwright)
├── test-all.sh              # Runs every suite in tests/unit
└── tools/                   # Generators, E2E tests, utilities

For Developers

Building

make build        # all four binaries for your machine, into dist/
make build-server # just one
make crossbuild   # static linux/amd64 + linux/arm64 binaries

The SQLite driver is mattn/go-sqlite3, which is cgo, so a plain GOOS=linux go build from a Mac will not work: cross-compiling needs a C compiler that can target the other platform. make crossbuild uses zig as that compiler (install it and it just works) and links statically against musl, so the result is one self-contained file that runs on Alpine or scratch. The container build does the same thing — see Dockerfile.

Test Suite

Go tests cover the backend, Node.js unit tests in tests/unit/ cover the frontend logic, and Playwright tests in tests/e2e/ validate the UI in a browser.

# Go backend tests
cd cmd/server && go test ./... -v
cd cmd/ingestor && go test ./... -v

# Or across all 16 modules at once
make test

# Node.js frontend + integration tests
npm test

# Playwright E2E (requires running server on localhost:3000)
node tests/e2e/test-e2e-playwright.js

Generate Test Data

node tools/generate-packets.js --api --count 200

Migrating from Node.js

If you're running an existing Node.js deployment, see docs/go-migration.md for a step-by-step guide. The Go engine reads the same SQLite database and config.json — no data migration needed.

Contributing

Contributions welcome. Please read AGENTS.md for coding conventions, testing requirements, and engineering principles before submitting a PR.

Live instance: analyzer.on8ar.eu: all read endpoints are public, no auth required.

API Documentation: CoreScope auto-generates an OpenAPI 3.0 spec. Browse the interactive Swagger UI at /api/docs or fetch the machine-readable spec at /api/spec.

License

GPL-3.0-or-later

S
Description
No description provided
Readme GPL-3.0
137 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 48.1%
Go 46.5%
CSS 2.9%
Shell 1.6%
HTML 0.6%
Other 0.2%