efitenandClaude Opus 5.5 60af9bd469 fix(hops): narrow only the directly heard hop to the observer region (#2098) (#2188)
Fixes #2098.

## Problem

`HopResolver.resolve()` narrowed every hop of a path to the observer's
IATA region (300 km around the code's coordinates). Only the last hop
was heard by the observer directly; every earlier hop can be anywhere
the mesh reaches, so the region says nothing about it.

This stayed invisible on deployments whose observer codes were missing
from `/api/iata-coords`: there `nodeInRegion()` returns `null` and
nothing is filtered. A Belgian deployment's 42 observers use 10 codes
and none was in the table. Filling the table alone would have switched
on a filter that is wrong for every hop but the last.

## Why only the last hop

#2098 lists three options and argues against simply repopulating the
table, because the filter it would switch on is not sound. This PR does
fill the table, but only after limiting the filter to the one hop where
it is sound:

- In a MeshCore path the last hop is the repeater the observer received
the packet from, so it is within radio range of the observer. Measured
below: p99 30 km for directly heard multi-byte hops.
- An earlier hop says nothing about the observer's position. On the
Belgian deployment nearly every observer hears nearly every flood, so a
region filter on earlier hops would drop the right candidate whenever
the packet started outside the observer's region.

This was decided while preparing the PR, not on the issue thread, so the
reasoning is recorded here.

## Change

- **`public/hop-resolver.js`:** `resolve()` takes an optional
`opts.directHops`, the hops the observer heard directly. Without it, the
input is one path and its last hop is the direct one. The region check
runs only on direct hops. An earlier hop gets `regional: false` and,
with an observer, `regionSkipped: true`. `globalFallback` now means
checked against the region with no candidate in it, so it is only set on
a direct hop.
- **`public/hop-display.js`:** the badge counts every candidate for
`globalFallback` or `regionSkipped`. The dashed underline and the
popover's "(global fallback)" stay for `globalFallback` only, so an
earlier hop is not labelled as matched outside the region when no region
check ran on it.
- **`public/packets.js`:** `resolveHopsForPackets()` resolves the last
hop of each path apart from the earlier hops. `hopCacheKey(h,
observerId, direct)` keeps the two in separate cache entries, because
the same prefix can be the last hop in one packet and a relayed hop in
another. `renderPath()` reads the direct entry for the path's last hop,
and the detail-pane resolve writes it. The region-narrowed entry never
goes to the bare (observer-less) key, in the list or in the detail pane.
The detail pane resolves and caches the shown observation's path with
that observation's observer (`effectivePkt.observer_id`), the observer
`renderPath` already used; before, it resolved with the packet row's
observer, so when the first observation came from another observer the
last hop missed its direct entry and showed unfiltered. The route button
uses the same observer.
- **`cmd/server/routes.go`:** `iataCoords` gains 104 entries from
OurAirports `airports.csv` (public domain): every IATA code in Belgium,
the Netherlands and Luxembourg, and the airports with scheduled service
in Germany and France. AMS, CDG, FRA and MUC were already present and
keep their values.

## Measured before building

On 5000 packets from the Belgian deployment (3661 with a path):

- 1-byte last hops: 1212, all ambiguous, 1204 of them without a server
`resolved_path`, so they go through the client resolver.
- Distance from the observer to a directly heard node, on 1892 unique
multi-byte last hops: p95 25 km, p99 30 km, max 82 km.
- With this change, on 906 1-byte last hops from observers whose code is
now in the table: the badge counts 6.1 candidates on average instead of
6.6. The displayed name changes for 52, all from observers without their
own lat/lon, where distance to the region now orders the candidates
instead of list order. For observers with a position, no displayed name
changes, because the #2097 observer anchor already picked the nearest
candidate.

## Tests

- `tests/unit/test-issue-2098-last-hop-region.js` (new, in
`test-all.sh`, 25 assertions):
- HopResolver: the last hop of a path is narrowed and an earlier hop is
not; an earlier hop keeps every candidate and reports `regionSkipped`,
not `globalFallback`; a direct hop with no candidate in region reports
`globalFallback`; `directHops` overrides the position; a one-hop path is
narrowed.
- HopDisplay (real file): an earlier hop keeps a badge with every
candidate but no fallback class or popover text; a direct hop outside
the region keeps both.
- packets.js (real file, real resolver and display): with one observer,
a prefix that is the last hop of one packet and an earlier hop of
another renders narrowed in the first and with a badge in the second;
after the detail pane resolves a path, the observer-less render does not
show the narrowed last hop; when the shown observation comes from
another observer than the packet row, its last hop is narrowed to the
region of the observer that heard it.
- `TestIataCoordsIncludesBelgianObserverCodes`: ANR, BRU, GNE, KJK, LGG,
OBL, OST and MST with their OurAirports coordinates.
- Red commits first (`26000a22`, `bb91ff9d`, `189f55d7`, failing on
assertions), then green. Mutation check on the packets.js tests:
inverting `isDirect` fails 3 assertions, swapping the `directHops`
argument fails 4.
- `test-issue-2097-hop-ambiguity-badge.js`: two structural regexes now
allow the new `direct` argument; every assertion still holds.
- All 198 `node tests/unit/*` suites in `test-all.sh` pass. `cd
cmd/server && go test ./...` passes (with
`TestSaveGeoFilterPreservesFileMode` skipped, a Windows-only failure on
master too); `go vet` clean.

## Browser validation

Staging (this branch on top of the deployment's current build), headless
Chrome on `#/packets/<hash>` for packets whose path ends in a 1-byte
hop: the last hop's popover lists only candidates within the region with
their distances (for example `7E`: 3 candidates at 46, 207 and 296 km),
earlier hops list every candidate (`1D`: 13). The same prefix `7E`
showed 3 in region as the last hop of one path and 4 as a relayed hop of
another. No page errors. `/api/iata-coords` returns 158 codes.

## Browser validation of the follow-up

Staging again (this branch at `4e129b49` on top of the deployment's
build), headless Chrome:

- Packets list, 41 paths of 2+ hops: the global-fallback class appears
on 2 last hops and on 0 earlier hops.
- Detail pane, 5 packets whose path ends in a 1-byte hop (1 to 13 hops
shown): no earlier hop carries the class or "(global fallback)" in its
popover, and every ambiguous earlier hop keeps its badge. Every last hop
was checked against the region: 4 show "N candidates in region", 1 shows
"(global fallback)" with the class.
- Before the observer fix, 2 of 5 detail panes showed a last hop with
neither label, because the shown observation came from another observer
than the packet row (`b66a8f4c`: row from one observer with 13 hops,
shown observation from another with 9).
- No page errors.

## Not done

- The 300 km radius is unchanged. The measured direct-hearing distances
suggest it is wide, but a smaller radius would also have to hold for
less dense networks, and I have no data for those.
- `live.js` passes no observer, so the region filter does not run there,
before or after this change. `map.js` passes the observer of the chosen
path in path order, so once the resolver knows the observer regions it
narrows that path's last hop only.
- The "Cross-observer aggregate" line in the detail pane renders the
longest path with the packet's own observer, so its last hop is narrowed
to that observer's region even if another observer reported that path.
The per-observer keying there predates this PR (#2097).
- Two of the Belgian deployment's codes, HEP and NRW, are not airport
codes and are not in OurAirports; those observers stay without a region
filter.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:24:13 +02:00
2026-04-05 06:36:03 +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 all 40+ API endpoints from an in-memory packet store with 5 indexes (hash, txID, obsID, observer, node). 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 4× 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

  • 11 Analytics Tabs — RF, topology, channels, hash stats, distance, route patterns, 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
  • 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

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
PORT Override config port
DB_PATH Override SQLite database path

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

Or POST raw hex packets to POST /api/packets for manual injection.

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        # 327 test functions
│   └── ingestor/            # Go MQTT ingestor
│       ├── main.go          # MQTT subscription + packet processing
│       ├── decoder.go       # Packet decoder (shared logic)
│       ├── db.go            # SQLite write path
│       └── *_test.go        # 53 test functions
├── 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         # 11-tab 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

380 Go tests covering the backend, plus 150+ Node.js tests for the frontend and legacy logic, plus 49 Playwright E2E tests for browser validation.

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

# Or across all 14 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.00id.net — all API 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
204 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 48.4%
Go 45.9%
CSS 3%
Shell 1.7%
HTML 0.6%
Other 0.2%