From e2f9dd6f1e4db3762c7cff49883ebcc7936415ea Mon Sep 17 00:00:00 2001 From: you Date: Thu, 26 Mar 2026 16:04:39 +0000 Subject: [PATCH] fix: track repeater last_heard from relay path hops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeaters that actively relay packets showed stale 'last seen' times because last_seen only updates on adverts (every 12h) and last_heard only tracked sender/recipient appearances, not relay hops. - Add lastPathSeenMap: full pubkey → ISO timestamp for path hop sightings - updatePathSeenTimestamps() resolves hop prefixes via hopPrefixToKey cache - /api/nodes uses max(pktStore timestamp, path hop timestamp) for last_heard - 4 new tests: hop-only nodes, stale vs fresh, pktStore priority, cache invalidation --- server.js | 40 ++++++++++++++++++++++++-- test-server-routes.js | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 766f6e9e..23b12de9 100644 --- a/server.js +++ b/server.js @@ -521,12 +521,17 @@ function broadcast(msg) { // Auto-create stub nodes from path hops (≥2 bytes / 4 hex chars) // When an advert arrives later with a full pubkey matching the prefix, upsertNode will upgrade it const hopNodeCache = new Set(); // Avoid repeated DB lookups for known hops +// Track when nodes were last seen as relay hops in packet paths (full pubkey → ISO timestamp) +const lastPathSeenMap = new Map(); // Sequential hop disambiguation — delegates to server-helpers.js (single source of truth) function disambiguateHops(hops, allNodes) { return _disambiguateHops(hops, allNodes, MAX_HOP_DIST_SERVER); } +// Cache hop prefix → full pubkey for lastPathSeenMap resolution +const hopPrefixToKey = new Map(); + function autoLearnHopNodes(hops, now) { for (const hop of hops) { if (hop.length < 4) continue; // Skip 1-byte hops — too ambiguous @@ -535,11 +540,33 @@ function autoLearnHopNodes(hops, now) { const existing = db.db.prepare("SELECT public_key FROM nodes WHERE LOWER(public_key) LIKE ?").get(hopLower + '%'); if (existing) { hopNodeCache.add(hop); + hopPrefixToKey.set(hopLower, existing.public_key); continue; } // Create stub node — role is likely repeater (most hops are) db.upsertNode({ public_key: hopLower, name: null, role: 'repeater', lat: null, lon: null, last_seen: now }); hopNodeCache.add(hop); + hopPrefixToKey.set(hopLower, hopLower); // stub uses prefix as key + } +} + +// Update lastPathSeenMap for all hops in a packet path (including 1-byte hops) +function updatePathSeenTimestamps(hops, now) { + for (const hop of hops) { + const hopLower = hop.toLowerCase(); + // Try cached resolution first + let fullKey = hopPrefixToKey.get(hopLower); + if (!fullKey) { + // For 1-byte hops or uncached: try DB prefix match (single query) + const existing = db.db.prepare("SELECT public_key FROM nodes WHERE LOWER(public_key) LIKE ?").get(hopLower + '%'); + if (existing) { + fullKey = existing.public_key; + hopPrefixToKey.set(hopLower, fullKey); + } + } + if (fullKey) { + lastPathSeenMap.set(fullKey, now); + } } } @@ -648,6 +675,8 @@ for (const source of mqttSources) { if (decoded.path.hops.length > 0) { // Auto-create stub nodes from 2+ byte path hops autoLearnHopNodes(decoded.path.hops, now); + // Track when each hop node was last seen relaying + updatePathSeenTimestamps(decoded.path.hops, now); } if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) { @@ -981,7 +1010,9 @@ app.post('/api/packets', requireApiKey, (req, res) => { try { db.insertTransmission(apiPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); } if (decoded.path.hops.length > 0) { - autoLearnHopNodes(decoded.path.hops, new Date().toISOString()); + const _now = new Date().toISOString(); + autoLearnHopNodes(decoded.path.hops, _now); + updatePathSeenTimestamps(decoded.path.hops, _now); } if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) { @@ -1075,6 +1106,11 @@ app.get('/api/nodes', (req, res) => { } if (latest) node.last_heard = latest; } + // Also check if this node was seen as a relay hop in any packet path + const pathSeen = lastPathSeenMap.get(node.public_key); + if (pathSeen && (!node.last_heard || pathSeen > node.last_heard)) { + node.last_heard = pathSeen; + } } res.json({ nodes, total, counts }); @@ -2966,4 +3002,4 @@ function shutdown(signal) { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); -module.exports = { app, server, wss, pktStore, db, cache }; +module.exports = { app, server, wss, pktStore, db, cache, lastPathSeenMap }; diff --git a/test-server-routes.js b/test-server-routes.js index d93f68a5..3bd64b29 100644 --- a/test-server-routes.js +++ b/test-server-routes.js @@ -7,6 +7,7 @@ process.env.SEED_DB = 'true'; // Seed test data const request = require('supertest'); const { app, server, wss, pktStore, db, cache } = require('./server'); +const lastPathSeenMap = require('./server').lastPathSeenMap; let passed = 0, failed = 0; @@ -1038,6 +1039,71 @@ seedTestData(); assert(h.isHashSizeFlipFlop(null, null) === false); }); + // ── lastPathSeenMap: repeater hop tracking ── + await t('node appearing only as path hop gets last_heard', async () => { + // Create a node that has NO packets in pktStore (only exists in DB) + const hopPubkey = 'ffaa' + '0'.repeat(60); + db.upsertNode({ public_key: hopPubkey, name: 'HopOnlyRepeater', role: 'repeater', lat: 0, lon: 0, last_seen: '2020-01-01T00:00:00.000Z' }); + + // Simulate it being seen as a path hop + const recentTime = new Date().toISOString(); + lastPathSeenMap.set(hopPubkey, recentTime); + + const res = await request(app).get('/api/nodes?search=HopOnlyRepeater'); + assert(res.status === 200); + assert(res.body.nodes.length >= 1, 'should find the hop-only node'); + const node = res.body.nodes.find(n => n.public_key === hopPubkey); + assert(node, 'node should exist in results'); + assert(node.last_heard === recentTime, 'last_heard should come from lastPathSeenMap'); + + // Cleanup + lastPathSeenMap.delete(hopPubkey); + }); + + await t('last_heard from path hop preferred over stale last_seen', async () => { + const hopPubkey = 'ffbb' + '0'.repeat(60); + const staleTime = '2020-01-01T00:00:00.000Z'; + const freshTime = new Date().toISOString(); + db.upsertNode({ public_key: hopPubkey, name: 'StaleRepeater', role: 'repeater', lat: 0, lon: 0, last_seen: staleTime }); + + // Path hop seen recently + lastPathSeenMap.set(hopPubkey, freshTime); + + const res = await request(app).get('/api/nodes?search=StaleRepeater'); + assert(res.status === 200); + const node = res.body.nodes.find(n => n.public_key === hopPubkey); + assert(node, 'node should exist'); + assert(node.last_heard === freshTime, 'last_heard should be fresh path time, not stale DB time'); + assert(node.last_seen === staleTime, 'last_seen (DB) should still be stale'); + + lastPathSeenMap.delete(hopPubkey); + }); + + await t('last_heard from pktStore preferred over older path hop', async () => { + const hopPubkey = 'aabb' + '0'.repeat(60); // TestRepeater1 — has packets in pktStore + const oldPathTime = '2019-01-01T00:00:00.000Z'; + lastPathSeenMap.set(hopPubkey, oldPathTime); + + const res = await request(app).get('/api/nodes?search=TestRepeater1'); + assert(res.status === 200); + const node = res.body.nodes.find(n => n.public_key === hopPubkey); + assert(node, 'node should exist'); + // pktStore should have a more recent timestamp than our old path time + assert(node.last_heard > oldPathTime, 'pktStore timestamp should win over older path hop time'); + + lastPathSeenMap.delete(hopPubkey); + }); + + await t('bulk-health cache invalidated after advert', () => { + // Set a fake bulk-health cache entry + cache.set('bulk-health:50:r=', { fake: true }, 60000); + assert(cache.get('bulk-health:50:r='), 'cache should have bulk-health entry'); + + // Simulate what happens on advert: cache.invalidate('bulk-health') + cache.invalidate('bulk-health'); + assert(!cache.get('bulk-health:50:r='), 'bulk-health cache should be invalidated after advert'); + }); + // ── Summary ── console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`); if (failed > 0) process.exit(1);