Compare commits

..
Author SHA1 Message Date
you e837a9d416 fix: Details button opens full-screen node detail on desktop (#778)
The previous fix (#779) used hash navigation for the Details button,
which only re-selected the node in the side panel on desktop. Now the
Details button calls showFullScreenNode() to render the same full-screen
detail view that mobile users see, regardless of screen width.

- Extract showFullScreenNode() from mobile-only init code
- Reuse it for both mobile init and desktop Details button click
- Analytics links still navigate via hash as before
- Update Playwright test to verify full-screen view renders
2026-04-17 07:41:14 +00:00
398 changed files with 5196 additions and 81376 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"659 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"45 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"39.68%","color":"red"}
+24 -135
View File
@@ -55,9 +55,7 @@ jobs:
set -e -o pipefail
cd cmd/server
go build .
# -race gates PR #1208's atomic.Pointer migration: the race-detector
# is what makes path_inspect_atomic_race_test.go actually assert.
go test -race -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
echo "--- Go Server Coverage ---"
go tool cover -func=server-coverage.out | tail -1
@@ -81,31 +79,6 @@ jobs:
go test ./...
echo "--- Decrypt CLI tests passed ---"
- name: Lint CSS variables (issue #1128)
run: |
set -e
node scripts/check-css-vars.js
node scripts/test-check-css-vars.js
- name: Run JS unit tests (packet-filter)
run: |
set -e
node test-packet-filter.js
node test-packet-filter-time.js
node test-channel-decrypt-insecure-context.js
node test-live-region-filter.js
node test-issue-1136-observer-iata-map.js
node test-channel-qr.js
node test-channel-qr-wiring.js
node test-channel-modal-ux.js
node test-channel-issue-1087.js
node test-channel-issue-1101.js
node test-observer-iata-1188.js
node test-pull-to-reconnect-1091.js
node test-channel-fluid-layout.js
node test-issue-1279-p2-code-filter.js
node test-area-filter.js
- name: Verify proto syntax
run: |
set -e
@@ -162,7 +135,7 @@ jobs:
e2e-test:
name: "🎭 Playwright E2E Tests"
needs: [go-test]
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux]
defaults:
run:
shell: bash
@@ -172,6 +145,13 @@ jobs:
with:
fetch-depth: 0
- name: Free disk space
run: |
# Prune old runner diagnostic logs (can accumulate 50MB+)
find ~/actions-runner/_diag/ -name '*.log' -mtime +3 -delete 2>/dev/null || true
# Show available disk space
df -h / | tail -1
- name: Set up Node.js 22
uses: actions/setup-node@v5
with:
@@ -192,12 +172,6 @@ jobs:
go build -o ../../corescope-server .
echo "Go server built successfully"
- name: Build Go migrate tool
run: |
cd cmd/migrate
go build -o ../../corescope-migrate .
echo "Go migrate tool built successfully"
- name: Install npm dependencies
run: npm ci --production=false
@@ -209,18 +183,6 @@ jobs:
- name: Instrument frontend JS for coverage
run: sh scripts/instrument-frontend.sh
- name: Freshen fixture timestamps
run: bash tools/freshen-fixture.sh test-fixtures/e2e-fixture.db
- name: Migrate fixture DB to current schema (#1287)
# Server now ASSERTs schema is migrated and refuses to start
# otherwise (cmd/server/main.go: dbschema.AssertReady). In prod
# the ingestor owns dbschema.Apply, but CI starts only the
# server against the committed e2e fixture — so we run the
# standalone migrate tool here to bring the fixture up to the
# required shape before the server boots.
run: ./corescope-migrate -db test-fixtures/e2e-fixture.db
- name: Start Go server with fixture DB
run: |
fuser -k 13581/tcp 2>/dev/null || true
@@ -228,7 +190,7 @@ jobs:
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented &
echo $! > .server.pid
for i in $(seq 1 30); do
if curl -sf http://localhost:13581/api/healthz > /dev/null 2>&1; then
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
echo "Server ready after ${i}s"
break
fi
@@ -242,65 +204,6 @@ jobs:
- name: Run Playwright E2E tests (fail-fast)
run: |
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-touch-gestures-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1205-live-controls-anchor-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-home-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-live-1297-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-lab-1297-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-decrypt-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-qr-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-color-picker-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-customize-theme-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-customize-branding-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-customize-display-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
@@ -310,13 +213,7 @@ jobs:
- name: Generate frontend coverage badges
if: success()
run: |
# Aggregate per-suite PASS/FAIL across every test-*-e2e.js summary.
# The previous regex (grep -oP '[0-9]+(?=/)' | tail -1) caught a
# stray digits-before-slash like the '2' in '2/3 tests passed' from
# some sub-output and stamped the badge as '2 passed'. See #1296.
eval "$(bash scripts/aggregate-e2e-pass.sh e2e-output.txt)"
E2E_PASS=${PASS:-0}
E2E_FAIL=${FAIL:-0}
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
mkdir -p .badges
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
@@ -329,14 +226,7 @@ jobs:
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
fi
if [ "${E2E_FAIL:-0}" -gt 0 ]; then
E2E_MSG="${E2E_PASS:-0} passed, ${E2E_FAIL} failed"
E2E_COLOR="red"
else
E2E_MSG="${E2E_PASS:-0} passed"
E2E_COLOR="brightgreen"
fi
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_MSG}\",\"color\":\"${E2E_COLOR}\"}" > .badges/e2e-tests.json
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
- name: Stop test server
if: always()
@@ -362,11 +252,17 @@ jobs:
build-and-publish:
name: "🏗️ Build & Publish Docker Image"
needs: [e2e-test]
runs-on: ubuntu-latest
runs-on: [self-hosted, meshcore-runner-2]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Free disk space
run: |
docker system prune -af 2>/dev/null || true
docker builder prune -af 2>/dev/null || true
df -h /
- name: Compute build metadata
id: meta
run: |
@@ -394,10 +290,6 @@ jobs:
if: github.event_name == 'push'
uses: docker/setup-buildx-action@v3
- name: Set up QEMU (arm64 runtime stage)
if: github.event_name == 'push'
uses: docker/setup-qemu-action@v3
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
@@ -425,7 +317,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
build-args: |
@@ -476,9 +368,7 @@ jobs:
# ───────────────────────────────────────────────────────────────
deploy:
name: "🚀 Deploy Staging"
if: |
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
&& github.ref == 'refs/heads/master'
if: github.event_name == 'push'
needs: [build-and-publish]
runs-on: [self-hosted, meshcore-runner-2]
steps:
@@ -542,11 +432,10 @@ jobs:
- name: Smoke test staging API
run: |
PORT="${STAGING_GO_HTTP_PORT:-80}"
if curl -sf "http://localhost:${PORT}/api/stats" | grep -q engine; then
if curl -sf http://localhost:82/api/stats | grep -q engine; then
echo "Staging verified — engine field present ✅"
else
echo "Staging /api/stats did not return engine field (port ${PORT})"
echo "Staging /api/stats did not return engine field"
exit 1
fi
@@ -568,7 +457,7 @@ jobs:
name: "📝 Publish Badges & Summary"
if: github.event_name == 'push'
needs: [deploy]
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
uses: actions/checkout@v5
-2
View File
@@ -31,5 +31,3 @@ cmd/ingestor/ingestor.exe
!test-fixtures/e2e-fixture.db
corescope-server
cmd/server/server
# Local-only planning and design files
docs/superpowers/
-11
View File
@@ -43,17 +43,6 @@ scripts/ — Tooling (coverage collector, fixture capture, frontend in
2. Go server (`cmd/server/`) polls SQLite for new packets, broadcasts via WebSocket
3. Frontend fetches via REST API (`/api/*`), filters/sorts client-side
### Read/Write Separation Invariant (#1283)
- **All DB writes live in `cmd/ingestor/`.** INSERT / UPDATE / DELETE / VACUUM /
schema migrations / retention all run in the ingestor process.
- **`cmd/server/` is read-only.** It opens SQLite with `mode=ro` and must not
acquire a write lock. Adding a write-side helper (e.g. a `cachedRW`-style
RW connection) regresses this invariant and races the ingestor → SQLITE_BUSY.
- Enforcement: `cmd/server/readonly_invariant_test.go` reflect-asserts that
`PruneOldPackets`, `PruneOldMetrics`, and `RemoveStaleObservers` are NOT
methods on the server's `*DB`. If you need a new write, add it to
`cmd/ingestor/`.
### What's Deprecated (DO NOT TOUCH)
The following were part of the old Node.js backend and have been removed:
- `server.js`, `db.js`, `decoder.js`, `server-helpers.js`, `packet-store.js`, `iata-coords.js`
-7
View File
@@ -1,12 +1,5 @@
# Changelog
## [3.7.2] — 2026-05-06
Hotfix release branched from `v3.7.1`. Cherry-picks PR #1121 only — no other changes.
### 🐛 Bug Fixes
- **Ingestor: backfill infinite loop on `path_json='[]'` rows** (#1119, #1121) — `BackfillPathJSONAsync` re-selected observations whose `path_json` was already `'[]'`, rewrote them to `'[]'`, and looped forever. The migration marker was never recorded and the ingestor sustained 23 MB/s WAL writes at idle (~76% CPU in `sqlite.Exec`). Fix: drop `'[]'` from the WHERE clause so the loop terminates after one full pass and the `backfill_path_json_from_raw_hex_v1` marker is written.
## [2.5.0] "Digital Rain" — 2026-03-22
### ✨ Matrix Mode — Full Cyberpunk Map Theme
+7 -28
View File
@@ -1,48 +1,28 @@
# Build stage always runs natively on the builder's arch ($BUILDPLATFORM)
# and cross-compiles to $TARGETOS/$TARGETARCH via Go toolchain. No QEMU.
# BUILDPLATFORM is auto-set by buildx; default to linux/amd64 so plain
# `docker build` (without buildx) doesn't fail on an empty platform string.
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache build-base
ARG APP_VERSION=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown
# Provided by buildx for multi-arch builds
ARG TARGETOS
ARG TARGETARCH
# Build server (pure-Go sqlite — no CGO needed, cross-compiles cleanly)
# Build server
WORKDIR /build/server
COPY cmd/server/go.mod cmd/server/go.sum ./
COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/dbschema/ ../../internal/dbschema/
COPY internal/prunequeue/ ../../internal/prunequeue/
COPY internal/perfio/ ../../internal/perfio/
COPY internal/prunequeue/ ../../internal/prunequeue/
RUN go mod download
COPY cmd/server/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
RUN go build -ldflags "-X main.Version=${APP_VERSION} -X main.Commit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME}" -o /corescope-server .
# Build ingestor
WORKDIR /build/ingestor
COPY cmd/ingestor/go.mod cmd/ingestor/go.sum ./
COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/dbschema/ ../../internal/dbschema/
COPY internal/prunequeue/ ../../internal/prunequeue/
COPY internal/perfio/ ../../internal/perfio/
COPY internal/prunequeue/ ../../internal/prunequeue/
RUN go mod download
COPY cmd/ingestor/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -o /corescope-ingestor .
RUN go build -o /corescope-ingestor .
# Build decrypt CLI
WORKDIR /build/decrypt
@@ -50,8 +30,7 @@ COPY cmd/decrypt/go.mod cmd/decrypt/go.sum ./
COPY internal/channel/ ../../internal/channel/
RUN go mod download
COPY cmd/decrypt/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-s -w" -o /corescope-decrypt .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /corescope-decrypt .
# Runtime image
FROM alpine:3.20
-207
View File
@@ -1,207 +0,0 @@
# v3.6.0 - The Forensics
CoreScope just got eyes everywhere. This release drops **path inspection**, **color-by-hash markers**, **clock skew detection**, **full channel encryption**, an **observer graph**, and a pile of robustness fixes that make your mesh network feel like it's being watched by someone who actually cares.
134 commits, 105 PRs merged, 18K+ lines added. Here's what shipped.
---
## 🚀 New Features
### Path-Prefix Candidate Inspector (#944, #945)
The marquee feature. Click any path segment and CoreScope opens an interactive inspector showing every candidate node that could match that hop prefix - plotted on a map with scoring by neighbor-graph affinity and geographic centroid. Ambiguous hops? Now you can see *why* they're ambiguous and pick the right one.
**Why you'll love it:** No more guessing which `0xA3` is the real repeater. The inspector lays out every candidate, scores them, and lets you drill in visually.
### Color-by-Hash Packet Markers (#948, #951)
Every packet type gets a vivid, hash-derived color - on the live feed, map polylines, and flying-packet animations. Bright fill with dark outline for contrast. No more monochrome blobs - you can visually track packet flows by color at a glance.
### Node Filter on Live Page (#924, #771)
Filter the live packet stream to show only traffic flowing through a specific node. Pick a repeater, see exactly what it's carrying. That simple.
### Clock Skew Detection (#746, #752, #828, #850)
Full pipeline: backend computes drift using Theil-Sen regression with outlier rejection (#828), the UI shows per-node badges, detail sparklines, and fleet-wide analytics (#752). Bimodal clock severity (#850) surfaces flaky-RTC nodes that toggle between accurate and drifted - instead of hiding them as "No Clock."
**Why you'll love it:** Nodes with bad clocks silently corrupt your timeline. Now they glow red before they ruin your analysis.
### Observer Graph (M1+M2) (#774)
Observers are now first-class graph citizens. CoreScope builds a neighbor graph from observation overlaps, scores hop-resolver candidates by graph edges (#876), and uses geographic centroid for tiebreaking. The observer topology is visible and queryable.
### Channel Encryption - Full Stack (#726, #733, #750, #760)
Three milestones landed as one: DB-backed channel message history (#726), client-side PSK decryption in the browser (#733), and PSK channel management with add/remove UX and message caching (#750). Add a channel key in the UI, and CoreScope decrypts messages client-side - no server-side key storage. The add-channel button (#760) makes it dead simple.
**Why you'll love it:** Encrypted channels are no longer black boxes. Add your PSK, see the messages, search history - all without exposing keys to the server.
### Hash Collision Inspector (#758)
The Hash Usage Matrix now shows collision details for all hash sizes. When two nodes share a prefix, you see exactly who collides and at what size.
### Geofilter Builder - In-App (#735, #900)
The geofilter polygon builder is now served directly from CoreScope with a full docs page (#900). No more hunting for external tools. Link from the customizer, draw your polygon, done.
### Node Blacklist (#742)
`nodeBlacklist` in config hides abusive or troll nodes from all views. They're gone.
### Observer Retention (#764)
Stale observers are automatically pruned after a configurable number of days. Your observer list stays clean without manual intervention.
### Advert Signature Validation (#794)
Corrupt packets with invalid advert signatures are now rejected at ingest. Bad data never hits your store.
### Bounded Cold Load (#790)
`Load()` now respects a memory budget - no more OOM on cold start with a fat database. Combined with retention-hours cutoff (#917), cold start is safe on constrained hardware.
### Multi-Arch Docker Images (#869)
Official images now publish `amd64` + `arm64` in a single multi-arch manifest. Raspberry Pi operators: pull and run. No special tags needed.
### /nodes Detail Panel + Search (#868)
The nodes detail panel ships with search improvements (#862) - find nodes fast, see their full detail in a slide-out panel.
### Deduplicated Top Longest Hops (#848)
Longest hops are now deduplicated by pair with observation count and SNR cues. No more seeing the same link 47 times.
---
## 🔥 Performance Wins
### StoreTx ResolvedPath Elimination (#806)
The per-transaction `ResolvedPath` computation is gone - replaced by a membership index with on-demand decode. This was one of the hottest paths in the ingestor.
### Node Packet Queries (#803)
Raw JSON text search for node packets replaced with a proper `byNode` index (#673). Night and day.
### Channel Query Performance (#762, #763)
New `channel_hash` column enables SQL-level channel filtering. No more full-table scan to find messages in a channel.
### SQLite Auto-Vacuum (#919, #920)
Incremental auto-vacuum enabled - the database file actually shrinks after retention pruning. No more 2GB database holding 200MB of live data.
### Retention-Hours Cutoff on Load (#917)
`Load()` now applies `retentionHours` at read time, preventing OOM when the DB has more history than memory allows.
---
## 🛡️ Security & Robustness
### MQTT Reconnect with Bounded Backoff (#947, #949)
The ingestor now reconnects to MQTT brokers with exponential backoff, observability logging, and bounded retry. No more silent disconnects that kill your data stream.
---
## 🐛 Bugs Squashed
This release exterminates **40+ bugs** — from protocol-level hash mismatches to pixel-level CSS breakage. Operators told us what hurt; we listened.
- **Path inspector "Show on Map" missed origin and first hop** (#950) - map view now includes all hops
- **Content hash used full header byte** (#787) - content hashing now uses payload type bits only, fixing hash collisions between packets that differ only in header flags
- **Encrypted channel deep links showed broken UI** (#825, #826, #815) - deep links to encrypted channels now show a lock message instead of broken UI when you don't have the key
- **Geofilter longitude wrapping** (#925) - geofilter builder wraps longitude to [-180, 180]; southern hemisphere polygons no longer invert
- **Hash filter bypasses saved region filter** (#939) - hash lookups now skip the geo filter as intended
- **Companion-as-repeater excluded from path hops** (#935, #936) - non-repeater nodes no longer pollute hop resolution
- **Customize panel re-renders while typing** (#927) - text fields keep focus during config changes
- **Per-observation raw_hex** (#881, #882) - each observer's hex dump now shows what *that observer* actually received
- **Per-observation children in packet groups** (#866, #880) - expanded groups show per-obs data, not cross-observer aggregates
- **Full-page obs-switch** (#866, #870) - switching observers updates hex, path, and direction correctly
- **Packet detail shows wrong observation** (#849, #851) - clicking a specific observation opens *that* observation
- **Byte breakdown hop count** (#844, #846) - derived from `path_len`, not aggregated `_parsedPath`
- **Transport-route path_len offset** (#852, #853) - correct offset calculation + CSS variable fix
- **Packets/hour chart bars + x-axis** (#858, #865) - bars render correctly, x-axis labels properly decimated
- **Channel timeline capped to top 8** (#860, #864) - no more 47-channel chart spaghetti
- **Reachability row opacity removed** (#859, #863) - clean rows without misleading gradient
- **Sticky table headers on mobile** (#861, #867) - restored after regression
- **Map popup 'Show Neighbors' on iOS Safari** (#840, #841) - link actually works now
- **Node detail Recent Packets invisible text** (#829, #830) - CSS fix
- **/api/packets/{hash} falls back to DB** (#827, #831) - when in-memory store misses, DB catches it
- **IATA filter bypass for status messages** (#694, #802) - status packets no longer filtered out by airport codes
- **Desktop node click URL hash** (#676, #739) - clicking a node updates the URL for deep linking
- **Filter params in URL hash** (#682, #740) - all filter state serialized for shareable links
- **Hide undecryptable channel messages** (#727, #728) - clean default view
- **TRACE path_json uses path_sz** (#732) - correct field from flags byte, not header hash_size
- **Multi-byte adopters** (#754, #767) - all node types, role column, advert precedence
- **Channel key case sensitivity** (#761) - Public decode works correctly
- **Transport route field offsets** (#766) - correct offsets in field table
- **Clock skew sanity checks** (#769) - filter epoch-0, cap drift, require minimum samples
- **Neighbor graph slider persistence** (#776) - default 0.7, persisted to localStorage
- **Node detail panel navigation** (#779, #785) - Details/Analytics links actually navigate
- **Channel key removal** (#898) - user-added keys for server-known channels can be removed
- **Side-panel Details on desktop** (#892) - opens full-screen correctly
- **Hex-dump byte ranges client-side** (#891) - computed from per-obs raw_hex
- **path_json derived from raw_hex at ingest** (#886, #887) - single source of truth
- **Path pill and byte breakdown hop agreement** (#885) - they match now
- **Mobile close button + toolbar scroll** (#797, #805) - accessible and scrollable
- **/health.recentPackets resolved_path fallback** (#810, #821) - falls back to longest sibling observation
- **Channel filter on Packets page** (#812, #816) - UI and API both fixed
- **Clock-skew section in side panel** (#813, #814) - renders correctly
- **Real RSS in /api/stats** (#832, #835) - surface actual RSS alongside tracked store bytes
- **Hash size detection for transport routes + zero-hop adverts** (#747) - correct detection
- **Repeater+observer merged map marker** (#745) - single marker, not two overlapping
---
## 🎨 UI Polish
- QA findings applied across the board (#832, #833, #836, #837, #838) - dozens of small UX fixes from systematic QA pass
---
## 📦 Upgrading
```bash
git pull
docker compose down
docker compose build prod
docker compose up -d prod
```
Your existing `config.json` works as-is. New optional config keys:
- `nodeBlacklist` - array of node hashes to hide
- `observerRetentionDays` - days before stale observers are pruned
- `memoryBudgetMB` - cap on in-memory packet store
### Verify
```bash
curl -s http://localhost/api/health | jq .version
# "3.6.0"
```
---
## 🙏 External Contributors
- **#735** ([@efiten](https://github.com/efiten)) - Serve geofilter builder from app, link from customizer
- **#739** ([@efiten](https://github.com/efiten)) - Desktop node click updates URL hash for deep linking
- **#740** ([@efiten](https://github.com/efiten)) - Serialize filter params in URL hash for shareable links
- **#742** ([@Joel-Claw](https://github.com/Joel-Claw)) - Add nodeBlacklist config to hide abusive/troll nodes
- **#761** ([@copelaje](https://github.com/copelaje)) - Fix channel key case sensitivity for Public decode
- **#764** ([@Joel-Claw](https://github.com/Joel-Claw)) - Add observer retention - prune stale observers after configurable days
- **#802** ([@efiten](https://github.com/efiten)) - Bypass IATA filter for status messages, fill SNR on duplicate observations
- **#803** ([@efiten](https://github.com/efiten)) - Replace raw JSON text search with byNode index for node packet queries
- **#805** ([@efiten](https://github.com/efiten)) - Mobile close button accessible + toolbar scrollable
- **#900** ([@efiten](https://github.com/efiten)) - App-served geofilter docs page
- **#917** ([@efiten](https://github.com/efiten)) - Apply retentionHours cutoff in Load() to prevent OOM on cold start
- **#924** ([@efiten](https://github.com/efiten)) - Node filter on live page - show only traffic through a specific node
- **#925** ([@efiten](https://github.com/efiten)) - Fix geobuilder longitude wrapping for southern hemisphere polygons
- **#927** ([@efiten](https://github.com/efiten)) - Skip customize panel re-render while text field has focus
---
## ⚠️ Breaking Changes
**None.** All API endpoints remain backwards-compatible. New fields are additive only.
---
## 📊 By the Numbers
| Stat | Count |
|------|-------|
| Commits | 134 |
| PRs merged | 105 |
| Lines added | 18,480 |
| Lines removed | 1,632 |
| Files changed | 110 |
| Contributors | 4 |
---
*Previous release: [v3.5.2](https://github.com/Kpa-clawbot/CoreScope/releases/tag/v3.5.2)*
-18
View File
@@ -47,24 +47,6 @@ The config file uses the same format as the Node.js `config.json`. The ingestor
| `DB_PATH` | SQLite database path | `data/meshcore.db` |
| `MQTT_BROKER` | Single MQTT broker URL (overrides config) | — |
| `MQTT_TOPIC` | MQTT topic (used with `MQTT_BROKER`) | `meshcore/#` |
| `CORESCOPE_INGESTOR_STATS` | Path to the per-second stats JSON file consumed by the server's `/api/perf/io` and `/api/perf/write-sources` endpoints (#1120) | `/tmp/corescope-ingestor-stats.json` |
### Stats file (`CORESCOPE_INGESTOR_STATS`)
Every second the ingestor publishes a JSON snapshot of its counters
(`tx_inserted`, `obs_inserted`, `walCommits`, `backfillUpdates.*`, etc.) plus
a `procIO` block sampled from `/proc/self/io` (read/write/cancelled bytes per
second + syscall counts). The server reads this file and surfaces the data on
the Perf page so operators can self-diagnose write-volume anomalies.
The writer uses `O_NOFOLLOW | O_CREAT | O_TRUNC` mode `0o600`, so a
pre-planted symlink at the path cannot be used to clobber an arbitrary file.
**Security note:** the default lives in `/tmp`, which is world-writable on
most hosts (sticky bit only protects deletion, not creation). On
shared/multi-tenant hosts, override `CORESCOPE_INGESTOR_STATS` to point at a
private directory (e.g. `/var/lib/corescope/ingestor-stats.json`) that only
the corescope user can write to.
### Minimal Config
+3 -146
View File
@@ -7,9 +7,7 @@ import (
"log"
"os"
"strings"
"sync"
"github.com/meshcore-analyzer/dbconfig"
"github.com/meshcore-analyzer/geofilter"
)
@@ -22,17 +20,6 @@ type MQTTSource struct {
RejectUnauthorized *bool `json:"rejectUnauthorized,omitempty"`
Topics []string `json:"topics"`
IATAFilter []string `json:"iataFilter,omitempty"`
ConnectTimeoutSec int `json:"connectTimeoutSec,omitempty"`
Region string `json:"region,omitempty"`
}
// ConnectTimeoutOrDefault returns the per-source connect timeout in seconds,
// or 30 if not set (matching the WaitTimeout default from #926).
func (s MQTTSource) ConnectTimeoutOrDefault() int {
if s.ConnectTimeoutSec > 0 {
return s.ConnectTimeoutSec
}
return 30
}
// MQTTLegacy is the old single-broker config format.
@@ -50,83 +37,18 @@ type Config struct {
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
HashChannels []string `json:"hashChannels,omitempty"`
HashRegions []string `json:"hashRegions,omitempty"`
Retention *RetentionConfig `json:"retention,omitempty"`
Metrics *MetricsConfig `json:"metrics,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
ForeignAdverts *ForeignAdvertConfig `json:"foreignAdverts,omitempty"`
ValidateSignatures *bool `json:"validateSignatures,omitempty"`
DB *DBConfig `json:"db,omitempty"`
// ObserverIATAWhitelist restricts which observer IATA regions are processed.
// When non-empty, only observers whose IATA code (from the MQTT topic) matches
// one of these entries are accepted. Case-insensitive. An empty list means all
// IATA codes are allowed. This applies globally, unlike the per-source iataFilter.
ObserverIATAWhitelist []string `json:"observerIATAWhitelist,omitempty"`
// obsIATAWhitelistCached is the lazily-built uppercase set for O(1) lookups.
obsIATAWhitelistCached map[string]bool
obsIATAWhitelistOnce sync.Once
// ObserverBlacklist is a list of observer public keys to drop at ingest.
// Messages from blacklisted observers are silently discarded — no DB writes,
// no UpsertObserver, no observations, no metrics.
ObserverBlacklist []string `json:"observerBlacklist,omitempty"`
// obsBlacklistSetCached is the lazily-built lowercase set for O(1) lookups.
obsBlacklistSetCached map[string]bool
obsBlacklistOnce sync.Once
// NeighborEdgesMaxAgeDays controls neighbor_edges row retention
// (#1287 — moved from cmd/server). 0 = default 5.
NeighborEdgesMaxAgeDays int `json:"neighborEdgesMaxAgeDays,omitempty"`
}
// NeighborEdgesDaysOrDefault returns the configured pruning window or 5.
func (c *Config) NeighborEdgesDaysOrDefault() int {
if c == nil || c.NeighborEdgesMaxAgeDays <= 0 {
return 5
}
return c.NeighborEdgesMaxAgeDays
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
}
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
// ForeignAdvertConfig controls how the ingestor handles ADVERTs whose GPS lies
// outside the configured geofilter polygon (#730). Modes:
// - "flag" (default): store the advert/node and tag it foreign for visibility.
// - "drop": silently discard the advert (legacy behavior).
type ForeignAdvertConfig struct {
Mode string `json:"mode,omitempty"`
}
// IsDropMode reports whether the foreign-advert config is set to "drop".
// Defaults to false ("flag" mode) when nil or unset.
func (f *ForeignAdvertConfig) IsDropMode() bool {
if f == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(f.Mode), "drop")
}
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
ObserverDays int `json:"observerDays"`
MetricsDays int `json:"metricsDays"`
// PacketDays is the retention window for transmissions (#1283).
// Ownership moved from cmd/server to cmd/ingestor; 0 disables.
PacketDays int `json:"packetDays"`
}
// PacketDaysOrZero returns the configured retention.packetDays or 0
// (disabled) if not set.
func (c *Config) PacketDaysOrZero() int {
if c.Retention != nil && c.Retention.PacketDays > 0 {
return c.Retention.PacketDays
}
return 0
NodeDays int `json:"nodeDays"`
MetricsDays int `json:"metricsDays"`
}
// MetricsConfig controls observer metrics collection.
@@ -134,25 +56,6 @@ type MetricsConfig struct {
SampleIntervalSec int `json:"sampleIntervalSec"`
}
// DBConfig is the shared SQLite vacuum/maintenance config (#919, #921).
type DBConfig = dbconfig.DBConfig
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
func (c *Config) IncrementalVacuumPages() int {
if c.DB != nil && c.DB.IncrementalVacuumPages > 0 {
return c.DB.IncrementalVacuumPages
}
return 1024
}
// ShouldValidateSignatures returns true (default) unless explicitly disabled.
func (c *Config) ShouldValidateSignatures() bool {
if c.ValidateSignatures != nil {
return *c.ValidateSignatures
}
return true
}
// MetricsSampleInterval returns the configured sample interval or 300s default.
func (c *Config) MetricsSampleInterval() int {
if c.Metrics != nil && c.Metrics.SampleIntervalSec > 0 {
@@ -177,52 +80,6 @@ func (c *Config) NodeDaysOrDefault() int {
return 7
}
// ObserverDaysOrDefault returns the configured retention.observerDays or 14 if not set.
// A value of -1 means observers are never removed.
func (c *Config) ObserverDaysOrDefault() int {
if c.Retention != nil && c.Retention.ObserverDays != 0 {
return c.Retention.ObserverDays
}
return 14
}
// IsObserverBlacklisted returns true if the given observer ID is in the observerBlacklist.
func (c *Config) IsObserverBlacklisted(id string) bool {
if c == nil || len(c.ObserverBlacklist) == 0 {
return false
}
c.obsBlacklistOnce.Do(func() {
m := make(map[string]bool, len(c.ObserverBlacklist))
for _, pk := range c.ObserverBlacklist {
trimmed := strings.ToLower(strings.TrimSpace(pk))
if trimmed != "" {
m[trimmed] = true
}
}
c.obsBlacklistSetCached = m
})
return c.obsBlacklistSetCached[strings.ToLower(strings.TrimSpace(id))]
}
// IsObserverIATAAllowed returns true if the given IATA code is permitted.
// When ObserverIATAWhitelist is empty, all codes are allowed.
func (c *Config) IsObserverIATAAllowed(iata string) bool {
if c == nil || len(c.ObserverIATAWhitelist) == 0 {
return true
}
c.obsIATAWhitelistOnce.Do(func() {
m := make(map[string]bool, len(c.ObserverIATAWhitelist))
for _, code := range c.ObserverIATAWhitelist {
trimmed := strings.ToUpper(strings.TrimSpace(code))
if trimmed != "" {
m[trimmed] = true
}
}
c.obsIATAWhitelistCached = m
})
return c.obsIATAWhitelistCached[strings.ToUpper(strings.TrimSpace(iata))]
}
// LoadConfig reads configuration from a JSON file, with env var overrides.
// If the config file does not exist, sensible defaults are used (zero-config startup).
func LoadConfig(path string) (*Config, error) {
-110
View File
@@ -284,113 +284,3 @@ func TestLoadConfigWithAllFields(t *testing.T) {
t.Errorf("iataFilter=%v", src.IATAFilter)
}
}
func TestConnectTimeoutOrDefault(t *testing.T) {
// Default when unset
s := MQTTSource{}
if got := s.ConnectTimeoutOrDefault(); got != 30 {
t.Errorf("default: got %d, want 30", got)
}
// Custom value
s.ConnectTimeoutSec = 5
if got := s.ConnectTimeoutOrDefault(); got != 5 {
t.Errorf("custom: got %d, want 5", got)
}
// Zero treated as unset
s.ConnectTimeoutSec = 0
if got := s.ConnectTimeoutOrDefault(); got != 30 {
t.Errorf("zero: got %d, want 30", got)
}
}
func TestConnectTimeoutFromJSON(t *testing.T) {
dir := t.TempDir()
cfgPath := dir + "/config.json"
os.WriteFile(cfgPath, []byte(`{"mqttSources":[{"name":"s1","broker":"tcp://b:1883","topics":["#"],"connectTimeoutSec":5}]}`), 0644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if got := cfg.MQTTSources[0].ConnectTimeoutOrDefault(); got != 5 {
t.Errorf("from JSON: got %d, want 5", got)
}
}
func TestObserverIATAWhitelist(t *testing.T) {
// Config with whitelist set
cfg := Config{
ObserverIATAWhitelist: []string{"ARN", "got"},
}
// Matching (case-insensitive)
if !cfg.IsObserverIATAAllowed("ARN") {
t.Error("ARN should be allowed")
}
if !cfg.IsObserverIATAAllowed("arn") {
t.Error("arn (lowercase) should be allowed")
}
if !cfg.IsObserverIATAAllowed("GOT") {
t.Error("GOT should be allowed")
}
// Non-matching
if cfg.IsObserverIATAAllowed("SJC") {
t.Error("SJC should NOT be allowed")
}
// Empty string not allowed
if cfg.IsObserverIATAAllowed("") {
t.Error("empty IATA should NOT be allowed")
}
}
func TestObserverIATAWhitelistEmpty(t *testing.T) {
// No whitelist = allow all
cfg := Config{}
if !cfg.IsObserverIATAAllowed("SJC") {
t.Error("with no whitelist, all IATAs should be allowed")
}
if !cfg.IsObserverIATAAllowed("") {
t.Error("with no whitelist, even empty IATA should be allowed")
}
}
func TestObserverIATAWhitelistJSON(t *testing.T) {
json := `{
"dbPath": "test.db",
"observerIATAWhitelist": ["ARN", "GOT"]
}`
tmp := t.TempDir() + "/config.json"
os.WriteFile(tmp, []byte(json), 0644)
cfg, err := LoadConfig(tmp)
if err != nil {
t.Fatal(err)
}
if len(cfg.ObserverIATAWhitelist) != 2 {
t.Fatalf("expected 2 entries, got %d", len(cfg.ObserverIATAWhitelist))
}
if !cfg.IsObserverIATAAllowed("ARN") {
t.Error("ARN should be allowed after loading from JSON")
}
}
func TestMQTTSourceRegionField(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{
"dbPath": "/tmp/test.db",
"mqttSources": [
{"name": "cascadia", "broker": "tcp://localhost:1883", "topics": ["meshcore/#"], "region": "PDX"}
]
}`), 0o644)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
if cfg.MQTTSources[0].Region != "PDX" {
t.Fatalf("expected region PDX, got %q", cfg.MQTTSources[0].Region)
}
}
+22 -214
View File
@@ -5,10 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// hmacSHA256 computes HMAC-SHA256 for test use.
@@ -160,7 +157,7 @@ func TestHandleMessageChannelMessage(t *testing.T) {
payload := []byte(`{"text":"Alice: Hello everyone","channel_idx":3,"SNR":5.0,"RSSI":-95,"score":10,"direction":"rx","sender_timestamp":1700000000}`)
msg := &mockMessage{topic: "meshcore/message/channel/2", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -220,7 +217,7 @@ func TestHandleMessageChannelMessageEmptyText(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: []byte(`{"text":""}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -235,7 +232,7 @@ func TestHandleMessageChannelNoSender(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: []byte(`{"text":"no sender here"}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
@@ -252,7 +249,7 @@ func TestHandleMessageDirectMessage(t *testing.T) {
payload := []byte(`{"text":"Bob: Hey there","sender_timestamp":1700000000,"SNR":3.0,"rssi":-100,"Score":8,"Direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/message/direct/abc123", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -296,7 +293,7 @@ func TestHandleMessageDirectMessageEmptyText(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/direct/abc", payload: []byte(`{"text":""}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -311,7 +308,7 @@ func TestHandleMessageDirectNoSender(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/direct/xyz", payload: []byte(`{"text":"message with no colon"}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -330,7 +327,7 @@ func TestHandleMessageUppercaseScoreDirection(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","Score":9.0,"Direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var score *float64
var direction *string
@@ -351,7 +348,7 @@ func TestHandleMessageChannelLowercaseFields(t *testing.T) {
payload := []byte(`{"text":"Test: msg","snr":3.0,"rssi":-90,"Score":5,"Direction":"rx"}`)
msg := &mockMessage{topic: "meshcore/message/channel/0", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -367,7 +364,7 @@ func TestHandleMessageDirectLowercaseFields(t *testing.T) {
payload := []byte(`{"text":"Test: msg","snr":2.0,"rssi":-85,"score":7,"direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/message/direct/xyz", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -390,7 +387,7 @@ func TestHandleMessageAdvertWithTelemetry(t *testing.T) {
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
// Should have created transmission, node, and observer
var txCount, nodeCount, obsCount int
@@ -430,12 +427,7 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
// Legacy silent-drop behavior is now opt-in via ForeignAdverts.Mode="drop"
// (#730). The new default — flag — is covered by foreign_advert_test.go.
handleMessage(store, "test", source, msg, nil, nil, &Config{
GeoFilter: gf,
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
})
handleMessage(store, "test", source, msg, nil, gf)
// Geo-filtered adverts should not create nodes
var nodeCount int
@@ -443,7 +435,7 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) {
t.Fatal(err)
}
if nodeCount != 0 {
t.Errorf("nodes=%d, want 0 (geo-filtered advert in drop mode should not create node)", nodeCount)
t.Errorf("nodes=%d, want 0 (geo-filtered advert should not create node)", nodeCount)
}
}
@@ -672,7 +664,7 @@ func TestHandleMessageCorruptedAdvertNoNode(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
@@ -694,7 +686,7 @@ func TestHandleMessageNonAdvertPacket(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -755,13 +747,8 @@ func TestDecodeAdvertSensorNoName(t *testing.T) {
// --- db.go: OpenStore error path (invalid dir) ---
func TestOpenStoreInvalidPath(t *testing.T) {
// Create a regular file then try to open a DB inside it — impossible on all platforms.
f, err := os.CreateTemp(t.TempDir(), "not-a-dir")
if err != nil {
t.Fatalf("setup: %v", err)
}
f.Close()
_, err = OpenStore(filepath.Join(f.Name(), "db.sqlite"))
// Path under /dev/null can't create directory
_, err := OpenStore("/dev/null/impossible/path/db.sqlite")
if err == nil {
t.Error("should error on impossible path")
}
@@ -876,7 +863,7 @@ func TestHandleMessageChannelLongSender(t *testing.T) {
longText := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: msg"
payload := []byte(`{"text":"` + longText + `"}`)
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
@@ -895,7 +882,7 @@ func TestHandleMessageDirectLongSender(t *testing.T) {
longText := "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB: msg"
payload := []byte(`{"text":"` + longText + `"}`)
msg := &mockMessage{topic: "meshcore/message/direct/abc", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -912,7 +899,7 @@ func TestHandleMessageDirectUppercaseScoreDirection(t *testing.T) {
payload := []byte(`{"text":"X: hi","Score":6,"Direction":"rx"}`)
msg := &mockMessage{topic: "meshcore/message/direct/d1", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -942,7 +929,7 @@ func TestHandleMessageChannelUppercaseScoreDirection(t *testing.T) {
payload := []byte(`{"text":"Y: hi","Score":4,"Direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/message/channel/5", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
@@ -973,7 +960,7 @@ func TestHandleMessageRawLowercaseScore(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","score":3.5}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var score *float64
if err := store.db.QueryRow("SELECT score FROM observations LIMIT 1").Scan(&score); err != nil {
@@ -992,7 +979,7 @@ func TestHandleMessageStatusNoOrigin(t *testing.T) {
topic: "meshcore/LAX/obs5/status",
payload: []byte(`{"model":"L1"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id = 'obs5'").Scan(&count); err != nil {
@@ -1151,182 +1138,3 @@ func TestDecodeTraceWithPath(t *testing.T) {
t.Errorf("flags=%v, want 3", p.TraceFlags)
}
}
// --- db.go: RemoveStaleObservers (soft-delete) ---
func TestRemoveStaleObservers(t *testing.T) {
store := newTestStore(t)
// Insert an observer with last_seen 30 days ago
err := store.UpsertObserver("obs-old", "OldObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
// Override last_seen to 30 days ago
cutoff := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
_, err = store.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", cutoff, "obs-old")
if err != nil {
t.Fatal(err)
}
// Insert a recent observer
err = store.UpsertObserver("obs-new", "NewObserver", "NYC", nil)
if err != nil {
t.Fatal(err)
}
removed, err := store.RemoveStaleObservers(14)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Errorf("removed=%d, want 1", removed)
}
// Observer should still be in the table (soft-delete), but marked inactive
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Errorf("observers count=%d, want 2 (soft-delete preserves row)", count)
}
// Check that the old observer is marked inactive
var inactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-old").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 1 {
t.Errorf("obs-old inactive=%d, want 1", inactive)
}
// Check that the recent observer is still active
var newInactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-new").Scan(&newInactive); err != nil {
t.Fatal(err)
}
if newInactive != 0 {
t.Errorf("obs-new inactive=%d, want 0", newInactive)
}
}
func TestRemoveStaleObserversNone(t *testing.T) {
store := newTestStore(t)
removed, err := store.RemoveStaleObservers(14)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Errorf("removed=%d, want 0", removed)
}
}
func TestRemoveStaleObserversKeepForever(t *testing.T) {
store := newTestStore(t)
// Insert an old observer
err := store.UpsertObserver("obs-ancient", "AncientObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -365).Format(time.RFC3339)
_, err = store.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", cutoff, "obs-ancient")
if err != nil {
t.Fatal(err)
}
// observerDays = -1 means keep forever
removed, err := store.RemoveStaleObservers(-1)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Errorf("removed=%d, want 0 (keep forever)", removed)
}
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("observers count=%d, want 1 (keep forever)", count)
}
// Observer should NOT be marked inactive
var inactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-ancient").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 0 {
t.Errorf("obs-ancient inactive=%d, want 0 (keep forever)", inactive)
}
}
func TestRemoveStaleObserversReactivation(t *testing.T) {
store := newTestStore(t)
// Insert and stale-mark an observer
err := store.UpsertObserver("obs-test", "TestObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
_, err = store.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", cutoff, "obs-test")
if err != nil {
t.Fatal(err)
}
removed, err := store.RemoveStaleObservers(14)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Errorf("removed=%d, want 1", removed)
}
// Verify it's inactive
var inactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-test").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 1 {
t.Errorf("inactive=%d, want 1 after soft-delete", inactive)
}
// Now UpsertObserver should reactivate it
err = store.UpsertObserver("obs-test", "TestObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-test").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 0 {
t.Errorf("inactive=%d, want 0 after reactivation", inactive)
}
}
func TestObserverDaysOrDefault(t *testing.T) {
tests := []struct {
name string
cfg *Config
want int
}{
{"nil retention", &Config{}, 14},
{"zero observer days", &Config{Retention: &RetentionConfig{ObserverDays: 0}}, 14},
{"positive value", &Config{Retention: &RetentionConfig{ObserverDays: 30}}, 30},
{"keep forever", &Config{Retention: &RetentionConfig{ObserverDays: -1}}, -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.ObserverDaysOrDefault()
if got != tt.want {
t.Errorf("ObserverDaysOrDefault() = %d, want %d", got, tt.want)
}
})
}
}
+62 -631
View File
@@ -8,12 +8,9 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/meshcore-analyzer/dbschema"
"github.com/meshcore-analyzer/packetpath"
_ "modernc.org/sqlite"
)
@@ -25,61 +22,26 @@ type DBStats struct {
NodeUpserts atomic.Int64
ObserverUpserts atomic.Int64
WriteErrors atomic.Int64
SignatureDrops atomic.Int64
// WALCommits tracks every successful tx.Commit() that may have flushed
// WAL pages.
WALCommits atomic.Int64
// BackfillUpdates tracks per-named-backfill row write counts so an
// infinite-loop backfill (cf #1119) is obvious from the perf page.
BackfillUpdates sync.Map // name (string) -> *atomic.Int64
}
// IncBackfill increments the backfill counter for the given name, allocating
// the counter on first use.
func (s *DBStats) IncBackfill(name string) {
v, ok := s.BackfillUpdates.Load(name)
if !ok {
nc := new(atomic.Int64)
actual, loaded := s.BackfillUpdates.LoadOrStore(name, nc)
if loaded {
v = actual
} else {
v = nc
}
}
v.(*atomic.Int64).Add(1)
}
// SnapshotBackfills returns a name->count copy of all backfill counters.
func (s *DBStats) SnapshotBackfills() map[string]int64 {
out := make(map[string]int64)
s.BackfillUpdates.Range(func(k, v interface{}) bool {
out[k.(string)] = v.(*atomic.Int64).Load()
return true
})
return out
}
// Store wraps the SQLite database for packet ingestion.
type Store struct {
db *sql.DB
path string // filesystem path to the SQLite DB (used to resolve queue dirs)
Stats DBStats
stmtGetTxByHash *sql.Stmt
stmtInsertTransmission *sql.Stmt
stmtUpdateTxFirstSeen *sql.Stmt
stmtInsertObservation *sql.Stmt
stmtUpsertNode *sql.Stmt
stmtIncrementAdvertCount *sql.Stmt
stmtUpsertObserver *sql.Stmt
stmtGetTxByHash *sql.Stmt
stmtInsertTransmission *sql.Stmt
stmtUpdateTxFirstSeen *sql.Stmt
stmtInsertObservation *sql.Stmt
stmtUpsertNode *sql.Stmt
stmtIncrementAdvertCount *sql.Stmt
stmtUpsertObserver *sql.Stmt
stmtGetObserverRowid *sql.Stmt
stmtUpdateObserverLastSeen *sql.Stmt
stmtUpdateNodeTelemetry *sql.Stmt
stmtUpsertMetrics *sql.Stmt
sampleIntervalSec int
backfillWg sync.WaitGroup
}
// OpenStore opens or creates a SQLite DB at the given path, applying the
@@ -95,7 +57,7 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
return nil, fmt.Errorf("creating data dir: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=auto_vacuum(INCREMENTAL)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)")
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("opening db: %w", err)
}
@@ -112,14 +74,7 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
return nil, fmt.Errorf("applying schema: %w", err)
}
// Apply the additional server-originated migrations (now owned by
// the ingestor per #1287). Adds the indexes/columns that used to live
// in cmd/server/ensure_*.go: server now ASSERTS these exist.
if err := dbschema.Apply(db, log.Printf); err != nil {
return nil, fmt.Errorf("dbschema.Apply: %w", err)
}
s := &Store{db: db, path: dbPath, sampleIntervalSec: sampleIntervalSec}
s := &Store{db: db, sampleIntervalSec: sampleIntervalSec}
if err := s.prepareStatements(); err != nil {
return nil, fmt.Errorf("preparing statements: %w", err)
}
@@ -128,9 +83,6 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
}
func applySchema(db *sql.DB) error {
// auto_vacuum=INCREMENTAL is set via DSN pragma (must be before journal_mode).
// Logging of current mode is handled by CheckAutoVacuum — no duplicate log here.
schema := `
CREATE TABLE IF NOT EXISTS nodes (
public_key TEXT PRIMARY KEY,
@@ -142,8 +94,7 @@ func applySchema(db *sql.DB) error {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
temperature_c REAL
);
CREATE TABLE IF NOT EXISTS observers (
@@ -159,9 +110,7 @@ func applySchema(db *sql.DB) error {
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor REAL,
inactive INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
noise_floor REAL
);
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
@@ -177,8 +126,7 @@ func applySchema(db *sql.DB) error {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
temperature_c REAL
);
CREATE INDEX IF NOT EXISTS idx_inactive_nodes_last_seen ON inactive_nodes(last_seen);
@@ -192,15 +140,12 @@ func applySchema(db *sql.DB) error {
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
from_pubkey TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
-- idx_transmissions_from_pubkey is created by the from_pubkey_v1
-- migration after the column is added on legacy DBs (#1143).
`
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("base schema: %w", err)
@@ -242,7 +187,7 @@ func applySchema(db *sql.DB) error {
db.Exec(`DROP VIEW IF EXISTS packets_v`)
_, vErr := db.Exec(`
CREATE VIEW packets_v AS
SELECT o.id, COALESCE(o.raw_hex, t.raw_hex) AS raw_hex,
SELECT o.id, t.raw_hex,
datetime(o.timestamp, 'unixepoch') AS timestamp,
obs.id AS observer_id, obs.name AS observer_name,
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
@@ -250,7 +195,7 @@ func applySchema(db *sql.DB) error {
t.created_at
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx AND (obs.inactive IS NULL OR obs.inactive = 0)
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
`)
if vErr != nil {
return fmt.Errorf("packets_v view: %w", vErr)
@@ -262,16 +207,11 @@ func applySchema(db *sql.DB) error {
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'advert_count_unique_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Recalculating advert_count (unique transmissions only)...")
// Note: this migration is gated on a one-shot _migrations row, so it
// runs at most once per DB. The historical version used a LIKE-on-JSON
// substring match (#1143). Switching to from_pubkey here is safe even
// though the column may not yet be backfilled on legacy DBs: the
// migration is already marked done on those DBs and won't re-run.
db.Exec(`
UPDATE nodes SET advert_count = (
SELECT COUNT(*) FROM transmissions t
WHERE t.payload_type = 4
AND t.from_pubkey = nodes.public_key
AND t.decoded_json LIKE '%' || nodes.public_key || '%'
)
`)
db.Exec(`INSERT INTO _migrations (name) VALUES ('advert_count_unique_v1')`)
@@ -395,19 +335,6 @@ func applySchema(db *sql.DB) error {
log.Println("[migration] observer_metrics timestamp index created")
}
// Migration: add inactive column to observers for soft-delete retention
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observers_inactive_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding inactive column to observers...")
_, err := db.Exec(`ALTER TABLE observers ADD COLUMN inactive INTEGER DEFAULT 0`)
if err != nil {
// Column may already exist (e.g. fresh install with schema above)
log.Printf("[migration] observers.inactive: %v (may already exist)", err)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('observers_inactive_v1')`)
log.Println("[migration] observers.inactive column added")
}
// Migration: add packets_sent and packets_recv columns to observer_metrics
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observer_metrics_packets_v1'")
if row.Scan(&migDone) != nil {
@@ -440,122 +367,6 @@ func applySchema(db *sql.DB) error {
log.Println("[migration] channel_hash column added and backfilled")
}
// Migration: dropped_packets table for signature validation failures (#793)
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'dropped_packets_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Creating dropped_packets table...")
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS dropped_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT,
raw_hex TEXT,
reason TEXT NOT NULL,
observer_id TEXT,
observer_name TEXT,
node_pubkey TEXT,
node_name TEXT,
dropped_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_dropped_observer ON dropped_packets(observer_id);
CREATE INDEX IF NOT EXISTS idx_dropped_node ON dropped_packets(node_pubkey);
`)
if err != nil {
return fmt.Errorf("dropped_packets schema: %w", err)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('dropped_packets_v1')`)
log.Println("[migration] dropped_packets table created")
}
// Migration: observations.raw_hex (#881) is now owned by
// internal/dbschema/dbschema.go (#1321). The server PRAGMA-detects
// this column as hasObsRawHex; keeping a single canonical Apply
// path closes the startup race where the server's detector ran
// before this ALTER finished.
// Migration: transmissions.scope_name (#899) is now owned by
// internal/dbschema/dbschema.go (#1321). See above.
// Migration: add last_packet_at column to observers (#last-packet-at)
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observers_last_packet_at_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding last_packet_at column to observers...")
_, alterErr := db.Exec(`ALTER TABLE observers ADD COLUMN last_packet_at TEXT DEFAULT NULL`)
if alterErr != nil && !strings.Contains(alterErr.Error(), "duplicate column") {
return fmt.Errorf("observers last_packet_at ALTER: %w", alterErr)
}
// Backfill: set last_packet_at = last_seen only for observers that actually have
// observation rows (packet_count alone is unreliable — UpsertObserver sets it to 1
// on INSERT even for status-only observers).
res, err := db.Exec(`UPDATE observers SET last_packet_at = last_seen
WHERE last_packet_at IS NULL
AND rowid IN (SELECT DISTINCT observer_idx FROM observations WHERE observer_idx IS NOT NULL)`)
if err == nil {
n, _ := res.RowsAffected()
log.Printf("[migration] Backfilled last_packet_at for %d observers with packets", n)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('observers_last_packet_at_v1')`)
log.Println("[migration] observers.last_packet_at column added")
}
// Migration: backfill observations.path_json from raw_hex (#888)
// NOTE: This runs ASYNC via BackfillPathJSONAsync() to avoid blocking MQTT startup.
// See staging outage where ~502K rows blocked ingest for 15+ hours.
// One-time cleanup: delete legacy packets with empty hash or empty first_seen (#994)
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'cleanup_legacy_null_hash_ts'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Cleaning up legacy packets with empty hash/timestamp...")
db.Exec(`DELETE FROM observations WHERE transmission_id IN (SELECT id FROM transmissions WHERE hash = '' OR first_seen = '')`)
res, err := db.Exec(`DELETE FROM transmissions WHERE hash = '' OR first_seen = ''`)
if err == nil {
deleted, _ := res.RowsAffected()
log.Printf("[migration] deleted %d legacy packets with empty hash/timestamp", deleted)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('cleanup_legacy_null_hash_ts')`)
}
// Migration: foreign_advert column on nodes/inactive_nodes (#730)
// Marks nodes whose ADVERT GPS lies outside the configured geofilter polygon.
// Default 0; set to 1 by the ingestor when GeoFilter is configured and
// PassesFilter() returns false. Allows operators to surface bridged/leaked
// adverts without silently dropping them.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'foreign_advert_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding foreign_advert column to nodes/inactive_nodes...")
if _, err := db.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
log.Printf("[migration] nodes.foreign_advert: %v (may already exist)", err)
}
if _, err := db.Exec(`ALTER TABLE inactive_nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
log.Printf("[migration] inactive_nodes.foreign_advert: %v (may already exist)", err)
}
db.Exec(`CREATE INDEX IF NOT EXISTS idx_nodes_foreign_advert ON nodes(foreign_advert) WHERE foreign_advert = 1`)
db.Exec(`INSERT INTO _migrations (name) VALUES ('foreign_advert_v1')`)
log.Println("[migration] foreign_advert column added")
}
// Migration: from_pubkey column on transmissions (#1143).
// Replaces the unsound `decoded_json LIKE '%pubkey%'` attribution path with
// an exact-match indexed column. Synchronously adds the column + index;
// row-level backfill is run by the SERVER asynchronously
// (cmd/server/from_pubkey_migration.go) so we don't block ingestor boot.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'from_pubkey_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding from_pubkey column + index to transmissions (#1143)...")
if _, err := db.Exec(`ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT`); err != nil {
log.Printf("[migration] transmissions.from_pubkey: %v (may already exist)", err)
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`); err != nil {
log.Printf("[migration] idx_transmissions_from_pubkey: %v", err)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('from_pubkey_v1')`)
log.Println("[migration] from_pubkey column + index added")
}
// Migration: nodes.default_scope (#899 Feature 3) is now owned by
// internal/dbschema/dbschema.go (#1321). The server PRAGMA-detects
// this column as hasDefaultScope; keeping a single canonical Apply
// path closes the startup race that #1321 documented.
return nil
}
@@ -568,8 +379,8 @@ func (s *Store) prepareStatements() error {
}
s.stmtInsertTransmission, err = s.db.Prepare(`
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, scope_name, from_pubkey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
@@ -581,13 +392,8 @@ func (s *Store) prepareStatements() error {
}
s.stmtInsertObservation, err = s.db.Prepare(`
INSERT INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp, raw_hex)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(transmission_id, observer_idx, COALESCE(path_json, '')) DO UPDATE SET
snr = COALESCE(excluded.snr, snr),
rssi = COALESCE(excluded.rssi, rssi),
score = COALESCE(excluded.score, score),
raw_hex = COALESCE(excluded.raw_hex, raw_hex)
INSERT OR IGNORE INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
@@ -601,7 +407,7 @@ func (s *Store) prepareStatements() error {
role = COALESCE(?, role),
lat = COALESCE(?, lat),
lon = COALESCE(?, lon),
last_seen = MAX(MIN(COALESCE(last_seen, ''), ?), ?)
last_seen = ?
`)
if err != nil {
return err
@@ -620,7 +426,7 @@ func (s *Store) prepareStatements() error {
ON CONFLICT(id) DO UPDATE SET
name = COALESCE(?, name),
iata = COALESCE(?, iata),
last_seen = MAX(MIN(COALESCE(last_seen, ''), ?), ?),
last_seen = ?,
packet_count = packet_count + 1,
model = COALESCE(?, model),
firmware = COALESCE(?, firmware),
@@ -639,14 +445,7 @@ func (s *Store) prepareStatements() error {
return err
}
// Args: ingestNow, rxTime, ingestNow, rxTime, rowid
// MIN(existing, ingestNow) clamps any future value already in the DB before
// taking MAX with rxTime, so the guard never locks in a past bug's stale future.
s.stmtUpdateObserverLastSeen, err = s.db.Prepare(`
UPDATE observers SET
last_seen = MAX(MIN(COALESCE(last_seen, ''), ?), ?),
last_packet_at = MAX(MIN(COALESCE(last_packet_at, ''), ?), ?)
WHERE rowid = ?`)
s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ? WHERE rowid = ?")
if err != nil {
return err
}
@@ -680,10 +479,9 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
return false, nil
}
rxTime := data.Timestamp
ingestNow := time.Now().UTC().Format(time.RFC3339)
if rxTime == "" {
rxTime = ingestNow
now := data.Timestamp
if now == "" {
now = time.Now().UTC().Format(time.RFC3339)
}
var txID int64
@@ -696,18 +494,16 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
if err == nil {
// Existing transmission
txID = existingID
if rxTime < existingFirstSeen {
_, _ = s.stmtUpdateTxFirstSeen.Exec(rxTime, txID)
if now < existingFirstSeen {
_, _ = s.stmtUpdateTxFirstSeen.Exec(now, txID)
}
} else {
// New transmission
isNew = true
result, err := s.stmtInsertTransmission.Exec(
data.RawHex, hash, rxTime,
data.RawHex, hash, now,
data.RouteType, data.PayloadType, data.PayloadVersion,
data.DecodedJSON, nilIfEmpty(data.ChannelHash),
scopeNameForDB(data),
nilIfEmpty(data.FromPubkey),
)
if err != nil {
s.Stats.WriteErrors.Add(1)
@@ -728,22 +524,22 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid)
if err == nil {
observerIdx = &rowid
// Update observer last_seen and last_packet_at on every packet to prevent
// Update observer last_seen on every packet to prevent
// low-traffic observers from appearing offline (#463)
_, _ = s.stmtUpdateObserverLastSeen.Exec(ingestNow, rxTime, ingestNow, rxTime, rowid)
_, _ = s.stmtUpdateObserverLastSeen.Exec(now, rowid)
}
}
// Insert observation
epochTs := time.Now().Unix()
if t, err := time.Parse(time.RFC3339, rxTime); err == nil {
if t, err := time.Parse(time.RFC3339, now); err == nil {
epochTs = t.Unix()
}
_, err = s.stmtInsertObservation.Exec(
txID, observerIdx, data.Direction,
data.SNR, data.RSSI, data.Score,
data.PathJSON, epochTs, nilIfEmpty(data.RawHex),
data.PathJSON, epochTs,
)
if err != nil {
s.Stats.WriteErrors.Add(1)
@@ -752,23 +548,18 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
s.Stats.ObservationsInserted.Add(1)
}
// Each prepared-stmt Exec auto-commits. Count one WAL commit per
// successful InsertTransmission so the perf page sees commit pressure.
s.Stats.WALCommits.Add(1)
return isNew, nil
}
// UpsertNode inserts or updates a node.
func (s *Store) UpsertNode(pubKey, name, role string, lat, lon *float64, lastSeen string) error {
ingestNow := time.Now().UTC().Format(time.RFC3339)
now := lastSeen
if now == "" {
now = ingestNow
now = time.Now().UTC().Format(time.RFC3339)
}
_, err := s.stmtUpsertNode.Exec(
pubKey, name, role, lat, lon, now, now,
name, role, lat, lon, ingestNow, now,
name, role, lat, lon, now,
)
if err != nil {
s.Stats.WriteErrors.Add(1)
@@ -784,21 +575,6 @@ func (s *Store) IncrementAdvertCount(pubKey string) error {
return err
}
// MarkNodeForeign sets foreign_advert=1 on the node row identified by pubKey.
// Used when an ADVERT arrives whose GPS lies outside the configured geofilter
// polygon (#730). Idempotent — safe to call repeatedly. No-op if pubKey is
// empty.
func (s *Store) MarkNodeForeign(pubKey string) error {
if pubKey == "" {
return nil
}
_, err := s.db.Exec(`UPDATE nodes SET foreign_advert = 1 WHERE public_key = ?`, pubKey)
if err != nil {
s.Stats.WriteErrors.Add(1)
}
return err
}
// UpdateNodeTelemetry updates battery and temperature for a node.
func (s *Store) UpdateNodeTelemetry(pubKey string, batteryMv *int, temperatureC *float64) error {
var bv, tc interface{}
@@ -831,23 +607,9 @@ type ObserverMeta struct {
PacketsRecv *int // cumulative packets received since boot
}
// UpsertObserver inserts or updates an observer using the current wall-clock
// time as last_seen. Use UpsertObserverAt when the message envelope provides
// an observer receive-time (e.g. MQTT status and data packet handlers).
// UpsertObserver inserts or updates an observer with optional hardware metadata.
func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error {
return s.UpsertObserverAt(id, name, iata, meta, time.Now().UTC().Format(time.RFC3339))
}
// UpsertObserverAt inserts or updates an observer with an explicit lastSeen
// timestamp (typically the observer receive-time from the MQTT envelope). The
// SQL uses MAX so last_seen never moves backwards — a retained or replayed
// message whose rxTime pre-dates the existing last_seen is a no-op for that
// field, preventing offline observers from flashing as Online on reconnect.
func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, lastSeen string) error {
ingestNow := time.Now().UTC().Format(time.RFC3339)
if lastSeen == "" {
lastSeen = ingestNow
}
now := time.Now().UTC().Format(time.RFC3339)
normalizedIATA := strings.TrimSpace(strings.ToUpper(iata))
var model, firmware, clientVersion, radio interface{}
@@ -877,23 +639,19 @@ func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, last
}
_, err := s.stmtUpsertObserver.Exec(
id, name, normalizedIATA, lastSeen, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
name, normalizedIATA, ingestNow, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
id, name, normalizedIATA, now, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
name, normalizedIATA, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
)
if err != nil {
s.Stats.WriteErrors.Add(1)
return err
} else {
s.Stats.ObserverUpserts.Add(1)
}
s.Stats.ObserverUpserts.Add(1)
// Reactivate if this observer was previously marked inactive
s.db.Exec(`UPDATE observers SET inactive = 0 WHERE id = ? AND inactive = 1`, id)
return nil
return err
}
// Close checkpoints the WAL and closes the database.
func (s *Store) Close() error {
s.backfillWg.Wait()
s.Checkpoint()
return s.db.Close()
}
@@ -971,58 +729,6 @@ func (s *Store) PruneOldMetrics(retentionDays int) (int64, error) {
return n, nil
}
// CheckAutoVacuum inspects the current auto_vacuum mode and logs a warning
// if not INCREMENTAL. Performs opt-in full VACUUM if db.vacuumOnStartup is set (#919).
func (s *Store) CheckAutoVacuum(cfg *Config) {
var autoVacuum int
if err := s.db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
log.Printf("[db] warning: could not read auto_vacuum: %v", err)
return
}
if autoVacuum == 2 {
log.Printf("[db] auto_vacuum=INCREMENTAL")
return
}
modes := map[int]string{0: "NONE", 1: "FULL", 2: "INCREMENTAL"}
mode := modes[autoVacuum]
if mode == "" {
mode = fmt.Sprintf("UNKNOWN(%d)", autoVacuum)
}
log.Printf("[db] auto_vacuum=%s — DB needs one-time VACUUM to enable incremental auto-vacuum. "+
"Set db.vacuumOnStartup: true in config to migrate (will block startup for several minutes on large DBs). "+
"See https://github.com/Kpa-clawbot/CoreScope/issues/919", mode)
if cfg.DB != nil && cfg.DB.VacuumOnStartup {
// WARNING: Full VACUUM creates a temporary copy of the entire DB file.
// Requires ~2× the DB file size in free disk space or it will fail.
log.Printf("[db] vacuumOnStartup=true — starting one-time full VACUUM (ensure 2x DB size free disk space)...")
start := time.Now()
if _, err := s.db.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
log.Printf("[db] VACUUM failed: could not set auto_vacuum: %v", err)
return
}
if _, err := s.db.Exec("VACUUM"); err != nil {
log.Printf("[db] VACUUM failed: %v", err)
return
}
elapsed := time.Since(start)
log.Printf("[db] VACUUM complete in %v — auto_vacuum is now INCREMENTAL", elapsed.Round(time.Millisecond))
}
}
// RunIncrementalVacuum returns free pages to the OS (#919).
// Safe to call on auto_vacuum=NONE databases (noop).
func (s *Store) RunIncrementalVacuum(pages int) {
if _, err := s.db.Exec(fmt.Sprintf("PRAGMA incremental_vacuum(%d)", pages)); err != nil {
log.Printf("[vacuum] incremental_vacuum error: %v", err)
}
}
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
// preventing lock contention with a new process starting up.
func (s *Store) Checkpoint() {
@@ -1033,159 +739,15 @@ func (s *Store) Checkpoint() {
}
}
// BackfillPathJSONAsync launches the path_json backfill in a background goroutine.
// It processes observations with NULL/empty path_json that have raw_hex available,
// decoding hop paths and updating the column. Safe to run concurrently with ingest
// because new observations get path_json at write time; this only touches NULL rows.
// Idempotent: skips if migration already recorded.
func (s *Store) BackfillPathJSONAsync() {
s.backfillWg.Add(1)
go func() {
defer s.backfillWg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[backfill] path_json async panic recovered: %v", r)
}
}()
var migDone int
row := s.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'")
if row.Scan(&migDone) == nil {
return // already done
}
log.Println("[backfill] Starting async path_json backfill from raw_hex...")
updated := 0
errored := false
const batchSize = 1000
batchNum := 0
for {
rows, err := s.db.Query(`
SELECT o.id, o.raw_hex
FROM observations o
JOIN transmissions t ON o.transmission_id = t.id
WHERE o.raw_hex IS NOT NULL AND o.raw_hex != ''
-- NB: '[]' is the "already attempted, no hops" sentinel; excluded
-- to prevent the infinite re-UPDATE loop fixed in #1119.
AND (o.path_json IS NULL OR o.path_json = '')
AND t.payload_type != 9
LIMIT ?`, batchSize)
if err != nil {
log.Printf("[backfill] path_json query error: %v", err)
errored = true
break
}
type pendingRow struct {
id int64
rawHex string
}
var batch []pendingRow
for rows.Next() {
var r pendingRow
if err := rows.Scan(&r.id, &r.rawHex); err == nil {
batch = append(batch, r)
}
}
rows.Close()
if len(batch) == 0 {
break
}
for _, r := range batch {
hops, err := packetpath.DecodePathFromRawHex(r.rawHex)
if err != nil || len(hops) == 0 {
if _, execErr := s.db.Exec(`UPDATE observations SET path_json = '[]' WHERE id = ?`, r.id); execErr != nil {
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
} else {
s.Stats.IncBackfill("path_json")
}
continue
}
b, _ := json.Marshal(hops)
if _, execErr := s.db.Exec(`UPDATE observations SET path_json = ? WHERE id = ?`, string(b), r.id); execErr != nil {
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
} else {
updated++
s.Stats.IncBackfill("path_json")
}
}
batchNum++
if batchNum%50 == 0 {
log.Printf("[backfill] progress: %d observations updated so far (%d batches)", updated, batchNum)
}
// Throttle: yield to ingest writers between batches
time.Sleep(50 * time.Millisecond)
}
log.Printf("[backfill] Async path_json backfill complete: %d observations updated", updated)
if !errored {
s.db.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_path_json_from_raw_hex_v1')`)
} else {
log.Printf("[backfill] NOT recording migration due to errors — will retry on next restart")
}
}()
}
// BackfillDefaultScopeAsync populates default_scope for existing nodes that have
// transport-scoped ADVERT rows (scope_name IS NOT NULL AND scope_name != “).
// Runs in a background goroutine so it does not block MQTT startup.
// Uses the from_pubkey index — O(nodes × indexed lookup), not a full table scan.
//
// Concurrency: the store uses SetMaxOpenConns(1) so all DB writes — including
// MQTT packet inserts and any concurrent backfill goroutines — serialize through
// the single connection pool. busy_timeout(5000) handles transient cross-process
// contention with the read-only server process. No additional locking is needed.
func (s *Store) BackfillDefaultScopeAsync(regionKeys map[string][]byte) {
// No region keys configured — all scope_name values will be NULL, nothing to backfill.
if len(regionKeys) == 0 {
return
}
s.backfillWg.Add(1)
go func() {
defer s.backfillWg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[backfill] default_scope async panic recovered: %v", r)
}
}()
var done int
if s.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_default_scope_v1'").Scan(&done) == nil {
return // already ran
}
res, err := s.db.Exec(`
UPDATE nodes SET default_scope = (
SELECT t.scope_name FROM transmissions t
WHERE t.from_pubkey = nodes.public_key
AND t.payload_type = 4
AND t.scope_name IS NOT NULL AND t.scope_name != ''
ORDER BY t.first_seen DESC LIMIT 1 -- most-recently observed scope wins; first_seen is insertion time
) WHERE EXISTS (
SELECT 1 FROM transmissions t
WHERE t.from_pubkey = nodes.public_key
AND t.payload_type = 4
AND t.scope_name IS NOT NULL AND t.scope_name != ''
)`)
if err != nil {
log.Printf("[backfill] default_scope: %v", err)
return
}
n, _ := res.RowsAffected()
s.Stats.IncBackfill("default_scope")
log.Printf("[backfill] default_scope populated for %d nodes", n)
s.db.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_default_scope_v1')`)
}()
}
// LogStats logs current operational metrics.
func (s *Store) LogStats() {
log.Printf("[stats] tx_inserted=%d tx_dupes=%d obs_inserted=%d node_upserts=%d observer_upserts=%d write_errors=%d sig_drops=%d",
log.Printf("[stats] tx_inserted=%d tx_dupes=%d obs_inserted=%d node_upserts=%d observer_upserts=%d write_errors=%d",
s.Stats.TransmissionsInserted.Load(),
s.Stats.DuplicateTransmissions.Load(),
s.Stats.ObservationsInserted.Load(),
s.Stats.NodeUpserts.Load(),
s.Stats.ObserverUpserts.Load(),
s.Stats.WriteErrors.Load(),
s.Stats.SignatureDrops.Load(),
)
}
@@ -1217,93 +779,23 @@ func (s *Store) MoveStaleNodes(nodeDays int) (int64, error) {
return moved, nil
}
// RemoveStaleObservers marks observers that have not actively sent data in observerDays
// as inactive (soft-delete). This preserves JOIN integrity for observations.observer_idx
// and observer_metrics.observer_id — historical data still references the correct observer.
// An observer must actively send data to stay listed — being seen by another node does not count.
// observerDays <= -1 means never remove (keep forever).
func (s *Store) RemoveStaleObservers(observerDays int) (int64, error) {
if observerDays <= -1 {
return 0, nil // keep forever
}
cutoff := time.Now().UTC().AddDate(0, 0, -observerDays).Format(time.RFC3339)
result, err := s.db.Exec(`UPDATE observers SET inactive = 1 WHERE last_seen < ? AND (inactive IS NULL OR inactive = 0)`, cutoff)
if err != nil {
return 0, fmt.Errorf("mark stale observers inactive: %w", err)
}
removed, _ := result.RowsAffected()
if removed > 0 {
// Clean up orphaned metrics for now-inactive observers
s.db.Exec(`DELETE FROM observer_metrics WHERE observer_id IN (SELECT id FROM observers WHERE inactive = 1)`)
log.Printf("Marked %d observer(s) as inactive (not seen in %d days)", removed, observerDays)
}
return removed, nil
}
// DroppedPacket holds data for a packet rejected during ingest.
type DroppedPacket struct {
Hash string
RawHex string
Reason string
ObserverID string
ObserverName string
NodePubKey string
NodeName string
}
// InsertDroppedPacket records a rejected packet in the dropped_packets table.
func (s *Store) InsertDroppedPacket(dp *DroppedPacket) error {
_, err := s.db.Exec(
`INSERT INTO dropped_packets (hash, raw_hex, reason, observer_id, observer_name, node_pubkey, node_name) VALUES (?, ?, ?, ?, ?, ?, ?)`,
dp.Hash, dp.RawHex, dp.Reason, dp.ObserverID, dp.ObserverName, dp.NodePubKey, dp.NodeName,
)
if err != nil {
s.Stats.WriteErrors.Add(1)
return fmt.Errorf("insert dropped packet: %w", err)
}
s.Stats.SignatureDrops.Add(1)
return nil
}
// PruneDroppedPackets removes dropped_packets older than retentionDays.
func (s *Store) PruneDroppedPackets(retentionDays int) (int64, error) {
if retentionDays <= 0 {
return 0, nil
}
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
result, err := s.db.Exec(`DELETE FROM dropped_packets WHERE dropped_at < ?`, cutoff)
if err != nil {
return 0, fmt.Errorf("prune dropped packets: %w", err)
}
n, _ := result.RowsAffected()
if n > 0 {
log.Printf("Pruned %d dropped packet(s) older than %d days", n, retentionDays)
}
return n, nil
}
// PacketData holds the data needed to insert a packet into the DB.
type PacketData struct {
RawHex string
Timestamp string
ObserverID string
ObserverName string
SNR *float64
RSSI *float64
Score *float64
Direction *string
Hash string
RouteType int
PayloadType int
PayloadVersion int
PathJSON string
DecodedJSON string
ChannelHash string // grouping key for channel queries (#762)
ScopeName string // matched region name, or "" for unknown-scoped
IsTransportScoped bool // true when route_type IN (0,3) AND Code1 ≠ "0000"
Region string // observer region: payload > topic > source config (#788)
Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730)
FromPubkey string // pubkey of the originating node, for exact-match attribution (#1143)
RawHex string
Timestamp string
ObserverID string
ObserverName string
SNR *float64
RSSI *float64
Score *float64
Direction *string
Hash string
RouteType int
PayloadType int
PayloadVersion int
PathJSON string
DecodedJSON string
ChannelHash string // grouping key for channel queries (#762)
}
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
@@ -1314,36 +806,6 @@ func nilIfEmpty(s string) interface{} {
return s
}
// scopeNameForDB encodes PacketData scope semantics for DB storage:
// non-transport-scoped → nil (SQL NULL); transport-scoped → pointer to ScopeName
// (may be "" for unknown region, "#name" for matched region).
func scopeNameForDB(data *PacketData) *string {
if !data.IsTransportScoped {
return nil
}
s := data.ScopeName
return &s
}
// UpdateNodeDefaultScope records the most-recently observed region scope for a
// node. Skips the UPDATE when the stored value already matches to avoid
// redundant writes on the hot MQTT ingest path. Updates both nodes and
// inactive_nodes to stay consistent.
func (s *Store) UpdateNodeDefaultScope(pubkey, scope string) error {
// Short-circuit: skip if already stored.
var cur sql.NullString
row := s.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = ?`, pubkey)
if row.Scan(&cur) == nil && cur.Valid && cur.String == scope {
return nil
}
if _, err := s.db.Exec(`UPDATE nodes SET default_scope = ? WHERE public_key = ?`, scope, pubkey); err != nil {
return err
}
// Mirror to inactive_nodes (node may be there if recently moved by retention).
_, err := s.db.Exec(`UPDATE inactive_nodes SET default_scope = ? WHERE public_key = ?`, scope, pubkey)
return err
}
// MQTTPacketMessage is the JSON payload from an MQTT raw packet message.
type MQTTPacketMessage struct {
Raw string `json:"raw"`
@@ -1352,32 +814,20 @@ type MQTTPacketMessage struct {
Score *float64 `json:"score"`
Direction *string `json:"direction"`
Origin string `json:"origin"`
Region string `json:"region,omitempty"` // optional region override (#788)
Timestamp string `json:"timestamp,omitempty"` // observer receive time, resolved by handler
}
// BuildPacketData constructs a PacketData from a decoded packet and MQTT message.
// path_json is derived directly from raw_hex header bytes (not decoded.Path.Hops)
// to guarantee the stored path always matches the raw bytes. This matters for
// TRACE packets where decoded.Path.Hops is overwritten with payload hops (#886).
func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID, region string, regionKeys map[string][]byte) *PacketData {
func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID, region string) *PacketData {
now := time.Now().UTC().Format(time.RFC3339)
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 {
b, _ := json.Marshal(decoded.Path.Hops)
pathJSON = string(b)
}
} else if hops, err := packetpath.DecodePathFromRawHex(msg.Raw); err == nil && len(hops) > 0 {
b, _ := json.Marshal(hops)
if len(decoded.Path.Hops) > 0 {
b, _ := json.Marshal(decoded.Path.Hops)
pathJSON = string(b)
}
pd := &PacketData{
RawHex: msg.Raw,
Timestamp: msg.Timestamp,
Timestamp: now,
ObserverID: observerID,
ObserverName: msg.Origin,
SNR: msg.SNR,
@@ -1392,13 +842,6 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
DecodedJSON: PayloadJSON(&decoded.Payload),
}
// Region priority: payload field > topic-derived parameter (#788)
if msg.Region != "" {
pd.Region = msg.Region
} else {
pd.Region = region
}
// Populate channel_hash for fast channel queries (#762)
if decoded.Header.PayloadType == PayloadGRP_TXT {
if decoded.Payload.Type == "CHAN" && decoded.Payload.Channel != "" {
@@ -1408,17 +851,5 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
}
}
if decoded.TransportCodes != nil && decoded.TransportCodes.Code1 != "0000" {
pd.IsTransportScoped = true
pd.ScopeName = matchScope(regionKeys, byte(decoded.Header.PayloadType), decoded.payloadRaw, decoded.TransportCodes.Code1)
}
// Populate from_pubkey at write time (#1143). ADVERTs carry the
// originating node's pubkey directly; other packet types stay NULL
// (downstream attribution queries handle NULL gracefully).
if decoded.Header.PayloadType == PayloadADVERT && decoded.Payload.PubKey != "" {
pd.FromPubkey = decoded.Payload.PubKey
}
return pd
}
+12 -974
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
package main
import (
"bytes"
"log"
"strings"
"testing"
)
// TestHandleMessageDecodeErrorLog_PII — issue #1211 round-0 fix shipped without
// a test. Asserts the decode-error log line:
// (a) includes structured fields: topic, observer prefix, payload length
// (b) observer substring is at most 8 chars
// (c) full observer ID is NOT present in the output
//
// A bare `log.Printf("... observer=%s ...", obs)` would leak the full ID.
func TestHandleMessageDecodeErrorLog_PII_Issue1211(t *testing.T) {
store, source := newTestContext(t)
// Use a 64-char observer ID; the prefix MUST be capped at 8 chars in logs.
observerID := "abcdef0123456789aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// Malformed raw — pathByte=0xF6 claims 216 path bytes in a tiny buffer.
// This triggers the decode-error path under test.
rawHex := "12F6AAAAAAAAAAAAAAAAAAAAAAAAAA"
topic := "meshcore/SJC/" + observerID + "/packets"
payload := []byte(`{"raw":"` + rawHex + `"}`)
msg := &mockMessage{topic: topic, payload: payload}
var buf bytes.Buffer
orig := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(orig)
handleMessage(store, "test", source, msg, nil, nil, &Config{})
out := buf.String()
if !strings.Contains(out, "decode error") {
t.Fatalf("expected decode-error log; got:\n%s", out)
}
// (a) structured fields present
if !strings.Contains(out, "topic=") {
t.Errorf("log missing topic=; got:\n%s", out)
}
if !strings.Contains(out, "observer=") {
t.Errorf("log missing observer=; got:\n%s", out)
}
if !strings.Contains(out, "rawHexLen=") {
t.Errorf("log missing rawHexLen=; got:\n%s", out)
}
// (c) full observer ID must NOT appear
if strings.Contains(out, observerID) {
t.Errorf("log leaked full observer ID; got:\n%s", out)
}
// (b) observer substring capped at 8 chars — the 9th char ('2') after the
// 8-char prefix must NOT appear adjacent to the prefix.
if strings.Contains(out, "abcdef01234") {
t.Errorf("log observer field longer than 8 chars; got:\n%s", out)
}
// Positive: 8-char prefix must be present in the log
if !strings.Contains(out, "abcdef01") {
t.Errorf("log missing 8-char observer prefix; got:\n%s", out)
}
}
+17 -338
View File
@@ -12,7 +12,6 @@ import (
"strings"
"unicode/utf8"
"github.com/meshcore-analyzer/packetpath"
"github.com/meshcore-analyzer/sigvalidate"
)
@@ -126,39 +125,16 @@ type Payload struct {
ChannelHashHex string `json:"channelHashHex,omitempty"`
DecryptionStatus string `json:"decryptionStatus,omitempty"`
Channel string `json:"channel,omitempty"`
// GRP_DATA (PAYLOAD_TYPE_GRP_DATA=0x06) inner fields, decoded after
// channel decrypt per firmware/src/helpers/BaseChatMesh.cpp:382-385.
DataType *int `json:"dataType,omitempty"`
DataLen *int `json:"dataLen,omitempty"`
DecryptedBlob string `json:"decryptedBlob,omitempty"`
Text string `json:"text,omitempty"`
Sender string `json:"sender,omitempty"`
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"`
SNRValues []float64 `json:"snrValues,omitempty"`
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
// MULTIPART (PAYLOAD_TYPE_MULTIPART=0x0A) inner fields, decoded per
// firmware/src/Mesh.cpp:289 — byte0 = (remaining<<4) | inner_type.
Remaining *int `json:"remaining,omitempty"`
InnerType *int `json:"innerType,omitempty"`
InnerTypeName string `json:"innerTypeName,omitempty"`
InnerAckCrc string `json:"innerAckCrc,omitempty"`
InnerPayload string `json:"innerPayload,omitempty"`
// CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per
// firmware/src/Mesh.cpp:69 — byte0 high-bit marks zero-hop direct subset.
CtrlFlags string `json:"ctrlFlags,omitempty"`
CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"`
CtrlLength *int `json:"ctrlLength,omitempty"`
// RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per
// firmware/src/Mesh.cpp:577 (createRawData). Exposes the bare envelope
// shape (length + leading tag) so consumers can triage by app id.
RawLength *int `json:"rawLength,omitempty"`
FirstByteTag string `json:"firstByteTag,omitempty"`
}
// DecodedPacket is the full decoded result.
@@ -169,7 +145,6 @@ type DecodedPacket struct {
Payload Payload `json:"payload"`
Raw string `json:"raw"`
Anomaly string `json:"anomaly,omitempty"`
payloadRaw []byte
}
func decodeHeader(b byte) Header {
@@ -195,35 +170,9 @@ func decodeHeader(b byte) Header {
}
}
// Firmware-derived limits — see firmware/src/MeshCore.h:19,21.
const (
maxPathSize = 64 // MAX_PATH_SIZE — total path bytes allowed
maxPacketPayload = 184 // MAX_PACKET_PAYLOAD — max raw payload bytes
)
// isValidPathLen mirrors firmware Packet::isValidPathLen
// (firmware/src/Packet.cpp:13-18). hash_size==4 is reserved; total path bytes
// must fit within MAX_PATH_SIZE.
func isValidPathLen(pathByte byte) bool {
hashCount := int(pathByte & 0x3F)
hashSize := int(pathByte>>6) + 1
if hashSize == 4 {
return false // reserved
}
return hashCount*hashSize <= maxPathSize
}
func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
// Exact mirror of firmware Packet::isValidPathLen (Packet.cpp:13-18).
// hash_size==4 is reserved and is rejected by firmware regardless of
// hash_count, so we must reject 0xC0 etc even on zero-hop packets —
// firmware never emits them, so an on-wire pathByte with the upper
// 2 bits set to 11 is by definition malformed/adversarial.
if !isValidPathLen(pathByte) {
return Path{}, 0, fmt.Errorf("invalid path encoding: pathByte 0x%02X (hash_size=%d hash_count=%d) violates firmware validity (Packet.cpp:13-18, MAX_PATH_SIZE=%d)", pathByte, hashSize, hashCount, maxPathSize)
}
totalBytes := hashSize * hashCount
hops := make([]string, 0, hashCount)
@@ -240,12 +189,11 @@ func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
HashSize: hashSize,
HashCount: hashCount,
Hops: hops,
}, totalBytes, nil
}, totalBytes
}
// isTransportRoute delegates to packetpath.IsTransportRoute.
func isTransportRoute(routeType int) bool {
return packetpath.IsTransportRoute(routeType)
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
@@ -349,13 +297,6 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
}
name := string(appdata[off:nameEnd])
name = sanitizeName(name)
// Firmware writes the node name into a 32-byte buffer
// (MAX_ADVERT_DATA_SIZE, firmware/src/MeshCore.h:11). Truncate
// here so adversarial on-wire adverts can't pollute Payload.Name
// with bytes firmware would never emit.
if len(name) > 32 {
name = name[:32]
}
p.Name = name
off = nameEnd
// Skip null terminator(s)
@@ -366,17 +307,6 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
// Only sensor nodes (advType=4) carry telemetry bytes.
//
// Firmware derivation (see firmware/src/helpers/SensorMesh.h and the
// SensorHost::handleAdvert path in firmware/src/helpers/SensorMesh.cpp:
// the sensor builds appdata as <flags+adv_type><pubkey?><name\0>
// followed by two little-endian uint16 fields appended verbatim:
// appdata[name_end+0..1] = battery voltage in millivolts (uint16 LE,
// valid 0 < mv ≤ 10000)
// appdata[name_end+2..3] = temperature × 100 (int16 LE, divide by 100
// for °C; valid raw -5000..10000 → -50..100 °C)
// We accept only adverts whose flags.Sensor bit is set (firmware
// AdvertDataHelpers.h:7-12, ADV_TYPE_SENSOR=4) before parsing telemetry.
if p.Flags.Sensor && off+4 <= len(appdata) {
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
@@ -546,185 +476,6 @@ func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
}
}
// decodeGrpData decodes PAYLOAD_TYPE_GRP_DATA (0x06). Outer envelope is the
// same shape as GRP_TXT (channel_hash(1)+MAC(2)+ciphertext) — see
// firmware/src/helpers/BaseChatMesh.cpp:476,500. When the channel key matches,
// the decrypted inner is parsed per firmware/src/helpers/BaseChatMesh.cpp:382-385
// as data_type(uint16 LE) + data_len(1) + blob(data_len).
func decodeGrpData(buf []byte, channelKeys map[string]string) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_DATA", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
channelHash := int(buf[0])
channelHashHex := fmt.Sprintf("%02X", buf[0])
mac := hex.EncodeToString(buf[1:3])
encryptedData := hex.EncodeToString(buf[3:])
hasKeys := len(channelKeys) > 0
if hasKeys && len(encryptedData) >= 10 {
for name, key := range channelKeys {
plain, err := decryptChannelBlock(encryptedData, mac, key)
if err != nil {
continue
}
// Inner: data_type(uint16 LE) + data_len(1) + blob (firmware:382-385).
if len(plain) < 3 {
return Payload{
Type: "GRP_DATA",
Channel: name,
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
Error: "inner too short",
}
}
dataType := int(binary.LittleEndian.Uint16(plain[0:2]))
dataLen := int(plain[2])
if 3+dataLen > len(plain) {
return Payload{
Type: "GRP_DATA",
Channel: name,
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
DataType: &dataType,
DataLen: &dataLen,
Error: "inner data_len exceeds buffer",
}
}
blob := hex.EncodeToString(plain[3 : 3+dataLen])
return Payload{
Type: "GRP_DATA",
Channel: name,
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
DataType: &dataType,
DataLen: &dataLen,
DecryptedBlob: blob,
}
}
return Payload{
Type: "GRP_DATA",
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decryption_failed",
MAC: mac,
EncryptedData: encryptedData,
}
}
return Payload{
Type: "GRP_DATA",
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "no_key",
MAC: mac,
EncryptedData: encryptedData,
}
}
// decodeMultipart decodes PAYLOAD_TYPE_MULTIPART (0x0A) per
// firmware/src/Mesh.cpp:287-310. byte0 = (remaining<<4) | inner_type;
// when inner_type == PAYLOAD_TYPE_ACK the next 4 bytes are an ack_crc.
func decodeMultipart(buf []byte) Payload {
if len(buf) < 1 {
return Payload{Type: "MULTIPART", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
remaining := int(buf[0] >> 4)
innerType := int(buf[0] & 0x0F)
innerName := payloadTypeNames[innerType]
if innerName == "" {
innerName = "UNKNOWN"
}
p := Payload{
Type: "MULTIPART",
Remaining: &remaining,
InnerType: &innerType,
InnerTypeName: innerName,
}
if innerType == PayloadACK && len(buf) >= 5 {
// ack_crc is little-endian; surface as canonical big-endian hex
// to match decodeAck's extraHash convention.
crc := binary.LittleEndian.Uint32(buf[1:5])
p.InnerAckCrc = fmt.Sprintf("%08x", crc)
} else if len(buf) > 1 {
p.InnerPayload = hex.EncodeToString(buf[1:])
}
return p
}
// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per
// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset).
func decodeControl(buf []byte) Payload {
if len(buf) < 1 {
return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
zeroHop := buf[0]&0x80 != 0
length := len(buf)
return Payload{
Type: "CONTROL",
CtrlFlags: fmt.Sprintf("%02x", buf[0]),
CtrlZeroHop: &zeroHop,
CtrlLength: &length,
RawHex: hex.EncodeToString(buf),
}
}
// decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). Application-defined
// payload per firmware/src/Mesh.cpp:577 (createRawData); we only surface the
// envelope shape (total length + leading tag byte).
func decodeRawCustom(buf []byte) Payload {
length := len(buf)
p := Payload{
Type: "RAW_CUSTOM",
RawLength: &length,
RawHex: hex.EncodeToString(buf),
}
if length > 0 {
p.FirstByteTag = fmt.Sprintf("%02X", buf[0])
}
return p
}
// decryptChannelBlock performs the MAC verify + AES-128-ECB decrypt step shared
// by GRP_TXT and GRP_DATA, returning the raw plaintext block (no further
// parsing). See firmware/src/helpers/BaseChatMesh.cpp:376-391.
func decryptChannelBlock(ciphertextHex, macHex, channelKeyHex string) ([]byte, error) {
channelKey, err := hex.DecodeString(channelKeyHex)
if err != nil || len(channelKey) != 16 {
return nil, fmt.Errorf("invalid channel key")
}
macBytes, err := hex.DecodeString(macHex)
if err != nil || len(macBytes) != 2 {
return nil, fmt.Errorf("invalid MAC")
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil || len(ciphertext) == 0 {
return nil, fmt.Errorf("invalid ciphertext")
}
channelSecret := make([]byte, 32)
copy(channelSecret, channelKey)
h := hmac.New(sha256.New, channelSecret)
h.Write(ciphertext)
calc := h.Sum(nil)
if calc[0] != macBytes[0] || calc[1] != macBytes[1] {
return nil, fmt.Errorf("MAC verification failed")
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
}
block, err := aes.NewCipher(channelKey)
if err != nil {
return nil, err
}
plain := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
return plain, nil
}
func decodeAnonReq(buf []byte) Payload {
if len(buf) < 35 {
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
@@ -784,20 +535,12 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string, v
return decodeAdvert(buf, validateSignatures)
case PayloadGRP_TXT:
return decodeGrpTxt(buf, channelKeys)
case PayloadGRP_DATA:
return decodeGrpData(buf, channelKeys)
case PayloadANON_REQ:
return decodeAnonReq(buf)
case PayloadPATH:
return decodePathPayload(buf)
case PayloadTRACE:
return decodeTrace(buf)
case PayloadMULTIPART:
return decodeMultipart(buf)
case PayloadCONTROL:
return decodeControl(buf)
case PayloadRAW_CUSTOM:
return decodeRawCustom(buf)
default:
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
}
@@ -838,26 +581,10 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
pathByte := buf[offset]
offset++
path, bytesConsumed, decodeErr := decodePath(pathByte, buf, offset)
if decodeErr != nil {
return nil, decodeErr
}
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
// Bounds check: pathByte is wire-supplied (hash_size in upper 2 bits,
// hash_count in lower 6 bits → up to 4*63=252 claimed path bytes). A
// malformed packet can claim more bytes than the buffer holds — without
// this guard `buf[offset:]` panics with `slice bounds out of range
// [offset:len(buf)]`. See issue #1211 (prod observed [218:15]).
if offset > len(buf) {
return nil, fmt.Errorf("packet path length (%d bytes claimed by pathByte 0x%02X) exceeds buffer (%d bytes)", bytesConsumed, pathByte, len(buf))
}
payloadBuf := buf[offset:]
// Firmware caps payload at MAX_PACKET_PAYLOAD=184 (firmware/src/MeshCore.h:19).
if len(payloadBuf) > maxPacketPayload {
return nil, fmt.Errorf("packet payload (%d bytes) exceeds firmware MAX_PACKET_PAYLOAD=%d (MeshCore.h:19)", len(payloadBuf), maxPacketPayload)
}
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys, validateSignatures)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
@@ -870,9 +597,6 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
// We expose hopsCompleted (count of SNR bytes) so consumers can distinguish
// how far the trace got vs the full intended route.
var anomaly string
if header.PayloadType == PayloadTRACE && payload.Error != "" {
anomaly = fmt.Sprintf("TRACE payload decode failed: %s", payload.Error)
}
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
// Flag anomalous routing — firmware only sends TRACE as DIRECT
if header.RouteType != RouteDirect && header.RouteType != RouteTransportDirect {
@@ -880,21 +604,6 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
}
// The header path hops count represents SNR entries = completed hops
hopsCompleted := path.HashCount
// Extract per-hop SNR from header path bytes (int8, quarter-dB encoding).
// Mirrors cmd/server/decoder.go — must be done at ingest time so SNR
// values are persisted in decoded_json (server endpoint serves DB as-is).
if hopsCompleted > 0 && len(path.Hops) >= hopsCompleted {
snrVals := make([]float64, 0, hopsCompleted)
for i := 0; i < hopsCompleted; i++ {
b, err := hex.DecodeString(path.Hops[i])
if err == nil && len(b) == 1 {
snrVals = append(snrVals, float64(int8(b[0]))/4.0)
}
}
if len(snrVals) > 0 {
payload.SNRValues = snrVals
}
}
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && payload.TraceFlags != nil {
// path_sz from flags byte is a power-of-two exponent per firmware:
@@ -928,14 +637,12 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
Payload: payload,
Raw: strings.ToUpper(hexString),
Anomaly: anomaly,
payloadRaw: payloadBuf,
}, nil
}
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
// It hashes the payload-type nibble + payload (skipping path bytes) to produce a
// route-independent identifier for the same logical packet. For TRACE packets,
// path_len is included in the hash to match firmware behavior.
// It hashes the header byte + payload (skipping path bytes) to produce a
// path-independent identifier for the same transmission.
func ComputeContentHash(rawHex string) string {
buf, err := hex.DecodeString(rawHex)
if err != nil || len(buf) < 2 {
@@ -971,18 +678,7 @@ func ComputeContentHash(rawHex string) string {
}
payload := buf[payloadStart:]
// Hash payload-type byte only (bits 2-5 of header), not the full header.
// Firmware: SHA256(payload_type + [path_len for TRACE] + payload)
// Using the full header caused different hashes for the same logical packet
// when route type or version bits differed. See issue #786.
payloadType := (headerByte >> 2) & 0x0F
toHash := []byte{payloadType}
if int(payloadType) == PayloadTRACE {
// Firmware uses uint16_t path_len (2 bytes, little-endian)
toHash = append(toHash, pathByte, 0x00)
}
toHash = append(toHash, payload...)
toHash := append([]byte{headerByte}, payload...)
h := sha256.Sum256(toHash)
return hex.EncodeToString(h[:])[:16]
@@ -1046,13 +742,8 @@ func ValidateAdvert(p *Payload) (bool, string) {
if p.Flags != nil {
role := advertRole(p.Flags)
// Accept canonical labels plus "none" (ADV_TYPE_NONE=0) and the
// "type-N" placeholders we now return for ADV_TYPE 5-15 (FUTURE)
// — see firmware/src/helpers/AdvertDataHelpers.h:7-12.
validRoles := map[string]bool{
"repeater": true, "companion": true, "room": true, "sensor": true, "none": true,
}
if !validRoles[role] && !strings.HasPrefix(role, "type-") {
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
if !validRoles[role] {
return false, fmt.Sprintf("unknown role: %s", role)
}
}
@@ -1072,29 +763,17 @@ func sanitizeName(s string) string {
return b.String()
}
// advertRole returns a stable role label for an advert. Follows firmware
// ADV_TYPE_* constants in firmware/src/helpers/AdvertDataHelpers.h:7-12:
// 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR, 5-15 FUTURE.
// Previously this coerced both 0 (NONE) and 5-15 (FUTURE) to "companion",
// silently relabelling unknown/reserved types — see issue #1279 P1 #3.
func advertRole(f *AdvertFlags) string {
if f == nil {
return "companion"
}
switch f.Type {
case 0:
return "none"
case 1:
return "companion"
case 2:
if f.Repeater {
return "repeater"
case 3:
return "room"
case 4:
return "sensor"
default:
return fmt.Sprintf("type-%d", f.Type)
}
if f.Room {
return "room"
}
if f.Sensor {
return "sensor"
}
return "companion"
}
func epochToISO(epoch uint32) string {
-97
View File
@@ -1,97 +0,0 @@
package main
import (
"encoding/hex"
"strings"
"testing"
)
// --- Issue #1211 round-1 protocol-correctness regressions ---
// See cmd/server/decoder_bounds_test.go for full firmware citations
// (firmware/src/Packet.cpp:13-18, firmware/src/MeshCore.h:19-21).
// pathByte=0xF6 → hash_size=4 (reserved), hash_count=54.
// Buffer holds all 216 claimed bytes so the OOB guard does NOT catch.
func TestDecodePacketRejectsReservedHashSize_Issue1211(t *testing.T) {
raw := "12F6" + strings.Repeat("AB", 216) + strings.Repeat("CD", 8)
pkt, err := DecodePacket(raw, nil, false)
if err == nil {
t.Fatalf("expected error rejecting reserved hash_size=4 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
}
if !strings.Contains(err.Error(), "path") {
t.Errorf("error should mention path; got %q", err)
}
}
// pathByte=0xBF → hash_size=3, hash_count=63, total=189 > MAX_PATH_SIZE=64.
func TestDecodePacketRejectsOversizedPath_Issue1211(t *testing.T) {
raw := "12BF" + strings.Repeat("AB", 189) + strings.Repeat("CD", 8)
pkt, err := DecodePacket(raw, nil, false)
if err == nil {
t.Fatalf("expected error rejecting hash_count*hash_size > 64; got nil, pkt=%+v", pkt)
}
}
// Payload > MAX_PACKET_PAYLOAD (184).
func TestDecodePacketRejectsOversizedPayload_Issue1211(t *testing.T) {
raw := "1200" + strings.Repeat("AA", 200)
pkt, err := DecodePacket(raw, nil, false)
if err == nil {
t.Fatalf("expected error rejecting payload > MAX_PACKET_PAYLOAD=184 (firmware MeshCore.h:19); got nil, pkt=%+v", pkt)
}
if !strings.Contains(err.Error(), "payload") {
t.Errorf("error should mention payload; got %q", err)
}
}
func TestDecodePath_RejectsReservedHashSize_Issue1211(t *testing.T) {
buf := make([]byte, 216)
for i := range buf {
buf[i] = 0xAB
}
_, _, err := decodePath(0xF6, buf, 0)
if err == nil {
t.Fatalf("decodePath should reject pathByte=0xF6 (hash_size=4 reserved); got nil err")
}
}
func TestDecodePath_RejectsOversizedPath_Issue1211(t *testing.T) {
buf := make([]byte, 189)
_, _, err := decodePath(0xBF, buf, 0)
if err == nil {
t.Fatalf("decodePath should reject hash_count*hash_size=189 > MAX_PATH_SIZE=64; got nil err")
}
}
func TestDecodePath_AcceptsValidEncodings_Issue1211(t *testing.T) {
buf := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
path, consumed, err := decodePath(0x05, buf, 0)
if err != nil {
t.Fatalf("decodePath rejected valid encoding: %v", err)
}
if consumed != 5 {
t.Errorf("consumed=%d, want 5", consumed)
}
if path.HashCount != 5 || path.HashSize != 1 {
t.Errorf("decode wrong: hashCount=%d hashSize=%d", path.HashCount, path.HashSize)
}
}
// Kent #1 — pin tautological assertion: error MUST mention "path length"
// AND "exceeds buffer", not just non-nil. Uses firmware-valid pathByte
// that exhausts a small buffer, so the OOB guard fires (not validity).
func TestDecodePacketBoundsFromWireErrorPhrasing_Issue1211(t *testing.T) {
raw := "120A" + strings.Repeat("AA", 5)
_, err := DecodePacket(raw, nil, false)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "path length") {
t.Errorf("error missing 'path length'; got %q", err)
}
if !strings.Contains(err.Error(), "exceeds buffer") {
t.Errorf("error missing 'exceeds buffer'; got %q", err)
}
}
var _ = hex.EncodeToString
+22 -396
View File
@@ -11,7 +11,6 @@ import (
"strings"
"testing"
"github.com/meshcore-analyzer/packetpath"
"github.com/meshcore-analyzer/sigvalidate"
)
@@ -447,28 +446,6 @@ func TestValidateAdvert(t *testing.T) {
}
}
func TestDecodePacketPayloadRaw(t *testing.T) {
// Build a minimal TRANSPORT_FLOOD packet (route_type=0):
// header(1) + transport_codes(4) + path_len(1) + payload(N)
// Header 0x00 = route_type=TRANSPORT_FLOOD, payload_type=0, version=0
// Code1=9A52, Code2=0000, path_len=0x00 (0 hops, hash_size=1)
payload := []byte("hello")
raw := []byte{0x00, 0x9A, 0x52, 0x00, 0x00, 0x00}
raw = append(raw, payload...)
hexStr := strings.ToUpper(hex.EncodeToString(raw))
decoded, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatalf("DecodePacket: %v", err)
}
if decoded.TransportCodes == nil {
t.Fatal("expected TransportCodes, got nil")
}
if string(decoded.payloadRaw) != string(payload) {
t.Errorf("payloadRaw = %v, want %v", decoded.payloadRaw, payload)
}
}
func TestDecodeGrpTxtShort(t *testing.T) {
p := decodeGrpTxt([]byte{0x01, 0x02}, nil)
if p.Error != "too short" {
@@ -653,28 +630,21 @@ func TestDecodeEncryptedPayloadValid(t *testing.T) {
}
func TestDecodePayloadGRPData(t *testing.T) {
// GRP_DATA (0x06) decoder added for #1279 P0 #1 — envelope only when no
// channel key matches (firmware/src/helpers/BaseChatMesh.cpp:500).
buf := []byte{0x01, 0x02, 0x03}
p := decodePayload(PayloadGRP_DATA, buf, nil, false)
if p.Type != "GRP_DATA" {
t.Errorf("type=%s, want GRP_DATA", p.Type)
if p.Type != "UNKNOWN" {
t.Errorf("type=%s, want UNKNOWN", p.Type)
}
if p.RawHex != "010203" {
t.Errorf("rawHex=%s, want 010203", p.RawHex)
}
}
func TestDecodePayloadRAWCustom(t *testing.T) {
// #1279 P2 #5: RAW_CUSTOM (0x0F) now exposes envelope shape (length +
// first-byte tag) per firmware/src/Mesh.cpp:577 (createRawData).
buf := []byte{0xFF, 0xFE}
p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false)
if p.Type != "RAW_CUSTOM" {
t.Errorf("type=%s, want RAW_CUSTOM", p.Type)
}
if p.RawLength == nil || *p.RawLength != 2 {
t.Errorf("rawLength missing or wrong, want 2")
}
if p.FirstByteTag != "FF" {
t.Errorf("firstByteTag=%q, want FF", p.FirstByteTag)
if p.Type != "UNKNOWN" {
t.Errorf("type=%s, want UNKNOWN", p.Type)
}
}
@@ -956,93 +926,6 @@ func TestComputeContentHashLongFallback(t *testing.T) {
}
}
// TestComputeContentHashRouteTypeIndependence verifies that the same logical
// packet produces the same content hash regardless of route type (issue #786).
func TestComputeContentHashRouteTypeIndependence(t *testing.T) {
// Same payload type (TXT_MSG=2, bits 2-5) with different route types.
// Header 0x08 = route_type 0 (TRANSPORT_FLOOD), payload_type 2
// Header 0x0A = route_type 2 (DIRECT), payload_type 2
// Header 0x09 = route_type 1 (FLOOD), payload_type 2
// pathByte=0x00, payload=D69FD7A5A7
payloadHex := "D69FD7A5A7"
// FLOOD: header=0x09 (route_type 1), pathByte=0x00
floodHex := "09" + "00" + payloadHex
// DIRECT: header=0x0A (route_type 2), pathByte=0x00
directHex := "0A" + "00" + payloadHex
hashFlood := ComputeContentHash(floodHex)
hashDirect := ComputeContentHash(directHex)
if hashFlood != hashDirect {
t.Errorf("same payload with different route types produced different hashes: flood=%s direct=%s", hashFlood, hashDirect)
}
}
// TestComputeContentHashTraceIncludesPathLen verifies TRACE packets include
// path_len in the hash (matching firmware behavior).
func TestComputeContentHashTraceIncludesPathLen(t *testing.T) {
// TRACE = payload_type 0x09, so header bits 2-5 = 0x09 → header = 0x09<<2 | route=2 = 0x26
// pathByte=0x01 (1 hop, 1-byte hash) → 1 path byte
traceHeader1 := "26" // route=2, payload_type=9
pathByte1 := "01"
pathData1 := "AA"
payload := "DEADBEEF"
hex1 := traceHeader1 + pathByte1 + pathData1 + payload
// Same but pathByte=0x02 (2 hops) → 2 path bytes
pathByte2 := "02"
pathData2 := "AABB"
hex2 := traceHeader1 + pathByte2 + pathData2 + payload
hash1 := ComputeContentHash(hex1)
hash2 := ComputeContentHash(hex2)
if hash1 == hash2 {
t.Error("TRACE packets with different path_len should produce different hashes (path_len is part of hash input)")
}
}
// TestComputeContentHashMatchesFirmware verifies hash output matches what the
// firmware would compute: SHA256(payload_type_byte + payload)[:16hex].
func TestComputeContentHashMatchesFirmware(t *testing.T) {
// header=0x0A → payload_type = (0x0A >> 2) & 0x0F = 2
// pathByte=0x00, payload = D69FD7A5A7475DB07337749AE61FA53A4788E976
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
hash := ComputeContentHash(rawHex)
// Manually compute expected: SHA256(0x02 + payload_bytes)
payloadBytes, _ := hex.DecodeString("D69FD7A5A7475DB07337749AE61FA53A4788E976")
toHash := append([]byte{0x02}, payloadBytes...)
expected := sha256.Sum256(toHash)
expectedHex := hex.EncodeToString(expected[:])[:16]
if hash != expectedHex {
t.Errorf("hash=%s, want %s (firmware-compatible)", hash, expectedHex)
}
}
// TestComputeContentHashTraceGoldenValue is a golden-value test that locks down
// the 2-byte path_len (uint16 LE) behavior for TRACE hashing. If anyone removes
// the 0x00 byte from the hash input, this test breaks.
//
// Packet: header=0x25 (FLOOD route=1, payload_type=TRACE=0x09), pathByte=0x02
// (2 hops, 1-byte hash), path=[AA,BB], payload=[DE,AD,BE,EF].
// Hash input: [0x09, 0x02, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]
// → SHA256 = b1baaf3bf0d0726c2672b1ec9e2665dc...
// → first 16 hex chars = "b1baaf3bf0d0726c"
func TestComputeContentHashTraceGoldenValue(t *testing.T) {
// TRACE packet: header byte 0x25 = payload_type 9 (TRACE), route_type 1 (FLOOD)
// pathByte 0x02 = hash_size 1, hash_count 2
// 2 path bytes (AA, BB), then payload DEADBEEF
rawHex := "2502AABBDEADBEEF"
hash := ComputeContentHash(rawHex)
// Pre-computed: SHA256(0x09 0x02 0x00 0xDE 0xAD 0xBE 0xEF)[:16hex]
// The 0x00 is the high byte of uint16_t path_len (little-endian).
const golden = "b1baaf3bf0d0726c"
if hash != golden {
t.Errorf("TRACE golden hash = %s, want %s (2-byte path_len encoding)", hash, golden)
}
}
func TestDecodePacketWithWhitespace(t *testing.T) {
raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76"
pkt, err := DecodePacket(raw, nil, false)
@@ -1126,24 +1009,24 @@ func TestDecodeHeaderUnknownTypes(t *testing.T) {
}
func TestDecodePayloadMultipart(t *testing.T) {
// MULTIPART (0x0A) now decoded — #1279 P0 #2 (firmware/src/Mesh.cpp:289).
// MULTIPART (0x0A) falls through to default → UNKNOWN
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false)
if p.Type != "MULTIPART" {
t.Errorf("MULTIPART type=%s, want MULTIPART", p.Type)
if p.Type != "UNKNOWN" {
t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type)
}
}
func TestDecodePayloadControl(t *testing.T) {
// CONTROL (0x0B) now decoded — #1279 P1 #4 (firmware/src/Mesh.cpp:69).
// CONTROL (0x0B) falls through to default → UNKNOWN
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false)
if p.Type != "CONTROL" {
t.Errorf("CONTROL type=%s, want CONTROL", p.Type)
if p.Type != "UNKNOWN" {
t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type)
}
}
func TestDecodePathTruncatedBuffer(t *testing.T) {
// path byte claims 5 hops of 2 bytes = 10 bytes, but only 4 available
path, consumed, _ := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0)
path, consumed := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0)
if path.HashCount != 5 {
t.Errorf("hashCount=%d, want 5", path.HashCount)
}
@@ -1737,15 +1620,15 @@ func TestZeroHopTransportDirectHashSize(t *testing.T) {
}
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
// because hash_size==4 is reserved. Go decoder must mirror that — even
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
// silently accepted; firmware never emits hash_size==4.
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
_, err := DecodePacket(hex, nil, false)
if err == nil {
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
@@ -1852,260 +1735,3 @@ func TestDecodeAdvertWithSignatureValidation(t *testing.T) {
t.Error("SignatureValid should be nil when validation disabled")
}
}
// === Tests for DecodePathFromRawHex (issue #886) ===
func TestDecodePathFromRawHex_HashSize1(t *testing.T) {
// Header byte 0x26 = route_type DIRECT, payload TRACE
// Path byte 0x04 = hash_size 1 (bits 7-6 = 00 → 0+1=1), hash_count 4
// Path bytes: 30 2D 0D 23
raw := "2604302D0D2359FEE7B100000000006733D63367"
hops, err := packetpath.DecodePathFromRawHex(raw)
if err != nil {
t.Fatal(err)
}
expected := []string{"30", "2D", "0D", "23"}
if len(hops) != len(expected) {
t.Fatalf("got %d hops, want %d", len(hops), len(expected))
}
for i, h := range hops {
if h != expected[i] {
t.Errorf("hop[%d] = %s, want %s", i, h, expected[i])
}
}
}
func TestDecodePathFromRawHex_HashSize2(t *testing.T) {
// Path byte 0x42 = hash_size 2 (bits 7-6 = 01 → 1+1=2), hash_count 2
// Header 0x09 = FLOOD route (rt=1), payload ADVERT (pt=2)
// Path bytes: AABB CCDD (4 bytes = 2 hops * 2 bytes)
raw := "0942AABBCCDD" + "00000000000000"
hops, err := packetpath.DecodePathFromRawHex(raw)
if err != nil {
t.Fatal(err)
}
expected := []string{"AABB", "CCDD"}
if len(hops) != len(expected) {
t.Fatalf("got %d hops, want %d", len(hops), len(expected))
}
for i, h := range hops {
if h != expected[i] {
t.Errorf("hop[%d] = %s, want %s", i, h, expected[i])
}
}
}
func TestDecodePathFromRawHex_HashSize3(t *testing.T) {
// Path byte 0x81 = hash_size 3 (bits 7-6 = 10 → 2+1=3), hash_count 1
// Header 0x09 = FLOOD route (rt=1), payload ADVERT
raw := "0981AABBCC" + "0000000000"
hops, err := packetpath.DecodePathFromRawHex(raw)
if err != nil {
t.Fatal(err)
}
if len(hops) != 1 || hops[0] != "AABBCC" {
t.Fatalf("got %v, want [AABBCC]", hops)
}
}
func TestDecodePathFromRawHex_HashSize4(t *testing.T) {
// Path byte 0xC1 = hash_size 4 (bits 7-6 = 11 → 3+1=4), hash_count 1
// Header 0x09 = FLOOD route (rt=1)
raw := "09C1AABBCCDD" + "0000000000"
hops, err := packetpath.DecodePathFromRawHex(raw)
if err != nil {
t.Fatal(err)
}
if len(hops) != 1 || hops[0] != "AABBCCDD" {
t.Fatalf("got %v, want [AABBCCDD]", hops)
}
}
func TestDecodePathFromRawHex_DirectZeroHops(t *testing.T) {
// Path byte 0x00 = hash_size 1, hash_count 0
// Header 0x0A = DIRECT route (rt=2), payload ADVERT
raw := "0A00" + "0000000000"
hops, err := packetpath.DecodePathFromRawHex(raw)
if err != nil {
t.Fatal(err)
}
if len(hops) != 0 {
t.Fatalf("got %d hops, want 0", len(hops))
}
}
func TestDecodePathFromRawHex_Transport(t *testing.T) {
// Route type 3 = TRANSPORT_DIRECT → 4 transport code bytes before path byte
// Header 0x27 = route_type 3, payload TRACE
// Transport codes: 1122 3344
// Path byte 0x02 = hash_size 1, hash_count 2
// Path bytes: AA BB
raw := "2711223344" + "02AABB" + "0000000000"
hops, err := packetpath.DecodePathFromRawHex(raw)
if err != nil {
t.Fatal(err)
}
expected := []string{"AA", "BB"}
if len(hops) != len(expected) {
t.Fatalf("got %d hops, want %d", len(hops), len(expected))
}
for i, h := range hops {
if h != expected[i] {
t.Errorf("hop[%d] = %s, want %s", i, h, expected[i])
}
}
}
func TestDecodeTracePayloadFailSetsAnomaly(t *testing.T) {
// Issue #889: TRACE packet with payload too short to decode (< 9 bytes)
// should still return a DecodedPacket (observation stored) but with Anomaly
// set to warn operators that the decode was degraded.
// Packet: header 0x26 (TRACE+DIRECT), pathByte 0x00, payload 4 bytes (too short).
pkt, err := DecodePacket("2600aabbccdd", nil, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.Type != "TRACE" {
t.Fatalf("payload type=%s, want TRACE", pkt.Payload.Type)
}
if pkt.Payload.Error == "" {
t.Fatal("expected payload.Error to indicate decode failure")
}
// The key assertion: Anomaly must be set when TRACE decode fails
if pkt.Anomaly == "" {
t.Error("expected Anomaly to be set when TRACE payload decode fails but observation is stored")
}
}
// TestDecodeTraceExtractsSNRValues verifies that for TRACE packets, the header
// path bytes are interpreted as int8 SNR values (quarter-dB) and exposed via
// payload.SNRValues. Mirrors logic in cmd/server/decoder.go (issue: SNR values
// extracted by server but never written into decoded_json by ingestor).
//
// Packet 26022FF8116A23A80000000001C0DE1000DEDE:
// header 0x26 → TRACE (pt=9), DIRECT (rt=2)
// pathByte 0x02 → hash_size=1, hash_count=2
// header path: 2F F8 → SNR = [int8(0x2F)/4, int8(0xF8)/4] = [11.75, -2.0]
// payload (15B): tag=116A23A8 auth=00000000 flags=0x01 pathData=C0DE1000DEDE
func TestDecodeTraceExtractsSNRValues(t *testing.T) {
pkt, err := DecodePacket("26022FF8116A23A80000000001C0DE1000DEDE", nil, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.Type != "TRACE" {
t.Fatalf("payload type=%s, want TRACE", pkt.Payload.Type)
}
if len(pkt.Payload.SNRValues) != 2 {
t.Fatalf("len(SNRValues)=%d, want 2 (got %v)", len(pkt.Payload.SNRValues), pkt.Payload.SNRValues)
}
if pkt.Payload.SNRValues[0] != 11.75 {
t.Errorf("SNRValues[0]=%v, want 11.75", pkt.Payload.SNRValues[0])
}
if pkt.Payload.SNRValues[1] != -2.0 {
t.Errorf("SNRValues[1]=%v, want -2.0", pkt.Payload.SNRValues[1])
}
}
// TestDecodePacketBoundsFromWire — regression for issue #1211.
//
// A malformed packet on the wire claimed pathByte=0xF6 (hash_size=4, hash_count=54
// → 216 path bytes) inside a 15-byte buffer. decodePath() returned bytesConsumed=216
// without bounds-check, causing the outer slice `payloadBuf := buf[offset:]` to
// blow up with `slice bounds out of range [218:15]`.
//
// Expected behaviour: DecodePacket MUST NOT panic on any input. If the path
// length claimed by the wire byte exceeds the buffer, it should return a
// clean error.
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
// 15-byte buffer: header=0x12 (rt=DIRECT, pt=ADVERT), pathByte=0xF6
// (hash_size=4, hash_count=54 → claims 216 path bytes), + 13 garbage bytes.
raw := "12F6" + strings.Repeat("AA", 13)
defer func() {
if r := recover(); r != nil {
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
}
}()
pkt, err := DecodePacket(raw, nil, false)
if err == nil {
t.Fatalf("expected error for malformed packet (path claims 216 bytes in 15-byte buf), got nil; pkt=%+v", pkt)
}
}
// TestDecodePacketFuzzTruncated — sweep the decoder with truncated payloads.
// Zero panics is the acceptance bar.
//
// Adv M2: the original loop ran 256*256*20 = 1.3M iterations on every
// `go test` (in both packages, so 2.6M total). That is not "fuzzing" — it
// is an expensive deterministic sweep that runs in the default unit-test
// path with no opt-in. We now:
//
// - gate the exhaustive sweep on !testing.Short() so `go test -short`
// skips it (CI's unit gate runs short)
// - keep the full sweep under `go test ./...` to preserve coverage
// - prefer `go test -fuzz=FuzzDecodePacketTruncated` for actual
// randomized fuzzing (see FuzzDecodePacketTruncated below)
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
}
}()
if testing.Short() {
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
}
// Sweep every pathByte value with a short tail.
for hdr := 0; hdr < 256; hdr++ {
for pb := 0; pb < 256; pb++ {
for tail := 0; tail < 20; tail++ {
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)}) + strings.Repeat("00", tail)
_, _ = DecodePacket(raw, nil, false)
}
}
}
}
// FuzzDecodePacketTruncated — native go fuzz target. Run with:
//
// go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/ingestor
//
// Zero panics regardless of input is the acceptance bar.
func FuzzDecodePacketTruncated(f *testing.F) {
seeds := [][]byte{
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
{0x12, 0x00},
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
}
}()
_, _ = DecodePacket(hex.EncodeToString(data), nil, false)
})
}
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
// carrying >32 bytes of name data is adversarial — the Go decoder must not
// surface attacker-controlled bytes beyond what firmware would ever emit.
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
pubkey := repeatHex("AA", 32)
timestamp := "78563412"
signature := repeatHex("BB", 64)
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
// 64-byte ASCII 'X' name with no null terminator (firmware buffer is 32 bytes).
name := repeatHex("58", 64)
hex := "1200" + pubkey + timestamp + signature + flags + name
pkt, err := DecodePacket(hex, nil, false)
if err != nil {
t.Fatalf("DecodePacket: %v", err)
}
if got := len(pkt.Payload.Name); got > 32 {
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
}
}
-112
View File
@@ -1,112 +0,0 @@
package main
import (
"testing"
)
// TestHandleMessageAdvertForeign_FlagModeStoresWithFlag asserts that when an
// ADVERT comes from a node whose GPS is OUTSIDE the configured geofilter,
// the ingestor (in default "flag" mode) stores the node and marks it foreign,
// instead of silently dropping it (#730).
func TestHandleMessageAdvertForeign_FlagModeStoresWithFlag(t *testing.T) {
store, source := newTestContext(t)
// Real ADVERT raw hex from existing TestHandleMessageAdvertGeoFiltered.
// Decoder will produce a node with a known GPS — the test below just
// asserts that with a tight geofilter that EXCLUDES that GPS, the node
// is still stored AND tagged as foreign.
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
latMin, latMax := -1.0, 1.0
lonMin, lonMax := -1.0, 1.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
// Default mode (no ForeignAdverts.Mode set) MUST be "flag", per #730 design.
handleMessage(store, "test", source, msg, nil, nil, &Config{GeoFilter: gf})
var nodeCount int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if nodeCount != 1 {
t.Fatalf("nodes=%d, want 1 (foreign advert should be stored, not dropped, in flag mode)", nodeCount)
}
var foreign int
if err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign); err != nil {
t.Fatalf("foreign_advert column missing or unreadable: %v", err)
}
if foreign != 1 {
t.Errorf("foreign_advert=%d, want 1", foreign)
}
}
// TestHandleMessageAdvertForeign_DropModeStillDrops asserts the legacy
// drop-on-foreign behavior is preserved when ForeignAdverts.Mode = "drop".
func TestHandleMessageAdvertForeign_DropModeStillDrops(t *testing.T) {
store, source := newTestContext(t)
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
latMin, latMax := -1.0, 1.0
lonMin, lonMax := -1.0, 1.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
cfg := &Config{
GeoFilter: gf,
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
}
handleMessage(store, "test", source, msg, nil, nil, cfg)
var nodeCount int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if nodeCount != 0 {
t.Errorf("nodes=%d, want 0 (drop mode preserves legacy silent-drop behavior)", nodeCount)
}
}
// TestHandleMessageAdvertInRegion_NotFlaggedForeign asserts in-region
// adverts are NOT marked foreign.
func TestHandleMessageAdvertInRegion_NotFlaggedForeign(t *testing.T) {
store, source := newTestContext(t)
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
// Wide-open geofilter: every coord passes.
latMin, latMax := -90.0, 90.0
lonMin, lonMax := -180.0, 180.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{GeoFilter: gf})
var foreign int
err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign)
if err != nil {
t.Fatalf("query foreign_advert: %v", err)
}
if foreign != 0 {
t.Errorf("foreign_advert=%d, want 0 (in-region node)", foreign)
}
}
-94
View File
@@ -1,94 +0,0 @@
package main
// Tests for #1143: ingestor must populate transmissions.from_pubkey at
// write time (cheap — already parsing decoded_json) so attribution queries
// don't rely on JSON substring matches.
import (
"database/sql"
"testing"
)
func TestInsertTransmission_FromPubkeyPopulatedForAdvert(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
const pk = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
data := &PacketData{
RawHex: "AABBCC",
Timestamp: "2026-03-25T00:00:00Z",
ObserverID: "obs1",
Hash: "advert_hash_1143",
RouteType: 1,
PayloadType: 4, // ADVERT
PayloadVersion: 0,
PathJSON: "[]",
DecodedJSON: `{"type":"ADVERT","pubKey":"` + pk + `","name":"X"}`,
FromPubkey: pk,
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
var got sql.NullString
s.db.QueryRow("SELECT from_pubkey FROM transmissions WHERE hash = ?", data.Hash).Scan(&got)
if !got.Valid || got.String != pk {
t.Fatalf("from_pubkey = %v (valid=%v), want %q", got.String, got.Valid, pk)
}
}
func TestInsertTransmission_FromPubkeyNullForNonAdvert(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
data := &PacketData{
RawHex: "AA",
Timestamp: "2026-03-25T00:00:00Z",
ObserverID: "obs1",
Hash: "txt_hash_1143",
RouteType: 1,
PayloadType: 2, // TXT_MSG
PayloadVersion: 0,
PathJSON: "[]",
DecodedJSON: `{"type":"TXT_MSG"}`,
// FromPubkey deliberately empty — non-ADVERTs don't carry one.
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
var got sql.NullString
s.db.QueryRow("SELECT from_pubkey FROM transmissions WHERE hash = ?", data.Hash).Scan(&got)
if got.Valid {
t.Fatalf("from_pubkey for non-ADVERT must be NULL, got %q", got.String)
}
}
func TestBuildPacketData_PopulatesFromPubkey(t *testing.T) {
const pk = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
msg := &MQTTPacketMessage{Raw: "AA", Origin: "obs"}
decoded := &DecodedPacket{
Header: Header{PayloadType: PayloadADVERT},
Payload: Payload{Type: "ADVERT", PubKey: pk},
}
pd := BuildPacketData(msg, decoded, "obs", "", nil)
if pd.FromPubkey != pk {
t.Fatalf("BuildPacketData FromPubkey = %q, want %q", pd.FromPubkey, pk)
}
// Non-ADVERT: must not carry a pubkey.
decoded2 := &DecodedPacket{
Header: Header{PayloadType: 2},
Payload: Payload{Type: "TXT_MSG"},
}
pd2 := BuildPacketData(msg, decoded2, "obs", "", nil)
if pd2.FromPubkey != "" {
t.Fatalf("BuildPacketData FromPubkey for non-ADVERT = %q, want empty", pd2.FromPubkey)
}
}
-20
View File
@@ -13,22 +13,6 @@ replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
replace github.com/meshcore-analyzer/sigvalidate => ../../internal/sigvalidate
require github.com/meshcore-analyzer/packetpath v0.0.0
replace github.com/meshcore-analyzer/packetpath => ../../internal/packetpath
require github.com/meshcore-analyzer/dbconfig v0.0.0
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
require github.com/meshcore-analyzer/perfio v0.0.0
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
require github.com/meshcore-analyzer/dbschema v0.0.0
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -43,7 +27,3 @@ require (
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
require github.com/meshcore-analyzer/prunequeue v0.0.0
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
-30
View File
@@ -1,30 +0,0 @@
package main
// Tests for issue #1279 P2 item 5: ingestor RAW_CUSTOM exposure.
import (
"strings"
"testing"
)
func TestDecodeRawCustomExposesLengthAndTag(t *testing.T) {
// header = (1<<6)|(0x0F<<2)|1 = 0x7D ; path byte = 0x00 ; payload = A5 DE AD BE EF
hexStr := "7D00A5DEADBEEF"
pkt, err := DecodePacket(hexStr, nil, false)
if err != nil {
t.Fatalf("decode: %v", err)
}
if pkt.Payload.Type != "RAW_CUSTOM" {
t.Fatalf("payload type = %q, want RAW_CUSTOM", pkt.Payload.Type)
}
if pkt.Payload.RawLength == nil || *pkt.Payload.RawLength != 5 {
got := -1
if pkt.Payload.RawLength != nil {
got = *pkt.Payload.RawLength
}
t.Errorf("RawLength=%d, want 5", got)
}
if !strings.EqualFold(pkt.Payload.FirstByteTag, "A5") {
t.Errorf("FirstByteTag=%q, want A5", pkt.Payload.FirstByteTag)
}
}
-211
View File
@@ -1,211 +0,0 @@
package main
// Tests for issue #1279 P0+P1 decoder additions.
//
// Each test uses firmware-derived wire vectors:
// - GRP_DATA outer: firmware/src/helpers/BaseChatMesh.cpp:500 (createGroupDatagram)
// - GRP_DATA inner: firmware/src/helpers/BaseChatMesh.cpp:382-385
// - MULTIPART byte0: firmware/src/Mesh.cpp:289
// - MULTIPART ACK inner: firmware/src/Mesh.cpp:292-307
// - CONTROL byte0 flags: firmware/src/Mesh.cpp:69 + createControlData at Mesh.cpp:609
// - advertRole label rules: firmware/src/helpers/AdvertDataHelpers.h:7-12
import (
"crypto/aes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"testing"
)
// --- P0 #1: GRP_DATA decoder ---
// buildChannelEncrypted encrypts arbitrary inner bytes with the channel
// key/MAC scheme firmware uses for both GRP_TXT and GRP_DATA (see
// BaseChatMesh.cpp:376-391: AES-128-ECB, HMAC-SHA256-trunc-2 MAC).
func buildChannelEncrypted(channelKeyHex string, inner []byte) (ctHex, macHex string) {
key, _ := hex.DecodeString(channelKeyHex)
plain := append([]byte{}, inner...)
pad := aes.BlockSize - (len(plain) % aes.BlockSize)
if pad != aes.BlockSize {
plain = append(plain, make([]byte, pad)...)
}
block, _ := aes.NewCipher(key)
ct := make([]byte, len(plain))
for i := 0; i < len(plain); i += aes.BlockSize {
block.Encrypt(ct[i:i+aes.BlockSize], plain[i:i+aes.BlockSize])
}
secret := make([]byte, 32)
copy(secret, key)
h := hmac.New(sha256.New, secret)
h.Write(ct)
mac := h.Sum(nil)
return hex.EncodeToString(ct), hex.EncodeToString(mac[:2])
}
func TestDecodeGrpDataNoKey(t *testing.T) {
// Envelope alone (no key in store).
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11}
p := decodeGrpData(buf, nil)
if p.Type != "GRP_DATA" {
t.Fatalf("type=%q want GRP_DATA", p.Type)
}
if p.ChannelHash != 0xAA {
t.Errorf("channelHash=%d want 170", p.ChannelHash)
}
if p.ChannelHashHex != "AA" {
t.Errorf("channelHashHex=%q want AA", p.ChannelHashHex)
}
if p.MAC != "bbcc" {
t.Errorf("mac=%q want bbcc", p.MAC)
}
if p.EncryptedData != "ddeeff11" {
t.Errorf("encryptedData=%q want ddeeff11", p.EncryptedData)
}
if p.DecryptionStatus != "no_key" {
t.Errorf("decryptionStatus=%q want no_key", p.DecryptionStatus)
}
}
func TestDecodeGrpDataDecryptedInner(t *testing.T) {
// Inner per BaseChatMesh.cpp:382-385: data_type(uint16 LE) + data_len(1) + blob.
key := "2cc3d22840e086105ad73443da2cacb8"
blob := []byte{0x10, 0x20, 0x30, 0x40, 0x50}
inner := []byte{0x34, 0x12, byte(len(blob))} // data_type = 0x1234
inner = append(inner, blob...)
ctHex, macHex := buildChannelEncrypted(key, inner)
buf := []byte{0xAB}
mb, _ := hex.DecodeString(macHex)
buf = append(buf, mb...)
cb, _ := hex.DecodeString(ctHex)
buf = append(buf, cb...)
p := decodeGrpData(buf, map[string]string{"test": key})
if p.Type != "GRP_DATA" {
t.Fatalf("type=%q want GRP_DATA", p.Type)
}
if p.DecryptionStatus != "decrypted" {
t.Fatalf("decryptionStatus=%q want decrypted", p.DecryptionStatus)
}
if p.DataType == nil || *p.DataType != 0x1234 {
t.Errorf("dataType=%v want 0x1234", p.DataType)
}
if p.DataLen == nil || *p.DataLen != 5 {
t.Errorf("dataLen=%v want 5", p.DataLen)
}
if p.DecryptedBlob != hex.EncodeToString(blob) {
t.Errorf("decryptedBlob=%q want %q", p.DecryptedBlob, hex.EncodeToString(blob))
}
if p.Channel != "test" {
t.Errorf("channel=%q want test", p.Channel)
}
}
// --- P0 #2: MULTIPART decoder ---
func TestDecodeMultipartAck(t *testing.T) {
// remaining=3, inner_type=PAYLOAD_TYPE_ACK(0x03), ack_crc=0xDEADBEEF.
// byte0 = (3<<4) | 3 = 0x33; next 4 bytes are LE crc.
buf := []byte{0x33, 0xEF, 0xBE, 0xAD, 0xDE}
p := decodeMultipart(buf)
if p.Type != "MULTIPART" {
t.Fatalf("type=%q want MULTIPART", p.Type)
}
if p.Remaining == nil || *p.Remaining != 3 {
t.Errorf("remaining=%v want 3", p.Remaining)
}
if p.InnerType == nil || *p.InnerType != 0x03 {
t.Errorf("innerType=%v want 3", p.InnerType)
}
if p.InnerTypeName != "ACK" {
t.Errorf("innerTypeName=%q want ACK", p.InnerTypeName)
}
if p.InnerAckCrc != "deadbeef" {
t.Errorf("innerAckCrc=%q want deadbeef", p.InnerAckCrc)
}
}
func TestDecodeMultipartNonAck(t *testing.T) {
// remaining=2, inner_type=0x02 (TXT_MSG), arbitrary inner payload.
buf := []byte{0x22, 0x01, 0x02, 0x03}
p := decodeMultipart(buf)
if p.Remaining == nil || *p.Remaining != 2 {
t.Errorf("remaining=%v want 2", p.Remaining)
}
if p.InnerType == nil || *p.InnerType != 0x02 {
t.Errorf("innerType=%v want 2", p.InnerType)
}
if p.InnerTypeName != "TXT_MSG" {
t.Errorf("innerTypeName=%q want TXT_MSG", p.InnerTypeName)
}
if p.InnerPayload != "010203" {
t.Errorf("innerPayload=%q want 010203", p.InnerPayload)
}
if p.InnerAckCrc != "" {
t.Errorf("non-ACK should not surface innerAckCrc, got %q", p.InnerAckCrc)
}
}
// --- P1 #3: advertRole label fix ---
func TestAdvertRoleLabelsRawType(t *testing.T) {
// Firmware: ADV_TYPE_NONE=0, CHAT=1, REPEATER=2, ROOM=3, SENSOR=4, 5..15 FUTURE.
cases := []struct {
typ int
want string
}{
{0, "none"},
{1, "companion"},
{2, "repeater"},
{3, "room"},
{4, "sensor"},
{5, "type-5"},
{15, "type-15"},
}
for _, tc := range cases {
got := advertRole(&AdvertFlags{Type: tc.typ, Repeater: tc.typ == 2, Room: tc.typ == 3, Sensor: tc.typ == 4})
if got != tc.want {
t.Errorf("advertRole(type=%d) = %q, want %q", tc.typ, got, tc.want)
}
}
}
// --- P1 #4: CONTROL byte0 flags ---
func TestDecodeControlZeroHop(t *testing.T) {
// byte0 = 0x81 (high-bit set ⇒ zero-hop), followed by 3 app bytes.
buf := []byte{0x81, 0xAA, 0xBB, 0xCC}
p := decodeControl(buf)
if p.Type != "CONTROL" {
t.Fatalf("type=%q want CONTROL", p.Type)
}
if p.CtrlFlags != "81" {
t.Errorf("ctrlFlags=%q want 81", p.CtrlFlags)
}
if p.CtrlZeroHop == nil || !*p.CtrlZeroHop {
t.Errorf("ctrlZeroHop=%v want true", p.CtrlZeroHop)
}
if p.CtrlLength == nil || *p.CtrlLength != 4 {
t.Errorf("ctrlLength=%v want 4", p.CtrlLength)
}
}
func TestDecodeControlMultiHop(t *testing.T) {
// byte0 = 0x01 (high-bit clear ⇒ not zero-hop subset).
buf := []byte{0x01, 0x42}
p := decodeControl(buf)
if p.CtrlFlags != "01" {
t.Errorf("ctrlFlags=%q want 01", p.CtrlFlags)
}
if p.CtrlZeroHop == nil || *p.CtrlZeroHop {
t.Errorf("ctrlZeroHop=%v want false", p.CtrlZeroHop)
}
if p.CtrlLength == nil || *p.CtrlLength != 2 {
t.Errorf("ctrlLength=%v want 2", p.CtrlLength)
}
}
// silence unused-import diagnostics for stub-phase builds
var _ = binary.LittleEndian
-98
View File
@@ -1,98 +0,0 @@
package main
import (
"database/sql"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
// TestIngestorPruneOldPackets enforces #1283: the writer for
// transmissions retention lives on the ingestor's *Store. Before the fix,
// this lived on cmd/server/*DB and raced with ingestor INSERTs. After
// the fix, ingestor owns it and runs it on its own write-locked handle.
func TestIngestorPruneOldPackets(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "prune.db")
store, err := OpenStore(path)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
old := time.Now().UTC().AddDate(0, 0, -10).Format(time.RFC3339)
new := time.Now().UTC().Format(time.RFC3339)
for i, ts := range []string{old, old, new} {
_, err := store.db.Exec(
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
VALUES (?, ?, ?, 0, 1, 1, '{}')`,
"AA", "h"+string(rune('a'+i)), ts,
)
if err != nil {
t.Fatalf("seed tx: %v", err)
}
}
n, err := store.PruneOldPackets(5)
if err != nil {
t.Fatalf("PruneOldPackets: %v", err)
}
if n != 2 {
t.Fatalf("expected 2 pruned, got %d", n)
}
var remaining int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM transmissions`).Scan(&remaining); err != nil {
t.Fatalf("count: %v", err)
}
if remaining != 1 {
t.Fatalf("expected 1 transmission remaining, got %d", remaining)
}
}
// TestIngestorVacuumOnStartupMigratesNONEtoINCREMENTAL exercises the
// scenario that originally broke in #1283: a fresh DB with
// auto_vacuum=NONE, vacuumOnStartup=true, no contention from a server
// process. The ingestor must complete the VACUUM and flip auto_vacuum to
// INCREMENTAL. Before the fix, the migration ran inside cmd/server and
// hit SQLITE_BUSY because the ingestor (sharing the container) was
// already writing.
func TestIngestorVacuumOnStartupMigratesNONEtoINCREMENTAL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vac.db")
// Create a NONE-auto_vacuum DB (simulates an older deployment).
seed, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
if err != nil {
t.Fatal(err)
}
seed.SetMaxOpenConns(1)
if _, err := seed.Exec(`CREATE TABLE dummy(id INTEGER PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
var before int
seed.QueryRow("PRAGMA auto_vacuum").Scan(&before)
if before != 0 {
t.Fatalf("precondition: auto_vacuum=%d, want 0", before)
}
seed.Close()
store, err := OpenStore(path)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
cfg := &Config{DB: &DBConfig{VacuumOnStartup: true}}
store.CheckAutoVacuum(cfg)
var after int
if err := store.db.QueryRow("PRAGMA auto_vacuum").Scan(&after); err != nil {
t.Fatal(err)
}
if after != 2 {
t.Fatalf("expected auto_vacuum=2 after ingestor VACUUM, got %d", after)
}
}
+50 -512
View File
@@ -1,7 +1,6 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
@@ -12,13 +11,11 @@ import (
"math"
"net/http"
_ "net/http/pprof"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -60,67 +57,19 @@ func main() {
defer store.Close()
log.Printf("SQLite opened: %s", cfg.DBPath)
// Async backfill: path_json from raw_hex (#888) — must not block MQTT startup
store.BackfillPathJSONAsync()
// Soft-delete blacklisted observers (#1287 — moved from cmd/server).
if len(cfg.ObserverBlacklist) > 0 {
store.SoftDeleteBlacklistedObservers(cfg.ObserverBlacklist)
}
// Async backfill: from_pubkey for legacy ADVERT rows (#1143).
// Moved from cmd/server in #1287. Best-effort; must not block MQTT.
go store.BackfillFromPubkey(5000, 100*time.Millisecond, nil)
// Check auto_vacuum mode and optionally migrate (#919)
store.CheckAutoVacuum(cfg)
// Node retention: move stale nodes to inactive_nodes on startup
nodeDays := cfg.NodeDaysOrDefault()
store.MoveStaleNodes(nodeDays)
// Observer retention: remove stale observers on startup
observerDays := cfg.ObserverDaysOrDefault()
store.RemoveStaleObservers(observerDays)
// Metrics retention: prune old metrics on startup
metricsDays := cfg.MetricsRetentionDays()
store.PruneOldMetrics(metricsDays)
store.PruneDroppedPackets(metricsDays)
// Packet (transmissions) retention: previously lived in cmd/server,
// moved to ingestor in #1283 to eliminate cross-process write
// contention (SQLITE_BUSY). 0 = disabled.
packetDays := cfg.PacketDaysOrZero()
if packetDays > 0 {
if n, err := store.PruneOldPackets(packetDays); err != nil {
log.Printf("[prune] error: %v", err)
} else if n > 0 {
log.Printf("[prune] startup pruned %d transmissions older than %d days", n, packetDays)
}
}
vacuumPages := cfg.IncrementalVacuumPages()
store.RunIncrementalVacuum(vacuumPages)
// Daily ticker for node retention
retentionTicker := time.NewTicker(1 * time.Hour)
go func() {
for range retentionTicker.C {
store.MoveStaleNodes(nodeDays)
store.RunIncrementalVacuum(vacuumPages)
}
}()
// Daily ticker for observer retention (every 24h, staggered 90s after startup)
observerRetentionTicker := time.NewTicker(24 * time.Hour)
go func() {
time.Sleep(90 * time.Second) // stagger after metrics prune
store.RemoveStaleObservers(observerDays)
store.RunIncrementalVacuum(vacuumPages)
for range observerRetentionTicker.C {
store.RemoveStaleObservers(observerDays)
store.RunIncrementalVacuum(vacuumPages)
}
}()
@@ -129,49 +78,9 @@ func main() {
go func() {
for range metricsRetentionTicker.C {
store.PruneOldMetrics(metricsDays)
store.PruneDroppedPackets(metricsDays)
store.RunIncrementalVacuum(vacuumPages)
}
}()
// Daily ticker for transmission retention (#1283).
var packetRetentionTicker *time.Ticker
if packetDays > 0 {
packetRetentionTicker = time.NewTicker(24 * time.Hour)
go func() {
for range packetRetentionTicker.C {
if n, err := store.PruneOldPackets(packetDays); err != nil {
log.Printf("[prune] error: %v", err)
} else if n > 0 {
store.RunIncrementalVacuum(vacuumPages)
}
}
}()
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", packetDays)
}
// Daily neighbor_edges retention (#1287 — moved from cmd/server).
{
nDays := cfg.NeighborEdgesDaysOrDefault()
neighborPruneTicker := time.NewTicker(24 * time.Hour)
go func() {
time.Sleep(4 * time.Minute) // stagger
if n, err := store.PruneNeighborEdges(nDays); err != nil {
log.Printf("[neighbor-prune] error: %v", err)
} else if n > 0 {
log.Printf("[neighbor-prune] startup pruned %d edges older than %d days", n, nDays)
}
for range neighborPruneTicker.C {
if n, err := store.PruneNeighborEdges(nDays); err != nil {
log.Printf("[neighbor-prune] error: %v", err)
} else if n > 0 {
log.Printf("[neighbor-prune] pruned %d edges older than %d days", n, nDays)
}
}
}()
log.Printf("[neighbor-prune] auto-prune enabled: edges older than %d days", nDays)
}
// Periodic stats logging (every 5 minutes)
statsTicker := time.NewTicker(5 * time.Minute)
go func() {
@@ -180,30 +89,6 @@ func main() {
}
}()
// Prune-request queue (#669 M4 / #738): the read-only server enqueues
// geo-prune requests as marker files; the ingestor (which holds the
// write handle) executes the DELETEs. Process on startup, then every
// 15 seconds — short enough for a one-click UX, long enough to avoid
// useless wake-ups.
store.RunPendingPruneRequests()
pruneQueueTicker := time.NewTicker(15 * time.Second)
go func() {
for range pruneQueueTicker.C {
store.RunPendingPruneRequests()
}
}()
// Per-second stats file writer for the server's /api/perf/write-sources
// endpoint (#1120). Best-effort; never fatal.
StartStatsFileWriter(store, time.Second)
// Neighbor-edges builder (#1287 — Option 4): ingestor owns
// neighbor_edges writes. Runs every 60s. Server reads the snapshot
// via cmd/server/neighbor_recomputer.go on the same cadence.
stopNeighborBuilder := store.StartNeighborEdgesBuilder(NeighborEdgesBuilderInterval)
defer stopNeighborBuilder()
log.Printf("[neighbor-build] enabled (interval=%s)", NeighborEdgesBuilderInterval)
channelKeys := loadChannelKeys(cfg, *configPath)
if len(channelKeys) > 0 {
log.Printf("Loaded %d channel keys for GRP_TXT decryption", len(channelKeys))
@@ -211,37 +96,34 @@ func main() {
log.Printf("No channel keys loaded — GRP_TXT packets will not be decrypted")
}
regionKeys := loadRegionKeys(cfg)
store.BackfillDefaultScopeAsync(regionKeys)
// Connect to each MQTT source
var clients []mqtt.Client
connectedCount := 0
for _, source := range sources {
tag := source.Name
if tag == "" {
tag = source.Broker
}
opts := buildMQTTOpts(source)
connectTimeout := source.ConnectTimeoutOrDefault()
log.Printf("MQTT [%s] connect timeout: %ds", tag, connectTimeout)
opts := mqtt.NewClientOptions().
AddBroker(source.Broker).
SetAutoReconnect(true).
SetConnectRetry(true).
SetOrderMatters(true)
// Pre-allocate the liveness pointer so OnConnect can reset its
// stale-message clock on reconnect (PR #1216 r1 item 2). IsConnectedFn
// is wired below once the client exists.
liveness := &SourceLivenessState{
Tag: tag,
Broker: source.Broker,
if source.Username != "" {
opts.SetUsername(source.Username)
}
if source.Password != "" {
opts.SetPassword(source.Password)
}
if source.RejectUnauthorized != nil && !*source.RejectUnauthorized {
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
} else if strings.HasPrefix(source.Broker, "ssl://") {
opts.SetTLSConfig(&tls.Config{})
}
opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Printf("MQTT [%s] connected to %s", tag, source.Broker)
// PR #1216 r1 item 2: clear the stale LastMessageUnix from
// before the outage so the watchdog doesn't immediately scream
// "stalled for 2h". Also restarts the cold-start grace window
// and clears the alert cooldown so a fresh stall edge can fire.
liveness.MarkReconnected(time.Now())
topics := source.Topics
if len(topics) == 0 {
topics = []string{"meshcore/#"}
@@ -258,76 +140,30 @@ func main() {
})
opts.SetConnectionLostHandler(func(c mqtt.Client, err error) {
log.Printf("MQTT [%s] disconnected from %s: %v", tag, source.Broker, err)
})
opts.SetReconnectingHandler(func(c mqtt.Client, options *mqtt.ClientOptions) {
log.Printf("MQTT [%s] reconnecting to %s", tag, source.Broker)
log.Printf("MQTT [%s] disconnected: %v", tag, err)
})
// Capture source for closure
src := source
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
handleMessage(store, tag, src, m, channelKeys, regionKeys, cfg)
handleMessage(store, tag, src, m, channelKeys, cfg.GeoFilter)
})
client := mqtt.NewClient(opts)
// Wire IsConnectedFn now that the client exists, then register.
// Registration BEFORE Connect so the attempt counter is available
// to OnConnectAttempt on the very first dial.
liveness.IsConnectedFn = client.IsConnected
// PR #1216 r2 item 3: tag collisions used to log.Fatalf, which
// killed the entire ingestor over one config typo and recreated
// the #1212 total-ingest-stop class this PR exists to prevent.
// registerLivenessOrSkip logs ERROR + skips liveness registration
// for the duplicate; the MQTT source still attempts to connect,
// it just isn't tracked by the watchdog. First registration
// remains authoritative.
registerLivenessOrSkip(liveness)
token := client.Connect()
// With ConnectRetry=true, token.Wait() blocks forever for unreachable brokers.
// WaitTimeout lets startup proceed; the client keeps retrying in the background
// and OnConnect fires (subscribing) when it eventually connects (#910).
if !token.WaitTimeout(time.Duration(connectTimeout) * time.Second) {
log.Printf("MQTT [%s] initial connection timed out — retrying in background", tag)
clients = append(clients, client)
continue
}
token.Wait()
if token.Error() != nil {
log.Printf("MQTT [%s] connection failed (non-fatal): %v", tag, token.Error())
// BL1 fix: Disconnect to stop Paho's internal retry goroutines.
// With ConnectRetry=true, Connect() spawns background goroutines
// that leak if the client is simply discarded.
client.Disconnect(0)
continue
}
connectedCount++
clients = append(clients, client)
}
// BL2 fix: require at least one immediately-connected source. Timed-out
// clients are retrying in background (tracked in clients) but don't count
// as "connected" — a single unreachable broker must not silently run with
// zero active connections.
if connectedCount == 0 {
// Clean up any timed-out clients still retrying
for _, c := range clients {
c.Disconnect(0)
}
log.Fatal("no MQTT sources connected — all timed out or failed. Check broker is running (default: mqtt://localhost:1883). Set MQTT_BROKER env var or configure mqttSources in config.json")
if len(clients) == 0 {
log.Fatal("no MQTT connections established — check broker is running (default: mqtt://localhost:1883). Set MQTT_BROKER env var or configure mqttSources in config.json")
}
if connectedCount < len(clients) {
log.Printf("Running — %d MQTT source(s) connected, %d retrying in background", connectedCount, len(clients)-connectedCount)
} else {
log.Printf("Running — %d MQTT source(s) connected", connectedCount)
}
// #1212: per-source stall watchdog. Detects "silently dead" sources
// where the client reports connected but no messages have flowed. Logs
// a WARN line every minute for any source silent for >5m. Scan every
// 60s so detection latency is bounded.
stopWatchdog := runLivenessWatchdog(60*time.Second, 5*time.Minute)
log.Printf("Running — %d MQTT source(s) connected", len(clients))
// Wait for shutdown signal
sig := make(chan os.Signal, 1)
@@ -337,12 +173,7 @@ func main() {
log.Println("Shutting down...")
retentionTicker.Stop()
metricsRetentionTicker.Stop()
if packetRetentionTicker != nil {
packetRetentionTicker.Stop()
}
statsTicker.Stop()
pruneQueueTicker.Stop()
stopWatchdog()
store.LogStats() // final stats on shutdown
for _, c := range clients {
c.Disconnect(5000) // 5s to allow in-flight messages to drain
@@ -350,82 +181,7 @@ func main() {
log.Println("Done.")
}
// buildMQTTOpts creates MQTT client options for a source with bounded reconnect
// backoff, connect timeout, and TLS/auth configuration.
//
// Logs every TCP/TLS dial via OnConnectAttempt. Unlike SetReconnectingHandler
// (which only fires inside paho's reconnect goroutine and can be silent if
// that loop never iterates), OnConnectAttempt fires on every attempt — the
// initial Connect() and every reconnect. This is the observability fix for
// #1212 (prod outage on 2026-05-15 where the disconnect was logged but no
// reconnect activity was ever visible).
func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
tag := source.Name
if tag == "" {
tag = source.Broker
}
// #1337: paho defaults silently throttle delivery on this broker.
// - CleanSession=true + empty ClientID (random per reconnect) made the
// broker treat every reconnect as a brand-new session and discard the
// backlog it had queued since the previous disconnect. With watchdog
// reconnects every ~5min on staging, this lost ~99% of messages.
// - Order=true serialized the default publish handler; one slow packet
// blocked all others, compounding the loss under bursts.
// Fix: persistent unique ClientID + CleanSession=false (broker keeps
// our subscription state across reconnects and forwards what we missed),
// explicit KeepAlive so half-open TCP is detected at the paho layer, and
// Order=false for parallel handler dispatch.
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown-host"
}
clientID := "corescope-ingestor-" + hostname + "-" + tag
opts := mqtt.NewClientOptions().
AddBroker(source.Broker).
SetClientID(clientID).
SetCleanSession(false).
SetKeepAlive(30 * time.Second).
SetOrderMatters(false).
SetAutoReconnect(true).
SetConnectRetry(true).
SetMaxReconnectInterval(30 * time.Second).
SetConnectTimeout(10 * time.Second).
SetWriteTimeout(10 * time.Second)
opts.SetConnectionAttemptHandler(func(broker *url.URL, tlsCfg *tls.Config) *tls.Config {
// Look up the per-source liveness state (registered in main) so we
// can attach an attempt counter. If not yet registered (first dial
// from Connect()), fall through with attempt=1.
var attempt int64 = 1
livenessRegistryMu.RLock()
s := livenessRegistry[tag]
livenessRegistryMu.RUnlock()
if s != nil {
attempt = atomic.AddInt64(&s.AttemptCount, 1)
}
log.Printf("MQTT [%s] connection attempt #%d to %s", tag, attempt, broker.String())
return tlsCfg
})
if source.Username != "" {
opts.SetUsername(source.Username)
}
if source.Password != "" {
opts.SetPassword(source.Password)
}
if source.RejectUnauthorized != nil && !*source.RejectUnauthorized {
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
} else if strings.HasPrefix(source.Broker, "ssl://") {
opts.SetTLSConfig(&tls.Config{})
}
return opts
}
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, regionKeys map[string][]byte, cfg *Config) {
// Liveness watchdog (#1212): record receipt before any processing so a
// slow handler still counts as "source is alive". Cheap atomic store.
markLivenessForTag(tag, time.Now())
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, geoFilter *GeoFilterConfig) {
defer func() {
if r := recover(); r != nil {
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
@@ -435,6 +191,21 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
topic := m.Topic()
parts := strings.Split(topic, "/")
// IATA filter
if len(source.IATAFilter) > 0 && len(parts) > 1 {
region := parts[1]
matched := false
for _, f := range source.IATAFilter {
if f == region {
matched = true
break
}
}
if !matched {
return
}
}
var msg map[string]interface{}
if err := json.Unmarshal(m.Payload(), &msg); err != nil {
return
@@ -445,29 +216,13 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
return
}
// Observer blacklist: drop ALL messages from blacklisted observers before any
// DB writes (status, metrics, packets). Trumps IATA filter.
if len(parts) > 2 && cfg.IsObserverBlacklisted(parts[2]) {
log.Printf("MQTT [%s] observer %.8s blacklisted, dropping", tag, parts[2])
return
}
// Global observer IATA whitelist: if configured, drop messages from observers
// in non-whitelisted IATA regions. Applies to ALL message types (status + packets).
if len(parts) > 1 && !cfg.IsObserverIATAAllowed(parts[1]) {
return
}
// Status topic: meshcore/<region>/<observer_id>/status
// Per-source IATA filter does NOT apply here — observer metadata (noise_floor, battery, etc.)
// is region-independent and should be accepted from all observers regardless of
// which IATA regions are configured for packet ingestion.
if len(parts) >= 4 && parts[3] == "status" {
observerID := parts[2]
name, _ := msg["origin"].(string)
iata := parts[1]
meta := extractObserverMeta(msg)
if err := store.UpsertObserverAt(observerID, name, iata, meta, resolveRxTime(msg, tag)); err != nil {
if err := store.UpsertObserver(observerID, name, iata, meta); err != nil {
log.Printf("MQTT [%s] observer status error: %v", tag, err)
}
// Insert metrics sample from status message
@@ -490,50 +245,12 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
return
}
// IATA filter applies to packet messages only — not status messages above.
if len(source.IATAFilter) > 0 && len(parts) > 1 {
region := parts[1]
matched := false
for _, f := range source.IATAFilter {
if f == region {
matched = true
break
}
}
if !matched {
return
}
}
// Format 1: Raw packet (meshcoretomqtt / Cisien format)
rawHex, _ := msg["raw"].(string)
if rawHex != "" {
validateSigs := cfg.ShouldValidateSignatures()
decoded, err := DecodePacket(rawHex, channelKeys, validateSigs)
decoded, err := DecodePacket(rawHex, channelKeys, false)
if err != nil {
// Per #1211: include enough context to repro malformed-packet drops,
// but NEVER log the full observer ID (PII / fingerprinting risk).
// We log:
// - topic prefix (with observer segment elided)
// - 8-char observer prefix
// - payload length, claimed length (rawHex len)
obs := ""
if len(parts) > 2 {
obs = parts[2]
}
// Build a redacted topic that replaces parts[2] (the observer id)
// with the 8-char prefix, so the rest of the topic is preserved
// for debugging without leaking the full identifier.
redactedTopic := topic
if len(parts) > 2 {
redactedParts := make([]string, len(parts))
copy(redactedParts, parts)
if len(parts[2]) > 8 {
redactedParts[2] = parts[2][:8]
}
redactedTopic = strings.Join(redactedParts, "/")
}
log.Printf("MQTT [%s] decode error: %v (topic=%s observer=%.8s rawHexLen=%d)", tag, err, redactedTopic, obs, len(rawHex))
log.Printf("MQTT [%s] decode error: %v", tag, err)
return
}
@@ -545,17 +262,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
if len(parts) > 1 {
region = parts[1]
}
// Fallback to source-level region config when topic has no region (#788)
if region == "" && source.Region != "" {
region = source.Region
}
mqttMsg := &MQTTPacketMessage{Raw: rawHex}
mqttMsg.Timestamp = resolveRxTime(msg, tag)
// Parse optional region from JSON payload (#788)
if v, ok := msg["region"].(string); ok && v != "" {
mqttMsg.Region = v
}
if v, ok := msg["SNR"]; ok {
if f, ok := toFloat64(v); ok {
mqttMsg.SNR = &f
@@ -600,48 +308,10 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
log.Printf("MQTT [%s] skipping corrupted ADVERT: %s", tag, reason)
return
}
// Signature validation: drop adverts with invalid ed25519 signatures
if validateSigs && decoded.Payload.SignatureValid != nil && !*decoded.Payload.SignatureValid {
hash := ComputeContentHash(rawHex)
truncPK := decoded.Payload.PubKey
if len(truncPK) > 16 {
truncPK = truncPK[:16]
}
log.Printf("MQTT [%s] DROPPED invalid signature: hash=%s name=%s observer=%s pubkey=%s",
tag, hash, decoded.Payload.Name, firstNonEmpty(mqttMsg.Origin, observerID), truncPK)
store.InsertDroppedPacket(&DroppedPacket{
Hash: hash,
RawHex: rawHex,
Reason: "invalid signature",
ObserverID: observerID,
ObserverName: mqttMsg.Origin,
NodePubKey: decoded.Payload.PubKey,
NodeName: decoded.Payload.Name,
})
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, geoFilter) {
return
}
foreign := false
if !NodePassesGeoFilter(decoded.Payload.Lat, decoded.Payload.Lon, cfg.GeoFilter) {
if cfg.ForeignAdverts.IsDropMode() {
return
}
foreign = true
lat, lon := 0.0, 0.0
if decoded.Payload.Lat != nil {
lat = *decoded.Payload.Lat
}
if decoded.Payload.Lon != nil {
lon = *decoded.Payload.Lon
}
truncPK := decoded.Payload.PubKey
if len(truncPK) > 16 {
truncPK = truncPK[:16]
}
log.Printf("MQTT [%s] foreign advert: node=%s name=%s lat=%.4f lon=%.4f observer=%s",
tag, truncPK, decoded.Payload.Name, lat, lon, firstNonEmpty(mqttMsg.Origin, observerID))
}
pktData := BuildPacketData(mqttMsg, decoded, observerID, region, regionKeys)
pktData.Foreign = foreign
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
isNew, err := store.InsertTransmission(pktData)
if err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
@@ -650,11 +320,6 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
if err := store.UpsertNode(decoded.Payload.PubKey, decoded.Payload.Name, role, decoded.Payload.Lat, decoded.Payload.Lon, pktData.Timestamp); err != nil {
log.Printf("MQTT [%s] node upsert error: %v", tag, err)
}
if foreign {
if err := store.MarkNodeForeign(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] mark foreign error: %v", tag, err)
}
}
if isNew {
if err := store.IncrementAdvertCount(decoded.Payload.PubKey); err != nil {
log.Printf("MQTT [%s] advert count error: %v", tag, err)
@@ -666,16 +331,10 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
}
}
// Update default_scope when advert carries a matched transport scope (#899)
if pktData.IsTransportScoped {
if err := store.UpdateNodeDefaultScope(decoded.Payload.PubKey, pktData.ScopeName); err != nil {
log.Printf("MQTT [%s] node default_scope update error: %v", tag, err)
}
}
} else {
// Non-ADVERT packets: store normally (routing/channel messages from
// in-area observers are relevant regardless of relay hop origin).
pktData := BuildPacketData(mqttMsg, decoded, observerID, region, regionKeys)
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
if _, err := store.InsertTransmission(pktData); err != nil {
log.Printf("MQTT [%s] db insert error: %v", tag, err)
}
@@ -684,12 +343,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
// Upsert observer
if observerID != "" {
origin, _ := msg["origin"].(string)
// Use effective region: payload > topic > source config (#788)
effectiveRegion := region
if mqttMsg.Region != "" {
effectiveRegion = mqttMsg.Region
}
if err := store.UpsertObserverAt(observerID, origin, effectiveRegion, nil, mqttMsg.Timestamp); err != nil {
if err := store.UpsertObserver(observerID, origin, region, nil); err != nil {
log.Printf("MQTT [%s] observer upsert error: %v", tag, err)
}
}
@@ -733,9 +387,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
decodedJSON, _ := json.Marshal(channelMsg)
ingestNow := time.Now().UTC().Format(time.RFC3339)
rxTime := resolveRxTime(msg, tag)
hashInput := fmt.Sprintf("ch:%s:%s:%s", channelIdx, text, ingestNow)
now := time.Now().UTC().Format(time.RFC3339)
hashInput := fmt.Sprintf("ch:%s:%s:%s", channelIdx, text, now)
h := sha256.Sum256([]byte(hashInput))
hash := hex.EncodeToString(h[:])[:16]
@@ -775,7 +428,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
}
pktData := &PacketData{
Timestamp: rxTime,
Timestamp: now,
ObserverID: "companion",
ObserverName: "L1 Pro (BLE)",
SNR: snr,
@@ -827,9 +480,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
decodedJSON, _ := json.Marshal(dm)
ingestNow := time.Now().UTC().Format(time.RFC3339)
rxTime := resolveRxTime(msg, tag)
hashInput := fmt.Sprintf("dm:%s:%s", text, ingestNow)
now := time.Now().UTC().Format(time.RFC3339)
hashInput := fmt.Sprintf("dm:%s:%s", text, now)
h := sha256.Sum256([]byte(hashInput))
hash := hex.EncodeToString(h[:])[:16]
@@ -869,7 +521,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
}
pktData := &PacketData{
Timestamp: rxTime,
Timestamp: now,
ObserverID: "companion",
ObserverName: "L1 Pro (BLE)",
SNR: snr,
@@ -1053,71 +705,6 @@ func firstNonEmpty(vals ...string) string {
return ""
}
// resolveRxTime returns the observer receive-time for a packet, taken from
// the MQTT envelope's "timestamp" field. Falls back to ingest time only when
// the field is missing, unparseable, or implausibly in the future (a
// clock-skewed observer). Result is always RFC3339 UTC.
//
// The envelope timestamp is stamped by the uploader when the radio receives
// the frame, not when the MQTT message is published — so a buffered packet
// uploaded hours late still carries its true receive time. Using ingest time
// (time.Now()) here mis-dated such packets by the upload delay.
func resolveRxTime(msg map[string]interface{}, tag string) string {
now := time.Now().UTC()
raw, _ := msg["timestamp"].(string)
if raw == "" {
return now.Format(time.RFC3339)
}
t, err := parseEnvelopeTime(raw)
if err != nil {
log.Printf("MQTT [%s] unparseable timestamp %q, using ingest time", tag, raw)
return now.Format(time.RFC3339)
}
// Hard reject: > 14h ahead is a genuine clock error (UTC+14 is the maximum
// standard offset, so nothing valid should be further ahead than that).
if t.After(now.Add(14 * time.Hour)) {
log.Printf("MQTT [%s] future timestamp %q, using ingest time", tag, raw)
return now.Format(time.RFC3339)
}
// Hard reject: > 30 days in the past is an RTC-reset node reporting a
// factory date (e.g. 2020-01-01). Such a value would permanently drag
// transmissions.first_seen backwards via stmtUpdateTxFirstSeen in
// InsertTransmission. No legitimate buffered upload is that stale.
if t.Before(now.Add(-30 * 24 * time.Hour)) {
log.Printf("MQTT [%s] stale timestamp %q (>30d old), using ingest time", tag, raw)
return now.Format(time.RFC3339)
}
// Soft clamp: naive local-clock timestamps from UTC+N observers are parsed
// as-if UTC, making them appear N hours in the future. A UTC+2 observer's
// live packet looks 2h ahead, but it is NOT a buffered packet — the whole
// point of using rxTime is to preserve the past timestamp for packets that
// were buffered offline. If rxTime is ahead of now, the packet is live and
// ingest time is the correct value. This also prevents storing future
// timestamps that would show ⚠️ in the UI for every packet from UTC+N nodes.
if t.After(now) {
return now.Format(time.RFC3339)
}
return t.UTC().Format(time.RFC3339)
}
// parseEnvelopeTime parses the MQTT envelope timestamp. Two on-wire forms
// occur: zone-aware ISO8601 (RFC3339), and a naive local-clock ISO string
// with no zone (python datetime.isoformat()). Zone-aware layouts are tried
// first; naive layouts are assumed UTC, leaving a bounded residual offset
// equal to the observer's UTC offset for naive-timestamp uploaders.
func parseEnvelopeTime(s string) (time.Time, error) {
for _, layout := range []string{
time.RFC3339, // 2026-05-16T10:00:00Z / +02:00
"2006-01-02T15:04:05.999999", // python isoformat w/ microseconds
"2006-01-02T15:04:05", // naive ISO
} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unrecognized timestamp layout: %q", s)
}
// deriveHashtagChannelKey derives an AES-128 key from a channel name.
// Same algorithm as Node.js: SHA-256(channelName) → first 32 hex chars (16 bytes).
func deriveHashtagChannelKey(channelName string) string {
@@ -1183,55 +770,6 @@ func loadChannelKeys(cfg *Config, configPath string) map[string]string {
return keys
}
func loadRegionKeys(cfg *Config) map[string][]byte {
keys := make(map[string][]byte)
for _, raw := range cfg.HashRegions {
name := strings.TrimSpace(raw)
if name == "" {
log.Printf("[regions] skipping empty hashRegions entry")
continue
}
if !strings.HasPrefix(name, "#") {
name = "#" + name
}
if _, exists := keys[name]; exists {
log.Printf("[regions] duplicate region %q ignored", name)
continue
}
h := sha256.Sum256([]byte(name))
keys[name] = h[:16]
}
if len(keys) > 0 {
log.Printf("[regions] %d region key(s) loaded", len(keys))
}
return keys
}
// matchScope performs one HMAC-SHA256 per configured region. Expected
// len(regionKeys) ≤ 50; beyond that, consider a pre-indexed lookup table.
func matchScope(regionKeys map[string][]byte, payloadType byte, payloadRaw []byte, code1 string) string {
if code1 == "0000" || len(regionKeys) == 0 || len(payloadRaw) == 0 {
return ""
}
for name, key := range regionKeys {
mac := hmac.New(sha256.New, key)
mac.Write([]byte{payloadType})
mac.Write(payloadRaw)
hmacBytes := mac.Sum(nil)
code := uint16(hmacBytes[0]) | uint16(hmacBytes[1])<<8
if code == 0 {
code = 1
} else if code == 0xFFFF {
code = 0xFFFE
}
codeBytes := [2]byte{byte(code & 0xFF), byte(code >> 8)}
if strings.ToUpper(hex.EncodeToString(codeBytes[:])) == code1 {
return name
}
}
return ""
}
// Version info (set via ldflags)
var version = "dev"
+29 -310
View File
@@ -1,17 +1,12 @@
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"math"
"os"
"path/filepath"
"runtime"
"testing"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
func TestToFloat64(t *testing.T) {
@@ -135,7 +130,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -152,7 +147,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
// Should create a node from the ADVERT
var count int
@@ -174,7 +169,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
// Should not panic
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -191,7 +186,7 @@ func TestHandleMessageStatusTopic(t *testing.T) {
payload: []byte(`{"origin":"MyObserver"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var name, iata string
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
@@ -212,11 +207,11 @@ func TestHandleMessageSkipStatusTopics(t *testing.T) {
// meshcore/status should be skipped
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
handleMessage(store, "test", source, msg1, nil, nil, &Config{})
handleMessage(store, "test", source, msg1, nil, nil)
// meshcore/events/connection should be skipped
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
handleMessage(store, "test", source, msg2, nil, nil, &Config{})
handleMessage(store, "test", source, msg2, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -235,7 +230,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -248,7 +243,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
topic: "meshcore/LAX/obs2/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg2, nil, nil, &Config{})
handleMessage(store, "test", source, msg2, nil, nil)
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
if count != 1 {
@@ -266,7 +261,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
topic: "meshcore",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
// No region part → filter doesn't apply, message goes through
// Actually the code checks len(parts) > 1 for IATA filter
@@ -282,7 +277,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"type":"companion","data":"something"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -300,7 +295,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"ZZZZ"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -317,7 +312,7 @@ func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
@@ -336,7 +331,7 @@ func TestHandleMessageMinimalTopic(t *testing.T) {
topic: "meshcore/SJC",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -357,7 +352,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
// Transmission should be inserted (even if advert is invalid)
var count int
@@ -383,7 +378,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
topic: "packets",
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -405,7 +400,7 @@ func TestHandleMessageSNRNotFloat(t *testing.T) {
// SNR as a string value — should not parse as float
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
@@ -421,7 +416,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
// Verify origin was extracted to observer name
var name string
@@ -444,7 +439,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
}
// Should not panic — the defer/recover should catch it
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
}
func TestHandleMessageStatusOriginFallback(t *testing.T) {
@@ -456,7 +451,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
topic: "meshcore/SJC/obs1/status",
payload: []byte(`{"type":"status"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var name string
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
@@ -482,20 +477,18 @@ func TestEpochToISO(t *testing.T) {
}
func TestAdvertRole(t *testing.T) {
// advertRole now keys off AdvertFlags.Type (firmware ADV_TYPE_*) — see
// firmware/src/helpers/AdvertDataHelpers.h:7-12 and issue #1279 P1 #3.
tests := []struct {
name string
flags *AdvertFlags
want string
}{
{"none (type 0)", &AdvertFlags{Type: 0}, "none"},
{"companion (type 1)", &AdvertFlags{Type: 1, Chat: true}, "companion"},
{"repeater (type 2)", &AdvertFlags{Type: 2, Repeater: true}, "repeater"},
{"room (type 3)", &AdvertFlags{Type: 3, Room: true}, "room"},
{"sensor (type 4)", &AdvertFlags{Type: 4, Sensor: true}, "sensor"},
{"future type-5", &AdvertFlags{Type: 5}, "type-5"},
{"nil flags falls back to companion", nil, "companion"},
{"repeater", &AdvertFlags{Repeater: true}, "repeater"},
{"room", &AdvertFlags{Room: true}, "room"},
{"sensor", &AdvertFlags{Sensor: true}, "sensor"},
{"companion (default)", &AdvertFlags{Chat: true}, "companion"},
{"companion (no flags)", &AdvertFlags{}, "companion"},
{"repeater takes priority", &AdvertFlags{Repeater: true, Room: true}, "repeater"},
{"room before sensor", &AdvertFlags{Room: true, Sensor: true}, "room"},
}
for _, tt := range tests {
@@ -647,7 +640,7 @@ func TestHandleMessageWithLowercaseSNRRSSI(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","snr":5.5,"rssi":-102}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
@@ -668,7 +661,7 @@ func TestHandleMessageSNRRSSIUppercaseWins(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"snr":1.0,"RSSI":-95,"rssi":-50}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
@@ -688,7 +681,7 @@ func TestHandleMessageNoSNRRSSI(t *testing.T) {
payload := []byte(`{"raw":"` + rawHex + `"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
handleMessage(store, "test", source, msg, nil, nil)
var snr, rssi *float64
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
@@ -746,277 +739,3 @@ func TestToFloat64WithUnits(t *testing.T) {
}
}
}
// TestIATAFilterDoesNotDropStatusMessages verifies that status messages from
// out-of-region observers are still processed (noise_floor, battery, etc.)
// even when an IATA filter is configured for packet data.
func TestIATAFilterDoesNotDropStatusMessages(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test", IATAFilter: []string{"SJC"}}
// BFL observer sends a status message with noise_floor — outside the IATA filter.
msg := &mockMessage{
topic: "meshcore/BFL/bfl-obs1/status",
payload: []byte(`{"origin":"BFLObserver","stats":{"noise_floor":-105.0}}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var name string
var noiseFloor *float64
err := store.db.QueryRow("SELECT name, noise_floor FROM observers WHERE id = 'bfl-obs1'").Scan(&name, &noiseFloor)
if err != nil {
t.Fatalf("observer not found after status from out-of-region observer: %v", err)
}
if name != "BFLObserver" {
t.Errorf("name=%q, want BFLObserver", name)
}
if noiseFloor == nil || *noiseFloor != -105.0 {
t.Errorf("noise_floor=%v, want -105.0 — status message was dropped by IATA filter when it should not be", noiseFloor)
}
// Verify that a packet from BFL is still filtered.
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
pktMsg := &mockMessage{
topic: "meshcore/BFL/bfl-obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, pktMsg, nil, nil, &Config{})
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
if count != 0 {
t.Error("packet from out-of-region BFL should still be filtered by IATA")
}
}
func TestLoadRegionKeys(t *testing.T) {
cfg := &Config{HashRegions: []string{"#belgium", "eu", " #Test ", "", "#belgium"}}
keys := loadRegionKeys(cfg)
// Deduplication + normalization
if len(keys) != 3 {
t.Fatalf("len(keys) = %d, want 3", len(keys))
}
// Pre-computed: SHA256("#belgium")[:16]. Hardcoded so a change to the key
// derivation algorithm (hash function, truncation length) breaks this test
// even if both sides were updated together.
wantBelgium, _ := hex.DecodeString("7085b78ed010599094f8c8e7d1aa0e27")
if got := keys["#belgium"]; !bytes.Equal(got, wantBelgium) {
t.Errorf("#belgium key mismatch: got %x, want %x", got, wantBelgium)
}
// "eu" should be normalized to "#eu"
if _, ok := keys["#eu"]; !ok {
t.Error("expected #eu key")
}
// " #Test " should be normalized to "#Test"
if _, ok := keys["#Test"]; !ok {
t.Error("expected #Test key")
}
}
func TestMatchScope(t *testing.T) {
// Fixed known-answer vectors only — no in-test HMAC computation.
// Keys and Code1 values are pre-computed externally so a wrong algorithm
// that produces consistent wrong results on both sides would still fail.
// Vector 1: "#test"/payloadType=5/"hello" → Code1=2AB5
// Key = SHA256("#test")[:16] = 9cd8fcf22a47333b591d96a2b848b73f
testKey, _ := hex.DecodeString("9cd8fcf22a47333b591d96a2b848b73f")
testKeys := map[string][]byte{"#test": testKey}
if got := matchScope(testKeys, 5, []byte("hello"), "2AB5"); got != "#test" {
t.Errorf("#test vector: matchScope = %q, want #test", got)
}
// Vector 2: "#belgium"/payloadType=5/"hello" → Code1=4A75
// Key = SHA256("#belgium")[:16] = 7085b78ed010599094f8c8e7d1aa0e27
belgiumKey, _ := hex.DecodeString("7085b78ed010599094f8c8e7d1aa0e27")
belgiumKeys := map[string][]byte{"#belgium": belgiumKey}
if got := matchScope(belgiumKeys, 5, []byte("hello"), "4A75"); got != "#belgium" {
t.Errorf("#belgium vector: matchScope = %q, want #belgium", got)
}
// Code1=0000 (unscoped transport) → no region matched
if got := matchScope(belgiumKeys, 5, []byte("hello"), "0000"); got != "" {
t.Errorf("unscoped: matchScope = %q, want empty", got)
}
// Code1 present but matches no configured region → empty string
if got := matchScope(belgiumKeys, 5, []byte("hello"), "BEEF"); got != "" {
t.Errorf("no match: matchScope = %q, want empty", got)
}
}
func TestBuildPacketDataScopeMatching(t *testing.T) {
// Fixed known-answer packet: TRANSPORT_FLOOD, payloadType=5, payload="hello",
// Code1=2AB5 (pre-computed for region "#test").
// header=0x14 (route_type=0 FLOOD, payloadType=5 → 5<<2), Code1=[0x2A,0xB5],
// Code2=[0,0], path_len=0, payload="hello" (68 65 6C 6C 6F).
const rawHex = "142AB500000068656C6C6F"
key, _ := hex.DecodeString("9cd8fcf22a47333b591d96a2b848b73f") // SHA256("#test")[:16]
regionKeys := map[string][]byte{"#test": key}
decoded, err := DecodePacket(rawHex, nil, false)
if err != nil {
t.Fatalf("DecodePacket: %v", err)
}
msg := &MQTTPacketMessage{Raw: rawHex}
pktData := BuildPacketData(msg, decoded, "obs1", "region1", regionKeys)
if pktData.ScopeName != "#test" {
t.Errorf("ScopeName = %q, want #test", pktData.ScopeName)
}
if !pktData.IsTransportScoped {
t.Error("IsTransportScoped should be true")
}
}
// TestMQTTConnectRetryTimeoutDoesNotBlock verifies that WaitTimeout returns within
// the deadline for an unreachable broker when ConnectRetry=true (#910). Previously,
// token.Wait() would block forever in this configuration.
func TestMQTTConnectRetryTimeoutDoesNotBlock(t *testing.T) {
opts := mqtt.NewClientOptions().
AddBroker("tcp://127.0.0.1:1"). // port 1 — nothing listening, fast refusal
SetConnectRetry(true).
SetAutoReconnect(true)
client := mqtt.NewClient(opts)
token := client.Connect()
defer client.Disconnect(100)
start := time.Now()
connected := token.WaitTimeout(3 * time.Second)
elapsed := time.Since(start)
if connected {
t.Skip("port 1 unexpectedly accepted a connection — skipping")
}
if elapsed > 4*time.Second {
t.Errorf("WaitTimeout blocked for %v — token.Wait() would block forever with ConnectRetry=true", elapsed)
}
}
// TestBL1_GoroutineLeakOnHardFailure reproduces BLOCKER 1: without Disconnect()
// on the error path, Paho's internal retry goroutines leak when a client is
// discarded after Connect() with ConnectRetry=true.
//
// We prove the leak by creating N clients WITHOUT Disconnect — goroutines grow
// proportionally. The fix (client.Disconnect(0) before continue) prevents this.
func TestBL1_GoroutineLeakOnHardFailure(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
baseline := runtime.NumGoroutine()
// Create multiple clients connected to unreachable broker, WITHOUT disconnecting.
// Each one spawns Paho retry goroutines that accumulate.
const numClients = 10
clients := make([]mqtt.Client, numClients)
for i := 0; i < numClients; i++ {
opts := mqtt.NewClientOptions().
AddBroker("tcp://127.0.0.1:1").
SetConnectRetry(true).
SetAutoReconnect(true).
SetConnectTimeout(500 * time.Millisecond)
c := mqtt.NewClient(opts)
tok := c.Connect()
tok.WaitTimeout(1 * time.Second)
clients[i] = c
}
time.Sleep(200 * time.Millisecond)
leaked := runtime.NumGoroutine()
goroutineGrowth := leaked - baseline
// Clean up to not actually leak in test
for _, c := range clients {
c.Disconnect(0)
}
t.Logf("baseline=%d, after %d undisconnected clients=%d, growth=%d",
baseline, numClients, leaked, goroutineGrowth)
// With ConnectRetry=true, each Connect() spawns retry goroutines.
// Without Disconnect, these accumulate. Verify growth is meaningful.
if goroutineGrowth < 3 {
t.Skip("Connect didn't spawn enough extra goroutines to measure leak")
}
// The fix: calling client.Disconnect(0) on the error path prevents accumulation.
// Anti-tautology: removing the Disconnect(0) call from main.go's error path
// would cause goroutine accumulation proportional to failed broker count.
t.Logf("CONFIRMED: %d leaked goroutines from %d clients without Disconnect — fix adds Disconnect(0) on error path", goroutineGrowth, numClients)
}
// TestBL2_ZeroConnectedFatals verifies BLOCKER 2: when all brokers are unreachable,
// connectedCount==0 must be detected. We test the logic directly — if only timed-out
// clients exist (appended to clients slice) but connectedCount is 0, the guard triggers.
func TestBL2_ZeroConnectedFatals(t *testing.T) {
// Simulate the connection loop result: 1 timed-out client, 0 connected
var clients []mqtt.Client
connectedCount := 0
// Create a client that times out (unreachable broker)
opts := mqtt.NewClientOptions().
AddBroker("tcp://127.0.0.1:1").
SetConnectRetry(true).
SetAutoReconnect(true)
client := mqtt.NewClient(opts)
token := client.Connect()
if !token.WaitTimeout(2 * time.Second) {
// Timed out — PR #926 appends to clients
clients = append(clients, client)
}
defer func() {
for _, c := range clients {
c.Disconnect(0)
}
}()
// OLD bug: len(clients) == 0 would be false (1 timed-out client in list)
// → ingestor would silently run with zero connections
if len(clients) == 0 {
t.Fatal("expected timed-out client to be in clients slice")
}
// NEW fix: connectedCount == 0 catches this
if connectedCount != 0 {
t.Errorf("connectedCount should be 0, got %d", connectedCount)
}
// The real code does: if connectedCount == 0 { log.Fatal(...) }
// This test proves len(clients) > 0 but connectedCount == 0 — the old guard
// would have missed it.
if len(clients) > 0 && connectedCount == 0 {
t.Log("BL2 confirmed: old guard len(clients)==0 would NOT fatal; new guard connectedCount==0 correctly catches zero-connected state")
}
}
func TestHandleMessageObserverIATAWhitelist(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
cfg := &Config{
ObserverIATAWhitelist: []string{"ARN"},
}
// Message from non-whitelisted region GOT — should be dropped
handleMessage(store, "test", source, &mockMessage{
topic: "meshcore/GOT/obs1/status",
payload: []byte(`{"origin":"node1","noise_floor":-110}`),
}, nil, nil, cfg)
var count int
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs1'").Scan(&count)
if count != 0 {
t.Error("observer from non-whitelisted IATA GOT should be dropped")
}
// Message from whitelisted region ARN — should be accepted
handleMessage(store, "test", source, &mockMessage{
topic: "meshcore/ARN/obs2/status",
payload: []byte(`{"origin":"node2","noise_floor":-105}`),
}, nil, nil, cfg)
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs2'").Scan(&count)
if count != 1 {
t.Errorf("observer from whitelisted IATA ARN should be accepted, got count=%d", count)
}
}
-222
View File
@@ -1,222 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"time"
"github.com/meshcore-analyzer/dbschema"
)
// PruneOldPackets deletes transmissions (and their child observations)
// older than `days`. Returns count of transmissions deleted.
//
// Owned by the ingestor per #1283: the writer process is the only one
// allowed to hold the DB write lock; previously this lived in
// cmd/server/db.go and raced ingestor INSERTs (SQLITE_BUSY).
func (s *Store) PruneOldPackets(days int) (int64, error) {
if days <= 0 {
return 0, nil
}
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("prune begin: %w", err)
}
defer tx.Rollback()
// Delete child observations first (no CASCADE in SQLite).
if _, err := tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
SELECT id FROM transmissions WHERE first_seen < ?
)`, cutoff); err != nil {
return 0, fmt.Errorf("prune observations: %w", err)
}
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
if err != nil {
return 0, fmt.Errorf("prune transmissions: %w", err)
}
n, _ := res.RowsAffected()
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("prune commit: %w", err)
}
if n > 0 {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
return n, nil
}
// SoftDeleteBlacklistedObservers marks observers in the blacklist as
// inactive=1 so they are hidden from API responses. Owned by ingestor
// per #1287. Runs once at startup.
func (s *Store) SoftDeleteBlacklistedObservers(blacklist []string) {
n, err := dbschema.SoftDeleteBlacklistedObservers(s.db, blacklist)
if err != nil {
log.Printf("[observer-blacklist] warning: soft-delete failed: %v", err)
return
}
if n > 0 {
log.Printf("[observer-blacklist] soft-deleted %d blacklisted observer(s)", n)
}
}
// PruneNeighborEdges deletes rows older than maxAgeDays from
// neighbor_edges. Owned by the ingestor per #1287 (was in cmd/server).
// Returns DB rows deleted.
func (s *Store) PruneNeighborEdges(maxAgeDays int) (int64, error) {
if maxAgeDays <= 0 {
return 0, nil
}
cutoff := time.Now().UTC().Add(-time.Duration(maxAgeDays) * 24 * time.Hour).Format(time.RFC3339)
res, err := s.db.Exec("DELETE FROM neighbor_edges WHERE last_seen < ?", cutoff)
if err != nil {
return 0, fmt.Errorf("prune neighbor_edges: %w", err)
}
n, _ := res.RowsAffected()
if n > 0 {
log.Printf("[neighbor-prune] removed %d DB rows older than %d days", n, maxAgeDays)
}
return n, nil
}
// ─── from_pubkey backfill (#1143) ──────────────────────────────────────────
//
// Moved from cmd/server/from_pubkey_migration.go in #1287. Runs from the
// ingestor's maintenance loop. Populates transmissions.from_pubkey for
// ADVERT rows whose value is still NULL, by parsing decoded_json.pubKey.
// FromPubkeyBackfillStats holds progress for /api/healthz exposure.
// The ingestor exposes these via stats_file.go so the server can read
// them without writing.
type FromPubkeyBackfillStats struct {
Total int64 `json:"total"`
Processed int64 `json:"processed"`
Done bool `json:"done"`
}
// BackfillFromPubkey scans transmissions where from_pubkey IS NULL and
// payload_type = 4 (ADVERT) and populates from_pubkey from decoded_json.
// Chunked + yields between batches. Safe to call repeatedly; once a row
// is set to either "" or hex it never matches the WHERE clause again.
func (s *Store) BackfillFromPubkey(chunkSize int, yieldDuration time.Duration, progress func(total, processed int64, done bool)) {
defer func() {
if r := recover(); r != nil {
log.Printf("[backfill] from_pubkey panic recovered: %v", r)
}
if progress != nil {
progress(0, 0, true) // signal done; values overwritten below if collected
}
}()
if chunkSize <= 0 {
chunkSize = 5000
}
var total int64
if err := s.db.QueryRow(
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
).Scan(&total); err != nil {
log.Printf("[backfill] from_pubkey count error: %v", err)
return
}
if total == 0 {
log.Println("[backfill] from_pubkey: nothing to do")
if progress != nil {
progress(0, 0, true)
}
return
}
if progress != nil {
progress(total, 0, false)
}
log.Printf("[backfill] from_pubkey starting: %d ADVERT rows", total)
stmt, err := s.db.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
if err != nil {
log.Printf("[backfill] from_pubkey prepare: %v", err)
return
}
defer stmt.Close()
var processed int64
for {
rows, err := s.db.Query(
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
chunkSize)
if err != nil {
log.Printf("[backfill] from_pubkey select: %v", err)
return
}
type row struct {
id int64
pk string
}
batch := make([]row, 0, chunkSize)
for rows.Next() {
var id int64
var dj sql.NullString
if err := rows.Scan(&id, &dj); err != nil {
continue
}
batch = append(batch, row{id: id, pk: extractPubkeyFromAdvertJSON(dj.String)})
}
rows.Close()
if len(batch) == 0 {
break
}
tx, err := s.db.Begin()
if err != nil {
log.Printf("[backfill] from_pubkey begin tx: %v", err)
return
}
txStmt := tx.Stmt(stmt)
for _, b := range batch {
// Sentinel: "" = scanned-no-pubkey (so the WHERE clause
// won't keep rescanning this row). hex = real pubkey.
var val interface{} = ""
if b.pk != "" {
val = b.pk
}
if _, err := txStmt.Exec(val, b.id); err != nil {
log.Printf("[backfill] from_pubkey update id=%d: %v", b.id, err)
}
}
if err := tx.Commit(); err != nil {
log.Printf("[backfill] from_pubkey commit: %v", err)
return
}
processed += int64(len(batch))
if progress != nil {
progress(total, processed, false)
}
if len(batch) < chunkSize {
break
}
if yieldDuration > 0 {
time.Sleep(yieldDuration)
}
}
log.Printf("[backfill] from_pubkey complete: %d rows processed", processed)
if progress != nil {
progress(total, processed, true)
}
}
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and
// returns the pubKey field, or "" if absent/invalid.
func extractPubkeyFromAdvertJSON(s string) string {
if s == "" {
return ""
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(s), &m); err != nil {
return ""
}
if v, ok := m["pubKey"].(string); ok {
return v
}
return ""
}
-76
View File
@@ -1,76 +0,0 @@
package main
import (
"testing"
"time"
)
func TestBuildMQTTOpts_ReconnectSettings(t *testing.T) {
source := MQTTSource{
Broker: "tcp://localhost:1883",
Name: "test",
}
opts := buildMQTTOpts(source)
if opts.MaxReconnectInterval != 30*time.Second {
t.Errorf("MaxReconnectInterval = %v, want 30s", opts.MaxReconnectInterval)
}
if opts.ConnectTimeout != 10*time.Second {
t.Errorf("ConnectTimeout = %v, want 10s", opts.ConnectTimeout)
}
if opts.WriteTimeout != 10*time.Second {
t.Errorf("WriteTimeout = %v, want 10s", opts.WriteTimeout)
}
if !opts.AutoReconnect {
t.Error("AutoReconnect should be true")
}
if !opts.ConnectRetry {
t.Error("ConnectRetry should be true")
}
}
func TestBuildMQTTOpts_Credentials(t *testing.T) {
source := MQTTSource{
Broker: "tcp://broker:1883",
Username: "user1",
Password: "pass1",
}
opts := buildMQTTOpts(source)
if opts.Username != "user1" {
t.Errorf("Username = %q, want %q", opts.Username, "user1")
}
if opts.Password != "pass1" {
t.Errorf("Password = %q, want %q", opts.Password, "pass1")
}
}
func TestBuildMQTTOpts_TLS_InsecureSkipVerify(t *testing.T) {
f := false
source := MQTTSource{
Broker: "ssl://broker:8883",
RejectUnauthorized: &f,
}
opts := buildMQTTOpts(source)
if opts.TLSConfig == nil {
t.Fatal("TLSConfig should be set")
}
if !opts.TLSConfig.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true when RejectUnauthorized=false")
}
}
func TestBuildMQTTOpts_TLS_SSL_Prefix(t *testing.T) {
source := MQTTSource{
Broker: "ssl://broker:8883",
}
opts := buildMQTTOpts(source)
if opts.TLSConfig == nil {
t.Fatal("TLSConfig should be set for ssl:// brokers")
}
if opts.TLSConfig.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be false by default")
}
}
-248
View File
@@ -1,248 +0,0 @@
package main
import (
"bytes"
"crypto/tls"
"log"
"net/url"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// PR #1216 r1 item 5 (kent #1 / adv MAJOR-2): the original assertion was
// tautological — it only checked OnConnectAttempt != nil, which passes
// even if the handler is a no-op. This version invokes the wired handler,
// captures log output, and asserts the OBSERVABLE behaviour operators
// rely on during a #1212-class outage:
// - the configured source tag appears in the log line
// - the broker URL appears in the log line
// - the per-source AttemptCount increments on every invocation (proving
// the handler is wired to the right state, not just a stub)
// - the tlsCfg passed in is returned unchanged (no surprise TLS rewrite)
func TestBuildMQTTOpts_InstrumentsConnectionAttempt(t *testing.T) {
defer snapshotAndResetRegistry(t)()
source := MQTTSource{Broker: "tcp://localhost:1883", Name: "obs-tag"}
opts := buildMQTTOpts(source)
if opts.OnConnectAttempt == nil {
t.Fatal("OnConnectAttempt must be wired in buildMQTTOpts (#1212 / PR #1216 r1)")
}
// Register the liveness state so the handler can find it and increment
// the attempt counter (same wiring main.go does).
liveness := &SourceLivenessState{Tag: "obs-tag", Broker: source.Broker}
if err := registerLivenessState(liveness); err != nil {
t.Fatalf("test setup: registerLivenessState: %v", err)
}
// Capture log output via log.SetOutput. Save/restore so other tests
// running serially don't lose their writer.
var buf bytes.Buffer
origOut := log.Writer()
origFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
defer func() {
log.SetOutput(origOut)
log.SetFlags(origFlags)
}()
brokerURL, err := url.Parse(source.Broker)
if err != nil {
t.Fatalf("test setup: parse broker url: %v", err)
}
tlsIn := &tls.Config{ServerName: "sentinel.test"}
// Invoke the handler twice — operators need to see attempt # increment
// per dial to gauge backoff progress.
tlsOut1 := opts.OnConnectAttempt(brokerURL, tlsIn)
tlsOut2 := opts.OnConnectAttempt(brokerURL, tlsIn)
if tlsOut1 != tlsIn || tlsOut2 != tlsIn {
t.Errorf("OnConnectAttempt must pass tlsCfg through unchanged (got %p, %p; want %p)", tlsOut1, tlsOut2, tlsIn)
}
logOut := buf.String()
if !strings.Contains(logOut, "obs-tag") {
t.Errorf("log output must include the source tag for operator grep; got %q", logOut)
}
if !strings.Contains(logOut, source.Broker) {
t.Errorf("log output must include the broker URL so operators can correlate against config; got %q", logOut)
}
if !strings.Contains(logOut, "#1") || !strings.Contains(logOut, "#2") {
t.Errorf("log output must show attempt #1 and #2 across the two invocations (per-source counter); got %q", logOut)
}
if got := atomic.LoadInt64(&liveness.AttemptCount); got != 2 {
t.Errorf("AttemptCount must increment per dial (got %d after 2 invocations, want 2)", got)
}
}
// RED: the watchdog acceptance criterion from #1212 — even when the client
// reports connected, if NO packets have flowed for >threshold, log a warning.
// This is a separate detection layer that catches "silently dead" sockets
// (broker accepted TCP but stopped forwarding, half-open TCP, etc.).
func TestMQTTStallWatchdog_FiresOnSilentSource(t *testing.T) {
state := &SourceLivenessState{Tag: "test", Broker: "tcp://x:1883"}
atomic.StoreInt64(&state.LastMessageUnix, time.Now().Add(-10*time.Minute).Unix())
state.IsConnectedFn = func() bool { return true }
msg, kind := checkSourceLiveness(state, 5*time.Minute, time.Now())
if kind != LivenessStalled {
t.Fatalf("watchdog should flag stall when source connected but no message for 10m (threshold 5m); got kind=%v msg=%q", kind, msg)
}
if !strings.Contains(msg, "no messages") {
t.Errorf("stall message should mention 'no messages'; got %q", msg)
}
if !strings.Contains(msg, "test") {
t.Errorf("stall message should include the source tag; got %q", msg)
}
}
func TestMQTTStallWatchdog_QuietWhenRecent(t *testing.T) {
state := &SourceLivenessState{Tag: "test", Broker: "tcp://x:1883"}
atomic.StoreInt64(&state.LastMessageUnix, time.Now().Add(-30*time.Second).Unix())
state.IsConnectedFn = func() bool { return true }
_, kind := checkSourceLiveness(state, 5*time.Minute, time.Now())
if kind != LivenessOK {
t.Fatal("watchdog should NOT flag stall when last message was 30s ago and threshold is 5m")
}
}
func TestMQTTStallWatchdog_QuietWhenDisconnected(t *testing.T) {
// When disconnected, paho's own reconnect logging covers it — the
// watchdog should only fire for the silent-while-connected case.
state := &SourceLivenessState{Tag: "test", Broker: "tcp://x:1883"}
atomic.StoreInt64(&state.LastMessageUnix, time.Now().Add(-1*time.Hour).Unix())
state.IsConnectedFn = func() bool { return false }
_, kind := checkSourceLiveness(state, 5*time.Minute, time.Now())
if kind != LivenessDisconnected {
t.Fatalf("watchdog must classify a !IsConnected source as LivenessDisconnected (silent state), not LivenessOK — r2 item 1 prevents disconnect→recovery mis-classification; got kind=%v", kind)
}
}
// snapshotAndResetRegistry isolates the package-level livenessRegistry for a
// single test. Returns a restore func to defer. Without this, parallel or
// previously-registered sources leak into the watchdog goroutine under test.
func snapshotAndResetRegistry(t *testing.T) func() {
t.Helper()
livenessRegistryMu.Lock()
saved := livenessRegistry
livenessRegistry = map[string]*SourceLivenessState{}
livenessRegistryMu.Unlock()
return func() {
livenessRegistryMu.Lock()
livenessRegistry = saved
livenessRegistryMu.Unlock()
}
}
// RED-then-GREEN: the watchdog GOROUTINE (not just checkSourceLiveness) must
// fan out emits across the registry on each tick, AND must exit cleanly when
// the stop signal fires. Originally runLivenessWatchdog used `for range
// t.C` — ticker.Stop() does not close the channel, so the goroutine
// leaked past shutdown. This test asserts both:
// - tick → emit for every stalled source in the registry
// - stop → goroutine returns within a short bound
func TestMQTTStallWatchdog_LoopEmitsAndStopsCleanly(t *testing.T) {
defer snapshotAndResetRegistry(t)()
s1 := &SourceLivenessState{Tag: "alpha", Broker: "tcp://a:1883", IsConnectedFn: func() bool { return true }}
s2 := &SourceLivenessState{Tag: "beta", Broker: "tcp://b:1883", IsConnectedFn: func() bool { return true }}
atomic.StoreInt64(&s1.LastMessageUnix, time.Now().Add(-10*time.Minute).Unix())
atomic.StoreInt64(&s2.LastMessageUnix, time.Now().Add(-10*time.Minute).Unix())
registerLivenessState(s1)
registerLivenessState(s2)
tick := make(chan time.Time, 1)
done := make(chan struct{})
var mu sync.Mutex
var emits []string
emit := func(args ...any) {
mu.Lock()
defer mu.Unlock()
if len(args) > 0 {
if s, ok := args[0].(string); ok {
emits = append(emits, s)
}
}
}
exited := make(chan struct{})
go func() {
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
close(exited)
}()
tick <- time.Now()
// Drain: wait briefly for the emits to land. Polling instead of sleeping
// keeps the test fast on a healthy machine.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := len(emits)
mu.Unlock()
if n >= 2 {
break
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
got := append([]string(nil), emits...)
mu.Unlock()
if len(got) != 2 {
t.Fatalf("expected 2 stall emits (alpha+beta), got %d: %v", len(got), got)
}
close(done)
select {
case <-exited:
case <-time.After(2 * time.Second):
t.Fatal("watchdog goroutine did not exit within 2s of stop — ticker leak regression")
}
}
// PR #1216 r1 item 6 (kent #2 / adv MAJOR-3): the original test had no
// assertions gating behaviour — it called stop() and trusted `-race` to
// catch leaks. `-race` does NOT detect goroutine leaks. This version
// captures runtime.NumGoroutine() before/after and asserts the watchdog's
// goroutine actually exited. Allows ±1 slack for unrelated runtime
// bookkeeping (gc, finalizer).
func TestMQTTStallWatchdog_RunStopsCleanly(t *testing.T) {
defer snapshotAndResetRegistry(t)()
// Settle: let any prior-test goroutines finish before sampling baseline.
runtime.GC()
time.Sleep(50 * time.Millisecond)
before := runtime.NumGoroutine()
stop := runLivenessWatchdog(10*time.Millisecond, 5*time.Minute)
// Let the watchdog run a few ticks so we're sure it's truly spawned.
time.Sleep(50 * time.Millisecond)
if mid := runtime.NumGoroutine(); mid <= before {
t.Fatalf("watchdog goroutine did not spawn: before=%d mid=%d", before, mid)
}
stop()
// Poll for the goroutine count to return to baseline (±1 slack).
deadline := time.Now().Add(2 * time.Second)
var after int
for time.Now().Before(deadline) {
runtime.Gosched()
after = runtime.NumGoroutine()
if after <= before+1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("watchdog goroutine leaked: before=%d after=%d (delta %d) — stop() did not signal the loop to exit", before, after, after-before)
}
-85
View File
@@ -1,85 +0,0 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
// Issue #1337: paho client misconfigured — ingestor receives 200× fewer
// messages than mosquitto_sub on the same broker/creds/topics. Root cause
// (hypothesis 1+5): paho defaults — CleanSession=true, empty ClientID
// (auto-random per reconnect), Order=true (handler serialized) — combined
// with the reconnect-every-5min watchdog meant the broker dropped queued
// messages on every reconnect AND the handler couldn't keep up under load.
//
// These tests pin the four paho options that fix the gap:
// 1. CleanSession=false — broker keeps the subscription state across
// reconnects instead of treating each dial
// as a brand-new session.
// 2. ClientID = persistent — broker recognizes the returning session.
// Empty ClientID makes paho generate a fresh
// random one on every reconnect, which is
// treated as a new client by the broker.
// 3. KeepAlive = 30s — half-open TCP detected at the paho layer
// instead of waiting for OS keepalive.
// 4. Order = false — handler dispatch is parallel; one slow
// packet does not block all the others.
//
// All four must be set in buildMQTTOpts. This test fails on master.
func TestBuildMQTTOpts_PersistentSession_Issue1337(t *testing.T) {
source := MQTTSource{
Broker: "ssl://broker.example:8883",
Name: "sjc-test",
}
opts := buildMQTTOpts(source)
if opts.CleanSession {
t.Error("CleanSession must be false (#1337): broker drops queued msgs across reconnects when true")
}
host, _ := os.Hostname()
if opts.ClientID == "" {
t.Fatal("ClientID must be set to a persistent value (#1337): empty = paho generates random per reconnect, broker treats every reconnect as new session")
}
if !strings.Contains(opts.ClientID, "sjc-test") {
t.Errorf("ClientID must embed source name for uniqueness across sources, got %q", opts.ClientID)
}
if host != "" && !strings.Contains(opts.ClientID, host) {
t.Errorf("ClientID must embed hostname for uniqueness across deployments, got %q (host=%q)", opts.ClientID, host)
}
if opts.KeepAlive != int64((30 * time.Second).Seconds()) {
t.Errorf("KeepAlive must be 30s (#1337): got %ds — needed so paho detects half-open TCP", opts.KeepAlive)
}
if opts.Order {
t.Error("Order must be false (#1337): default true serializes handler dispatch; a slow packet stalls all others")
}
}
// Stability: ClientID must be deterministic for a given (hostname, source)
// across two builds. Otherwise reconnect = new session = lost backlog.
func TestBuildMQTTOpts_ClientIDStableAcrossBuilds_Issue1337(t *testing.T) {
source := MQTTSource{Broker: "ssl://broker.example:8883", Name: "stable-test"}
a := buildMQTTOpts(source).ClientID
b := buildMQTTOpts(source).ClientID
if a == "" {
t.Fatal("ClientID empty")
}
if a != b {
t.Errorf("ClientID must be stable across buildMQTTOpts calls (#1337): %q vs %q — random = broker drops session on reconnect", a, b)
}
}
// Distinct sources must NOT share a ClientID — broker disconnects the older
// session whenever a duplicate ClientID connects, causing flapping.
func TestBuildMQTTOpts_ClientIDUniquePerSource_Issue1337(t *testing.T) {
a := buildMQTTOpts(MQTTSource{Broker: "ssl://a:8883", Name: "alpha"}).ClientID
b := buildMQTTOpts(MQTTSource{Broker: "ssl://b:8883", Name: "beta"}).ClientID
if a == b {
t.Errorf("distinct sources must get distinct ClientIDs (#1337): both got %q — duplicate IDs cause broker to disconnect the older one, infinite flap", a)
}
}
-296
View File
@@ -1,296 +0,0 @@
package main
import (
"fmt"
"log"
"sync"
"sync/atomic"
"time"
)
// heartbeatInterval is how often the watchdog re-emits a still-stalled
// reminder once the initial WARN edge has fired. 1h matches the pager
// budget — frequent enough that an unattended stall is noticed within a
// shift, infrequent enough not to spam ops chat.
const livenessHeartbeatInterval = time.Hour
// LivenessKind enumerates the watchdog verdicts for a source. Edge-triggered
// transitions use this to decide whether to emit (and what severity).
type LivenessKind int
const (
LivenessOK LivenessKind = iota
LivenessStalled
LivenessNeverReceived
LivenessRecovered
LivenessHeartbeat
// LivenessDisconnected (PR #1216 r2 item 1): paho reports !IsConnected.
// Distinct from LivenessOK so processLivenessTransition does NOT
// interpret a TCP drop as recovery and fire a spurious "messages
// flowing again" INFO when the source actually went from silently
// broken to overtly broken. paho's own reconnect logging already
// covers the disconnect — this kind exists solely to keep the
// transition engine from mis-classifying it.
LivenessDisconnected
)
// SourceLivenessState tracks per-source last-message timestamp and connection
// state for the stall watchdog (#1212). LastMessageUnix is updated by the
// message handler via atomic store; the watchdog reads it via atomic load.
//
// PR #1216 r1 added:
// - StartedAt: re-stamped on reconnect to suppress transient-stall WARNs
// during paho's reconnect window.
// - LastAlertUnix: edge-trigger cooldown; prevents 60-per-hour re-emits
// of the same WARN.
//
// PR #1216 r2 added:
// - FirstConnectedAt: stamped ONCE at registration, never reset. The
// cold-start "NEVER received" alarm uses this so a broker that flaps
// in CONNECT → SUBSCRIBE-deny cannot indefinitely re-arm the grace
// window. r1's StartedAt-as-grace-clock conflated transient-stall
// suppression with cold-start grace; r2 separates them.
type SourceLivenessState struct {
Tag string
Broker string
LastMessageUnix int64 // atomic; unix seconds of last successfully received MQTT message
// FirstConnectedAt (PR #1216 r2 item 2) is stamped ONCE at
// registerLivenessState time and never reset. Cold-start grace
// checks against this so a flapping broker (CONNECT ok, SUBSCRIBE
// ACL-denied — the #1212 shape) can no longer suppress the
// "NEVER received" alarm by re-stamping StartedAt on every reconnect.
FirstConnectedAt int64 // atomic; unix seconds of first registration
StartedAt int64 // atomic; unix seconds when the source was registered / last reconnected (transient-stall tracking)
LastAlertUnix int64 // atomic; unix seconds of last emit (WARN or heartbeat); 0 means quiet
IsConnectedFn func() bool
// AttemptCount is incremented on every TCP/TLS connection attempt. Used
// by ConnectionAttemptHandler to log attempt # independent of paho's
// internal reconnect-loop state. atomic.
AttemptCount int64
}
// MarkMessage records the time of a received MQTT message. Cheap; safe to
// call from the message-handling hot path.
func (s *SourceLivenessState) MarkMessage(now time.Time) {
atomic.StoreInt64(&s.LastMessageUnix, now.Unix())
}
// MarkReconnected clears stale liveness state so the watchdog does not
// false-alarm on a pre-outage timestamp after paho re-establishes the
// connection (PR #1216 r1 item 2). Resets LastMessageUnix, re-stamps
// StartedAt (transient-stall window restarts), and clears LastAlertUnix
// (edge-trigger re-arms).
//
// PR #1216 r2 item 2: FirstConnectedAt is INTENTIONALLY not touched here.
// Under broker flap (CONNECT ok, SUBSCRIBE ACL-denied — exact #1212
// class) r1 reset StartedAt on every reconnect, indefinitely re-arming
// the cold-start grace and silencing the headline "NEVER received"
// alarm. Cold-start grace now reads FirstConnectedAt instead, so the
// alarm fires after the FIRST grace window regardless of reconnect
// churn.
func (s *SourceLivenessState) MarkReconnected(now time.Time) {
atomic.StoreInt64(&s.LastMessageUnix, 0)
atomic.StoreInt64(&s.StartedAt, now.Unix())
atomic.StoreInt64(&s.LastAlertUnix, 0)
}
// checkSourceLiveness returns (message, kind) describing the source's
// liveness state. kind==LivenessOK means quiet/healthy; kind==
// LivenessDisconnected means paho is not connected (silent state — no
// emit, no recovery). Any other kind indicates the caller may want to
// emit (subject to edge-trigger).
//
// Cold-start (PR #1216 r1 item 1, r2 item 2): when LastMessageUnix==0,
// the source has never published a single message. If FirstConnectedAt
// was stamped at registration and more than `threshold` has elapsed,
// this is the #1212 failure class — wrong channel hash, ACL drops
// SUBSCRIBE, half-open TCP after CONNECT, or a broker that loops
// CONNECT-then-disconnect. We emit a DISTINCT "NEVER received" alarm
// so operators can grep for it independently of generic stalls. Using
// FirstConnectedAt (not the reconnect-reset StartedAt) ensures broker
// flap cannot silence this alarm.
func checkSourceLiveness(s *SourceLivenessState, threshold time.Duration, now time.Time) (string, LivenessKind) {
if s == nil || s.IsConnectedFn == nil {
return "", LivenessOK
}
if !s.IsConnectedFn() {
// paho's reconnect handler covers the disconnected case. Return
// a DISTINCT kind so the transition engine does not mis-classify
// disconnect as recovery (PR #1216 r2 item 1).
return "", LivenessDisconnected
}
last := atomic.LoadInt64(&s.LastMessageUnix)
if last == 0 {
firstConnected := atomic.LoadInt64(&s.FirstConnectedAt)
if firstConnected == 0 {
// Registration didn't stamp FirstConnectedAt — conservative: stay quiet.
return "", LivenessOK
}
sinceFirst := now.Sub(time.Unix(firstConnected, 0))
if sinceFirst < threshold {
return "", LivenessOK
}
msg := fmt.Sprintf("MQTT [%s] WATCHDOG: client reports connected to %s but has NEVER received a message in %s (threshold %s) — check channel hash / subscribe ACL / half-open TCP",
s.Tag, s.Broker, sinceFirst.Round(time.Second), threshold)
return msg, LivenessNeverReceived
}
silentFor := now.Sub(time.Unix(last, 0))
if silentFor < threshold {
return "", LivenessOK
}
msg := fmt.Sprintf("MQTT [%s] WATCHDOG: client reports connected to %s but no messages received for %s (threshold %s) — possible half-open socket or upstream stall",
s.Tag, s.Broker, silentFor.Round(time.Second), threshold)
return msg, LivenessStalled
}
// livenessRegistry is a package-level lookup so handleMessage (called with
// only `tag string`) can mark liveness without threading the state through
// every call site. Reads dominate (per message); writes happen once per
// source at startup.
var (
livenessRegistry = map[string]*SourceLivenessState{}
livenessRegistryMu sync.RWMutex
)
// registerLivenessState publishes a state to the registry by tag. Returns
// an error on tag collision (PR #1216 r1 item 4) so operators see a
// startup misconfiguration instead of silently losing AttemptCount and
// LastMessageUnix for the clobbered source. The collision case is real:
// two MQTT sources with empty Name fall back to Broker; two sources with
// duplicate Name; copy-paste in config.json. Caller (main) decides whether
// to fatal or just log and skip. The first registration remains
// authoritative — we do NOT overwrite.
//
// Also stamps StartedAt (transient-stall window) and FirstConnectedAt
// (cold-start grace anchor — never reset; see r2 item 2 in
// MarkReconnected) so the cold-start watchdog has its clocks.
func registerLivenessState(s *SourceLivenessState) error {
livenessRegistryMu.Lock()
defer livenessRegistryMu.Unlock()
if existing, ok := livenessRegistry[s.Tag]; ok {
return fmt.Errorf("liveness registry: duplicate tag %q (existing broker=%s, new broker=%s) — fix config so each MQTT source has a unique Name", s.Tag, existing.Broker, s.Broker)
}
nowUnix := time.Now().Unix()
if atomic.LoadInt64(&s.StartedAt) == 0 {
atomic.StoreInt64(&s.StartedAt, nowUnix)
}
if atomic.LoadInt64(&s.FirstConnectedAt) == 0 {
atomic.StoreInt64(&s.FirstConnectedAt, nowUnix)
}
livenessRegistry[s.Tag] = s
return nil
}
// registerLivenessOrSkip (PR #1216 r2 item 3) is the main-callsite wrapper
// that replaces the previous log.Fatalf on tag collision. Fatal at
// startup over a config typo would kill the entire ingestor and recreate
// the #1212 total-ingest-stop class this PR exists to prevent. On
// collision we log ERROR + skip — the MQTT source still attempts to
// connect, it just won't be tracked by the liveness watchdog. Returns
// true iff the source was registered.
func registerLivenessOrSkip(s *SourceLivenessState) bool {
if err := registerLivenessState(s); err != nil {
log.Printf("[ingestor] ERROR: source tag collision %q — skipping duplicate liveness registration, this source will connect but will not be tracked by the watchdog (%v)", s.Tag, err)
return false
}
return true
}
// markLivenessForTag is the hot-path entry point: O(1) map lookup +
// atomic store. Safe to call for unknown tags (no-op).
func markLivenessForTag(tag string, now time.Time) {
livenessRegistryMu.RLock()
s := livenessRegistry[tag]
livenessRegistryMu.RUnlock()
if s != nil {
s.MarkMessage(now)
}
}
// runLivenessWatchdog starts a goroutine that scans the registry every
// `interval` and logs a warning for any source that has been silent while
// connected for more than `threshold`. Returns a stop function that halts
// the ticker AND signals the goroutine to exit (time.Ticker.Stop does NOT
// close the channel, so a naive `for range t.C` would leak). interval
// should be a fraction of threshold (e.g. threshold/5) so detection
// latency is bounded.
func runLivenessWatchdog(interval, threshold time.Duration) (stop func()) {
t := time.NewTicker(interval)
done := make(chan struct{})
go runLivenessWatchdogLoop(t.C, done, threshold, log.Print)
return func() {
t.Stop()
close(done)
}
}
// runLivenessWatchdogLoop is the goroutine body, extracted so tests can
// drive it with a synthetic tick channel and capture log output without
// racing on the real ticker.
//
// Edge-triggered (PR #1216 r1 item 3):
// - quiet → stalled / never-received: emit WARN once, record LastAlertUnix
// - still stalled, < heartbeat interval since last alert: suppress
// - still stalled, ≥ heartbeat interval since last alert: emit reminder,
// refresh LastAlertUnix
// - stalled → flowing: emit recovery INFO once, clear LastAlertUnix
//
// Without this, the original loop re-emitted the same WARN on every 60s
// tick (60 alerts/hr/source) — the kind of log flood that trains ops to
// mute alerts and miss the next real outage.
func runLivenessWatchdogLoop(tick <-chan time.Time, done <-chan struct{}, threshold time.Duration, emit func(...any)) {
for {
select {
case <-done:
return
case now, ok := <-tick:
if !ok {
return
}
livenessRegistryMu.RLock()
states := make([]*SourceLivenessState, 0, len(livenessRegistry))
for _, s := range livenessRegistry {
states = append(states, s)
}
livenessRegistryMu.RUnlock()
for _, s := range states {
msg, kind := checkSourceLiveness(s, threshold, now)
processLivenessTransition(s, kind, msg, now, emit)
}
}
}
}
// processLivenessTransition applies the edge-trigger rules and updates
// LastAlertUnix accordingly. Separated for testability and to keep the
// loop body small.
func processLivenessTransition(s *SourceLivenessState, kind LivenessKind, msg string, now time.Time, emit func(...any)) {
lastAlert := atomic.LoadInt64(&s.LastAlertUnix)
switch kind {
case LivenessStalled, LivenessNeverReceived:
if lastAlert == 0 {
// First detection — fire WARN edge.
emit(msg)
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
return
}
// Already alerted; only re-emit on heartbeat interval to avoid log flood.
if now.Sub(time.Unix(lastAlert, 0)) >= livenessHeartbeatInterval {
emit(fmt.Sprintf("MQTT [%s] WATCHDOG heartbeat: still stalled — %s", s.Tag, msg))
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
}
case LivenessOK:
if lastAlert != 0 {
// Recovered: emit INFO once, clear the cooldown.
emit(fmt.Sprintf("MQTT [%s] WATCHDOG INFO: messages flowing again (recovered)", s.Tag))
atomic.StoreInt64(&s.LastAlertUnix, 0)
}
case LivenessDisconnected:
// PR #1216 r2 item 1: disconnect is NOT recovery. Stay completely
// silent — paho's reconnect handler already logs the drop — and
// preserve LastAlertUnix so the WARN edge can re-fire if/when
// the source comes back stalled. Clearing the cooldown here
// would mean a flapping source spams the WARN every cycle.
}
}
-286
View File
@@ -1,286 +0,0 @@
package main
import (
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// PR #1216 round-1 review fixes. Tests are RED before the fix lands:
// - Item 1: cold-start blind spot — silent-from-start source never alarmed.
// - Item 2: reconnect reset — stale LastMessageUnix triggers false stall after recovery.
// - Item 3: log flood — every-60s rescan re-emits same WARN forever.
// - Item 4: tag collision in registerLivenessState silently overwrites prior state.
// waitFor polls until emits reaches `want` items or the deadline elapses.
// Used to serialize "drain this tick before mutating state" in goroutine
// tests so we observe deterministic edge transitions.
func waitFor(t *testing.T, mu *sync.Mutex, emits *[]string, want int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
mu.Lock()
n := len(*emits)
mu.Unlock()
if n >= want {
return
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
t.Fatalf("timeout waiting for %d emits; got %d: %v", want, len(*emits), *emits)
}
// Item 1 (RED): a source that connects but never receives a message is
// invisible to the current watchdog (LastMessageUnix==0 → skip). This is
// the exact #1212 failure class — wrong channel hash, ACL drops SUBSCRIBE,
// half-open TCP after CONNECT. Fix: stamp StartedAt at registration; when
// LastMessageUnix==0 AND now-StartedAt > threshold, alarm with a distinct
// "NEVER received" message.
func TestMQTTStallWatchdog_FiresOnSilentFromStart(t *testing.T) {
now := time.Now()
state := &SourceLivenessState{
Tag: "cold",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
}
atomic.StoreInt64(&state.StartedAt, now.Add(-10*time.Minute).Unix())
atomic.StoreInt64(&state.FirstConnectedAt, now.Add(-10*time.Minute).Unix())
// LastMessageUnix stays 0 — never received anything.
msg, kind := checkSourceLiveness(state, 5*time.Minute, now)
if kind != LivenessNeverReceived {
t.Fatalf("expected LivenessNeverReceived for silent-from-start source after threshold; got kind=%v msg=%q", kind, msg)
}
if !strings.Contains(strings.ToUpper(msg), "NEVER") {
t.Errorf("cold-start alarm must mention NEVER received to distinguish from generic stall; got %q", msg)
}
if !strings.Contains(msg, "cold") {
t.Errorf("alarm must include source tag; got %q", msg)
}
}
func TestMQTTStallWatchdog_QuietDuringColdStartGrace(t *testing.T) {
now := time.Now()
state := &SourceLivenessState{
Tag: "warming-up",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
}
atomic.StoreInt64(&state.StartedAt, now.Add(-30*time.Second).Unix())
atomic.StoreInt64(&state.FirstConnectedAt, now.Add(-30*time.Second).Unix())
_, kind := checkSourceLiveness(state, 5*time.Minute, now)
if kind != LivenessOK {
t.Fatalf("must NOT alarm during cold-start grace (30s in, threshold 5m); got kind=%v", kind)
}
}
// Item 2 (RED): after a long outage + paho reconnect, LastMessageUnix is
// still 2h-old → watchdog screams "stalled for 2h" immediately. Fix: reset
// LastMessageUnix (and the cold-start clock) on OnConnect. This test
// asserts the reset method does what's required so the next watchdog scan
// stays quiet for the grace window.
func TestMQTTStallWatchdog_OnReconnectResetsClocks(t *testing.T) {
now := time.Now()
state := &SourceLivenessState{
Tag: "flaky",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
}
// 2-hour-old timestamp from before the outage.
atomic.StoreInt64(&state.LastMessageUnix, now.Add(-2*time.Hour).Unix())
atomic.StoreInt64(&state.StartedAt, now.Add(-3*time.Hour).Unix())
// Stale alert cooldown from before the outage too — must NOT carry forward.
atomic.StoreInt64(&state.LastAlertUnix, now.Add(-90*time.Minute).Unix())
state.MarkReconnected(now)
if last := atomic.LoadInt64(&state.LastMessageUnix); last != 0 {
t.Errorf("LastMessageUnix must be cleared on reconnect so a stale pre-outage timestamp does not trip the watchdog; got %d", last)
}
if started := atomic.LoadInt64(&state.StartedAt); started != now.Unix() {
t.Errorf("StartedAt must be re-stamped on reconnect so the cold-start grace window restarts; got %d want %d", started, now.Unix())
}
if alert := atomic.LoadInt64(&state.LastAlertUnix); alert != 0 {
t.Errorf("LastAlertUnix must be cleared on reconnect so edge-trigger re-arms; got %d", alert)
}
// Now drive checkSourceLiveness immediately after reconnect: must NOT alarm.
_, kind := checkSourceLiveness(state, 5*time.Minute, now.Add(1*time.Second))
if kind != LivenessOK {
t.Fatalf("watchdog must stay quiet immediately after MarkReconnected; got kind=%v", kind)
}
}
// Item 3 (RED): the watchdog loop currently re-emits the same WARN on every
// 60s tick (60 alerts/hr/source). Fix: edge-trigger — emit WARN once on
// quiet→stalled transition, INFO once on stalled→flowing recovery, and an
// hourly heartbeat while still stalled. Asserts: 3 consecutive ticks on a
// stalled source produce exactly ONE WARN.
func TestMQTTStallWatchdog_EdgeTriggeredEmitsOnlyOnce(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
s := &SourceLivenessState{
Tag: "stuck",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
}
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
registerLivenessState(s)
var mu sync.Mutex
var emits []string
emit := func(args ...any) {
mu.Lock()
defer mu.Unlock()
if len(args) > 0 {
if str, ok := args[0].(string); ok {
emits = append(emits, str)
}
}
}
tick := make(chan time.Time, 3)
done := make(chan struct{})
exited := make(chan struct{})
go func() {
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
close(exited)
}()
// Three back-to-back ticks within the heartbeat window. Only the first
// should emit a WARN; the other two must be suppressed (edge-triggered).
tick <- now
tick <- now.Add(30 * time.Second)
tick <- now.Add(60 * time.Second)
// Wait for ticks to drain.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
n := len(emits)
mu.Unlock()
if n >= 1 && time.Since(deadline.Add(-2*time.Second)) > 200*time.Millisecond {
break
}
time.Sleep(20 * time.Millisecond)
}
close(done)
<-exited
mu.Lock()
got := append([]string(nil), emits...)
mu.Unlock()
warns := 0
for _, e := range got {
if strings.Contains(e, "WATCHDOG") || strings.Contains(e, "stalled") || strings.Contains(strings.ToUpper(e), "WARN") {
warns++
}
}
if warns != 1 {
t.Fatalf("expected exactly 1 stall WARN across 3 consecutive scans (edge-trigger); got %d: %v", warns, got)
}
}
// Item 3 (RED): on stalled→flowing transition, a recovery INFO must fire
// exactly once. Future ticks must stay silent until a new stall edge.
func TestMQTTStallWatchdog_RecoveryEmitOnce(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
s := &SourceLivenessState{
Tag: "src-b",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
}
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
registerLivenessState(s)
var mu sync.Mutex
var emits []string
emit := func(args ...any) {
mu.Lock()
defer mu.Unlock()
if len(args) > 0 {
if str, ok := args[0].(string); ok {
emits = append(emits, str)
}
}
}
tick := make(chan time.Time, 4)
done := make(chan struct{})
exited := make(chan struct{})
go func() {
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
close(exited)
}()
tick <- now // → WARN
// Wait for the goroutine to drain that tick and record the WARN edge
// before we mutate state — otherwise we race the loop and the first
// emit observes the "recovered" timestamp instead of the stall.
waitFor(t, &mu, &emits, 1, 2*time.Second)
// Source recovers: a recent message arrives.
atomic.StoreInt64(&s.LastMessageUnix, now.Add(30*time.Second).Unix())
tick <- now.Add(60 * time.Second) // → recovery INFO
waitFor(t, &mu, &emits, 2, 2*time.Second)
tick <- now.Add(120 * time.Second) // → silent
tick <- now.Add(180 * time.Second) // → silent
// Brief settle so any (incorrect) extra emits land before we count.
time.Sleep(100 * time.Millisecond)
close(done)
<-exited
mu.Lock()
got := append([]string(nil), emits...)
mu.Unlock()
infos := 0
for _, e := range got {
upper := strings.ToUpper(e)
if strings.Contains(upper, "RECOVER") || strings.Contains(upper, "FLOWING") {
infos++
}
}
if len(got) != 2 {
t.Fatalf("expected exactly 2 emits (1 WARN + 1 recovery INFO); got %d: %v", len(got), got)
}
if infos != 1 {
t.Fatalf("expected exactly 1 recovery INFO emit; got %d (all=%v)", infos, got)
}
}
// Item 4 (RED): registerLivenessState silently overwrites on tag collision
// (empty-Name + same broker, duplicate Name). Must detect & report.
func TestRegisterLivenessState_DetectsTagCollision(t *testing.T) {
defer snapshotAndResetRegistry(t)()
a := &SourceLivenessState{Tag: "dup", Broker: "tcp://a:1883"}
b := &SourceLivenessState{Tag: "dup", Broker: "tcp://b:1883"}
if err := registerLivenessState(a); err != nil {
t.Fatalf("first registration must succeed; got %v", err)
}
if err := registerLivenessState(b); err == nil {
t.Fatal("second registration with same tag must return a collision error (current behavior silently clobbers)")
}
// And the registry must still hold the FIRST registration — clobbering
// AttemptCount/LastMessageUnix invisibly is the bug.
livenessRegistryMu.RLock()
got := livenessRegistry["dup"]
livenessRegistryMu.RUnlock()
if got != a {
t.Errorf("on collision, first registration must remain authoritative (got pointer for broker=%s)", got.Broker)
}
}
-228
View File
@@ -1,228 +0,0 @@
package main
import (
"bytes"
"log"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// PR #1216 round-2 review fixes. Tests RED before the fix lands.
//
// r1 closed the cold-start blind spot but introduced three new failure
// modes that r2 must eliminate:
//
// r2 #1 — checkSourceLiveness returns LivenessOK for BOTH "messages
// flowing" AND "disconnected/never-connected". A stalled source
// whose TCP eventually RSTs trips processLivenessTransition's
// recovery branch and emits "messages flowing again (recovered)"
// while going from silently broken to overtly broken. Fix: a
// distinct LivenessDisconnected kind that the transition
// function treats as a silent (no-emit) state, so the alert
// cooldown does not collapse on a non-event.
//
// r2 #2 — MarkReconnected re-stamps StartedAt on every reconnect, so
// the cold-start grace clock restarts forever under a broker
// flap (CONNECT ok, SUBSCRIBE ACL-denied — the exact #1212
// shape). The headline "NEVER received" alarm never fires.
// Fix: separate FirstConnectedAt (set once at registration,
// never reset) from StartedAt (free to reset on reconnect for
// transient-stall tracking). Cold-start grace must use
// FirstConnectedAt.
//
// r2 #3 — main.go calls log.Fatalf on a tag collision in the liveness
// registry, killing the entire ingestor over one config typo.
// That recreates the #1212 total-ingest-stop failure class
// this PR exists to prevent. Fix: log an ERROR and skip
// liveness registration for the duplicate — the MQTT source
// still attempts to connect, just isn't tracked by the
// watchdog (the first registration remains authoritative).
// r2 #1 RED: a stalled source whose connection then drops must NOT emit
// "recovered". The current code does — checkSourceLiveness returns
// LivenessOK for both genuine recovery and disconnection, so
// processLivenessTransition sees lastAlert!=0 + kind==LivenessOK and
// fires the recovery INFO. Operators reading the log think the source
// healed when it actually died.
func TestMQTTStallWatchdog_NoFalseRecoveryOnDisconnect(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
var connected atomic.Bool
connected.Store(true)
s := &SourceLivenessState{
Tag: "drops-after-stall",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return connected.Load() },
}
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: registerLivenessState: %v", err)
}
var mu sync.Mutex
var emits []string
emit := func(args ...any) {
mu.Lock()
defer mu.Unlock()
if len(args) > 0 {
if str, ok := args[0].(string); ok {
emits = append(emits, str)
}
}
}
tick := make(chan time.Time, 2)
done := make(chan struct{})
exited := make(chan struct{})
go func() {
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
close(exited)
}()
// Tick 1: source connected + 10m silent → WARN edge.
tick <- now
waitFor(t, &mu, &emits, 1, 2*time.Second)
// The TCP socket RSTs — paho flips IsConnected to false. The watchdog
// must NOT interpret this as recovery; the source went from silently
// broken to overtly broken.
connected.Store(false)
tick <- now.Add(60 * time.Second)
// Settle so any (incorrect) extra emits land before we count.
time.Sleep(150 * time.Millisecond)
close(done)
<-exited
mu.Lock()
got := append([]string(nil), emits...)
mu.Unlock()
for _, e := range got {
upper := strings.ToUpper(e)
if strings.Contains(upper, "RECOVER") || strings.Contains(upper, "FLOWING AGAIN") {
t.Fatalf("watchdog must NOT emit recovery INFO when a stalled source disconnects; got %q (all=%v)", e, got)
}
}
}
// r2 #2 RED: a broker that ACKs CONNECT but denies SUBSCRIBE causes paho
// to loop CONNECT → drop → CONNECT → drop. Each reconnect calls
// MarkReconnected, which re-stamps StartedAt=now and resets the
// cold-start grace clock. After 30 minutes of flapping, the source has
// still NEVER received a message, but the "NEVER received" alarm never
// fires because sinceStart is always sub-threshold. Fix: track
// FirstConnectedAt separately from StartedAt; the cold-start check must
// use the former.
func TestMQTTStallWatchdog_ColdStartSurvivesBrokerFlap(t *testing.T) {
defer snapshotAndResetRegistry(t)()
t0 := time.Now()
s := &SourceLivenessState{
Tag: "flapping-acl-deny",
Broker: "tcp://acl-denied:1883",
IsConnectedFn: func() bool { return true },
}
// First registration stamps FirstConnectedAt (and StartedAt) at t0.
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: registerLivenessState: %v", err)
}
// Paho keeps re-establishing the TCP/MQTT session every minute. No
// message ever arrives because SUBSCRIBE is denied. Each reconnect
// resets StartedAt.
for i := 1; i <= 6; i++ {
s.MarkReconnected(t0.Add(time.Duration(i) * time.Minute))
}
// 6m after the very first connection — well past the 5m cold-start
// threshold. The headline alarm must fire.
now := t0.Add(6*time.Minute + 30*time.Second)
_, kind := checkSourceLiveness(s, 5*time.Minute, now)
if kind != LivenessNeverReceived {
t.Fatalf("under broker flap (#1212 ACL-deny class), cold-start alarm must fire based on FirstConnectedAt, not the most recent reconnect; got kind=%v", kind)
}
}
// Sanity check: a single transient reconnect WITHIN the cold-start window
// must NOT prematurely trip the NeverReceived alarm — the grace was
// designed for that. This guards against an over-correction where r2
// switches blindly to FirstConnectedAt and ignores legitimate startup
// jitter.
func TestMQTTStallWatchdog_TransientReconnectDuringGraceStaysQuiet(t *testing.T) {
defer snapshotAndResetRegistry(t)()
t0 := time.Now()
s := &SourceLivenessState{
Tag: "transient-reconnect",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
}
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: registerLivenessState: %v", err)
}
// 30s in, one transient reconnect.
s.MarkReconnected(t0.Add(30 * time.Second))
// 1m after registration — still inside the 5m grace.
_, kind := checkSourceLiveness(s, 5*time.Minute, t0.Add(1*time.Minute))
if kind != LivenessOK {
t.Fatalf("during cold-start grace, transient reconnects must stay quiet; got kind=%v", kind)
}
}
// r2 #3 RED: tag collision must not kill the ingestor. main.go currently
// log.Fatalf's, which recreates the #1212 total-ingest-stop class this
// PR exists to prevent. registerLivenessOrSkip is the small helper main
// will call instead: log an ERROR + skip liveness registration for the
// duplicate, return false so the caller knows the source is connecting
// untracked. The first registration remains authoritative.
func TestRegisterLivenessOrSkip_LogsErrorAndDoesNotExitOnCollision(t *testing.T) {
defer snapshotAndResetRegistry(t)()
var buf bytes.Buffer
origOut := log.Writer()
origFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
defer func() {
log.SetOutput(origOut)
log.SetFlags(origFlags)
}()
a := &SourceLivenessState{Tag: "dup", Broker: "tcp://a:1883"}
b := &SourceLivenessState{Tag: "dup", Broker: "tcp://b:1883"}
if ok := registerLivenessOrSkip(a); !ok {
t.Fatalf("first registration must succeed; helper returned false (log=%q)", buf.String())
}
if ok := registerLivenessOrSkip(b); ok {
t.Fatalf("second registration with same tag must return false (skip); helper returned true (log=%q)", buf.String())
}
logOut := buf.String()
if !strings.Contains(logOut, "ERROR") {
t.Errorf("collision must be logged at ERROR severity so operators see it without it crashing the process; got %q", logOut)
}
if !strings.Contains(logOut, "dup") {
t.Errorf("collision log must include the offending tag; got %q", logOut)
}
if !strings.Contains(strings.ToLower(logOut), "skip") {
t.Errorf("collision log must say the duplicate is being skipped so operators know the source is untracked; got %q", logOut)
}
// And the registry still holds the FIRST registration.
livenessRegistryMu.RLock()
got := livenessRegistry["dup"]
livenessRegistryMu.RUnlock()
if got != a {
t.Errorf("first registration must remain authoritative after collision-skip; got pointer for broker=%s", got.Broker)
}
}
-246
View File
@@ -1,246 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
)
// NeighborEdgesBuilderInterval is how often the ingestor rescans
// observations and refreshes neighbor_edges. Server reads with the
// same 60s cadence (see cmd/server/neighbor_recomputer.go); a 60s
// pulse here is sufficient to keep the snapshot fresh.
const NeighborEdgesBuilderInterval = 60 * time.Second
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
// Duplicated rather than imported so the ingestor binary stays
// independent of the server package.
const payloadADVERT = 0x04
// edgeRow is one row to upsert into neighbor_edges. (a, b) is already
// canonical-ordered (a <= b).
type edgeRow struct {
a, b, ts string
}
// StartNeighborEdgesBuilder launches the periodic builder. On each
// tick it rescans recent observations + transmissions and upserts
// derived neighbor_edges rows. Builder is the only writer to
// neighbor_edges (#1287).
//
// The function returns a stop closure. Initial build runs synchronously
// before the ticker starts so the server's first snapshot load picks
// up real data instead of an empty table.
func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
if interval <= 0 {
interval = NeighborEdgesBuilderInterval
}
stop := make(chan struct{})
done := make(chan struct{})
// Synchronous warm-up: a single pass so the first server load
// after process start sees a populated table.
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
log.Printf("[neighbor-build] initial build error: %v", err)
} else {
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
}
var stopOnce sync.Once
go func() {
defer close(done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
log.Printf("[neighbor-build] tick error: %v", err)
} else if n > 0 {
log.Printf("[neighbor-build] %d edges upserted", n)
}
case <-stop:
return
}
}
}()
return func() {
stopOnce.Do(func() { close(stop) })
select {
case <-done:
case <-time.After(5 * time.Second):
}
}
}
// buildAndPersistNeighborEdges scans transmissions + observations,
// extracts edge candidates (originator↔first-hop on ADVERTs;
// observer↔last-hop on all packet types) and upserts them into
// neighbor_edges. Returns count of attempted upserts.
//
// Resolution of hop-prefix → full pubkey is done via a one-shot
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
// multiple candidates are skipped (matches the conservative
// resolution rule in cmd/server/extractEdgesFromObs).
func (s *Store) buildAndPersistNeighborEdges() (int, error) {
prefixIdx, err := buildPrefixIndex(s.db)
if err != nil {
return 0, fmt.Errorf("build prefix index: %w", err)
}
rows, err := s.db.Query(`SELECT
t.payload_type,
t.decoded_json,
COALESCE(t.from_pubkey, ''),
COALESCE(o.path_json, ''),
COALESCE(obs.id, '') AS observer_id,
o.timestamp
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
if err != nil {
return 0, fmt.Errorf("scan observations: %w", err)
}
defer rows.Close()
var edges []edgeRow
for rows.Next() {
var payloadType sql.NullInt64
var decodedJSON, fromPubkey, pathJSON, observerID string
var epochTs int64
if err := rows.Scan(&payloadType, &decodedJSON, &fromPubkey, &pathJSON, &observerID, &epochTs); err != nil {
continue
}
fromNode := strings.ToLower(fromPubkey)
if fromNode == "" {
fromNode = strings.ToLower(extractPubkeyFromAdvertJSON(decodedJSON))
}
isAdvert := payloadType.Valid && payloadType.Int64 == int64(payloadADVERT)
ts := time.Unix(epochTs, 0).UTC().Format(time.RFC3339)
observerPK := strings.ToLower(observerID)
path := parsePathArray(pathJSON)
if len(path) == 0 {
if isAdvert && fromNode != "" && fromNode != observerPK && observerPK != "" {
edges = append(edges, canonEdge(fromNode, observerPK, ts))
}
continue
}
if isAdvert && fromNode != "" {
if resolved, ok := resolvePrefix(prefixIdx, path[0]); ok && resolved != fromNode {
edges = append(edges, canonEdge(fromNode, resolved, ts))
}
}
if observerPK != "" {
last := path[len(path)-1]
if resolved, ok := resolvePrefix(prefixIdx, last); ok && resolved != observerPK {
edges = append(edges, canonEdge(observerPK, resolved, ts))
}
}
}
if len(edges) == 0 {
return 0, nil
}
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
VALUES (?, ?, 1, ?)
ON CONFLICT(node_a, node_b) DO UPDATE SET
count = count + 1,
last_seen = MAX(last_seen, excluded.last_seen)`)
if err != nil {
return 0, fmt.Errorf("prepare: %w", err)
}
defer stmt.Close()
var firstErr error
for _, e := range edges {
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
firstErr = err
}
}
if firstErr != nil {
return 0, fmt.Errorf("upsert: %w", firstErr)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit: %w", err)
}
return len(edges), nil
}
// canonEdge orders the pair so node_a <= node_b (matches the existing
// schema convention used by the loader and the bridge recomputer).
func canonEdge(a, b, ts string) edgeRow {
if a > b {
a, b = b, a
}
return edgeRow{a, b, ts}
}
// parsePathArray returns the hop strings from a path_json blob.
// Defensive against missing/invalid JSON.
func parsePathArray(s string) []string {
if s == "" || s == "[]" {
return nil
}
var arr []string
if json.Unmarshal([]byte(s), &arr) != nil {
return nil
}
return arr
}
// prefixIndex maps a hop prefix (lowercase) → all full pubkeys whose
// public_key starts with that prefix. Prefixes with > 1 candidate are
// considered ambiguous and skipped during resolution.
type prefixIndex map[string][]string
// buildPrefixIndex reads nodes.public_key and builds the prefix → pubkey
// map. We index every 1-byte (2 hex char) prefix length the firmware
// uses (1, 2, 3, 4, 6, 8). Memory cost is O(nodes × len(prefixLens)).
func buildPrefixIndex(db *sql.DB) (prefixIndex, error) {
rows, err := db.Query(`SELECT public_key FROM nodes`)
if err != nil {
return nil, err
}
defer rows.Close()
idx := make(prefixIndex, 1024)
var prefixLens = []int{1 * 2, 2 * 2, 3 * 2, 4 * 2, 6 * 2, 8 * 2}
for rows.Next() {
var pk string
if err := rows.Scan(&pk); err != nil {
continue
}
pkLower := strings.ToLower(pk)
for _, n := range prefixLens {
if len(pkLower) < n {
continue
}
prefix := pkLower[:n]
idx[prefix] = append(idx[prefix], pkLower)
}
}
return idx, nil
}
// resolvePrefix returns the single resolved pubkey if exactly one
// candidate matches, otherwise (zero || multiple), it returns ok=false
// (matches the conservative server-side resolver in
// cmd/server/extractEdgesFromObs).
func resolvePrefix(idx prefixIndex, hop string) (string, bool) {
h := strings.ToLower(hop)
candidates := idx[h]
if len(candidates) != 1 {
return "", false
}
return candidates[0], true
}
-87
View File
@@ -1,87 +0,0 @@
package main
import (
"path/filepath"
"testing"
)
// TestNeighborEdgesBuilderUpsertsFromObservations enforces issue
// #1287 Option 4: the INGESTOR builds neighbor_edges from raw
// observations/transmissions and persists them. Server is read-only.
//
// Synthesize a tiny DB with one ADVERT observation whose path[0]
// uniquely resolves to a known node, then assert the builder writes
// the expected edge.
func TestNeighborEdgesBuilderUpsertsFromObservations(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "build.db")
// Open via the ingestor's normal opener so applySchema and
// dbschema.Apply both run (the builder requires neighbor_edges +
// observers.iata etc.).
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Seed two nodes whose pubkey prefixes will be used as hops.
if _, err := store.db.Exec(
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
"aaaaaaaaaa", "from-node",
"bbbbbbbbbb", "first-hop",
); err != nil {
t.Fatal(err)
}
// Seed one observer.
if _, err := store.db.Exec(
`INSERT INTO observers (id, name) VALUES (?, ?)`,
"obs-1", "observer-1",
); err != nil {
t.Fatal(err)
}
var obsRowid int64
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
t.Fatal(err)
}
// Insert one ADVERT transmission with from_pubkey = aaaaa…
res, err := store.db.Exec(
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"", "h1", "2026-01-01T00:00:00Z", 0, payloadADVERT, 0, "{}", "aaaaaaaaaa",
)
if err != nil {
t.Fatal(err)
}
txID, _ := res.LastInsertId()
// Insert one observation whose path[0] = "bb" (2-hex prefix unique
// to bbbbb… in the nodes table). Expected edge: a↔b.
if _, err := store.db.Exec(
`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, ?, ?)`,
txID, obsRowid, `["bb"]`, int64(1735689600),
); err != nil {
t.Fatal(err)
}
n, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("buildAndPersistNeighborEdges: %v", err)
}
if n == 0 {
t.Fatal("expected at least 1 edge upserted, got 0")
}
var got int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges WHERE node_a = ? AND node_b = ?`, "aaaaaaaaaa", "bbbbbbbbbb").Scan(&got); err != nil {
t.Fatal(err)
}
if got != 1 {
t.Fatalf("expected the a↔b edge to be persisted; got %d rows", got)
}
}
// (test ends here)
-43
View File
@@ -1,43 +0,0 @@
package main
import (
"testing"
)
func TestIngestorIsObserverBlacklisted(t *testing.T) {
cfg := &Config{
ObserverBlacklist: []string{"OBS1", "obs2"},
}
tests := []struct {
id string
want bool
}{
{"OBS1", true},
{"obs1", true},
{"OBS2", true},
{"obs3", false},
{"", false},
}
for _, tt := range tests {
got := cfg.IsObserverBlacklisted(tt.id)
if got != tt.want {
t.Errorf("IsObserverBlacklisted(%q) = %v, want %v", tt.id, got, tt.want)
}
}
}
func TestIngestorIsObserverBlacklistedEmpty(t *testing.T) {
cfg := &Config{}
if cfg.IsObserverBlacklisted("anything") {
t.Error("empty blacklist should not match")
}
}
func TestIngestorIsObserverBlacklistedNil(t *testing.T) {
var cfg *Config
if cfg.IsObserverBlacklisted("anything") {
t.Error("nil config should not match")
}
}
-96
View File
@@ -1,96 +0,0 @@
package main
import (
"encoding/json"
"testing"
)
// Regression test for #1044: observer metadata (model, firmware, battery_mv,
// noise_floor) is silently dropped when an MQTT status payload arrives, even
// though the same payload's `radio` and `client_version` fields ARE persisted.
//
// Real-world payload captured from the production MQTT bridge:
//
// {"status":"online","origin":"TestObserver","origin_id":"AABBCCDD",
// "radio":"910.5250244,62.5,7,5",
// "model":"Heltec V3",
// "firmware_version":"1.12.0-test",
// "client_version":"meshcoretomqtt/1.0.8.0",
// "stats":{"battery_mv":4209,"uptime_secs":75821,"noise_floor":-109,
// "tx_air_secs":80,"rx_air_secs":1903,"recv_errors":934}}
func TestStatusMessageMetadataPersisted_Issue1044(t *testing.T) {
const payload = `{"status":"online","origin":"TestObserver","origin_id":"AABBCCDD","radio":"910.5250244,62.5,7,5","model":"Heltec V3","firmware_version":"1.12.0-test","client_version":"meshcoretomqtt/1.0.8.0","stats":{"battery_mv":4209,"uptime_secs":75821,"noise_floor":-109,"tx_air_secs":80,"rx_air_secs":1903,"recv_errors":934}}`
var msg map[string]interface{}
if err := json.Unmarshal([]byte(payload), &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
meta := extractObserverMeta(msg)
if meta == nil {
t.Fatal("extractObserverMeta returned nil for a payload that contains model/firmware/battery_mv")
}
if meta.Model == nil || *meta.Model != "Heltec V3" {
t.Errorf("meta.Model = %v, want \"Heltec V3\"", meta.Model)
}
if meta.Firmware == nil || *meta.Firmware != "1.12.0-test" {
t.Errorf("meta.Firmware = %v, want \"1.12.0-test\"", meta.Firmware)
}
if meta.ClientVersion == nil || *meta.ClientVersion != "meshcoretomqtt/1.0.8.0" {
t.Errorf("meta.ClientVersion = %v, want \"meshcoretomqtt/1.0.8.0\"", meta.ClientVersion)
}
if meta.Radio == nil || *meta.Radio != "910.5250244,62.5,7,5" {
t.Errorf("meta.Radio = %v, want radio string", meta.Radio)
}
if meta.BatteryMv == nil || *meta.BatteryMv != 4209 {
t.Errorf("meta.BatteryMv = %v, want 4209", meta.BatteryMv)
}
if meta.NoiseFloor == nil || *meta.NoiseFloor != -109 {
t.Errorf("meta.NoiseFloor = %v, want -109", meta.NoiseFloor)
}
if meta.UptimeSecs == nil || *meta.UptimeSecs != 75821 {
t.Errorf("meta.UptimeSecs = %v, want 75821", meta.UptimeSecs)
}
// Now drive the meta through UpsertObserver and verify the row.
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.UpsertObserver("AABBCCDD", "TestObserver", "SJC", meta); err != nil {
t.Fatalf("UpsertObserver: %v", err)
}
var (
gotModel, gotFirmware, gotClientVersion, gotRadio string
gotBattery int
gotUptime int64
gotNoise float64
)
err = s.db.QueryRow(`SELECT model, firmware, client_version, radio,
battery_mv, uptime_secs, noise_floor
FROM observers WHERE id = 'AABBCCDD'`).Scan(
&gotModel, &gotFirmware, &gotClientVersion, &gotRadio,
&gotBattery, &gotUptime, &gotNoise,
)
if err != nil {
t.Fatalf("scan observer row: %v", err)
}
if gotModel != "Heltec V3" {
t.Errorf("DB model = %q, want \"Heltec V3\"", gotModel)
}
if gotFirmware != "1.12.0-test" {
t.Errorf("DB firmware = %q, want \"1.12.0-test\"", gotFirmware)
}
if gotBattery != 4209 {
t.Errorf("DB battery_mv = %d, want 4209", gotBattery)
}
if gotUptime != 75821 {
t.Errorf("DB uptime_secs = %d, want 75821", gotUptime)
}
if gotNoise != -109 {
t.Errorf("DB noise_floor = %f, want -109", gotNoise)
}
}
-106
View File
@@ -1,106 +0,0 @@
// Package main: ingestor-side processor for prune-request marker files
// written by the read-only server (see internal/prunequeue).
//
// The server cannot DELETE because it opens SQLite mode=ro (#1283/#1289).
// Instead, the server writes request-<id>.json under <dataDir>/prune-requests/
// and the ingestor consumes it here.
package main
import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/meshcore-analyzer/prunequeue"
)
// DeleteNodesByPubkeys deletes nodes by public key. Returns the count deleted.
// Only the ingestor calls this (server has no write handle).
func (s *Store) DeleteNodesByPubkeys(pubkeys []string) (int64, error) {
if len(pubkeys) == 0 {
return 0, nil
}
// Chunk to keep statements under SQLite's variable limit (default 999).
const chunk = 500
var total int64
for start := 0; start < len(pubkeys); start += chunk {
end := start + chunk
if end > len(pubkeys) {
end = len(pubkeys)
}
batch := pubkeys[start:end]
placeholders := strings.Repeat("?,", len(batch))
placeholders = placeholders[:len(placeholders)-1]
args := make([]interface{}, len(batch))
for i, pk := range batch {
args[i] = pk
}
// Cascade cleanup: a node row carries the canonical identity, but
// observations/transmissions reference the pubkey too via observer
// metadata and originator fields. There are no FK constraints in
// the current schema (#669 review note), so we explicitly clear
// the most obvious follow-on rows that would otherwise become
// orphans visible to operators.
//
// Conservative scope: only the `nodes` row is removed here. The
// referenced observation/transmission history is retained for
// audit; operators can run the regular packet-retention prune to
// age it out. If a future schema introduces FKs, revisit.
res, err := s.db.Exec("DELETE FROM nodes WHERE public_key IN ("+placeholders+")", args...)
if err != nil {
return total, fmt.Errorf("delete batch [%d:%d]: %w", start, end, err)
}
n, _ := res.RowsAffected()
total += n
}
return total, nil
}
// RunPendingPruneRequests scans the prune-requests/ directory next to the
// SQLite database and processes any request-<id>.json markers written by
// the server. Each request is honored verbatim — the server is responsible
// for the TOCTOU snapshot (only pubkeys that were still outside the
// geofilter at confirm time). After running DELETE, the ingestor writes
// result-<id>.json and removes the request file (atomic, via os.Rename in
// prunequeue.WriteResult).
//
// Safe to call from a ticker — no-op when the queue is empty.
func (s *Store) RunPendingPruneRequests() {
paths, err := prunequeue.ListPending(s.path)
if err != nil {
log.Printf("[prune-queue] list pending failed: %v", err)
return
}
if len(paths) == 0 {
return
}
for _, p := range paths {
req, err := prunequeue.ReadRequest(p)
if err != nil {
log.Printf("[prune-queue] read %s failed: %v — removing", p, err)
_ = os.Remove(p)
continue
}
log.Printf("[prune-queue] processing request %s: %d pubkey(s) (%s)",
req.ID, len(req.Pubkeys), req.Reason)
start := time.Now()
deleted, derr := s.DeleteNodesByPubkeys(req.Pubkeys)
res := prunequeue.Result{
ID: req.ID,
RequestedAt: req.RequestedAt,
CompletedAt: time.Now().UTC(),
Deleted: deleted,
}
if derr != nil {
res.Error = derr.Error()
log.Printf("[prune-queue] request %s FAILED after %s: %v", req.ID, time.Since(start), derr)
} else {
log.Printf("[prune-queue] request %s deleted %d node(s) in %s", req.ID, deleted, time.Since(start))
}
if werr := prunequeue.WriteResult(s.path, res); werr != nil {
log.Printf("[prune-queue] write result for %s failed: %v", req.ID, werr)
}
}
}
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"path/filepath"
"testing"
"time"
"github.com/meshcore-analyzer/prunequeue"
)
func TestRunPendingPruneRequests(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Seed two nodes; one will be pruned, one will be kept.
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
VALUES ('aaaa', 'gone', 'companion', 1.0, 1.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
('bbbb', 'kept', 'companion', 2.0, 2.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`); err != nil {
t.Fatalf("seed: %v", err)
}
id := prunequeue.NewID()
if err := prunequeue.WriteRequest(dbPath, prunequeue.Request{
ID: id,
RequestedAt: time.Now().UTC(),
Reason: "geo-prune-test",
Pubkeys: []string{"aaaa"},
}); err != nil {
t.Fatalf("WriteRequest: %v", err)
}
store.RunPendingPruneRequests()
// Request file gone, result file present.
if exists, _ := prunequeue.RequestExists(dbPath, id); exists {
t.Error("request file should have been consumed")
}
res, err := prunequeue.ReadResult(dbPath, id)
if err != nil || res == nil {
t.Fatalf("ReadResult: res=%v err=%v", res, err)
}
if res.Deleted != 1 {
t.Errorf("expected Deleted=1, got %d", res.Deleted)
}
if res.Error != "" {
t.Errorf("unexpected error: %s", res.Error)
}
// Verify DB state: aaaa gone, bbbb kept.
var n int
store.db.QueryRow("SELECT COUNT(*) FROM nodes WHERE public_key='aaaa'").Scan(&n)
if n != 0 {
t.Errorf("expected 'aaaa' deleted, got count=%d", n)
}
store.db.QueryRow("SELECT COUNT(*) FROM nodes WHERE public_key='bbbb'").Scan(&n)
if n != 1 {
t.Errorf("expected 'bbbb' kept, got count=%d", n)
}
}
func TestRunPendingPruneRequests_EmptyQueueIsNoop(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Must not panic / error on empty queue.
store.RunPendingPruneRequests()
}
-80
View File
@@ -1,80 +0,0 @@
package main
import (
"testing"
"time"
)
func TestParseEnvelopeTime(t *testing.T) {
cases := []struct {
name string
in string
ok bool
}{
{"rfc3339 utc", "2026-05-16T10:00:00Z", true},
{"rfc3339 offset", "2026-05-16T12:00:00+02:00", true},
{"naive iso", "2026-05-16T10:00:00", true},
{"naive iso micros", "2026-05-16T10:00:00.123456", true},
{"garbage", "not-a-time", false},
{"empty", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := parseEnvelopeTime(c.in)
if (err == nil) != c.ok {
t.Fatalf("parseEnvelopeTime(%q): want ok=%v, got err=%v", c.in, c.ok, err)
}
})
}
}
func TestResolveRxTime(t *testing.T) {
now := time.Now().UTC()
mustParse := func(s string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("result %q is not RFC3339: %v", s, err)
}
return parsed
}
nearNow := func(s string) bool {
d := mustParse(s).Sub(now)
if d < 0 {
d = -d
}
return d <= time.Minute
}
rx := now.Add(-5 * time.Hour).Format(time.RFC3339)
if got := resolveRxTime(map[string]interface{}{"timestamp": rx}, "test"); got != rx {
t.Errorf("plausible past timestamp: got %q want %q", got, rx)
}
if got := resolveRxTime(map[string]interface{}{}, "test"); !nearNow(got) {
t.Errorf("missing timestamp: got %q, expected ~now", got)
}
if got := resolveRxTime(map[string]interface{}{"timestamp": "garbage"}, "test"); !nearNow(got) {
t.Errorf("garbage timestamp: got %q, expected ~now", got)
}
future := now.Add(48 * time.Hour).Format(time.RFC3339)
if got := resolveRxTime(map[string]interface{}{"timestamp": future}, "test"); !nearNow(got) {
t.Errorf("future timestamp: got %q, expected ~now (rejected)", got)
}
// RTC-reset node reporting a factory date — must not drag first_seen back.
factory := "2020-01-01T00:00:00Z"
if got := resolveRxTime(map[string]interface{}{"timestamp": factory}, "test"); !nearNow(got) {
t.Errorf("stale factory timestamp: got %q, expected ~now (rejected)", got)
}
// Just past the 30-day floor → rejected.
stale := now.Add(-31 * 24 * time.Hour).Format(time.RFC3339)
if got := resolveRxTime(map[string]interface{}{"timestamp": stale}, "test"); !nearNow(got) {
t.Errorf("stale timestamp >30d: got %q, expected ~now (rejected)", got)
}
// Just inside the 30-day floor → used verbatim.
recent := now.Add(-29 * 24 * time.Hour).Format(time.RFC3339)
if got := resolveRxTime(map[string]interface{}{"timestamp": recent}, "test"); got != recent {
t.Errorf("recent timestamp <30d: got %q want %q", got, recent)
}
}
-339
View File
@@ -1,339 +0,0 @@
package main
import (
"crypto/ed25519"
"encoding/binary"
"encoding/hex"
"strings"
"testing"
)
// buildAdvertHex constructs a full ADVERT packet hex string.
// header(1) + pathByte(1) + pubkey(32) + timestamp(4) + signature(64) + appdata
func buildAdvertHex(pubKey ed25519.PublicKey, privKey ed25519.PrivateKey, timestamp uint32, appdata []byte) string {
// Build signed message: pubkey(32) + timestamp(4 LE) + appdata
msg := make([]byte, 32+4+len(appdata))
copy(msg[0:32], pubKey)
binary.LittleEndian.PutUint32(msg[32:36], timestamp)
copy(msg[36:], appdata)
sig := ed25519.Sign(privKey, msg)
// Payload: pubkey(32) + timestamp(4) + signature(64) + appdata
payload := make([]byte, 0, 100+len(appdata))
payload = append(payload, pubKey...)
ts := make([]byte, 4)
binary.LittleEndian.PutUint32(ts, timestamp)
payload = append(payload, ts...)
payload = append(payload, sig...)
payload = append(payload, appdata...)
// Header: ADVERT (0x04 << 2) | FLOOD (1) = 0x11, pathByte=0 (no hops)
header := byte(0x11)
pathByte := byte(0x00)
pkt := append([]byte{header, pathByte}, payload...)
return hex.EncodeToString(pkt)
}
// makeAppdata builds minimal appdata: flags(1) + name
func makeAppdata(name string) []byte {
flags := byte(0x81) // hasName=true, type=companion(1)
data := []byte{flags}
data = append(data, []byte(name)...)
data = append(data, 0x00) // null terminator
return data
}
func TestSigValidation_ValidAdvertStored(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
pub, priv, _ := ed25519.GenerateKey(nil)
appdata := makeAppdata("TestNode")
rawHex := buildAdvertHex(pub, priv, 1700000000, appdata)
source := MQTTSource{Name: "test"}
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+rawHex+`","origin":"TestObs"}`)
cfg := &Config{}
handleMessage(store, "test", source, msg, nil, nil, cfg)
// Verify packet was stored
var count int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
if count == 0 {
t.Fatal("valid advert should be stored, got 0 transmissions")
}
}
func TestSigValidation_TamperedSignatureDropped(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
pub, priv, _ := ed25519.GenerateKey(nil)
appdata := makeAppdata("BadNode")
rawHex := buildAdvertHex(pub, priv, 1700000000, appdata)
// Tamper with signature (flip a byte in the signature area)
// Signature starts at offset 2 (header+path) + 32 (pubkey) + 4 (timestamp) = 38
// That's byte 38 in the packet, hex chars 76-77
rawBytes := []byte(rawHex)
if rawBytes[76] == '0' {
rawBytes[76] = 'f'
} else {
rawBytes[76] = '0'
}
tamperedHex := string(rawBytes)
source := MQTTSource{Name: "test"}
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+tamperedHex+`","origin":"TestObs"}`)
cfg := &Config{}
handleMessage(store, "test", source, msg, nil, nil, cfg)
// Verify packet was NOT stored in transmissions
var txCount int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount)
if txCount != 0 {
t.Fatalf("tampered advert should be dropped, got %d transmissions", txCount)
}
// Verify it was recorded in dropped_packets
var dropCount int
store.db.QueryRow("SELECT COUNT(*) FROM dropped_packets").Scan(&dropCount)
if dropCount == 0 {
t.Fatal("tampered advert should be recorded in dropped_packets")
}
// Verify drop counter incremented
if store.Stats.SignatureDrops.Load() != 1 {
t.Fatalf("expected 1 signature drop, got %d", store.Stats.SignatureDrops.Load())
}
// Verify dropped_packets has correct fields
var reason, nodeKey, nodeName, obsID string
store.db.QueryRow("SELECT reason, node_pubkey, node_name, observer_id FROM dropped_packets LIMIT 1").Scan(&reason, &nodeKey, &nodeName, &obsID)
if reason != "invalid signature" {
t.Fatalf("expected reason 'invalid signature', got %q", reason)
}
if nodeKey == "" {
t.Fatal("dropped packet should have node_pubkey")
}
if !strings.Contains(nodeName, "BadNode") {
t.Fatalf("expected node_name to contain 'BadNode', got %q", nodeName)
}
if obsID != "obs1" {
t.Fatalf("expected observer_id 'obs1', got %q", obsID)
}
}
func TestSigValidation_TruncatedAppdataDropped(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
pub, priv, _ := ed25519.GenerateKey(nil)
appdata := makeAppdata("TruncNode")
rawHex := buildAdvertHex(pub, priv, 1700000000, appdata)
// Sign was computed with full appdata. Now truncate the raw hex to remove
// some appdata bytes, making the signature invalid.
// Truncate last 4 hex chars (2 bytes of appdata)
truncatedHex := rawHex[:len(rawHex)-4]
source := MQTTSource{Name: "test"}
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+truncatedHex+`","origin":"TestObs"}`)
cfg := &Config{}
handleMessage(store, "test", source, msg, nil, nil, cfg)
var txCount int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount)
if txCount != 0 {
t.Fatalf("truncated advert should be dropped, got %d transmissions", txCount)
}
}
func TestSigValidation_DisabledByConfig(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
pub, priv, _ := ed25519.GenerateKey(nil)
appdata := makeAppdata("NoValNode")
rawHex := buildAdvertHex(pub, priv, 1700000000, appdata)
// Tamper with signature
rawBytes := []byte(rawHex)
if rawBytes[76] == '0' {
rawBytes[76] = 'f'
} else {
rawBytes[76] = '0'
}
tamperedHex := string(rawBytes)
source := MQTTSource{Name: "test"}
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+tamperedHex+`","origin":"TestObs"}`)
falseVal := false
cfg := &Config{ValidateSignatures: &falseVal}
handleMessage(store, "test", source, msg, nil, nil, cfg)
// With validation disabled, tampered packet should be stored
var txCount int
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount)
if txCount == 0 {
t.Fatal("with validateSignatures=false, tampered advert should be stored")
}
}
func TestSigValidation_DropCounterIncrements(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
pub, priv, _ := ed25519.GenerateKey(nil)
source := MQTTSource{Name: "test"}
cfg := &Config{}
for i := 0; i < 3; i++ {
appdata := makeAppdata("Node")
rawHex := buildAdvertHex(pub, priv, uint32(1700000000+i), appdata)
// Tamper
rawBytes := []byte(rawHex)
if rawBytes[76] == '0' {
rawBytes[76] = 'f'
} else {
rawBytes[76] = '0'
}
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+string(rawBytes)+`","origin":"Obs"}`)
handleMessage(store, "test", source, msg, nil, nil, cfg)
}
if store.Stats.SignatureDrops.Load() != 3 {
t.Fatalf("expected 3 signature drops, got %d", store.Stats.SignatureDrops.Load())
}
}
func TestSigValidation_LogContainsFields(t *testing.T) {
// This test verifies the dropped_packets row has all required fields
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
pub, priv, _ := ed25519.GenerateKey(nil)
appdata := makeAppdata("LogTestNode")
rawHex := buildAdvertHex(pub, priv, 1700000000, appdata)
// Tamper
rawBytes := []byte(rawHex)
if rawBytes[76] == '0' {
rawBytes[76] = 'f'
} else {
rawBytes[76] = '0'
}
source := MQTTSource{Name: "test"}
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+string(rawBytes)+`","origin":"MyObserver"}`)
cfg := &Config{}
handleMessage(store, "test", source, msg, nil, nil, cfg)
var hash, reason, obsID, obsName, pubkey, nodeName string
err = store.db.QueryRow("SELECT hash, reason, observer_id, observer_name, node_pubkey, node_name FROM dropped_packets LIMIT 1").
Scan(&hash, &reason, &obsID, &obsName, &pubkey, &nodeName)
if err != nil {
t.Fatal(err)
}
if hash == "" {
t.Error("dropped packet should have hash")
}
if reason != "invalid signature" {
t.Errorf("expected reason 'invalid signature', got %q", reason)
}
if obsID != "obs1" {
t.Errorf("expected observer_id 'obs1', got %q", obsID)
}
if obsName != "MyObserver" {
t.Errorf("expected observer_name 'MyObserver', got %q", obsName)
}
if pubkey == "" {
t.Error("dropped packet should have node_pubkey")
}
if !strings.Contains(nodeName, "LogTestNode") {
t.Errorf("expected node_name containing 'LogTestNode', got %q", nodeName)
}
}
func TestPruneDroppedPackets(t *testing.T) {
dbPath := t.TempDir() + "/test.db"
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal(err)
}
defer store.Close()
// Insert an old dropped packet
store.db.Exec(`INSERT INTO dropped_packets (hash, reason, dropped_at) VALUES ('old', 'test', datetime('now', '-60 days'))`)
store.db.Exec(`INSERT INTO dropped_packets (hash, reason, dropped_at) VALUES ('new', 'test', datetime('now'))`)
n, err := store.PruneDroppedPackets(30)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("expected 1 pruned, got %d", n)
}
var count int
store.db.QueryRow("SELECT COUNT(*) FROM dropped_packets").Scan(&count)
if count != 1 {
t.Fatalf("expected 1 remaining, got %d", count)
}
}
func TestShouldValidateSignatures_Default(t *testing.T) {
cfg := &Config{}
if !cfg.ShouldValidateSignatures() {
t.Fatal("default should be true")
}
falseVal := false
cfg2 := &Config{ValidateSignatures: &falseVal}
if cfg2.ShouldValidateSignatures() {
t.Fatal("explicit false should be false")
}
trueVal := true
cfg3 := &Config{ValidateSignatures: &trueVal}
if !cfg3.ShouldValidateSignatures() {
t.Fatal("explicit true should be true")
}
}
// newMockMsg creates a minimal mqtt.Message for testing.
func newMockMsg(topic, payload string) *mockMessage {
return &mockMessage{topic: topic, payload: []byte(payload)}
}
-226
View File
@@ -1,226 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"log"
"os"
"time"
"github.com/meshcore-analyzer/perfio"
)
// PerfIOSample is the canonical per-process I/O rate sample, sourced from the
// shared internal/perfio package. The server consumes the same type when it
// reads this binary's stats file — sharing the type prevents silent JSON
// contract drift (#1167 follow-up).
type PerfIOSample = perfio.Sample
// IngestorStatsSnapshot mirrors the JSON shape consumed by the server's
// /api/perf/write-sources endpoint (see cmd/server/perf_io.go IngestorStats).
//
// NOTE: each field below is sampled with an independent atomic.Load(), so the
// snapshot is EVENTUALLY-CONSISTENT — invariants like
// `walCommits >= tx_inserted` may be momentarily violated
// in a single sample. Consumers MUST NOT derive ratios on the assumption these
// counters were captured at the same instant; treat each field as an
// independent monotonically-increasing counter and look at deltas across
// multiple samples instead.
type IngestorStatsSnapshot struct {
SampledAt string `json:"sampledAt"`
TxInserted int64 `json:"tx_inserted"`
ObsInserted int64 `json:"obs_inserted"`
DuplicateTx int64 `json:"tx_dupes"`
NodeUpserts int64 `json:"node_upserts"`
ObserverUpserts int64 `json:"observer_upserts"`
WriteErrors int64 `json:"write_errors"`
SignatureDrops int64 `json:"sig_drops"`
WALCommits int64 `json:"walCommits"`
GroupCommitFlushes int64 `json:"groupCommitFlushes"` // always 0 — group commit reverted (refs #1129)
BackfillUpdates map[string]int64 `json:"backfillUpdates"`
// ProcIO is the ingestor's own /proc/self/io rate snapshot. Surfaced via
// the server's /api/perf/io endpoint under .ingestor (#1120 — "Both
// ingestor and server"). Optional; absent on non-Linux hosts.
ProcIO *PerfIOSample `json:"procIO,omitempty"`
}
// statsFilePath returns the writable path the ingestor will publish stats to.
// Override via env CORESCOPE_INGESTOR_STATS for tests / non-default deploys.
//
// SECURITY: the default lives in /tmp which is world-writable. The writer uses
// O_NOFOLLOW + 0o600 so a pre-planted symlink cannot be used to clobber an
// arbitrary file via this path. Operators who want stronger guarantees should
// point CORESCOPE_INGESTOR_STATS at a private directory (e.g. /var/lib/corescope/).
func statsFilePath() string {
if p := os.Getenv("CORESCOPE_INGESTOR_STATS"); p != "" {
return p
}
return "/tmp/corescope-ingestor-stats.json"
}
// writeStatsAtomic writes b to path via a tmp-then-rename, refusing to follow
// symlinks on the tmp file. Returns nil on success, an error otherwise.
func writeStatsAtomic(path string, b []byte) error {
tmp := path + ".tmp"
// O_NOFOLLOW: if tmp is a pre-existing symlink, openat fails with ELOOP
// instead of clobbering the symlink target. O_TRUNC zeroes existing
// regular-file content. 0o600 — no need for world-readable.
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|oNoFollow, 0o600)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// procIOSnapshot is the raw counter snapshot used to compute per-second rates
// across two consecutive ticks of the stats-file writer.
type procIOSnapshot struct {
at time.Time
readBytes int64
writeBytes int64
cancelledWrite int64
syscR int64
syscW int64
ok bool
}
// readProcSelfIOFn is the package-level hook the writer loop uses to read
// /proc/self/io. Defaults to readProcSelfIO; tests override it to inject
// deterministic counter snapshots without depending on a Linux kernel
// that exposes /proc/self/io (CONFIG_TASK_IO_ACCOUNTING).
var readProcSelfIOFn = readProcSelfIO
// readProcSelfIO parses /proc/self/io. Returns ok=false on non-Linux hosts or
// any read/parse failure (caller skips the procIO block in that case).
func readProcSelfIO() procIOSnapshot {
out := procIOSnapshot{at: time.Now()}
f, err := os.Open("/proc/self/io")
if err != nil {
return out
}
defer f.Close()
parseProcSelfIOInto(bufio.NewScanner(f), &out)
return out
}
// parseProcSelfIOInto reads /proc/self/io-shaped key:value lines from sc and
// populates the byte/syscall fields on out. Sets out.ok=true only if at
// least one expected key was successfully parsed (#1167 must-fix #3).
//
// Implementation delegates to perfio.ParseProcIO so the ingestor and the
// server share exactly one parser (Carmack must-fix #7).
func parseProcSelfIOInto(sc *bufio.Scanner, out *procIOSnapshot) {
var c perfio.Counters
out.ok = perfio.ParseProcIO(sc, &c)
out.readBytes = c.ReadBytes
out.writeBytes = c.WriteBytes
out.cancelledWrite = c.CancelledWriteBytes
out.syscR = c.SyscR
out.syscW = c.SyscW
}
// procIORate computes a per-second rate sample between two procIOSnapshots
// using the supplied stamp string for the resulting Sample.SampledAt
// (Carmack must-fix #5 — the writer captures time.Now() once per tick and
// passes the same RFC3339 string down so the snapshot top-level SampledAt
// and the inner procIO SampledAt cannot drift).
// Returns nil if either snapshot is invalid or the interval is zero.
func procIORate(prev, cur procIOSnapshot, stamp string) *PerfIOSample {
if !prev.ok || !cur.ok {
return nil
}
dt := cur.at.Sub(prev.at).Seconds()
if dt < 0.001 {
return nil
}
return &PerfIOSample{
ReadBytesPerSec: float64(cur.readBytes-prev.readBytes) / dt,
WriteBytesPerSec: float64(cur.writeBytes-prev.writeBytes) / dt,
CancelledWriteBytesPerSec: float64(cur.cancelledWrite-prev.cancelledWrite) / dt,
SyscallsRead: float64(cur.syscR-prev.syscR) / dt,
SyscallsWrite: float64(cur.syscW-prev.syscW) / dt,
SampledAt: stamp,
}
}
// StartStatsFileWriter writes the current stats snapshot to disk every
// `interval` so the server can serve them at /api/perf/write-sources.
// Failures are logged once-per-interval and never fatal.
//
// The stats file path is resolved via statsFilePath() once at writer-loop
// start; the env var (CORESCOPE_INGESTOR_STATS) is only re-read on process
// restart, not per tick.
func StartStatsFileWriter(s *Store, interval time.Duration) {
if interval <= 0 {
interval = time.Second
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
path := statsFilePath()
// Track previous procIO sample so we can compute per-second deltas
// across ticks (#1120 follow-up: ingestor /proc/self/io exposure).
prevIO := readProcSelfIOFn()
// Reuse a single bytes.Buffer + json.Encoder across ticks
// (Carmack must-fix #4) — the snapshot shape is stable; a fresh
// json.Marshal allocation per second × forever is pure GC waste.
// The buffer grows once and stays.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for range t.C {
// Capture time.Now() ONCE per tick (Carmack must-fix #5).
// Both snapshot.SampledAt and procIO.SampledAt MUST share the
// same string so the freshness guard isn't validating one
// timestamp while the consumer renders another.
tickAt := time.Now().UTC()
stamp := tickAt.Format(time.RFC3339)
curIO := readProcSelfIOFn()
ioRate := procIORate(prevIO, curIO, stamp)
prevIO = curIO
snap := IngestorStatsSnapshot{
SampledAt: stamp,
TxInserted: s.Stats.TransmissionsInserted.Load(),
ObsInserted: s.Stats.ObservationsInserted.Load(),
DuplicateTx: s.Stats.DuplicateTransmissions.Load(),
NodeUpserts: s.Stats.NodeUpserts.Load(),
ObserverUpserts: s.Stats.ObserverUpserts.Load(),
WriteErrors: s.Stats.WriteErrors.Load(),
SignatureDrops: s.Stats.SignatureDrops.Load(),
WALCommits: s.Stats.WALCommits.Load(),
GroupCommitFlushes: 0, // group commit reverted (refs #1129)
BackfillUpdates: s.Stats.SnapshotBackfills(),
ProcIO: ioRate,
}
buf.Reset()
if err := enc.Encode(&snap); err != nil {
log.Printf("[stats-file] encode: %v", err)
continue
}
// json.Encoder.Encode appends a trailing newline; strip it
// so the on-disk byte content stays identical to what
// json.Marshal produced previously (operators / tests may
// have hashed prior output).
b := buf.Bytes()
if n := len(b); n > 0 && b[n-1] == '\n' {
b = b[:n-1]
}
if err := writeStatsAtomic(path, b); err != nil {
log.Printf("[stats-file] write %s: %v", path, err)
}
}
}()
}
-98
View File
@@ -1,98 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
)
const benchProcSelfIOSample = `rchar: 12345678
wchar: 87654321
syscr: 12345
syscw: 67890
read_bytes: 4096000
write_bytes: 8192000
cancelled_write_bytes: 12345
`
// TestStatsFileWriterBench_Sanity is a tiny non-bench test added solely to
// exercise the bench helpers' assertion path so the preflight scanner sees
// at least one t.Error*/t.Fatal* in this file (the benchmarks themselves
// use b.Fatal, which the scanner doesn't recognise as an assertion).
func TestStatsFileWriterBench_Sanity(t *testing.T) {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(benchProcSelfIOSample)), &s)
if !s.ok {
t.Fatalf("expected bench sample to parse ok=true")
}
if s.readBytes != 4096000 {
t.Errorf("readBytes = %d, want 4096000", s.readBytes)
}
}
// BenchmarkParseProcSelfIOInto measures the ingestor-side /proc/self/io
// parser on a representative payload (Carmack must-fix #3). Tracks
// allocations to verify the shared perfio.ParseProcIO path doesn't
// regress vs. the previous in-package implementation.
func BenchmarkParseProcSelfIOInto(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(benchProcSelfIOSample)), &s)
}
}
// BenchmarkStatsFileWriter_Tick simulates the body of one writer tick
// (snap construction + JSON encode via the reused buffer) WITHOUT the
// disk write. Carmack must-fix #3 + #4 — the per-tick allocation budget
// for the marshaling step on a 1Hz ticker that runs forever.
func BenchmarkStatsFileWriter_Tick(b *testing.B) {
// Mirror the writer-loop's reused encoder.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
// A representative non-empty BackfillUpdates map; the writer reuses
// the *map*'s entries across ticks (SnapshotBackfills returns a
// fresh map each call in production; we use a stable one here so
// the bench measures the encode path, not map allocation).
backfills := map[string]int64{"path_a": 100, "path_b": 200}
stamp := time.Now().UTC().Format(time.RFC3339)
io := &PerfIOSample{
ReadBytesPerSec: 100,
WriteBytesPerSec: 200,
CancelledWriteBytesPerSec: 0,
SyscallsRead: 5,
SyscallsWrite: 6,
SampledAt: stamp,
}
// Stand-in atomic counters (StartStatsFileWriter loads from a real
// Store; for the bench we just pass concrete values).
var n atomic.Int64
n.Store(123456)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
snap := IngestorStatsSnapshot{
SampledAt: stamp,
TxInserted: n.Load(),
ObsInserted: n.Load(),
DuplicateTx: n.Load(),
NodeUpserts: n.Load(),
ObserverUpserts: n.Load(),
WriteErrors: n.Load(),
SignatureDrops: n.Load(),
WALCommits: n.Load(),
GroupCommitFlushes: 0,
BackfillUpdates: backfills,
ProcIO: io,
}
buf.Reset()
_ = enc.Encode(&snap)
}
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !windows
package main
import "syscall"
// oNoFollow is syscall.O_NOFOLLOW on platforms that define it (all non-Windows targets).
// On Windows this constant does not exist; see stats_file_nofollow_windows.go.
const oNoFollow = syscall.O_NOFOLLOW
@@ -1,8 +0,0 @@
//go:build windows
package main
// oNoFollow is 0 on Windows: O_NOFOLLOW is not defined in the Windows syscall
// package. The ingestor is only deployed on Linux where the flag is enforced;
// on Windows the flag is a no-op so the binary compiles and tests run.
const oNoFollow = 0
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"bufio"
"strings"
"testing"
)
// TestParseProcSelfIO_EmptyDoesNotMarkOK — #1167 must-fix #3: an empty file
// (or one with no recognised keys) MUST result in ok=false. Otherwise the
// next tick computes a huge positive delta against zero → phantom write
// spike on first published rate.
func TestParseProcSelfIO_EmptyDoesNotMarkOK(t *testing.T) {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader("")), &s)
if s.ok {
t.Errorf("empty input must produce ok=false, got ok=true (phantom-spike risk)")
}
}
// TestParseProcSelfIO_NoKnownKeysDoesNotMarkOK — same as above, but the file
// has lines with unrecognised keys (a future /proc schema change). MUST NOT
// be treated as a valid sample.
func TestParseProcSelfIO_NoKnownKeysDoesNotMarkOK(t *testing.T) {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader("garbage_key: 42\nother: 99\n")), &s)
if s.ok {
t.Errorf("input without recognised keys must produce ok=false, got ok=true")
}
}
// TestParseProcSelfIO_ValidSampleMarksOK — positive companion: a real
// /proc/self/io-shaped input MUST mark ok=true with the parsed counters.
func TestParseProcSelfIO_ValidSampleMarksOK(t *testing.T) {
const sample = `rchar: 1024
wchar: 2048
syscr: 10
syscw: 20
read_bytes: 4096
write_bytes: 8192
cancelled_write_bytes: 1234
`
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
if !s.ok {
t.Fatalf("valid sample must produce ok=true")
}
if s.readBytes != 4096 || s.writeBytes != 8192 || s.cancelledWrite != 1234 {
t.Errorf("unexpected parsed counters: %+v", s)
}
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// TestStatsFileWriter_PublishesProcIO asserts the ingestor's published
// stats snapshot includes a `procIO` block with the per-process I/O rate
// fields required by issue #1120 ("Both ingestor and server").
func TestStatsFileWriter_PublishesProcIO(t *testing.T) {
if _, err := os.Stat("/proc/self/io"); err != nil {
t.Skip("skip: /proc/self/io unavailable on this host")
}
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
store, err := OpenStore(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
StartStatsFileWriter(store, 50*time.Millisecond)
// Wait for at least 2 ticks so the writer has had a chance to populate
// procIO rates from a delta.
deadline := time.Now().Add(3 * time.Second)
var snap map[string]interface{}
for time.Now().Before(deadline) {
time.Sleep(75 * time.Millisecond)
b, err := os.ReadFile(statsPath)
if err != nil {
continue
}
if err := json.Unmarshal(b, &snap); err != nil {
continue
}
if _, ok := snap["procIO"]; ok {
break
}
}
pio, ok := snap["procIO"].(map[string]interface{})
if !ok {
t.Fatalf("expected procIO block in stats snapshot, got: %v", snap)
}
for _, field := range []string{"readBytesPerSec", "writeBytesPerSec", "cancelledWriteBytesPerSec", "syscallsRead", "syscallsWrite"} {
v, present := pio[field]
if !present {
t.Errorf("procIO missing field %q", field)
continue
}
// #1167 must-fix #5: assert the field actually decodes as a JSON
// number, not just that the key exists. An empty PerfIOSample{}
// substruct would still serialise the keys since the inner numeric
// fields lack omitempty — without this Kind check the test would
// silently pass on an empty struct regression.
if _, isFloat := v.(float64); !isFloat {
t.Errorf("procIO[%q] expected JSON number (float64), got %T (%v)", field, v, v)
}
}
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// TestStatsFileWriter_SampledAtMatchesProcIOSampledAt drives the real
// StartStatsFileWriter and asserts the byte-equal invariant established
// by #1167 Carmack must-fix #5: the writer captures time.Now() once per
// tick and reuses that single RFC3339 string for both the snapshot
// top-level SampledAt and the inner procIO.SampledAt. If a future change
// reintroduces two independent time.Now() calls — or, equivalently,
// reverts procIORate to format procIO.SampledAt from its own
// (independently-sampled) `cur.at` instead of the passed `stamp` — the
// two strings will diverge and this test fails on the byte-equal
// assertion.
//
// This replaces the earlier `TestPerfIOEndpoint_IngestorTimestampMatchesSnapshot`
// in cmd/server, which asserted a hand-flipped `ingestorTickCapturesTimeOnce = true`
// flag and therefore did NOT gate the production behaviour (Kent Beck
// Gate review pullrequestreview-4254521304).
//
// Implementation note: the test injects a deterministic procIO reader
// via the readProcSelfIOFn hook, returning a snapshot whose `at`
// timestamp is pinned to 2020-01-01. In the FIXED writer, procIORate
// uses the writer-tick stamp string (today's date), so the published
// procIO.SampledAt equals snap.SampledAt byte-for-byte. In a regressed
// writer that uses the procIO snapshot's own `at` for the inner
// SampledAt, the inner string would render as 2020-01-01 while the
// snapshot's stays today — the byte-equal assertion fails immediately
// and unambiguously, regardless of how slow the host is.
func TestStatsFileWriter_SampledAtMatchesProcIOSampledAt(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
store, err := OpenStore(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Inject a deterministic procIO reader. `at` is pinned far in the
// past so any code path that formats the inner SampledAt from
// `cur.at` (the regressed shape) produces a string that cannot
// possibly match the writer's tick stamp.
origFn := readProcSelfIOFn
t.Cleanup(func() { readProcSelfIOFn = origFn })
pinnedAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
var calls int64
readProcSelfIOFn = func() procIOSnapshot {
calls++
// Advance counters across calls so procIORate's dt > 0.001
// gate passes and a non-nil PerfIOSample is published. The
// first call backdates `at` by 1s vs the second so the
// computed dt is positive and stable.
return procIOSnapshot{
at: pinnedAt.Add(time.Duration(calls) * time.Second),
readBytes: 1000 * calls,
writeBytes: 2000 * calls,
cancelledWrite: 0,
syscR: 10 * calls,
syscW: 20 * calls,
ok: true,
}
}
StartStatsFileWriter(store, 50*time.Millisecond)
// Wait for the file to land with a populated procIO block.
deadline := time.Now().Add(3 * time.Second)
var snap map[string]interface{}
for time.Now().Before(deadline) {
time.Sleep(75 * time.Millisecond)
b, err := os.ReadFile(statsPath)
if err != nil {
continue
}
if err := json.Unmarshal(b, &snap); err != nil {
continue
}
if _, ok := snap["procIO"].(map[string]interface{}); ok {
break
}
}
topSampledAt, ok := snap["sampledAt"].(string)
if !ok || topSampledAt == "" {
t.Fatalf("expected snapshot.sampledAt non-empty string, got: %v (snap=%v)", snap["sampledAt"], snap)
}
pio, ok := snap["procIO"].(map[string]interface{})
if !ok {
t.Fatalf("expected procIO block, snap=%v", snap)
}
innerSampledAt, ok := pio["sampledAt"].(string)
if !ok || innerSampledAt == "" {
t.Fatalf("expected procIO.sampledAt non-empty string, got: %v", pio["sampledAt"])
}
if topSampledAt != innerSampledAt {
t.Errorf("snapshot.sampledAt != procIO.sampledAt (writer reverted to two independent timestamps?)\n top: %q\n inner: %q", topSampledAt, innerSampledAt)
}
}
-22
View File
@@ -1,22 +0,0 @@
module github.com/corescope/migrate
go 1.22
require (
github.com/meshcore-analyzer/dbschema v0.0.0
modernc.org/sqlite v1.34.5
)
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
-43
View File
@@ -1,43 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-55
View File
@@ -1,55 +0,0 @@
// Command migrate runs all dbschema migrations against a SQLite
// CoreScope database and exits. Used by CI / one-shot tooling to bring
// an unmigrated fixture (or a fresh DB) up to the schema shape the
// read-only server (cmd/server) requires via dbschema.AssertReady.
//
// In production the ingestor (cmd/ingestor) runs dbschema.Apply at
// startup before subscribing to MQTT — this binary exists so CI's E2E
// job can migrate the e2e-fixture.db without booting the full ingestor
// (which needs MQTT brokers).
//
// Usage:
//
// migrate -db path/to/file.db
package main
import (
"database/sql"
"flag"
"log"
"github.com/meshcore-analyzer/dbschema"
_ "modernc.org/sqlite"
)
func main() {
dbPath := flag.String("db", "", "path to SQLite database to migrate (required)")
flag.Parse()
if *dbPath == "" {
log.Fatalf("[migrate] -db is required")
}
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[migrate] ")
db, err := sql.Open("sqlite", *dbPath)
if err != nil {
log.Fatalf("open %s: %v", *dbPath, err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("ping %s: %v", *dbPath, err)
}
if err := dbschema.Apply(db, log.Printf); err != nil {
log.Fatalf("dbschema.Apply: %v", err)
}
if err := dbschema.AssertReady(db); err != nil {
log.Fatalf("dbschema.AssertReady after Apply: %v (this is a bug — Apply did not produce a ready schema)", err)
}
log.Printf("OK: %s is migrated and ready", *dbPath)
}
-84
View File
@@ -1,84 +0,0 @@
// Test that the migrate binary brings the e2e fixture DB up to the
// shape required by cmd/server's dbschema.AssertReady. Regression test
// for PR #1289 / fix for the CI "Server failed to start within 30s"
// failure: AssertReady fired against the unmigrated fixture and the
// server fatal-logged before opening its HTTP listener.
package main
import (
"database/sql"
"io"
"os"
"path/filepath"
"testing"
"github.com/meshcore-analyzer/dbschema"
_ "modernc.org/sqlite"
)
// fixtureCandidates lists possible locations of the committed e2e
// fixture DB relative to this test's package directory. We resolve
// against runtime cwd which is cmd/migrate when `go test` runs.
var fixtureCandidates = []string{
"../../test-fixtures/e2e-fixture.db",
}
func locateFixture(t *testing.T) string {
t.Helper()
for _, p := range fixtureCandidates {
if _, err := os.Stat(p); err == nil {
abs, _ := filepath.Abs(p)
return abs
}
}
t.Skipf("e2e fixture not found (looked in: %v)", fixtureCandidates)
return ""
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatalf("open src: %v", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatalf("create dst: %v", err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
t.Fatalf("copy: %v", err)
}
}
// TestMigrateBringsFixtureToReady is the gate test for the CI bug.
// Before the fix landed, AssertReady against the committed fixture
// returned an error ("missing: inactive_nodes.foreign_advert" etc.).
// After Apply(), AssertReady must return nil.
func TestMigrateBringsFixtureToReady(t *testing.T) {
src := locateFixture(t)
dst := filepath.Join(t.TempDir(), "fixture-copy.db")
copyFile(t, src, dst)
db, err := sql.Open("sqlite", dst)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
// Sanity: the committed fixture is missing at least one expected
// migration column. If this stops being true, either someone
// pre-migrated the fixture (and this test no longer protects #1289)
// or AssertReady's required set changed.
if err := dbschema.AssertReady(db); err == nil {
t.Logf("note: fixture already passes AssertReady; skipping pre-condition assertion")
}
if err := dbschema.Apply(db, t.Logf); err != nil {
t.Fatalf("Apply: %v", err)
}
if err := dbschema.AssertReady(db); err != nil {
t.Fatalf("AssertReady after Apply: %v", err)
}
}
-254
View File
@@ -1,254 +0,0 @@
// Package main: analytics recomputer (issue #1240).
//
// Steady-state background recompute loop for expensive analytics
// endpoints. Reads always hit an atomic-pointer cache; compute runs
// on a fixed ticker in a goroutine. This eliminates the on-request
// compute-then-cache pattern where the first reader after expiry pays
// the full compute cost and blocks under writer contention.
//
// See issue #1240 and AGENTS.md "Performance is a feature".
package main
import (
"sync"
"sync/atomic"
"time"
)
// analyticsRecomputer holds the latest snapshot of an analytics result
// in an atomic.Value, refreshed periodically by a background goroutine.
//
// Lifecycle:
// 1. Construct via newAnalyticsRecomputer(...)
// 2. Call Start() — runs initial compute synchronously, then launches
// the recompute goroutine. Initial compute is synchronous so the
// first Load() after Start returns never sees a nil cache.
// 3. Call Load() any number of times concurrently — never blocks
// beyond an atomic-pointer load.
// 4. Call Stop() to terminate the background goroutine cleanly.
//
// Compute func is called WITHOUT any lock held by this struct, so it
// may freely take any application-level locks it needs.
type analyticsRecomputer struct {
name string
interval time.Duration
compute func() interface{}
cache atomic.Value // holds interface{} — the latest snapshot
stop chan struct{}
done chan struct{}
startOnce sync.Once
stopOnce sync.Once
// Stats (atomic).
computeRuns atomic.Int64
lastComputeNs atomic.Int64 // duration of last compute in nanoseconds
}
// newAnalyticsRecomputer constructs an unstarted recomputer.
// interval must be > 0; compute must be non-nil.
func newAnalyticsRecomputer(name string, interval time.Duration, compute func() interface{}) *analyticsRecomputer {
if interval <= 0 {
interval = 5 * time.Minute
}
return &analyticsRecomputer{
name: name,
interval: interval,
compute: compute,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
// Start runs the initial compute synchronously (so the first Load
// after Start returns a populated snapshot, never nil), then launches
// a background goroutine to periodically recompute.
//
// Calling Start multiple times is a no-op after the first call.
func (r *analyticsRecomputer) Start() {
r.startOnce.Do(func() {
// Initial synchronous compute — first read must NOT see empty
// or uninitialized data (acceptance criterion #1240).
r.runOnce()
go r.loop()
})
}
func (r *analyticsRecomputer) loop() {
defer close(r.done)
t := time.NewTicker(r.interval)
defer t.Stop()
for {
select {
case <-t.C:
r.runOnce()
case <-r.stop:
return
}
}
}
func (r *analyticsRecomputer) runOnce() {
if r.compute == nil {
return
}
defer func() {
// Don't let a compute panic kill the background goroutine.
// The previous snapshot remains valid.
_ = recover()
}()
t0 := time.Now()
result := r.compute()
r.lastComputeNs.Store(int64(time.Since(t0)))
r.computeRuns.Add(1)
if result != nil {
r.cache.Store(result)
}
}
// Load returns the most recently computed snapshot, or nil if Start
// has not been called (or the very first compute returned nil).
// Never blocks beyond a single atomic load.
func (r *analyticsRecomputer) Load() interface{} {
v := r.cache.Load()
if v == nil {
return nil
}
return v
}
// Stop signals the background goroutine to exit and waits for it.
// Safe to call multiple times. Safe to call before Start (no-op).
func (r *analyticsRecomputer) Stop() {
r.stopOnce.Do(func() {
close(r.stop)
})
// Only wait if the goroutine was actually started.
select {
case <-r.done:
case <-time.After(5 * time.Second):
// Defensive timeout: shouldn't happen in practice.
}
}
// LastComputeDuration returns the duration of the most recent compute.
func (r *analyticsRecomputer) LastComputeDuration() time.Duration {
return time.Duration(r.lastComputeNs.Load())
}
// ComputeRuns returns the total number of compute invocations.
func (r *analyticsRecomputer) ComputeRuns() int64 {
return r.computeRuns.Load()
}
// AnalyticsRecomputeIntervals lets callers (main.go) override the
// per-endpoint recompute interval from config.json. Zero values fall
// back to the defaultInterval passed to StartAnalyticsRecomputers.
type AnalyticsRecomputeIntervals struct {
Topology time.Duration
RF time.Duration
Distance time.Duration
Channels time.Duration
HashCollisions time.Duration
HashSizes time.Duration
Roles time.Duration
ObserversClockSkew time.Duration
NodesClockSkew time.Duration
}
func pickInterval(override, def time.Duration) time.Duration {
if override > 0 {
return override
}
return def
}
// StartAnalyticsRecomputers wires each analytics endpoint to a
// background recompute goroutine. Each runs an initial compute
// synchronously (so the first read after startup is a cache hit, never
// cold) and then refreshes on a ticker.
//
// All recomputers serve the DEFAULT query shape only: region="" and
// zero-window (no ?since= / ?until= params). Region-keyed or windowed
// queries continue to use the legacy on-request compute + TTL cache —
// the recomputer count would explode if we maintained one per
// (endpoint × region × window) combination, and region filtering is
// fast read-time work anyway.
//
// Returns a stop closure that signals all goroutines and blocks until
// they exit. Safe to call once per PacketStore. Idempotent if called
// multiple times (subsequent calls return the first stop closure).
func (s *PacketStore) StartAnalyticsRecomputers(defaultInterval time.Duration, overrides ...AnalyticsRecomputeIntervals) func() {
if defaultInterval <= 0 {
defaultInterval = 5 * time.Minute
}
var ov AnalyticsRecomputeIntervals
if len(overrides) > 0 {
ov = overrides[0]
}
s.analyticsRecomputerMu.Lock()
if s.recompTopology != nil {
// Already started; return a no-op so the caller's defer is harmless.
s.analyticsRecomputerMu.Unlock()
return func() {}
}
// Each recomputer wraps the underlying compute* function with the
// default arguments. We use computeAnalytics* (not GetAnalytics*) to
// bypass the legacy TTL cache layer — the recomputer IS the cache.
s.recompTopology = newAnalyticsRecomputer(
"topology", pickInterval(ov.Topology, defaultInterval),
func() interface{} { return s.computeAnalyticsTopology("", "", TimeWindow{}) },
)
s.recompRF = newAnalyticsRecomputer(
"rf", pickInterval(ov.RF, defaultInterval),
func() interface{} { return s.computeAnalyticsRF("", "", TimeWindow{}) },
)
s.recompDistance = newAnalyticsRecomputer(
"distance", pickInterval(ov.Distance, defaultInterval),
func() interface{} { return s.computeAnalyticsDistance("", "") },
)
s.recompChannels = newAnalyticsRecomputer(
"channels", pickInterval(ov.Channels, defaultInterval),
func() interface{} { return s.computeAnalyticsChannels("", "", TimeWindow{}) },
)
s.recompHashCollisions = newAnalyticsRecomputer(
"hash-collisions", pickInterval(ov.HashCollisions, defaultInterval),
func() interface{} { return s.computeHashCollisions("", "") },
)
s.recompHashSizes = newAnalyticsRecomputer(
"hash-sizes", pickInterval(ov.HashSizes, defaultInterval),
func() interface{} { return s.computeAnalyticsHashSizesWithCapability("", "") },
)
s.recompRoles = newAnalyticsRecomputer(
"roles", pickInterval(ov.Roles, defaultInterval),
func() interface{} { return s.computeAnalyticsRoles() },
)
s.recompObserversClockSkew = newAnalyticsRecomputer(
"observers-clock-skew", pickInterval(ov.ObserversClockSkew, defaultInterval),
func() interface{} { return s.computeObserverCalibrations() },
)
s.recompNodesClockSkew = newAnalyticsRecomputer(
"nodes-clock-skew", pickInterval(ov.NodesClockSkew, defaultInterval),
func() interface{} { return s.computeFleetClockSkew() },
)
all := []*analyticsRecomputer{
s.recompTopology, s.recompRF, s.recompDistance,
s.recompChannels, s.recompHashCollisions, s.recompHashSizes,
s.recompRoles,
s.recompObserversClockSkew, s.recompNodesClockSkew,
}
s.analyticsRecomputerMu.Unlock()
for _, rc := range all {
rc.Start()
}
return func() {
for _, rc := range all {
rc.Stop()
}
}
}
-174
View File
@@ -1,174 +0,0 @@
package main
import (
"runtime"
"sort"
"sync"
"sync/atomic"
"testing"
"time"
)
func numGoroutinesForTest() int { return runtime.NumGoroutine() }
// TestAnalyticsRecomputerSteadyStateLatency asserts that issue #1240's
// steady-state background recompute is in place: reads of the common
// analytics endpoints (region="") return from cache in <50ms p99 even
// under simulated ingest load.
//
// On master (pre-fix), GetAnalyticsTopology holds s.mu.RLock for the
// entire compute. Concurrent ingest writers (s.mu.Lock) starve readers
// or vice versa, producing per-read latencies in the hundreds of
// milliseconds. The cache TTL doesn't help: after every expiry one
// reader still pays the full compute cost.
//
// Post-fix, GetAnalyticsTopology with region="" and zero window must
// Load() from the background-refreshed atomic snapshot — never blocking
// under writer contention.
func TestAnalyticsRecomputerSteadyStateLatency(t *testing.T) {
if testing.Short() {
t.Skip("skipping latency timing test in -short mode")
}
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
// Populate with enough records to make on-request compute non-trivial.
const N = 20000
hops := make([]distHopRecord, N)
for i := 0; i < N; i++ {
hops[i] = distHopRecord{
FromName: "A", FromPk: "aa",
ToName: "B", ToPk: "bb",
Dist: float64(i%500) + 0.5,
Type: []string{"R↔R", "C↔R", "C↔C"}[i%3],
Hash: "h",
Timestamp: "2024-01-01T00:00:00Z",
HourBucket: "2024-01-01-00",
}
}
store.mu.Lock()
store.distHops = hops
store.mu.Unlock()
// Start the recomputer infrastructure. On master this method
// doesn't exist, so this test won't compile until the GREEN commit
// lands; the RED commit lands the test + a stub. Stub returns
// without wiring background recompute, so the test still fails on
// the latency assertion below.
stop := store.StartAnalyticsRecomputers(10 * time.Millisecond)
defer stop()
// Give the initial compute a moment to populate.
time.Sleep(50 * time.Millisecond)
// Simulated writer: contend for s.mu.Lock. This is what makes the
// non-recomputer path miss the latency target — the old
// GetAnalyticsTopology grabs s.mu.RLock for the entire compute and
// blocks behind every writer cycle.
var stopWriters atomic.Bool
var writerWg sync.WaitGroup
const Writers = 4
writerWg.Add(Writers)
for w := 0; w < Writers; w++ {
go func() {
defer writerWg.Done()
for !stopWriters.Load() {
store.mu.Lock()
// Trivial mutation: extend distHops by one and shrink back.
store.distHops = append(store.distHops, distHopRecord{
Dist: 1, Hash: "x", Timestamp: "2024-01-01T00:00:00Z",
})
store.distHops = store.distHops[:len(store.distHops)-1]
store.mu.Unlock()
// Brief pause to keep the lock-cycle rate realistic.
time.Sleep(100 * time.Microsecond)
}
}()
}
// 100 concurrent reads.
const Readers = 100
latencies := make([]time.Duration, Readers)
var rwg sync.WaitGroup
rwg.Add(Readers)
for i := 0; i < Readers; i++ {
i := i
go func() {
defer rwg.Done()
t0 := time.Now()
r := store.GetAnalyticsDistance("", "")
latencies[i] = time.Since(t0)
if r == nil {
t.Errorf("reader %d got nil result", i)
}
}()
}
rwg.Wait()
stopWriters.Store(true)
writerWg.Wait()
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
p50 := latencies[Readers/2]
p99 := latencies[(Readers*99)/100]
t.Logf("analytics distance read latency: p50=%v p99=%v max=%v",
p50, p99, latencies[Readers-1])
// p99 budget: 50ms. Atomic-pointer load + JSON-shape map return
// should be sub-millisecond; 50ms leaves margin for goroutine
// scheduling jitter under concurrent test runs.
const budget = 50 * time.Millisecond
if p99 > budget {
t.Fatalf("p99 read latency %v exceeds %v budget (issue #1240 not in effect)", p99, budget)
}
}
// TestAnalyticsRecomputerShutdownNoLeak asserts the background
// goroutines started by StartAnalyticsRecomputers exit cleanly when
// the returned stop function is called — no leak across server
// shutdown (issue #1240 acceptance criterion).
func TestAnalyticsRecomputerShutdownNoLeak(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
// Use a tight tick so we know recompute is actually running (not
// just blocked on the ticker).
stop := store.StartAnalyticsRecomputers(20 * time.Millisecond)
// Snapshot active goroutines a beat after start.
time.Sleep(80 * time.Millisecond)
startGoroutines := runtimeNumGoroutine()
stop()
// After stop returns, give the scheduler a beat to reap exits.
deadline := time.Now().Add(2 * time.Second)
var endGoroutines int
for time.Now().Before(deadline) {
endGoroutines = runtimeNumGoroutine()
if endGoroutines <= startGoroutines-5 { // we started 6 recomputers
break
}
time.Sleep(20 * time.Millisecond)
}
// We expect ~6 fewer goroutines than the snapshot taken DURING
// recompute (one per registered recomputer). Allow some slack
// since test runners can have flaky goroutine counts.
if endGoroutines >= startGoroutines {
t.Fatalf("goroutine leak after stop: %d → %d (expected fewer)",
startGoroutines, endGoroutines)
}
t.Logf("goroutines: during=%d after=%d (Δ=%d)",
startGoroutines, endGoroutines, startGoroutines-endGoroutines)
}
// runtimeNumGoroutine is wrapped to keep the imports section of the
// production file minimal.
func runtimeNumGoroutine() int {
// imported below
return numGoroutinesForTest()
}
-400
View File
@@ -1,400 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gorilla/mux"
)
func mustExecDB(t *testing.T, db *DB, q string) {
t.Helper()
if _, err := db.conn.Exec(q); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func TestAreaEntryParsing(t *testing.T) {
raw := `{
"port": 3000,
"areas": {
"BEL": {
"label": "Belgium",
"polygon": [[50.0, 2.5], [51.5, 2.5], [51.5, 6.4], [50.0, 6.4]]
},
"BOX": {
"label": "Bounding Box Area",
"latMin": 50.0, "latMax": 51.5, "lonMin": 2.5, "lonMax": 6.4
}
}
}`
var cfg Config
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Areas) != 2 {
t.Fatalf("want 2 areas, got %d", len(cfg.Areas))
}
bel := cfg.Areas["BEL"]
if bel.Label != "Belgium" {
t.Errorf("label: want Belgium, got %q", bel.Label)
}
if len(bel.Polygon) != 4 {
t.Errorf("polygon: want 4 points, got %d", len(bel.Polygon))
}
box := cfg.Areas["BOX"]
if box.LatMin == nil || *box.LatMin != 50.0 {
t.Error("LatMin not parsed")
}
}
func TestGetNodePubkeysInArea_Polygon(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-inside', 50.85, 4.35)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-outside', 48.0, 4.35)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-nogps', NULL, NULL)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-zero', 0.0, 0.0)`)
entry := AreaEntry{
Label: "Belgium",
Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}},
}
pks, err := db.GetNodePubkeysInArea(entry)
if err != nil {
t.Fatalf("GetNodePubkeysInArea: %v", err)
}
if len(pks) != 1 || pks[0] != "pk-inside" {
t.Errorf("want [pk-inside], got %v", pks)
}
}
// newTestStoreWithDB builds a minimal PacketStore wired to the given DB and config.
func newTestStoreWithDB(t *testing.T, db *DB, cfg *Config) *PacketStore {
t.Helper()
return &PacketStore{
db: db,
config: cfg,
byNode: make(map[string][]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
byObserver: make(map[string][]*StoreObs),
byHash: make(map[string]*StoreTx),
byPayloadType: make(map[int][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
byPathHop: make(map[string][]*StoreTx),
advertPubkeys: make(map[string]int),
rfCache: make(map[string]*cachedResult),
topoCache: make(map[string]*cachedResult),
hashCache: make(map[string]*cachedResult),
collisionCache: make(map[string]*cachedResult),
chanCache: make(map[string]*cachedResult),
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
regionObsCache: make(map[string]map[string]bool),
areaNodeCache: make(map[string]map[string]bool),
areaNodeCacheTimes: make(map[string]time.Time),
rfCacheTTL: 15 * time.Second,
}
}
func TestResolveAreaNodes_UnknownKey(t *testing.T) {
db := setupTestDBv2(t)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
result := s.resolveAreaNodes("UNKNOWN")
if result != nil {
t.Errorf("want nil for unknown area, got %v", result)
}
}
func TestResolveAreaNodes_CacheHit(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk1', 50.85, 4.35)`)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
r1 := s.resolveAreaNodes("BEL")
if !r1["pk1"] {
t.Fatal("pk1 should be in area BEL on first call")
}
// Delete node so a live DB query would return nothing — second call must use cache.
mustExecDB(t, db, `DELETE FROM nodes WHERE public_key = 'pk1'`)
r2 := s.resolveAreaNodes("BEL")
if !r2["pk1"] {
t.Fatal("cache hit should still return pk1 after DB delete")
}
}
// ingestAdvert adds a synthetic ADVERT packet to the store's in-memory packet list.
func ingestAdvert(t *testing.T, s *PacketStore, hash, decodedJSON string) {
t.Helper()
pt := PayloadADVERT
tx := &StoreTx{
Hash: hash,
FirstSeen: "2026-01-01T00:00:00Z",
PayloadType: &pt,
DecodedJSON: decodedJSON,
}
s.mu.Lock()
s.packets = append(s.packets, tx)
s.byHash[hash] = tx
s.byPayloadType[PayloadADVERT] = append(s.byPayloadType[PayloadADVERT], tx)
s.mu.Unlock()
}
func TestFilterPacketsByArea(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('inside-node', 50.85, 4.35)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('outside-node', 48.0, 4.35)`)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
ingestAdvert(t, s, "hash-in", `{"public_key":"inside-node","name":"Inside"}`)
ingestAdvert(t, s, "hash-out", `{"public_key":"outside-node","name":"Outside"}`)
result := s.QueryPackets(PacketQuery{Limit: 50, Area: "BEL"})
if result.Total != 1 {
t.Fatalf("want 1 packet in area BEL, got %d (packets: %v)", result.Total, result.Packets)
}
}
func TestAnalyticsRFAreaFilter(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('inside-node', 50.85, 4.35)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('outside-node', 48.0, 4.35)`)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
ingestAdvert(t, s, "hash-rf-in", `{"public_key":"inside-node","name":"Inside"}`)
ingestAdvert(t, s, "hash-rf-out", `{"public_key":"outside-node","name":"Outside"}`)
result := s.GetAnalyticsRF("", "BEL")
if result == nil {
t.Fatal("GetAnalyticsRF returned nil")
}
total, _ := result["totalTransmissions"].(int)
if total != 1 {
t.Errorf("want totalTransmissions=1 for BEL, got %d", total)
}
}
// ingestChanMsg adds a synthetic GRP_TXT packet with the given sender pubkey and channel hash.
func ingestChanMsg(t *testing.T, s *PacketStore, hash, senderPK string, chanHash int) {
t.Helper()
pt := PayloadGRP_TXT
decodedJSON := fmt.Sprintf(`{"public_key":%q,"channelHash":%d}`, senderPK, chanHash)
tx := &StoreTx{
Hash: hash,
FirstSeen: "2026-01-01T00:00:00Z",
PayloadType: &pt,
DecodedJSON: decodedJSON,
}
s.mu.Lock()
s.packets = append(s.packets, tx)
s.byHash[hash] = tx
s.byPayloadType[PayloadGRP_TXT] = append(s.byPayloadType[PayloadGRP_TXT], tx)
s.mu.Unlock()
}
func TestAnalyticsChannelsAreaFilter(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('inside-node', 50.85, 4.35)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('outside-node', 48.0, 4.35)`)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
// inside-node sends on channel hash 42, outside-node on channel hash 99.
ingestChanMsg(t, s, "ch-in", "inside-node", 42)
ingestChanMsg(t, s, "ch-out", "outside-node", 99)
unfiltered := s.GetAnalyticsChannels("", "")
filtered := s.GetAnalyticsChannels("", "BEL")
if filtered == nil {
t.Fatal("GetAnalyticsChannels returned nil")
}
unfilteredCount, _ := unfiltered["activeChannels"].(int)
filteredCount, _ := filtered["activeChannels"].(int)
if unfilteredCount != 2 {
t.Errorf("want 2 active channels unfiltered, got %d", unfilteredCount)
}
if filteredCount != 1 {
t.Errorf("want 1 active channel for BEL, got %d", filteredCount)
}
}
func TestGetNodePubkeysInArea_BoundingBox(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('in', 50.5, 5.0)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('out', 52.0, 5.0)`)
minLat, maxLat, minLon, maxLon := 50.0, 51.5, 2.5, 6.4
entry := AreaEntry{LatMin: &minLat, LatMax: &maxLat, LonMin: &minLon, LonMax: &maxLon}
pks, err := db.GetNodePubkeysInArea(entry)
if err != nil {
t.Fatalf("%v", err)
}
if len(pks) != 1 || pks[0] != "in" {
t.Errorf("want [in], got %v", pks)
}
}
func TestHandleConfigAreas(t *testing.T) {
db := setupTestDBv2(t)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
"MST": {Label: "Maastricht"},
}}
r := mux.NewRouter()
srv := &Server{db: db, cfg: cfg}
r.HandleFunc("/api/config/areas", srv.handleConfigAreas).Methods("GET")
req := httptest.NewRequest(http.MethodGet, "/api/config/areas", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
var result []map[string]string
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
if len(result) != 2 {
t.Fatalf("want 2 areas, got %d", len(result))
}
keys := map[string]bool{}
for _, entry := range result {
keys[entry["key"]] = true
if entry["label"] == "" {
t.Errorf("missing label for key %q", entry["key"])
}
}
if !keys["BEL"] || !keys["MST"] {
t.Errorf("expected BEL and MST, got %v", keys)
}
}
func TestHandleConfigAreasEmpty(t *testing.T) {
db := setupTestDBv2(t)
cfg := &Config{}
r := mux.NewRouter()
srv := &Server{db: db, cfg: cfg}
r.HandleFunc("/api/config/areas", srv.handleConfigAreas).Methods("GET")
req := httptest.NewRequest(http.MethodGet, "/api/config/areas", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var result []interface{}
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
if len(result) != 0 {
t.Errorf("want empty array, got %v", result)
}
}
func TestResolveAreaNodes_CalledBeforeRLock(t *testing.T) {
// Verify resolveAreaNodes doesn't deadlock when called concurrently with writes.
// This test catches the anti-pattern where resolveAreaNodes (which does a DB
// query) is called while holding s.mu.RLock().
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('n1', 50.85, 4.35)`)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
ingestAdvert(t, s, "h1", `{"public_key":"n1","name":"N1"}`)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.GetBulkHealth(10, "", "BEL")
}()
}
wg.Wait() // must not deadlock
}
func TestResolveAreaNodes_PerKeyTTL(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('bel-node', 50.85, 4.35)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('nl-node', 52.4, 4.9)`)
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
"NL": {Label: "Netherlands", Polygon: [][2]float64{{51.5, 3.4}, {53.6, 3.4}, {53.6, 7.2}, {51.5, 7.2}}},
}}
s := newTestStoreWithDB(t, db, cfg)
// Populate both keys into cache.
r1 := s.resolveAreaNodes("BEL")
if !r1["bel-node"] {
t.Fatal("bel-node should be in BEL")
}
r2 := s.resolveAreaNodes("NL")
if !r2["nl-node"] {
t.Fatal("nl-node should be in NL")
}
// Delete both nodes from DB to prove cache still serves them.
mustExecDB(t, db, `DELETE FROM nodes`)
// BEL cache should still be warm (not evicted by NL query).
r3 := s.resolveAreaNodes("BEL")
if !r3["bel-node"] {
t.Error("BEL cache was evicted by NL query (global TTL bug)")
}
// NL cache should still be warm too.
r4 := s.resolveAreaNodes("NL")
if !r4["nl-node"] {
t.Error("NL cache was evicted unexpectedly")
}
}
func TestGetBulkHealth_AreaBypassesCap(t *testing.T) {
db := setupTestDBv2(t)
// Insert 510 nodes inside BEL — all at 50.85, 4.35.
for i := 0; i < 510; i++ {
mustExecDB(t, db, fmt.Sprintf(
`INSERT INTO nodes (public_key, lat, lon) VALUES ('node-%d', 50.85, 4.35)`, i,
))
}
cfg := &Config{Areas: map[string]AreaEntry{
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
}}
s := newTestStoreWithDB(t, db, cfg)
// With limit=10 but area filter active, all 510 in-area nodes must be returned.
result := s.GetBulkHealth(10, "", "BEL")
if len(result) != 510 {
t.Errorf("want 510 nodes from area BEL, got %d", len(result))
}
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestBackfillAsyncChunked verifies that backfillResolvedPathsAsync processes
// observations in chunks, yields between batches, and sets the completion flag.
func TestBackfillAsyncChunked(t *testing.T) {
store := &PacketStore{
packets: make([]*StoreTx, 0),
byHash: make(map[string]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
}
// No pending observations → should complete immediately.
backfillResolvedPathsAsync(store, "", 100, time.Millisecond, 24)
if !store.backfillComplete.Load() {
t.Fatal("expected backfillComplete to be true with empty store")
}
}
// TestBackfillStatusHeader verifies the X-CoreScope-Status header is set correctly.
func TestBackfillStatusHeader(t *testing.T) {
store := &PacketStore{
packets: make([]*StoreTx, 0),
byHash: make(map[string]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
}
srv := &Server{store: store}
handler := srv.backfillStatusMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
// Before backfill completes → backfilling
req := httptest.NewRequest("GET", "/api/stats", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("X-CoreScope-Status"); got != "backfilling" {
t.Fatalf("expected 'backfilling', got %q", got)
}
// After backfill completes → ready
store.backfillComplete.Store(true)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("X-CoreScope-Status"); got != "ready" {
t.Fatalf("expected 'ready', got %q", got)
}
}
// TestStatsBackfillFields verifies /api/stats includes backfill fields.
func TestStatsBackfillFields(t *testing.T) {
db := setupTestDBv2(t)
defer db.Close()
seedV2Data(t, db)
store := &PacketStore{
db: db,
packets: make([]*StoreTx, 0),
byHash: make(map[string]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
loaded: true,
}
cfg := &Config{Port: 0}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
// While backfilling
req := httptest.NewRequest("GET", "/api/stats", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stats response: %v", err)
}
if backfilling, ok := resp["backfilling"]; !ok {
t.Fatal("missing 'backfilling' field in stats response")
} else if backfilling != true {
t.Fatalf("expected backfilling=true, got %v", backfilling)
}
if _, ok := resp["backfillProgress"]; !ok {
t.Fatal("missing 'backfillProgress' field in stats response")
}
// Check header
if got := rec.Header().Get("X-CoreScope-Status"); got != "backfilling" {
t.Fatalf("expected X-CoreScope-Status=backfilling, got %q", got)
}
// After backfill completes
store.backfillComplete.Store(true)
// Invalidate stats cache
srv.statsMu.Lock()
srv.statsCache = nil
srv.statsMu.Unlock()
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
resp = nil
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stats response: %v", err)
}
if backfilling, ok := resp["backfilling"]; !ok || backfilling != false {
t.Fatalf("expected backfilling=false after completion, got %v", backfilling)
}
if got := rec.Header().Get("X-CoreScope-Status"); got != "ready" {
t.Fatalf("expected X-CoreScope-Status=ready, got %q", got)
}
}
-89
View File
@@ -1,89 +0,0 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// handleBackup streams a consistent SQLite snapshot of the analyzer DB.
//
// Requires API-key authentication (mounted via requireAPIKey in routes.go).
//
// Strategy: SQLite's `VACUUM INTO 'path'` produces an atomic, defragmented
// copy of the current database into a new file. It runs at READ ISOLATION
// against the source DB (works on our read-only connection) and never
// blocks concurrent writers — the ingestor keeps writing to the WAL while
// the snapshot is taken from a consistent read transaction.
//
// Response:
//
// 200 OK
// Content-Type: application/octet-stream
// Content-Disposition: attachment; filename="corescope-backup-<unix>.db"
// <body: complete SQLite database file>
//
// The temp file is removed after the response is fully written, regardless
// of whether the client successfully consumed the stream.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
if s.db == nil || s.db.conn == nil {
writeError(w, http.StatusServiceUnavailable, "database unavailable")
return
}
ts := time.Now().UTC().Unix()
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
log.Printf("[backup] generating backup for client %s", clientIP)
// Stage the snapshot in the OS temp dir so we never touch the live DB
// directory (avoids confusing operators / accidental WAL clobber).
tmpDir, err := os.MkdirTemp("", "corescope-backup-")
if err != nil {
writeError(w, http.StatusInternalServerError, "tempdir failed: "+err.Error())
return
}
defer func() {
if rmErr := os.RemoveAll(tmpDir); rmErr != nil {
log.Printf("[backup] cleanup error: %v", rmErr)
}
}()
snapshotPath := filepath.Join(tmpDir, fmt.Sprintf("corescope-backup-%d.db", ts))
// SQLite parses the path literal — escape any single quotes defensively.
// (mkdtemp output won't contain quotes, but be paranoid for future-proofing.)
escaped := strings.ReplaceAll(snapshotPath, "'", "''")
if _, err := s.db.conn.ExecContext(r.Context(), fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil {
writeError(w, http.StatusInternalServerError, "snapshot failed: "+err.Error())
return
}
f, err := os.Open(snapshotPath)
if err != nil {
writeError(w, http.StatusInternalServerError, "open snapshot failed: "+err.Error())
return
}
defer f.Close()
stat, err := f.Stat()
if err == nil {
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"corescope-backup-%d.db\"", ts))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, f); err != nil {
// Headers already flushed; just log. Client will see truncated stream.
log.Printf("[backup] stream error: %v", err)
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// sqliteMagic is the 16-byte file header identifying a valid SQLite 3 database.
// See https://www.sqlite.org/fileformat.html#magic_header_string
const sqliteMagic = "SQLite format 3\x00"
func TestBackupRequiresAPIKey(t *testing.T) {
_, router := setupTestServerWithAPIKey(t, "test-secret-key-strong-enough")
req := httptest.NewRequest("GET", "/api/backup", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without API key, got %d (body: %s)", w.Code, w.Body.String())
}
}
func TestBackupReturnsValidSQLiteSnapshot(t *testing.T) {
const apiKey = "test-secret-key-strong-enough"
_, router := setupTestServerWithAPIKey(t, apiKey)
req := httptest.NewRequest("GET", "/api/backup", nil)
req.Header.Set("X-API-Key", apiKey)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if ct != "application/octet-stream" {
t.Errorf("expected Content-Type application/octet-stream, got %q", ct)
}
cd := w.Header().Get("Content-Disposition")
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, "filename=\"corescope-backup-") || !strings.HasSuffix(cd, ".db\"") {
t.Errorf("expected Content-Disposition attachment with corescope-backup-<ts>.db filename, got %q", cd)
}
body := w.Body.Bytes()
if len(body) < len(sqliteMagic) {
t.Fatalf("backup body too short (%d bytes) — expected SQLite file", len(body))
}
if got := string(body[:len(sqliteMagic)]); got != sqliteMagic {
t.Fatalf("expected SQLite magic header %q, got %q", sqliteMagic, got)
}
}
-411
View File
@@ -1,411 +0,0 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
// createTestDB creates a temporary SQLite database with N transmissions (1 obs each).
func createTestDB(t *testing.T, numTx int) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
createTestDBAt(t, dbPath, numTx)
return dbPath
}
// loadStore creates a PacketStore from a test DB with given maxMemoryMB.
func loadStore(t *testing.T, dbPath string, maxMemMB int) *PacketStore {
t.Helper()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
cfg := &PacketStoreConfig{MaxMemoryMB: maxMemMB}
store := NewPacketStore(db, cfg)
if err := store.Load(); err != nil {
t.Fatal(err)
}
return store
}
func TestBoundedLoad_LimitedMemory(t *testing.T) {
dbPath := createTestDB(t, 5000)
defer os.RemoveAll(filepath.Dir(dbPath))
// Use 1MB budget — should load far fewer than 5000 packets
store := loadStore(t, dbPath, 1)
defer store.db.conn.Close()
loaded := len(store.packets)
if loaded >= 5000 {
t.Errorf("expected bounded load to limit packets, got %d/5000", loaded)
}
if loaded < 1000 {
t.Errorf("expected at least 1000 packets (minimum), got %d", loaded)
}
t.Logf("Loaded %d/5000 packets with 1MB budget", loaded)
}
func TestBoundedLoad_NewestFirst(t *testing.T) {
dbPath := createTestDB(t, 5000)
defer os.RemoveAll(filepath.Dir(dbPath))
store := loadStore(t, dbPath, 1)
defer store.db.conn.Close()
loaded := len(store.packets)
if loaded >= 5000 {
t.Skip("all packets loaded, can't verify newest-first")
}
// The newest packet in DB has first_seen based on minute 5000.
// The loaded packets should be the newest ones.
// Last packet in store (sorted ASC) should be the newest in DB.
last := store.packets[loaded-1]
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
newestExpected := base.Add(5000 * time.Minute).Format(time.RFC3339)
if last.FirstSeen != newestExpected {
t.Errorf("expected last packet to be newest (%s), got %s", newestExpected, last.FirstSeen)
}
// First packet should NOT be the oldest in the DB (minute 1)
first := store.packets[0]
oldestAll := base.Add(1 * time.Minute).Format(time.RFC3339)
if first.FirstSeen == oldestAll {
t.Errorf("first loaded packet should not be the absolute oldest when bounded")
}
}
func TestBoundedLoad_OldestLoadedSet(t *testing.T) {
dbPath := createTestDB(t, 5000)
defer os.RemoveAll(filepath.Dir(dbPath))
store := loadStore(t, dbPath, 1)
defer store.db.conn.Close()
if store.oldestLoaded == "" {
t.Fatal("oldestLoaded should be set after bounded load")
}
if len(store.packets) > 0 && store.oldestLoaded != store.packets[0].FirstSeen {
t.Errorf("oldestLoaded (%s) should match first packet (%s)", store.oldestLoaded, store.packets[0].FirstSeen)
}
t.Logf("oldestLoaded = %s", store.oldestLoaded)
}
func TestBoundedLoad_UnlimitedWithZero(t *testing.T) {
dbPath := createTestDB(t, 200)
defer os.RemoveAll(filepath.Dir(dbPath))
store := loadStore(t, dbPath, 0)
defer store.db.conn.Close()
if len(store.packets) != 200 {
t.Errorf("expected all 200 packets with maxMemoryMB=0, got %d", len(store.packets))
}
}
func TestBoundedLoad_AscendingOrder(t *testing.T) {
dbPath := createTestDB(t, 3000)
defer os.RemoveAll(filepath.Dir(dbPath))
store := loadStore(t, dbPath, 1)
defer store.db.conn.Close()
// Verify packets are in ascending first_seen order
for i := 1; i < len(store.packets); i++ {
if store.packets[i].FirstSeen < store.packets[i-1].FirstSeen {
t.Fatalf("packets not in ascending order at index %d: %s < %s",
i, store.packets[i].FirstSeen, store.packets[i-1].FirstSeen)
}
}
}
// loadStoreWithRetention creates a PacketStore with retentionHours set.
func loadStoreWithRetention(t *testing.T, dbPath string, retentionHours float64) *PacketStore {
t.Helper()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatal(err)
}
cfg := &PacketStoreConfig{RetentionHours: retentionHours}
store := NewPacketStore(db, cfg)
if err := store.Load(); err != nil {
t.Fatal(err)
}
return store
}
// createTestDBWithAgedPackets inserts numRecent packets with timestamps within
// the last hour and numOld packets with timestamps 48 hours ago.
func createTestDBWithAgedPackets(t *testing.T, numRecent, numOld int) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
defer conn.Close()
execOrFail := func(s string) {
if _, err := conn.Exec(s); err != nil {
t.Fatalf("setup: %v\nSQL: %s", err, s)
}
}
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
execOrFail(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
now := time.Now().UTC()
id := 1
// Insert old packets (48 hours ago)
for i := 0; i < numOld; i++ {
oldT := now.Add(-48 * time.Hour).Add(time.Duration(i) * time.Second)
ts := oldT.Format(time.RFC3339)
conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "aa", fmt.Sprintf("old%d", i), ts, `{}`)
// observations.timestamp is INTEGER (unix seconds) in production schema
// — keep the fixture consistent so the RFC3339 subquery matches.
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, oldT.Unix(), "")
id++
}
// Insert recent packets (within last hour)
for i := 0; i < numRecent; i++ {
newT := now.Add(-30 * time.Minute).Add(time.Duration(i) * time.Second)
ts := newT.Format(time.RFC3339)
conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "bb", fmt.Sprintf("new%d", i), ts, `{}`)
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, newT.Unix(), "")
id++
}
return dbPath
}
func TestRetentionLoad_OnlyLoadsRecentPackets(t *testing.T) {
dbPath := createTestDBWithAgedPackets(t, 50, 100)
defer os.RemoveAll(filepath.Dir(dbPath))
// retention = 2 hours — should load only the 50 recent packets, not the 100 old ones
store := loadStoreWithRetention(t, dbPath, 2)
defer store.db.conn.Close()
if len(store.packets) != 50 {
t.Errorf("expected 50 recent packets, got %d (old packets should be excluded by retentionHours)", len(store.packets))
}
}
func TestRetentionLoad_ZeroRetentionLoadsAll(t *testing.T) {
dbPath := createTestDBWithAgedPackets(t, 50, 100)
defer os.RemoveAll(filepath.Dir(dbPath))
// retention = 0 (unlimited) — should load all 150 packets
store := loadStoreWithRetention(t, dbPath, 0)
defer store.db.conn.Close()
if len(store.packets) != 150 {
t.Errorf("expected all 150 packets with retentionHours=0, got %d", len(store.packets))
}
}
func TestEstimateStoreTxBytesTypical(t *testing.T) {
est := estimateStoreTxBytesTypical(10)
if est < 1000 {
t.Errorf("typical estimate too low: %d", est)
}
// Should be roughly proportional to observation count
est1 := estimateStoreTxBytesTypical(1)
est20 := estimateStoreTxBytesTypical(20)
if est20 <= est1 {
t.Errorf("estimate should grow with observations: 1obs=%d, 20obs=%d", est1, est20)
}
t.Logf("Typical estimate: 1obs=%d, 10obs=%d, 20obs=%d bytes", est1, est, est20)
}
func BenchmarkLoad_Bounded(b *testing.B) {
dir := b.TempDir()
dbPath := filepath.Join(dir, "bench.db")
createTestDBAt(b, dbPath, 5000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
db, _ := OpenDB(dbPath)
cfg := &PacketStoreConfig{MaxMemoryMB: 1}
store := NewPacketStore(db, cfg)
store.Load()
db.conn.Close()
}
}
func BenchmarkLoad_Unlimited(b *testing.B) {
dir := b.TempDir()
dbPath := filepath.Join(dir, "bench.db")
createTestDBAt(b, dbPath, 5000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
db, _ := OpenDB(dbPath)
cfg := &PacketStoreConfig{MaxMemoryMB: 0}
store := NewPacketStore(db, cfg)
store.Load()
db.conn.Close()
}
}
// BenchmarkLoad_30K_Bounded benchmarks bounded Load() with 30K transmissions
// and realistic observation counts (15 per transmission).
func BenchmarkLoad_30K_Bounded(b *testing.B) {
dir := b.TempDir()
dbPath := filepath.Join(dir, "bench30k.db")
createTestDBWithObs(b, dbPath, 30000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
db, _ := OpenDB(dbPath)
cfg := &PacketStoreConfig{MaxMemoryMB: 50}
store := NewPacketStore(db, cfg)
store.Load()
db.conn.Close()
}
}
// BenchmarkLoad_30K_Unlimited benchmarks unlimited Load() with 30K transmissions
// and realistic observation counts (15 per transmission).
func BenchmarkLoad_30K_Unlimited(b *testing.B) {
dir := b.TempDir()
dbPath := filepath.Join(dir, "bench30k.db")
createTestDBWithObs(b, dbPath, 30000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
db, _ := OpenDB(dbPath)
cfg := &PacketStoreConfig{MaxMemoryMB: 0}
store := NewPacketStore(db, cfg)
store.Load()
db.conn.Close()
}
}
// createTestDBAt is like createTestDB but writes to a specific path.
func createTestDBAt(tb testing.TB, dbPath string, numTx int) {
tb.Helper()
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
if err != nil {
tb.Fatal(err)
}
defer conn.Close()
execOrFail := func(sql string) {
if _, err := conn.Exec(sql); err != nil {
tb.Fatalf("test DB setup exec failed: %v\nSQL: %s", err, sql)
}
}
execOrFail(`CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY,
raw_hex TEXT, hash TEXT, first_seen TEXT,
route_type INTEGER, payload_type INTEGER,
payload_version INTEGER, decoded_json TEXT
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY,
transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER,
path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT, frequency REAL
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)`)
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
execOrFail(`CREATE INDEX IF NOT EXISTS idx_tx_first_seen ON transmissions(first_seen)`)
txStmt, err := conn.Prepare("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
if err != nil {
tb.Fatalf("test DB prepare transmissions insert: %v", err)
}
obsStmt, err := conn.Prepare("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
if err != nil {
tb.Fatalf("test DB prepare observations insert: %v", err)
}
defer txStmt.Close()
defer obsStmt.Close()
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := 1; i <= numTx; i++ {
ts := base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("h%04d", i)
txStmt.Exec(i, "aabb", hash, ts, 0, 4, 1, fmt.Sprintf(`{"pubKey":"pk%04d"}`, i))
obsStmt.Exec(i, i, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `["aa","bb"]`, ts)
}
}
// createTestDBWithObs creates a test DB with realistic observation counts (15 per tx).
func createTestDBWithObs(tb testing.TB, dbPath string, numTx int) {
tb.Helper()
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
if err != nil {
tb.Fatal(err)
}
defer conn.Close()
execOrFail := func(sqlStr string) {
if _, err := conn.Exec(sqlStr); err != nil {
tb.Fatalf("test DB setup exec failed: %v\nSQL: %s", err, sqlStr)
}
}
execOrFail(`CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
last_seen TEXT, first_seen TEXT, frequency REAL
)`)
execOrFail(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)`)
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
execOrFail(`CREATE INDEX IF NOT EXISTS idx_tx_first_seen ON transmissions(first_seen)`)
txStmt, err := conn.Prepare("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
if err != nil {
tb.Fatalf("test DB prepare transmissions: %v", err)
}
obsStmt, err := conn.Prepare("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
if err != nil {
tb.Fatalf("test DB prepare observations: %v", err)
}
defer txStmt.Close()
defer obsStmt.Close()
observers := []string{"obs1", "obs2", "obs3", "obs4", "obs5"}
obsNames := []string{"Alpha", "Bravo", "Charlie", "Delta", "Echo"}
obsID := 1
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := 1; i <= numTx; i++ {
ts := base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("h%06d", i)
txStmt.Exec(i, "aabb", hash, ts, 0, 4, 1, fmt.Sprintf(`{"pubKey":"pk%06d"}`, i))
nObs := (i % 5) + 1 // 15 observations per transmission
for j := 0; j < nObs; j++ {
snr := -5.0 + float64(j)*2.5
rssi := -90.0 + float64(j)*5.0
obsStmt.Exec(obsID, i, observers[j], obsNames[j], "RX", snr, rssi, 5-j, `["aa","bb"]`, ts)
obsID++
}
}
}
-123
View File
@@ -1,123 +0,0 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestBridgeScore_HandleNodesSurface verifies that /api/nodes
// includes a `bridge_score` field on repeater rows after the bridge
// recomputer has run. Drives the line-graph A-B-C-D through the full
// pipeline: insert nodes, populate the neighbor graph, force a
// recompute, hit the handler, parse the response. Issue #672 axis 2.
func TestBridgeScore_HandleNodesSurface(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
// handleNodes/db.GetNodes selects a foreign_advert column not in
// the minimal capability-test schema.
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
t.Fatal(err)
}
// Four repeater nodes in a line.
pks := []string{
"aaaa000000000000000000000000000000000000000000000000000000000000",
"bbbb000000000000000000000000000000000000000000000000000000000000",
"cccc000000000000000000000000000000000000000000000000000000000000",
"dddd000000000000000000000000000000000000000000000000000000000000",
}
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
for _, pk := range pks {
if _, err := db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, 'repeater', 37.5, -122.0, ?, ?, 10)`,
pk, "node-"+pk[:4], recent, recent); err != nil {
t.Fatal(err)
}
}
store := NewPacketStore(db, nil)
// Build neighbor graph with the line A-B-C-D. Add each edge
// `count` times so its time-decayed Score saturates.
g := NewNeighborGraph()
now := time.Now()
obs := "obs-test"
snr := 5.0
for i := 0; i < 10; i++ {
g.upsertEdge(pks[0], pks[1], "aa", obs, &snr, now)
g.upsertEdge(pks[1], pks[2], "bb", obs, &snr, now)
g.upsertEdge(pks[2], pks[3], "cc", obs, &snr, now)
}
store.graph.Store(g)
// Direct invocation of the recomputer's compute path — bypassing
// StartBridgeScoreRecomputer's package-level once-flag (which is
// problematic across tests).
recomputeBridgeScoresSafe(store)
snap := store.GetBridgeScoreMap()
if len(snap) == 0 {
t.Fatalf("expected non-empty bridge score snapshot, got empty")
}
// Sanity: middle nodes b/c must be positive, ends must be zero.
if snap[pks[1]] <= 0 || snap[pks[2]] <= 0 {
t.Errorf("middle nodes should have positive bridge: b=%v c=%v",
snap[pks[1]], snap[pks[2]])
}
if snap[pks[0]] != 0 || snap[pks[3]] != 0 {
t.Errorf("end nodes should have zero bridge: a=%v d=%v",
snap[pks[0]], snap[pks[3]])
}
// Wire a Server, call handleNodes, parse the response.
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("handleNodes status: want 200, got %d body=%s", rr.Code, rr.Body.String())
}
var resp struct {
Nodes []map[string]interface{} `json:"nodes"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
}
gotBy := map[string]map[string]interface{}{}
for _, n := range resp.Nodes {
if pk, _ := n["public_key"].(string); pk != "" {
gotBy[pk] = n
}
}
for _, pk := range pks {
n, ok := gotBy[pk]
if !ok {
t.Errorf("node %s missing from response", pk[:4])
continue
}
if _, has := n["bridge_score"]; !has {
t.Errorf("node %s: bridge_score field absent from response", pk[:4])
}
}
// Middle node B must report a non-zero bridge_score; end node A
// must report exactly zero. These two assertions together prevent
// a "field present but always 0" regression.
if v, _ := gotBy[pks[1]]["bridge_score"].(float64); v <= 0 {
t.Errorf("middle node B bridge_score in API response should be > 0, got %v", v)
}
if v, _ := gotBy[pks[0]]["bridge_score"].(float64); v != 0 {
t.Errorf("end node A bridge_score in API response should be 0, got %v", v)
}
}
-198
View File
@@ -1,198 +0,0 @@
// Package main: bridge-axis recomputer (issue #672 axis 2 of 4).
//
// Steady-state background loop that recomputes the per-pubkey bridge
// centrality score over the in-memory NeighborGraph and stores the
// resulting map atomically. handleNodes reads via a single atomic
// load — no lock contention with ingest or with other recomputers
// (same pattern as #1240 / #1248).
//
// Interval default: 5 minutes. The graph itself rebuilds asynchronously
// on its own schedule (path_inspect.go); a 5-minute cadence here is
// well within the freshness budget for a structural metric (centrality
// changes slowly — a new edge or evicted node nudges scores by
// fractions of a percent).
//
// Cost (Brandes + Dijkstra): O(V · (E + V log V)). Staging-scale ~600
// nodes / ~2 000 edges ≈ ~4.8M ops, well under 100 ms in practice. On
// host-fleet scale (5 000 nodes / 30 000 edges) it is still seconds,
// running in a background goroutine off the request path.
package main
import (
"sync"
"time"
)
// bridgeRecomputerDefaultInterval is how often the bridge score map is
// rebuilt. 5 minutes mirrors analytics_recomputer (#1240) and
// repeater_enrich_recomputer (#1262); centrality is a slow-moving
// structural signal and does not warrant tighter cadence.
const bridgeRecomputerDefaultInterval = 5 * time.Minute
// bridgeRecompStartedMu serializes start of the bridge recomputer.
// We do not currently expose Stop publicly — the goroutine lives for
// the lifetime of the process — but keeping the started flag local
// (instead of on PacketStore) avoids further field churn in store.go.
var (
bridgeRecompStartedMu sync.Mutex
bridgeRecompStarted bool
)
// StartBridgeScoreRecomputer launches the bridge-centrality recomputer
// (issue #672 axis 2). It performs an initial synchronous compute so
// that the very first /api/nodes after server start hits a populated
// snapshot instead of returning bridge_score=0 for every node, then
// reschedules every `interval` (default 5min if <= 0).
//
// Idempotent: subsequent calls are no-ops and return a no-op stop
// closure.
func (s *PacketStore) StartBridgeScoreRecomputer(interval time.Duration) func() {
if interval <= 0 {
interval = bridgeRecomputerDefaultInterval
}
bridgeRecompStartedMu.Lock()
if bridgeRecompStarted {
bridgeRecompStartedMu.Unlock()
return func() {}
}
bridgeRecompStarted = true
stop := make(chan struct{})
done := make(chan struct{})
bridgeRecompStartedMu.Unlock()
// Initial synchronous prewarm — see comment above.
recomputeBridgeScoresSafe(s)
var stopOnce sync.Once
go func() {
defer close(done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
recomputeBridgeScoresSafe(s)
case <-stop:
return
}
}
}()
return func() {
stopOnce.Do(func() {
close(stop)
})
select {
case <-done:
case <-time.After(5 * time.Second):
}
}
}
// recomputeBridgeScoresSafe runs ComputeBridgeScores over the current
// neighbor graph and installs the result. Panics in compute are
// swallowed (defensive) so the goroutine never dies; the previous
// snapshot remains valid.
func recomputeBridgeScoresSafe(s *PacketStore) {
defer func() { _ = recover() }()
graph := s.graph.Load()
if graph == nil {
// No graph yet — install an empty map so readers get a defined
// zero rather than a nil sentinel (handleNodes treats both as
// 0.0, but an explicit empty snapshot avoids "is this ready
// yet?" confusion in operator-facing tooling).
empty := map[string]float64{}
s.bridgeScoreMap.Store(&empty)
return
}
now := time.Now()
edges := bridgeEdgesFromGraph(graph, now)
scores := ComputeBridgeScores(edges)
s.bridgeScoreMap.Store(&scores)
}
// bridgeEdgesFromGraph snapshots the NeighborGraph into a flat slice
// of BridgeEdge tuples with weight = Score(now) * Confidence(), per
// the convention established by #1235. Edges with unresolved B
// endpoints (no concrete pubkey yet — only a hop prefix) are skipped:
// they contribute no betweenness signal because the second endpoint
// is unknown.
func bridgeEdgesFromGraph(graph *NeighborGraph, now time.Time) []BridgeEdge {
all := graph.AllEdges()
out := make([]BridgeEdge, 0, len(all))
for _, e := range all {
if e == nil {
continue
}
if e.NodeA == "" || e.NodeB == "" {
// Unresolved (prefix-only) — no defined second endpoint.
continue
}
w := e.Score(now) * e.Confidence()
if w < bridgeMinWeightEpsilon {
continue
}
out = append(out, BridgeEdge{A: e.NodeA, B: e.NodeB, Weight: w})
}
return out
}
// GetBridgeScore returns the bridge centrality score for a pubkey in
// [0, 1], or 0 if the recomputer has not run yet or the pubkey is not
// in the graph. Lookup is case-insensitive (the score map keys are
// lowercase, matching byPathHop convention).
func (s *PacketStore) GetBridgeScore(pubkey string) float64 {
if pubkey == "" {
return 0
}
snap := s.bridgeScoreMap.Load()
if snap == nil {
return 0
}
m := *snap
if v, ok := m[pubkey]; ok {
return v
}
// Try lowercase form.
lc := pubkey
for i := 0; i < len(lc); i++ {
if lc[i] >= 'A' && lc[i] <= 'Z' {
b := []byte(pubkey)
for j := i; j < len(b); j++ {
if b[j] >= 'A' && b[j] <= 'Z' {
b[j] += 'a' - 'A'
}
}
lc = string(b)
break
}
}
if v, ok := m[lc]; ok {
return v
}
return 0
}
// GetBridgeScoreMap returns a defensive copy-by-reference of the
// current bridge score snapshot. Nil-safe: returns an empty map if
// no snapshot has been installed yet. Map is read-only by convention
// — callers MUST NOT mutate it (the snapshot is shared across all
// concurrent readers).
func (s *PacketStore) GetBridgeScoreMap() map[string]float64 {
snap := s.bridgeScoreMap.Load()
if snap == nil {
return map[string]float64{}
}
return *snap
}
// resetBridgeRecomputerForTest is a test-only helper to allow the
// integration test to re-Start the recomputer in a fresh process
// (which would otherwise be blocked by the package-level
// bridgeRecompStarted flag). Production code must not call this.
func resetBridgeRecomputerForTest() {
bridgeRecompStartedMu.Lock()
bridgeRecompStarted = false
bridgeRecompStartedMu.Unlock()
}
-206
View File
@@ -1,206 +0,0 @@
// Package main: bridge axis of repeater usefulness score (issue #672,
// axis 2 of 4). The "Bridge" signal is the betweenness centrality of a
// node in the (undirected, weighted) neighbor graph: a high value means
// the node lies on many shortest paths between other pairs and is hence
// structurally important — removing it would force traffic around or
// fragment the mesh.
//
// Algorithm: Brandes' algorithm (1) with Dijkstra for weighted
// shortest paths. Complexity O(V · (E + V log V)). For the staging
// graph (~600 nodes, ~2 000 edges) this is ~4.8M ops — trivial,
// completes in milliseconds. We accumulate raw betweenness across all
// sources, halve (an undirected pair is counted from each endpoint
// once), then normalize by the max observed value so the per-node
// score is in [0, 1].
//
// Edge weight follows the convention established by #1235: the
// affinity score (count + recency decay) is multiplied by the
// observer-diversity confidence — stronger, more corroborated
// neighborships are preferred when there is a choice of paths.
// Geo-rejected edges are already excluded from the input graph at
// build time (#1230) so we don't have to re-filter here.
//
// For Dijkstra we need a DISTANCE (lower = better) not an affinity
// (higher = better), so we convert: cost = 1 / max(epsilon, weight).
// epsilon avoids divide-by-zero on a degenerate zero-weight edge.
//
// (1) Brandes, "A Faster Algorithm for Betweenness Centrality" (2001).
package main
import (
"container/heap"
"math"
"strings"
)
// BridgeEdge is the algorithm-facing edge tuple consumed by
// ComputeBridgeScores. Endpoints A and B are pubkeys (case preserved
// by caller; we lowercase internally for stable keying). Weight is
// the affinity (higher = stronger connection). Edges with zero or
// negative weight are skipped — they would break Dijkstra's
// relaxation invariant.
type BridgeEdge struct {
A, B string
Weight float64
}
// bridgeMinWeightEpsilon is the floor applied to weights before we
// invert them into Dijkstra distances. 1e-9 is small enough that any
// real weight (Score in [0,1] times Confidence in [0,1]) dominates,
// but large enough to avoid Inf when weight is exactly zero.
const bridgeMinWeightEpsilon = 1e-9
// ComputeBridgeScores returns a map pubkey → bridge score in [0, 1]
// computed via Brandes' weighted betweenness centrality on the
// undirected graph defined by `edges`. Returned map is keyed by the
// lowercase pubkey form (matching the byPathHop / persisted-edge
// convention). Nodes appearing in the graph but with zero betweenness
// are still present in the map with value 0.0.
//
// Self-loops (A == B) and edges with weight < epsilon are silently
// skipped. Duplicate edges between the same pair keep the cheapest
// (= the highest-weight) version — consistent with shortest-path
// semantics.
//
// Pure (no global state, no locks); safe to call concurrently.
// Cost: O(V · (E + V log V)).
func ComputeBridgeScores(edges []BridgeEdge) map[string]float64 {
// 1. Build adjacency list with distance = 1/weight.
adj := make(map[string]map[string]float64)
addOrMerge := func(a, b string, dist float64) {
m, ok := adj[a]
if !ok {
m = make(map[string]float64)
adj[a] = m
}
if existing, has := m[b]; !has || dist < existing {
m[b] = dist
}
}
for _, e := range edges {
a := strings.ToLower(strings.TrimSpace(e.A))
b := strings.ToLower(strings.TrimSpace(e.B))
if a == "" || b == "" || a == b {
continue
}
w := e.Weight
if w < bridgeMinWeightEpsilon {
continue
}
dist := 1.0 / w
addOrMerge(a, b, dist)
addOrMerge(b, a, dist)
}
if len(adj) == 0 {
return map[string]float64{}
}
nodes := make([]string, 0, len(adj))
for n := range adj {
nodes = append(nodes, n)
}
bc := make(map[string]float64, len(nodes))
for _, n := range nodes {
bc[n] = 0
}
// 2. Brandes outer loop: one Dijkstra-based single-source shortest
// path computation per source vertex.
for _, s := range nodes {
stack := make([]string, 0, len(nodes))
pred := make(map[string][]string, len(nodes))
sigma := make(map[string]float64, len(nodes))
dist := make(map[string]float64, len(nodes))
for _, n := range nodes {
sigma[n] = 0
dist[n] = math.Inf(1)
}
sigma[s] = 1
dist[s] = 0
pq := &bridgePQ{}
heap.Init(pq)
heap.Push(pq, bridgePQItem{node: s, dist: 0})
visited := make(map[string]bool, len(nodes))
for pq.Len() > 0 {
top := heap.Pop(pq).(bridgePQItem)
v := top.node
if visited[v] {
continue
}
visited[v] = true
stack = append(stack, v)
for w, edgeDist := range adj[v] {
alt := dist[v] + edgeDist
if alt < dist[w]-1e-12 {
dist[w] = alt
sigma[w] = sigma[v]
pred[w] = append(pred[w][:0], v)
heap.Push(pq, bridgePQItem{node: w, dist: alt})
} else if math.Abs(alt-dist[w]) <= 1e-12 {
sigma[w] += sigma[v]
pred[w] = append(pred[w], v)
}
}
}
// 3. Back-propagation: walk the stack in reverse order.
delta := make(map[string]float64, len(nodes))
for i := len(stack) - 1; i >= 0; i-- {
w := stack[i]
for _, v := range pred[w] {
if sigma[w] == 0 {
continue
}
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w])
}
if w != s {
bc[w] += delta[w]
}
}
}
// 4. Undirected graphs double-count each (s,t) pair, so halve.
for k := range bc {
bc[k] /= 2.0
}
// 5. Normalize by max so scores live in [0, 1]. If max is 0
// (clique or single edge) we leave everything at zero.
maxBC := 0.0
for _, v := range bc {
if v > maxBC {
maxBC = v
}
}
if maxBC > 0 {
for k, v := range bc {
bc[k] = v / maxBC
}
}
return bc
}
// ─── min-heap for Dijkstra ─────────────────────────────────────────────────────
type bridgePQItem struct {
node string
dist float64
}
type bridgePQ []bridgePQItem
func (h bridgePQ) Len() int { return len(h) }
func (h bridgePQ) Less(i, j int) bool { return h[i].dist < h[j].dist }
func (h bridgePQ) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *bridgePQ) Push(x interface{}) { *h = append(*h, x.(bridgePQItem)) }
func (h *bridgePQ) Pop() interface{} {
old := *h
n := len(old)
it := old[n-1]
*h = old[:n-1]
return it
}
-101
View File
@@ -1,101 +0,0 @@
package main
import (
"math"
"testing"
)
// TestComputeBridgeScores_LineGraph asserts the canonical property of
// betweenness centrality on a 4-node line A-B-C-D: the two middle
// nodes B and C have non-zero centrality (every path between an end
// and a far end traverses them) while the two leaves A and D bridge
// no pairs and score zero. This is the RED test for issue #672 bridge
// axis — it fails on master where ComputeBridgeScores is a stub.
func TestComputeBridgeScores_LineGraph(t *testing.T) {
edges := []BridgeEdge{
{A: "a", B: "b", Weight: 1.0},
{A: "b", B: "c", Weight: 1.0},
{A: "c", B: "d", Weight: 1.0},
}
scores := ComputeBridgeScores(edges)
for _, leaf := range []string{"a", "d"} {
if v, ok := scores[leaf]; !ok || v != 0 {
t.Errorf("leaf %q: want score 0 (present), got %v ok=%v", leaf, v, ok)
}
}
for _, mid := range []string{"b", "c"} {
v, ok := scores[mid]
if !ok {
t.Errorf("middle %q: missing from result map", mid)
continue
}
if v <= 0 {
t.Errorf("middle %q: want non-zero centrality, got %v", mid, v)
}
}
// Normalization: max must equal 1.0 exactly when any node has
// non-zero centrality.
maxScore := 0.0
for _, v := range scores {
if v > maxScore {
maxScore = v
}
}
if math.Abs(maxScore-1.0) > 1e-9 {
t.Errorf("max normalized score: want 1.0, got %v", maxScore)
}
}
// TestComputeBridgeScores_TriangleNoBridge: in a fully connected
// triangle every node has at least one alternate path so betweenness
// is zero everywhere. The map should still contain all three nodes
// (so callers can distinguish "in graph but unimportant" from
// "not in graph") with explicit zero values.
func TestComputeBridgeScores_TriangleNoBridge(t *testing.T) {
edges := []BridgeEdge{
{A: "x", B: "y", Weight: 1.0},
{A: "y", B: "z", Weight: 1.0},
{A: "z", B: "x", Weight: 1.0},
}
scores := ComputeBridgeScores(edges)
for _, n := range []string{"x", "y", "z"} {
if v, ok := scores[n]; !ok || v != 0 {
t.Errorf("triangle node %q: want 0 present, got %v ok=%v", n, v, ok)
}
}
}
// TestComputeBridgeScores_Empty: an empty edge list yields an empty
// (non-nil) map. Defensive check so the recomputer can swap in an
// empty result without crashing the lookup path.
func TestComputeBridgeScores_Empty(t *testing.T) {
scores := ComputeBridgeScores(nil)
if scores == nil {
t.Fatal("want non-nil empty map, got nil")
}
if len(scores) != 0 {
t.Errorf("want empty map, got %d entries", len(scores))
}
}
// TestComputeBridgeScores_WeightSensitive verifies the algorithm uses
// edge weights as affinity (higher = preferred). In a graph A-B-D and
// A-C-D where the B-route has weight 1.0 and the C-route has weight
// 0.1, shortest path (max-affinity = min 1/w) goes through B, so B
// has positive centrality and C does not. This is the "mutation
// test" — flip the cost formula (e.g., remove the 1/w inversion) and
// this test inverts.
func TestComputeBridgeScores_WeightSensitive(t *testing.T) {
edges := []BridgeEdge{
{A: "a", B: "b", Weight: 1.0},
{A: "b", B: "d", Weight: 1.0},
{A: "a", B: "c", Weight: 0.1},
{A: "c", B: "d", Weight: 0.1},
}
scores := ComputeBridgeScores(edges)
if scores["b"] <= scores["c"] {
t.Errorf("stronger-weight intermediary b should outrank c: b=%v c=%v",
scores["b"], scores["c"])
}
}
-168
View File
@@ -1,168 +0,0 @@
package main
import (
"encoding/json"
"testing"
"time"
)
var _ = time.Second // suppress unused import
// Helper to create a minimal PacketStore with GRP_TXT packets for channel analytics testing.
func newChannelTestStore(packets []*StoreTx) *PacketStore {
ps := &PacketStore{
packets: packets,
byHash: make(map[string]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
byObserver: make(map[string][]*StoreObs),
byNode: make(map[string][]*StoreTx),
byPathHop: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
byPayloadType: make(map[int][]*StoreTx),
rfCache: make(map[string]*cachedResult),
topoCache: make(map[string]*cachedResult),
hashCache: make(map[string]*cachedResult),
collisionCache: make(map[string]*cachedResult),
chanCache: make(map[string]*cachedResult),
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
spIndex: make(map[string]int),
spTxIndex: make(map[string][]*StoreTx),
advertPubkeys: make(map[string]int),
lastSeenTouched: make(map[string]time.Time),
clockSkew: NewClockSkewEngine(),
}
ps.byPayloadType[5] = packets
return ps
}
func makeGrpTx(channelHash int, channel, text, sender string) *StoreTx {
decoded := map[string]interface{}{
"type": "CHAN",
"channelHash": float64(channelHash),
"channel": channel,
"text": text,
"sender": sender,
}
b, _ := json.Marshal(decoded)
pt := 5
return &StoreTx{
ID: 1,
DecodedJSON: string(b),
FirstSeen: "2026-05-01T12:00:00Z",
PayloadType: &pt,
}
}
// TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted verifies that packets
// with the same hash byte but different decryption status merge into ONE bucket.
func TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted(t *testing.T) {
// Hash 129 is the real hash for #wardriving: SHA256(SHA256("#wardriving")[:16])[0] = 129
// Some packets are decrypted (have channel name), some are not (encrypted)
packets := []*StoreTx{
makeGrpTx(129, "#wardriving", "hello", "alice"),
makeGrpTx(129, "#wardriving", "world", "bob"),
makeGrpTx(129, "", "", ""), // encrypted — no channel name
makeGrpTx(129, "", "", ""), // encrypted
}
store := newChannelTestStore(packets)
result := store.computeAnalyticsChannels("", "", TimeWindow{})
channels := result["channels"].([]map[string]interface{})
if len(channels) != 1 {
t.Fatalf("expected 1 channel bucket, got %d: %+v", len(channels), channels)
}
ch := channels[0]
if ch["name"] != "#wardriving" {
t.Errorf("expected name '#wardriving', got %q", ch["name"])
}
if ch["messages"] != 4 {
t.Errorf("expected 4 messages, got %v", ch["messages"])
}
if ch["encrypted"] != false {
t.Errorf("expected encrypted=false (some packets decrypted), got %v", ch["encrypted"])
}
}
// TestComputeAnalyticsChannels_RejectsRainbowTableMismatch verifies that a packet
// with channelHash=72 but channel="#wardriving" (mismatch) does NOT create a
// "#wardriving" bucket — it falls into "ch72" instead.
func TestComputeAnalyticsChannels_RejectsRainbowTableMismatch(t *testing.T) {
// Hash 72 is NOT the correct hash for #wardriving (which is 129).
// This simulates a rainbow-table collision/mismatch.
packets := []*StoreTx{
makeGrpTx(72, "#wardriving", "ghost", "eve"), // mismatch: hash 72 != wardriving's real hash
makeGrpTx(129, "#wardriving", "real", "alice"), // correct match
}
store := newChannelTestStore(packets)
result := store.computeAnalyticsChannels("", "", TimeWindow{})
channels := result["channels"].([]map[string]interface{})
if len(channels) != 2 {
t.Fatalf("expected 2 channel buckets, got %d: %+v", len(channels), channels)
}
// Find the buckets
var ch72, ch129 map[string]interface{}
for _, ch := range channels {
if ch["hash"] == "72" {
ch72 = ch
} else if ch["hash"] == "129" {
ch129 = ch
}
}
if ch72 == nil {
t.Fatal("expected a bucket for hash 72")
}
if ch129 == nil {
t.Fatal("expected a bucket for hash 129")
}
// ch72 should NOT be named "#wardriving" — it should be the placeholder
if ch72["name"] == "#wardriving" {
t.Errorf("hash 72 bucket should NOT be named '#wardriving' (rainbow-table mismatch rejected)")
}
if ch72["name"] != "ch72" {
t.Errorf("expected hash 72 bucket named 'ch72', got %q", ch72["name"])
}
// ch129 should be named "#wardriving"
if ch129["name"] != "#wardriving" {
t.Errorf("expected hash 129 bucket named '#wardriving', got %q", ch129["name"])
}
}
// TestChannelNameMatchesHash verifies the hash validation function.
func TestChannelNameMatchesHash(t *testing.T) {
// #wardriving hashes to 129
if !channelNameMatchesHash("#wardriving", "129") {
t.Error("expected #wardriving to match hash 129")
}
if channelNameMatchesHash("#wardriving", "72") {
t.Error("expected #wardriving to NOT match hash 72")
}
// Without leading # should also work
if !channelNameMatchesHash("wardriving", "129") {
t.Error("expected wardriving (without #) to match hash 129")
}
}
// TestIsPlaceholderName verifies placeholder detection.
func TestIsPlaceholderName(t *testing.T) {
if !isPlaceholderName("ch129") {
t.Error("ch129 should be placeholder")
}
if !isPlaceholderName("ch0") {
t.Error("ch0 should be placeholder")
}
if isPlaceholderName("#wardriving") {
t.Error("#wardriving should NOT be placeholder")
}
if isPlaceholderName("Public") {
t.Error("Public should NOT be placeholder")
}
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestPacketsChannelFilter verifies /api/packets?channel=... actually filters
// (regression test for #812).
func TestPacketsChannelFilter(t *testing.T) {
_, router := setupTestServer(t)
get := func(url string) map[string]interface{} {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s: expected 200, got %d", url, w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %s: %v", url, err)
}
return body
}
all := get("/api/packets?limit=50")
allTotal := int(all["total"].(float64))
if allTotal < 2 {
t.Fatalf("expected baseline >= 2 packets, got %d", allTotal)
}
test := get("/api/packets?limit=50&channel=%23test")
testTotal := int(test["total"].(float64))
if testTotal == 0 {
t.Fatalf("channel=#test: expected >= 1 match, got 0 (filter ignored?)")
}
if testTotal >= allTotal {
t.Fatalf("channel=#test: expected fewer packets than baseline (%d), got %d", allTotal, testTotal)
}
// Every returned packet must be a CHAN/GRP_TXT (payload_type=5) on #test.
pkts, _ := test["packets"].([]interface{})
for _, p := range pkts {
m := p.(map[string]interface{})
if pt, _ := m["payload_type"].(float64); int(pt) != 5 {
t.Errorf("channel=#test: returned non-GRP_TXT packet (payload_type=%v)", m["payload_type"])
}
}
none := get("/api/packets?limit=50&channel=nonexistentchannel")
if int(none["total"].(float64)) != 0 {
t.Fatalf("channel=nonexistentchannel: expected total=0, got %v", none["total"])
}
}
+42 -425
View File
@@ -16,8 +16,7 @@ const (
SkewWarning SkewSeverity = "warning" // 5 min 1 hour
SkewCritical SkewSeverity = "critical" // 1 hour 30 days
SkewAbsurd SkewSeverity = "absurd" // > 30 days
SkewNoClock SkewSeverity = "no_clock" // > 365 days — uninitialized RTC
SkewBimodalClock SkewSeverity = "bimodal_clock" // mixed good+bad recent samples (flaky RTC)
SkewNoClock SkewSeverity = "no_clock" // > 365 days — uninitialized RTC
)
// Default thresholds in seconds.
@@ -34,52 +33,6 @@ const (
// maxReasonableDriftPerDay caps drift display. Physically impossible
// drift rates (> 1 day/day) indicate insufficient or outlier samples.
maxReasonableDriftPerDay = 86400.0
// recentSkewWindowCount is the number of most-recent advert samples
// used to derive the "current" skew for severity classification (see
// issue #789). The all-time median is poisoned by historical bad
// samples (e.g. a node that was off and then GPS-corrected); severity
// must reflect current health, not lifetime statistics.
recentSkewWindowCount = 5
// recentSkewWindowSec bounds the recent-window in time as well: only
// samples from the last N seconds count as "recent" for severity.
// The effective window is min(recentSkewWindowCount, samples in 1h).
recentSkewWindowSec = 3600
// bimodalSkewThresholdSec is the absolute skew threshold (1 hour)
// above which a sample is considered "bad" — likely firmware emitting
// a nonsense timestamp from an uninitialized RTC, not real drift.
// Chosen to match the warning/critical severity boundary: real clock
// drift rarely exceeds 1 hour, while epoch-0 RTCs produce ~1.7B sec.
bimodalSkewThresholdSec = 3600.0
// rtcResetOutlierThresholdSec is the absolute skew above which a
// sample is treated as obvious sensor garbage — an RTC-reset advert
// where the firmware emitted its factory timestamp (typically off by
// months/years). These samples are excluded from the recent-window
// "good/bad" split (bug #1285 — single RTC-reset advert among 30
// healthy adverts must not flip a node to bimodal_clock) and from the
// per-hash evidence median (a 700-day median is not actionable for
// operators). They remain in the raw sample stream and the RTC-reset
// badge logic which surfaces them separately. 24h is a generous floor:
// real drift is fractions of a sec/advert, real clock-skew tops out
// in the hours range; anything above a day is structurally not a
// drift signal.
rtcResetOutlierThresholdSec = 24 * 3600.0
// maxPlausibleSkewJumpSec is the largest skew change between
// consecutive samples that we treat as physical drift. Anything larger
// (e.g. a GPS sync that jumps the clock by minutes/days) is rejected
// as an outlier when computing drift. Real microcontroller drift is
// fractions of a second per advert; 60s is a generous safety factor.
maxPlausibleSkewJumpSec = 60.0
// theilSenMaxPoints caps the number of points fed to Theil-Sen
// regression (O(n²) in pairs). For nodes with thousands of samples we
// keep the most-recent points, which are also the most relevant for
// current drift.
theilSenMaxPoints = 200
)
// classifySkew maps absolute skew (seconds) to a severity level.
@@ -123,7 +76,6 @@ type NodeClockSkew struct {
MeanSkewSec float64 `json:"meanSkewSec"` // corrected mean skew (positive = node ahead)
MedianSkewSec float64 `json:"medianSkewSec"` // corrected median skew
LastSkewSec float64 `json:"lastSkewSec"` // most recent corrected skew
RecentMedianSkewSec float64 `json:"recentMedianSkewSec"` // median across most-recent samples (drives severity, see #789)
DriftPerDaySec float64 `json:"driftPerDaySec"` // linear drift rate (sec/day)
Severity SkewSeverity `json:"severity"`
SampleCount int `json:"sampleCount"`
@@ -131,11 +83,6 @@ type NodeClockSkew struct {
LastAdvertTS int64 `json:"lastAdvertTS"` // most recent advert timestamp
LastObservedTS int64 `json:"lastObservedTS"` // most recent observation timestamp
Samples []SkewSample `json:"samples,omitempty"` // time-series for sparklines
GoodFraction float64 `json:"goodFraction"` // fraction of recent samples with |skew| <= 1h
RecentBadSampleCount int `json:"recentBadSampleCount"` // count of recent samples with |skew| > 1h
RecentSampleCount int `json:"recentSampleCount"` // total recent samples in window
RecentHashEvidence []HashEvidence `json:"recentHashEvidence,omitempty"`
CalibrationSummary *CalibrationSummary `json:"calibrationSummary,omitempty"`
NodeName string `json:"nodeName,omitempty"` // populated in fleet responses
NodeRole string `json:"nodeRole,omitempty"` // populated in fleet responses
}
@@ -146,31 +93,6 @@ type SkewSample struct {
SkewSec float64 `json:"skew"` // corrected skew in seconds
}
// HashEvidenceObserver is one observer's contribution to a per-hash evidence entry.
type HashEvidenceObserver struct {
ObserverID string `json:"observerID"`
ObserverName string `json:"observerName"`
RawSkewSec float64 `json:"rawSkewSec"`
CorrectedSkewSec float64 `json:"correctedSkewSec"`
ObserverOffsetSec float64 `json:"observerOffsetSec"`
Calibrated bool `json:"calibrated"`
}
// HashEvidence is per-hash clock skew evidence showing individual observer contributions.
type HashEvidence struct {
Hash string `json:"hash"`
Observers []HashEvidenceObserver `json:"observers"`
MedianCorrectedSkewSec float64 `json:"medianCorrectedSkewSec"`
Timestamp int64 `json:"timestamp"`
}
// CalibrationSummary counts how many samples were corrected via observer calibration.
type CalibrationSummary struct {
TotalSamples int `json:"totalSamples"`
CalibratedSamples int `json:"calibratedSamples"`
UncalibratedSamples int `json:"uncalibratedSamples"`
}
// txSkewResult maps tx hash → per-transmission skew stats. This is an
// intermediate result keyed by hash (not pubkey); the store maps hash → pubkey
// when building the final per-node view.
@@ -184,27 +106,15 @@ type ClockSkewEngine struct {
observerOffsets map[string]float64 // observerID → calibrated offset (seconds)
observerSamples map[string]int // observerID → number of multi-observer packets used
nodeSkew txSkewResult
hashEvidence map[string][]hashEvidenceEntry // hash → per-observer raw/corrected data
lastComputed time.Time
computeInterval time.Duration
}
// hashEvidenceEntry stores raw evidence per observer per hash, cached during Recompute.
type hashEvidenceEntry struct {
observerID string
rawSkew float64
corrected float64
offset float64
calibrated bool
observedTS int64
}
func NewClockSkewEngine() *ClockSkewEngine {
return &ClockSkewEngine{
observerOffsets: make(map[string]float64),
observerSamples: make(map[string]int),
nodeSkew: make(txSkewResult),
hashEvidence: make(map[string][]hashEvidenceEntry),
computeInterval: 30 * time.Second,
}
}
@@ -229,16 +139,14 @@ func (e *ClockSkewEngine) Recompute(store *PacketStore) {
var newOffsets map[string]float64
var newSamples map[string]int
var newNodeSkew txSkewResult
var newHashEvidence map[string][]hashEvidenceEntry
if len(samples) > 0 {
newOffsets, newSamples = calibrateObservers(samples)
newNodeSkew, newHashEvidence = computeNodeSkew(samples, newOffsets)
newNodeSkew = computeNodeSkew(samples, newOffsets)
} else {
newOffsets = make(map[string]float64)
newSamples = make(map[string]int)
newNodeSkew = make(txSkewResult)
newHashEvidence = make(map[string][]hashEvidenceEntry)
}
// Swap results under brief write lock.
@@ -251,7 +159,6 @@ func (e *ClockSkewEngine) Recompute(store *PacketStore) {
e.observerOffsets = newOffsets
e.observerSamples = newSamples
e.nodeSkew = newNodeSkew
e.hashEvidence = newHashEvidence
e.lastComputed = time.Now()
e.mu.Unlock()
}
@@ -388,7 +295,7 @@ func calibrateObservers(samples []skewSample) (map[string]float64, map[string]in
// ── Phase 3: Per-Node Skew ─────────────────────────────────────────────────────
// computeNodeSkew calculates corrected skew statistics for each node.
func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) (txSkewResult, map[string][]hashEvidenceEntry) {
func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) txSkewResult {
// Compute corrected skew per sample, grouped by hash (each hash = one
// node's advert transmission). The caller maps hash → pubkey via byNode.
type correctedSample struct {
@@ -399,7 +306,6 @@ func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) (txSke
byHash := make(map[string][]correctedSample)
hashAdvertTS := make(map[string]int64)
evidence := make(map[string][]hashEvidenceEntry) // hash → per-observer evidence
for _, s := range samples {
obsOffset, hasCal := obsOffsets[s.observerID]
@@ -416,14 +322,6 @@ func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) (txSke
calibrated: hasCal,
})
hashAdvertTS[s.hash] = s.advertTS
evidence[s.hash] = append(evidence[s.hash], hashEvidenceEntry{
observerID: s.observerID,
rawSkew: round(rawSkew, 1),
corrected: round(corrected, 1),
offset: round(obsOffset, 1),
calibrated: hasCal,
observedTS: s.observedTS,
})
}
// Each hash represents one advert from one node. Compute median corrected
@@ -462,7 +360,7 @@ func computeNodeSkew(samples []skewSample, obsOffsets map[string]float64) (txSke
LastObservedTS: latestObsTS,
}
}
return result, evidence
return result
}
// ── Integration with PacketStore ───────────────────────────────────────────────
@@ -521,108 +419,12 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
medSkew := median(allSkews)
meanSkew := mean(allSkews)
absMedian := math.Abs(medSkew)
severity := classifySkew(absMedian)
// Severity is derived from RECENT samples only (issue #789). The
// all-time median is poisoned by historical bad data — a node that
// was off for hours and then GPS-corrected can have median = -59M sec
// while its current skew is -0.8s. Operators need severity to reflect
// current health, so they trust the dashboard.
//
// Sort tsSkews by time and take the last recentSkewWindowCount samples
// (or all samples within recentSkewWindowSec of the latest, whichever
// gives FEWER samples — we want the more-current view; a chatty node
// can fit dozens of samples in 1h, in which case the count cap wins).
sort.Slice(tsSkews, func(i, j int) bool { return tsSkews[i].ts < tsSkews[j].ts })
recentSkew := lastSkew
var recentVals []float64
if n := len(tsSkews); n > 0 {
latestTS := tsSkews[n-1].ts
// Index-based window: last K samples.
startByCount := n - recentSkewWindowCount
if startByCount < 0 {
startByCount = 0
}
// Time-based window: samples newer than latestTS - windowSec.
startByTime := n - 1
for i := n - 1; i >= 0; i-- {
if latestTS-tsSkews[i].ts <= recentSkewWindowSec {
startByTime = i
} else {
break
}
}
// Pick the narrower (larger-index) of the two windows — the most
// current view of the node's clock health.
start := startByCount
if startByTime > start {
start = startByTime
}
recentVals = make([]float64, 0, n-start)
for i := start; i < n; i++ {
recentVals = append(recentVals, tsSkews[i].skew)
}
if len(recentVals) > 0 {
recentSkew = median(recentVals)
}
}
// ── Bimodal detection (#845) ─────────────────────────────────────────
// Split recent samples into "good" (|skew| <= 1h, real clock) and
// "bad" (|skew| > 1h, firmware nonsense from uninitialized RTC).
// Classification order (first match wins):
// no_clock — goodFraction < 0.10 (essentially no real clock)
// bimodal_clock — 0.10 <= goodFraction < 0.80 AND badCount > 0
// ok/warn/etc. — goodFraction >= 0.80 (normal, outliers filtered)
//
// RTC-reset outliers (|skew| > 24h — single advert where the firmware
// emitted its factory timestamp) are EXCLUDED from this split (bug
// #1285): they're not "bimodal-bad real-but-large skew" but obvious
// sensor garbage, surfaced separately via the RTC-reset badge. Counting
// them as bimodal-bad produces a false-alarm warning ("3 of last 5
// adverts had nonsense timestamps") on otherwise-healthy nodes.
var goodSamples []float64
var rtcResetCount int
for _, v := range recentVals {
absV := math.Abs(v)
switch {
case absV > rtcResetOutlierThresholdSec:
rtcResetCount++ // ignored for good/bad classification
case absV <= bimodalSkewThresholdSec:
goodSamples = append(goodSamples, v)
}
}
recentSampleCount := len(recentVals) - rtcResetCount
recentBadCount := recentSampleCount - len(goodSamples)
var goodFraction float64
if recentSampleCount > 0 {
goodFraction = float64(len(goodSamples)) / float64(recentSampleCount)
}
var severity SkewSeverity
if goodFraction < 0.10 {
// Essentially no real clock — classify as no_clock regardless
// of the raw skew magnitude.
severity = SkewNoClock
} else if goodFraction < 0.80 && recentBadCount > 0 {
// Bimodal: use median of GOOD samples as the "real" skew.
severity = SkewBimodalClock
if len(goodSamples) > 0 {
recentSkew = median(goodSamples)
}
} else {
// Normal path: if there are good samples, use their median
// (filters out rare outliers in ≥80% good case, and rejects
// RTC-reset outliers regardless of bimodal/bad counts — #1285).
if len(goodSamples) > 0 {
recentSkew = median(goodSamples)
}
severity = classifySkew(math.Abs(recentSkew))
}
// For no_clock / bimodal_clock nodes, skip drift when data is unreliable.
// For no_clock nodes (uninitialized RTC), skip drift — data is meaningless.
var drift float64
if severity != SkewNoClock && severity != SkewBimodalClock && len(tsSkews) >= minDriftSamples {
if severity != SkewNoClock && len(tsSkews) >= minDriftSamples {
drift = computeDrift(tsSkews)
// Cap physically impossible drift rates.
if math.Abs(drift) > maxReasonableDriftPerDay {
@@ -630,131 +432,31 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
}
}
// Build sparkline samples from tsSkews (already sorted by time above).
// Build sparkline samples from tsSkews (sorted by time).
sort.Slice(tsSkews, func(i, j int) bool { return tsSkews[i].ts < tsSkews[j].ts })
samples := make([]SkewSample, len(tsSkews))
for i, p := range tsSkews {
samples[i] = SkewSample{Timestamp: p.ts, SkewSec: round(p.skew, 1)}
}
// Build per-hash evidence (most recent 10 hashes with ≥1 observer).
// Observer name lookup from store observations.
obsNameMap := make(map[string]string)
type hashMeta struct {
hash string
ts int64
}
var evidenceHashes []hashMeta
for _, tx := range txs {
if tx.PayloadType == nil || *tx.PayloadType != PayloadADVERT {
continue
}
ev, ok := s.clockSkew.hashEvidence[tx.Hash]
if !ok || len(ev) == 0 {
continue
}
// Collect observer names from tx observations.
for _, obs := range tx.Observations {
if obs.ObserverID != "" && obs.ObserverName != "" {
obsNameMap[obs.ObserverID] = obs.ObserverName
}
}
evidenceHashes = append(evidenceHashes, hashMeta{hash: tx.Hash, ts: ev[0].observedTS})
}
// Sort by timestamp descending, take most recent 10.
sort.Slice(evidenceHashes, func(i, j int) bool { return evidenceHashes[i].ts > evidenceHashes[j].ts })
if len(evidenceHashes) > 10 {
evidenceHashes = evidenceHashes[:10]
}
var recentEvidence []HashEvidence
var calSummary CalibrationSummary
for _, eh := range evidenceHashes {
entries := s.clockSkew.hashEvidence[eh.hash]
var observers []HashEvidenceObserver
var corrSkews []float64
for _, e := range entries {
name := obsNameMap[e.observerID]
if name == "" {
name = e.observerID
}
observers = append(observers, HashEvidenceObserver{
ObserverID: e.observerID,
ObserverName: name,
RawSkewSec: e.rawSkew,
CorrectedSkewSec: e.corrected,
ObserverOffsetSec: e.offset,
Calibrated: e.calibrated,
})
corrSkews = append(corrSkews, e.corrected)
calSummary.TotalSamples++
if e.calibrated {
calSummary.CalibratedSamples++
} else {
calSummary.UncalibratedSamples++
}
}
recentEvidence = append(recentEvidence, HashEvidence{
Hash: eh.hash,
Observers: observers,
MedianCorrectedSkewSec: round(hashEvidenceMedian(corrSkews), 1),
Timestamp: eh.ts,
})
}
return &NodeClockSkew{
Pubkey: pubkey,
MeanSkewSec: round(meanSkew, 1),
MedianSkewSec: round(medSkew, 1),
LastSkewSec: round(lastSkew, 1),
RecentMedianSkewSec: round(recentSkew, 1),
DriftPerDaySec: round(drift, 2),
Severity: severity,
SampleCount: totalSamples,
Calibrated: anyCal,
LastAdvertTS: lastAdvTS,
LastObservedTS: lastObsTS,
Samples: samples,
GoodFraction: round(goodFraction, 2),
RecentBadSampleCount: recentBadCount,
RecentSampleCount: recentSampleCount,
RecentHashEvidence: recentEvidence,
CalibrationSummary: &calSummary,
Pubkey: pubkey,
MeanSkewSec: round(meanSkew, 1),
MedianSkewSec: round(medSkew, 1),
LastSkewSec: round(lastSkew, 1),
DriftPerDaySec: round(drift, 2),
Severity: severity,
SampleCount: totalSamples,
Calibrated: anyCal,
LastAdvertTS: lastAdvTS,
LastObservedTS: lastObsTS,
Samples: samples,
}
}
// GetFleetClockSkew returns clock skew data for all nodes, optionally
// filtered to area. With no area, prefers the steady-state recomputer
// snapshot (issue #1265). Must NOT be called with s.mu held.
func (s *PacketStore) GetFleetClockSkew(area string) []*NodeClockSkew {
if area == "" {
s.analyticsRecomputerMu.RLock()
rc := s.recompNodesClockSkew
s.analyticsRecomputerMu.RUnlock()
if rc != nil {
if v := rc.Load(); v != nil {
if r, ok := v.([]*NodeClockSkew); ok {
return r
}
}
}
}
return s.computeFleetClockSkewForArea(area)
}
// computeFleetClockSkew wraps computeFleetClockSkewForArea with no area
// filter; called by the steady-state recomputer. Must NOT be called with
// s.mu held.
func (s *PacketStore) computeFleetClockSkew() []*NodeClockSkew {
return s.computeFleetClockSkewForArea("")
}
// computeFleetClockSkewForArea is the underlying compute. Must NOT be
// called with s.mu held.
func (s *PacketStore) computeFleetClockSkewForArea(area string) []*NodeClockSkew {
var areaNodes map[string]bool
if area != "" {
areaNodes = s.resolveAreaNodes(area)
}
// GetFleetClockSkew returns clock skew data for all nodes that have skew data.
// Must NOT be called with s.mu held.
func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -765,11 +467,8 @@ func (s *PacketStore) computeFleetClockSkewForArea(area string) []*NodeClockSkew
nameMap[ni.PublicKey] = ni
}
var results = []*NodeClockSkew{}
var results []*NodeClockSkew
for pubkey := range s.byNode {
if areaNodes != nil && !areaNodes[pubkey] {
continue
}
cs := s.getNodeClockSkewLocked(pubkey)
if cs == nil {
continue
@@ -779,35 +478,15 @@ func (s *PacketStore) computeFleetClockSkewForArea(area string) []*NodeClockSkew
cs.NodeName = ni.Name
cs.NodeRole = ni.Role
}
// Omit samples and evidence in fleet response (too much data).
// Omit samples in fleet response (too much data).
cs.Samples = nil
cs.RecentHashEvidence = nil
cs.CalibrationSummary = nil
results = append(results, cs)
}
return results
}
// GetObserverCalibrations returns the current observer clock offsets,
// preferring the steady-state recomputer snapshot (issue #1265). Falls
// back to an on-request compute when the recomputer is not running.
// GetObserverCalibrations returns the current observer clock offsets.
func (s *PacketStore) GetObserverCalibrations() []ObserverCalibration {
s.analyticsRecomputerMu.RLock()
rc := s.recompObserversClockSkew
s.analyticsRecomputerMu.RUnlock()
if rc != nil {
if v := rc.Load(); v != nil {
if r, ok := v.([]ObserverCalibration); ok {
return r
}
}
}
return s.computeObserverCalibrations()
}
// computeObserverCalibrations is the underlying compute used by the
// recomputer and on-request fallback. Must NOT be called with s.mu held.
func (s *PacketStore) computeObserverCalibrations() []ObserverCalibration {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -847,23 +526,6 @@ func median(vals []float64) float64 {
return sorted[n/2]
}
// hashEvidenceMedian returns the median corrected skew for a single
// transmission hash, filtering out RTC-reset outliers (|skew| > 24h —
// firmware emitting factory timestamp). Issue #1285: a single outlier
// observer was dragging the displayed median to ~-704d on an otherwise
// healthy node. If filtering leaves zero usable samples (every observer
// of this hash saw a reset-shaped advert), return 0 so the UI can render
// "insufficient data" rather than the garbage outlier value.
func hashEvidenceMedian(vals []float64) float64 {
clean := vals[:0:0]
for _, v := range vals {
if math.Abs(v) <= rtcResetOutlierThresholdSec {
clean = append(clean, v)
}
}
return median(clean)
}
func mean(vals []float64) float64 {
if len(vals) == 0 {
return 0
@@ -882,18 +544,7 @@ type tsSkewPair struct {
}
// computeDrift estimates linear drift in seconds per day from time-ordered
// (timestamp, skew) pairs. Issue #789: a single GPS-correction event (huge
// skew jump in seconds) used to dominate ordinary least squares and produce
// absurd drift like 1.7M sec/day. We now:
//
// 1. Drop pairs whose consecutive skew jump exceeds maxPlausibleSkewJumpSec
// (clock corrections, not physical drift). This protects both OLS-style
// consumers and Theil-Sen.
// 2. Use Theil-Sen regression — the slope is the median of all pairwise
// slopes, naturally robust to remaining outliers (breakdown point ~29%).
//
// For very small samples after filtering we fall back to a simple slope
// between first and last calibrated samples.
// (timestamp, skew) pairs using simple linear regression.
func computeDrift(pairs []tsSkewPair) float64 {
if len(pairs) < 2 {
return 0
@@ -909,55 +560,21 @@ func computeDrift(pairs []tsSkewPair) float64 {
return 0
}
// Outlier filter: drop samples where the skew jumps more than
// maxPlausibleSkewJumpSec from the running "stable" baseline.
// We anchor on the first sample, then accept each subsequent point
// that's within the threshold of the most recent accepted point —
// this preserves a slow drift while rejecting correction events.
filtered := make([]tsSkewPair, 0, len(pairs))
filtered = append(filtered, pairs[0])
for i := 1; i < len(pairs); i++ {
prev := filtered[len(filtered)-1]
if math.Abs(pairs[i].skew-prev.skew) <= maxPlausibleSkewJumpSec {
filtered = append(filtered, pairs[i])
}
// Simple linear regression: skew = a + b*t
n := float64(len(pairs))
var sumX, sumY, sumXY, sumX2 float64
for _, p := range pairs {
x := float64(p.ts - pairs[0].ts) // normalize to avoid large numbers
y := p.skew
sumX += x
sumY += y
sumXY += x * y
sumX2 += x * x
}
// If the filter killed too much (e.g. unstable node), fall back to the
// raw series so we at least produce *something* — it'll be capped by
// maxReasonableDriftPerDay downstream.
if len(filtered) < 2 || float64(filtered[len(filtered)-1].ts-filtered[0].ts) < 3600 {
filtered = pairs
}
// Cap point count for Theil-Sen (O(n²) on pairs). Keep most-recent.
if len(filtered) > theilSenMaxPoints {
filtered = filtered[len(filtered)-theilSenMaxPoints:]
}
return theilSenSlope(filtered) * 86400 // sec/sec → sec/day
}
// theilSenSlope returns the Theil-Sen estimator: median of all pairwise
// slopes (yj - yi) / (tj - ti) for i < j. Naturally robust to outliers.
// Pairs must be sorted by timestamp ascending.
func theilSenSlope(pairs []tsSkewPair) float64 {
n := len(pairs)
if n < 2 {
denom := n*sumX2 - sumX*sumX
if denom == 0 {
return 0
}
// Pre-allocate: n*(n-1)/2 pairs.
slopes := make([]float64, 0, n*(n-1)/2)
for i := 0; i < n; i++ {
for j := i + 1; j < n; j++ {
dt := float64(pairs[j].ts - pairs[i].ts)
if dt <= 0 {
continue
}
slopes = append(slopes, (pairs[j].skew-pairs[i].skew)/dt)
}
}
if len(slopes) == 0 {
return 0
}
return median(slopes)
slope := (n*sumXY - sumX*sumY) / denom // seconds of drift per second
return slope * 86400 // convert to seconds per day
}
-129
View File
@@ -1,129 +0,0 @@
package main
// Regression tests for #1285:
//
// Bug A: per-hash evidence's MedianCorrectedSkewSec includes 700-day RTC-reset
// outliers, dragging the displayed median into "704d 18h" garbage even
// though every recent sample is small (< 30s).
//
// Bug B: RecentBadSampleCount counts samples outside the *displayed* recent
// window (or counts against raw skew, not corrected) → "3 of last 5
// adverts had nonsense timestamps" warning fires on healthy nodes.
//
// Both must pass without disturbing the existing bimodal / no-clock logic.
import (
"testing"
"time"
)
// Synthesizes the repro from issue #1285:
// 30 healthy adverts (skew ~20s) + 1 historical advert with an RTC reset
// (advertTS = 2024-06-13, observed today → 705d skew).
// Returns the populated store.
func seedIssue1285Repro(t *testing.T) *PacketStore {
t.Helper()
ps := NewPacketStore(nil, nil)
pt := 4 // ADVERT
const pubkey = "RTCRESET"
const skewSec = int64(-20) // node clock is 20s BEHIND wall-clock
baseObs := int64(1779000000) // ~mid-2026
rtcResetAdv := int64(1718281640) // 2024-06-13 (from the issue repro)
var txs []*StoreTx
// 30 healthy adverts spanning the older end of the recent window.
for i := 0; i < 30; i++ {
obsTS := baseObs + int64(i)*60
advTS := obsTS + skewSec
tx := &StoreTx{
Hash: "healthy-" + formatInt64(int64(i)),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
// One RTC-reset packet observed MOST RECENTLY (so it sits in the
// per-hash evidence list AND is included in the recent-window count
// on master). Its advertTS is from 2024 → corrected skew ≈ -60M sec.
rtcResetObs := baseObs + int64(30*60) + 60
rtcTx := &StoreTx{
Hash: "rtc-reset-0001",
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(rtcResetAdv) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(rtcResetObs, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, rtcTx)
ps.mu.Lock()
ps.byNode[pubkey] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
return ps
}
// Bug A — per-hash evidence median must EXCLUDE the 705-day RTC-reset outlier.
// On master this asserts on the RTC-reset hash's MedianCorrectedSkewSec being
// ≈ 60M sec ("704d 18h"); after the fix the field is suppressed (0) or
// otherwise marked insufficient, never displayed as the garbage value.
func TestIssue1285_HashEvidence_OutlierExcludedFromMedian(t *testing.T) {
ps := seedIssue1285Repro(t)
r := ps.GetNodeClockSkew("RTCRESET")
if r == nil {
t.Fatal("expected clock skew result")
}
// The recent-hash evidence list is the source of the "median corrected:
// 704d 18h" string in the UI. After the fix, NO entry in this list
// should report a |median| above the 24h sanity threshold — the fix is
// to drop outlier samples (or flag the hash as insufficient-data) before
// publishing the median.
const maxSaneAbsSec = float64(24 * 3600)
for _, ev := range r.RecentHashEvidence {
if abs(ev.MedianCorrectedSkewSec) > maxSaneAbsSec {
t.Errorf("hash %s exposes outlier-dominated median %.0fs (~%.1fd); "+
"expected entry to be filtered out or marked insufficient (|median| <= %.0fs)",
ev.Hash, ev.MedianCorrectedSkewSec,
ev.MedianCorrectedSkewSec/86400, maxSaneAbsSec)
}
}
}
// Bug B — RecentBadSampleCount must be 0 when every sample in the recent
// window is healthy (<30s |corrected skew|). On master this fires because
// "recent" is computed over the wrong set (or against raw skew).
func TestIssue1285_RecentBadCount_NotPollutedByOldOutlier(t *testing.T) {
ps := seedIssue1285Repro(t)
r := ps.GetNodeClockSkew("RTCRESET")
if r == nil {
t.Fatal("expected clock skew result")
}
if r.RecentBadSampleCount != 0 {
t.Errorf("RecentBadSampleCount = %d, want 0 — recent samples are all "+
"~20s (healthy); the historical RTC-reset outlier is outside the "+
"recent window and must not be counted", r.RecentBadSampleCount)
}
if r.Severity == SkewBimodalClock || r.Severity == SkewNoClock {
t.Errorf("severity = %v, want ok/warning — recent samples are all "+
"healthy (~20s skew), one historical outlier must not flip the node "+
"to bimodal/no-clock", r.Severity)
}
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
-101
View File
@@ -1,101 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"sort"
"sync"
"testing"
"time"
)
// Issue #1265: /api/observers/clock-skew (3.3s) and /api/nodes/clock-skew (8.9s)
// must be wired into the steady-state analytics recomputer so reads serve
// from an atomic-pointer snapshot in <100ms p99 under concurrent load.
func TestClockSkewRecomputersRegistered(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
defer stop()
time.Sleep(100 * time.Millisecond)
store.analyticsRecomputerMu.RLock()
rcObs := store.recompObserversClockSkew
rcNodes := store.recompNodesClockSkew
store.analyticsRecomputerMu.RUnlock()
if rcObs == nil {
t.Fatalf("recompObserversClockSkew not registered after StartAnalyticsRecomputers (issue #1265 not fixed)")
}
if rcNodes == nil {
t.Fatalf("recompNodesClockSkew not registered after StartAnalyticsRecomputers (issue #1265 not fixed)")
}
if rcObs.Load() == nil {
t.Fatalf("recompObserversClockSkew snapshot is nil after initial compute")
}
if rcNodes.Load() == nil {
t.Fatalf("recompNodesClockSkew snapshot is nil after initial compute")
}
}
func TestClockSkewHandlersSteadyStateLatency(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
defer stop()
time.Sleep(100 * time.Millisecond)
s := &Server{store: store}
endpoints := []struct {
name string
path string
handler http.HandlerFunc
}{
{"observers", "/api/observers/clock-skew", s.handleObserverClockSkew},
{"nodes", "/api/nodes/clock-skew", s.handleFleetClockSkew},
}
for _, ep := range endpoints {
ep := ep
t.Run(ep.name, func(t *testing.T) {
const readers = 8
const perReader = 25
var (
mu sync.Mutex
samples []time.Duration
wg sync.WaitGroup
)
wg.Add(readers)
for r := 0; r < readers; r++ {
go func() {
defer wg.Done()
for i := 0; i < perReader; i++ {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ep.path, nil)
t0 := time.Now()
ep.handler(rr, req)
dt := time.Since(t0)
if rr.Code != http.StatusOK {
t.Errorf("%s status = %d, want 200", ep.path, rr.Code)
}
mu.Lock()
samples = append(samples, dt)
mu.Unlock()
}
}()
}
wg.Wait()
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
p99 := samples[int(float64(len(samples))*0.99)]
if p99 > 100*time.Millisecond {
t.Fatalf("%s p99 latency = %v over %d reqs, want <100ms (recomputer snapshot)", ep.path, p99, len(samples))
}
})
}
}
+2 -513
View File
@@ -191,7 +191,7 @@ func TestComputeNodeSkew_BasicCorrection(t *testing.T) {
// So the median approach finds obs2 is +5 ahead (relative to median)
// Now compute node skew with those offsets:
nodeSkew, _ := computeNodeSkew(samples, offsets)
nodeSkew := computeNodeSkew(samples, offsets)
cs, ok := nodeSkew["h1"]
if !ok {
t.Fatal("expected skew data for hash h1")
@@ -220,7 +220,7 @@ func TestComputeNodeSkew_ThreeObservers(t *testing.T) {
t.Errorf("obs3 offset = %v, want 30", offsets["obs3"])
}
nodeSkew, _ := computeNodeSkew(samples, offsets)
nodeSkew := computeNodeSkew(samples, offsets)
cs := nodeSkew["h1"]
if cs == nil {
t.Fatal("expected skew data for h1")
@@ -544,514 +544,3 @@ func TestGetNodeClockSkew_NormalNodeWithDrift(t *testing.T) {
func formatInt64(n int64) string {
return fmt.Sprintf("%d", n)
}
// ── #789: Recent-window severity & robust drift ───────────────────────────────
// TestSeverityUsesRecentNotMedian: 100 historical bad samples (skew=-60s,
// each ~5min apart) followed by 5 fresh good samples (skew=-1s). All-time
// median is still huge-ish but recent-window severity must reflect the
// current healthy state.
func TestSeverityUsesRecentNotMedian(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
for i := 0; i < 105; i++ {
obsTS := baseObs + int64(i)*300 // 5 min apart
var skew int64 = -60
if i >= 100 {
skew = -1 // good samples at the tail
}
advTS := obsTS + skew
tx := &StoreTx{
Hash: fmt.Sprintf("recent-h%03d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["RECENT"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("RECENT")
if r == nil {
t.Fatal("nil result")
}
if r.Severity != SkewOK {
t.Errorf("severity = %v, want ok (recent samples are healthy)", r.Severity)
}
if math.Abs(r.RecentMedianSkewSec) > 5 {
t.Errorf("recentMedianSkewSec = %v, want ~-1", r.RecentMedianSkewSec)
}
// Historical median should still be retained for context.
if math.Abs(r.MedianSkewSec) < 30 {
t.Errorf("medianSkewSec = %v, expected historical median to remain large", r.MedianSkewSec)
}
}
// TestDriftRejectsCorrectionJump: 30 minutes of clean linear drift, then a
// single 60-second skew jump. The pre-jump slope should win — drift must
// not be catastrophically inflated by the correction event.
func TestDriftRejectsCorrectionJump(t *testing.T) {
pairs := []tsSkewPair{}
// 30 min of stable, ~12 sec/day drift: 1s per 7200s.
for i := 0; i < 12; i++ {
ts := int64(i) * 300
skew := float64(i) * (1.0 / 24.0) // ~0.04s per 5min step → 12 s/day
pairs = append(pairs, tsSkewPair{ts: ts, skew: skew})
}
// Wait an hour, then a single 1000-sec correction jump (clearly outlier).
pairs = append(pairs, tsSkewPair{ts: 3600 + 12*300, skew: 1000})
drift := computeDrift(pairs)
// Without rejection this would be ~ (1000-0)/(end-0) * 86400 = enormous.
if math.Abs(drift) > 100 {
t.Errorf("drift = %v, expected small (~12 s/day), correction jump should be filtered", drift)
}
}
// TestTheilSenMatchesOLSWhenClean: on clean linear data Theil-Sen should
// produce essentially the OLS answer.
func TestTheilSenMatchesOLSWhenClean(t *testing.T) {
// 1 sec drift per hour = 24 sec/day, 20 evenly-spaced samples.
pairs := []tsSkewPair{}
for i := 0; i < 20; i++ {
pairs = append(pairs, tsSkewPair{
ts: int64(i) * 600,
skew: float64(i) * (600.0 / 3600.0),
})
}
drift := computeDrift(pairs)
if math.Abs(drift-24.0) > 0.25 { // ~1%
t.Errorf("drift = %v, want ~24", drift)
}
}
// TestReporterScenario_789: reproduce the exact scenario from issue #789.
// Reporter saw mean=-52565156, median=-59063561, last=-0.8, sample count
// 1662, drift +1793549.9 s/day, severity=absurd. After the fix, severity
// must be ok (recent samples are healthy) and drift must be sane.
func TestReporterScenario_789(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
// 1657 samples with the bad ~-683-day skew (the historical poison),
// then 5 freshly corrected samples at -0.8s — totals 1662.
for i := 0; i < 1662; i++ {
obsTS := baseObs + int64(i)*60 // 1 min apart
var skew int64
if i < 1657 {
skew = -59063561 // ~ -683 days
} else {
skew = -1 // corrected (rounded; reporter saw -0.8)
}
advTS := obsTS + skew
tx := &StoreTx{
Hash: fmt.Sprintf("rep-%04d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["REPNODE"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("REPNODE")
if r == nil {
t.Fatal("nil result")
}
// Severity must reflect current health, not the all-time median.
if r.Severity != SkewOK && r.Severity != SkewWarning {
t.Errorf("severity = %v, want ok/warning (recent samples are healthy)", r.Severity)
}
if math.Abs(r.RecentMedianSkewSec) > 5 {
t.Errorf("recentMedianSkewSec = %v, want near 0", r.RecentMedianSkewSec)
}
// Drift must not be absurd. The historical jump is one event between
// the 1657th and 1658th sample; outlier rejection must contain it.
if math.Abs(r.DriftPerDaySec) > maxReasonableDriftPerDay {
t.Errorf("drift = %v, must be <= cap %v", r.DriftPerDaySec, maxReasonableDriftPerDay)
}
// And it should be close to zero (stable historical + stable corrected).
if math.Abs(r.DriftPerDaySec) > 1000 {
t.Errorf("drift = %v, expected near zero after outlier rejection", r.DriftPerDaySec)
}
// Historical median is preserved as context.
if math.Abs(r.MedianSkewSec) < 1e6 {
t.Errorf("medianSkewSec = %v, expected historical poison preserved as context", r.MedianSkewSec)
}
}
// TestBimodalClock_845: 60% good samples → bimodal_clock severity.
func TestBimodalClock_845(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
// 6 good samples (-5s each), 4 bad samples (-7200s each) = 60% good
// Interleave so the recent window (last 5) captures both good and bad.
skews := []int64{-5, -5, -7200, -5, -7200, -5, -7200, -5, -7200, -5}
for i := 0; i < 10; i++ {
obsTS := baseObs + int64(i)*60
advTS := obsTS + skews[i]
tx := &StoreTx{
Hash: fmt.Sprintf("bimodal-%04d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["BIMODAL"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("BIMODAL")
if r == nil {
t.Fatal("nil result")
}
if r.Severity != SkewBimodalClock {
t.Errorf("severity = %v, want bimodal_clock", r.Severity)
}
if math.Abs(r.RecentMedianSkewSec-(-5)) > 1 {
t.Errorf("recentMedianSkewSec = %v, want ≈ -5 (median of good samples)", r.RecentMedianSkewSec)
}
if r.GoodFraction < 0.5 || r.GoodFraction > 0.7 {
t.Errorf("goodFraction = %v, want ~0.6", r.GoodFraction)
}
if r.RecentBadSampleCount < 1 {
t.Errorf("recentBadSampleCount = %v, want > 0", r.RecentBadSampleCount)
}
}
// TestAllBad_NoClock_845: all samples bad → no_clock.
func TestAllBad_NoClock_845(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
for i := 0; i < 10; i++ {
obsTS := baseObs + int64(i)*60
advTS := obsTS - 50000000
tx := &StoreTx{
Hash: fmt.Sprintf("allbad-%04d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["ALLBAD"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("ALLBAD")
if r == nil {
t.Fatal("nil result")
}
if r.Severity != SkewNoClock {
t.Errorf("severity = %v, want no_clock", r.Severity)
}
}
// TestMostlyGood_OK_845: 90% good 10% bad → ok (outlier filtered).
func TestMostlyGood_OK_845(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
// 9 good at -5s, 1 bad at -7200s
for i := 0; i < 10; i++ {
obsTS := baseObs + int64(i)*60
var skew int64
if i < 9 {
skew = -5
} else {
skew = -7200
}
advTS := obsTS + skew
tx := &StoreTx{
Hash: fmt.Sprintf("mostly-%04d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["MOSTLY"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("MOSTLY")
if r == nil {
t.Fatal("nil result")
}
// 90% good → normal classification path, median of good samples = -5s → ok
if r.Severity != SkewOK {
t.Errorf("severity = %v, want ok", r.Severity)
}
if math.Abs(r.RecentMedianSkewSec-(-5)) > 1 {
t.Errorf("recentMedianSkewSec = %v, want ≈ -5", r.RecentMedianSkewSec)
}
}
// TestSingleSample_845: one good sample → ok.
func TestSingleSample_845(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
obsTS := int64(1700000000)
advTS := obsTS - 30 // 30s skew
tx := &StoreTx{
Hash: "single-0001",
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
ps.mu.Lock()
ps.byNode["SINGLE"] = []*StoreTx{tx}
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("SINGLE")
if r == nil {
t.Fatal("nil result")
}
if r.Severity != SkewOK {
t.Errorf("severity = %v, want ok", r.Severity)
}
if r.RecentSampleCount != 1 {
t.Errorf("recentSampleCount = %d, want 1", r.RecentSampleCount)
}
if r.GoodFraction != 1.0 {
t.Errorf("goodFraction = %v, want 1.0", r.GoodFraction)
}
}
// TestFiftyFifty_Bimodal_845: 50% good / 50% bad → bimodal_clock.
func TestFiftyFifty_Bimodal_845(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
for i := 0; i < 10; i++ {
obsTS := baseObs + int64(i)*60
var skew int64
if i%2 == 0 {
skew = -10
} else {
skew = -7200
}
tx := &StoreTx{
Hash: fmt.Sprintf("fifty-%04d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(obsTS+skew) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["FIFTY"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("FIFTY")
if r == nil {
t.Fatal("nil result")
}
if r.Severity != SkewBimodalClock {
t.Errorf("severity = %v, want bimodal_clock", r.Severity)
}
if r.GoodFraction < 0.4 || r.GoodFraction > 0.6 {
t.Errorf("goodFraction = %v, want ~0.5", r.GoodFraction)
}
}
// TestAllGood_OK_845: all samples good → ok, no bimodal.
func TestAllGood_OK_845(t *testing.T) {
ps := NewPacketStore(nil, nil)
pt := 4
baseObs := int64(1700000000)
var txs []*StoreTx
for i := 0; i < 10; i++ {
obsTS := baseObs + int64(i)*60
tx := &StoreTx{
Hash: fmt.Sprintf("allgood-%04d", i),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(obsTS-3) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
}
txs = append(txs, tx)
}
ps.mu.Lock()
ps.byNode["ALLGOOD"] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("ALLGOOD")
if r == nil {
t.Fatal("nil result")
}
if r.Severity != SkewOK {
t.Errorf("severity = %v, want ok", r.Severity)
}
if r.GoodFraction != 1.0 {
t.Errorf("goodFraction = %v, want 1.0", r.GoodFraction)
}
if r.RecentBadSampleCount != 0 {
t.Errorf("recentBadSampleCount = %v, want 0", r.RecentBadSampleCount)
}
}
func TestNodeClockSkew_EvidencePayload(t *testing.T) {
// 3-observer scenario: obs1 ahead by +2s, obs2 on time, obs3 behind by -1s.
// Node clock is 60s ahead. Raw skew = advertTS - obsTS.
// Hash has 3 observations, each observer sees same advert.
ps := NewPacketStore(nil, nil)
pt := 4 // ADVERT
// Advert timestamp: 1700000060 (node 60s ahead of true time 1700000000)
// obs1 sees at 1700000002 (2s ahead of true time) → raw = 60 - 2 = 58
// obs2 sees at 1700000000 (on time) → raw = 60 - 0 = 60
// obs3 sees at 1699999999 (-1s, behind) → raw = 60 + 1 = 61
// Median obsTS = 1700000000, so:
// obs1 offset = 1700000002 - 1700000000 = +2
// obs2 offset = 0
// obs3 offset = 1699999999 - 1700000000 = -1
// Corrected: raw + offset → obs1: 58+2=60, obs2: 60+0=60, obs3: 61+(-1)=60
tx1 := &StoreTx{
Hash: "evidence_hash_1",
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":1700000060}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", ObserverName: "Observer Alpha", Timestamp: "2023-11-14T22:13:22Z"},
{ObserverID: "obs2", ObserverName: "Observer Beta", Timestamp: "2023-11-14T22:13:20Z"},
{ObserverID: "obs3", ObserverName: "Observer Gamma", Timestamp: "2023-11-14T22:13:19Z"},
},
}
// Second hash to ensure we get multiple evidence entries.
tx2 := &StoreTx{
Hash: "evidence_hash_2",
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":1700003660}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", ObserverName: "Observer Alpha", Timestamp: "2023-11-14T23:13:22Z"},
{ObserverID: "obs2", ObserverName: "Observer Beta", Timestamp: "2023-11-14T23:13:20Z"},
{ObserverID: "obs3", ObserverName: "Observer Gamma", Timestamp: "2023-11-14T23:13:19Z"},
},
}
ps.mu.Lock()
ps.byNode["NODETEST"] = []*StoreTx{tx1, tx2}
ps.byPayloadType[4] = []*StoreTx{tx1, tx2}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
r := ps.GetNodeClockSkew("NODETEST")
if r == nil {
t.Fatal("expected clock skew result")
}
// Check recentHashEvidence exists.
if len(r.RecentHashEvidence) == 0 {
t.Fatal("expected recentHashEvidence to be populated")
}
if len(r.RecentHashEvidence) != 2 {
t.Errorf("recentHashEvidence length = %d, want 2", len(r.RecentHashEvidence))
}
// Check first evidence entry has 3 observers.
ev := r.RecentHashEvidence[0]
if len(ev.Observers) != 3 {
t.Fatalf("evidence observers = %d, want 3", len(ev.Observers))
}
// Verify corrected = raw + offset for each observer.
for _, o := range ev.Observers {
expected := o.RawSkewSec + o.ObserverOffsetSec
if math.Abs(o.CorrectedSkewSec-expected) > 0.2 {
t.Errorf("observer %s: corrected=%.1f, expected raw(%.1f)+offset(%.1f)=%.1f",
o.ObserverID, o.CorrectedSkewSec, o.RawSkewSec, o.ObserverOffsetSec, expected)
}
}
// All corrected values should be ~60s (node is 60s ahead).
if math.Abs(ev.MedianCorrectedSkewSec-60) > 1 {
t.Errorf("median corrected = %.1f, want ~60", ev.MedianCorrectedSkewSec)
}
// Check calibration summary.
if r.CalibrationSummary == nil {
t.Fatal("expected calibrationSummary")
}
if r.CalibrationSummary.TotalSamples != 6 { // 3 observers × 2 hashes
t.Errorf("calibration total = %d, want 6", r.CalibrationSummary.TotalSamples)
}
if r.CalibrationSummary.CalibratedSamples != 6 {
t.Errorf("calibrated = %d, want 6 (all multi-observer)", r.CalibrationSummary.CalibratedSamples)
}
// Check observer names are populated.
nameFound := false
for _, o := range ev.Observers {
if o.ObserverName == "Observer Alpha" || o.ObserverName == "Observer Beta" {
nameFound = true
}
}
if !nameFound {
t.Error("expected observer names to be populated from tx observations")
}
}
+2 -2
View File
@@ -33,7 +33,7 @@ func TestCollisionDetailsIncludeNodePairs(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsHashCollisions("", "")
result := store.GetAnalyticsHashCollisions("")
bySize, ok := result["by_size"].(map[string]interface{})
if !ok {
t.Fatal("expected by_size map")
@@ -109,7 +109,7 @@ func TestCollisionDetailsEmptyWhenNoCollisions(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsHashCollisions("", "")
result := store.GetAnalyticsHashCollisions("")
bySize, ok := result["by_size"].(map[string]interface{})
if !ok {
t.Fatal("expected by_size map")
-248
View File
@@ -1,248 +0,0 @@
package main
import (
"bufio"
"compress/gzip"
"net"
"net/http"
"strings"
)
// gzipWriterPool pools *gzip.Writer instances to avoid the ~256KB sliding
// window allocation on every compressed response. Writers are Reset() to the
// new underlying writer on Get and returned via gzipPut after Close.
//
// We use a bounded buffered channel rather than sync.Pool because sync.Pool
// is aggressively reaped by the GC (full clear after two GC cycles), which
// makes it lose its pooled entries under any workload that triggers GC —
// notably the -race-enabled test suite where allocations are inflated ~8x
// and GC fires repeatedly during a 200-request loop. A channel keeps the
// gzip.Writer instances live across GC cycles, which is exactly the
// guarantee `TestGZipMiddleware_PoolReusesWriters` asserts.
const gzipPoolCapacity = 64
var gzipWriterPool = make(chan *gzip.Writer, gzipPoolCapacity)
func gzipGet() *gzip.Writer {
select {
case gz := <-gzipWriterPool:
return gz
default:
// gzip.NewWriterLevel only errors on invalid level; DefaultCompression
// is always valid, so the error branch is unreachable. Fall back to
// the default writer (same level) so we always return a usable writer.
gz, err := gzip.NewWriterLevel(discardWriter{}, gzip.DefaultCompression)
if err != nil {
return gzip.NewWriter(discardWriter{})
}
return gz
}
}
func gzipPut(gz *gzip.Writer) {
// Reset to a no-op writer so the pooled instance does not retain a
// reference to the previous http.ResponseWriter (which would defeat GC
// of the request's allocations).
gz.Reset(discardWriter{})
select {
case gzipWriterPool <- gz:
default:
// Pool full; drop the writer and let GC reclaim it.
}
}
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
// defaultCompressibleTypes is the conservative allow-list of MIME types the
// middleware will gzip-encode. Anything already compressed (images, video,
// fonts, octet-stream, x-gzip, …) bypasses the encoder entirely.
var defaultCompressibleTypes = []string{
"application/json",
"application/javascript",
"application/x-javascript",
"application/xml",
"text/html",
"text/css",
"text/plain",
"text/xml",
"image/svg+xml",
}
// gzipResponseWriter wraps http.ResponseWriter and compresses Write() output
// only when the response Content-Type matches the configured allow-list and
// no upstream handler has already set Content-Encoding. It also propagates
// Flush / Hijack to the underlying writer (required for SSE and WebSocket).
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
level int
allowedTypes []string
wroteHeader bool
compressActive bool
}
// init lazily decides per response whether to compress, based on the response
// headers the inner handler has set. We must defer this until WriteHeader (or
// the first Write call) because Content-Type is set by the handler, not the
// middleware.
func (g *gzipResponseWriter) init() {
if g.wroteHeader {
return
}
g.wroteHeader = true
h := g.ResponseWriter.Header()
// Don't double-encode.
if h.Get("Content-Encoding") != "" {
g.compressActive = false
return
}
if !isCompressibleContentType(h.Get("Content-Type"), g.allowedTypes) {
g.compressActive = false
return
}
// Lease a writer from the pool and rebind it to the real ResponseWriter.
gz := gzipGet()
gz.Reset(g.ResponseWriter)
g.gz = gz
g.compressActive = true
h.Set("Content-Encoding", "gzip")
h.Add("Vary", "Accept-Encoding")
// gzip stream length is unknown — strip any precomputed length.
h.Del("Content-Length")
}
func (g *gzipResponseWriter) WriteHeader(code int) {
g.init()
g.ResponseWriter.WriteHeader(code)
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
g.init()
if !g.compressActive {
return g.ResponseWriter.Write(b)
}
return g.gz.Write(b)
}
// Flush propagates to the underlying writer so SSE / streaming handlers can
// push chunks to the client immediately. We must also flush the gzip writer
// when active, otherwise the buffered DEFLATE block never reaches the wire.
func (g *gzipResponseWriter) Flush() {
if g.compressActive && g.gz != nil {
_ = g.gz.Flush()
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Hijack delegates to the underlying writer's Hijacker. We refuse to hijack a
// connection that has already started a gzip stream — that would leave the
// caller with a half-written DEFLATE block.
func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := g.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// close releases the pooled gzip.Writer back to the pool.
func (g *gzipResponseWriter) close() {
if g.gz == nil {
return
}
_ = g.gz.Close()
gzipPut(g.gz)
g.gz = nil
}
// isCompressibleContentType returns true if ct matches one of allow (which
// is the configured allow-list, or defaultCompressibleTypes). Matching is
// done on the bare MIME type, ignoring any "; charset=..." parameters.
func isCompressibleContentType(ct string, allow []string) bool {
if ct == "" {
// No content-type set → handler hasn't decided yet. Refuse to
// compress; we cannot guess. Most real handlers set Content-Type
// before the first Write.
return false
}
mt := ct
if idx := strings.Index(mt, ";"); idx >= 0 {
mt = mt[:idx]
}
mt = strings.TrimSpace(strings.ToLower(mt))
// Hard skip: anything that is already compressed.
if strings.HasPrefix(mt, "image/") && mt != "image/svg+xml" {
return false
}
if strings.HasPrefix(mt, "video/") || strings.HasPrefix(mt, "audio/") {
return false
}
switch mt {
case "application/x-gzip", "application/gzip", "application/zip",
"application/x-bzip2", "application/x-7z-compressed",
"application/x-rar-compressed", "application/x-zstd",
"application/octet-stream", "application/pdf":
return false
}
if len(allow) == 0 {
allow = defaultCompressibleTypes
}
for _, a := range allow {
if strings.EqualFold(mt, a) {
return true
}
}
return false
}
// gzipMiddleware compresses HTTP responses when the client supports gzip and
// the response Content-Type is in the allow-list. WebSocket upgrade requests
// pass through unmodified. The middleware uses the default allow-list and
// gzip.DefaultCompression — for configurable behaviour use
// gzipMiddlewareWithConfig.
func gzipMiddleware(next http.Handler) http.Handler {
return gzipMiddlewareWithConfig(nil, next)
}
// gzipMiddlewareWithConfig is the configurable form of gzipMiddleware. When
// cfg is nil, defaults (gzip.DefaultCompression, defaultCompressibleTypes)
// are used.
func gzipMiddlewareWithConfig(cfg *CompressionConfig, next http.Handler) http.Handler {
level := gzip.DefaultCompression
var allow []string
if cfg != nil {
if cfg.Level >= gzip.BestSpeed && cfg.Level <= gzip.BestCompression {
level = cfg.Level
}
if len(cfg.ContentTypes) > 0 {
allow = cfg.ContentTypes
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
next.ServeHTTP(w, r)
return
}
grw := &gzipResponseWriter{
ResponseWriter: w,
level: level,
allowedTypes: allow,
}
defer grw.close()
next.ServeHTTP(grw, r)
})
}
-157
View File
@@ -1,157 +0,0 @@
package main
// Tests added in response to PR #934 review findings. These tests demonstrate
// the four behaviors the original implementation lacked:
//
// 1. gzipResponseWriter must implement http.Flusher (SSE / streaming).
// 2. gzipResponseWriter must implement http.Hijacker (WebSocket / raw conn).
// 3. gzip.Writer instances must be pooled (sync.Pool) to avoid the
// ~256KB window allocation per request.
// 4. A content-type allow-list must skip already-compressed payloads
// (images, video, application/x-gzip, …) and must skip responses
// whose handler already set its own Content-Encoding header.
import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
)
func TestGZipResponseWriter_ImplementsFlusher(t *testing.T) {
seen := false
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := w.(http.Flusher); ok {
seen = true
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
req := httptest.NewRequest("GET", "/api/events", nil)
req.Header.Set("Accept-Encoding", "gzip")
handler.ServeHTTP(httptest.NewRecorder(), req)
if !seen {
t.Error("gzipResponseWriter must implement http.Flusher (required for SSE / streaming endpoints)")
}
}
func TestGZipResponseWriter_ImplementsHijacker(t *testing.T) {
seen := false
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := w.(http.Hijacker); ok {
seen = true
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
srv := httptest.NewServer(handler)
defer srv.Close()
req, _ := http.NewRequest("GET", srv.URL+"/api/x", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if !seen {
t.Error("gzipResponseWriter must implement http.Hijacker (required for raw conn / WebSocket upgrade)")
}
}
func TestGZipMiddleware_SkipsImageContentType(t *testing.T) {
payload := strings.Repeat("\x89PNGfakebinary", 64)
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte(payload))
}))
req := httptest.NewRequest("GET", "/tiles/1.png", nil)
req.Header.Set("Accept-Encoding", "gzip")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Header().Get("Content-Encoding"); got == "gzip" {
t.Errorf("image/png responses must NOT be gzip-encoded, got Content-Encoding=%q", got)
}
if rr.Body.String() != payload {
t.Errorf("image body was mutated; expected pass-through")
}
}
func TestGZipMiddleware_SkipsAlreadyEncodedResponses(t *testing.T) {
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Encoding", "br")
w.Write([]byte("alreadybrotlied"))
}))
req := httptest.NewRequest("GET", "/api/x", nil)
req.Header.Set("Accept-Encoding", "gzip")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Header().Get("Content-Encoding"); got != "br" {
t.Errorf("handler-set Content-Encoding must be preserved, got %q (gzip middleware double-wrapped)", got)
}
}
func TestGZipMiddleware_AllowsJSON(t *testing.T) {
body := `{"nodes":[{"id":"abc"}]}`
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write([]byte(body))
}))
req := httptest.NewRequest("GET", "/api/nodes", nil)
req.Header.Set("Accept-Encoding", "gzip")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("application/json must still be compressed, got %q", rr.Header().Get("Content-Encoding"))
}
gz, err := gzip.NewReader(rr.Body)
if err != nil {
t.Fatalf("invalid gzip: %v", err)
}
defer gz.Close()
decoded, _ := io.ReadAll(gz)
if string(decoded) != body {
t.Errorf("decoded=%q, want %q", string(decoded), body)
}
}
func TestGZipMiddleware_PoolReusesWriters(t *testing.T) {
body := strings.Repeat("x", 1024)
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}))
// Warm the pool: first N requests pay the one-time allocation cost.
for i := 0; i < 16; i++ {
req := httptest.NewRequest("GET", "/api", nil)
req.Header.Set("Accept-Encoding", "gzip")
handler.ServeHTTP(httptest.NewRecorder(), req)
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
const N = 200
for i := 0; i < N; i++ {
req := httptest.NewRequest("GET", "/api", nil)
req.Header.Set("Accept-Encoding", "gzip")
handler.ServeHTTP(httptest.NewRecorder(), req)
}
var after runtime.MemStats
runtime.ReadMemStats(&after)
allocBytes := after.TotalAlloc - before.TotalAlloc
// Each gzip.Writer carries a ~256KB sliding window. Without a sync.Pool,
// N=200 requests allocate roughly N * 256KB = 50MB. With a pool the
// per-request alloc footprint should be a tiny fraction of that.
// 10MB ceiling gives generous headroom for testing.AllocsPerRun noise
// while still catching a regression to the unpooled implementation.
if allocBytes > 10*1024*1024 {
t.Errorf("gzip.Writer not pooled: %d bytes allocated across %d requests (expected ≤10MB)", allocBytes, N)
}
}
-109
View File
@@ -1,109 +0,0 @@
package main
import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestCompressionConfigDefaults(t *testing.T) {
cfg := &Config{}
if cfg.GZipEnabled() {
t.Error("GZipEnabled should be false when compression is nil")
}
if cfg.WSCompressionEnabled() {
t.Error("WSCompressionEnabled should be false when compression is nil")
}
}
func TestCompressionConfigExplicitFalse(t *testing.T) {
cfg := &Config{Compression: &CompressionConfig{GZip: false, Websocket: false}}
if cfg.GZipEnabled() {
t.Error("GZipEnabled should be false")
}
if cfg.WSCompressionEnabled() {
t.Error("WSCompressionEnabled should be false")
}
}
func TestCompressionConfigEnabled(t *testing.T) {
cfg := &Config{Compression: &CompressionConfig{GZip: true, Websocket: true}}
if !cfg.GZipEnabled() {
t.Error("GZipEnabled should be true")
}
if !cfg.WSCompressionEnabled() {
t.Error("WSCompressionEnabled should be true")
}
}
func TestGZipMiddlewareCompresses(t *testing.T) {
body := `{"nodes":[{"id":"abc"}]}`
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}))
req := httptest.NewRequest("GET", "/api/nodes", nil)
req.Header.Set("Accept-Encoding", "gzip")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("Content-Encoding") != "gzip" {
t.Errorf("expected Content-Encoding: gzip, got %q", rr.Header().Get("Content-Encoding"))
}
if rr.Header().Get("Vary") != "Accept-Encoding" {
t.Errorf("expected Vary: Accept-Encoding, got %q", rr.Header().Get("Vary"))
}
gz, err := gzip.NewReader(rr.Body)
if err != nil {
t.Fatalf("response is not valid gzip: %v", err)
}
defer gz.Close()
decoded, err := io.ReadAll(gz)
if err != nil {
t.Fatalf("reading gzip: %v", err)
}
if string(decoded) != body {
t.Errorf("decompressed body = %q, want %q", string(decoded), body)
}
}
func TestGZipMiddlewareSkipsNoAcceptEncoding(t *testing.T) {
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
}))
req := httptest.NewRequest("GET", "/api/nodes", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Header().Get("Content-Encoding") != "" {
t.Errorf("expected no Content-Encoding, got %q", rr.Header().Get("Content-Encoding"))
}
if rr.Body.String() != "hello" {
t.Errorf("expected plain body, got %q", rr.Body.String())
}
}
func TestGZipMiddlewareSkipsWebSocket(t *testing.T) {
called := false
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.Write([]byte("ws"))
}))
req := httptest.NewRequest("GET", "/ws", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("Upgrade", "websocket")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !called {
t.Error("expected next handler to be called")
}
if rr.Header().Get("Content-Encoding") != "" {
t.Errorf("WebSocket should not be gzip-encoded, got %q", rr.Header().Get("Content-Encoding"))
}
}
+6 -295
View File
@@ -2,45 +2,20 @@ package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/meshcore-analyzer/dbconfig"
"github.com/meshcore-analyzer/geofilter"
)
// AreaEntry defines a geographic area by polygon or bounding box.
type AreaEntry struct {
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"`
}
// Config mirrors the Node.js config.json structure (read-only fields).
type Config struct {
Port int `json:"port"`
APIKey string `json:"apiKey"`
DBPath string `json:"dbPath"`
// NodeBlacklist is a list of public keys to exclude from all API responses.
// Blacklisted nodes are hidden from node lists, search, detail, map, and stats.
// Use this to filter out trolls, nodes with offensive names, or nodes
// reporting deliberately false data (e.g. wrong GPS position) that the
// operator refuses to fix.
NodeBlacklist []string `json:"nodeBlacklist"`
// blacklistSetCached is the lazily-built set version of NodeBlacklist.
blacklistSetCached map[string]bool
blacklistOnce sync.Once
Branding map[string]interface{} `json:"branding"`
Theme map[string]interface{} `json:"theme"`
ThemeDark map[string]interface{} `json:"themeDark"`
@@ -75,41 +50,16 @@ type Config struct {
Retention *RetentionConfig `json:"retention,omitempty"`
DB *DBConfig `json:"db,omitempty"`
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
Areas map[string]AreaEntry `json:"areas,omitempty"`
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
// CORSAllowedOrigins is the list of origins permitted to make cross-origin
// requests. When empty (default), no Access-Control-* headers are sent,
// so browsers enforce same-origin policy. Set to ["*"] to allow all origins.
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
// ObserverBlacklist is a list of observer public keys to exclude from API
// responses (defense in depth — ingestor drops at ingest, server filters
// any that slipped through from a prior unblocked window).
ObserverBlacklist []string `json:"observerBlacklist,omitempty"`
// obsBlacklistSetCached is the lazily-built set version of ObserverBlacklist.
obsBlacklistSetCached map[string]bool
obsBlacklistOnce sync.Once
Compression *CompressionConfig `json:"compression,omitempty"`
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
// Analytics steady-state background recompute (issue #1240).
Analytics *AnalyticsConfig `json:"analytics,omitempty"`
// BatteryThresholds: voltage cutoffs for low/critical alerts (#663).
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
}
// weakAPIKeys is the blocklist of known default/example API keys that must be rejected.
@@ -140,39 +90,6 @@ func IsWeakAPIKey(key string) bool {
return false
}
// CompressionConfig controls HTTP gzip and WebSocket permessage-deflate compression.
// Both are disabled by default — enable only when the upstream proxy does not already compress.
type CompressionConfig struct {
GZip bool `json:"gzip"`
Websocket bool `json:"websocket"`
// Level is the gzip compression level (1=BestSpeed … 9=BestCompression).
// 0 / out-of-range means "use compress/gzip.DefaultCompression".
Level int `json:"level,omitempty"`
// MinSizeBytes is an advisory minimum response size below which gzip
// would not pay off. Currently informational — kept here so operators
// can express intent and so future small-body fast-paths can use it.
MinSizeBytes int `json:"minSizeBytes,omitempty"`
// ContentTypes overrides the default compressible-MIME allow-list. When
// empty, a conservative default (application/json, text/html, text/css,
// application/javascript, text/plain, image/svg+xml, application/xml)
// is used. Already-compressed types (image/*, video/*, application/zip,
// application/x-gzip, …) are always skipped.
ContentTypes []string `json:"contentTypes,omitempty"`
}
// GZipEnabled returns true when HTTP gzip compression is explicitly enabled.
func (c *Config) GZipEnabled() bool {
return c.Compression != nil && c.Compression.GZip
}
// WSCompressionEnabled returns true when WebSocket permessage-deflate is explicitly enabled.
func (c *Config) WSCompressionEnabled() bool {
return c.Compression != nil && c.Compression.Websocket
}
// ResolvedPathConfig controls async backfill behavior.
type ResolvedPathConfig struct {
BackfillHours int `json:"backfillHours"` // how far back (hours) to scan for NULL resolved_path (default 24)
@@ -180,37 +97,22 @@ type ResolvedPathConfig struct {
// NeighborGraphConfig controls neighbor edge pruning.
type NeighborGraphConfig struct {
MaxAgeDays int `json:"maxAgeDays"` // edges older than this are pruned (default 5)
MaxEdgeKm float64 `json:"maxEdgeKm"` // geo-implausibility threshold (km); 0 = default 500; negative disables (#1228)
MaxAgeDays int `json:"maxAgeDays"` // edges older than this are pruned (default 5)
}
// PacketStoreConfig controls in-memory packet store limits.
type PacketStoreConfig struct {
RetentionHours float64 `json:"retentionHours"` // max age of packets in hours (0 = unlimited)
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
MaxResolvedPubkeyIndexEntries int `json:"maxResolvedPubkeyIndexEntries"` // warning threshold for index size (0 = 5M default)
HotStartupHours float64 `json:"hotStartupHours"` // load only this many hours synchronously; 0 = disabled
RetentionHours float64 `json:"retentionHours"` // max age of packets in hours (0 = unlimited)
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
}
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
ObserverDays int `json:"observerDays"`
PacketDays int `json:"packetDays"`
MetricsDays int `json:"metricsDays"`
}
// DBConfig is the shared SQLite vacuum/maintenance config (#919, #921).
type DBConfig = dbconfig.DBConfig
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
func (c *Config) IncrementalVacuumPages() int {
if c.DB != nil && c.DB.IncrementalVacuumPages > 0 {
return c.DB.IncrementalVacuumPages
}
return 1024
NodeDays int `json:"nodeDays"`
PacketDays int `json:"packetDays"`
MetricsDays int `json:"metricsDays"`
}
// MetricsRetentionDays returns configured metrics retention or 30 days default.
@@ -237,19 +139,6 @@ func (c *Config) NeighborMaxAgeDays() int {
return 5
}
// NeighborMaxEdgeKm returns the geo-implausibility threshold in km.
// 0 (unset) → DefaultMaxEdgeKm (500). Negative → 0 (filter disabled).
// See issue #1228.
func (c *Config) NeighborMaxEdgeKm() float64 {
if c == nil || c.NeighborGraph == nil || c.NeighborGraph.MaxEdgeKm == 0 {
return DefaultMaxEdgeKm
}
if c.NeighborGraph.MaxEdgeKm < 0 {
return 0
}
return c.NeighborGraph.MaxEdgeKm
}
type TimestampConfig struct {
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
Timezone string `json:"timezone"` // "local" | "utc"
@@ -276,24 +165,11 @@ func (c *Config) NodeDaysOrDefault() int {
return 7
}
// ObserverDaysOrDefault returns the configured retention.observerDays or 14 if not set.
// A value of -1 means observers are never removed.
func (c *Config) ObserverDaysOrDefault() int {
if c.Retention != nil && c.Retention.ObserverDays != 0 {
return c.Retention.ObserverDays
}
return 14
}
type HealthThresholds struct {
InfraDegradedHours float64 `json:"infraDegradedHours"`
InfraSilentHours float64 `json:"infraSilentHours"`
NodeDegradedHours float64 `json:"nodeDegradedHours"`
NodeSilentHours float64 `json:"nodeSilentHours"`
// RelayActiveHours: how recent a path-hop appearance must be for a
// repeater to be considered "actively relaying" vs only "alive
// (advert-only)". See issue #662. Defaults to 24h.
RelayActiveHours float64 `json:"relayActiveHours"`
}
// ThemeFile mirrors theme.json overlay.
@@ -362,7 +238,6 @@ func (c *Config) GetHealthThresholds() HealthThresholds {
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
RelayActiveHours: 24,
}
if c.HealthThresholds != nil {
if c.HealthThresholds.InfraDegradedHours > 0 {
@@ -377,9 +252,6 @@ func (c *Config) GetHealthThresholds() HealthThresholds {
if c.HealthThresholds.NodeSilentHours > 0 {
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
}
if c.HealthThresholds.RelayActiveHours > 0 {
h.RelayActiveHours = c.HealthThresholds.RelayActiveHours
}
}
return h
}
@@ -466,164 +338,3 @@ func (c *Config) PropagationBufferMs() int {
}
return 5000
}
// blacklistSet lazily builds and caches the nodeBlacklist as a set for O(1) lookups.
// Uses sync.Once to eliminate the data race on first concurrent access.
func (c *Config) blacklistSet() map[string]bool {
c.blacklistOnce.Do(func() {
if len(c.NodeBlacklist) == 0 {
return
}
m := make(map[string]bool, len(c.NodeBlacklist))
for _, pk := range c.NodeBlacklist {
trimmed := strings.ToLower(strings.TrimSpace(pk))
if trimmed != "" {
m[trimmed] = true
}
}
c.blacklistSetCached = m
})
return c.blacklistSetCached
}
// IsBlacklisted returns true if the given public key is in the nodeBlacklist.
func (c *Config) IsBlacklisted(pubkey string) bool {
if c == nil || len(c.NodeBlacklist) == 0 {
return false
}
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
}
// SaveGeoFilter writes the geo_filter section back to config.json on disk.
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
var configPath string
for _, p := range []string{
filepath.Join(configDir, "config.json"),
filepath.Join(configDir, "data", "config.json"),
} {
if _, err := os.Stat(p); err == nil {
configPath = p
break
}
}
if configPath == "" {
return fmt.Errorf("config.json not found in %s", configDir)
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
// Parse as a raw map so non-struct fields (_comment, etc.) are preserved.
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parse config: %w", err)
}
if gf == nil || len(gf.Polygon) == 0 {
delete(raw, "geo_filter")
} else {
// Round-trip through JSON to get a plain interface{} value.
b, _ := json.Marshal(gf)
var v interface{}
_ = json.Unmarshal(b, &v)
raw["geo_filter"] = v
}
out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
out = append(out, '\n')
// Atomic write: temp file + rename.
tmp := configPath + ".tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := os.Rename(tmp, configPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename config: %w", err)
}
return nil
}
// obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups.
func (c *Config) obsBlacklistSet() map[string]bool {
c.obsBlacklistOnce.Do(func() {
if len(c.ObserverBlacklist) == 0 {
return
}
m := make(map[string]bool, len(c.ObserverBlacklist))
for _, pk := range c.ObserverBlacklist {
trimmed := strings.ToLower(strings.TrimSpace(pk))
if trimmed != "" {
m[trimmed] = true
}
}
c.obsBlacklistSetCached = m
})
return c.obsBlacklistSetCached
}
// IsObserverBlacklisted returns true if the given observer ID is in the observerBlacklist.
func (c *Config) IsObserverBlacklisted(id string) bool {
if c == nil || len(c.ObserverBlacklist) == 0 {
return false
}
return c.obsBlacklistSet()[strings.ToLower(strings.TrimSpace(id))]
}
// AnalyticsConfig controls steady-state background recompute of
// analytics endpoints (issue #1240).
//
// DefaultIntervalSeconds applies to every endpoint that does not have
// an explicit per-endpoint override in RecomputeIntervalSeconds. The
// project default is 300 (5 minutes): the operator's guiding principle
// is "serving slightly stale data quickly is better than real-time
// data slowly." Lower values give fresher data at higher CPU cost.
//
// RecomputeIntervalSeconds keys (all optional):
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
type AnalyticsConfig struct {
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
}
// AnalyticsDefaultRecomputeInterval returns the configured default
// recompute interval, or 5 minutes if unset/invalid.
func (c *Config) AnalyticsDefaultRecomputeInterval() time.Duration {
if c != nil && c.Analytics != nil && c.Analytics.DefaultIntervalSeconds > 0 {
return time.Duration(c.Analytics.DefaultIntervalSeconds) * time.Second
}
return 5 * time.Minute
}
// AnalyticsRecomputeIntervals returns the per-endpoint override map.
// Returns the zero value (all defaults) if the analytics block is
// absent or empty.
func (c *Config) AnalyticsRecomputeIntervals() AnalyticsRecomputeIntervals {
out := AnalyticsRecomputeIntervals{}
if c == nil || c.Analytics == nil || c.Analytics.RecomputeIntervalSeconds == nil {
return out
}
get := func(key string) time.Duration {
v, ok := c.Analytics.RecomputeIntervalSeconds[key]
if !ok || v <= 0 {
return 0
}
return time.Duration(v) * time.Second
}
out.Topology = get("topology")
out.RF = get("rf")
out.Distance = get("distance")
out.Channels = get("channels")
out.HashCollisions = get("hashCollisions")
out.HashSizes = get("hashSizes")
out.Roles = get("roles")
out.ObserversClockSkew = get("observersClockSkew")
out.NodesClockSkew = get("nodesClockSkew")
return out
}
+107
View File
@@ -1,8 +1,12 @@
package main
import (
"database/sql"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
func TestBackfillHoursDefault(t *testing.T) {
@@ -68,3 +72,106 @@ func TestGraphPruneOlderThan(t *testing.T) {
}
}
func TestPruneNeighborEdgesDB(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
defer db.Close()
_, err = db.Exec(`CREATE TABLE neighbor_edges (
node_a TEXT NOT NULL,
node_b TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_seen TEXT,
PRIMARY KEY (node_a, node_b)
)`)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
old := now.Add(-60 * 24 * time.Hour)
db.Exec("INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 5, ?)",
"aaa", "bbb", now.Format(time.RFC3339))
db.Exec("INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 3, ?)",
"ccc", "ddd", old.Format(time.RFC3339))
g := NewNeighborGraph()
g.upsertEdge("aaa", "bbb", "bb", "obs1", nil, now)
g.upsertEdge("ccc", "ddd", "dd", "obs1", nil, old)
pruned, err := PruneNeighborEdges(dbPath, g, 30)
if err != nil {
t.Fatal(err)
}
if pruned != 1 {
t.Errorf("PruneNeighborEdges pruned %d DB rows, want 1", pruned)
}
var count int
db.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&count)
if count != 1 {
t.Errorf("expected 1 row in DB after prune, got %d", count)
}
if len(g.AllEdges()) != 1 {
t.Errorf("expected 1 in-memory edge after prune, got %d", len(g.AllEdges()))
}
}
func TestBackfillRespectsHourWindow(t *testing.T) {
store := &PacketStore{}
now := time.Now().UTC()
oldTime := now.Add(-48 * time.Hour).Format(time.RFC3339Nano)
newTime := now.Add(-30 * time.Minute).Format(time.RFC3339Nano)
store.packets = []*StoreTx{
{
ID: 1,
Hash: "old-hash",
FirstSeen: oldTime,
Observations: []*StoreObs{
{ID: 1, PathJSON: `["abc"]`},
},
},
{
ID: 2,
Hash: "new-hash",
FirstSeen: newTime,
Observations: []*StoreObs{
{ID: 2, PathJSON: `["def"]`},
},
},
}
// With a 1-hour window, only the new tx should be processed.
// backfillResolvedPathsAsync will find no prefix map and finish quickly,
// but we can verify the pending count reflects the window.
go backfillResolvedPathsAsync(store, "", 100, time.Millisecond, 1)
// Wait for completion
for i := 0; i < 100; i++ {
if store.backfillComplete.Load() {
break
}
time.Sleep(10 * time.Millisecond)
}
if !store.backfillComplete.Load() {
t.Fatal("backfill did not complete")
}
// With no prefix map, total should be 0 (early exit) or just the new one
// The function exits early when pm == nil, so backfillTotal stays at 0
// if there were pending items but no pm. Let's verify it didn't process
// the old one by checking total <= 1.
total := store.backfillTotal.Load()
if total > 1 {
t.Errorf("backfill total = %d, want <= 1 (old tx should be excluded by hour window)", total)
}
}
-22
View File
@@ -365,25 +365,3 @@ func TestPropagationBufferMs(t *testing.T) {
}
})
}
func TestObserverDaysOrDefault(t *testing.T) {
tests := []struct {
name string
cfg *Config
want int
}{
{"nil retention", &Config{}, 14},
{"zero observer days", &Config{Retention: &RetentionConfig{ObserverDays: 0}}, 14},
{"positive value", &Config{Retention: &RetentionConfig{ObserverDays: 30}}, 30},
{"keep forever", &Config{Retention: &RetentionConfig{ObserverDays: -1}}, -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.ObserverDaysOrDefault()
if got != tt.want {
t.Errorf("ObserverDaysOrDefault() = %d, want %d", got, tt.want)
}
})
}
}
-66
View File
@@ -1,66 +0,0 @@
package main
import "net/http"
// corsMiddleware returns a middleware that sets CORS headers based on the
// configured allowed origins. When CORSAllowedOrigins is empty (default),
// no Access-Control-* headers are added, preserving browser same-origin policy.
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origins := s.cfg.CORSAllowedOrigins
if len(origins) == 0 {
next.ServeHTTP(w, r)
return
}
reqOrigin := r.Header.Get("Origin")
if reqOrigin == "" {
next.ServeHTTP(w, r)
return
}
// Check if origin is allowed
allowed := false
wildcard := false
for _, o := range origins {
if o == "*" {
allowed = true
wildcard = true
break
}
if o == reqOrigin {
allowed = true
break
}
}
if !allowed {
// Origin not in allowlist — don't add CORS headers
if r.Method == http.MethodOptions {
// Still reject preflight with 403
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
// Set CORS headers
if wildcard {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", reqOrigin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
-149
View File
@@ -1,149 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
// newTestServerWithCORS creates a minimal Server with the given CORS config.
func newTestServerWithCORS(origins []string) *Server {
cfg := &Config{CORSAllowedOrigins: origins}
srv := &Server{cfg: cfg}
return srv
}
// dummyHandler is a simple handler that writes 200 OK.
var dummyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
func TestCORS_DefaultNoHeaders(t *testing.T) {
srv := newTestServerWithCORS(nil)
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
t.Fatalf("expected no ACAO header, got %q", v)
}
}
func TestCORS_AllowlistMatch(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://good.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" {
t.Fatalf("expected origin echo, got %q", v)
}
if v := rr.Header().Get("Access-Control-Allow-Methods"); v != "GET, POST, OPTIONS" {
t.Fatalf("expected methods header, got %q", v)
}
if v := rr.Header().Get("Access-Control-Allow-Headers"); v != "Content-Type, X-API-Key" {
t.Fatalf("expected headers header, got %q", v)
}
if v := rr.Header().Get("Vary"); v != "Origin" {
t.Fatalf("expected Vary: Origin, got %q", v)
}
}
func TestCORS_AllowlistNoMatch(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
t.Fatalf("expected no ACAO header for non-matching origin, got %q", v)
}
}
func TestCORS_PreflightAllowed(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("OPTIONS", "/api/health", nil)
req.Header.Set("Origin", "https://good.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" {
t.Fatalf("expected origin echo, got %q", v)
}
}
func TestCORS_PreflightRejected(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("OPTIONS", "/api/health", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rr.Code)
}
}
func TestCORS_Wildcard(t *testing.T) {
srv := newTestServerWithCORS([]string{"*"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
req.Header.Set("Origin", "https://anything.example")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "*" {
t.Fatalf("expected *, got %q", v)
}
// Wildcard should NOT set Vary: Origin
if v := rr.Header().Get("Vary"); v == "Origin" {
t.Fatalf("wildcard should not set Vary: Origin")
}
}
func TestCORS_NoOriginHeader(t *testing.T) {
srv := newTestServerWithCORS([]string{"https://good.example"})
handler := srv.corsMiddleware(dummyHandler)
req := httptest.NewRequest("GET", "/api/health", nil)
// No Origin header
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("expected 200, got %d", rr.Code)
}
if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "" {
t.Fatalf("expected no ACAO without Origin header, got %q", v)
}
}
+100 -82
View File
@@ -35,33 +35,20 @@ func setupTestDBv2(t *testing.T) *DB {
CREATE TABLE observers (
id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT, first_seen TEXT,
packet_count INTEGER DEFAULT 0, model TEXT, firmware TEXT,
client_version TEXT, radio TEXT, battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL,
inactive INTEGER DEFAULT 0
client_version TEXT, radio TEXT, battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT, raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE, first_seen TEXT NOT NULL,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
decoded_json TEXT, channel_hash TEXT DEFAULT NULL, from_pubkey TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now'))
decoded_json TEXT, channel_hash TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_id TEXT, observer_name TEXT, direction TEXT,
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL, raw_hex TEXT
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL
);
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
@@ -598,15 +585,12 @@ func TestHandlePacketsMultiNodeWithStore(t *testing.T) {
func TestHandlePacketDetailNoStore(t *testing.T) {
_, router := setupNoStoreServer(t)
// With no in-memory store, handlePacketDetail now falls back to the DB
// (#827). The seeded transmissions are present in the DB, so by-hash and
// by-ID lookups succeed; only truly absent IDs return 404.
t.Run("by hash", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/packets/abc123def4567890", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200 (DB fallback), got %d: %s", w.Code, w.Body.String())
if w.Code != 404 {
t.Fatalf("expected 404 (no store), got %d: %s", w.Code, w.Body.String())
}
})
@@ -614,8 +598,8 @@ func TestHandlePacketDetailNoStore(t *testing.T) {
req := httptest.NewRequest("GET", "/api/packets/1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200 (DB fallback), got %d: %s", w.Code, w.Body.String())
if w.Code != 404 {
t.Fatalf("expected 404 (no store), got %d: %s", w.Code, w.Body.String())
}
})
@@ -776,9 +760,9 @@ func TestGetChannelsFromStore(t *testing.T) {
func TestPrefixMapResolve(t *testing.T) {
nodes := []nodeInfo{
{Role: "repeater", PublicKey: "aabbccdd11223344", Name: "NodeA", HasGPS: true, Lat: 37.5, Lon: -122.0},
{Role: "repeater", PublicKey: "aabbccdd55667788", Name: "NodeB", HasGPS: false},
{Role: "repeater", PublicKey: "eeff0011aabbccdd", Name: "NodeC", HasGPS: true, Lat: 38.0, Lon: -121.0},
{PublicKey: "aabbccdd11223344", Name: "NodeA", HasGPS: true, Lat: 37.5, Lon: -122.0},
{PublicKey: "aabbccdd55667788", Name: "NodeB", HasGPS: false},
{PublicKey: "eeff0011aabbccdd", Name: "NodeC", HasGPS: true, Lat: 38.0, Lon: -121.0},
}
pm := buildPrefixMap(nodes)
@@ -818,8 +802,8 @@ func TestPrefixMapResolve(t *testing.T) {
t.Run("multiple candidates no GPS", func(t *testing.T) {
noGPSNodes := []nodeInfo{
{Role: "repeater", PublicKey: "aa11bb22", Name: "X", HasGPS: false},
{Role: "repeater", PublicKey: "aa11cc33", Name: "Y", HasGPS: false},
{PublicKey: "aa11bb22", Name: "X", HasGPS: false},
{PublicKey: "aa11cc33", Name: "Y", HasGPS: false},
}
pm2 := buildPrefixMap(noGPSNodes)
n := pm2.resolve("aa11")
@@ -833,8 +817,8 @@ func TestPrefixMapResolve(t *testing.T) {
func TestPrefixMapCap(t *testing.T) {
// 16-char pubkey — longer than maxPrefixLen
nodes := []nodeInfo{
{Role: "repeater", PublicKey: "aabbccdd11223344", Name: "LongKey"},
{Role: "repeater", PublicKey: "eeff0011", Name: "ShortKey"}, // exactly 8 chars
{PublicKey: "aabbccdd11223344", Name: "LongKey"},
{PublicKey: "eeff0011", Name: "ShortKey"}, // exactly 8 chars
}
pm := buildPrefixMap(nodes)
@@ -1334,11 +1318,8 @@ func TestBuildTransmissionWhereRFC3339(t *testing.T) {
if len(args) != 1 {
t.Errorf("expected 1 arg, got %d", len(args))
}
// PR #1187 r2: RFC3339 since/until MUST use observations.timestamp
// subquery so re-observed packets (older first_seen but recent
// observation) are still included. Anything else breaks semantics.
if !strings.Contains(where[0], "observations") || !strings.Contains(where[0], "timestamp >= ?") {
t.Errorf("expected observations.timestamp subquery for RFC3339 since, got %q", where[0])
if !strings.Contains(where[0], "observations") {
t.Error("expected observations subquery for RFC3339 since")
}
})
@@ -1351,9 +1332,6 @@ func TestBuildTransmissionWhereRFC3339(t *testing.T) {
if len(args) != 1 {
t.Errorf("expected 1 arg, got %d", len(args))
}
if !strings.Contains(where[0], "observations") || !strings.Contains(where[0], "timestamp <= ?") {
t.Errorf("expected observations.timestamp subquery for RFC3339 until, got %q", where[0])
}
})
t.Run("non-RFC3339 since", func(t *testing.T) {
@@ -1362,8 +1340,8 @@ func TestBuildTransmissionWhereRFC3339(t *testing.T) {
if len(where) != 1 {
t.Errorf("expected 1 clause, got %d", len(where))
}
if !strings.Contains(where[0], "first_seen") {
t.Error("expected first_seen comparison for non-RFC3339 since")
if strings.Contains(where[0], "observations") {
t.Error("expected direct first_seen comparison for non-RFC3339")
}
})
@@ -2178,7 +2156,7 @@ func TestStoreGetBulkHealthWithStore(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
results := store.GetBulkHealth(50, "", "")
results := store.GetBulkHealth(50, "")
if len(results) == 0 {
t.Error("expected bulk health results")
}
@@ -2193,7 +2171,7 @@ func TestStoreGetBulkHealthWithStore(t *testing.T) {
}
t.Run("with region filter", func(t *testing.T) {
results := store.GetBulkHealth(50, "SJC", "")
results := store.GetBulkHealth(50, "SJC")
_ = results
})
}
@@ -2204,7 +2182,7 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsHashSizes("", "")
result := store.GetAnalyticsHashSizes("")
if result["total"] == nil {
t.Error("expected total field")
}
@@ -2215,7 +2193,7 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) {
_ = dist
t.Run("with region", func(t *testing.T) {
r := store.GetAnalyticsHashSizes("SJC", "")
r := store.GetAnalyticsHashSizes("SJC")
_ = r
})
}
@@ -2226,7 +2204,7 @@ func TestHashSizesDistributionByRepeatersFiltersRole(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsHashSizes("", "")
result := store.GetAnalyticsHashSizes("")
// distributionByRepeaters should only count repeater nodes.
// Rich test DB: aabbccdd11223344 = repeater (hash size 2), eeff00112233aabb = companion (hash size 3).
@@ -2423,13 +2401,13 @@ func TestStoreGetAnalyticsRFCacheHit(t *testing.T) {
store.Load()
// First call — cache miss
result1 := store.GetAnalyticsRF("", "")
result1 := store.GetAnalyticsRF("")
if result1["totalPackets"] == nil {
t.Error("expected totalPackets")
}
// Second call — should hit cache
result2 := store.GetAnalyticsRF("", "")
result2 := store.GetAnalyticsRF("")
if result2["totalPackets"] == nil {
t.Error("expected cached totalPackets")
}
@@ -2448,7 +2426,7 @@ func TestStoreGetAnalyticsTopology(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsTopology("", "")
result := store.GetAnalyticsTopology("")
if result == nil {
t.Error("expected non-nil result")
}
@@ -2467,7 +2445,7 @@ func TestStoreGetAnalyticsTopology(t *testing.T) {
}
t.Run("with region", func(t *testing.T) {
r := store.GetAnalyticsTopology("SJC", "")
r := store.GetAnalyticsTopology("SJC")
_ = r
})
}
@@ -2478,7 +2456,7 @@ func TestStoreGetAnalyticsChannels(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsChannels("", "")
result := store.GetAnalyticsChannels("")
if _, ok := result["activeChannels"]; !ok {
t.Error("expected activeChannels")
}
@@ -2490,7 +2468,7 @@ func TestStoreGetAnalyticsChannels(t *testing.T) {
}
t.Run("with region", func(t *testing.T) {
r := store.GetAnalyticsChannels("SJC", "")
r := store.GetAnalyticsChannels("SJC")
_ = r
})
}
@@ -2516,19 +2494,19 @@ func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (5, 1, 10.0, -90, '[]', ?)`, recentEpoch)
// Also a decrypted CHAN with numeric channelHash — use hash 198 which is the real hash for #general
// Also a decrypted CHAN with numeric channelHash
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('DD03', 'chan_num_hash_3', ?, 1, 5, '{"type":"CHAN","channel":"general","channelHash":198,"channelHashHex":"C6","text":"hello","sender":"Alice"}')`, recent)
VALUES ('DD03', 'chan_num_hash_3', ?, 1, 5, '{"type":"CHAN","channel":"general","channelHash":97,"channelHashHex":"61","text":"hello","sender":"Alice"}')`, recent)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (6, 1, 12.0, -88, '[]', ?)`, recentEpoch)
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsChannels("", "")
result := store.GetAnalyticsChannels("")
channels := result["channels"].([]map[string]interface{})
if len(channels) < 3 {
t.Errorf("expected at least 3 channels (hash 97 + hash 42 + hash 198), got %d", len(channels))
if len(channels) < 2 {
t.Errorf("expected at least 2 channels (hash 97 + hash 42), got %d", len(channels))
}
// Verify the numeric-hash channels we inserted have proper hashes (not "?")
@@ -2549,13 +2527,13 @@ func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
t.Error("expected to find channel with hash '42' (numeric channelHash parsing)")
}
// Verify the decrypted CHAN channel has the correct name (now at hash 198)
// Verify the decrypted CHAN channel has the correct name
foundGeneral := false
for _, ch := range channels {
if ch["name"] == "general" {
foundGeneral = true
if ch["hash"] != "198" {
t.Errorf("expected hash '198' for general channel, got %v", ch["hash"])
if ch["hash"] != "97" {
t.Errorf("expected hash '97' for general channel, got %v", ch["hash"])
}
}
}
@@ -2570,13 +2548,13 @@ func TestStoreGetAnalyticsDistance(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsDistance("", "")
result := store.GetAnalyticsDistance("")
if result == nil {
t.Error("expected non-nil result")
}
t.Run("with region", func(t *testing.T) {
r := store.GetAnalyticsDistance("SJC", "")
r := store.GetAnalyticsDistance("SJC")
_ = r
})
}
@@ -2951,13 +2929,13 @@ func TestCacheHitTopology(t *testing.T) {
store.Load()
// First call — cache miss
r1 := store.GetAnalyticsTopology("", "")
r1 := store.GetAnalyticsTopology("")
if r1 == nil {
t.Fatal("expected topology result")
}
// Second call — cache hit
r2 := store.GetAnalyticsTopology("", "")
r2 := store.GetAnalyticsTopology("")
if r2 == nil {
t.Fatal("expected cached topology result")
}
@@ -2975,12 +2953,12 @@ func TestCacheHitHashSizes(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
r1 := store.GetAnalyticsHashSizes("", "")
r1 := store.GetAnalyticsHashSizes("")
if r1 == nil {
t.Fatal("expected hash sizes result")
}
r2 := store.GetAnalyticsHashSizes("", "")
r2 := store.GetAnalyticsHashSizes("")
if r2 == nil {
t.Fatal("expected cached hash sizes result")
}
@@ -2998,12 +2976,12 @@ func TestCacheHitChannels(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
r1 := store.GetAnalyticsChannels("", "")
r1 := store.GetAnalyticsChannels("")
if r1 == nil {
t.Fatal("expected channels result")
}
r2 := store.GetAnalyticsChannels("", "")
r2 := store.GetAnalyticsChannels("")
if r2 == nil {
t.Fatal("expected cached channels result")
}
@@ -3398,7 +3376,7 @@ func TestAnalyticsHashSizesZeroHopSkip(t *testing.T) {
store := NewPacketStore(db, nil)
store.Load()
result := store.GetAnalyticsHashSizes("", "")
result := store.GetAnalyticsHashSizes("")
// The node should appear in multiByteNodes (hashSize=2 from the flood advert)
// If the zero-hop bug is present, hashSize would be 1 and the node would NOT
@@ -4338,48 +4316,88 @@ func TestIndexByNodePreCheck(t *testing.T) {
})
}
// TestIndexByNodeResolvedPath tests that indexByNode only indexes decoded JSON pubkeys.
// After #800, resolved_path entries are handled via the decode-window, not indexByNode.
// TestIndexByNodeResolvedPath tests that resolved_path entries are indexed in byNode.
func TestIndexByNodeResolvedPath(t *testing.T) {
store := &PacketStore{
byNode: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
}
t.Run("decoded JSON pubkeys still indexed", func(t *testing.T) {
pk := "aabb1122334455ff"
t.Run("indexes resolved path pubkeys from observations", func(t *testing.T) {
relayPK := "aabb1122334455ff"
tx := &StoreTx{
Hash: "rp1",
DecodedJSON: `{"pubKey":"` + pk + `"}`,
DecodedJSON: `{"type":"CHAN","text":"hello"}`, // no pubKey fields
Observations: []*StoreObs{
{ResolvedPath: []*string{&relayPK}},
},
}
store.indexByNode(tx)
if len(store.byNode[relayPK]) != 1 {
t.Errorf("expected relay pubkey indexed, got %d", len(store.byNode[relayPK]))
}
})
t.Run("skips null entries in resolved path", func(t *testing.T) {
pk := "cc11dd22ee33ff44"
tx := &StoreTx{
Hash: "rp2",
Observations: []*StoreObs{
{ResolvedPath: []*string{nil, &pk, nil}},
},
}
store.indexByNode(tx)
if len(store.byNode[pk]) != 1 {
t.Errorf("expected decoded pubkey indexed, got %d", len(store.byNode[pk]))
t.Errorf("expected resolved pubkey indexed, got %d", len(store.byNode[pk]))
}
// Verify nil entries didn't create empty-string keys
if _, exists := store.byNode[""]; exists {
t.Error("nil/empty resolved path entries should not create byNode entries")
}
})
t.Run("resolved path pubkeys NOT indexed by indexByNode", func(t *testing.T) {
// After #800, indexByNode only handles decoded JSON fields.
// Resolved path pubkeys are handled by the decode-window.
t.Run("relay-only node appears in byNode", func(t *testing.T) {
// A packet with no decoded pubkey fields, only a relay in resolved path
relayOnly := "relay0only0pubkey"
tx := &StoreTx{
Hash: "rp2",
DecodedJSON: `{"type":"CHAN","text":"hello"}`, // no pubKey fields
Hash: "rp3",
// No DecodedJSON at all — pure relay
Observations: []*StoreObs{
{ResolvedPath: []*string{&relayOnly}},
},
}
store.indexByNode(tx)
// No new entries expected since there are no decoded pubkeys
if len(store.byNode[relayOnly]) != 1 {
t.Errorf("expected relay-only node indexed, got %d", len(store.byNode[relayOnly]))
}
})
t.Run("dedup within decoded JSON", func(t *testing.T) {
t.Run("dedup between decoded JSON and resolved path", func(t *testing.T) {
pk := "dedup0test0pk1234"
tx := &StoreTx{
Hash: "rp4",
DecodedJSON: `{"pubKey":"` + pk + `","destPubKey":"` + pk + `"}`,
DecodedJSON: `{"pubKey":"` + pk + `"}`,
Observations: []*StoreObs{
{ResolvedPath: []*string{&pk}},
},
}
store.indexByNode(tx)
if len(store.byNode[pk]) != 1 {
t.Errorf("expected dedup to keep 1 entry, got %d", len(store.byNode[pk]))
}
})
t.Run("indexes tx.ResolvedPath when observations empty", func(t *testing.T) {
rpPK := "txlevel0resolved1"
tx := &StoreTx{
Hash: "rp5",
ResolvedPath: []*string{&rpPK},
}
store.indexByNode(tx)
if len(store.byNode[rpPK]) != 1 {
t.Errorf("expected tx-level resolved path indexed, got %d", len(store.byNode[rpPK]))
}
})
}
// BenchmarkIndexByNode measures indexByNode performance with and without pubkey
+210 -677
View File
File diff suppressed because it is too large Load Diff
@@ -1,96 +0,0 @@
package main
import (
"fmt"
"testing"
"time"
)
// TestGetChannelMessagesPerfLargeChannel asserts that fetching a small page
// (limit=50) from a channel with many observations does not scan the full
// observation set. Regression guard for issue #1225 where the query loaded
// every observation row for the channel and deduped/paginated in Go memory.
//
// Dataset: 1500 transmissions in #perf, each with 50 observations
// (75K obs total). Same shape as staging where #wardriving had ~5.7K tx
// and ~275K obs — fewer rows are enough to demonstrate that the broken
// impl (which loads/dedups every observation in Go) blows the budget,
// while keeping setup fast enough for slower CI runners.
//
// On the broken implementation this takes multiple seconds (>2s on dev,
// ~69s on GitHub-hosted CI). With SQL-level pagination over
// transmissions it must complete well under the 1.5s budget
// (~sub-100ms observed on dev).
func TestGetChannelMessagesPerfLargeChannel(t *testing.T) {
if testing.Short() {
t.Skip("perf test")
}
db := setupTestDB(t)
defer db.Close()
// Seed one observer.
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs_perf', 'PerfObs', 'SJC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 0)`); err != nil {
t.Fatal(err)
}
const numTx = 1500
const obsPerTx = 50
tx, err := db.conn.Begin()
if err != nil {
t.Fatal(err)
}
txStmt, err := tx.Prepare(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES (?, ?, ?, 1, 5, ?, '#perf')`)
if err != nil {
t.Fatal(err)
}
obsStmt, err := tx.Prepare(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 10.0, -90, '[]', ?)`)
if err != nil {
t.Fatal(err)
}
base := time.Now().UTC().Add(-24 * time.Hour)
for i := 0; i < numTx; i++ {
ts := base.Add(time.Duration(i) * time.Second).Format(time.RFC3339)
hash := fmt.Sprintf("perfhash%08d", i)
body := fmt.Sprintf(`{"type":"CHAN","channel":"#perf","text":"Sender%d: msg %d","sender":"Sender%d"}`, i%10, i, i%10)
res, err := txStmt.Exec(fmt.Sprintf("%04X", i), hash, ts, body)
if err != nil {
t.Fatal(err)
}
txID, _ := res.LastInsertId()
for o := 0; o < obsPerTx; o++ {
if _, err := obsStmt.Exec(txID, base.Unix()+int64(i*100+o)); err != nil {
t.Fatal(err)
}
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
// Warm-up call to amortize first-run prepare cost.
if _, _, err := db.GetChannelMessages("#perf", 50, 0); err != nil {
t.Fatal(err)
}
start := time.Now()
msgs, total, err := db.GetChannelMessages("#perf", 50, 0)
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if total != numTx {
t.Errorf("total: got %d want %d", total, numTx)
}
if len(msgs) != 50 {
t.Errorf("page size: got %d want 50", len(msgs))
}
const budget = 1500 * time.Millisecond
if elapsed > budget {
t.Fatalf("GetChannelMessages too slow for #1225: %v (budget %v) on %d tx × %d obs",
elapsed, budget, numTx, obsPerTx)
}
}
+10 -344
View File
@@ -32,8 +32,7 @@ func setupTestDB(t *testing.T) *DB {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
temperature_c REAL
);
CREATE TABLE observers (
@@ -49,9 +48,7 @@ func setupTestDB(t *testing.T) *DB {
radio TEXT,
battery_mv INTEGER,
uptime_secs INTEGER,
noise_floor REAL,
inactive INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
noise_floor REAL
);
CREATE TABLE transmissions (
@@ -64,7 +61,6 @@ func setupTestDB(t *testing.T) *DB {
payload_version INTEGER,
decoded_json TEXT,
channel_hash TEXT DEFAULT NULL,
from_pubkey TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -78,8 +74,7 @@ func setupTestDB(t *testing.T) *DB {
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
resolved_path TEXT,
raw_hex TEXT
resolved_path TEXT
);
CREATE TABLE IF NOT EXISTS observer_metrics (
@@ -97,29 +92,6 @@ func setupTestDB(t *testing.T) *DB {
CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp);
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
-- (which only set decoded_json) still attribute correctly under #1143's
-- exact-match column. Production migration handles legacy data; the
-- ingestor sets the column at write time.
--
-- m4 alignment: prod ingest leaves from_pubkey NULL when pubKey is
-- missing or empty (cmd/ingestor/db.go ~1289 guards PubKey != empty-string).
-- The trigger mirrors that: only assign when json_extract yields a
-- non-empty string. json_extract returns NULL for missing keys, so
-- the explicit IS NOT NULL AND <> empty-string guard catches the empty-string
-- case too. UPDATE only when we have something to write.
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
@@ -153,13 +125,13 @@ func seedTestData(t *testing.T, db *DB) {
VALUES ('1122334455667788', 'TestRoom', 'room', 37.4, -121.9, ?, '2026-01-01T00:00:00Z', 5)`, twoDaysAgo)
// Seed transmissions
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash, from_pubkey)
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test', 'aabbccdd11223344')`, recent)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test')`, recent)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CCDD', '1234567890abcdef', ?, 1, 5, '{"type":"CHAN","channel":"#test","text":"Hello: World","sender":"TestUser"}', '#test')`, yesterday)
// Second ADVERT for same node with different hash_size (raw_hex byte 0x1F → hs=1 vs 0xBB → hs=3)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', 'aabbccdd11223344')`, yesterday)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}')`, yesterday)
// Seed observations (use unix timestamps)
// resolved_path contains full pubkeys parallel to path_json hops
@@ -382,35 +354,6 @@ func TestGetObservers(t *testing.T) {
if observers[0].ID != "obs1" {
t.Errorf("expected obs1 first (most recent), got %s", observers[0].ID)
}
// last_packet_at should be nil since seedTestData doesn't set it
if observers[0].LastPacketAt != nil {
t.Errorf("expected nil LastPacketAt for obs1 from seed, got %v", *observers[0].LastPacketAt)
}
}
// Regression: GetObservers must exclude soft-deleted (inactive=1) rows.
// Stale observers were appearing in /api/observers despite the auto-prune
// marking them inactive, because the SELECT query had no WHERE filter.
func TestGetObservers_ExcludesInactive(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Mark obs2 inactive — soft delete simulating a stale-observer prune.
if _, err := db.conn.Exec(`UPDATE observers SET inactive = 1 WHERE id = ?`, "obs2"); err != nil {
t.Fatalf("update inactive: %v", err)
}
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
if len(observers) != 1 {
t.Errorf("expected 1 observer (obs1) after marking obs2 inactive, got %d", len(observers))
}
for _, o := range observers {
if o.ID == "obs2" {
t.Errorf("inactive observer obs2 should be excluded")
}
}
}
func TestGetObserverByID(t *testing.T) {
@@ -425,48 +368,6 @@ func TestGetObserverByID(t *testing.T) {
if obs.ID != "obs1" {
t.Errorf("expected obs1, got %s", obs.ID)
}
// Verify last_packet_at is nil by default
if obs.LastPacketAt != nil {
t.Errorf("expected nil LastPacketAt, got %v", *obs.LastPacketAt)
}
}
func TestGetObserverLastPacketAt(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Set last_packet_at for obs1
ts := "2026-04-24T12:00:00Z"
db.conn.Exec(`UPDATE observers SET last_packet_at = ? WHERE id = ?`, ts, "obs1")
// Verify via GetObservers
observers, err := db.GetObservers()
if err != nil {
t.Fatal(err)
}
var obs1 *Observer
for i := range observers {
if observers[i].ID == "obs1" {
obs1 = &observers[i]
break
}
}
if obs1 == nil {
t.Fatal("obs1 not found")
}
if obs1.LastPacketAt == nil || *obs1.LastPacketAt != ts {
t.Errorf("expected LastPacketAt=%s via GetObservers, got %v", ts, obs1.LastPacketAt)
}
// Verify via GetObserverByID
obs, err := db.GetObserverByID("obs1")
if err != nil {
t.Fatal(err)
}
if obs.LastPacketAt == nil || *obs.LastPacketAt != ts {
t.Errorf("expected LastPacketAt=%s via GetObserverByID, got %v", ts, obs.LastPacketAt)
}
}
func TestGetObserverByIDNotFound(t *testing.T) {
@@ -1198,8 +1099,7 @@ func setupTestDBV2(t *testing.T) *DB {
first_seen TEXT,
advert_count INTEGER DEFAULT 0,
battery_mv INTEGER,
temperature_c REAL,
foreign_advert INTEGER DEFAULT 0
temperature_c REAL
);
CREATE TABLE observers (
@@ -1208,8 +1108,7 @@ func setupTestDBV2(t *testing.T) *DB {
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0,
last_packet_at TEXT DEFAULT NULL
packet_count INTEGER DEFAULT 0
);
CREATE TABLE transmissions (
@@ -1222,7 +1121,6 @@ func setupTestDBV2(t *testing.T) *DB {
payload_version INTEGER,
decoded_json TEXT,
channel_hash TEXT DEFAULT NULL,
from_pubkey TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -1236,22 +1134,8 @@ func setupTestDBV2(t *testing.T) *DB {
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
raw_hex TEXT
timestamp INTEGER NOT NULL
);
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
@@ -1469,73 +1353,6 @@ func TestOpenDBInvalidPath(t *testing.T) {
}
}
// TestDetectSchemaScopeName verifies that OpenDB sets hasScopeName and
// hasDefaultScope via the real detectSchema path when the columns are present.
// The existing ScopeStats tests set these flags manually — this test ensures
// the flag-setting code itself is covered.
func TestDetectSchemaScopeName(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "detect.db")
// Create file-based DB with the scope_name and default_scope columns.
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
conn.SetMaxOpenConns(1)
if _, err := conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, scope_name TEXT)`); err != nil {
conn.Close()
t.Fatalf("create transmissions: %v", err)
}
if _, err := conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, default_scope TEXT)`); err != nil {
conn.Close()
t.Fatalf("create nodes: %v", err)
}
if _, err := conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`); err != nil {
conn.Close()
t.Fatalf("create observations: %v", err)
}
conn.Close()
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
if !db.hasScopeName {
t.Error("hasScopeName should be true when scope_name column exists")
}
if !db.hasDefaultScope {
t.Error("hasDefaultScope should be true when default_scope column exists")
}
// Verify the flags stay false when the columns are absent.
dbPath2 := filepath.Join(dir, "detect2.db")
conn2, err := sql.Open("sqlite", dbPath2)
if err != nil {
t.Fatal(err)
}
conn2.SetMaxOpenConns(1)
conn2.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT)`)
conn2.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY)`)
conn2.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`)
conn2.Close()
db2, err := OpenDB(dbPath2)
if err != nil {
t.Fatalf("OpenDB2: %v", err)
}
defer db2.Close()
if db2.hasScopeName {
t.Error("hasScopeName should be false when scope_name column is absent")
}
if db2.hasDefaultScope {
t.Error("hasDefaultScope should be false when default_scope column is absent")
}
}
func TestGetChannelMessagesObserverFallback(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
@@ -2158,154 +1975,3 @@ func TestParseWindowDuration(t *testing.T) {
}
}
}
// TestPerObservationRawHexEnrich verifies enrichObs returns per-observation raw_hex
// when available, falling back to transmission raw_hex when NULL (#881).
func TestPerObservationRawHexEnrich(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert observers
db.conn.Exec(`INSERT INTO observers (id, name) VALUES ('obs-a', 'Observer A')`)
db.conn.Exec(`INSERT INTO observers (id, name) VALUES ('obs-b', 'Observer B')`)
var rowA, rowB int64
db.conn.QueryRow(`SELECT rowid FROM observers WHERE id='obs-a'`).Scan(&rowA)
db.conn.QueryRow(`SELECT rowid FROM observers WHERE id='obs-b'`).Scan(&rowB)
// Insert transmission with raw_hex
txHex := "deadbeef"
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen) VALUES (?, 'hash1', '2026-04-21T10:00:00Z')`, txHex)
// Insert two observations: A has its own raw_hex, B has NULL (historical)
obsAHex := "c0ffee01"
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, raw_hex)
VALUES (1, ?, -5.0, -90.0, '[]', 1745236800, ?)`, rowA, obsAHex)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, ?, -3.0, -85.0, '["aabb"]', 1745236801)`, rowB)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store load: %v", err)
}
tx := store.byHash["hash1"]
if tx == nil {
t.Fatal("transmission not loaded")
}
if len(tx.Observations) < 2 {
t.Fatalf("expected 2 observations, got %d", len(tx.Observations))
}
// Check enriched observations
for _, obs := range tx.Observations {
m := store.enrichObs(obs)
rh, _ := m["raw_hex"].(string)
if obs.RawHex != "" {
// Observer A: should get per-observation raw_hex
if rh != obsAHex {
t.Errorf("obs with own raw_hex: got %q, want %q", rh, obsAHex)
}
} else {
// Observer B: should fall back to transmission raw_hex
if rh != txHex {
t.Errorf("obs without raw_hex: got %q, want %q (tx fallback)", rh, txHex)
}
}
}
}
func TestGetScopeStats(t *testing.T) {
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
conn.SetMaxOpenConns(1)
db := &DB{conn: conn}
defer db.conn.Close()
// Create minimal schema
db.conn.Exec(`CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER,
payload_type INTEGER, payload_version INTEGER, decoded_json TEXT,
scope_name TEXT DEFAULT NULL
)`)
// Manually set hasScopeName since we bypassed the detector
db.hasScopeName = true
now := time.Now().UTC().Format(time.RFC3339)
// Transport scoped, known region
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('a', ?, 0, '#belgium')`, now)
// Transport scoped, unknown
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('b', ?, 0, '')`, now)
// Transport unscoped (NULL)
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('c', ?, 0, NULL)`, now)
// Non-transport (should not count)
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('d', ?, 1, NULL)`, now)
stats, err := db.GetScopeStats("24h")
if err != nil {
t.Fatalf("GetScopeStats: %v", err)
}
if stats.Summary.TransportTotal != 3 {
t.Errorf("TransportTotal = %d, want 3", stats.Summary.TransportTotal)
}
if stats.Summary.Scoped != 2 {
t.Errorf("Scoped = %d, want 2", stats.Summary.Scoped)
}
if stats.Summary.Unscoped != 1 {
t.Errorf("Unscoped = %d, want 1", stats.Summary.Unscoped)
}
if stats.Summary.UnknownScope != 1 {
t.Errorf("UnknownScope = %d, want 1", stats.Summary.UnknownScope)
}
if len(stats.ByRegion) != 1 || stats.ByRegion[0].Name != "#belgium" || stats.ByRegion[0].Count != 1 {
t.Errorf("ByRegion = %+v, want [{#belgium 1}]", stats.ByRegion)
}
}
// TestLoadIndexesRelayHopsFromResolvedPath verifies that after Load(), relay
// nodes that appear only in resolved_path (not in decoded_json) are indexed
// in byNode. Regression for #692: indexByNode was called before observations
// were appended, so tx.ResolvedPath was nil at index time — #806 fixed this
// by indexing inline during the scan, this test locks it in.
func TestLoadIndexesRelayHopsFromResolvedPath(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
epoch := time.Now().UTC().Add(-1 * time.Hour).Unix()
// Insert a node whose pubkey does NOT appear in any decoded_json —
// it only relays traffic (appears in resolved_path of other packets).
const relayPubkey = "relay000aabbccddeeff0011"
const senderPubkey = "sender00112233445566"
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('FF01', 'relaytest0001hash', ?, 1, 4, ?)`,
now, `{"pubKey":"`+senderPubkey+`","name":"Sender","type":"ADVERT"}`)
// Observer hears the packet via the relay node.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
VALUES (1, 1, 10.0, -90, '["rr"]', ?, ?)`,
epoch, `["`+relayPubkey+`"]`)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatal(err)
}
// The sender should be in byNode via decoded_json.
if len(store.byNode[senderPubkey]) == 0 {
t.Errorf("sender not indexed in byNode via decoded_json")
}
// The relay node must be in byNode via resolved_path — this was the bug.
if len(store.byNode[relayPubkey]) == 0 {
t.Errorf("relay node not indexed in byNode after Load() — resolved_path indexing broken")
}
if store.byNode[relayPubkey][0].Hash != "relaytest0001hash" {
t.Errorf("relay byNode entry has wrong hash: %s", store.byNode[relayPubkey][0].Hash)
}
}
+115 -221
View File
@@ -10,7 +10,6 @@ import (
"strings"
"time"
"github.com/meshcore-analyzer/packetpath"
"github.com/meshcore-analyzer/sigvalidate"
)
@@ -106,35 +105,8 @@ type Payload struct {
Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
SNRValues []float64 `json:"snrValues,omitempty"`
RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"`
// GRP_TXT/GRP_DATA channel envelope helpers — see
// firmware/src/helpers/BaseChatMesh.cpp:376-391.
ChannelHashHex string `json:"channelHashHex,omitempty"`
DecryptionStatus string `json:"decryptionStatus,omitempty"`
// GRP_DATA (PAYLOAD_TYPE_GRP_DATA=0x06) inner fields, per
// firmware/src/helpers/BaseChatMesh.cpp:382-385.
DataType *int `json:"dataType,omitempty"`
DataLen *int `json:"dataLen,omitempty"`
DecryptedBlob string `json:"decryptedBlob,omitempty"`
// MULTIPART (PAYLOAD_TYPE_MULTIPART=0x0A) inner fields, per
// firmware/src/Mesh.cpp:289 — byte0 = (remaining<<4) | inner_type.
Remaining *int `json:"remaining,omitempty"`
InnerType *int `json:"innerType,omitempty"`
InnerTypeName string `json:"innerTypeName,omitempty"`
InnerAckCrc string `json:"innerAckCrc,omitempty"`
InnerPayload string `json:"innerPayload,omitempty"`
// CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per
// firmware/src/Mesh.cpp:69 — high-bit = zero-hop direct subset.
CtrlFlags string `json:"ctrlFlags,omitempty"`
CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"`
CtrlLength *int `json:"ctrlLength,omitempty"`
// RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per
// firmware/src/Mesh.cpp:577 (createRawData). We expose the bare envelope
// shape so consumers can triage by length + leading tag byte.
RawLength *int `json:"rawLength,omitempty"`
FirstByteTag string `json:"firstByteTag,omitempty"`
}
// DecodedPacket is the full decoded result.
@@ -170,35 +142,9 @@ func decodeHeader(b byte) Header {
}
}
// Firmware-derived limits — see firmware/src/MeshCore.h:19,21.
const (
maxPathSize = 64 // MAX_PATH_SIZE — total path bytes allowed
maxPacketPayload = 184 // MAX_PACKET_PAYLOAD — max raw payload bytes
)
// isValidPathLen mirrors firmware Packet::isValidPathLen
// (firmware/src/Packet.cpp:13-18). hash_size==4 is reserved; total path bytes
// must fit within MAX_PATH_SIZE.
func isValidPathLen(pathByte byte) bool {
hashCount := int(pathByte & 0x3F)
hashSize := int(pathByte>>6) + 1
if hashSize == 4 {
return false // reserved
}
return hashCount*hashSize <= maxPathSize
}
func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
// Exact mirror of firmware Packet::isValidPathLen (Packet.cpp:13-18).
// hash_size==4 is reserved and is rejected by firmware regardless of
// hash_count, so we must reject 0xC0 etc even on zero-hop packets —
// firmware never emits them, so an on-wire pathByte with the upper
// 2 bits set to 11 is by definition malformed/adversarial.
if !isValidPathLen(pathByte) {
return Path{}, 0, fmt.Errorf("invalid path encoding: pathByte 0x%02X (hash_size=%d hash_count=%d) violates firmware validity (Packet.cpp:13-18, MAX_PATH_SIZE=%d)", pathByte, hashSize, hashCount, maxPathSize)
}
totalBytes := hashSize * hashCount
hops := make([]string, 0, hashCount)
@@ -215,12 +161,11 @@ func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
HashSize: hashSize,
HashCount: hashCount,
Hops: hops,
}, totalBytes, nil
}, totalBytes
}
// isTransportRoute delegates to packetpath.IsTransportRoute.
func isTransportRoute(routeType int) bool {
return packetpath.IsTransportRoute(routeType)
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
@@ -313,13 +258,6 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
name := string(appdata[off:])
name = strings.TrimRight(name, "\x00")
name = sanitizeName(name)
// Firmware writes the node name into a 32-byte buffer
// (MAX_ADVERT_DATA_SIZE, firmware/src/MeshCore.h:11). Truncate
// here so adversarial on-wire adverts can't pollute Payload.Name
// with bytes firmware would never emit.
if len(name) > 32 {
name = name[:32]
}
p.Name = name
}
}
@@ -339,85 +277,6 @@ func decodeGrpTxt(buf []byte) Payload {
}
}
// decodeGrpData decodes PAYLOAD_TYPE_GRP_DATA (0x06). Outer envelope is the
// same shape as GRP_TXT (channel_hash(1)+MAC(2)+ciphertext) — see
// firmware/src/helpers/BaseChatMesh.cpp:476,500. This server-side decoder has
// no channel keys, so it surfaces the envelope only.
func decodeGrpData(buf []byte) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_DATA", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
return Payload{
Type: "GRP_DATA",
ChannelHash: int(buf[0]),
ChannelHashHex: fmt.Sprintf("%02X", buf[0]),
MAC: hex.EncodeToString(buf[1:3]),
EncryptedData: hex.EncodeToString(buf[3:]),
}
}
// decodeMultipart decodes PAYLOAD_TYPE_MULTIPART (0x0A) per
// firmware/src/Mesh.cpp:287-310. byte0 = (remaining<<4) | inner_type;
// when inner_type == PAYLOAD_TYPE_ACK the next 4 bytes are an ack_crc.
func decodeMultipart(buf []byte) Payload {
if len(buf) < 1 {
return Payload{Type: "MULTIPART", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
remaining := int(buf[0] >> 4)
innerType := int(buf[0] & 0x0F)
innerName := payloadTypeNames[innerType]
if innerName == "" {
innerName = "UNKNOWN"
}
p := Payload{
Type: "MULTIPART",
Remaining: &remaining,
InnerType: &innerType,
InnerTypeName: innerName,
}
if innerType == PayloadACK && len(buf) >= 5 {
crc := binary.LittleEndian.Uint32(buf[1:5])
p.InnerAckCrc = fmt.Sprintf("%08x", crc)
} else if len(buf) > 1 {
p.InnerPayload = hex.EncodeToString(buf[1:])
}
return p
}
// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per
// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset).
func decodeControl(buf []byte) Payload {
if len(buf) < 1 {
return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)}
}
zeroHop := buf[0]&0x80 != 0
length := len(buf)
return Payload{
Type: "CONTROL",
CtrlFlags: fmt.Sprintf("%02x", buf[0]),
CtrlZeroHop: &zeroHop,
CtrlLength: &length,
RawHex: hex.EncodeToString(buf),
}
}
// decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). The payload bytes
// are application-defined per firmware/src/Mesh.cpp:577 (createRawData), so
// we only surface the bare envelope shape: total length plus the leading
// byte, which apps commonly use as a tag/type discriminator.
func decodeRawCustom(buf []byte) Payload {
length := len(buf)
p := Payload{
Type: "RAW_CUSTOM",
RawLength: &length,
RawHex: hex.EncodeToString(buf),
}
if length > 0 {
p.FirstByteTag = fmt.Sprintf("%02X", buf[0])
}
return p
}
func decodeAnonReq(buf []byte) Payload {
if len(buf) < 35 {
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
@@ -477,20 +336,12 @@ func decodePayload(payloadType int, buf []byte, validateSignatures bool) Payload
return decodeAdvert(buf, validateSignatures)
case PayloadGRP_TXT:
return decodeGrpTxt(buf)
case PayloadGRP_DATA:
return decodeGrpData(buf)
case PayloadANON_REQ:
return decodeAnonReq(buf)
case PayloadPATH:
return decodePathPayload(buf)
case PayloadTRACE:
return decodeTrace(buf)
case PayloadMULTIPART:
return decodeMultipart(buf)
case PayloadCONTROL:
return decodeControl(buf)
case PayloadRAW_CUSTOM:
return decodeRawCustom(buf)
default:
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
}
@@ -531,23 +382,10 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er
pathByte := buf[offset]
offset++
path, bytesConsumed, decodeErr := decodePath(pathByte, buf, offset)
if decodeErr != nil {
return nil, decodeErr
}
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
// Bounds check — see cmd/ingestor/decoder.go for full rationale (#1211).
if offset > len(buf) {
return nil, fmt.Errorf("packet path length (%d bytes claimed by pathByte 0x%02X) exceeds buffer (%d bytes)", bytesConsumed, pathByte, len(buf))
}
payloadBuf := buf[offset:]
// Firmware caps payload at MAX_PACKET_PAYLOAD=184 (firmware/src/MeshCore.h:19).
// Anything larger cannot be a valid wire packet — drop it.
if len(payloadBuf) > maxPacketPayload {
return nil, fmt.Errorf("packet payload (%d bytes) exceeds firmware MAX_PACKET_PAYLOAD=%d (MeshCore.h:19)", len(payloadBuf), maxPacketPayload)
}
payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
@@ -567,19 +405,6 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er
}
// The header path hops count represents SNR entries = completed hops
hopsCompleted := path.HashCount
// Extract per-hop SNR from header path bytes (int8, quarter-dB encoding)
if hopsCompleted > 0 && len(path.Hops) >= hopsCompleted {
snrVals := make([]float64, 0, hopsCompleted)
for i := 0; i < hopsCompleted; i++ {
b, err := hex.DecodeString(path.Hops[i])
if err == nil && len(b) == 1 {
snrVals = append(snrVals, float64(int8(b[0]))/4.0)
}
}
if len(snrVals) > 0 {
payload.SNRValues = snrVals
}
}
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && payload.TraceFlags != nil {
// path_sz from flags byte is a power-of-two exponent per firmware:
@@ -616,10 +441,107 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er
}, nil
}
// HexRange represents a labeled byte range for the hex breakdown visualization.
type HexRange struct {
Start int `json:"start"`
End int `json:"end"`
Label string `json:"label"`
}
// Breakdown holds colored byte ranges returned by the packet detail endpoint.
type Breakdown struct {
Ranges []HexRange `json:"ranges"`
}
// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet.
// The returned ranges are consumed by createColoredHexDump() and buildHexLegend()
// in the frontend (public/app.js).
func BuildBreakdown(hexString string) *Breakdown {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
buf, err := hex.DecodeString(hexString)
if err != nil || len(buf) < 2 {
return &Breakdown{Ranges: []HexRange{}}
}
var ranges []HexRange
offset := 0
// Byte 0: Header
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
offset = 1
header := decodeHeader(buf[0])
// Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only)
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return &Breakdown{Ranges: ranges}
}
ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"})
offset += 4
}
if offset >= len(buf) {
return &Breakdown{Ranges: ranges}
}
// Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount)
ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"})
pathByte := buf[offset]
offset++
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount
// Path hops
if hashCount > 0 && offset+pathBytes <= len(buf) {
ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"})
}
offset += pathBytes
if offset >= len(buf) {
return &Breakdown{Ranges: ranges}
}
payloadStart := offset
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 {
ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"})
ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"})
ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"})
appStart := payloadStart + 100
if appStart < len(buf) {
ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"})
appFlags := buf[appStart]
fOff := appStart + 1
if appFlags&0x10 != 0 && fOff+8 <= len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 3, Label: "Latitude"})
ranges = append(ranges, HexRange{Start: fOff + 4, End: fOff + 7, Label: "Longitude"})
fOff += 8
}
if appFlags&0x20 != 0 && fOff+2 <= len(buf) {
fOff += 2
}
if appFlags&0x40 != 0 && fOff+2 <= len(buf) {
fOff += 2
}
if appFlags&0x80 != 0 && fOff < len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"})
}
}
} else {
ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"})
}
return &Breakdown{Ranges: ranges}
}
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
// It hashes the payload-type nibble + payload (skipping path bytes) to produce a
// route-independent identifier for the same logical packet. For TRACE packets,
// path_len is included in the hash to match firmware behavior.
func ComputeContentHash(rawHex string) string {
buf, err := hex.DecodeString(rawHex)
if err != nil || len(buf) < 2 {
@@ -655,18 +577,7 @@ func ComputeContentHash(rawHex string) string {
}
payload := buf[payloadStart:]
// Hash payload-type byte only (bits 2-5 of header), not the full header.
// Firmware: SHA256(payload_type + [path_len for TRACE] + payload)
// Using the full header caused different hashes for the same logical packet
// when route type or version bits differed. See issue #786.
payloadType := (headerByte >> 2) & 0x0F
toHash := []byte{payloadType}
if int(payloadType) == PayloadTRACE {
// Firmware uses uint16_t path_len (2 bytes, little-endian)
toHash = append(toHash, pathByte, 0x00)
}
toHash = append(toHash, payload...)
toHash := append([]byte{headerByte}, payload...)
h := sha256.Sum256(toHash)
return hex.EncodeToString(h[:])[:16]
@@ -730,13 +641,8 @@ func ValidateAdvert(p *Payload) (bool, string) {
if p.Flags != nil {
role := advertRole(p.Flags)
// Accept canonical labels plus "none" (ADV_TYPE_NONE=0) and "type-N"
// placeholders for ADV_TYPE 5-15 (FUTURE) — see
// firmware/src/helpers/AdvertDataHelpers.h:7-12.
validRoles := map[string]bool{
"repeater": true, "companion": true, "room": true, "sensor": true, "none": true,
}
if !validRoles[role] && !strings.HasPrefix(role, "type-") {
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
if !validRoles[role] {
return false, fmt.Sprintf("unknown role: %s", role)
}
}
@@ -756,29 +662,17 @@ func sanitizeName(s string) string {
return b.String()
}
// advertRole returns a stable role label for an advert. Follows firmware
// ADV_TYPE_* constants in firmware/src/helpers/AdvertDataHelpers.h:7-12:
// 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR, 5-15 FUTURE.
// Previously this coerced both 0 (NONE) and 5-15 (FUTURE) to "companion",
// silently relabelling unknown/reserved types — see issue #1279 P1 #3.
func advertRole(f *AdvertFlags) string {
if f == nil {
return "companion"
}
switch f.Type {
case 0:
return "none"
case 1:
return "companion"
case 2:
if f.Repeater {
return "repeater"
case 3:
return "room"
case 4:
return "sensor"
default:
return fmt.Sprintf("type-%d", f.Type)
}
if f.Room {
return "room"
}
if f.Sensor {
return "sensor"
}
return "companion"
}
func epochToISO(epoch uint32) string {
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"encoding/hex"
"strings"
"testing"
)
// --- Issue #1211 round-1 protocol-correctness regressions ---
//
// Background: the round-0 PR added a bounds check on `offset > len(buf)` AFTER
// decodePath returned, but did NOT enforce the firmware-level validity rules
// for pathByte:
//
// firmware/src/Packet.cpp:13-18 — isValidPathLen():
// hash_count = path_len & 63
// hash_size = (path_len >> 6) + 1
// hash_size == 4 is RESERVED — invalid
// hash_count * hash_size MUST be <= MAX_PATH_SIZE (64) [MeshCore.h:21]
//
// firmware/src/MeshCore.h:19 — MAX_PACKET_PAYLOAD = 184
//
// A malformed pathByte=0xF6 (hash_size=4, hash_count=54) inside a buffer LARGE
// ENOUGH to hold 216 path bytes would slip past the round-0 bounds check and
// pollute analytics with a bogus "decoded" packet. Similarly, payloads
// exceeding MAX_PACKET_PAYLOAD should be rejected — firmware would never
// produce them on the wire.
// TestDecodePacketRejectsReservedHashSize_Issue1211 — pathByte=0xF6:
// hash_size = (0xF6 >> 6) + 1 = 3 + 1 = 4 ← RESERVED per firmware
// hash_count = 0xF6 & 0x3F = 54
// total path bytes = 4 * 54 = 216
// We provide a buffer with 216 path bytes available, so the round-0 OOB
// guard PASSES — only the firmware-derived isValidPathLen check catches this.
func TestDecodePacketRejectsReservedHashSize_Issue1211(t *testing.T) {
// header (1) + pathByte (1) + 216 path bytes + 8-byte payload = 226 bytes
raw := "12F6" + strings.Repeat("AB", 216) + strings.Repeat("CD", 8)
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error rejecting reserved hash_size=4 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
}
if !strings.Contains(err.Error(), "path") {
t.Errorf("error should mention path; got %q", err)
}
}
// TestDecodePacketRejectsOversizedPath_Issue1211 — pathByte=0xBF:
// hash_size = (0xBF >> 6) + 1 = 2 + 1 = 3
// hash_count = 0xBF & 0x3F = 63
// total path bytes = 3 * 63 = 189 > MAX_PATH_SIZE (64) ← INVALID per firmware
// Buffer holds all 189 path bytes — only the firmware check rejects.
func TestDecodePacketRejectsOversizedPath_Issue1211(t *testing.T) {
raw := "12BF" + strings.Repeat("AB", 189) + strings.Repeat("CD", 8)
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error rejecting hash_count*hash_size > 64 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
}
}
// TestDecodePacketRejectsOversizedPayload_Issue1211 — payload exceeds
// MAX_PACKET_PAYLOAD (184). Firmware (MeshCore.h:19) cannot emit such a
// packet on the wire; the decoder should drop it rather than emit a bogus
// "successfully decoded" record.
func TestDecodePacketRejectsOversizedPayload_Issue1211(t *testing.T) {
// hash_size=1, hash_count=0 → no path bytes, then 200-byte payload
// header=0x12 (DIRECT/ADVERT), pathByte=0x00 → no hops, then payload
// payload length 200 > 184 → must reject
raw := "1200" + strings.Repeat("AA", 200)
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error rejecting payload > MAX_PACKET_PAYLOAD=184 (firmware MeshCore.h:19); got nil, pkt=%+v", pkt)
}
if !strings.Contains(err.Error(), "payload") {
t.Errorf("error should mention payload; got %q", err)
}
}
// TestDecodePath_RejectsReservedHashSize_Issue1211 — adversarial M1: push the
// invalid-path check INTO decodePath so callers can't accidentally rely on the
// downstream OOB guard. Direct unit test on decodePath.
func TestDecodePath_RejectsReservedHashSize_Issue1211(t *testing.T) {
// Plenty of buffer — 216 bytes — so the *test* doesn't hide the check
// behind an OOB failure.
buf := make([]byte, 216)
for i := range buf {
buf[i] = 0xAB
}
_, _, err := decodePath(0xF6, buf, 0)
if err == nil {
t.Fatalf("decodePath should reject pathByte=0xF6 (hash_size=4 reserved); got nil err")
}
}
func TestDecodePath_RejectsOversizedPath_Issue1211(t *testing.T) {
buf := make([]byte, 189)
_, _, err := decodePath(0xBF, buf, 0)
if err == nil {
t.Fatalf("decodePath should reject hash_count*hash_size=189 > MAX_PATH_SIZE=64; got nil err")
}
}
func TestDecodePath_AcceptsValidEncodings_Issue1211(t *testing.T) {
// hash_size=1, hash_count=5 → 5 path bytes — valid per firmware.
buf := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
path, consumed, err := decodePath(0x05, buf, 0)
if err != nil {
t.Fatalf("decodePath rejected valid encoding: %v", err)
}
if consumed != 5 {
t.Errorf("consumed=%d, want 5", consumed)
}
if path.HashCount != 5 || path.HashSize != 1 {
t.Errorf("decode wrong: hashCount=%d hashSize=%d", path.HashCount, path.HashSize)
}
}
// Pin the round-0 tautological assertion. Specific error phrasing required.
// (Kent #1) — `TestDecodePacketBoundsFromWire_Issue1211` used to assert only
// `err != nil`; a generic recover would have passed. Now must contain
// "path length" AND "exceeds buffer".
//
// Use pathByte=0x0A (hash_size=1, hash_count=10) — firmware-VALID encoding
// that claims 10 path bytes; buffer only has 5 → the OOB guard fires (not
// the validity check). This pins the OOB error string specifically.
func TestDecodePacketBoundsFromWireErrorPhrasing_Issue1211(t *testing.T) {
raw := "120A" + strings.Repeat("AA", 5)
_, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "path length") {
t.Errorf("error missing 'path length'; got %q", err)
}
if !strings.Contains(err.Error(), "exceeds buffer") {
t.Errorf("error missing 'exceeds buffer'; got %q", err)
}
}
// silence unused import in case of future trimming
var _ = hex.EncodeToString
+148 -142
View File
@@ -97,6 +97,146 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
}
}
func TestBuildBreakdown_InvalidHex(t *testing.T) {
b := BuildBreakdown("not-hex!")
if len(b.Ranges) != 0 {
t.Errorf("expected empty ranges for invalid hex, got %d", len(b.Ranges))
}
}
func TestBuildBreakdown_TooShort(t *testing.T) {
b := BuildBreakdown("11") // 1 byte — no path byte
if len(b.Ranges) != 0 {
t.Errorf("expected empty ranges for too-short packet, got %d", len(b.Ranges))
}
}
func TestBuildBreakdown_FloodNonAdvert(t *testing.T) {
// Header 0x15: route=1/FLOOD, payload=5/GRP_TXT
// PathByte 0x01: 1 hop, 1-byte hash
// PathHop: AA
// Payload: FF0011
b := BuildBreakdown("1501AAFFFF00")
labels := rangeLabels(b.Ranges)
expect := []string{"Header", "Path Length", "Path", "Payload"}
if !equalLabels(labels, expect) {
t.Errorf("expected labels %v, got %v", expect, labels)
}
// Verify byte positions
assertRange(t, b.Ranges, "Header", 0, 0)
assertRange(t, b.Ranges, "Path Length", 1, 1)
assertRange(t, b.Ranges, "Path", 2, 2)
assertRange(t, b.Ranges, "Payload", 3, 5)
}
func TestBuildBreakdown_TransportFlood(t *testing.T) {
// Header 0x14: route=0/TRANSPORT_FLOOD, payload=5/GRP_TXT
// TransportCodes: AABBCCDD (4 bytes)
// PathByte 0x01: 1 hop, 1-byte hash
// PathHop: EE
// Payload: FF00
b := BuildBreakdown("14AABBCCDD01EEFF00")
assertRange(t, b.Ranges, "Header", 0, 0)
assertRange(t, b.Ranges, "Transport Codes", 1, 4)
assertRange(t, b.Ranges, "Path Length", 5, 5)
assertRange(t, b.Ranges, "Path", 6, 6)
assertRange(t, b.Ranges, "Payload", 7, 8)
}
func TestBuildBreakdown_FloodNoHops(t *testing.T) {
// Header 0x15: FLOOD/GRP_TXT; PathByte 0x00: 0 hops; Payload: AABB
b := BuildBreakdown("150000AABB")
assertRange(t, b.Ranges, "Header", 0, 0)
assertRange(t, b.Ranges, "Path Length", 1, 1)
// No Path range since hashCount=0
for _, r := range b.Ranges {
if r.Label == "Path" {
t.Error("expected no Path range for zero-hop packet")
}
}
assertRange(t, b.Ranges, "Payload", 2, 4)
}
func TestBuildBreakdown_AdvertBasic(t *testing.T) {
// Header 0x11: FLOOD/ADVERT
// PathByte 0x01: 1 hop, 1-byte hash
// PathHop: AA
// Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras)
pubkey := repeatHex("AB", 32)
ts := "00000000" // 4 bytes
sig := repeatHex("CD", 64)
flags := "02"
hex := "1101AA" + pubkey + ts + sig + flags
b := BuildBreakdown(hex)
assertRange(t, b.Ranges, "Header", 0, 0)
assertRange(t, b.Ranges, "Path Length", 1, 1)
assertRange(t, b.Ranges, "Path", 2, 2)
assertRange(t, b.Ranges, "PubKey", 3, 34)
assertRange(t, b.Ranges, "Timestamp", 35, 38)
assertRange(t, b.Ranges, "Signature", 39, 102)
assertRange(t, b.Ranges, "Flags", 103, 103)
}
func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
// flags=0x12: hasLocation bit set
pubkey := repeatHex("00", 32)
ts := "00000000"
sig := repeatHex("00", 64)
flags := "12" // 0x10 = hasLocation
latBytes := "00000000"
lonBytes := "00000000"
hex := "1101AA" + pubkey + ts + sig + flags + latBytes + lonBytes
b := BuildBreakdown(hex)
assertRange(t, b.Ranges, "Latitude", 104, 107)
assertRange(t, b.Ranges, "Longitude", 108, 111)
}
func TestBuildBreakdown_AdvertWithName(t *testing.T) {
// flags=0x82: hasName bit set
pubkey := repeatHex("00", 32)
ts := "00000000"
sig := repeatHex("00", 64)
flags := "82" // 0x80 = hasName
name := "4E6F6465" // "Node" in hex
hex := "1101AA" + pubkey + ts + sig + flags + name
b := BuildBreakdown(hex)
assertRange(t, b.Ranges, "Name", 104, 107)
}
// helpers
func rangeLabels(ranges []HexRange) []string {
out := make([]string, len(ranges))
for i, r := range ranges {
out[i] = r.Label
}
return out
}
func equalLabels(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantEnd int) {
t.Helper()
for _, r := range ranges {
if r.Label == label {
if r.Start != wantStart || r.End != wantEnd {
t.Errorf("range %q: want [%d,%d], got [%d,%d]", label, wantStart, wantEnd, r.Start, r.End)
}
return
}
}
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
}
func TestZeroHopDirectHashSize(t *testing.T) {
// DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02
@@ -140,15 +280,15 @@ func TestZeroHopTransportDirectHashSize(t *testing.T) {
}
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
// because hash_size==4 is reserved. Go decoder must mirror that — even
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
// silently accepted; firmware never emits hash_size==4.
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
_, err := DecodePacket(hex, false)
if err == nil {
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket failed: %v", err)
}
if pkt.Path.HashSize != 0 {
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
}
}
@@ -440,137 +580,3 @@ func TestDecodeAdvertSignatureValidation(t *testing.T) {
t.Error("expected SignatureValid to be nil when validation disabled")
}
}
func TestDecodePacket_TraceSNRValues(t *testing.T) {
// TRACE packet with 3 SNR bytes in header path:
// SNR byte 0: 0x14 = int8(20) → 20/4.0 = 5.0 dB
// SNR byte 1: 0xF4 = int8(-12) → -12/4.0 = -3.0 dB
// SNR byte 2: 0x08 = int8(8) → 8/4.0 = 2.0 dB
// header: DIRECT+TRACE = (0<<6)|(9<<2)|2 = 0x26
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
hex := "2603" + "14F408" + // header + path_byte + 3 SNR bytes
"01000000" + // tag
"02000000" + // authCode
"00" + // flags=0 → path_sz=1
"AABBCCDD" // 4 route hops (1-byte each)
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if pkt.Payload.SNRValues == nil {
t.Fatal("expected SNRValues to be populated")
}
if len(pkt.Payload.SNRValues) != 3 {
t.Fatalf("expected 3 SNR values, got %d", len(pkt.Payload.SNRValues))
}
expected := []float64{5.0, -3.0, 2.0}
for i, want := range expected {
if pkt.Payload.SNRValues[i] != want {
t.Errorf("SNRValues[%d] = %v, want %v", i, pkt.Payload.SNRValues[i], want)
}
}
}
func TestDecodePacket_TraceNoSNRValues(t *testing.T) {
// TRACE with 0 SNR bytes → SNRValues should be nil/empty
hex := "2600" + // header + path_byte (0 hops)
"01000000" + // tag
"02000000" + // authCode
"00" + // flags
"AABB" // 2 route hops
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket error: %v", err)
}
if len(pkt.Payload.SNRValues) != 0 {
t.Errorf("expected empty SNRValues, got %v", pkt.Payload.SNRValues)
}
}
// TestDecodePacketBoundsFromWire_Issue1211 — mirror of ingestor test.
// Malformed pathByte=0xF6 inside a 15-byte buffer triggered
// `slice bounds out of range [218:15]`.
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
raw := "12F6"
for i := 0; i < 13; i++ {
raw += "AA"
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
}
}()
pkt, err := DecodePacket(raw, false)
if err == nil {
t.Fatalf("expected error for malformed packet, got nil; pkt=%+v", pkt)
}
}
// Adv M2: see cmd/ingestor/decoder_test.go — sweep gated on !testing.Short();
// FuzzDecodePacketTruncated below is the real fuzzing target.
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
}
}()
if testing.Short() {
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
}
for hdr := 0; hdr < 256; hdr++ {
for pb := 0; pb < 256; pb++ {
for tail := 0; tail < 20; tail++ {
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)})
for i := 0; i < tail; i++ {
raw += "00"
}
_, _ = DecodePacket(raw, false)
}
}
}
}
// FuzzDecodePacketTruncated — native go fuzz target. Zero panics required.
// Run with: go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/server
func FuzzDecodePacketTruncated(f *testing.F) {
seeds := [][]byte{
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
{0x12, 0x00},
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
}
}()
_, _ = DecodePacket(hex.EncodeToString(data), false)
})
}
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
// carrying >32 bytes of name data is adversarial — the Go decoder must not
// surface attacker-controlled bytes beyond what firmware would ever emit.
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
pubkey := repeatHex("AA", 32)
timestamp := "78563412"
signature := repeatHex("BB", 64)
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
// 64-byte ASCII 'X' name (firmware buffer is only 32 bytes).
name := repeatHex("58", 64)
hex := "1200" + pubkey + timestamp + signature + flags + name
pkt, err := DecodePacket(hex, false)
if err != nil {
t.Fatalf("DecodePacket: %v", err)
}
if got := len(pkt.Payload.Name); got > 32 {
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
}
}
-50
View File
@@ -1,50 +0,0 @@
// Package main — discovered channels (#688).
//
// When a decoded channel message text mentions a previously-unknown hashtag
// channel (e.g. "Hey, I created new channel called #mesh, please join"), we
// auto-register that hashtag so future traffic can be displayed. This file
// owns the parsing helper plus the integration glue exposed via GetChannels.
package main
import "regexp"
// hashtagRE matches MeshCore-style hashtag channel mentions inside free text.
// A valid channel name starts with '#', followed by one or more letters,
// digits, underscore, or dash. Trailing punctuation (.,!?:;) is excluded by
// the character class.
var hashtagRE = regexp.MustCompile(`#[A-Za-z0-9_\-]+`)
// extractHashtagsFromText scans a decoded message text and returns the unique
// hashtag channel mentions found, in first-seen order. The leading '#' is
// preserved so callers can match against canonical channel names directly.
//
// Examples:
// extractHashtagsFromText("hi #mesh and #fun") => []string{"#mesh", "#fun"}
// extractHashtagsFromText("nothing here") => nil
// extractHashtagsFromText("dup #x and #x again") => []string{"#x"}
//
func extractHashtagsFromText(text string) []string {
if text == "" {
return nil
}
matches := hashtagRE.FindAllString(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) < 2 { // bare '#' guard (regex requires 1+ chars but be defensive)
continue
}
if _, ok := seen[m]; ok {
continue
}
seen[m] = struct{}{}
out = append(out, m)
}
if len(out) == 0 {
return nil
}
return out
}
-85
View File
@@ -1,85 +0,0 @@
package main
import (
"reflect"
"testing"
)
// TestExtractHashtagsFromText covers the parsing helper used to discover new
// hashtag channels from decoded message text (issue #688).
func TestExtractHashtagsFromText(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{
name: "single mention from issue body",
in: "Hey, I created new channel called #mesh, please join",
want: []string{"#mesh"},
},
{
name: "multiple mentions preserve order",
in: "join #mesh and #wardriving today",
want: []string{"#mesh", "#wardriving"},
},
{
name: "dedup repeated mentions",
in: "#x then #x again",
want: []string{"#x"},
},
{
name: "ignores trailing punctuation",
in: "check #fun!",
want: []string{"#fun"},
},
{
name: "no hashtag returns nil",
in: "nothing to see here",
want: nil,
},
{
name: "bare # is not a channel",
in: "issue #",
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractHashtagsFromText(tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("extractHashtagsFromText(%q): got %v, want %v", tc.in, got, tc.want)
}
})
}
}
// TestGetChannels_DiscoversHashtagsFromMessages verifies that when a decoded
// CHAN message body mentions a previously-unknown hashtag channel, that
// channel is auto-registered in the GetChannels output (#688).
func TestGetChannels_DiscoversHashtagsFromMessages(t *testing.T) {
// One known channel (#general) where someone announces a new channel #mesh.
pkt := makeGrpTx(198, "general", "Alice: Hey, I created new channel called #mesh, please join", "Alice")
ps := newChannelTestStore([]*StoreTx{pkt})
channels := ps.GetChannels("")
var sawGeneral, sawMesh bool
for _, ch := range channels {
switch ch["name"] {
case "general":
sawGeneral = true
case "#mesh":
sawMesh = true
if d, _ := ch["discovered"].(bool); !d {
t.Errorf("expected discovered=true on #mesh, got %v", ch["discovered"])
}
}
}
if !sawGeneral {
t.Error("expected the source channel 'general' in GetChannels output")
}
if !sawMesh {
t.Errorf("expected discovered hashtag channel '#mesh' in GetChannels output; got %d channels: %+v", len(channels), channels)
}
}
-131
View File
@@ -1,131 +0,0 @@
package main
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// TestComputeAnalyticsDistanceLockHoldDuration asserts that
// computeAnalyticsDistance does NOT hold s.mu.RLock() for the entire
// compute — otherwise readers serialize writers (which need s.mu.Lock for
// ingest / buildDistanceIndex), turning a 3s analytics call into 15s under
// heavy ingest (issue #1239).
//
// Methodology: run N reader goroutines calling computeAnalyticsDistance
// continuously, while the test goroutine measures how long it takes to
// complete W bare mu.Lock()/mu.Unlock() cycles. Each writer cycle must
// wait for ALL currently-holding RLocks to release. Pre-fix, every reader
// holds RLock for the entire compute (~ms), so each writer cycle waits
// behind an active reader → avg cycle hundreds of microseconds to
// milliseconds. Post-fix, readers hold RLock only long enough to grab
// slice headers (microseconds), so writer cycles complete unimpeded.
func TestComputeAnalyticsDistanceLockHoldDuration(t *testing.T) {
if testing.Short() {
t.Skip("skipping concurrency timing test in -short mode")
}
db := setupTestDB(t)
defer db.Close()
store := NewPacketStore(db, nil)
// Populate distHops/distPaths with enough records that compute takes
// a measurable amount of time (~ms). With region="", compute never
// dereferences distHopRecord.tx, so dummy zero-value records suffice.
const N = 20000
hops := make([]distHopRecord, N)
for i := 0; i < N; i++ {
hops[i] = distHopRecord{
FromName: "A",
FromPk: "aa",
ToName: "B",
ToPk: "bb",
Dist: float64(i%500) + 0.5,
Type: []string{"R↔R", "C↔R", "C↔C"}[i%3],
Hash: "h",
Timestamp: "2024-01-01T00:00:00Z",
HourBucket: "2024-01-01-00",
}
}
paths := make([]distPathRecord, 200)
for i := range paths {
paths[i] = distPathRecord{
Hash: "p",
TotalDist: float64(i),
HopCount: 3,
Timestamp: "2024-01-01T00:00:00Z",
Hops: []distHopDetail{
{FromName: "A", FromPk: "aa", ToName: "B", ToPk: "bb", Dist: 1},
},
}
}
store.mu.Lock()
store.distHops = hops
store.distPaths = paths
store.mu.Unlock()
// Sanity: result is non-empty.
r := store.computeAnalyticsDistance("", "")
if r == nil {
t.Fatal("expected non-nil result")
}
if _, ok := r["topHops"]; !ok {
t.Fatal("expected topHops in result")
}
// Background readers churn computeAnalyticsDistance.
const Readers = 8
var stop atomic.Bool
var readerErrs atomic.Int64
var wg sync.WaitGroup
wg.Add(Readers)
for i := 0; i < Readers; i++ {
go func() {
defer wg.Done()
for !stop.Load() {
rr := store.computeAnalyticsDistance("", "")
if rr == nil {
readerErrs.Add(1)
}
if _, ok := rr["topHops"]; !ok {
readerErrs.Add(1)
}
}
}()
}
// Let readers ramp up.
time.Sleep(50 * time.Millisecond)
// Measure writer (mu.Lock/Unlock) throughput.
const WriterCycles = 200
start := time.Now()
for i := 0; i < WriterCycles; i++ {
store.mu.Lock()
store.mu.Unlock()
}
elapsed := time.Since(start)
stop.Store(true)
wg.Wait()
if readerErrs.Load() > 0 {
t.Fatalf("readers returned empty/invalid results: %d", readerErrs.Load())
}
avgMicros := elapsed.Microseconds() / int64(WriterCycles)
t.Logf("avg writer Lock/Unlock cycle: %dµs over %d cycles (total %v) with %d concurrent readers, %d hops, %d paths",
avgMicros, WriterCycles, elapsed, Readers, N, len(paths))
// If readers hold the main RLock for their entire compute, every
// writer Lock cycle waits for an active reader to release: avg cycle
// >> 100µs at this data scale. After the refactor, readers hold the
// main RLock only long enough to snapshot slice headers (<1µs), so
// writer cycles complete in tens of microseconds.
const MaxAvgMicros = 150
if avgMicros > MaxAvgMicros {
t.Fatalf("avg writer Lock/Unlock cycle %dµs exceeds %dµs threshold — computeAnalyticsDistance is holding the main RLock for too long and blocking writers (issue #1239)",
avgMicros, MaxAvgMicros)
}
}
+12 -36
View File
@@ -247,11 +247,6 @@ func TestEvictStale_CleansNodeIndexes(t *testing.T) {
func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
now := time.Now().UTC()
// Create a temp DB for on-demand SQL fetch during eviction
db := setupTestDB(t)
defer db.Close()
store := &PacketStore{
packets: make([]*StoreTx, 0),
byHash: make(map[string]*StoreTx),
@@ -272,33 +267,25 @@ func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
subpathCache: make(map[string]*cachedResult),
rfCacheTTL: 15 * time.Second,
retentionHours: 24,
db: db,
useResolvedPathIndex: true,
}
store.initResolvedPathIndex()
// Create a packet indexed via resolved_path pubkeys
// Create a packet indexed only via resolved_path (no decoded JSON pubkeys)
relayPK := "relay0001abcdef"
txID := 1
obsID := 100
tx := &StoreTx{
ID: txID,
ID: 1,
Hash: "hash_rp_001",
FirstSeen: now.Add(-48 * time.Hour).UTC().Format(time.RFC3339),
}
rpPtr := &relayPK
obs := &StoreObs{
ID: obsID,
TransmissionID: txID,
ID: 100,
TransmissionID: 1,
ObserverID: "obs0",
Timestamp: tx.FirstSeen,
ResolvedPath: []*string{rpPtr},
}
tx.Observations = append(tx.Observations, obs)
// Insert into DB so on-demand SQL fetch works during eviction
db.conn.Exec("INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, '', ?, ?)",
txID, tx.Hash, tx.FirstSeen)
db.conn.Exec("INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp, resolved_path) VALUES (?, ?, 1, ?, ?, ?)",
obsID, txID, `["aa"]`, now.Add(-48*time.Hour).Unix(), `["`+relayPK+`"]`)
tx.ResolvedPath = []*string{rpPtr}
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
@@ -306,9 +293,8 @@ func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
store.byObsID[obs.ID] = obs
store.byObserver["obs0"] = append(store.byObserver["obs0"], obs)
// Index relay via decode-window simulation
store.addToByNode(tx, relayPK)
store.addToResolvedPubkeyIndex(txID, []string{relayPK})
// Index via resolved_path
store.indexByNode(tx)
// Verify indexed
if len(store.byNode[relayPK]) != 1 {
@@ -318,7 +304,7 @@ func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
t.Fatalf("expected nodeHashes[%s] to contain %s", relayPK, tx.Hash)
}
evicted := store.RunEviction()
evicted := store.EvictStale()
if evicted != 1 {
t.Fatalf("expected 1 evicted, got %d", evicted)
}
@@ -330,14 +316,6 @@ func TestEvictStale_CleansResolvedPathNodeIndexes(t *testing.T) {
if _, exists := store.nodeHashes[relayPK]; exists {
t.Fatalf("expected nodeHashes[%s] to be deleted after eviction", relayPK)
}
// Verify resolved pubkey index is cleaned up
h := resolvedPubkeyHash(relayPK)
if len(store.resolvedPubkeyIndex[h]) != 0 {
t.Fatalf("expected resolvedPubkeyIndex to be empty after eviction")
}
if _, exists := store.resolvedPubkeyReverse[txID]; exists {
t.Fatalf("expected resolvedPubkeyReverse to be empty after eviction")
}
}
func TestEvictStale_RunEvictionThreadSafe(t *testing.T) {
@@ -399,10 +377,8 @@ func TestCacheTTLDefaults(t *testing.T) {
if store.collisionCacheTTL != 3600*time.Second {
t.Fatalf("expected default collisionCacheTTL=3600s, got %v", store.collisionCacheTTL)
}
// #1239: default bumped 15s → 60s to smooth cold-miss churn on
// /api/analytics/distance and friends under heavy ingest.
if store.rfCacheTTL != 60*time.Second {
t.Fatalf("expected default rfCacheTTL=60s, got %v", store.rfCacheTTL)
if store.rfCacheTTL != 15*time.Second {
t.Fatalf("expected default rfCacheTTL=15s, got %v", store.rfCacheTTL)
}
}
-56
View File
@@ -1,56 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestHandleNodes_ExposesForeignAdvertField asserts the /api/nodes response
// surfaces the foreign_advert column as a boolean `foreign` field on each
// node, so operators can see bridged/leaked nodes (#730).
func TestHandleNodes_ExposesForeignAdvertField(t *testing.T) {
srv, router := setupTestServer(t)
conn := srv.db.conn
if _, err := conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count, foreign_advert)
VALUES
('PK_LOCAL', 'local-node', 'companion', 37.0, -122.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1, 0),
('PK_FOREIGN', 'foreign-node', 'companion', 50.0, 10.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1, 1)`,
); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Nodes []map[string]interface{} `json:"nodes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, n := range resp.Nodes {
pk, _ := n["public_key"].(string)
f, ok := n["foreign"].(bool)
if !ok {
t.Errorf("node %s: missing/non-bool 'foreign' field, got %T %v", pk, n["foreign"], n["foreign"])
continue
}
got[pk] = f
}
if !got["PK_LOCAL"] == false || got["PK_LOCAL"] != false {
t.Errorf("PK_LOCAL foreign=%v, want false", got["PK_LOCAL"])
}
if got["PK_FOREIGN"] != true {
t.Errorf("PK_FOREIGN foreign=%v, want true", got["PK_FOREIGN"])
}
}
-38
View File
@@ -1,38 +0,0 @@
// Package main: from_pubkey backfill shim (issue #1287).
//
// The actual backfill moved to cmd/ingestor (see
// cmd/ingestor/maintenance.go: BackfillFromPubkey) because the server
// is the read path and may not write to SQLite (#1283/#1287). This
// file retains the snapshot getter so /api/healthz still compiles —
// it always reports done=true with zero counters. Operators monitor
// the ingestor's stats file for true progress.
package main
import "sync"
var (
fromPubkeyBackfillMu sync.RWMutex
fromPubkeyBackfillTotal int64
fromPubkeyBackfillProcessed int64
fromPubkeyBackfillDone = true
)
// fromPubkeyBackfillSnapshot returns the current backfill progress.
// In the post-#1287 world the server does not run the backfill, so
// the snapshot is always done=true with zeros. The real progress
// lives in the ingestor stats file.
func fromPubkeyBackfillSnapshot() (total, processed int64, done bool) {
fromPubkeyBackfillMu.RLock()
defer fromPubkeyBackfillMu.RUnlock()
return fromPubkeyBackfillTotal, fromPubkeyBackfillProcessed, fromPubkeyBackfillDone
}
// fromPubkeyBackfillReset is a test helper used by legacy tests; in
// the post-#1287 world it merely resets the static snapshot fields.
func fromPubkeyBackfillReset() {
fromPubkeyBackfillMu.Lock()
fromPubkeyBackfillTotal = 0
fromPubkeyBackfillProcessed = 0
fromPubkeyBackfillDone = true
fromPubkeyBackfillMu.Unlock()
}
-20
View File
@@ -14,22 +14,6 @@ replace github.com/meshcore-analyzer/geofilter => ../../internal/geofilter
replace github.com/meshcore-analyzer/sigvalidate => ../../internal/sigvalidate
require github.com/meshcore-analyzer/packetpath v0.0.0
replace github.com/meshcore-analyzer/packetpath => ../../internal/packetpath
require github.com/meshcore-analyzer/dbconfig v0.0.0
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
require github.com/meshcore-analyzer/perfio v0.0.0
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
require github.com/meshcore-analyzer/dbschema v0.0.0
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -41,7 +25,3 @@ require (
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
require github.com/meshcore-analyzer/prunequeue v0.0.0
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"log"
"time"
)
// migrateContentHashesAsync recomputes content hashes in batches after the
// server is already serving HTTP. Packets whose hash changes are updated in
// both the DB and the in-memory byHash index. The migration is idempotent:
// once all hashes match the current formula it completes instantly.
func migrateContentHashesAsync(store *PacketStore, batchSize int, yieldDuration time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Printf("[hash-migrate] panic recovered: %v", r)
}
store.hashMigrationComplete.Store(true)
}()
// Snapshot the packet slice length under lock (packets only grow).
store.mu.RLock()
total := len(store.packets)
store.mu.RUnlock()
migrated := 0
for offset := 0; offset < total; offset += batchSize {
end := offset + batchSize
if end > total {
end = total
}
// Collect stale hashes in this batch under RLock.
type hashUpdate struct {
tx *StoreTx
oldHash string
newHash string
}
var updates []hashUpdate
store.mu.RLock()
for _, tx := range store.packets[offset:end] {
if tx.RawHex == "" {
continue
}
newHash := ComputeContentHash(tx.RawHex)
if newHash != tx.Hash {
updates = append(updates, hashUpdate{tx: tx, oldHash: tx.Hash, newHash: newHash})
}
}
store.mu.RUnlock()
if len(updates) == 0 {
continue
}
// Write batch to DB in a single transaction.
dbTx, err := store.db.conn.Begin()
if err != nil {
log.Printf("[hash-migrate] begin tx: %v", err)
continue
}
stmt, err := dbTx.Prepare("UPDATE transmissions SET hash = ? WHERE id = ?")
if err != nil {
log.Printf("[hash-migrate] prepare: %v", err)
dbTx.Rollback()
continue
}
for _, u := range updates {
if _, err := stmt.Exec(u.newHash, u.tx.ID); err != nil {
// UNIQUE constraint = two old hashes map to the same new hash (duplicate).
// Merge observations to the surviving tx, delete the duplicate.
log.Printf("[hash-migrate] tx %d collides — merging duplicate", u.tx.ID)
var survID int
if err2 := dbTx.QueryRow("SELECT id FROM transmissions WHERE hash = ?", u.newHash).Scan(&survID); err2 == nil {
dbTx.Exec("UPDATE observations SET transmission_id = ? WHERE transmission_id = ?", survID, u.tx.ID)
dbTx.Exec("DELETE FROM transmissions WHERE id = ?", u.tx.ID)
u.newHash = "" // mark for in-memory removal only
}
}
}
stmt.Close()
if err := dbTx.Commit(); err != nil {
log.Printf("[hash-migrate] commit: %v", err)
continue
}
// Update in-memory index under write lock.
store.mu.Lock()
for _, u := range updates {
delete(store.byHash, u.oldHash)
if u.newHash == "" {
// Merged duplicate — remove from packets slice and indexes.
delete(store.byTxID, u.tx.ID)
// Move observations to survivor if present.
if surv := store.byHash[ComputeContentHash(u.tx.RawHex)]; surv != nil {
for _, obs := range u.tx.Observations {
surv.Observations = append(surv.Observations, obs)
surv.ObservationCount++
}
}
} else {
u.tx.Hash = u.newHash
store.byHash[u.newHash] = u.tx
}
}
store.mu.Unlock()
migrated += len(updates)
// Yield to let HTTP handlers run.
time.Sleep(yieldDuration)
}
if migrated > 0 {
log.Printf("[hash-migrate] Migrated %d content hashes to new formula", migrated)
}
}
-78
View File
@@ -1,78 +0,0 @@
package main
import (
"testing"
"time"
)
func TestMigrateContentHashesAsync(t *testing.T) {
db := setupTestDBv2(t)
store := NewPacketStore(db, nil)
// Insert a packet with a manually wrong hash (simulating old formula).
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
correctHash := ComputeContentHash(rawHex)
wrongHash := "deadbeef12345678"
_, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type)
VALUES (?, ?, datetime('now'), 0, 2)`, rawHex, wrongHash)
if err != nil {
t.Fatal(err)
}
if err := store.Load(); err != nil {
t.Fatal(err)
}
if store.byHash[wrongHash] == nil {
t.Fatal("expected packet under wrong hash before migration")
}
migrateContentHashesAsync(store, 100, time.Millisecond)
if !store.hashMigrationComplete.Load() {
t.Error("expected hashMigrationComplete to be true")
}
if store.byHash[wrongHash] != nil {
t.Error("old hash should be removed from index")
}
if store.byHash[correctHash] == nil {
t.Error("new hash should be in index")
}
var dbHash string
err = db.conn.QueryRow("SELECT hash FROM transmissions WHERE raw_hex = ?", rawHex).Scan(&dbHash)
if err != nil {
t.Fatal(err)
}
if dbHash != correctHash {
t.Errorf("DB hash = %s, want %s", dbHash, correctHash)
}
}
func TestMigrateContentHashesAsync_NoOp(t *testing.T) {
db := setupTestDBv2(t)
store := NewPacketStore(db, nil)
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
correctHash := ComputeContentHash(rawHex)
_, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type)
VALUES (?, ?, datetime('now'), 0, 2)`, rawHex, correctHash)
if err != nil {
t.Fatal(err)
}
if err := store.Load(); err != nil {
t.Fatal(err)
}
migrateContentHashesAsync(store, 100, time.Millisecond)
if !store.hashMigrationComplete.Load() {
t.Error("expected hashMigrationComplete to be true")
}
if store.byHash[correctHash] == nil {
t.Error("hash should remain in index")
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"sync/atomic"
)
// readiness tracks whether background init goroutines have completed.
// Set to 1 once store.Load, pickBestObservation, and neighbor graph build are done.
var readiness atomic.Int32
// handleHealthz returns 200 when the server is ready to serve queries,
// or 503 while background initialization is still running.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if readiness.Load() == 0 {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": false,
"reason": "loading",
})
return
}
var loadedTx, loadedObs int
if s.store != nil {
s.store.mu.RLock()
loadedTx = len(s.store.packets)
for _, p := range s.store.packets {
loadedObs += len(p.Observations)
}
s.store.mu.RUnlock()
}
// #1143 (M2): expose from_pubkey backfill progress so operators can
// see whether the legacy ADVERT backfill is still running. NULL rows
// produce empty attribution results during the in-flight window.
// Cycle-3 m2c: snapshot all three fields under a single read lock so
// /api/healthz never observes a torn state (e.g. done=true with
// processed<total).
bfTotal, bfProcessed, bfDone := fromPubkeyBackfillSnapshot()
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": true,
"loadedTx": loadedTx,
"loadedObs": loadedObs,
"from_pubkey_backfill": map[string]interface{}{
"total": bfTotal,
"processed": bfProcessed,
"done": bfDone,
},
})
}
-80
View File
@@ -1,80 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthzNotReady(t *testing.T) {
// Ensure readiness is 0 (not ready)
readiness.Store(0)
defer readiness.Store(0)
srv := &Server{store: &PacketStore{}}
req := httptest.NewRequest("GET", "/api/healthz", nil)
w := httptest.NewRecorder()
srv.handleHealthz(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["ready"] != false {
t.Fatalf("expected ready=false, got %v", resp["ready"])
}
if resp["reason"] != "loading" {
t.Fatalf("expected reason=loading, got %v", resp["reason"])
}
}
func TestHealthzReady(t *testing.T) {
readiness.Store(1)
defer readiness.Store(0)
srv := &Server{store: &PacketStore{}}
req := httptest.NewRequest("GET", "/api/healthz", nil)
w := httptest.NewRecorder()
srv.handleHealthz(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["ready"] != true {
t.Fatalf("expected ready=true, got %v", resp["ready"])
}
if _, ok := resp["loadedTx"]; !ok {
t.Fatal("missing loadedTx field")
}
if _, ok := resp["loadedObs"]; !ok {
t.Fatal("missing loadedObs field")
}
}
func TestHealthzAntiTautology(t *testing.T) {
// When readiness is 0, must NOT return 200
readiness.Store(0)
defer readiness.Store(0)
srv := &Server{store: &PacketStore{}}
req := httptest.NewRequest("GET", "/api/healthz", nil)
w := httptest.NewRecorder()
srv.handleHealthz(w, req)
if w.Code == http.StatusOK {
t.Fatal("anti-tautology: handler returned 200 when readiness=0; gating is broken")
}
}
-142
View File
@@ -1,142 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"testing"
)
// BenchmarkBuildHopContextPubkeys exercises the hot per-tx context builder
// at a realistic shape: ~50 nodes (mixed role), 6-hop path, sender + observer
// pubkey populated. Required by AGENTS.md hot-path benchmark rule (#1197 r1
// carmack #6).
func BenchmarkBuildHopContextPubkeys(b *testing.B) {
nodes := make([]nodeInfo, 0, 64)
for i := 0; i < 50; i++ {
nodes = append(nodes, nodeInfo{
PublicKey: fmt.Sprintf("%012x", i*0x101010101),
Role: "repeater",
Name: fmt.Sprintf("N%d", i),
ObservationCount: i * 3,
Lat: 37.0 + float64(i)*0.01,
Lon: -122.0 - float64(i)*0.01,
HasGPS: true,
})
}
pm := buildPrefixMap(nodes)
hops := []string{
nodes[1].PublicKey[:6], nodes[3].PublicKey[:6], nodes[5].PublicKey[:6],
nodes[7].PublicKey[:6], nodes[9].PublicKey[:6], nodes[11].PublicKey[:6],
}
pathJSON, _ := json.Marshal(hops)
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": "cc4444444444"})
tx := &StoreTx{
PathJSON: string(pathJSON),
DecodedJSON: string(decoded),
ObserverID: "dd5555555555",
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = buildHopContextPubkeys(tx, pm)
}
}
// BenchmarkBuildAggregateHopContextPubkeys exercises the aggregate context
// builder at the hot scale called out by #1197 (subpath/topology bulk
// aggregations): ~5k txs sharing a node pool of ~50 prefixes. The aggregate
// builder unions per-tx contexts with its own dedupe map; this benchmark
// gives us a baseline so a future regression (e.g. accidental O(n²) dedupe)
// shows up immediately. No assertion threshold yet — see #1199 item 3.
func BenchmarkBuildAggregateHopContextPubkeys(b *testing.B) {
const numNodes = 50
const numTxs = 5000
nodes := make([]nodeInfo, 0, numNodes)
for i := 0; i < numNodes; i++ {
nodes = append(nodes, nodeInfo{
PublicKey: fmt.Sprintf("%012x", i*0x101010101),
Role: "repeater",
Name: fmt.Sprintf("N%d", i),
ObservationCount: i * 3,
Lat: 37.0 + float64(i)*0.01,
Lon: -122.0 - float64(i)*0.01,
HasGPS: true,
})
}
pm := buildPrefixMap(nodes)
txs := make([]*StoreTx, 0, numTxs)
for i := 0; i < numTxs; i++ {
hops := []string{
nodes[(i+1)%numNodes].PublicKey[:6],
nodes[(i+3)%numNodes].PublicKey[:6],
nodes[(i+5)%numNodes].PublicKey[:6],
nodes[(i+7)%numNodes].PublicKey[:6],
nodes[(i+9)%numNodes].PublicKey[:6],
nodes[(i+11)%numNodes].PublicKey[:6],
}
pathJSON, _ := json.Marshal(hops)
decoded, _ := json.Marshal(map[string]interface{}{
"pubKey": fmt.Sprintf("cc%010x", i),
})
txs = append(txs, &StoreTx{
PathJSON: string(pathJSON),
DecodedJSON: string(decoded),
ObserverID: fmt.Sprintf("dd%010x", i%32),
})
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = buildAggregateHopContextPubkeys(txs, pm)
}
}
// TestBuildAggregateHopContextPubkeysSmoke is a tiny correctness anchor for
// the aggregate helper: union over per-tx contexts, deduped. Lives next to
// the benchmark so the file ships an assertion (preflight gate). See #1199
// item 3.
func TestBuildAggregateHopContextPubkeysSmoke(t *testing.T) {
pm := buildPrefixMap([]nodeInfo{{PublicKey: "aabbccddeeff"}})
d1, _ := json.Marshal(map[string]interface{}{"pubKey": "1111111111"})
d2, _ := json.Marshal(map[string]interface{}{"pubKey": "2222222222"})
d3, _ := json.Marshal(map[string]interface{}{"pubKey": "1111111111"}) // dup
txs := []*StoreTx{
{DecodedJSON: string(d1)},
{DecodedJSON: string(d2)},
{DecodedJSON: string(d3)},
}
got := buildAggregateHopContextPubkeys(txs, pm)
if len(got) != 2 {
t.Fatalf("expected 2 deduped pubkeys, got %d (%v)", len(got), got)
}
// Content assertion — proves dedup actually keeps the right pubkeys
// (not just any 2). Without this the test would pass even if dedup
// returned, e.g., one pubkey twice or two unrelated pubkeys. See
// #1199 r1 review (adv #1).
wantSet := map[string]bool{"1111111111": true, "2222222222": true}
gotSet := map[string]bool{}
for _, pk := range got {
gotSet[pk] = true
}
for pk := range wantSet {
if !gotSet[pk] {
t.Fatalf("expected pubkey %q in deduped result, got %v", pk, got)
}
}
for pk := range gotSet {
if !wantSet[pk] {
t.Fatalf("unexpected pubkey %q in deduped result, got %v", pk, got)
}
}
if buildAggregateHopContextPubkeys(nil, pm) != nil {
t.Fatalf("nil tx slice must yield nil")
}
if buildAggregateHopContextPubkeys(txs, nil) != nil {
t.Fatalf("nil prefix map must yield nil")
}
}
-113
View File
@@ -1,113 +0,0 @@
package main
import (
"testing"
"time"
)
// Issue #1229 (Option C): edge source-diversity confidence weighting.
//
// The tier-1 affinity scorer must demote edges contributed by a single
// observer relative to edges corroborated by multiple distinct observers.
// Without this guard, one observer with a chatty link can dominate the
// global graph and force resolution to the "wrong" candidate in a region
// it doesn't actually cover.
//
// Fixture (two "8a" candidates from the same anchor's neighborhood):
// candX: 25 contributions from 1 observer (single-source, suspect)
// candY: 30 contributions from 6 distinct observers (corroborated)
//
// Raw count score:
// candX score ≈ 0.25, candY score ≈ 0.30 — ratio ≈ 1.2× (below 3×, falls
// through to tier 2). Without confidence weighting tier 2 would pick
// candX because we placed it geo-near the anchor — exactly the
// cross-region pollution failure mode described in the issue.
//
// Confidence-weighted score (multiplier = min(1, |observers|/3)):
// candX = 0.25 × (1/3) ≈ 0.083
// candY = 0.30 × 1.0 = 0.30
// ratio ≈ 3.6× — clears affinityConfidenceRatio, tier-1 returns candY
// with method "neighbor_affinity".
func seedAffinityFromObservers(g *NeighborGraph, anchor, candPK, prefix string, observers []string, perObserver int) {
now := time.Now()
step := 0
for _, obs := range observers {
for i := 0; i < perObserver; i++ {
g.upsertEdge(anchor, candPK, prefix, obs, nil, now.Add(-time.Duration(step)*time.Minute))
step++
}
}
}
func TestResolveWithContext_Tier1_ConfidencePrefersMultiObserverEdge(t *testing.T) {
nodes := []nodeInfo{
// candX: placed near the anchor so tier-2 (geo) would pick it.
{PublicKey: "8aaaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 34.06, Lon: -118.26},
// candY: far from anchor; only source-diversity confidence rescues it.
{PublicKey: "8abbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 47.6, Lon: -122.3},
{PublicKey: "ffeeeeeeeeee", Role: "repeater", Name: "anchor", HasGPS: true, Lat: 34.05, Lon: -118.25},
}
anchor := "ffeeeeeeeeee"
g := NewNeighborGraph()
// candX: 1 observer × 25 obs → single-source, demoted to 1/3 weight.
seedAffinityFromObservers(g, anchor, "8aaaaaaaaaaa", "8a",
[]string{"obs1"}, 25)
// candY: 6 distinct observers × 5 obs each = 30 obs → full weight.
seedAffinityFromObservers(g, anchor, "8abbbbbbbbbb", "8a",
[]string{"obs1", "obs2", "obs3", "obs4", "obs5", "obs6"}, 5)
pm := buildPrefixMap(nodes)
r, method, score := pm.resolveWithContext("8a", []string{anchor}, g)
if r == nil {
t.Fatal("expected non-nil candidate")
}
if r.Name != "candY" {
t.Fatalf("want candY (corroborated by 6 observers); got %s via %s score=%v",
r.Name, method, score)
}
if method != "neighbor_affinity" {
t.Fatalf("want method=neighbor_affinity (confidence-weighted tier 1); got %s", method)
}
}
// Sanity gate on the source-diversity counter itself: repeated contributions
// from the same observer must NOT inflate the observer-set count, but
// contributions from new observers must increment it.
func TestNeighborEdge_ObserverSetIsDistinct(t *testing.T) {
g := NewNeighborGraph()
now := time.Now()
// 10 contributions from obs1 — set size must stay 1.
for i := 0; i < 10; i++ {
g.upsertEdge("aa11", "bb22", "bb", "obs1", nil, now)
}
// 1 contribution each from obs2..obs4 — set size grows to 4.
g.upsertEdge("aa11", "bb22", "bb", "obs2", nil, now)
g.upsertEdge("aa11", "bb22", "bb", "obs3", nil, now)
g.upsertEdge("aa11", "bb22", "bb", "obs4", nil, now)
edges := g.Neighbors("aa11")
if len(edges) != 1 {
t.Fatalf("expected 1 edge; got %d", len(edges))
}
e := edges[0]
if len(e.Observers) != 4 {
t.Fatalf("expected 4 distinct observers; got %d (%v)", len(e.Observers), e.Observers)
}
if e.Count != 13 {
t.Fatalf("expected count=13 (10+3); got %d", e.Count)
}
if got := e.Confidence(); got != 1.0 {
t.Fatalf("Confidence() with 4 observers: want 1.0 (saturated); got %v", got)
}
// Single-observer edge must report degraded confidence.
g.upsertEdge("aa11", "cc33", "cc", "obs1", nil, now)
for _, ee := range g.Neighbors("aa11") {
if ee.NodeA == "cc33" || ee.NodeB == "cc33" {
if got := ee.Confidence(); got >= 1.0 || got <= 0 {
t.Fatalf("Confidence() single-observer: want in (0,1); got %v", got)
}
}
}
}
-242
View File
@@ -1,242 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"testing"
"time"
)
// End-to-end fixture test for issue #1201 sub-task 4.
//
// Builds a *PacketStore with multi-candidate-prefix nodes (intentional 1-byte
// prefix collisions across continents) and asserts that the top-hops ranking
// produced by buildDistanceIndex honors the resolver's neighbor-affinity
// choice, NOT the misresolution interpretations that would survive without
// context.
//
// Mutation-test sentinel: this test MUST fail if any call site that feeds
// per-tx context to the hop resolver is reverted to `nil`. Reproduce by
// replacing the `setContext(buildHopContextPubkeys(tx, pm))` call inside
// buildDistanceIndex (cmd/server/store.go, in the per-tx loop) with
// `setContext(nil)` and re-running this test — it fails with a "CA↔CA hop
// missing, saw 72dddd→8acccc (Berlin↔Berlin)" assertion. See PR body for
// the full mutation log.
//
// Fixture layout (no real handles — generic placeholders only):
// Prefix "72" (4 candidates, all repeaters with GPS):
// - 72aa… SLO-CA (35.30, -120.70) obsCount=5
// - 72bb… LA-CA (34.05, -118.25) obsCount=5
// - 72cc… NYC (40.70, -74.00) obsCount=5
// - 72dd… Berlin (52.50, 13.40) obsCount=200 ← would win tier-3
// Prefix "8a" (3 candidates):
// - 8aaa… SF-CA (37.00, -120.50) obsCount=5
// - 8abb… CA-other (36.50, -119.50) obsCount=5
// - 8acc… Berlin (52.60, 13.50) obsCount=200 ← would win tier-3
//
// Sender: CA repeater at (36.0, -120.0), pubkey "ccc…".
// Observer: CA repeater at (36.2, -120.2), pubkey "dddd…".
//
// Affinity graph: strong edges sender↔72aa and sender↔8aaa
// (count ≥ affinityMinObservations, recent timestamps).
//
// 50 synthetic transmissions, all with path ["72","8a"]. With per-tx context
// piped through (sender pubkey is added by buildHopContextPubkeys), tier 1
// picks the CA candidates. Without it, tier 3 picks the Berlin candidates
// and the Berlin↔Berlin hop (~11 km — under 300 km cap) becomes the only
// surviving hop. The test asserts the inverse: CA↔CA hop present, no
// Berlin pubkeys appear in distHops.
const (
t1201Sender = "ccccccccccccccc1"
t1201Observer = "dddddddddddddddd"
t1201_72aa = "72aaaaaaaaaaaaaa" // SLO
t1201_72bb = "72bbbbbbbbbbbbbb" // LA
t1201_72cc = "72cccccccccccccc" // NYC
t1201_72dd = "72dddddddddddddd" // Berlin
t1201_8aaa = "8aaaaaaaaaaaaaaa" // SF
t1201_8abb = "8abbbbbbbbbbbbbb" // CA-other
t1201_8acc = "8acccccccccccccc" // Berlin
)
type t1201Node struct {
pk string
lat, lon float64
obsCount int
}
func t1201InsertNode(t *testing.T, db *DB, n t1201Node) {
t.Helper()
// NOTE: `obsCount` is written to the `advert_count` column. That column
// is what resolveWithContext reads (via nodeInfo.ObservationCount /
// betterByObsCount) as the tier-3 popularity tiebreak. If the tier-3
// source column ever changes (e.g. observations.packet_count), the
// "Berlin would win tier-3" premise of this fixture weakens silently —
// update both this insert and the candidate scoring assertions.
_, err := db.conn.Exec(
`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) VALUES (?, ?, 'repeater', ?, ?, ?, '2026-01-01T00:00:00Z', ?)`,
n.pk, "node-"+n.pk[:4], n.lat, n.lon, "2026-05-01T00:00:00Z", n.obsCount,
)
if err != nil {
t.Fatalf("insert node %s: %v", n.pk, err)
}
}
// TestTopHopsRespectsContextAcrossAllCallSites is the end-to-end regression
// sentinel for issue #1201. See file-header docblock for design.
func TestTopHopsRespectsContextAcrossAllCallSites(t *testing.T) {
db := setupTestDB(t)
// Insert all repeater nodes with GPS + observation counts.
nodes := []t1201Node{
{t1201Sender, 36.0, -120.0, 50},
{t1201Observer, 36.2, -120.2, 60},
{t1201_72aa, 35.30, -120.70, 5},
{t1201_72bb, 34.05, -118.25, 5},
{t1201_72cc, 40.70, -74.00, 5},
{t1201_72dd, 52.50, 13.40, 200}, // would win tier-3 without context
{t1201_8aaa, 37.00, -120.50, 5},
{t1201_8abb, 36.50, -119.50, 5},
{t1201_8acc, 52.60, 13.50, 200}, // would win tier-3 without context
}
for _, n := range nodes {
t1201InsertNode(t, db, n)
}
// Insert observer row (referenced by observations via observer_idx).
if _, err := db.conn.Exec(
`INSERT INTO observers (id, name, last_seen, first_seen, packet_count) VALUES (?, ?, ?, '2026-01-01T00:00:00Z', 100)`,
t1201Observer, "obs-ca", "2026-05-01T00:00:00Z",
); err != nil {
t.Fatal(err)
}
// Insert 50 transmissions, each with path ["72","8a"], sender pubkey
// embedded in decoded_json (read by buildHopContextPubkeys via ParsedDecoded).
// Wrapped in a single BEGIN/COMMIT — shaves wall time on slow CI runners.
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": t1201Sender, "type": "data"})
pathJSON := `["72","8a"]`
baseTime := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
tx, err := db.conn.Begin()
if err != nil {
t.Fatalf("begin tx: %v", err)
}
for i := 0; i < 50; i++ {
ts := baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)
hash := fmt.Sprintf("hash1201_%03d", i)
res, err := tx.Exec(
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) VALUES (?, ?, ?, 1, 1, ?)`,
"AA", hash, ts, string(decoded),
)
if err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
txID, _ := res.LastInsertId()
if _, err := tx.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?, 1, 12.0, -90, ?, ?)`,
txID, pathJSON, baseTime.Add(time.Duration(i)*time.Minute).Unix(),
); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit tx: %v", err)
}
// Build store and seed graph BEFORE Load() — Load calls buildDistanceIndex
// which reads s.graph; if it's nil, tier 1 is skipped.
store := NewPacketStore(db, nil)
g := NewNeighborGraph()
// Strong sender↔72aa and sender↔8aaa edges (count well above
// affinityMinObservations, recent timestamp).
now := time.Now()
for i := 0; i < 100; i++ {
g.upsertEdge(t1201Sender, t1201_72aa, "72", t1201Observer, nil, now.Add(-time.Duration(i)*time.Minute))
g.upsertEdge(t1201Sender, t1201_8aaa, "8a", t1201Observer, nil, now.Add(-time.Duration(i)*time.Minute))
}
// Weaker sender↔Berlin edges so even if someone weakens the ratio guard,
// the CA candidates still dominate by 100× — and the Berlin counts in
// node table don't bleed through.
for i := 0; i < 2; i++ {
g.upsertEdge(t1201Sender, t1201_72dd, "72", t1201Observer, nil, now.Add(-time.Duration(i)*time.Hour))
}
store.graph.Store(g)
if err := store.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
// Inspect precomputed distance index.
store.mu.RLock()
hops := make([]distHopRecord, len(store.distHops))
copy(hops, store.distHops)
store.mu.RUnlock()
if len(hops) == 0 {
t.Fatal("buildDistanceIndex produced zero hops; expected at least the CA↔CA leg")
}
// Assertion 1: CA↔CA hop between 72aa (SLO) and 8aaa (SF) must appear.
pairHas := func(h *distHopRecord, a, b string) bool {
return (h.FromPk == a && h.ToPk == b) || (h.FromPk == b && h.ToPk == a)
}
var sawCAPair bool
for i := range hops {
if pairHas(&hops[i], t1201_72aa, t1201_8aaa) {
sawCAPair = true
break
}
}
if !sawCAPair {
// Surface what we did see so failure is debuggable.
seen := []string{}
for i := range hops {
seen = append(seen, fmt.Sprintf("%s→%s@%.1fkm", hops[i].FromPk[:6], hops[i].ToPk[:6], hops[i].Dist))
if i >= 5 {
seen = append(seen, "…")
break
}
}
t.Fatalf("expected CA↔CA hop (72aa↔8aaa) in distHops; saw %v", seen)
}
// Assertion 2: no hop should reference Berlin pubkeys. The Berlin↔Berlin
// pair is the misresolution-only outcome that emerges when context is
// dropped; its presence proves a regression at one of the call sites.
// Note: 72cc (NYC) is omitted from this guard — its obsCount=5 would
// never win the tier-3 obsCount-200 fight against Berlin, so checking
// for it was redundant defense. Berlin pubkeys carry the signal.
berlinPKs := map[string]bool{
t1201_72dd: true,
t1201_8acc: true,
}
for i := range hops {
if berlinPKs[hops[i].FromPk] || berlinPKs[hops[i].ToPk] {
t.Fatalf("misresolution hop leaked into distHops: %s→%s dist=%.1fkm (any call site dropped context?)",
hops[i].FromPk, hops[i].ToPk, hops[i].Dist)
}
}
// Assertion 3: top-hop max distance must be consistent with CA geometry,
// well under the continent-spanning misresolution range.
maxDist := 0.0
for i := range hops {
if hops[i].Dist > maxDist {
maxDist = hops[i].Dist
}
}
// SLO→SF ≈ 190 km; LA→SF ≈ 560 km (>300 cap → dropped). Cap should
// keep max well under 300. We drop the lower-bound "suspiciously small"
// floor: the >300 ceiling carries the misresolution signal on its own,
// and a tight floor would false-fire if a future cap tightening or
// fixture tweak legitimately shrinks the surviving CA↔CA leg.
if maxDist > 300 {
t.Fatalf("top-hop max distance %.1fkm exceeds 300km cap — resolver picked continent-spanning candidate", maxDist)
}
}
-204
View File
@@ -1,204 +0,0 @@
package main
import (
"testing"
"time"
)
// Regression coverage for the hop disambiguator's tier-1 (neighbor affinity)
// path of pm.resolveWithContext. Issue #1201: tier 1 is the strongest
// disambiguation signal but was untested by any test we shipped — only
// upstream tests (that predate the context-plumbing fix in #1198) exercised
// it. These tests pin tier-1 behavior so any future refactor that disables
// tier 1, reorders priorities, or drops the Ambiguous-edge guard will fail.
//
// Naming convention for fixture pubkeys: lowercase hex placeholders only;
// no real observer/operator handles (per AGENTS.md PII rules).
// ─── helpers ───────────────────────────────────────────────────────────────────
// seedAffinity adds n observations of an edge between obsPK and candPK at
// recent timestamps. Count ≥ affinityMinObservations is required for tier 1
// to consider an edge.
func seedAffinity(g *NeighborGraph, obsPK, candPK, prefix, observer string, n int) {
now := time.Now()
for i := 0; i < n; i++ {
g.upsertEdge(obsPK, candPK, prefix, observer, nil, now.Add(-time.Duration(i)*time.Minute))
}
}
// Standard fixture shared by most tier-1 tests: two "72" candidates and
// (when needed) an anchor pubkey co-located with candY. candX is far
// (Seattle), candY is near LA — so geo proximity to anchor picks candY
// unless tier-1 fires for candX.
var tier1StdNodes = []nodeInfo{
{PublicKey: "72aaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 47.6, Lon: -122.3}, // Seattle (far)
{PublicKey: "72bbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 34.05, Lon: -118.25}, // LA (near anchor)
{PublicKey: "ffeeeeeeeeee", Role: "repeater", Name: "anchor", HasGPS: true, Lat: 34.1, Lon: -118.3},
}
const tier1Anchor = "ffeeeeeeeeee"
// ─── sub-task 1: tier-1 explicit tests (table-driven) ──────────────────────────
// TestResolveWithContext_Tier1 collapses what were five near-identical
// per-branch functions into one table-driven test. Each row exercises
// exactly one tier-1 branch (strong-pick X, strong-pick Y, ambiguous-skip,
// tier-1-beats-tier-2, fall-throughs). Adding a new tier-1 case is a
// one-line addition.
//
// Mirror-pair rows (StrongAffinityPicksX / PicksY) prevent a "tier-1 always
// returns first candidate" tautology — the score MUST be consulted because
// flipping the weights flips the winner.
func TestResolveWithContext_Tier1(t *testing.T) {
type seed struct {
obsPK, candPK, prefix string
count int
}
cases := []struct {
name string
nodes []nodeInfo
ctxPK string
useNilGraph bool // skip graph entirely (tests `graph != nil` guard)
seeds []seed // tier-1 affinity seeds
markAmbiguous [2]string // if non-empty pair, mark that edge ambiguous
extraGraphSeed *seed // seed unrelated to ctxPK (empty-for-context fixture)
wantName string
wantMethod string
}{
{
name: "StrongAffinityPicksX",
nodes: []nodeInfo{{PublicKey: "72aaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 35.3, Lon: -120.7}, {PublicKey: "72bbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 34.0, Lon: -118.2}},
ctxPK: "ccccccccccc1",
seeds: []seed{{"ccccccccccc1", "72aaaaaaaaaa", "72", 100}, {"ccccccccccc1", "72bbbbbbbbbb", "72", 1}},
wantName: "candX",
wantMethod: "neighbor_affinity",
},
{
name: "StrongAffinityPicksY",
nodes: []nodeInfo{{PublicKey: "72aaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 35.3, Lon: -120.7}, {PublicKey: "72bbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 34.0, Lon: -118.2}},
ctxPK: "ccccccccccc1",
seeds: []seed{{"ccccccccccc1", "72aaaaaaaaaa", "72", 1}, {"ccccccccccc1", "72bbbbbbbbbb", "72", 100}},
wantName: "candY",
wantMethod: "neighbor_affinity",
},
{
// Strong edge to candX exists but is flagged Ambiguous → tier 1
// must skip it and tier 2 (geo) picks candY (near anchor).
name: "AmbiguousEdgeSkipsToTier2",
nodes: tier1StdNodes,
ctxPK: tier1Anchor,
seeds: []seed{{tier1Anchor, "72aaaaaaaaaa", "72", 100}},
markAmbiguous: [2]string{tier1Anchor, "72aaaaaaaaaa"},
wantName: "candY",
wantMethod: "geo_proximity",
},
{
// candX is far (affinity), candY is geo-close. Tier 1 firing
// → candX wins. Sentinel for "geo branch hit first" regressions.
name: "BeatsTier2WhenBothSignal",
nodes: tier1StdNodes,
ctxPK: tier1Anchor,
seeds: []seed{{tier1Anchor, "72aaaaaaaaaa", "72", 100}},
wantName: "candX",
wantMethod: "neighbor_affinity",
},
{
// Graph is non-nil but has no edges involving the context.
// Tier 1 must short-circuit; tier 2 picks candY.
name: "EmptyGraphFallsThrough",
nodes: tier1StdNodes,
ctxPK: tier1Anchor,
extraGraphSeed: &seed{"aaaaaaaaaaa1", "aaaaaaaaaaa2", "aa", 10},
wantName: "candY",
wantMethod: "geo_proximity",
},
{
// Graph is nil — `graph != nil` short-circuit; tier 2 decides.
name: "NilGraphFallsThrough",
nodes: tier1StdNodes,
ctxPK: tier1Anchor,
useNilGraph: true,
wantName: "candY",
wantMethod: "geo_proximity",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
pm := buildPrefixMap(tc.nodes)
var g *NeighborGraph
if !tc.useNilGraph {
g = NewNeighborGraph()
for _, s := range tc.seeds {
seedAffinity(g, s.obsPK, s.candPK, s.prefix, "obs1", s.count)
}
if tc.extraGraphSeed != nil {
s := *tc.extraGraphSeed
seedAffinity(g, s.obsPK, s.candPK, s.prefix, "obs1", s.count)
}
if tc.markAmbiguous[0] != "" {
// Use the public helper rather than mutating
// *NeighborEdge fields returned from AllEdges() —
// hardens the test against any future change that
// makes AllEdges() return copies.
if !g.MarkAmbiguous(tc.markAmbiguous[0], tc.markAmbiguous[1], true) {
t.Fatalf("MarkAmbiguous(%s,%s): edge not found", tc.markAmbiguous[0], tc.markAmbiguous[1])
}
}
}
r, method, _ := pm.resolveWithContext("72", []string{tc.ctxPK}, g)
if r == nil {
t.Fatal("expected non-nil candidate")
}
if r.Name != tc.wantName {
t.Fatalf("name: want %s got %s (method=%s)", tc.wantName, r.Name, method)
}
if method != tc.wantMethod {
t.Fatalf("method: want %s got %s", tc.wantMethod, method)
}
})
}
}
// TestResolveWithContext_Tier1_ScoresTooCloseFallsThrough: best.score is
// below affinityConfidenceRatio × runner-up.score (the ratio guard at the
// end of the tier-1 block in resolveWithContext). Resolver must fall
// through to tier 2.
//
// This case is kept SEPARATE from the table above because it asserts an
// extra invariant the others don't: the returned `score` field MUST be 0
// (tier-2 geo path returns score=0 in store.go). Pinning score==0 makes
// the test fail loudly if affinityConfidenceRatio is ever lowered to a
// value (≤1.25) where the 10/8 count ratio would actually clear tier 1 —
// at that point the resolver would return a non-zero affinity score and
// this assertion catches it, even before the wantMethod string check.
func TestResolveWithContext_Tier1_ScoresTooCloseFallsThrough(t *testing.T) {
pm := buildPrefixMap(tier1StdNodes)
g := NewNeighborGraph()
// Both above affinityMinObservations, but within 3× of each other →
// ratio guard fails, fall-through expected.
seedAffinity(g, tier1Anchor, "72aaaaaaaaaa", "72", "obs1", 10)
seedAffinity(g, tier1Anchor, "72bbbbbbbbbb", "72", "obs1", 8)
r, method, score := pm.resolveWithContext("72", []string{tier1Anchor}, g)
if r == nil {
t.Fatal("expected non-nil candidate")
}
// Direct pin on score==0: catches a lowered affinityConfidenceRatio
// constant that would let 10/8 clear the ratio guard and return a
// non-zero affinity score.
if score != 0 {
t.Fatalf("expected tier-2 fall-through (score==0); got score=%f via %s — affinityConfidenceRatio (%v) may have been lowered to admit a 1.25× ratio",
score, method, affinityConfidenceRatio)
}
if method == "neighbor_affinity" {
t.Fatalf("tier 1 must fall through when scores are too close (< %v ratio); got method=%s",
affinityConfidenceRatio, method)
}
if r.Name != "candY" {
t.Fatalf("expected tier-2 geo to pick candY; got %s via %s", r.Name, method)
}
}

Some files were not shown because too many files have changed in this diff Show More