diff --git a/cmd/server/db.go b/cmd/server/db.go index fa72d319..d6f6741c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2,6 +2,8 @@ package main import ( "database/sql" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "log" @@ -3175,9 +3177,11 @@ func (db *DB) getChannelScopeRegions(since string) (map[string][]string, error) // actively sending, which repeater first relayed each message (raw hash // prefixes — the caller resolves names via /api/resolve-hops), which // observer stations actually heard the traffic, signal quality (SNR/RSSI) -// over the same time buckets as the activity series, and each sender's -// messages grouped into distinct sessions/runs (see buildWardrivingSessions). -// See WardrivingObserverCoverage +// over the same time buckets as the activity series, each sender's messages +// grouped into distinct sessions/runs (see buildWardrivingSessions), and any +// payload anomalies suggesting a sender might be broadcasting real +// coordinates rather than the standard anonymous token (see +// detectWardrivingAnomalies). See WardrivingObserverCoverage // doc for why observer coverage — not sender GPS — is the reliable half of // a "where did this reach" picture: MeshMapper's #wardriving messages carry // an anonymous per-session token by default, not the sender's live @@ -3380,6 +3384,13 @@ func (db *DB) GetWardrivingStats(window, channel string) (*WardrivingStatsRespon } resp.Sessions = sessions + standardCount, anomalies, err := db.detectWardrivingAnomalies(channel, since) + if err != nil { + return nil, err + } + resp.StandardPayloadCount = standardCount + resp.Anomalies = anomalies + return resp, nil } @@ -3516,6 +3527,98 @@ func (db *DB) buildWardrivingSessions(channel, since string) ([]WardrivingSessio return sessions, nil } +// wardrivingStandardPayloadBytes is the decoded length of MeshMapper's +// default anonymous per-session wardriving token — confirmed empirically +// (every "MM:" payload observed decodes to exactly this many +// bytes). Anything else is a candidate for MeshMapper's optional +// "Broadcast My Coordinates" mode, whose on-air byte format is +// undocumented — see WardrivingAnomaly doc for why we detect but don't +// attempt to decode it. +const wardrivingStandardPayloadBytes = 7 + +// detectWardrivingAnomalies scans every #wardriving message's "MM:" +// payload (messages without that prefix — e.g. manual human chat on the +// channel — are ignored entirely, not counted either way) and buckets them +// into the standard-length count vs per-sender anomalies for any +// non-standard length or undecodable payload. +func (db *DB) detectWardrivingAnomalies(channel, since string) (int, []WardrivingAnomaly, error) { + rows, err := db.conn.Query(` + SELECT json_extract(decoded_json, '$.sender'), json_extract(decoded_json, '$.text'), first_seen + FROM transmissions + WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? + `, channel, since) + if err != nil { + return 0, nil, fmt.Errorf("wardriving anomaly query: %w", err) + } + defer rows.Close() + + type anomalyAgg struct { + count int + byteSet map[int]bool + sampleHex string + lastSeen string + } + standardCount := 0 + agg := make(map[string]*anomalyAgg) + + for rows.Next() { + var sender, text sql.NullString + var ts string + if err := rows.Scan(&sender, &text, &ts); err != nil { + continue + } + if !text.Valid || !strings.HasPrefix(text.String, "MM:") { + continue + } + payload := strings.TrimPrefix(text.String, "MM:") + decoded, decErr := base64.RawURLEncoding.DecodeString(payload) + if decErr == nil && len(decoded) == wardrivingStandardPayloadBytes { + standardCount++ + continue + } + + senderName := sender.String + if senderName == "" { + senderName = "(unknown sender)" + } + n := -1 // undecodable base64 + if decErr == nil { + n = len(decoded) + } + if agg[senderName] == nil { + agg[senderName] = &anomalyAgg{byteSet: make(map[int]bool)} + } + a := agg[senderName] + a.count++ + a.byteSet[n] = true + if ts >= a.lastSeen { + a.lastSeen = ts + a.sampleHex = hex.EncodeToString(decoded) + } + } + if err := rows.Err(); err != nil { + return 0, nil, fmt.Errorf("wardriving anomaly iteration: %w", err) + } + + anomalies := make([]WardrivingAnomaly, 0, len(agg)) + for senderName, a := range agg { + bytesList := make([]int, 0, len(a.byteSet)) + for b := range a.byteSet { + bytesList = append(bytesList, b) + } + sort.Ints(bytesList) + anomalies = append(anomalies, WardrivingAnomaly{ + Sender: senderName, + MessageCount: a.count, + PayloadBytes: bytesList, + SampleHex: a.sampleHex, + LastSeen: a.lastSeen, + }) + } + sort.Slice(anomalies, func(i, j int) bool { return anomalies[i].MessageCount > anomalies[j].MessageCount }) + return standardCount, anomalies, nil +} + // GetMatchedRegionNames returns the set of scope_name values that have ever // matched at least one transmission still in retention (NULL and empty-string // "unknown" rows are excluded). Used to diff against the operator's diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index d0107233..f544cab6 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -102,7 +102,7 @@ func routeDescriptions() map[string]routeMeta { "GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"}, "GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"}, "GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"}, - "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages carry an anonymous session token by default, not live GPS), average SNR/RSSI over the same time buckets as the activity series, and each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap). Cached 30s per window+channel.", Tag: "analytics", + "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session/anomaly analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages carry an anonymous session token by default, not live GPS), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap), and per-sender payload anomalies: messages whose \"MM:\" payload doesn't decode to the standard 7-byte token, a candidate signal that MeshMapper's optional coordinate-broadcast mode is active (payload bytes are surfaced as a raw hex dump, never interpreted as lat/lon — that byte format is undocumented). Cached 30s per window+channel.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, {Name: "channel", Description: "Channel name to analyze (default #wardriving)", Type: "string"}, diff --git a/cmd/server/types.go b/cmd/server/types.go index 7cfdd2bf..85338dca 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -276,18 +276,37 @@ type WardrivingSession struct { ObserverCount int `json:"observerCount"` // distinct observers that heard any message in the session } +// WardrivingAnomaly aggregates, per sender, every #wardriving message whose +// "MM:" payload does NOT decode to the standard +// wardrivingStandardPayloadBytes-byte anonymous session token — either a +// different length or genuinely undecodable base64. This is a detector, +// not a decoder: MeshMapper's optional "Broadcast My Coordinates" mode +// would produce on-air payloads in an undocumented format, so a +// non-standard length is a plausible signal that mode is active for that +// sender, but the payload bytes are deliberately NOT interpreted as +// lat/lon — see SampleHex, which is a raw hex dump for manual inspection. +type WardrivingAnomaly struct { + Sender string `json:"sender"` + MessageCount int `json:"messageCount"` + PayloadBytes []int `json:"payloadBytes"` // distinct decoded byte-lengths seen, sorted ascending (-1 marks undecodable base64) + SampleHex string `json:"sampleHex"` // hex dump of the most recent non-standard payload, for manual inspection + LastSeen string `json:"lastSeen"` +} + type WardrivingStatsResponse struct { - Window string `json:"window"` - Channel string `json:"channel"` - TotalMessages int `json:"totalMessages"` - TimeSeries []WardrivingTimePoint `json:"timeSeries"` - TopSenders []WardrivingSenderCount `json:"topSenders"` - EntryPoints []WardrivingEntryPrefix `json:"entryPoints"` - Observers []WardrivingObserverCoverage `json:"observers"` - SignalTimeSeries []WardrivingSignalPoint `json:"signalTimeSeries"` - AvgSNR *float64 `json:"avgSnr,omitempty"` - AvgRSSI *float64 `json:"avgRssi,omitempty"` - Sessions []WardrivingSession `json:"sessions"` + Window string `json:"window"` + Channel string `json:"channel"` + TotalMessages int `json:"totalMessages"` + TimeSeries []WardrivingTimePoint `json:"timeSeries"` + TopSenders []WardrivingSenderCount `json:"topSenders"` + EntryPoints []WardrivingEntryPrefix `json:"entryPoints"` + Observers []WardrivingObserverCoverage `json:"observers"` + SignalTimeSeries []WardrivingSignalPoint `json:"signalTimeSeries"` + AvgSNR *float64 `json:"avgSnr,omitempty"` + AvgRSSI *float64 `json:"avgRssi,omitempty"` + Sessions []WardrivingSession `json:"sessions"` + StandardPayloadCount int `json:"standardPayloadCount"` + Anomalies []WardrivingAnomaly `json:"anomalies"` } // ─── Health ──────────────────────────────────────────────────────────────────── diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go index 706bf732..810d6a87 100644 --- a/cmd/server/wardriving_stats_test.go +++ b/cmd/server/wardriving_stats_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "encoding/json" "net/http/httptest" "testing" @@ -284,6 +285,82 @@ func TestHandleWardrivingStats_Sessions(t *testing.T) { } } +// TestHandleWardrivingStats_Anomalies covers payload-anomaly detection: +// standard 7-byte tokens count toward StandardPayloadCount, non-standard +// lengths and undecodable base64 are grouped per sender into Anomalies, +// and messages with no "MM:" prefix at all (plain chat) are ignored +// entirely rather than polluting either bucket. +func TestHandleWardrivingStats_Anomalies(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + + insertTx := func(hash, sender, text string, tsOffset time.Duration) { + ts := time.Now().UTC().Add(tsOffset).Format(time.RFC3339) + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`, + "aa", hash, ts, `{"sender":"`+sender+`","text":"`+text+`"}`, + ); err != nil { + t.Fatalf("insert tx %s: %v", hash, err) + } + } + + standardToken := base64.RawURLEncoding.EncodeToString([]byte{1, 2, 3, 4, 5, 6, 7}) + longPayload := base64.RawURLEncoding.EncodeToString(make([]byte, 12)) + + insertTx("std1", "Alice", "MM:"+standardToken, -3*time.Hour) + insertTx("std2", "Bob", "MM:"+standardToken, -2*time.Hour) + // Suspect1: two messages with a consistent non-standard 12-byte payload. + insertTx("anom1", "Suspect1", "MM:"+longPayload, -90*time.Minute) + insertTx("anom2", "Suspect1", "MM:"+longPayload, -80*time.Minute) + // Suspect2: one genuinely undecodable payload (invalid base64 chars). + insertTx("anom3", "Suspect2", "MM:!!!not-valid-b64!!!", -70*time.Minute) + // Plain chat on the same channel — must be ignored entirely, not + // counted as standard or anomalous. + insertTx("chat1", "Eve", "hey is anyone home", -60*time.Minute) + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + if resp.StandardPayloadCount != 2 { + t.Errorf("StandardPayloadCount = %d, want 2 (Alice + Bob)", resp.StandardPayloadCount) + } + if len(resp.Anomalies) != 2 { + t.Fatalf("Anomalies = %+v, want 2 senders (Suspect1, Suspect2)", resp.Anomalies) + } + + // Sorted by MessageCount desc: Suspect1 (2 messages) before Suspect2 (1). + s1, s2 := resp.Anomalies[0], resp.Anomalies[1] + if s1.Sender != "Suspect1" || s1.MessageCount != 2 { + t.Errorf("Anomalies[0] = %+v, want {Suspect1, 2 messages}", s1) + } + if len(s1.PayloadBytes) != 1 || s1.PayloadBytes[0] != 12 { + t.Errorf("Suspect1 PayloadBytes = %v, want [12]", s1.PayloadBytes) + } + if s2.Sender != "Suspect2" || s2.MessageCount != 1 { + t.Errorf("Anomalies[1] = %+v, want {Suspect2, 1 message}", s2) + } + if len(s2.PayloadBytes) != 1 || s2.PayloadBytes[0] != -1 { + t.Errorf("Suspect2 PayloadBytes = %v, want [-1] (undecodable)", s2.PayloadBytes) + } + + // Eve's plain-chat message must not appear anywhere in either bucket. + for _, a := range resp.Anomalies { + if a.Sender == "Eve" { + t.Error("Eve's plain-chat message (no MM: prefix) must not be counted as an anomaly") + } + } +} + // TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats // window validation. func TestHandleWardrivingStats_InvalidWindow(t *testing.T) { @@ -318,11 +395,14 @@ func TestHandleWardrivingStats_EmptyChannel(t *testing.T) { if resp.TotalMessages != 0 { t.Errorf("TotalMessages = %d, want 0", resp.TotalMessages) } - if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil || resp.SignalTimeSeries == nil || resp.Sessions == nil { - t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v SignalTimeSeries=%v Sessions=%v", - resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries, resp.SignalTimeSeries, resp.Sessions) + if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil || resp.SignalTimeSeries == nil || resp.Sessions == nil || resp.Anomalies == nil { + t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v SignalTimeSeries=%v Sessions=%v Anomalies=%v", + resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries, resp.SignalTimeSeries, resp.Sessions, resp.Anomalies) } if resp.AvgSNR != nil || resp.AvgRSSI != nil { t.Errorf("expected nil AvgSNR/AvgRSSI for a channel with no observations, got AvgSNR=%v AvgRSSI=%v", resp.AvgSNR, resp.AvgRSSI) } + if resp.StandardPayloadCount != 0 { + t.Errorf("StandardPayloadCount = %d, want 0", resp.StandardPayloadCount) + } } diff --git a/public/analytics.js b/public/analytics.js index 49f0b052..1578f393 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5427,6 +5427,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf { label: 'Avg SNR', value: (d.avgSnr != null ? d.avgSnr.toFixed(1) + ' dB' : '—'), note: null }, { label: 'Avg RSSI', value: (d.avgRssi != null ? d.avgRssi.toFixed(1) + ' dBm' : '—'), note: null }, { label: 'Sessions', value: (d.sessions || []).length.toLocaleString(), note: '15min+ gap starts a new one' }, + { label: 'Payload Anomalies', value: (d.anomalies || []).length.toLocaleString(), note: 'senders, non-standard "MM:" payload' }, ].map(function(c) { return '
' + c.value + '
' + '
' + c.label + '
' + @@ -5599,6 +5600,32 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf ''; } + function payloadBytesLabel(bytesList) { + return (bytesList || []).map(function(n) { return n === -1 ? 'undecodable' : (n + 'B'); }).join(', '); + } + + // Payload anomalies — a heuristic, not a decode: MeshMapper's optional + // "Broadcast My Coordinates" mode uses an undocumented on-air byte + // format, so we only flag that a sender's payload length deviates from + // the standard 7-byte token, and show the raw hex for manual review — + // never an attempted lat/lon interpretation. + function anomaliesHtml(anomalies, standardCount) { + if (!anomalies || anomalies.length === 0) { + return '

All ' + standardCount.toLocaleString() + ' "MM:" wardriving messages in this window used the standard 7-byte anonymous token — no non-standard payloads detected.

'; + } + var rows = anomalies.map(function(a) { + return '' + esc(a.sender) + '' + + '' + a.messageCount.toLocaleString() + '' + + '' + payloadBytesLabel(a.payloadBytes) + '' + + '' + esc(a.sampleHex || '—') + '' + + '' + (typeof timeAgo === 'function' ? timeAgo(a.lastSeen) : a.lastSeen) + ''; + }).join(''); + return '' + + '' + + '' + rows + '' + + '
SenderMessagesPayload LengthSample Payload (hex)Last Seen
'; + } + function attachWindowButtons() { el.querySelectorAll('[data-wdwin]').forEach(function(btn) { btn.addEventListener('click', function() { @@ -5634,7 +5661,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + '
Avg SNR (dB)
' + signalChartHtml(d.signalTimeSeries, 'avgSnr', 'var(--accent)', 'Average SNR over time') + '
' + '
Avg RSSI (dBm)
' + signalChartHtml(d.signalTimeSeries, 'avgRssi', 'var(--warning, #f39c12)', 'Average RSSI over time') + '
' + - '
'; + '
' + + '

Payload Anomalies (Coordinate-Broadcast Watch)

' + + '

MeshMapper\'s default wardriving ping is a 7-byte anonymous token. A different payload length is a candidate signal that a sender has enabled MeshMapper\'s optional "Broadcast My Coordinates" mode — its on-air byte format is undocumented, so we show the raw hex rather than guessing at lat/lon.

' + + '
' + anomaliesHtml(d.anomalies, d.standardPayloadCount || 0) + '
'; } catch (err) { body = '
Failed to load wardriving stats: ' + esc(String(err)) + '
'; } diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index de03bea1..0e21edcf 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -142,6 +142,10 @@ function makeWardrivingResponse(overrides) { { sender: 'Bob', startTime: '2026-07-20T08:30:00Z', endTime: '2026-07-20T08:30:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1 }, { sender: 'Alice', startTime: '2026-07-20T08:00:00Z', endTime: '2026-07-20T08:05:00Z', durationMinutes: 5, messageCount: 1, entryPointCount: 2, observerCount: 1 }, ], + standardPayloadCount: 3, + anomalies: [ + { sender: 'Suspect1', messageCount: 2, payloadBytes: [12], sampleHex: 'aabbccdd', lastSeen: '2026-07-20T09:00:00Z' }, + ], }, overrides); } @@ -170,6 +174,29 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(el.innerHTML.includes('Observers Reached'), 'Observers Reached card label should render'); assert.ok(el.innerHTML.includes('5.5 dB'), 'Avg SNR card should show the API-provided average'); assert.ok(el.innerHTML.includes('-72.5 dBm'), 'Avg RSSI card should show the API-provided average'); + assert.ok(el.innerHTML.includes('
1
Payload Anomalies
'), 'Payload Anomalies card should show the anomalous-sender count'); + }); + + await testAsync('Payload Anomalies table lists non-standard senders with hex sample, hides interpreted coordinates', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('id="wardrivingAnomalies"'); + assert.ok(startIdx > -1, 'Payload Anomalies section should render'); + const section = el.innerHTML.slice(startIdx); + assert.ok(section.includes('Suspect1'), 'the anomalous sender should be listed'); + assert.ok(section.includes('12B'), 'the non-standard payload length should render'); + assert.ok(section.includes('aabbccdd'), 'the sample hex dump should render for manual inspection'); + assert.ok(!/-?\d+\.\d+,\s*-?\d+\.\d+/.test(section), 'must never render anything that looks like an interpreted lat/lon coordinate pair'); + }); + + await testAsync('Payload Anomalies shows a reassuring message when nothing is anomalous', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ anomalies: [], standardPayloadCount: 42 }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('id="wardrivingAnomalies"'); + const section = el.innerHTML.slice(startIdx); + assert.ok(section.includes('42') && section.includes('no non-standard payloads detected'), 'should report the standard-payload count and a clean bill of health'); }); await testAsync('Signal Quality Trends renders both SNR and RSSI charts', async () => { @@ -247,6 +274,7 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ totalMessages: 0, topSenders: [], entryPoints: [], observers: [], timeSeries: [], signalTimeSeries: [], avgSnr: null, avgRssi: null, sessions: [], + anomalies: [], standardPayloadCount: 0, }))); const el = fakeEl(); await ctx.window._analyticsRenderWardrivingTab(el); @@ -257,6 +285,7 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(el.innerHTML.includes('Insufficient data points to chart'), 'signal chart empty state should show'); assert.ok(el.innerHTML.includes('
—
Avg SNR
'), 'Avg SNR card should show a dash when there is no signal data'); assert.ok(el.innerHTML.includes('
—
Avg RSSI
'), 'Avg RSSI card should show a dash when there is no signal data'); + assert.ok(el.innerHTML.includes('no non-standard payloads detected'), 'anomalies empty state should show'); }); await testAsync('rendering registers a real interval, and stop() actually clears it (not a no-op)', async () => {