fix: correct traceroute path orientation when building graph edges

A completed traceroute is observed as a response packet, whose from/to are
reversed with respect to the path being traced: `to` is the node that started
the traceroute and `from` is the node that answered. Meshview assembled paths
as [from, *route, to], so both endpoint-adjacent edges were wrong for every
completed traceroute with at least one intermediate hop. Because the false
edges always attach to the endpoints, a busy initiator or gateway accumulated
false direct links to every remote target it ever traced.

Confirmed against TraceRouteModule::alterReceivedProtobuf, which renders a
response as printRoute(r, p.to, p.from, false), against the meshtastic python
CLI, which starts the forward path at p["to"] and the return path at p["from"],
and against real recorded traceroutes where a request's route[] is a prefix of
the matching response's route[].

Add meshview/traceroute.py and use it from /api/edges, /api/traceroute and
graph_traceroute, which had three separate copies of this logic that had all
drifted. /api/traceroute also gains initiator/target so the traceroute page
stops re-deriving the endpoints itself, which is where a fourth copy lived.

While in /api/edges, skip the NODENUM_BROADCAST placeholders that
insertUnknownHops() writes into route[] (they became a pseudo-node hub in the
graph) and guard against the (gateway, gateway) self edge that
graph_traceroute already guarded against.
This commit is contained in:
giannoug committed 2026-09-04 23:28:04 +00:00
1 parent 992296b0c1
commit becf4a8a42
5 files changed
+97 -37

No files matched your search

+22 -9
View File
@@ -203,6 +203,10 @@ Returns network edges (connections between nodes) based on traceroutes and neigh
Traceroute edges are collected over the last 12 hours. Neighbor edges are based on
port 71 packets.
A completed traceroute is observed as a response packet, whose `from`/`to` are reversed
with respect to the traced path, so edges are built as `to -> route[] -> from`. Unknown
hop placeholders (`0xFFFFFFFF`) are skipped.
Query Parameters
- `type` (optional, string): `traceroute` or `neighbor`. If omitted, returns both.
- `node_id` (optional, int): Filter edges to only those touching a node.
@@ -324,13 +328,21 @@ Returns traceroute details and derived paths for a packet.
Path Parameters
- `packet_id` (required, int): Packet ID.
A completed traceroute is observed as a response packet, whose `from`/`to` are reversed
with respect to the traced path. `initiator` and `target` resolve this: the forward path
runs `initiator -> forward_hops -> target` and the return path runs
`target -> reverse_hops -> initiator`, in both directions of travel. `forward_hops` and
`reverse_hops` contain only the intermediate hops, never the endpoints.
Response Example
```json
{
"packet": {
"id": 123,
"from": 111,
"to": 222,
"from": 222,
"to": 111,
"initiator": 111,
"target": 222,
"channel": "main"
},
"traceroute_packets": [
@@ -338,19 +350,20 @@ Response Example
"index": 0,
"gateway_node_id": 333,
"done": true,
"forward_hops": [111, 444, 222],
"reverse_hops": [222, 444, 111]
"forward_hops": [444, 555],
"reverse_hops": [555, 444]
}
],
"unique_forward_paths": [
{ "path": [111, 444, 222], "count": 2 }
{ "path": [444, 555], "count": 2 }
],
"unique_reverse_paths": [
[222, 444, 111]
[555, 444]
],
"winning_paths": [
[111, 444, 222]
]
"winning_paths": {
"forward": [[111, 444, 555, 222]],
"reverse": [[222, 555, 444, 111]]
}
}
```
+4 -3
View File
@@ -129,7 +129,7 @@ function uniqueForwardPaths(data, originId, targetId) {
function uniqueReversePaths(data, originId, targetId) {
return (data?.unique_reverse_paths || [])
.map(path => addMissingEndpoints(path || [], targetId, null))
.map(path => addMissingEndpoints(path || [], targetId, originId))
.filter(path => path.length > 1);
}
@@ -157,8 +157,9 @@ async function loadTraceroute() {
const nodes = new Map();
const edges = [];
const originId = data?.packet?.from != null ? String(data.packet.from) : null;
const targetId = data?.packet?.to != null ? String(data.packet.to) : null;
// A response packet carries from/to reversed, so use the endpoints the API resolved.
const originId = data?.packet?.initiator != null ? String(data.packet.initiator) : null;
const targetId = data?.packet?.target != null ? String(data.packet.target) : null;
const forwardPaths = (data?.winning_paths?.forward || []).map(path => path.map(id => String(id)));
const reversePaths = (data?.winning_paths?.reverse || []).map(path => path.map(id => String(id)));
const winningPathKeys = new Set([
+38
View File
@@ -0,0 +1,38 @@
"""Assemble node paths from Meshtastic RouteDiscovery payloads.
A traceroute response carries the endpoints reversed with respect to the path
being traced: `to` is the node that started the traceroute and `from` is the node
that answered. Only the response direction is affected, so both orientations are
handled here rather than at each call site.
"""
# Firmware inserts this into route[] as a placeholder for a hop it could not identify.
NODENUM_BROADCAST = 0xFFFFFFFF
def strip_unknown_hops(hops):
return [hop for hop in hops if hop != NODENUM_BROADCAST]
def forward_path(from_node_id, to_node_id, hops, done, gateway_node_id=None):
"""Path towards the traced node, starting at the node that began the traceroute."""
if done:
return [node for node in (to_node_id, *hops, from_node_id) if node is not None]
path = [node for node in (from_node_id, *hops) if node is not None]
# Some nodes add themselves to the route before uplinking.
if gateway_node_id is not None and (not path or path[-1] != gateway_node_id):
path.append(gateway_node_id)
return path
def return_path(from_node_id, to_node_id, hops):
"""Path back from the traced node, starting at the node that answered."""
return [node for node in (from_node_id, *hops, to_node_id) if node is not None]
def endpoints(from_node_id, to_node_id, done):
"""The (initiator, target) of the traceroute, regardless of packet direction."""
if done:
return to_node_id, from_node_id
return from_node_id, to_node_id
+11 -8
View File
@@ -17,7 +17,7 @@ from jinja2 import Environment, PackageLoader, Undefined, select_autoescape
from markupsafe import Markup
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import config, database, decode_payload, migrations, models, store
from meshview import config, database, decode_payload, migrations, models, store, traceroute
from meshview.__version__ import (
__version_string__,
)
@@ -395,14 +395,17 @@ async def graph_traceroute(request):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
path = [packet.from_node_id]
path.extend(route.route)
path = traceroute.forward_path(
packet.from_node_id,
packet.to_node_id,
route.route,
tr.done,
tr.gateway_node_id,
)
if not path:
continue
if tr.done:
dest = packet.to_node_id
path.append(packet.to_node_id)
elif path[-1] != tr.gateway_node_id:
# It seems some nodes add them self to the list before uplinking
path.append(tr.gateway_node_id)
dest = path[-1]
if not tr.done and tr.gateway_node_id not in node_seen_time and tr.import_time_us:
node_seen_time[path[-1]] = tr.import_time_us
+22 -17
View File
@@ -10,7 +10,7 @@ from aiohttp import web
from sqlalchemy import func, select
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store
from meshview import database, decode_payload, store, traceroute
from meshview.__version__ import __version__, _git_revision_short, get_version_info
from meshview.config import CONFIG
from meshview.models import DailySnapshot, Node
@@ -630,8 +630,13 @@ async def api_edges(request):
if route is None or tr.packet is None:
continue
path = [tr.packet.from_node_id] + list(route.route)
path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
path = traceroute.forward_path(
tr.packet.from_node_id,
tr.packet.to_node_id,
traceroute.strip_unknown_hops(route.route),
tr.done,
tr.gateway_node_id,
)
for a, b in zip(path, path[1:], strict=False):
if (a, b) not in edges:
@@ -989,21 +994,16 @@ async def api_traceroute(request):
from_node_id = packet.from_node_id
to_node_id = packet.to_node_id
winning_forward_with_endpoints = []
for path in set(winning_forward_paths):
full_path = list(path)
if to_node_id is not None and (not full_path or full_path[-1] != to_node_id):
full_path = [to_node_id, *full_path]
if from_node_id is not None and (not full_path or full_path[-1] != from_node_id):
full_path = [*full_path, from_node_id]
winning_forward_with_endpoints.append(full_path)
# Winning paths are only built from responses, which carry the endpoints reversed.
winning_forward_with_endpoints = [
traceroute.forward_path(from_node_id, to_node_id, path, done=True)
for path in set(winning_forward_paths)
]
winning_reverse_with_endpoints = []
for path in set(winning_reverse_paths):
full_path = list(path)
if to_node_id is not None and (not full_path or full_path[0] != to_node_id):
full_path = [to_node_id, *full_path]
winning_reverse_with_endpoints.append(full_path)
winning_reverse_with_endpoints = [
traceroute.return_path(from_node_id, to_node_id, path)
for path in set(winning_reverse_paths)
]
winning_paths_json = {
"forward": winning_forward_with_endpoints,
@@ -1013,12 +1013,17 @@ async def api_traceroute(request):
# --------------------------------------------
# Final API output
# --------------------------------------------
initiator, target = traceroute.endpoints(
from_node_id, to_node_id, any(tr["done"] for tr in tr_groups)
)
return web.json_response(
{
"packet": {
"id": packet.id,
"from": packet.from_node_id,
"to": packet.to_node_id,
"initiator": initiator,
"target": target,
"channel": packet.channel,
},
"traceroute_packets": tr_groups,