mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 15:51:37 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36be02a1b8 | |||
| 76c6b155c2 | |||
| d0b597ff49 | |||
| e19b0eba85 | |||
| df75468a8b | |||
| 0a55717283 | |||
| bcab31bf72 | |||
| 6ae62ce535 | |||
| 6e2f79c0ad | |||
| b0862f7a41 | |||
| 45991eca09 | |||
| 76c42556a2 | |||
| 6f8378a31c | |||
| 56115ee0a4 | |||
| 321d1cf913 | |||
| 790a713ba9 | |||
| cd470dffbe | |||
| 7ff89d8607 | |||
| 493849f2e3 | |||
| 87ac61748c | |||
| 26de38f4b6 | |||
| d2d4c504e8 | |||
| b37e8e2da2 | |||
| 45d8116880 | |||
| f68e98c376 | |||
| f3d5d1e021 | |||
| 02004c5912 | |||
| ef30031e2e | |||
| 67511ed6a7 | |||
| b35b473508 | |||
| d4f2c3ac66 | |||
| 37300bf5c8 | |||
| cb8a2e15c8 | |||
| aac038abb9 | |||
| 588fba226d | |||
| c670742589 | |||
| f897ce1b26 |
+402
-18
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -428,6 +429,49 @@ func TestMaxTransmissionID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- MaxTransmissionID incremental tracking ---
|
||||
|
||||
func TestMaxTransmissionIDIncremental(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
maxTx := store.MaxTransmissionID()
|
||||
maxObs := store.MaxObservationID()
|
||||
|
||||
if maxTx <= 0 {
|
||||
t.Fatalf("expected maxTx > 0 after Load, got %d", maxTx)
|
||||
}
|
||||
if maxObs <= 0 {
|
||||
t.Fatalf("expected maxObs > 0 after Load, got %d", maxObs)
|
||||
}
|
||||
|
||||
// Verify incremental field matches brute-force iteration
|
||||
store.mu.RLock()
|
||||
bruteMaxTx := 0
|
||||
for id := range store.byTxID {
|
||||
if id > bruteMaxTx {
|
||||
bruteMaxTx = id
|
||||
}
|
||||
}
|
||||
bruteMaxObs := 0
|
||||
for id := range store.byObsID {
|
||||
if id > bruteMaxObs {
|
||||
bruteMaxObs = id
|
||||
}
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
|
||||
if maxTx != bruteMaxTx {
|
||||
t.Errorf("maxTxID mismatch: incremental=%d brute=%d", maxTx, bruteMaxTx)
|
||||
}
|
||||
if maxObs != bruteMaxObs {
|
||||
t.Errorf("maxObsID mismatch: incremental=%d brute=%d", maxObs, bruteMaxObs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Route handler DB fallback (no store) ---
|
||||
|
||||
func TestHandleBulkHealthNoStore(t *testing.T) {
|
||||
@@ -770,6 +814,56 @@ func TestPrefixMapResolve(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrefixMapCap(t *testing.T) {
|
||||
// 16-char pubkey — longer than maxPrefixLen
|
||||
nodes := []nodeInfo{
|
||||
{PublicKey: "aabbccdd11223344", Name: "LongKey"},
|
||||
{PublicKey: "eeff0011", Name: "ShortKey"}, // exactly 8 chars
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
t.Run("short prefixes still work", func(t *testing.T) {
|
||||
n := pm.resolve("aabb")
|
||||
if n == nil || n.Name != "LongKey" {
|
||||
t.Errorf("expected LongKey for short prefix, got %v", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full pubkey exact match works", func(t *testing.T) {
|
||||
n := pm.resolve("aabbccdd11223344")
|
||||
if n == nil || n.Name != "LongKey" {
|
||||
t.Errorf("expected LongKey for full key, got %v", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("intermediate prefix beyond cap returns nil", func(t *testing.T) {
|
||||
// 10-char prefix — beyond maxPrefixLen but not full key
|
||||
n := pm.resolve("aabbccdd11")
|
||||
if n != nil {
|
||||
t.Errorf("expected nil for intermediate prefix beyond cap, got %v", n.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short key within cap has all prefixes", func(t *testing.T) {
|
||||
for l := 2; l <= 8; l++ {
|
||||
pfx := "eeff0011"[:l]
|
||||
n := pm.resolve(pfx)
|
||||
if n == nil || n.Name != "ShortKey" {
|
||||
t.Errorf("prefix %q: expected ShortKey, got %v", pfx, n)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map size is capped", func(t *testing.T) {
|
||||
// LongKey: 7 prefix entries (2..8) + 1 full key = 8
|
||||
// ShortKey: 7 prefix entries (2..8), no full key entry (len == maxPrefixLen) = 7
|
||||
// No overlapping prefixes between the two nodes → 8 + 7 = 15 unique map keys
|
||||
if len(pm.m) != 15 {
|
||||
t.Errorf("expected 15 map entries (8 for LongKey + 7 for ShortKey), got %d", len(pm.m))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- pathLen ---
|
||||
|
||||
func TestPathLen(t *testing.T) {
|
||||
@@ -1333,6 +1427,40 @@ func TestGetNodeLocations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetNodeLocationsByKeys ---
|
||||
|
||||
func TestGetNodeLocationsByKeys(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
// Query with a known key
|
||||
pk := "aabbccdd11223344"
|
||||
locs := db.GetNodeLocationsByKeys([]string{pk})
|
||||
if len(locs) != 1 {
|
||||
t.Errorf("expected 1 location, got %d", len(locs))
|
||||
}
|
||||
if entry, ok := locs[strings.ToLower(pk)]; ok {
|
||||
if entry["lat"] == nil {
|
||||
t.Error("expected non-nil lat")
|
||||
}
|
||||
} else {
|
||||
t.Error("expected node location for test repeater")
|
||||
}
|
||||
|
||||
// Query with no keys returns empty map
|
||||
empty := db.GetNodeLocationsByKeys([]string{})
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("expected 0 locations for empty keys, got %d", len(empty))
|
||||
}
|
||||
|
||||
// Query with unknown key returns empty map
|
||||
unknown := db.GetNodeLocationsByKeys([]string{"nonexistent"})
|
||||
if len(unknown) != 0 {
|
||||
t.Errorf("expected 0 locations for unknown key, got %d", len(unknown))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Store edge cases ---
|
||||
|
||||
func TestStoreQueryPacketsEdgeCases(t *testing.T) {
|
||||
@@ -1906,6 +2034,48 @@ func TestTxToMap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTxToMapLazyObservations(t *testing.T) {
|
||||
snr := 10.5
|
||||
rssi := -90.0
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc",
|
||||
Observations: []*StoreObs{
|
||||
{ID: 10, ObserverID: "obs1", ObserverName: "O1", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-01"},
|
||||
{ID: 11, ObserverID: "obs2", ObserverName: "O2", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-02"},
|
||||
},
|
||||
}
|
||||
|
||||
// Without flag: no observations key
|
||||
m := txToMap(tx)
|
||||
if _, ok := m["observations"]; ok {
|
||||
t.Error("txToMap without includeObservations should not include observations key")
|
||||
}
|
||||
|
||||
// With false: no observations key
|
||||
m = txToMap(tx, false)
|
||||
if _, ok := m["observations"]; ok {
|
||||
t.Error("txToMap(tx, false) should not include observations key")
|
||||
}
|
||||
|
||||
// With true: observations included
|
||||
m = txToMap(tx, true)
|
||||
obs, ok := m["observations"]
|
||||
if !ok {
|
||||
t.Fatal("txToMap(tx, true) should include observations key")
|
||||
}
|
||||
obsList, ok := obs.([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("observations should be []map[string]interface{}")
|
||||
}
|
||||
if len(obsList) != 2 {
|
||||
t.Errorf("expected 2 observations, got %d", len(obsList))
|
||||
}
|
||||
if obsList[0]["observer_id"] != "obs1" {
|
||||
t.Errorf("expected observer_id obs1, got %v", obsList[0]["observer_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- filterTxSlice ---
|
||||
|
||||
func TestFilterTxSlice(t *testing.T) {
|
||||
@@ -2099,6 +2269,84 @@ func TestSubpathPrecomputedIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubpathTxIndexPopulated(t *testing.T) {
|
||||
db := setupRichTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
// spTxIndex must be populated alongside spIndex
|
||||
if len(store.spTxIndex) == 0 {
|
||||
t.Fatal("expected spTxIndex to be populated after Load()")
|
||||
}
|
||||
|
||||
// Every key in spIndex must also exist in spTxIndex with matching count
|
||||
for key, count := range store.spIndex {
|
||||
txs, ok := store.spTxIndex[key]
|
||||
if !ok {
|
||||
t.Errorf("spTxIndex missing key %q that exists in spIndex", key)
|
||||
continue
|
||||
}
|
||||
if len(txs) != count {
|
||||
t.Errorf("spTxIndex[%q] has %d txs, spIndex count is %d", key, len(txs), count)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubpathDetail should return correct match count via indexed lookup
|
||||
detail := store.GetSubpathDetail([]string{"eeff", "0011"})
|
||||
if detail == nil {
|
||||
t.Fatal("expected non-nil detail for existing subpath")
|
||||
}
|
||||
matches, _ := detail["totalMatches"].(int)
|
||||
if matches != 1 {
|
||||
t.Errorf("totalMatches = %d, want 1", matches)
|
||||
}
|
||||
|
||||
// Non-existent subpath should return 0 matches
|
||||
detail2 := store.GetSubpathDetail([]string{"zzzz", "yyyy"})
|
||||
if detail2 == nil {
|
||||
t.Fatal("expected non-nil result even for non-existent subpath")
|
||||
}
|
||||
matches2, _ := detail2["totalMatches"].(int)
|
||||
if matches2 != 0 {
|
||||
t.Errorf("totalMatches for non-existent subpath = %d, want 0", matches2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubpathDetailMixedCaseHops(t *testing.T) {
|
||||
db := setupRichTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
// Query with lowercase hops to establish baseline
|
||||
lower := store.GetSubpathDetail([]string{"eeff", "0011"})
|
||||
if lower == nil {
|
||||
t.Fatal("expected non-nil detail for lowercase subpath")
|
||||
}
|
||||
lowerMatches, _ := lower["totalMatches"].(int)
|
||||
if lowerMatches == 0 {
|
||||
t.Fatal("expected >0 matches for lowercase subpath")
|
||||
}
|
||||
|
||||
// Query with mixed-case hops — must return the same results (case-insensitive)
|
||||
mixed := store.GetSubpathDetail([]string{"EEFF", "0011"})
|
||||
if mixed == nil {
|
||||
t.Fatal("expected non-nil detail for mixed-case subpath")
|
||||
}
|
||||
mixedMatches, _ := mixed["totalMatches"].(int)
|
||||
if mixedMatches != lowerMatches {
|
||||
t.Errorf("mixed-case totalMatches = %d, want %d (same as lowercase)", mixedMatches, lowerMatches)
|
||||
}
|
||||
|
||||
// All-uppercase should also match
|
||||
upper := store.GetSubpathDetail([]string{"EEFF", "0011"})
|
||||
upperMatches, _ := upper["totalMatches"].(int)
|
||||
if upperMatches != lowerMatches {
|
||||
t.Errorf("uppercase totalMatches = %d, want %d", upperMatches, lowerMatches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetAnalyticsRFCacheHit(t *testing.T) {
|
||||
db := setupRichTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -3716,6 +3964,71 @@ func TestGetChannelMessagesAfterIngest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveRegionObservers caching ---
|
||||
|
||||
func TestResolveRegionObserversCaching(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
store := &PacketStore{db: db}
|
||||
|
||||
// First call should populate cache.
|
||||
obs1 := store.resolveRegionObservers("SJC")
|
||||
if obs1 == nil || len(obs1) == 0 {
|
||||
t.Fatal("expected observer IDs for SJC on first call")
|
||||
}
|
||||
|
||||
// Second call should return cached result (same pointer).
|
||||
obs2 := store.resolveRegionObservers("SJC")
|
||||
if len(obs2) != len(obs1) {
|
||||
t.Errorf("cached result differs: got %d, want %d", len(obs2), len(obs1))
|
||||
}
|
||||
|
||||
// Non-existent region should return nil even from cache.
|
||||
obs3 := store.resolveRegionObservers("NONEXIST")
|
||||
if obs3 != nil {
|
||||
t.Errorf("expected nil for NONEXIST, got %v", obs3)
|
||||
}
|
||||
|
||||
// Verify cache fields are set.
|
||||
if store.regionObsCache == nil {
|
||||
t.Error("regionObsCache should be non-nil after calls")
|
||||
}
|
||||
if store.regionObsCacheTime.IsZero() {
|
||||
t.Error("regionObsCacheTime should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRegionObserversCacheMissNewRegion(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
|
||||
store := &PacketStore{db: db}
|
||||
|
||||
// Populate cache with SJC.
|
||||
obs1 := store.resolveRegionObservers("SJC")
|
||||
if obs1 == nil || len(obs1) == 0 {
|
||||
t.Fatal("expected observer IDs for SJC on first call")
|
||||
}
|
||||
|
||||
// Cache is now valid. Request a different region that exists in DB.
|
||||
// Before the fix, this would return nil from the map lookup instead of
|
||||
// fetching from DB, silently returning "no observers" for up to 30s.
|
||||
obs2 := store.resolveRegionObservers("LAX")
|
||||
// LAX may or may not have data in the test DB, but the key point is:
|
||||
// a non-existent region should be fetched (not just nil-returned).
|
||||
// Verify the region key was cached (even if empty).
|
||||
store.regionObsMu.Lock()
|
||||
_, cached := store.regionObsCache["LAX"]
|
||||
store.regionObsMu.Unlock()
|
||||
if !cached {
|
||||
t.Error("LAX should be cached after resolveRegionObservers call, even if empty")
|
||||
}
|
||||
_ = obs2
|
||||
}
|
||||
|
||||
func TestIndexByNodePreCheck(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
@@ -3914,44 +4227,115 @@ func TestBuildTransmissionWhereMultiObserver(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Distance index rebuild debounce (#557) ---
|
||||
// --- Distance index incremental update (#365, replaces debounce #557) ---
|
||||
|
||||
func TestDistanceRebuildDebounce(t *testing.T) {
|
||||
func TestDistanceIncrementalUpdate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
// After Load(), distLast is set to now — so distDirty should be false
|
||||
if store.distDirty {
|
||||
t.Fatal("distDirty should be false after Load()")
|
||||
}
|
||||
// Record initial distance index size.
|
||||
initialHops := len(store.distHops)
|
||||
initialPaths := len(store.distPaths)
|
||||
|
||||
// Insert a new observation with a different path to trigger distDirty
|
||||
// Insert a new observation with a different path to trigger an incremental update.
|
||||
maxObsID := db.GetMaxObservationID()
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 5.0, -100, '["xx","yy","zz"]', ?)`, time.Now().Unix())
|
||||
|
||||
store.IngestNewObservations(maxObsID, 500)
|
||||
|
||||
// distDirty should be true (30s hasn't elapsed since Load)
|
||||
if !store.distDirty {
|
||||
t.Fatal("distDirty should be true after path change within 30s window")
|
||||
}
|
||||
// Distance index should have been updated incrementally (sizes may differ
|
||||
// if the new path resolves differently, but should not panic or corrupt).
|
||||
_ = len(store.distHops)
|
||||
_ = len(store.distPaths)
|
||||
|
||||
// Now simulate 30s having elapsed by backdating distLast
|
||||
store.distLast = time.Now().Add(-31 * time.Second)
|
||||
|
||||
// Insert another observation to trigger another ingest cycle
|
||||
// Insert another observation with yet another path.
|
||||
maxObsID = db.GetMaxObservationID()
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 7.0, -95, '["aa","bb","cc","dd"]', ?)`, time.Now().Unix())
|
||||
|
||||
store.IngestNewObservations(maxObsID, 500)
|
||||
|
||||
// After 30s elapsed, distDirty should be cleared (rebuild happened)
|
||||
if store.distDirty {
|
||||
t.Fatal("distDirty should be false after rebuild (30s elapsed)")
|
||||
// Verify the index is still coherent (no duplicates for the same tx).
|
||||
txSeen := make(map[int]int)
|
||||
for _, r := range store.distPaths {
|
||||
if r.tx != nil {
|
||||
txSeen[r.tx.ID]++
|
||||
}
|
||||
}
|
||||
for txID, count := range txSeen {
|
||||
if count > 1 {
|
||||
t.Errorf("distPaths has %d entries for tx %d (expected at most 1)", count, txID)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Distance index: %d→%d hops, %d→%d paths (incremental)",
|
||||
initialHops, len(store.distHops), initialPaths, len(store.distPaths))
|
||||
}
|
||||
|
||||
func TestHandleBatchObservations(t *testing.T) {
|
||||
_, router := setupNoStoreServer(t)
|
||||
|
||||
t.Run("empty hashes returns empty results", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"hashes":[]}`)
|
||||
req := httptest.NewRequest("POST", "/api/packets/observations", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
results, ok := resp["results"].(map[string]interface{})
|
||||
if !ok || len(results) != 0 {
|
||||
t.Fatalf("expected empty results map, got %v", resp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns 400", func(t *testing.T) {
|
||||
body := strings.NewReader(`not json`)
|
||||
req := httptest.NewRequest("POST", "/api/packets/observations", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too many hashes returns 400", func(t *testing.T) {
|
||||
hashes := make([]string, 201)
|
||||
for i := range hashes {
|
||||
hashes[i] = fmt.Sprintf("hash%d", i)
|
||||
}
|
||||
data, _ := json.Marshal(map[string][]string{"hashes": hashes})
|
||||
req := httptest.NewRequest("POST", "/api/packets/observations", bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid hashes with no store returns empty results", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"hashes":["abc123","def456"]}`)
|
||||
req := httptest.NewRequest("POST", "/api/packets/observations", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
_, ok := resp["results"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected results map, got %v", resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+35
-1
@@ -377,7 +377,8 @@ type PacketQuery struct {
|
||||
Until string
|
||||
Region string
|
||||
Node string
|
||||
Order string // ASC or DESC
|
||||
Order string // ASC or DESC
|
||||
ExpandObservations bool // when true, include observation sub-maps in txToMap output
|
||||
}
|
||||
|
||||
// PacketResult wraps paginated packet list.
|
||||
@@ -1497,6 +1498,39 @@ func (db *DB) GetNodeLocations() map[string]map[string]interface{} {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetNodeLocationsByKeys returns location data only for the given public keys.
|
||||
// This avoids fetching ALL nodes when only a few keys need to be matched.
|
||||
func (db *DB) GetNodeLocationsByKeys(keys []string) map[string]map[string]interface{} {
|
||||
result := make(map[string]map[string]interface{})
|
||||
if len(keys) == 0 {
|
||||
return result
|
||||
}
|
||||
placeholders := make([]string, len(keys))
|
||||
args := make([]interface{}, len(keys))
|
||||
for i, k := range keys {
|
||||
placeholders[i] = "?"
|
||||
args[i] = strings.ToLower(k)
|
||||
}
|
||||
query := "SELECT public_key, lat, lon, role FROM nodes WHERE LOWER(public_key) IN (" + strings.Join(placeholders, ",") + ")"
|
||||
rows, err := db.conn.Query(query, args...)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
var role sql.NullString
|
||||
var lat, lon sql.NullFloat64
|
||||
rows.Scan(&pk, &lat, &lon, &role)
|
||||
result[strings.ToLower(pk)] = map[string]interface{}{
|
||||
"lat": nullFloat(lat),
|
||||
"lon": nullFloat(lon),
|
||||
"role": nullStr(role),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// QueryMultiNodePackets returns transmissions referencing any of the given pubkeys.
|
||||
func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) {
|
||||
if len(pubkeys) == 0 {
|
||||
|
||||
@@ -162,24 +162,50 @@ func TestEvictStale_NoEvictionWhenDisabled(t *testing.T) {
|
||||
|
||||
func TestEvictStale_MemoryBasedEviction(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
// Create enough packets to exceed a small memory limit
|
||||
// 1000 packets * 5KB + 2000 obs * 500B ≈ 6MB
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
// All packets are recent (1h old) so time-based won't trigger
|
||||
// All packets are recent (1h old) so time-based won't trigger.
|
||||
store.retentionHours = 24
|
||||
store.maxMemoryMB = 3 // ~3MB limit, should evict roughly half
|
||||
store.maxMemoryMB = 3
|
||||
// Inject deterministic estimator: simulates 6MB (over 3MB limit).
|
||||
// Uses packet count so it scales correctly after eviction.
|
||||
store.memoryEstimator = func() float64 {
|
||||
return float64(len(store.packets)*5120+store.totalObs*500) / 1048576.0
|
||||
}
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
t.Fatal("expected some evictions for memory cap")
|
||||
}
|
||||
// After eviction, estimated memory should be <= 3MB
|
||||
estMB := store.estimatedMemoryMB()
|
||||
if estMB > 3.5 { // small tolerance
|
||||
if estMB > 3.5 {
|
||||
t.Fatalf("expected <=3.5MB after eviction, got %.1fMB", estMB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvictStale_MemoryBasedEviction_UnderestimatedHeap verifies that eviction
|
||||
// fires correctly when actual heap is much larger than a formula-based estimate
|
||||
// would report — the scenario that caused OOM kills in production.
|
||||
func TestEvictStale_MemoryBasedEviction_UnderestimatedHeap(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
|
||||
store.retentionHours = 24
|
||||
store.maxMemoryMB = 500
|
||||
// Simulate actual heap 5x over budget (like production: ~5GB actual vs ~1GB limit).
|
||||
store.memoryEstimator = func() float64 {
|
||||
return 2500.0 // 2500MB actual vs 500MB limit
|
||||
}
|
||||
|
||||
evicted := store.EvictStale()
|
||||
if evicted == 0 {
|
||||
t.Fatal("expected evictions when heap is 5x over limit")
|
||||
}
|
||||
// Should keep roughly 500/2500 * 0.9 = 18% of packets → ~180 of 1000.
|
||||
remaining := len(store.packets)
|
||||
if remaining > 250 {
|
||||
t.Fatalf("expected most packets evicted (heap 5x over), but %d of 1000 remain", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictStale_CleansNodeIndexes(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := makeTestStore(10, now.Add(-48*time.Hour), 0)
|
||||
|
||||
+22
-5
@@ -224,8 +224,15 @@ func main() {
|
||||
defer stopEviction()
|
||||
|
||||
// Auto-prune old packets if retention.packetDays is configured
|
||||
var stopPrune func()
|
||||
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
|
||||
days := cfg.Retention.PacketDays
|
||||
pruneTicker := time.NewTicker(24 * time.Hour)
|
||||
pruneDone := make(chan struct{})
|
||||
stopPrune = func() {
|
||||
pruneTicker.Stop()
|
||||
close(pruneDone)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
@@ -233,11 +240,16 @@ func main() {
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
for range time.Tick(24 * time.Hour) {
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
for {
|
||||
select {
|
||||
case <-pruneTicker.C:
|
||||
if n, err := database.PruneOldPackets(days); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
case <-pruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -262,6 +274,11 @@ func main() {
|
||||
// 1. Stop accepting new WebSocket/poll data
|
||||
poller.Stop()
|
||||
|
||||
// 1b. Stop auto-prune ticker
|
||||
if stopPrune != nil {
|
||||
stopPrune()
|
||||
}
|
||||
|
||||
// 2. Gracefully drain HTTP connections (up to 15s)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -166,6 +166,7 @@ func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
|
||||
// Insert a unique node
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"ff11223344", "UniqueNode", 37.0, -122.0)
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -192,6 +193,7 @@ func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
|
||||
"ee1aaaaaaa", "Node-E1", 37.0, -122.0)
|
||||
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
|
||||
"ee1bbbbbbb", "Node-E2", 38.0, -121.0)
|
||||
srv.store.InvalidateNodeCache()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -204,8 +206,10 @@ func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
|
||||
if hr == nil {
|
||||
t.Fatal("expected hop in resolved map")
|
||||
}
|
||||
if hr.Confidence != "ambiguous" {
|
||||
t.Fatalf("expected ambiguous, got %s", hr.Confidence)
|
||||
// With both candidates having GPS and no affinity context, the resolver
|
||||
// picks the GPS-preferred candidate → confidence is "gps_preference".
|
||||
if hr.Confidence != "gps_preference" {
|
||||
t.Fatalf("expected gps_preference, got %s", hr.Confidence)
|
||||
}
|
||||
if len(hr.Candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
|
||||
|
||||
+144
-47
@@ -118,6 +118,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
|
||||
|
||||
// Packet endpoints
|
||||
r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST")
|
||||
r.HandleFunc("/api/packets/timestamps", s.handlePacketTimestamps).Methods("GET")
|
||||
r.HandleFunc("/api/packets/{id}", s.handlePacketDetail).Methods("GET")
|
||||
r.HandleFunc("/api/packets", s.handlePackets).Methods("GET")
|
||||
@@ -145,6 +146,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/analytics/hash-sizes", s.handleAnalyticsHashSizes).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpaths-bulk", s.handleAnalyticsSubpathsBulk).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
|
||||
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
|
||||
|
||||
@@ -718,7 +720,8 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
|
||||
Until: r.URL.Query().Get("until"),
|
||||
Region: r.URL.Query().Get("region"),
|
||||
Node: r.URL.Query().Get("node"),
|
||||
Order: "DESC",
|
||||
Order: "DESC",
|
||||
ExpandObservations: r.URL.Query().Get("expand") == "observations",
|
||||
}
|
||||
if r.URL.Query().Get("order") == "asc" {
|
||||
q.Order = "ASC"
|
||||
@@ -760,13 +763,6 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Strip observations from default response
|
||||
if r.URL.Query().Get("expand") != "observations" {
|
||||
for _, p := range result.Packets {
|
||||
delete(p, "observations")
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
@@ -791,6 +787,38 @@ var muxBraceParam = regexp.MustCompile(`\{([^}]+)\}`)
|
||||
// perfHexFallback matches hex IDs for perf path normalization fallback.
|
||||
var perfHexFallback = regexp.MustCompile(`[0-9a-f]{8,}`)
|
||||
|
||||
// handleBatchObservations returns observations for multiple hashes in a single request.
|
||||
// POST /api/packets/observations with JSON body: {"hashes": ["abc123", "def456", ...]}
|
||||
// Response: {"results": {"abc123": [...observations...], "def456": [...], ...}}
|
||||
// Limited to 200 hashes per request to prevent abuse.
|
||||
func (s *Server) handleBatchObservations(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Hashes []string `json:"hashes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, 400, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
const maxHashes = 200
|
||||
if len(body.Hashes) > maxHashes {
|
||||
writeError(w, 400, fmt.Sprintf("too many hashes (max %d)", maxHashes))
|
||||
return
|
||||
}
|
||||
if len(body.Hashes) == 0 {
|
||||
writeJSON(w, map[string]interface{}{"results": map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
results := make(map[string][]ObservationResp, len(body.Hashes))
|
||||
if s.store != nil {
|
||||
for _, hash := range body.Hashes {
|
||||
obs := s.store.GetObservationsForHash(hash)
|
||||
results[hash] = mapSliceToObservations(obs)
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"results": results})
|
||||
}
|
||||
|
||||
func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) {
|
||||
param := mux.Vars(r)["id"]
|
||||
var packet map[string]interface{}
|
||||
@@ -1065,16 +1093,44 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
prefix1 := strings.ToLower(pubkey)
|
||||
if len(prefix1) > 2 {
|
||||
prefix1 = prefix1[:2]
|
||||
}
|
||||
prefix2 := strings.ToLower(pubkey)
|
||||
// Use the precomputed byPathHop index instead of scanning all packets.
|
||||
// Look up by full pubkey (resolved hops) and by short prefixes (raw hops).
|
||||
lowerPK := strings.ToLower(pubkey)
|
||||
prefix2 := lowerPK
|
||||
if len(prefix2) > 4 {
|
||||
prefix2 = prefix2[:4]
|
||||
}
|
||||
prefix1 := lowerPK
|
||||
if len(prefix1) > 2 {
|
||||
prefix1 = prefix1[:2]
|
||||
}
|
||||
|
||||
s.store.mu.RLock()
|
||||
_, pm := s.store.getCachedNodesAndPM()
|
||||
|
||||
// Collect candidate transmissions from the index, deduplicating by tx ID.
|
||||
seen := make(map[int]bool)
|
||||
var candidates []*StoreTx
|
||||
addCandidates := func(key string) {
|
||||
for _, tx := range s.store.byPathHop[key] {
|
||||
if !seen[tx.ID] {
|
||||
seen[tx.ID] = true
|
||||
candidates = append(candidates, tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
addCandidates(lowerPK) // full pubkey match (from resolved_path)
|
||||
addCandidates(prefix1) // 2-char raw hop match
|
||||
addCandidates(prefix2) // 4-char raw hop match
|
||||
// Also check any raw hops that start with prefix2 (longer prefixes).
|
||||
// Raw hops are typically 2 chars, so iterate only keys with HasPrefix
|
||||
// on the small set of index keys rather than all packets.
|
||||
for key := range s.store.byPathHop {
|
||||
if len(key) > 4 && len(key) < len(lowerPK) && strings.HasPrefix(key, prefix2) {
|
||||
addCandidates(key)
|
||||
}
|
||||
}
|
||||
|
||||
type pathAgg struct {
|
||||
Hops []PathHopResp
|
||||
Count int
|
||||
@@ -1092,24 +1148,9 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
hopCache[hop] = r
|
||||
return r
|
||||
}
|
||||
for _, tx := range s.store.packets {
|
||||
hops := txGetParsedPath(tx)
|
||||
if len(hops) == 0 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, hop := range hops {
|
||||
hl := strings.ToLower(hop)
|
||||
if hl == prefix1 || hl == prefix2 || strings.HasPrefix(hl, prefix2) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tx := range candidates {
|
||||
totalTransmissions++
|
||||
hops := txGetParsedPath(tx)
|
||||
resolvedHops := make([]PathHopResp, len(hops))
|
||||
sigParts := make([]string, len(hops))
|
||||
for i, hop := range hops {
|
||||
@@ -1337,6 +1378,57 @@ func (s *Server) handleAnalyticsSubpaths(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnalyticsSubpathsBulk returns multiple length-range buckets in a single
|
||||
// response, avoiding repeated scans of the same packet data. Query format:
|
||||
// ?groups=2-2:50,3-3:30,4-4:20,5-8:15 (minLen-maxLen:limit per group)
|
||||
func (s *Server) handleAnalyticsSubpathsBulk(w http.ResponseWriter, r *http.Request) {
|
||||
region := r.URL.Query().Get("region")
|
||||
groupsParam := r.URL.Query().Get("groups")
|
||||
if groupsParam == "" {
|
||||
writeJSON(w, ErrorResp{Error: "groups parameter required (e.g. groups=2-2:50,3-3:30)"})
|
||||
return
|
||||
}
|
||||
|
||||
var groups []subpathGroup
|
||||
for _, g := range strings.Split(groupsParam, ",") {
|
||||
parts := strings.SplitN(g, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
writeJSON(w, ErrorResp{Error: "invalid group format: " + g})
|
||||
return
|
||||
}
|
||||
rangeParts := strings.SplitN(parts[0], "-", 2)
|
||||
if len(rangeParts) != 2 {
|
||||
writeJSON(w, ErrorResp{Error: "invalid range format: " + parts[0]})
|
||||
return
|
||||
}
|
||||
mn, err1 := strconv.Atoi(rangeParts[0])
|
||||
mx, err2 := strconv.Atoi(rangeParts[1])
|
||||
lim, err3 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil || err3 != nil || mn < 2 || mx < mn || lim < 1 {
|
||||
writeJSON(w, ErrorResp{Error: "invalid group: " + g})
|
||||
return
|
||||
}
|
||||
groups = append(groups, subpathGroup{mn, mx, lim})
|
||||
}
|
||||
|
||||
if s.store == nil {
|
||||
results := make([]map[string]interface{}, len(groups))
|
||||
for i := range groups {
|
||||
results[i] = map[string]interface{}{"subpaths": []interface{}{}, "totalPaths": 0}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"results": results})
|
||||
return
|
||||
}
|
||||
|
||||
results := s.store.GetAnalyticsSubpathsBulk(region, groups)
|
||||
writeJSON(w, map[string]interface{}{"results": results})
|
||||
}
|
||||
|
||||
// subpathGroup defines a length-range + limit for the bulk subpaths endpoint.
|
||||
type subpathGroup struct {
|
||||
MinLen, MaxLen, Limit int
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsSubpathDetail(w http.ResponseWriter, r *http.Request) {
|
||||
hops := r.URL.Query().Get("hops")
|
||||
if hops == "" {
|
||||
@@ -1406,24 +1498,25 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
hopLower := strings.ToLower(hop)
|
||||
rows, err := s.db.conn.Query("SELECT public_key, name, lat, lon FROM nodes WHERE LOWER(public_key) LIKE ?", hopLower+"%")
|
||||
if err != nil {
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "ambiguous"}
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve candidates from the in-memory prefix map instead of
|
||||
// issuing per-hop DB queries (fixes N+1 pattern, see #369).
|
||||
var candidates []HopCandidate
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
var name sql.NullString
|
||||
var lat, lon sql.NullFloat64
|
||||
rows.Scan(&pk, &name, &lat, &lon)
|
||||
candidates = append(candidates, HopCandidate{
|
||||
Name: nullStr(name), Pubkey: pk,
|
||||
Lat: nullFloat(lat), Lon: nullFloat(lon),
|
||||
})
|
||||
if pm != nil {
|
||||
if matched, ok := pm.m[hopLower]; ok {
|
||||
for _, ni := range matched {
|
||||
c := HopCandidate{Pubkey: ni.PublicKey}
|
||||
if ni.Name != "" {
|
||||
c.Name = ni.Name
|
||||
}
|
||||
if ni.HasGPS {
|
||||
c.Lat = ni.Lat
|
||||
c.Lon = ni.Lon
|
||||
}
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(candidates) == 0 {
|
||||
resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "no_match"}
|
||||
@@ -1546,8 +1639,12 @@ func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour).Unix()
|
||||
pktCounts := s.db.GetObserverPacketCounts(oneHourAgo)
|
||||
|
||||
// Batch lookup: node locations (observer ID may match a node public_key)
|
||||
nodeLocations := s.db.GetNodeLocations()
|
||||
// Batch lookup: node locations only for observer IDs (not all nodes)
|
||||
observerIDs := make([]string, len(observers))
|
||||
for i, o := range observers {
|
||||
observerIDs[i] = o.ID
|
||||
}
|
||||
nodeLocations := s.db.GetNodeLocationsByKeys(observerIDs)
|
||||
|
||||
result := make([]ObserverResp, 0, len(observers))
|
||||
for _, o := range observers {
|
||||
|
||||
+315
-9
@@ -1105,6 +1105,63 @@ func TestAnalyticsSubpaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsSubpathsBulk(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
|
||||
// Valid request with multiple groups.
|
||||
req := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk?groups=2-2:50,3-3:30,5-8:15", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
results, ok := body["results"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected results array")
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Errorf("expected 3 result groups, got %d", len(results))
|
||||
}
|
||||
// Each result should have subpaths and totalPaths.
|
||||
for i, r := range results {
|
||||
rm, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("result %d not a map", i)
|
||||
}
|
||||
if _, ok := rm["subpaths"]; !ok {
|
||||
t.Errorf("result %d missing subpaths", i)
|
||||
}
|
||||
if _, ok := rm["totalPaths"]; !ok {
|
||||
t.Errorf("result %d missing totalPaths", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Missing groups param → error.
|
||||
req2 := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
if w2.Code != 200 {
|
||||
t.Fatalf("expected 200 with error body, got %d", w2.Code)
|
||||
}
|
||||
var errBody map[string]interface{}
|
||||
json.Unmarshal(w2.Body.Bytes(), &errBody)
|
||||
if _, ok := errBody["error"]; !ok {
|
||||
t.Error("expected error field for missing groups param")
|
||||
}
|
||||
|
||||
// Invalid group format.
|
||||
req3 := httptest.NewRequest("GET", "/api/analytics/subpaths-bulk?groups=bad", nil)
|
||||
w3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w3, req3)
|
||||
var errBody3 map[string]interface{}
|
||||
json.Unmarshal(w3.Body.Bytes(), &errBody3)
|
||||
if _, ok := errBody3["error"]; !ok {
|
||||
t.Error("expected error for invalid group format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsSubpathDetailWithHops(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/analytics/subpath-detail?hops=aa,bb", nil)
|
||||
@@ -1170,6 +1227,11 @@ func TestResolveHopsAmbiguous(t *testing.T) {
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
@@ -2105,7 +2167,7 @@ tx := &StoreTx{
|
||||
ID: 9000 + i,
|
||||
RawHex: rawHex,
|
||||
Hash: "testhash" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
@@ -2151,7 +2213,7 @@ for i, raw := range raws {
|
||||
ID: 8000 + i,
|
||||
RawHex: raw,
|
||||
Hash: "dominant" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
@@ -2190,12 +2252,13 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
|
||||
// 4 historical 1-byte adverts, then 1 recent 2-byte advert (latest).
|
||||
// Mode would pick 1 (majority), but latest-wins should pick 2.
|
||||
raws := []string{raw1byte, raw1byte, raw1byte, raw1byte, raw2byte}
|
||||
baseTime := time.Now().UTC().Add(-1 * time.Hour)
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 7000 + i,
|
||||
RawHex: raw,
|
||||
Hash: "latest" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
|
||||
FirstSeen: baseTime.Add(time.Duration(i) * time.Minute).Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
@@ -2236,12 +2299,13 @@ func TestGetNodeHashSizeInfoIgnoreDirectZeroHop(t *testing.T) {
|
||||
|
||||
payloadType := 4
|
||||
raws := []string{rawFlood2B, rawDirect0, rawFlood2B, rawDirect0, rawFlood2B}
|
||||
baseTime2 := time.Now().UTC().Add(-1 * time.Hour)
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 9150 + i,
|
||||
RawHex: raw,
|
||||
Hash: "dirignore" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
|
||||
FirstSeen: baseTime2.Add(time.Duration(i) * time.Minute).Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
@@ -2284,7 +2348,7 @@ func TestGetNodeHashSizeInfoOnlyDirectZeroHopIgnored(t *testing.T) {
|
||||
ID: 9160,
|
||||
RawHex: rawDirect0,
|
||||
Hash: "onlydirect0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
@@ -2320,7 +2384,7 @@ func TestGetNodeHashSizeInfoDirectNonZeroHopCounted(t *testing.T) {
|
||||
ID: 9170,
|
||||
RawHex: rawDirectNonZero,
|
||||
Hash: "dirnonzero0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
@@ -2355,7 +2419,7 @@ func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) {
|
||||
ID: 6000,
|
||||
RawHex: "0440aabb",
|
||||
Hash: "noadverts0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: `{"pubKey":"` + pk + `"}`,
|
||||
}
|
||||
@@ -2397,7 +2461,7 @@ func TestHashAnalyticsZeroHopAdvert(t *testing.T) {
|
||||
ID: 8000,
|
||||
RawHex: raw,
|
||||
Hash: "zerohop0",
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
// No PathJSON → txGetParsedPath returns nil (zero hops)
|
||||
@@ -2451,7 +2515,7 @@ func TestAnalyticsHashSizeSameNameDifferentPubkey(t *testing.T) {
|
||||
ID: 6100 + i,
|
||||
RawHex: raw2byte,
|
||||
Hash: "samename" + strconv.Itoa(i),
|
||||
FirstSeen: "2024-01-01T00:00:00Z",
|
||||
FirstSeen: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
PathJSON: `["AABB"]`,
|
||||
@@ -2491,6 +2555,158 @@ t.Errorf("field %q is null, expected []", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestInconsistentNodesExcludesCompanions(t *testing.T) {
|
||||
// Issue #566: inconsistentNodes should only include repeaters and room servers.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
payloadType := 4
|
||||
|
||||
// Create three nodes: repeater, room_server, companion — all with inconsistent hash sizes
|
||||
nodes := []struct {
|
||||
pk string
|
||||
role string
|
||||
}{
|
||||
{"aa11111111111111111111111111111111111111111111111111111111111111", "repeater"},
|
||||
{"bb22222222222222222222222222222222222222222222222222222222222222", "room_server"},
|
||||
{"cc33333333333333333333333333333333333333333333333333333333333333", "companion"},
|
||||
}
|
||||
|
||||
for ni, n := range nodes {
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, ?, ?)", n.pk, "Node-"+n.role, n.role)
|
||||
decoded := `{"name":"Node-` + n.role + `","pubKey":"` + n.pk + `"}`
|
||||
// Create flip-flop pattern: 1-byte, 2-byte, 1-byte (transitions=2 → inconsistent)
|
||||
// Use header 0x11 (routeType=FLOOD, payloadType=4) and pathByte 0x41/0x81
|
||||
// (non-zero hop count) so packets aren't skipped by direct zero-hop filter.
|
||||
raws := []string{"11" + "41" + "aabb", "11" + "81" + "aabb", "11" + "41" + "aabb"}
|
||||
for i, raw := range raws {
|
||||
tx := &StoreTx{
|
||||
ID: 7000 + ni*10 + i,
|
||||
RawHex: raw,
|
||||
Hash: "incon-" + n.role + strconv.Itoa(i),
|
||||
FirstSeen: now,
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
}
|
||||
|
||||
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/analytics/hash-collisions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
incon := body["inconsistent_nodes"].([]interface{})
|
||||
for _, item := range incon {
|
||||
node := item.(map[string]interface{})
|
||||
role := node["role"].(string)
|
||||
if role == "companion" {
|
||||
t.Error("companion node should be excluded from inconsistent_nodes")
|
||||
}
|
||||
}
|
||||
|
||||
// Repeater and room_server should be present
|
||||
roles := make(map[string]bool)
|
||||
for _, item := range incon {
|
||||
node := item.(map[string]interface{})
|
||||
roles[node["role"].(string)] = true
|
||||
}
|
||||
if !roles["repeater"] {
|
||||
t.Error("expected repeater in inconsistent_nodes")
|
||||
}
|
||||
if !roles["room_server"] {
|
||||
t.Error("expected room_server in inconsistent_nodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashSizeInfoTimeWindow(t *testing.T) {
|
||||
// Issue #566: adverts older than 7 days should be excluded from hash size computation.
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load failed: %v", err)
|
||||
}
|
||||
|
||||
pk := "dd44444444444444444444444444444444444444444444444444444444444444"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'OldNode', 'repeater')", pk)
|
||||
|
||||
decoded := `{"name":"OldNode","pubKey":"` + pk + `"}`
|
||||
payloadType := 4
|
||||
|
||||
// Old adverts (>7 days ago) with flip-flop pattern
|
||||
// Use header 0x11 (routeType=FLOOD) and pathByte 0x41/0x81 (non-zero hop count)
|
||||
// so packets aren't skipped by direct zero-hop filter.
|
||||
oldTime := time.Now().UTC().Add(-10 * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
||||
oldRaws := []string{"11" + "41" + "aabb", "11" + "81" + "aabb", "11" + "41" + "aabb"}
|
||||
for i, raw := range oldRaws {
|
||||
tx := &StoreTx{
|
||||
ID: 6000 + i,
|
||||
RawHex: raw,
|
||||
Hash: "old-" + strconv.Itoa(i),
|
||||
FirstSeen: oldTime,
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
info := store.GetNodeHashSizeInfo()
|
||||
ni := info[pk]
|
||||
if ni != nil && ni.Inconsistent {
|
||||
t.Error("old adverts (>7 days) should be excluded; node should not be flagged as inconsistent")
|
||||
}
|
||||
|
||||
// Now add recent adverts with consistent hash size — should appear in info
|
||||
pk2 := "ee55555555555555555555555555555555555555555555555555555555555555"
|
||||
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'NewNode', 'repeater')", pk2)
|
||||
decoded2 := `{"name":"NewNode","pubKey":"` + pk2 + `"}`
|
||||
recentTime := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
for i := 0; i < 3; i++ {
|
||||
tx := &StoreTx{
|
||||
ID: 6100 + i,
|
||||
RawHex: "11" + "41" + "aabb",
|
||||
Hash: "new-" + strconv.Itoa(i),
|
||||
FirstSeen: recentTime,
|
||||
PayloadType: &payloadType,
|
||||
DecodedJSON: decoded2,
|
||||
}
|
||||
store.packets = append(store.packets, tx)
|
||||
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
|
||||
}
|
||||
|
||||
// Invalidate cache before second call
|
||||
store.hashSizeInfoMu.Lock()
|
||||
store.hashSizeInfoCache = nil
|
||||
store.hashSizeInfoMu.Unlock()
|
||||
|
||||
info2 := store.GetNodeHashSizeInfo()
|
||||
ni2 := info2[pk2]
|
||||
if ni2 == nil {
|
||||
t.Error("recent adverts should be included in hash size info")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserverAnalyticsNoStore(t *testing.T) {
|
||||
_, router := setupNoStoreServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/analytics", nil)
|
||||
@@ -3277,3 +3493,93 @@ func TestHashCollisionsOnlyRepeaters(t *testing.T) {
|
||||
t.Errorf("expected 2 nodes in collision, got %d", len(collisions[0].Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodePathsEndpointUsesIndex(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
// Verify byPathHop index was built during Load
|
||||
srv.store.mu.RLock()
|
||||
hopKeys := len(srv.store.byPathHop)
|
||||
srv.store.mu.RUnlock()
|
||||
if hopKeys == 0 {
|
||||
t.Fatal("byPathHop index is empty after Load")
|
||||
}
|
||||
|
||||
// Query paths for TestRepeater (pubkey aabbccdd11223344, prefix "aa")
|
||||
// Should find transmissions with hop "aa" in path
|
||||
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Paths []json.RawMessage `json:"paths"`
|
||||
TotalTransmissions int `json:"totalTransmissions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("bad JSON: %v", err)
|
||||
}
|
||||
|
||||
// Transmission 1 has path ["aa","bb"] which contains "aa" matching prefix of aabbccdd11223344
|
||||
if resp.TotalTransmissions == 0 {
|
||||
t.Error("expected at least 1 transmission matching node paths")
|
||||
}
|
||||
if len(resp.Paths) == 0 {
|
||||
t.Error("expected at least 1 path group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathHopIndexIncrementalUpdate(t *testing.T) {
|
||||
// Test that addTxToPathHopIndex and removeTxFromPathHopIndex work correctly
|
||||
idx := make(map[string][]*StoreTx)
|
||||
|
||||
pk1 := "fullpubkey1"
|
||||
tx1 := &StoreTx{
|
||||
ID: 1,
|
||||
PathJSON: `["ab","cd"]`,
|
||||
ResolvedPath: []*string{&pk1, nil},
|
||||
}
|
||||
|
||||
addTxToPathHopIndex(idx, tx1)
|
||||
|
||||
// Should be indexed under "ab", "cd", and "fullpubkey1"
|
||||
if len(idx["ab"]) != 1 {
|
||||
t.Errorf("expected 1 entry for 'ab', got %d", len(idx["ab"]))
|
||||
}
|
||||
if len(idx["cd"]) != 1 {
|
||||
t.Errorf("expected 1 entry for 'cd', got %d", len(idx["cd"]))
|
||||
}
|
||||
if len(idx["fullpubkey1"]) != 1 {
|
||||
t.Errorf("expected 1 entry for resolved pubkey, got %d", len(idx["fullpubkey1"]))
|
||||
}
|
||||
|
||||
// Add another tx with overlapping hop
|
||||
tx2 := &StoreTx{
|
||||
ID: 2,
|
||||
PathJSON: `["ab","ef"]`,
|
||||
}
|
||||
addTxToPathHopIndex(idx, tx2)
|
||||
|
||||
if len(idx["ab"]) != 2 {
|
||||
t.Errorf("expected 2 entries for 'ab', got %d", len(idx["ab"]))
|
||||
}
|
||||
if len(idx["ef"]) != 1 {
|
||||
t.Errorf("expected 1 entry for 'ef', got %d", len(idx["ef"]))
|
||||
}
|
||||
|
||||
// Remove tx1
|
||||
removeTxFromPathHopIndex(idx, tx1)
|
||||
|
||||
if len(idx["ab"]) != 1 {
|
||||
t.Errorf("expected 1 entry for 'ab' after removal, got %d", len(idx["ab"]))
|
||||
}
|
||||
if _, ok := idx["cd"]; ok {
|
||||
t.Error("expected 'cd' key to be deleted after removal")
|
||||
}
|
||||
if _, ok := idx["fullpubkey1"]; ok {
|
||||
t.Error("expected resolved pubkey key to be deleted after removal")
|
||||
}
|
||||
}
|
||||
|
||||
+768
-332
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
# Security Analysis: MeshCore Channel Encryption
|
||||
|
||||
## Scope
|
||||
|
||||
This analysis covers MeshCore's encryption vulnerabilities in order of practical severity. Section 1 addresses PSK brute-force (the highest-priority practical threat). Sections 2–9 cover AES-128-ECB structural weaknesses. Section 8 covers TXT_MSG. All claims are derived from firmware source (`BaseChatMesh.cpp`, `Utils.cpp`, `Mesh.cpp`, `MeshCore.h`) unless explicitly marked as conjecture.
|
||||
|
||||
## 1. PSK Brute-Force with Timestamp Oracle
|
||||
|
||||
### 1.1 The No-KDF Design
|
||||
|
||||
MeshCore channel PSKs are base64-decoded directly into AES-128 keys with no key derivation function (from `BaseChatMesh::addChannel()`):
|
||||
|
||||
```cpp
|
||||
int len = decode_base64((unsigned char *) psk_base64, strlen(psk_base64), dest->channel.secret);
|
||||
```
|
||||
|
||||
No PBKDF2, scrypt, argon2, or HKDF is applied. The base64-decoded bytes ARE the AES key. This means:
|
||||
|
||||
1. **Human-memorable passphrases have drastically reduced entropy.** If a user types "SecretChannel" as their PSK, the base64-decoded output is ~10 bytes of ASCII-range values. The key space is determined by the passphrase complexity, not by AES-128's theoretical 2^128 key space.
|
||||
|
||||
2. **Short passphrases produce short keys.** `decode_base64` maps every 4 base64 characters to 3 bytes. A passphrase shorter than ~22 base64 characters produces fewer than 16 bytes, and the remainder of the 16-byte key buffer depends on whatever was previously in memory (likely zeros from initialization). An 8-character passphrase decodes to only 6 bytes — the effective key space may be as low as 2^48.
|
||||
|
||||
3. **No salt.** Identical passphrases on different meshes produce identical keys. A single precomputed dictionary attack works globally against all MeshCore deployments.
|
||||
|
||||
### 1.2 Timestamp as Known-Plaintext Oracle
|
||||
|
||||
Every GRP_TXT plaintext begins with a structured, largely predictable header:
|
||||
|
||||
```
|
||||
Block 0: [TS₀][TS₁][TS₂][TS₃][0x00][sender_name][: ][message_start...]
|
||||
```
|
||||
|
||||
An attacker who captures a single packet can verify a candidate PSK by:
|
||||
1. Decrypting block 0 with the candidate key
|
||||
2. Checking if bytes 0–3 produce a plausible Unix timestamp (within a reasonable window of the capture time)
|
||||
3. Checking if byte 4 is 0x00 (TXT_TYPE_PLAIN)
|
||||
4. Optionally checking if bytes 5+ are valid ASCII (sender name)
|
||||
|
||||
The timestamp alone constrains the search: a ±1-hour window around capture time yields ~7,200 valid timestamps out of 2^32 possibilities — a false-positive rate of ~1.7×10^-6. Combined with the type byte and ASCII sender-name check, false positives are effectively zero. **One captured packet is sufficient for definitive key verification.**
|
||||
|
||||
### 1.3 Attack Cost Estimates
|
||||
|
||||
Hardware assumption: commodity GPU (e.g., RTX 4090) performing ~10 billion AES-128-ECB block encryptions per second. This is conservative — optimized implementations achieve higher throughput.
|
||||
|
||||
| Passphrase style | Search space | Time at 10^10 AES/sec |
|
||||
|---|---|---|
|
||||
| Single common English word (10K-word list) | ~10^4 | microseconds |
|
||||
| Single English word (170K full dictionary) | ~1.7×10^5 | microseconds |
|
||||
| Two concatenated common words | ~10^8 | ~10 milliseconds |
|
||||
| Three concatenated common words | ~10^12 | ~100 seconds (~2 min) |
|
||||
| Four random common words (Diceware-style) | ~10^16 | ~10^6 seconds (~12 days) |
|
||||
| Random 8-char alphanumeric (62^8) | ~2.2×10^14 | ~22,000 seconds (~6 hours) |
|
||||
| Random 12-char alphanumeric (62^12) | ~3.2×10^21 | ~10^11 seconds (infeasible) |
|
||||
| Full random 16-byte key (2^128) | ~3.4×10^38 | infeasible |
|
||||
|
||||
**Important caveats on search space:**
|
||||
- Dictionary sizes vary: "common English words" ≈ 3K–10K; full dictionary ≈ 170K. Estimates above use 10K for "common" lists.
|
||||
- Humans do not choose words uniformly. Zipf's law applies — a small fraction of words account for most selections. The effective entropy is **lower** than the uniform assumption, making attacks faster.
|
||||
- Concatenation without separators creates ambiguity ("therapist" = "therapist" or "the"+"rapist"), but this marginally reduces search space rather than increasing it.
|
||||
- Multi-channel amortization: an attacker can test each candidate against ALL captured channels simultaneously, paying the AES cost once per candidate.
|
||||
|
||||
### 1.4 Attack Properties
|
||||
|
||||
- **Offline attack.** No rate limiting, no lockout, no detection. The attacker works entirely on captured ciphertext.
|
||||
- **Single-packet verification.** One GRP_TXT packet is sufficient. No need to collect multiple messages.
|
||||
- **No KDF stretching.** Each candidate requires exactly one AES-128 block decryption (16 bytes), not thousands of hash iterations.
|
||||
- **Global applicability.** No salt means precomputed tables work across all MeshCore deployments using the same passphrase.
|
||||
- **Side-channel exposure.** Since the PSK IS the key (no KDF), any AES key-schedule side-channel directly reveals the passphrase. PSK reuse across systems (e.g., same passphrase for MeshCore and WiFi) means compromise of one compromises both.
|
||||
|
||||
### 1.5 Severity Assessment
|
||||
|
||||
**PSK brute-force is the #1 practical threat to MeshCore channel confidentiality.** Unlike ECB frequency analysis (§5), which requires hundreds of captured messages with repeated content, PSK brute-force requires a single captured packet and succeeds whenever users choose human-memorable passphrases — which is the common case for manually-configured channels.
|
||||
|
||||
Any channel using a passphrase of 3 or fewer common words, or any alphanumeric string shorter than 12 characters, should be considered **vulnerable to offline brute-force within hours to days** using commodity hardware.
|
||||
|
||||
### 1.6 Recommended Mitigations
|
||||
|
||||
**Priority 0 (Critical):** Apply a memory-hard KDF (argon2id preferred; scrypt or PBKDF2 with ≥100K iterations as fallback) to derive the AES key from the passphrase. This transforms each candidate test from ~1 nanosecond to ~100 milliseconds, increasing attack cost by a factor of ~10^8.
|
||||
|
||||
**Priority 0a:** Add a per-channel salt (random bytes stored alongside the channel config) to prevent precomputed/global attacks.
|
||||
|
||||
**Priority 0b:** Document that channel PSKs should be random 16-byte keys (e.g., generated with `openssl rand -base64 22`), not human-memorable passphrases. This is a stopgap until KDF support is added.
|
||||
|
||||
## 2. How Encryption Works
|
||||
|
||||
### Constants (from `MeshCore.h`)
|
||||
- `CIPHER_KEY_SIZE = 16` (AES-128)
|
||||
- `PUB_KEY_SIZE = 32`
|
||||
- `CIPHER_MAC_SIZE` = HMAC-SHA256 truncated output size
|
||||
|
||||
### encrypt() (from `Utils.cpp`)
|
||||
AES-128-ECB, block-by-block. No IV, no counter, no chaining:
|
||||
```cpp
|
||||
aes.setKey(shared_secret, CIPHER_KEY_SIZE); // first 16 bytes of shared_secret
|
||||
while (src_len >= 16) {
|
||||
aes.encryptBlock(dp, src); // each 16-byte block independently
|
||||
dp += 16; src += 16; src_len -= 16;
|
||||
}
|
||||
if (src_len > 0) { // partial final block
|
||||
uint8_t tmp[16];
|
||||
memset(tmp, 0, 16); // zero-fill
|
||||
memcpy(tmp, src, src_len); // copy remaining bytes
|
||||
aes.encryptBlock(dp, tmp);
|
||||
}
|
||||
```
|
||||
|
||||
### encryptThenMAC() (from `Utils.cpp`)
|
||||
```cpp
|
||||
int enc_len = encrypt(shared_secret, dest + CIPHER_MAC_SIZE, src, src_len);
|
||||
SHA256 sha;
|
||||
sha.resetHMAC(shared_secret, PUB_KEY_SIZE); // HMAC uses full 32 bytes
|
||||
sha.update(dest + CIPHER_MAC_SIZE, enc_len);
|
||||
sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, dest, CIPHER_MAC_SIZE);
|
||||
```
|
||||
|
||||
**Key reuse flaw:** The same `shared_secret` buffer serves both AES and HMAC. AES uses `shared_secret[0..15]` (first 16 bytes). HMAC uses `shared_secret[0..31]` (full 32 bytes). The AES key is a prefix of the HMAC key. See §7 for implications.
|
||||
|
||||
### GRP_TXT Plaintext Construction (from `BaseChatMesh::sendGroupMessage()`)
|
||||
|
||||
```cpp
|
||||
memcpy(temp, ×tamp, 4); // bytes 0-3: Unix timestamp (seconds)
|
||||
temp[4] = 0; // byte 4: TXT_TYPE_PLAIN
|
||||
sprintf((char *)&temp[5], "%s: ", sender_name); // bytes 5+: "SenderName: "
|
||||
char *ep = strchr((char *)&temp[5], 0);
|
||||
int prefix_len = ep - (char *)&temp[5]; // length of "SenderName: "
|
||||
memcpy(ep, text, text_len); // message text (no null terminator)
|
||||
ep[text_len] = 0; // null written AFTER data boundary
|
||||
// data_len passed to encrypt = 5 + prefix_len + text_len
|
||||
```
|
||||
|
||||
**The null terminator is NOT part of the encrypted data length.** The call to `createGroupDatagram` passes length `5 + prefix_len + text_len`. The null at `ep[text_len]` is written to the buffer but is beyond `data_len`. In the final partial block, `encrypt()` zero-fills with `memset(tmp, 0, 16)` before copying the remaining bytes — so a zero byte appears at the position where the null would be, but this is an artifact of zero-padding, not an explicit null in the plaintext.
|
||||
|
||||
On the receiving side, this is confirmed:
|
||||
```cpp
|
||||
data[len] = 0; // need to make a C string again, with null terminator
|
||||
```
|
||||
The receiver must re-add the null after decryption.
|
||||
|
||||
## 3. Block Layout Analysis
|
||||
|
||||
### Notation
|
||||
|
||||
Let `N` = length of sender name. Then:
|
||||
- `prefix_len` = N + 2 (for ": " suffix from `sprintf("%s: ", sender_name)`)
|
||||
- Header overhead = 4 (timestamp) + 1 (type) + prefix_len = N + 7 bytes
|
||||
- Message text begins at byte offset N + 7
|
||||
|
||||
### Block 0
|
||||
|
||||
Block 0 = bytes 0–15 of plaintext:
|
||||
```
|
||||
[TS₀][TS₁][TS₂][TS₃][0x00][sender_name: ][...message start...]
|
||||
```
|
||||
|
||||
The first 9 − N bytes of message text fit in block 0 (when N < 9). For N ≥ 9, no message text fits in block 0.
|
||||
|
||||
### Boundary Condition: Sender Name ≥ 12 Characters
|
||||
|
||||
When N ≥ 12, the header overhead (N + 7 ≥ 19) exceeds 16 bytes. The header itself spills into block 1:
|
||||
|
||||
**Example: sender name "LongUserName1" (N = 13), message "hi":**
|
||||
```
|
||||
Header = 13 + 7 = 20 bytes. Total plaintext = 20 + 2 = 22 bytes.
|
||||
|
||||
Block 0 (bytes 0-15): [TS₀][TS₁][TS₂][TS₃][0x00][L][o][n][g][U][s][e][r][N][a][m]
|
||||
Block 1 (bytes 16-31): [e][1][:][space][h][i][0x00 ×10] ← zero-padded partial block
|
||||
```
|
||||
|
||||
Block 1 here contains the tail of the sender name, the ": " separator, message text, AND zero-padding. For sender names of length 12–15, block 1 is a mix of header and message — **it is NOT "pure message text."**
|
||||
|
||||
For sender names ≥ 16, blocks 0 and 1 are both pure header, and message text doesn't begin until block 1 or later.
|
||||
|
||||
### General Block Content Table
|
||||
|
||||
| Sender name length N | Header bytes | Message starts at byte | Block 0 content | Block 1+ content |
|
||||
|---|---|---|---|---|
|
||||
| 1–8 | 8–15 | 8–15 | timestamp + header + message start | message text + zero-pad |
|
||||
| 9–11 | 16–18 | 16–18 | timestamp + header (no message) | header tail + message + zero-pad |
|
||||
| 12–15 | 19–22 | 19–22 | timestamp + partial header | header tail + message + zero-pad |
|
||||
| ≥16 | ≥23 | ≥23 | timestamp + partial header | header continuation, then message |
|
||||
|
||||
### Typical Case (N = 5, e.g. "Alice")
|
||||
|
||||
Header = 12 bytes. Message starts at byte 12. Block 0 holds 4 bytes of message text.
|
||||
|
||||
```
|
||||
Message "hello world" (11 chars). Total plaintext = 12 + 11 = 23 bytes.
|
||||
|
||||
Block 0 (bytes 0-15): [TS₀][TS₁][TS₂][TS₃][0x00][A][l][i][c][e][:][space][h][e][l][l]
|
||||
Block 1 (bytes 16-22): [o][space][w][o][r][l][d] → padded to: [o][space][w][o][r][l][d][0×9]
|
||||
```
|
||||
|
||||
Block 1 contains 7 bytes of message text and 9 bytes of zero-padding.
|
||||
|
||||
## 4. Attack Surface by Block Position
|
||||
|
||||
### Block 0: Accidental Nonce from Timestamp
|
||||
|
||||
The 4-byte Unix timestamp in bytes 0–3 acts as an **accidental nonce** — it was included "mostly as an extra blob to help make packet_hash unique" (per firmware comment), not as a cryptographic countermeasure against ECB determinism. Nevertheless, it has the effect of making block 0's plaintext vary per message.
|
||||
|
||||
**Precision on uniqueness:** Block 0 is unique per (sender, timestamp-second) pair, not per message. Two messages from the same sender within the same second, on the same channel, with the same type byte, produce identical block 0 plaintext and therefore identical block 0 ciphertext. At typical mesh chat rates, same-second collisions are rare but not impossible for automated/scripted senders.
|
||||
|
||||
**Known-plaintext observation:** Bytes 4–15 of block 0 are largely predictable per sender (type byte is always 0x00 for plain text; sender name and ": " are static). The timestamp is predictable within a window (Unix seconds). An attacker who knows the sender name and approximate time can compute all 16 plaintext bytes of block 0. However, **AES-128 is resistant to known-plaintext attacks** — knowing plaintext-ciphertext pairs for block 0 does not help recover the key or decrypt other blocks.
|
||||
|
||||
### Blocks 1+: Deterministic ECB (for short sender names)
|
||||
|
||||
When the sender name is short enough that the header fits in block 0 (N ≤ 8), blocks 1+ contain **only message text and zero-padding.** No timestamp, no nonce, no per-message varying data. Identical message text at the same block offset produces identical ciphertext, always.
|
||||
|
||||
When N ≥ 9, block 1 contains header spillover, which includes static sender name bytes — these vary per sender but not per message, so block 1 is still deterministic for a given sender once the header portion is fixed.
|
||||
|
||||
**The fundamental ECB property:** For any block beyond the timestamp's reach, `E_K(P) = E_K(P)`. Same plaintext block → same ciphertext block, regardless of when or how many times it's sent.
|
||||
|
||||
### Partial Final Block: Strongest Attack Target
|
||||
|
||||
The final block of every message is zero-padded by `encrypt()` to 16 bytes. The padding bytes are deterministic and known (always 0x00). For a message whose final block contains `B` bytes of actual content:
|
||||
|
||||
- `B` bytes are unknown message text
|
||||
- `16 - B` bytes are known zeros
|
||||
|
||||
When B is small (short final fragment), most of the block is known plaintext. For B = 1, the attacker knows 15 of 16 bytes — only 256 possible plaintext blocks exist. This means:
|
||||
|
||||
- **The final block has at most 2^(8B) possible plaintexts** (versus 2^128 for a full unknown block)
|
||||
- For B ≤ 4, there are ≤ 2^32 possibilities — a small enough space for dictionary attacks given enough ciphertext samples
|
||||
- The attacker can precompute all possible final-block plaintexts for small B values and match against observed ciphertext blocks
|
||||
|
||||
This makes the partial final block a **stronger frequency analysis target** than interior blocks, where all 16 bytes may be unknown text.
|
||||
|
||||
## 5. Feasible Attack Scenarios
|
||||
|
||||
### 4.1 Block Frequency Analysis on Blocks 1+
|
||||
|
||||
**Preconditions (all must hold):**
|
||||
1. Attacker can observe encrypted GRP_TXT packets (passive radio capture)
|
||||
2. Messages from the same sender (or senders with identical name lengths — same block alignment)
|
||||
3. Messages long enough to produce blocks beyond block 0 (text > 9 − N chars)
|
||||
4. Sufficient message volume with repeated content at the same block positions
|
||||
|
||||
**Method:**
|
||||
1. Collect GRP_TXT packets, group by sender hash
|
||||
2. Decompose encrypted payloads into 16-byte blocks (after stripping HMAC prefix)
|
||||
3. Discard block 0 (timestamp-varying)
|
||||
4. Build frequency tables for blocks 1, 2, 3, etc., per sender
|
||||
5. Match high-frequency ciphertext blocks against expected plaintext distributions
|
||||
|
||||
**Practical constraints limiting this attack:**
|
||||
- LoRa bandwidth severely limits message length. Most mesh chat messages are short — many fit entirely within block 0 (≤ 9 − N chars of text), yielding zero analyzable blocks.
|
||||
- Messages that spill into block 1+ tend to be longer and more varied — fewer repeated patterns.
|
||||
- The attack requires repeated identical 16-byte-aligned text fragments from the same sender over time.
|
||||
|
||||
**Conditions under which this attack succeeds:** Automated or scripted senders transmitting repetitive messages longer than block 0 capacity, on a channel with a static PSK, over an extended collection period. For human-typed conversational messages with typical length and variety, the number of repeated block 1+ patterns is likely too low for meaningful frequency analysis. (This is an empirical claim that depends on actual traffic patterns — no formal bound is established here.)
|
||||
|
||||
### 4.2 Partial Final Block Dictionary Attack
|
||||
|
||||
**Preconditions:**
|
||||
1. Attacker knows (or can estimate) the message length modulo 16
|
||||
2. Final block has few content bytes (B ≤ 4)
|
||||
|
||||
**Method:** Enumerate all 2^(8B) candidate plaintexts for the final block. Since AES-ECB is deterministic with a fixed key, the attacker can build a lookup table: if they ever observe a ciphertext block matching one of the candidates in a known-plaintext scenario (e.g., from a leaked or guessed message), they can identify which final-block value corresponds to which ciphertext.
|
||||
|
||||
**Limitation:** Without the key, the attacker cannot compute E_K(candidate) directly. The attack requires collecting enough ciphertext final blocks to perform frequency analysis within the reduced plaintext space. With only 256 possibilities (B=1), convergence is fast given sufficient samples.
|
||||
|
||||
### 4.3 Cross-Sender Correlation
|
||||
|
||||
Senders with identical name lengths produce identical block alignments. Messages from "Alice" (N=5) and "Bobby" (N=5) place message text at the same byte offsets. If both send the same message, their blocks 1+ are identical ciphertext — **but only if they share the same channel PSK** (same AES key). On the same channel, this enables cross-sender frequency analysis within same-name-length groups.
|
||||
|
||||
### 4.4 Message Length Leakage
|
||||
|
||||
Ciphertext length = ⌈(5 + prefix_len + text_len) / 16⌉ × 16 bytes. This reveals the message text length within a 16-byte window (not 15, because the block count is the observable quantity). Not ECB-specific — any block cipher without constant-length padding leaks this.
|
||||
|
||||
### 4.5 Replay Attacks
|
||||
|
||||
`encryptThenMAC()` authenticates the ciphertext, but if the mesh doesn't track previously-seen packet MACs, captured packets can be replayed. The embedded timestamp may be checked for staleness — this requires firmware verification beyond the scope of this analysis.
|
||||
|
||||
### 4.6 No Forward Secrecy
|
||||
|
||||
Channel PSKs are static and shared among all participants. ECDH shared secrets for direct messages are also static (no ephemeral key exchange). Compromise of any key decrypts all past and future traffic encrypted under that key.
|
||||
|
||||
## 6. What Known-Plaintext Does NOT Achieve
|
||||
|
||||
AES-128 is designed to resist known-plaintext attacks. An attacker who knows the full plaintext and ciphertext of block 0 (or any block) **cannot**:
|
||||
- Recover the AES key
|
||||
- Decrypt other blocks encrypted under the same key
|
||||
- Derive any information about other plaintexts from their ciphertexts
|
||||
|
||||
The ECB weakness is **determinism** (identical plaintext → identical ciphertext), not key recovery. The attacks in §5 exploit pattern matching and frequency analysis, not cryptanalysis of AES itself.
|
||||
|
||||
## 7. HMAC Key Reuse: Cryptographic Design Flaw
|
||||
|
||||
From `encryptThenMAC()`:
|
||||
- AES key: `shared_secret[0..15]` (CIPHER_KEY_SIZE = 16)
|
||||
- HMAC key: `shared_secret[0..31]` (PUB_KEY_SIZE = 32)
|
||||
|
||||
The AES key is the first half of the HMAC key. Both are derived from the same `shared_secret` — for channels, this is the PSK; for direct messages, the ECDH shared secret.
|
||||
|
||||
**Why this matters:**
|
||||
1. **Violated key separation principle.** Standard practice dictates that encryption and authentication keys must be independent. Using overlapping portions of the same secret means a weakness in one mechanism could leak information relevant to the other.
|
||||
2. **HMAC key reveals AES key.** If an attacker recovers the 32-byte HMAC key (e.g., through a side-channel attack on the HMAC computation), they automatically obtain the 16-byte AES key as a prefix.
|
||||
3. **No key derivation function.** The shared_secret is used directly — no HKDF or similar KDF is applied to derive independent subkeys. This is a departure from cryptographic best practice (cf. RFC 5869).
|
||||
|
||||
**Practical impact:** In the current threat model (passive radio capture of LoRa packets), this is unlikely to be directly exploitable — HMAC-SHA256 does not leak its key through normal operation. However, it represents a structural weakness that compounds with any future vulnerability in either the AES or HMAC implementation.
|
||||
|
||||
## 8. TXT_MSG (Direct Message) Block Layout
|
||||
|
||||
Direct messages use a different plaintext structure (from `BaseChatMesh::composeMsgPacket()`):
|
||||
|
||||
```cpp
|
||||
memcpy(temp, ×tamp, 4); // bytes 0-3: timestamp
|
||||
temp[4] = (attempt & 3); // byte 4: attempt counter (0-3)
|
||||
memcpy(&temp[5], text, text_len + 1); // bytes 5+: message text
|
||||
// data_len = 5 + text_len (null terminator copied but not counted in length)
|
||||
```
|
||||
|
||||
**Block layout for TXT_MSG:**
|
||||
```
|
||||
Block 0: [TS₀][TS₁][TS₂][TS₃][attempt][text bytes 0-10]
|
||||
Block 1: [text bytes 11-26] (if message long enough)
|
||||
```
|
||||
|
||||
Key differences from GRP_TXT:
|
||||
- **No sender name in plaintext** — the sender is identified by the source hash in the unencrypted packet header, not in the encrypted payload.
|
||||
- **Header is exactly 5 bytes** (4 timestamp + 1 attempt), always. No variable-length field.
|
||||
- **11 bytes of message text fit in block 0** (vs. 9 − N for GRP_TXT).
|
||||
- **Encrypted with per-pair ECDH shared secret**, not a group PSK. Each sender-recipient pair has a unique key.
|
||||
|
||||
**ECB implications for TXT_MSG:**
|
||||
- Block 0 is still protected by the timestamp accidental nonce.
|
||||
- Blocks 1+ are deterministic, same as GRP_TXT — identical message text at the same offset produces identical ciphertext.
|
||||
- However, frequency analysis is harder: each sender-recipient pair uses a different key, so the attacker can only correlate messages within a single pair. The message volume for any given pair is typically much lower than for a group channel.
|
||||
- The fixed 5-byte header means block alignment is consistent across ALL direct messages (unlike GRP_TXT where alignment varies by sender name length). An attacker who compromises one ECDH key can build block frequency tables, but only for that specific pair.
|
||||
|
||||
## 9. Mitigations
|
||||
|
||||
### Priority 1: Switch to AES-128-CTR
|
||||
|
||||
Replace ECB with CTR mode. Use the existing 4-byte timestamp + a 4-byte per-message counter as the 8-byte nonce (padded to 16 bytes for the CTR block). Each byte of plaintext gets XORed with a unique keystream byte — eliminates all block-level determinism.
|
||||
|
||||
**Wire format change:** None if the nonce is derived from header fields already present. If an explicit counter is added, 4 bytes of overhead per message.
|
||||
|
||||
### Priority 2: Derive Independent Subkeys
|
||||
|
||||
Apply HKDF (or at minimum, two distinct SHA-256 hashes) to the shared_secret to produce independent AES and HMAC keys. This is a minimal code change:
|
||||
```
|
||||
aes_key = SHA256(shared_secret || "encrypt")[0..15]
|
||||
hmac_key = SHA256(shared_secret || "authenticate")
|
||||
```
|
||||
|
||||
### Priority 3: Constant-Length Padding
|
||||
|
||||
Pad all messages to a fixed block count (e.g., 4 blocks = 64 bytes) to eliminate length leakage. Expensive on LoRa — should be configurable per channel as a security-vs-bandwidth tradeoff.
|
||||
|
||||
### Priority 4: Replay Protection
|
||||
|
||||
Track seen packet HMACs within a time window. Reject messages with timestamps older than N minutes.
|
||||
|
||||
### Priority 5: Channel Key Rotation
|
||||
|
||||
Manual or automated periodic rotation of channel PSKs. Even monthly rotation limits the exposure window.
|
||||
|
||||
### Priority 6: Forward Secrecy
|
||||
|
||||
Ephemeral ECDH for direct messages. Significant protocol change but prevents retroactive decryption on key compromise.
|
||||
|
||||
## 10. Speculative: LLM-Assisted Analysis
|
||||
|
||||
> **This section is speculation, not formal analysis.** The claims below are plausible but unvalidated. They do not affect the formal findings in §1–9.
|
||||
|
||||
An LLM could reduce the sample size needed for block frequency analysis:
|
||||
|
||||
1. **Context-aware candidate generation:** Given a sender's known patterns (the sender name is recoverable from block 0's predictable prefix), an LLM could generate likely message continuations and predict which plaintext blocks to look for in the frequency tables.
|
||||
2. **Conversational inference:** Timestamps + sender IDs + partially decoded messages could let an LLM reconstruct probable conversation flow, narrowing the search space for unknown blocks.
|
||||
3. **Community-specific vocabulary:** Training on public mesh chat logs could yield common phrases and greeting patterns, further reducing the candidate plaintext space.
|
||||
|
||||
This does not change the fundamental requirement (blocks 1+ must repeat, or the final block must be in a small enough space for dictionary matching). It potentially reduces the number of captured messages needed for convergence, but no quantitative bound is established.
|
||||
|
||||
## 11. Conclusion
|
||||
|
||||
MeshCore's encryption has four vulnerabilities, ranked by practical exploitability:
|
||||
|
||||
### Vulnerability #1: PSK Brute-Force (Critical)
|
||||
|
||||
**No KDF + known-plaintext oracle = offline key recovery from a single packet.** Any channel using a human-memorable passphrase of ≤3 common words or ≤11 alphanumeric characters is recoverable in minutes to hours on commodity GPU hardware. This is the highest-priority threat because it requires minimal attacker capability (one captured packet), succeeds against the most common deployment pattern (human-chosen passphrases), and completely compromises channel confidentiality. See §1.
|
||||
|
||||
### Vulnerability #2: ECB Determinism (Medium)
|
||||
|
||||
**Blocks beyond the timestamp's reach are deterministic.** Identical plaintext at the same block offset always produces identical ciphertext. For GRP_TXT messages longer than ~9 − N characters (where N is sender name length), this enables frequency analysis on blocks 1+. The partial final block, with its known zero-padding, is the strongest individual target. Exploitation requires hundreds of captured messages with repeated content — a higher bar than PSK brute-force. See §4–§5.
|
||||
|
||||
### Vulnerability #3: Key Material Reuse (Medium)
|
||||
|
||||
**AES and HMAC share the same key material** without a key derivation function. The AES key is a prefix of the HMAC key. This violates key separation and creates a structural dependency between the encryption and authentication mechanisms. See §7.
|
||||
|
||||
### Vulnerability #4: No Forward Secrecy (Low–Medium)
|
||||
|
||||
**No forward secrecy, no key rotation, no replay protection.** These are independent of the above but compound the risk: a single key compromise (whether via brute-force or other means) exposes all past and future traffic encrypted under that key. See §9.
|
||||
|
||||
**Summary of recommended mitigations (in priority order):**
|
||||
1. **(Critical)** Apply a memory-hard KDF (argon2id) to channel PSKs — §1.6
|
||||
2. **(Critical)** Add per-channel salt — §1.6
|
||||
3. **(High)** Switch from AES-128-ECB to AES-128-CTR — §9
|
||||
4. **(High)** Derive independent AES and HMAC subkeys via HKDF — §9
|
||||
5. **(Medium)** Constant-length padding, replay protection, key rotation — §9
|
||||
6. **(Low)** Forward secrecy via ephemeral ECDH — §9
|
||||
|
||||
The timestamp in block 0 was not designed as a nonce and should not be relied upon as one.
|
||||
+366
-13
@@ -86,6 +86,7 @@
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -173,6 +174,7 @@
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
case 'prefix-tool': await renderPrefixTool(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -985,11 +987,13 @@
|
||||
<a href="#/analytics?tab=collisions§ion=hashMatrixSection" style="color:var(--accent)">🔢 Hash Matrix</a>
|
||||
<span style="color:var(--border)">|</span>
|
||||
<a href="#/analytics?tab=collisions§ion=collisionRiskSection" style="color:var(--accent)">💥 Collision Risk</a>
|
||||
<span style="color:var(--border)">|</span>
|
||||
<a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">🔎 Check a prefix →</a>
|
||||
</nav>
|
||||
|
||||
<div class="analytics-card" id="inconsistentHashSection">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">⚠️ Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||||
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Nodes sending adverts with varying hash sizes. Caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">bug</a> where automatic adverts ignored the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</p>
|
||||
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Repeaters and room servers sending adverts with varying hash sizes in the last 7 days. Originally caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">firmware bug</a> where automatic adverts ignored the configured multibyte path setting, fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>. Companion nodes are excluded.</p>
|
||||
<div id="inconsistentHashList"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading…</div></div>
|
||||
</div>
|
||||
|
||||
@@ -1398,12 +1402,8 @@
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const [d2, d3, d4, d5] = await Promise.all([
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15' + rq, { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
const bulk = await api('/analytics/subpaths-bulk?groups=2-2:50,3-3:30,4-4:20,5-8:15' + rq, { ttl: CLIENT_TTL.analyticsRF });
|
||||
const [d2, d3, d4, d5] = bulk.results;
|
||||
|
||||
function renderTable(data, title) {
|
||||
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
|
||||
@@ -1602,10 +1602,9 @@
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||||
try {
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||||
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
|
||||
const [nodesResp, bulkHealth] = await Promise.all([
|
||||
api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
const nodes = nodesResp.nodes || nodesResp;
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
@@ -1622,8 +1621,22 @@
|
||||
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
|
||||
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
|
||||
|
||||
// Use server-computed status across ALL nodes
|
||||
const { active, degraded, silent, total: totalNodes, roleCounts } = netStatus;
|
||||
// Compute network status client-side from loaded nodes using shared getHealthThresholds()
|
||||
const now = Date.now();
|
||||
let active = 0, degraded = 0, silent = 0;
|
||||
nodes.forEach(function(n) {
|
||||
const role = n.role || 'unknown';
|
||||
const th = getHealthThresholds(role);
|
||||
const lastMs = n.last_heard ? new Date(n.last_heard).getTime()
|
||||
: n.last_seen ? new Date(n.last_seen).getTime()
|
||||
: 0;
|
||||
const age = lastMs ? (now - lastMs) : Infinity;
|
||||
if (age < th.degradedMs) active++;
|
||||
else if (age < th.silentMs) degraded++;
|
||||
else silent++;
|
||||
});
|
||||
const totalNodes = nodesResp.total || nodes.length;
|
||||
const roleCounts = nodesResp.counts || {};
|
||||
|
||||
function nodeLink(n) {
|
||||
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
|
||||
@@ -2293,5 +2306,345 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
_ngState.animId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// --- Prefix Tool: Pure logic (exported for testing via _prefixToolExports) ---
|
||||
const PREFIX_SPACE_SIZES = { 1: 256, 2: 65536, 3: 16777216 };
|
||||
|
||||
/** Build 3-tier prefix indexes from deduplicated nodes. Returns { 1: Map, 2: Map, 3: Map } */
|
||||
function buildPrefixIndex(nodes) {
|
||||
const idx = { 1: new Map(), 2: new Map(), 3: new Map() };
|
||||
nodes.forEach(n => {
|
||||
const pk = n.public_key.toUpperCase();
|
||||
[1, 2, 3].forEach(b => {
|
||||
const p = pk.slice(0, b * 2);
|
||||
if (!idx[b].has(p)) idx[b].set(p, []);
|
||||
idx[b].get(p).push(n);
|
||||
});
|
||||
});
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Compute collision stats per tier */
|
||||
function computePrefixStats(idx) {
|
||||
const stats = {};
|
||||
[1, 2, 3].forEach(b => {
|
||||
stats[b] = {
|
||||
usedPrefixes: idx[b].size,
|
||||
collidingPrefixes: [...idx[b].values()].filter(arr => arr.length > 1).length,
|
||||
};
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Recommend prefix byte size based on network size */
|
||||
function recommendPrefixSize(totalNodes) {
|
||||
if (totalNodes < 20) {
|
||||
return { rec: '1-byte', detail: `With only ${totalNodes} nodes, 1-byte prefixes have low collision risk.` };
|
||||
} else if (totalNodes < 500) {
|
||||
return { rec: '2-byte', detail: `With ${totalNodes} nodes, 2-byte prefixes are recommended to avoid collisions.` };
|
||||
} else {
|
||||
return { rec: '3-byte', detail: `With ${totalNodes} nodes, 3-byte prefixes are recommended for collision-free operation.` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate prefix input. Returns { valid, error, input, isFullKey, tiers } */
|
||||
function validatePrefixInput(raw) {
|
||||
const input = raw.trim().toUpperCase();
|
||||
if (!input) return { valid: false, error: null, input, isEmpty: true };
|
||||
if (!/^[0-9A-F]+$/.test(input)) {
|
||||
return { valid: false, error: 'Invalid input — hex characters only (0-9, A-F).', input };
|
||||
}
|
||||
if (input.length % 2 !== 0 || (input.length !== 2 && input.length !== 4 && input.length !== 6 && input.length < 8)) {
|
||||
return { valid: false, error: 'Prefix must be 2, 4, or 6 hex characters. For a full public key, use 64 characters.', input };
|
||||
}
|
||||
const isFullKey = input.length >= 8;
|
||||
const tiers = isFullKey
|
||||
? [{ b: 1, prefix: input.slice(0, 2) }, { b: 2, prefix: input.slice(0, 4) }, { b: 3, prefix: input.slice(0, 6) }]
|
||||
: [{ b: input.length / 2, prefix: input }];
|
||||
return { valid: true, input, isFullKey, tiers };
|
||||
}
|
||||
|
||||
/** Check a prefix against the index. Returns collision info per tier. */
|
||||
function checkPrefix(raw, idx, nodes) {
|
||||
const v = validatePrefixInput(raw);
|
||||
if (!v.valid) return v;
|
||||
const results = v.tiers.map(({ b, prefix }) => {
|
||||
const matches = idx[b].get(prefix) || [];
|
||||
const colliders = v.isFullKey ? matches.filter(n => n.public_key.toUpperCase() !== v.input) : matches;
|
||||
return { b, prefix, colliders, count: colliders.length };
|
||||
});
|
||||
const inNetwork = v.isFullKey ? nodes.some(n => n.public_key.toUpperCase() === v.input) : null;
|
||||
return { valid: true, input: v.input, isFullKey: v.isFullKey, results, inNetwork };
|
||||
}
|
||||
|
||||
/** Generate a collision-free prefix of the given byte size. Returns null if none available. */
|
||||
function generatePrefix(b, idx, randFn) {
|
||||
const hexLen = b * 2;
|
||||
const totalSpace = PREFIX_SPACE_SIZES[b];
|
||||
const available = totalSpace - idx[b].size;
|
||||
if (available === 0) return null;
|
||||
|
||||
const _rand = randFn || Math.random;
|
||||
if (b === 1) {
|
||||
const free = [];
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p)) free.push(p);
|
||||
}
|
||||
return free[Math.floor(_rand() * free.length)];
|
||||
}
|
||||
// Random sampling with fallback
|
||||
let attempts = 0, prefix;
|
||||
do {
|
||||
prefix = Math.floor(_rand() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
} while (idx[b].has(prefix) && ++attempts < 500);
|
||||
if (idx[b].has(prefix)) {
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p)) return p;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// --- Prefix Tool: HTML helpers ---
|
||||
function renderNodeEntry(n, escFn) {
|
||||
const name = escFn(n.name || n.public_key.slice(0, 12));
|
||||
const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${escFn(n.role)}</span>` : '';
|
||||
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${new Date(n.last_seen).toLocaleDateString()}</span>` : '';
|
||||
return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${when}</div>`;
|
||||
}
|
||||
|
||||
function renderSeverityBadge(count) {
|
||||
if (count === 0) return '<span style="color:var(--status-green)">✅ Unique</span>';
|
||||
if (count <= 2) return `<span style="color:var(--status-yellow)">⚠️ ${count} collision${count !== 1 ? 's' : ''}</span>`;
|
||||
return `<span style="color:var(--status-red)">🔴 ${count} collisions</span>`;
|
||||
}
|
||||
|
||||
function renderPrefixStatCard(b, stat, spaceSize) {
|
||||
const hasCollisions = stat.collidingPrefixes > 0;
|
||||
return `<div class="analytics-stat-card" style="flex:1;min-width:150px;border-color:${hasCollisions ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">${b}-byte prefixes</div>
|
||||
<div class="analytics-stat-value" style="font-size:1em">
|
||||
${stat.usedPrefixes.toLocaleString()}
|
||||
<span class="text-muted" style="font-size:0.7em"> / ${spaceSize.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style="font-size:0.82em;margin-top:4px;color:${hasCollisions ? 'var(--status-red)' : 'var(--status-green)'}">
|
||||
${stat.collidingPrefixes === 0
|
||||
? '✅ No collisions'
|
||||
: `⚠️ ${stat.collidingPrefixes} prefix${stat.collidingPrefixes !== 1 ? 'es' : ''} collide`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNetworkOverview(totalNodes, stats, rec, recDetail, regionLabel) {
|
||||
const regionNote = regionLabel
|
||||
? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Check all nodes →</a></p>`
|
||||
: '';
|
||||
return `<div class="analytics-card" id="ptOverview">
|
||||
<div style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none" id="ptOverviewToggle">
|
||||
<span id="ptOverviewChevron" style="font-size:0.75em;color:var(--text-muted);transition:transform 0.2s">▶</span>
|
||||
<h3 style="margin:0">Network Overview</h3>
|
||||
</div>
|
||||
<div id="ptOverviewBody" style="display:none">
|
||||
${regionNote}
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 16px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Total nodes</div>
|
||||
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
${[1, 2, 3].map(b => renderPrefixStatCard(b, stats[b], PREFIX_SPACE_SIZES[b])).join('')}
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px">
|
||||
<strong>Recommendation: ${rec} prefixes</strong> — ${recDetail}
|
||||
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:4px">Hash size is configured per-node in firmware. Changing requires reflashing.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrefixChecker(initPrefix) {
|
||||
return `<div class="analytics-card" id="ptChecker">
|
||||
<h3 style="margin-top:0">Check a Prefix</h3>
|
||||
<p class="text-muted" style="margin-top:0;font-size:0.9em">Enter a 1-byte (2 hex chars), 2-byte (4 hex chars), or 3-byte (6 hex chars) prefix — or paste a full public key.</p>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap">
|
||||
<input id="ptPrefixInput" type="text" placeholder="e.g. A3F1" maxlength="64"
|
||||
style="font-family:var(--mono);font-size:1em;padding:6px 10px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;min-width:180px;flex:1"
|
||||
value="${esc(initPrefix)}">
|
||||
<button id="ptCheckBtn" style="padding:6px 16px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">Check</button>
|
||||
</div>
|
||||
<div id="ptCheckerResults" style="margin-top:14px"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrefixGenerator(initGenerate) {
|
||||
return `<div class="analytics-card" id="ptGenerator">
|
||||
<h3 style="margin-top:0">Generate Available Prefix</h3>
|
||||
<p class="text-muted" style="margin-top:0;font-size:0.9em">Find a prefix with zero current collisions.</p>
|
||||
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="1" ${initGenerate === '1' ? 'checked' : ''}> 1-byte
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="2" ${initGenerate !== '1' && initGenerate !== '3' ? 'checked' : ''}> 2-byte
|
||||
<span class="text-muted" style="font-size:0.8em">(recommended)</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="3" ${initGenerate === '3' ? 'checked' : ''}> 3-byte
|
||||
</label>
|
||||
<button id="ptGenBtn" style="padding:6px 16px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">Generate</button>
|
||||
</div>
|
||||
<div id="ptGenResult"></div>
|
||||
<div style="margin-top:14px;padding:10px 14px;border:1px solid var(--accent);border-radius:6px;background:var(--bg-secondary,var(--bg));font-size:0.88em">
|
||||
📖 <strong>New to multi-byte prefixes?</strong>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md#39-q-what-is-multi-byte-support--what-do-1-byte-2-byte-3-byte-adverts-and-messages-mean"
|
||||
target="_blank" rel="noopener noreferrer" style="color:var(--accent);margin-left:4px">
|
||||
Read the MeshCore FAQ on multi-byte support →
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCheckerResults(checkResult, escFn) {
|
||||
if (!checkResult.valid) {
|
||||
return checkResult.error
|
||||
? `<p style="color:var(--status-red);margin:0">${checkResult.error}</p>`
|
||||
: '';
|
||||
}
|
||||
let html = '';
|
||||
if (checkResult.isFullKey) {
|
||||
const inp = checkResult.input;
|
||||
html += `<p class="text-muted" style="font-size:0.85em;margin:0 0 10px">Derived prefixes: <code class="mono">${inp.slice(0,2)}</code> / <code class="mono">${inp.slice(0,4)}</code> / <code class="mono">${inp.slice(0,6)}</code>${checkResult.inNetwork === false ? ' — <em>this node is not yet in the network</em>' : ''}</p>`;
|
||||
}
|
||||
checkResult.results.forEach(({ b, prefix, colliders, count }) => {
|
||||
html += `<div style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary,var(--bg))">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<code class="mono" style="font-weight:700">${prefix}</code>
|
||||
<span class="text-muted" style="font-size:0.82em">${b}-byte</span>
|
||||
${renderSeverityBadge(count)}
|
||||
</div>
|
||||
${count === 0
|
||||
? '<div class="text-muted" style="font-size:0.85em">No existing nodes use this prefix.</div>'
|
||||
: `<div style="font-size:0.85em;max-height:140px;overflow-y:auto">${colliders.map(n => renderNodeEntry(n, escFn)).join('')}</div>`}
|
||||
</div>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
// --- Prefix Tool: main render (orchestrates the above) ---
|
||||
async function renderPrefixTool(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading prefix data…</div>';
|
||||
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const regionLabel = rq ? (new URLSearchParams(rq.slice(1)).get('region') || '') : '';
|
||||
|
||||
let nodesResp;
|
||||
try {
|
||||
nodesResp = await api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList });
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="text-muted" role="alert" style="padding:40px">Failed to load: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeMap = new Map();
|
||||
(nodesResp.nodes || nodesResp).forEach(n => {
|
||||
if (n.public_key && n.public_key.length >= 6 && !nodeMap.has(n.public_key)) {
|
||||
nodeMap.set(n.public_key, n);
|
||||
}
|
||||
});
|
||||
const nodes = [...nodeMap.values()];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
el.innerHTML = `<div class="analytics-card"><p class="text-muted">No nodes in the network yet. Any prefix is available!</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const stats = computePrefixStats(idx);
|
||||
const totalNodes = nodes.length;
|
||||
const { rec, detail: recDetail } = recommendPrefixSize(totalNodes);
|
||||
|
||||
const hashParams = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
const initPrefix = hashParams.get('prefix') || '';
|
||||
const initGenerate = hashParams.get('generate') || '';
|
||||
|
||||
el.innerHTML = renderNetworkOverview(totalNodes, stats, rec, recDetail, regionLabel)
|
||||
+ renderPrefixChecker(initPrefix)
|
||||
+ renderPrefixGenerator(initGenerate);
|
||||
|
||||
// --- Wire up checker ---
|
||||
const doCheck = (raw) => {
|
||||
const resultsEl = document.getElementById('ptCheckerResults');
|
||||
if (!resultsEl) return;
|
||||
const result = checkPrefix(raw, idx, nodes);
|
||||
resultsEl.innerHTML = renderCheckerResults(result, esc);
|
||||
};
|
||||
|
||||
document.getElementById('ptCheckBtn').addEventListener('click', () => doCheck(document.getElementById('ptPrefixInput').value));
|
||||
document.getElementById('ptPrefixInput').addEventListener('keydown', e => { if (e.key === 'Enter') doCheck(e.target.value); });
|
||||
|
||||
// --- Wire up generator ---
|
||||
const doGenerate = () => {
|
||||
const genResultEl = document.getElementById('ptGenResult');
|
||||
if (!genResultEl) return;
|
||||
const sizeInput = el.querySelector('input[name="ptGenSize"]:checked');
|
||||
const b = sizeInput ? parseInt(sizeInput.value) : 2;
|
||||
const prefix = generatePrefix(b, idx);
|
||||
|
||||
if (!prefix) {
|
||||
const next = b < 3 ? (b + 1) + '-byte' : 'a different size';
|
||||
genResultEl.innerHTML = `<p style="color:var(--status-red);margin:0">No collision-free ${b}-byte prefixes available. Try ${next}.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSpace = PREFIX_SPACE_SIZES[b];
|
||||
const available = totalSpace - idx[b].size;
|
||||
genResultEl.innerHTML = `
|
||||
<div style="padding:12px 16px;border:1px solid var(--status-green);border-radius:6px;background:var(--bg-secondary,var(--bg))">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<code class="mono" style="font-size:1.3em;font-weight:700;color:var(--status-green)">${prefix}</code>
|
||||
<span style="color:var(--status-green)">✅ No existing nodes use this prefix</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:0.85em;margin-top:6px">${available.toLocaleString()} of ${totalSpace.toLocaleString()} ${b}-byte prefixes are available.</div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button id="ptRegenBtn" style="padding:5px 14px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.9em">Try another</button>
|
||||
<a href="https://agessaman.github.io/meshcore-web-keygen/?prefix=${prefix}" target="_blank" rel="noopener noreferrer"
|
||||
style="padding:5px 14px;background:var(--bg);color:var(--accent);border:1px solid var(--border);border-radius:4px;text-decoration:none;font-size:0.9em">
|
||||
Generate key with this prefix →
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('ptRegenBtn').addEventListener('click', doGenerate);
|
||||
};
|
||||
|
||||
document.getElementById('ptGenBtn').addEventListener('click', doGenerate);
|
||||
|
||||
// Network Overview toggle
|
||||
document.getElementById('ptOverviewToggle').addEventListener('click', () => {
|
||||
const body = document.getElementById('ptOverviewBody');
|
||||
const chevron = document.getElementById('ptOverviewChevron');
|
||||
const open = body.style.display === 'none';
|
||||
body.style.display = open ? '' : 'none';
|
||||
chevron.style.transform = open ? 'rotate(90deg)' : '';
|
||||
});
|
||||
|
||||
// Auto-run from URL params
|
||||
if (initPrefix) {
|
||||
doCheck(initPrefix);
|
||||
setTimeout(() => { document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
|
||||
} else if (initGenerate) {
|
||||
doGenerate();
|
||||
setTimeout(() => { document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
|
||||
}
|
||||
}
|
||||
|
||||
// Export pure functions for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
window._prefixToolExports = {
|
||||
buildPrefixIndex, computePrefixStats, recommendPrefixSize,
|
||||
validatePrefixInput, checkPrefix, generatePrefix,
|
||||
renderSeverityBadge, PREFIX_SPACE_SIZES
|
||||
};
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
+8
-14
@@ -540,6 +540,8 @@
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
propagationBuffer.clear();
|
||||
// Batch-update timeline once on restore instead of per-packet while hidden
|
||||
updateTimeline();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -564,7 +566,6 @@
|
||||
if (VCR.mode === 'LIVE') {
|
||||
// Skip animations when tab is backgrounded — just buffer for VCR timeline
|
||||
if (_tabHidden) {
|
||||
updateTimeline();
|
||||
return;
|
||||
}
|
||||
if (realisticPropagation && pkt.hash) {
|
||||
@@ -1697,20 +1698,13 @@
|
||||
|
||||
async function replayRecent() {
|
||||
try {
|
||||
const resp = await fetch('/api/packets?limit=8&groupByHash=true');
|
||||
// Single bulk fetch with expand=observations — no N+1 calls
|
||||
const resp = await fetch('/api/packets?limit=8&expand=observations');
|
||||
const data = await resp.json();
|
||||
const groups = (data.packets || []).reverse();
|
||||
|
||||
// Fetch all observations first, then stagger rendering
|
||||
const allGroups = [];
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
let observations = [];
|
||||
try {
|
||||
const detail = await fetch('/api/packets/' + encodeURIComponent(group.hash));
|
||||
const detailData = await detail.json();
|
||||
observations = detailData.observations || [];
|
||||
} catch {}
|
||||
const allGroups = groups.map((group) => {
|
||||
const observations = group.observations || [];
|
||||
|
||||
const livePackets = observations.map(obs => {
|
||||
const livePkt = dbPacketToLive(Object.assign({}, group, obs, {
|
||||
@@ -1729,8 +1723,8 @@
|
||||
}
|
||||
|
||||
livePackets.forEach(lp => VCR.buffer.push({ ts: lp._ts, pkt: lp }));
|
||||
allGroups.push(livePackets);
|
||||
}
|
||||
return livePackets;
|
||||
});
|
||||
|
||||
// Render with real timing gaps between packets
|
||||
// Sort by earliest timestamp
|
||||
|
||||
+100
-14
@@ -9,7 +9,7 @@
|
||||
let nodes = [];
|
||||
let targetNodeKey = null;
|
||||
let observers = [];
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all' };
|
||||
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false', statusFilter: localStorage.getItem('meshcore-map-status-filter') || 'all', byteSize: localStorage.getItem('meshcore-map-byte-filter') || 'all' };
|
||||
let selectedReferenceNode = null; // pubkey of the reference node for neighbor filtering
|
||||
let neighborPubkeys = null; // Set of pubkeys that are direct neighbors of selected node
|
||||
let wsHandler = null;
|
||||
@@ -94,6 +94,15 @@
|
||||
<legend class="mc-label">Node Types</legend>
|
||||
<div id="mcRoleChecks"></div>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Byte Size</legend>
|
||||
<div class="filter-group" id="mcByteFilter">
|
||||
<button class="btn ${filters.byteSize==='all'?'active':''}" data-byte="all">All</button>
|
||||
<button class="btn ${filters.byteSize==='1'?'active':''}" data-byte="1">1-byte</button>
|
||||
<button class="btn ${filters.byteSize==='2'?'active':''}" data-byte="2">2-byte</button>
|
||||
<button class="btn ${filters.byteSize==='3'?'active':''}" data-byte="3">3-byte</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Display</legend>
|
||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
@@ -181,11 +190,17 @@
|
||||
});
|
||||
|
||||
map.on('zoomend', () => {
|
||||
if (!_renderingMarkers) renderMarkers();
|
||||
clearTimeout(_zoomResizeTimer);
|
||||
_zoomResizeTimer = setTimeout(() => {
|
||||
if (!_renderingMarkers) _repositionMarkers();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
map.on('resize', () => {
|
||||
if (!_renderingMarkers) renderMarkers();
|
||||
clearTimeout(_zoomResizeTimer);
|
||||
_zoomResizeTimer = setTimeout(() => {
|
||||
if (!_renderingMarkers) _repositionMarkers();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
markerLayer = L.layerGroup().addTo(map);
|
||||
@@ -262,6 +277,16 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Byte size filter buttons
|
||||
document.querySelectorAll('#mcByteFilter .btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filters.byteSize = btn.dataset.byte;
|
||||
localStorage.setItem('meshcore-map-byte-filter', filters.byteSize);
|
||||
document.querySelectorAll('#mcByteFilter .btn').forEach(b => b.classList.toggle('active', b.dataset.byte === filters.byteSize));
|
||||
renderMarkers();
|
||||
});
|
||||
});
|
||||
|
||||
// Geo filter overlay
|
||||
(async function () {
|
||||
try {
|
||||
@@ -612,6 +637,8 @@
|
||||
|
||||
var _renderingMarkers = false;
|
||||
var _lastDeconflictZoom = null;
|
||||
var _currentMarkerData = []; // stored marker data for zoom-only repositioning
|
||||
var _zoomResizeTimer = null;
|
||||
|
||||
function deconflictLabels(markers, mapRef) {
|
||||
const placed = [];
|
||||
@@ -662,6 +689,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, update, or remove the offset indicator (dashed line + dot at true GPS position)
|
||||
* for a deconflicted marker. Shared by _renderMarkersInner and _repositionMarkers.
|
||||
* @param {Object} m - marker data object with latLng, adjustedLatLng, offset, _leafletLine, _leafletDot
|
||||
* @param {L.LayerGroup} layer - layer group to add/remove indicators from
|
||||
*/
|
||||
function _updateOffsetIndicator(m, layer) {
|
||||
var pos = m.adjustedLatLng || m.latLng;
|
||||
var redColor = getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444';
|
||||
|
||||
if (m.offset > 10) {
|
||||
// Line from true position to adjusted position
|
||||
if (m._leafletLine) {
|
||||
m._leafletLine.setLatLngs([m.latLng, pos]);
|
||||
} else {
|
||||
m._leafletLine = L.polyline([m.latLng, pos], {
|
||||
color: redColor, weight: 2, dashArray: '6,4', opacity: 0.85
|
||||
});
|
||||
layer.addLayer(m._leafletLine);
|
||||
}
|
||||
// Dot at true GPS position
|
||||
if (!m._leafletDot) {
|
||||
m._leafletDot = L.circleMarker(m.latLng, {
|
||||
radius: 3, fillColor: redColor, fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||||
});
|
||||
layer.addLayer(m._leafletDot);
|
||||
}
|
||||
} else {
|
||||
// No offset — remove indicator if it existed
|
||||
if (m._leafletLine) { layer.removeLayer(m._leafletLine); m._leafletLine = null; }
|
||||
if (m._leafletDot) { layer.removeLayer(m._leafletDot); m._leafletDot = null; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reposition existing markers by re-running deconfliction at the current zoom.
|
||||
* Avoids clearing and rebuilding all markers — eliminates flicker on zoom/resize.
|
||||
*/
|
||||
function _repositionMarkers() {
|
||||
if (!map || _currentMarkerData.length === 0) return;
|
||||
map.invalidateSize({ animate: false });
|
||||
|
||||
// Re-run deconfliction with current zoom pixel coordinates
|
||||
deconflictLabels(_currentMarkerData, map);
|
||||
|
||||
for (var i = 0; i < _currentMarkerData.length; i++) {
|
||||
var m = _currentMarkerData[i];
|
||||
var pos = m.adjustedLatLng || m.latLng;
|
||||
|
||||
// Update marker position
|
||||
if (m._leafletMarker) m._leafletMarker.setLatLng(pos);
|
||||
|
||||
_updateOffsetIndicator(m, markerLayer);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
if (_renderingMarkers) return;
|
||||
_renderingMarkers = true;
|
||||
@@ -670,10 +753,16 @@
|
||||
|
||||
function _renderMarkersInner() {
|
||||
markerLayer.clearLayers();
|
||||
_currentMarkerData = [];
|
||||
|
||||
const filtered = nodes.filter(n => {
|
||||
if (!n.lat || !n.lon) return false;
|
||||
if (!filters[n.role || 'companion']) return false;
|
||||
// Byte size filter (applies only to repeaters)
|
||||
if (filters.byteSize !== 'all' && (n.role || 'companion') === 'repeater') {
|
||||
const hs = n.hash_size || 1;
|
||||
if (String(hs) !== filters.byteSize) return false;
|
||||
}
|
||||
// Status filter
|
||||
if (filters.statusFilter !== 'all') {
|
||||
const role = (n.role || 'companion').toLowerCase();
|
||||
@@ -719,24 +808,20 @@
|
||||
deconflictLabels(allMarkers, map);
|
||||
}
|
||||
|
||||
// Store marker data for zoom/resize repositioning (avoids full rebuild)
|
||||
_currentMarkerData = allMarkers;
|
||||
|
||||
for (const m of allMarkers) {
|
||||
const pos = m.adjustedLatLng || m.latLng;
|
||||
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
|
||||
marker._nodeKey = m.node.public_key || m.node.id || null;
|
||||
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
m._leafletMarker = marker;
|
||||
m._leafletLine = null;
|
||||
m._leafletDot = null;
|
||||
|
||||
if (m.offset > 10) {
|
||||
const line = L.polyline([m.latLng, pos], {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
|
||||
});
|
||||
markerLayer.addLayer(line);
|
||||
// Small dot at true GPS position
|
||||
const dot = L.circleMarker(m.latLng, {
|
||||
radius: 3, fillColor: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||||
});
|
||||
markerLayer.addLayer(dot);
|
||||
}
|
||||
_updateOffsetIndicator(m, markerLayer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,6 +955,7 @@
|
||||
map = null;
|
||||
}
|
||||
markerLayer = null;
|
||||
_currentMarkerData = [];
|
||||
routeLayer = null;
|
||||
if (heatLayer) { heatLayer = null; }
|
||||
geoFilterLayer = null;
|
||||
|
||||
+17
-9
@@ -372,13 +372,25 @@
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch node detail + health data in parallel.
|
||||
* Both selectNode() and loadFullNode() need the same data —
|
||||
* this shared helper avoids duplicating the fetch logic (fixes #391).
|
||||
*/
|
||||
async function fetchNodeDetail(pubkey) {
|
||||
const [nodeData, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
nodeData.healthData = healthData;
|
||||
return nodeData;
|
||||
}
|
||||
|
||||
async function loadFullNode(pubkey) {
|
||||
const body = document.getElementById('nodeFullBody');
|
||||
try {
|
||||
const [nodeData, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
const nodeData = await fetchNodeDetail(pubkey);
|
||||
const healthData = nodeData.healthData;
|
||||
const n = nodeData.node;
|
||||
const adverts = (nodeData.recentAdverts || []).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
const title = document.querySelector('.node-full-title');
|
||||
@@ -963,11 +975,7 @@
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
|
||||
try {
|
||||
const [data, healthData] = await Promise.all([
|
||||
api('/nodes/' + encodeURIComponent(pubkey), { ttl: CLIENT_TTL.nodeDetail }),
|
||||
api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: CLIENT_TTL.nodeDetail }).catch(() => null)
|
||||
]);
|
||||
data.healthData = healthData;
|
||||
const data = await fetchNodeDetail(pubkey);
|
||||
renderDetail(panel, data);
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 229 KiB |
+50
-22
@@ -40,6 +40,21 @@
|
||||
clearTimeout(_renderTimer);
|
||||
_renderTimer = setTimeout(() => renderTableRows(), 200);
|
||||
}
|
||||
|
||||
// Coalesce WS-triggered renders into one per animation frame (#396).
|
||||
// Multiple WS batches arriving within the same frame only trigger a single
|
||||
// renderTableRows() call on the next rAF, preventing rapid full rebuilds.
|
||||
function scheduleWSRender() {
|
||||
_wsRenderDirty = true;
|
||||
if (_wsRafId) return; // already scheduled
|
||||
_wsRafId = requestAnimationFrame(function () {
|
||||
_wsRafId = null;
|
||||
if (_wsRenderDirty) {
|
||||
_wsRenderDirty = false;
|
||||
renderTableRows();
|
||||
}
|
||||
});
|
||||
}
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
@@ -59,6 +74,8 @@
|
||||
let _lastVisibleEnd = -1; // last rendered end index (for dirty checking)
|
||||
let _vsScrollHandler = null; // scroll listener reference
|
||||
let _wsRenderTimer = null; // debounce timer for WS-triggered renders
|
||||
let _wsRafId = null; // rAF id for coalescing WS-triggered renders (#396)
|
||||
let _wsRenderDirty = false; // dirty flag for rAF render coalescing (#396)
|
||||
let _observerFilterSet = null; // cached Set from filters.observer, hoisted above loops (#427)
|
||||
|
||||
function closeDetailPanel() {
|
||||
@@ -461,9 +478,8 @@
|
||||
if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT;
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
// Debounce WS-triggered renders to avoid rapid full rebuilds
|
||||
clearTimeout(_wsRenderTimer);
|
||||
_wsRenderTimer = setTimeout(function () { renderTableRows(); }, 200);
|
||||
// Coalesce WS-triggered renders via rAF (#396)
|
||||
scheduleWSRender();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -474,6 +490,8 @@
|
||||
wsHandler = null;
|
||||
detachVScrollListener();
|
||||
clearTimeout(_wsRenderTimer);
|
||||
if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; }
|
||||
_wsRenderDirty = false;
|
||||
_displayPackets = [];
|
||||
_rowCounts = [];
|
||||
_rowCountsDirty = false;
|
||||
@@ -524,7 +542,11 @@
|
||||
if (filters.hash) params.set('hash', filters.hash);
|
||||
if (filters.node) params.set('node', filters.node);
|
||||
if (filters.observer) params.set('observer', filters.observer);
|
||||
params.set('groupByHash', 'true'); // always fetch grouped
|
||||
if (groupByHash) {
|
||||
params.set('groupByHash', 'true');
|
||||
} else {
|
||||
params.set('expand', 'observations');
|
||||
}
|
||||
|
||||
const data = await api('/packets?' + params.toString());
|
||||
packets = data.packets || [];
|
||||
@@ -532,20 +554,14 @@
|
||||
for (const p of packets) { if (p.hash) hashIndex.set(p.hash, p); }
|
||||
totalCount = data.total || packets.length;
|
||||
|
||||
// When ungrouped, fetch observations for all multi-obs packets and flatten
|
||||
// When ungrouped, flatten observations inline (single API call, no N+1)
|
||||
if (!groupByHash) {
|
||||
const multiObs = packets.filter(p => (p.observation_count || p.count || 1) > 1);
|
||||
await Promise.all(multiObs.map(async (p) => {
|
||||
try {
|
||||
const d = await api(`/packets/${p.hash}`);
|
||||
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
|
||||
} catch {}
|
||||
}));
|
||||
// Flatten: replace grouped packets with individual observations
|
||||
const flat = [];
|
||||
for (const p of packets) {
|
||||
if (p._children && p._children.length > 1) {
|
||||
for (const c of p._children) flat.push(c);
|
||||
if (p.observations && p.observations.length > 1) {
|
||||
for (const o of p.observations) {
|
||||
flat.push(clearParsedCache({...p, ...o, _isObservation: true, observations: undefined}));
|
||||
}
|
||||
} else {
|
||||
flat.push(p);
|
||||
}
|
||||
@@ -873,18 +889,30 @@
|
||||
obsSortSel.addEventListener('change', async function () {
|
||||
obsSortMode = this.value;
|
||||
localStorage.setItem('meshcore-obs-sort', obsSortMode);
|
||||
// For non-observer sorts, fetch children for visible groups that don't have them yet
|
||||
// For non-observer sorts, batch-fetch children for visible groups that don't have them yet
|
||||
if (obsSortMode !== SORT_OBSERVER && groupByHash) {
|
||||
const toFetch = packets.filter(p => p.hash && !p._children && (p.observation_count || 0) > 1);
|
||||
await Promise.all(toFetch.map(async (p) => {
|
||||
if (toFetch.length > 0) {
|
||||
const hashes = toFetch.map(p => p.hash);
|
||||
try {
|
||||
const data = await api(`/packets/${p.hash}`);
|
||||
if (data?.packet && data.observations) {
|
||||
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
|
||||
p._fetchedData = data;
|
||||
const resp = await fetch('/api/packets/observations', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({hashes})
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
const results = data.results || {};
|
||||
for (const p of toFetch) {
|
||||
const obs = results[p.hash];
|
||||
if (obs && obs.length) {
|
||||
p._children = obs.map(o => clearParsedCache({...p, ...o, _isObservation: true}));
|
||||
p._fetchedData = {packet: p, observations: obs};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Re-sort all groups with children
|
||||
for (const p of packets) {
|
||||
|
||||
+15
-11
@@ -3193,20 +3193,24 @@ console.log('\n=== channels.js: formatHashHex (issue #465) ===');
|
||||
'destroy must reset observerMap to empty Map');
|
||||
});
|
||||
|
||||
test('WS handler debounces render via _wsRenderTimer', () => {
|
||||
test('WS handler coalesces render via rAF (#396)', () => {
|
||||
const wsBlock = src.slice(src.indexOf('wsHandler = debouncedOnWS'), src.indexOf('function destroy()'));
|
||||
assert.ok(wsBlock.includes('_wsRenderTimer'),
|
||||
'WS handler must debounce renders via _wsRenderTimer');
|
||||
assert.ok(wsBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'WS handler must clear pending timer before scheduling new render');
|
||||
assert.ok(/setTimeout\(function \(\) \{ renderTableRows\(\); \}/.test(wsBlock),
|
||||
'WS handler must schedule renderTableRows via setTimeout');
|
||||
assert.ok(wsBlock.includes('scheduleWSRender()'),
|
||||
'WS handler must coalesce renders via scheduleWSRender()');
|
||||
// Verify scheduleWSRender uses requestAnimationFrame
|
||||
const schedFn = src.slice(src.indexOf('function scheduleWSRender()'), src.indexOf('function scheduleWSRender()') + 300);
|
||||
assert.ok(schedFn.includes('requestAnimationFrame'),
|
||||
'scheduleWSRender must use requestAnimationFrame for coalescing');
|
||||
assert.ok(schedFn.includes('_wsRenderDirty'),
|
||||
'scheduleWSRender must use dirty flag pattern');
|
||||
});
|
||||
|
||||
test('destroy clears _wsRenderTimer', () => {
|
||||
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 500);
|
||||
assert.ok(destroyBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'destroy must clear _wsRenderTimer to prevent stale renders after navigation');
|
||||
test('destroy clears rAF and dirty flag (#396)', () => {
|
||||
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 600);
|
||||
assert.ok(destroyBlock.includes('cancelAnimationFrame(_wsRafId)'),
|
||||
'destroy must cancel pending rAF to prevent stale renders after navigation');
|
||||
assert.ok(destroyBlock.includes('_wsRenderDirty = false'),
|
||||
'destroy must reset dirty flag');
|
||||
});
|
||||
}
|
||||
// ===== NODES.JS: shared sandbox factory =====
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/* Unit tests for prefix tool logic (analytics.js _prefixToolExports) */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// Load analytics.js in a VM sandbox with minimal stubs
|
||||
const code = fs.readFileSync(__dirname + '/public/analytics.js', 'utf8');
|
||||
const sandbox = {
|
||||
window: {},
|
||||
document: { addEventListener() {} },
|
||||
location: { hash: '' },
|
||||
setTimeout: () => {},
|
||||
requestAnimationFrame: () => {},
|
||||
console,
|
||||
Map, Set, Array, Object, Number, Math, Date, JSON,
|
||||
encodeURIComponent,
|
||||
URLSearchParams,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
RegExp, Error, TypeError, RangeError,
|
||||
Promise: { resolve: () => ({ then: () => ({}) }) },
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
|
||||
try {
|
||||
vm.runInNewContext(code, sandbox, { filename: 'analytics.js', timeout: 5000 });
|
||||
} catch (e) {
|
||||
// IIFE may throw due to missing DOM — that's fine, we just need the exports
|
||||
}
|
||||
|
||||
const ex = sandbox.window._prefixToolExports;
|
||||
if (!ex) {
|
||||
console.log('❌ _prefixToolExports not found on window');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { buildPrefixIndex, computePrefixStats, recommendPrefixSize,
|
||||
validatePrefixInput, checkPrefix, generatePrefix,
|
||||
renderSeverityBadge, PREFIX_SPACE_SIZES } = ex;
|
||||
|
||||
console.log('\n--- buildPrefixIndex ---');
|
||||
|
||||
test('builds 3-tier index from nodes', () => {
|
||||
const nodes = [
|
||||
{ public_key: 'A1B2C3D4E5F6' },
|
||||
{ public_key: 'A1B2FFFFFF00' },
|
||||
{ public_key: 'FF00112233AA' },
|
||||
];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
assert.strictEqual(idx[1].size, 2); // A1, FF
|
||||
assert.strictEqual(idx[2].size, 2); // A1B2, FF00
|
||||
assert.strictEqual(idx[3].size, 3); // A1B2C3, A1B2FF, FF0011
|
||||
assert.strictEqual(idx[1].get('A1').length, 2);
|
||||
assert.strictEqual(idx[2].get('A1B2').length, 2);
|
||||
assert.strictEqual(idx[1].get('FF').length, 1);
|
||||
});
|
||||
|
||||
test('handles empty node list', () => {
|
||||
const idx = buildPrefixIndex([]);
|
||||
assert.strictEqual(idx[1].size, 0);
|
||||
assert.strictEqual(idx[2].size, 0);
|
||||
assert.strictEqual(idx[3].size, 0);
|
||||
});
|
||||
|
||||
console.log('\n--- computePrefixStats ---');
|
||||
|
||||
test('detects collisions', () => {
|
||||
const nodes = [
|
||||
{ public_key: 'A1B2C3D4E5F6' },
|
||||
{ public_key: 'A1B2FFFFFF00' },
|
||||
];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const stats = computePrefixStats(idx);
|
||||
assert.strictEqual(stats[1].collidingPrefixes, 1); // A1 collides
|
||||
assert.strictEqual(stats[2].collidingPrefixes, 1); // A1B2 collides
|
||||
assert.strictEqual(stats[3].collidingPrefixes, 0); // no 3-byte collision
|
||||
});
|
||||
|
||||
test('no collisions when all unique', () => {
|
||||
const nodes = [
|
||||
{ public_key: 'A1B2C3D4E5F6' },
|
||||
{ public_key: 'B1B2C3D4E5F6' },
|
||||
];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const stats = computePrefixStats(idx);
|
||||
assert.strictEqual(stats[1].collidingPrefixes, 0);
|
||||
});
|
||||
|
||||
console.log('\n--- recommendPrefixSize ---');
|
||||
|
||||
test('recommends 1-byte for small networks (<20)', () => {
|
||||
const r = recommendPrefixSize(5);
|
||||
assert.strictEqual(r.rec, '1-byte');
|
||||
});
|
||||
|
||||
test('recommends 2-byte for medium networks (20-499)', () => {
|
||||
const r = recommendPrefixSize(100);
|
||||
assert.strictEqual(r.rec, '2-byte');
|
||||
});
|
||||
|
||||
test('recommends 3-byte for large networks (>=500)', () => {
|
||||
const r = recommendPrefixSize(500);
|
||||
assert.strictEqual(r.rec, '3-byte');
|
||||
});
|
||||
|
||||
test('recommends 3-byte for very large networks', () => {
|
||||
const r = recommendPrefixSize(5000);
|
||||
assert.strictEqual(r.rec, '3-byte');
|
||||
});
|
||||
|
||||
test('boundary: 19 nodes = 1-byte', () => {
|
||||
assert.strictEqual(recommendPrefixSize(19).rec, '1-byte');
|
||||
});
|
||||
|
||||
test('boundary: 20 nodes = 2-byte', () => {
|
||||
assert.strictEqual(recommendPrefixSize(20).rec, '2-byte');
|
||||
});
|
||||
|
||||
test('boundary: 499 nodes = 2-byte', () => {
|
||||
assert.strictEqual(recommendPrefixSize(499).rec, '2-byte');
|
||||
});
|
||||
|
||||
console.log('\n--- validatePrefixInput ---');
|
||||
|
||||
test('empty input', () => {
|
||||
const r = validatePrefixInput('');
|
||||
assert.strictEqual(r.valid, false);
|
||||
assert.strictEqual(r.isEmpty, true);
|
||||
});
|
||||
|
||||
test('valid 1-byte prefix', () => {
|
||||
const r = validatePrefixInput('A1');
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.tiers.length, 1);
|
||||
assert.strictEqual(r.tiers[0].b, 1);
|
||||
assert.strictEqual(r.tiers[0].prefix, 'A1');
|
||||
});
|
||||
|
||||
test('valid 2-byte prefix', () => {
|
||||
const r = validatePrefixInput('a1b2');
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.tiers[0].prefix, 'A1B2');
|
||||
assert.strictEqual(r.isFullKey, false);
|
||||
});
|
||||
|
||||
test('valid 3-byte prefix', () => {
|
||||
const r = validatePrefixInput('A1B2C3');
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.tiers[0].b, 3);
|
||||
});
|
||||
|
||||
test('full public key (64 chars) derives 3 tiers', () => {
|
||||
const pk = 'A1B2C3D4' + '0'.repeat(56);
|
||||
const r = validatePrefixInput(pk);
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.isFullKey, true);
|
||||
assert.strictEqual(r.tiers.length, 3);
|
||||
assert.strictEqual(r.tiers[0].prefix, 'A1');
|
||||
assert.strictEqual(r.tiers[1].prefix, 'A1B2');
|
||||
assert.strictEqual(r.tiers[2].prefix, 'A1B2C3');
|
||||
});
|
||||
|
||||
test('rejects non-hex', () => {
|
||||
const r = validatePrefixInput('ZZZZ');
|
||||
assert.strictEqual(r.valid, false);
|
||||
assert(r.error.includes('hex'));
|
||||
});
|
||||
|
||||
test('rejects odd-length input', () => {
|
||||
const r = validatePrefixInput('A1B');
|
||||
assert.strictEqual(r.valid, false);
|
||||
assert(r.error.includes('2, 4, or 6'));
|
||||
});
|
||||
|
||||
console.log('\n--- checkPrefix ---');
|
||||
|
||||
test('detects collision on 1-byte', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }, { public_key: 'A1FFFFFF0000' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const r = checkPrefix('A1', idx, nodes);
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.results[0].count, 2);
|
||||
});
|
||||
|
||||
test('no collision for unused prefix', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const r = checkPrefix('FF', idx, nodes);
|
||||
assert.strictEqual(r.results[0].count, 0);
|
||||
});
|
||||
|
||||
test('full key excludes self from colliders', () => {
|
||||
const pk = 'A1B2C3D4E5F60000';
|
||||
const nodes = [{ public_key: pk }, { public_key: 'A1B2FFFFFF000000' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const r = checkPrefix(pk, idx, nodes);
|
||||
assert.strictEqual(r.isFullKey, true);
|
||||
// 1-byte tier: A1 has both nodes, but self excluded = 1 collider
|
||||
assert.strictEqual(r.results[0].count, 1);
|
||||
});
|
||||
|
||||
console.log('\n--- generatePrefix ---');
|
||||
|
||||
test('generates a collision-free 1-byte prefix', () => {
|
||||
const nodes = [];
|
||||
// Fill all but one 1-byte prefix
|
||||
for (let i = 0; i < 255; i++) {
|
||||
nodes.push({ public_key: i.toString(16).toUpperCase().padStart(2, '0') + '0000000000' });
|
||||
}
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const prefix = generatePrefix(1, idx, () => 0.5);
|
||||
assert.strictEqual(prefix, 'FF'); // only FF is free
|
||||
assert(!idx[1].has(prefix));
|
||||
});
|
||||
|
||||
test('returns null when no prefix available', () => {
|
||||
const nodes = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
nodes.push({ public_key: i.toString(16).toUpperCase().padStart(2, '0') + '0000000000' });
|
||||
}
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const prefix = generatePrefix(1, idx);
|
||||
assert.strictEqual(prefix, null);
|
||||
});
|
||||
|
||||
test('generates 2-byte prefix not in index', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const prefix = generatePrefix(2, idx, () => 0.5);
|
||||
assert.strictEqual(typeof prefix, 'string');
|
||||
assert.strictEqual(prefix.length, 4);
|
||||
assert(!idx[2].has(prefix));
|
||||
});
|
||||
|
||||
test('uses deterministic random function', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const p1 = generatePrefix(2, idx, () => 0.1);
|
||||
const p2 = generatePrefix(2, idx, () => 0.1);
|
||||
assert.strictEqual(p1, p2);
|
||||
});
|
||||
|
||||
console.log('\n--- renderSeverityBadge ---');
|
||||
|
||||
test('unique badge for 0', () => {
|
||||
assert(renderSeverityBadge(0).includes('Unique'));
|
||||
});
|
||||
|
||||
test('warning badge for 1-2', () => {
|
||||
assert(renderSeverityBadge(1).includes('1 collision'));
|
||||
assert(renderSeverityBadge(2).includes('2 collisions'));
|
||||
});
|
||||
|
||||
test('red badge for 3+', () => {
|
||||
assert(renderSeverityBadge(5).includes('5 collisions'));
|
||||
assert(renderSeverityBadge(5).includes('status-red'));
|
||||
});
|
||||
|
||||
// --- Summary ---
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user