Skip to content

fix(neighbor-info): keep links when neighbor lastHeard is NULL but NI report is fresh#3026

Merged
Yeraze merged 1 commit into
mainfrom
fix/3025-neighbor-info-null-lasthead-filter
May 15, 2026
Merged

fix(neighbor-info): keep links when neighbor lastHeard is NULL but NI report is fresh#3026
Yeraze merged 1 commit into
mainfrom
fix/3025-neighbor-info-null-lasthead-filter

Conversation

@Yeraze

@Yeraze Yeraze commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses one cause of #3025 — the NULL-lastHeard filter regression introduced in v4.0.0 by #2615 (zombie-node fix). NeighborInfo packets are still being parsed and saved correctly (🔗 Saved neighbor: log lines confirm this), but the read-side filter on both /api/neighbor-info and /api/sources/:id/neighbor-info was rejecting every indirect-neighbor link because PR #2615 intentionally leaves lastHeard=NULL on placeholder rows for nodes we've only heard about via NeighborInfo.

This PR keeps the reporter-side freshness requirement but falls back to the NeighborInfo record's own freshness signals (timestamp / lastRxTime) when the neighbor's lastHeard is missing or stale.

Root cause

The filter (introduced when the zombie guard landed) reads:

if (!ni.node?.lastHeard || !ni.neighbor?.lastHeard) return false;
return ni.node.lastHeard >= cutoffTime && ni.neighbor.lastHeard >= cutoffTime;

Combined with the zombie guard's behavior in meshtasticManager.ts (placeholder neighbors written with lastHeard=NULL), this drops every NeighborInfo row whose neighbor side is a node we haven't directly heard from — which is most of them.

The fix

Both filters now:

  1. Require the reporter (ni.node) to have a fresh lastHeard — we must have directly heard from the node making the claim.
  2. If the neighbor has a fresh lastHeard, keep the row (as before).
  3. Otherwise fall back to the freshness of the NeighborInfo record itself — Math.max(timestamp/1000, lastRxTime) against cutoffTime. timestamp is stored in ms (Date.now() in meshtasticManager.ts:6710), lastRxTime is firmware seconds.

Files changed

  • src/server/server.ts:4152GET /api/neighbor-info filter.
  • src/server/routes/sourceRoutes.ts:705GET /api/sources/:id/neighbor-info filter (the one the Messages tab actually uses).
  • src/server/routes/sourceRoutes.neighbor-info.test.ts — fixture timestamp corrected from seconds to ms (matches the real write path); existing staleness/NULL-lastHeard tests retargeted at the reporter side; new cases covering NULL-neighbor + fresh NI report (kept), NULL-neighbor + stale NI report (dropped), and NULL-neighbor + fresh lastRxTime (kept).
  • src/server/server.neighbor-info-position.test.ts — inline shadow filter synced with the real handler so the test mirror doesn't drift.

Honesty caveat

The reporter (@SyselMitY) followed up on #3025 indicating their case also involves neighbors that do have a fresh lastHeard, so this PR likely doesn't fully close out #3025. Use of Refs #3025 (not Fixes) is intentional — further DB inspection has been requested from the reporter, and there may be a second cause (sourceId scoping in getNodesByNums, possibly elsewhere) still to nail down.

Out of scope (deliberately)

  • GET /api/neighbor-info/:nodeNum — no node-age filter there, left alone.
  • The zombie-node write path in meshtasticManager.ts — the NULLing of lastHeard on placeholder rows is intentional and stays.
  • The MQTT early-return.

Follow-ups

Test plan

  • npm run typecheck — clean
  • npm run build — green
  • npx vitest run — full suite 4916/4916 pass (21/21 on targeted neighbor-info files)
  • Manual: confirm with bug reporter (@SyselMitY) on a live build that NeighborInfo links now show in the UI for their mesh

Authored by Merlin 🪄

…port is fresh

PR #2615 (zombie-node fix) intentionally leaves `lastHeard=NULL` on
placeholder neighbor rows we've only learned about second-hand through a
NeighborInfo packet. That was correct in isolation, but the read-side
filter in both /api/neighbor-info (server.ts) and
/api/sources/:id/neighbor-info (sourceRoutes.ts) rejects any row whose
neighbor.lastHeard is NULL or older than the cutoff, so every
indirect-neighbor link gets dropped on read.

This change keeps the reporter freshness check (we must have directly
heard from the reporting node), but falls back to the NeighborInfo
record's own `timestamp` / firmware-supplied `lastRxTime` when the
neighbor's `lastHeard` is missing or stale. `timestamp` is stored in ms
(set via `Date.now()` in meshtasticManager), `lastRxTime` is firmware
seconds — handled accordingly.

Tests:
- src/server/routes/sourceRoutes.neighbor-info.test.ts — fixture
  timestamp corrected to ms (matches real DB shape); existing
  staleness/null tests retargeted at the reporter side; new cases cover
  neighbor.lastHeard NULL + fresh NI report (kept), NULL + stale NI
  report (dropped), and NULL + fresh lastRxTime (kept).
- src/server/server.neighbor-info-position.test.ts — inline shadow
  filter synced with the real handler so the test mirror does not
  drift.

Refs #3025

Authored by Merlin 🪄
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 15, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: NeighborInfo NULL lastHeard Filter Fix

Todo List:

  • Read and analyze changed files
  • Review filtering logic implementation
  • Evaluate test coverage and quality
  • Check for edge cases and potential issues
  • Provide comprehensive feedback

✅ Code Quality Assessment

Overall Implementation Quality: Excellent

The implementation is well-crafted with clear logic, comprehensive documentation, and thorough test coverage. The fix correctly addresses the regression while maintaining the original security intent.

🔍 Detailed Review

Filtering Logic Changes (server.ts:4152-4164 & sourceRoutes.ts:705-717)

✅ Strengths:

  1. Clear, documented logic flow - The three-step filtering approach is well-commented and easy to understand
  2. Preserves security intent - Still requires reporter nodes to have fresh lastHeard
  3. Smart fallback strategy - Uses Math.max(reportSec, rxSec) to check either timestamp source
  4. Consistent implementation - Both endpoints use identical filtering logic (excellent!)
  5. Proper time handling - Correctly converts ms timestamps to seconds for comparison

🔧 Technical Implementation:

// Step 1: Require fresh reporter (security check)
if (!ni.node?.lastHeard || ni.node.lastHeard < cutoffTime) return false;

// Step 2: Keep if neighbor is also fresh 
if (ni.neighbor?.lastHeard && ni.neighbor.lastHeard >= cutoffTime) return true;

// Step 3: Fallback to NeighborInfo record freshness
const reportSec = Math.floor((ni.timestamp ?? 0) / 1000);
const rxSec = ni.lastRxTime ?? 0;
return Math.max(reportSec, rxSec) >= cutoffTime;

This logic is sound and efficient - early returns prevent unnecessary computation.

Test Coverage (sourceRoutes.neighbor-info.test.ts:202-280)

✅ Comprehensive Test Suite:

  1. Reporter validation (line 202) - Correctly filters out NULL reporter lastHeard
  2. Fresh NeighborInfo with NULL neighbor (line 218) - The main [BUG] Meshtastic NeigborInfo: Response received but not shown in UI #3025 regression case
  3. Stale NeighborInfo with NULL neighbor (line 238) - Ensures old reports are still filtered
  4. Fresh lastRxTime fallback (line 260) - Tests firmware timestamp fallback

Test Quality Analysis:

  • Proper time handling - Correctly uses Date.now() for timestamp (ms) and nowSec() for lastRxTime (seconds)
  • Clear test intent - Each test has descriptive comments explaining the scenario
  • Realistic data - Test fixtures match production data patterns
  • Edge case coverage - Tests both timestamp sources and boundary conditions

Mirror Test Sync (server.neighbor-info-position.test.ts:157-161)

✅ Excellent Maintenance Practice:
The inline shadow filter in the position test was updated to match the real implementation. This prevents test drift and ensures the mirror accurately reflects production behavior.

🚨 Issues Found

Minor Issues:

  1. Potential sourceId scoping issue (mentioned in PR description):
    • databaseService.nodes.getNodesByNums() may not scope by sourceId
    • Could cause cross-source data leakage in multi-source setups
    • Recommendation: Add follow-up issue to investigate this

Performance Considerations:

  1. Database query efficiency:
    • The current approach queries ALL neighbor info, then filters in-memory
    • For large datasets, consider moving age filtering to the database layer
    • Impact: Low priority - neighbor info datasets are typically small

🔒 Security Analysis

✅ Security Posture Maintained:

  • Reporter freshness requirement prevents stale nodes from injecting false neighbor claims
  • No new attack vectors introduced
  • Maintains defense against "zombie node" reporting

📊 Performance Assessment

✅ Performance Impact: Minimal

  • Logic is O(1) per record with early returns
  • No additional database queries introduced
  • Fallback calculation (Math.max) is negligible overhead

🧪 Test Coverage Evaluation

✅ Excellent Coverage:

📝 Code Style & Maintainability

✅ High Quality:

  • Consistent with existing codebase patterns
  • Clear variable naming (reportSec, rxSec)
  • Appropriate use of optional chaining (ni.node?.lastHeard)
  • DRY principle - logic shared between both endpoints

🔄 Follow-up Recommendations

  1. Investigate getNodesByNums sourceId scoping - As mentioned in PR description
  2. Consider database-level filtering - For future performance optimization
  3. Monitor [BUG] Meshtastic NeigborInfo: Response received but not shown in UI #3025 reporter feedback - To confirm this resolves their specific issue

✅ Final Assessment

This is a high-quality fix that addresses a real regression while maintaining security properties. The implementation is clean, well-tested, and follows best practices. The author's honesty about potential remaining issues (#3025) and proper use of "Refs" vs "Fixes" shows excellent engineering judgment.

Recommendation: ✅ Approve


| Branch: fix/3025-neighbor-info-null-lasthead-filter

@github-actions

Copy link
Copy Markdown
System Test Results

MeshMonitor System Test Results

Test Run: 2026-05-15 07:55:26 EDT

Test Summary

Test Suite Result
Configuration Import ✅ PASSED
Quick Start Test ✅ PASSED
Security Test ✅ PASSED
V1 API Test ✅ PASSED
Reverse Proxy Test ✅ PASSED
Reverse Proxy + OIDC ✅ PASSED
Virtual Node CLI Test ✅ PASSED
Backup & Restore Test ✅ PASSED
Database Migration Test ✅ PASSED
DB Backing Consistency ✅ PASSED
API Exercise (3 DBs) ✅ PASSED

✅ Overall Result: PASSED

All deployment configurations are working correctly!

Test Details

Configuration Import:

  • Tests configuration import and device reboot cycle
  • Verifies channel roles, PSKs, and LoRa configuration
  • Note: Channel name verification skipped due to architectural limitation

Quick Start Test:

  • Zero-config deployment (no SESSION_SECRET or COOKIE_SECURE required)
  • HTTP access without HSTS
  • Auto-generated admin user with default credentials
  • Session cookies work over HTTP
  • Meshtastic node connection and message exchange verified

Security Test:

  • Verifies Node IP address hidden from anonymous users in API responses
  • Verifies MQTT configuration hidden from anonymous users
  • Verifies Node IP address visible to authenticated users
  • Verifies MQTT configuration visible to authenticated users
  • Verifies protected endpoints require authentication

V1 API Test:

  • Tests v1 REST API endpoints with Bearer token authentication
  • Verifies Bearer token requests bypass CSRF protection
  • Verifies POST/PUT/DELETE work without CSRF token when using Bearer auth
  • Verifies session-based requests still require CSRF token

Reverse Proxy Test:

  • Production deployment with COOKIE_SECURE=true
  • HTTPS-ready configuration
  • Trust proxy enabled for reverse proxy compatibility
  • CORS configured for HTTPS domain
  • Meshtastic node connection and message exchange verified

Reverse Proxy + OIDC Test:

  • OIDC authentication integration
  • Mock OIDC provider health checks
  • Authorization flow and session creation
  • Hybrid mode (OIDC + local auth)
  • Meshtastic node connection verified

Virtual Node CLI Test:

  • Virtual Node Server enabled on TCP port 4404
  • Meshtastic Python client successfully connects
  • Node data download and synchronization verified
  • Test message sent on gauntlet channel (index 3)
  • Message delivery confirmed via Web UI API
  • Virtual Node Server connection logging verified

Backup & Restore Test:

  • System backup created from running dev container
  • New container spun up with RESTORE_FROM_BACKUP env var
  • Data integrity verified (node count, message count, settings)
  • Restore event logged in audit log
  • Dev container unaffected by restore test

Database Migration Test:

  • SQLite to PostgreSQL migration verified
  • SQLite to MySQL migration verified
  • Data integrity confirmed for both target databases
  • Row counts match between source and target

DB Backing Consistency Test:

  • SQLite, PostgreSQL, and MySQL backends tested with same device
  • Node counts within ±10 across all three backends
  • Favorite counts identical across all backends
  • Key station verified as favorite on all backends

@Yeraze Yeraze merged commit df4f33f into main May 15, 2026
20 checks passed
@Yeraze Yeraze deleted the fix/3025-neighbor-info-null-lasthead-filter branch May 15, 2026 12:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant