fix: track repeater last_heard from relay path hops

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
This commit is contained in:
you
2026-03-26 16:04:39 +00:00
parent e1a776bd34
commit e2f9dd6f1e
2 changed files with 104 additions and 2 deletions
+38 -2
View File
@@ -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 };
+66
View File
@@ -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);