Add WebSocket support for real-time traceroute visualizations.
This commit is contained in:
parent
8fd496c59d
commit
328fb3e842
5 changed files with 543 additions and 23 deletions
|
|
@ -30,6 +30,18 @@ services:
|
||||||
DATABASE_URL: "mysql://root:password@database:3306/meshtastic-map?connection_limit=100"
|
DATABASE_URL: "mysql://root:password@database:3306/meshtastic-map?connection_limit=100"
|
||||||
MAP_OPTS: "" # add any custom index.js options here
|
MAP_OPTS: "" # add any custom index.js options here
|
||||||
|
|
||||||
|
# publishes mqtt packets via websocket
|
||||||
|
meshtastic-ws:
|
||||||
|
container_name: meshtastic-ws
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
command: /app/docker/ws.sh
|
||||||
|
ports:
|
||||||
|
- 8081:8081/tcp
|
||||||
|
environment:
|
||||||
|
WS_OPTS: ""
|
||||||
|
|
||||||
# runs the database to store everything from mqtt
|
# runs the database to store everything from mqtt
|
||||||
database:
|
database:
|
||||||
container_name: database
|
container_name: database
|
||||||
|
|
|
||||||
5
docker/ws.sh
Executable file
5
docker/ws.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Starting websocket publisher"
|
||||||
|
exec node src/ws.js ${WS_OPTS}
|
||||||
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"mqtt": "^5.14.1",
|
"mqtt": "^5.14.1",
|
||||||
"protobufjs": "^7.5.4"
|
"protobufjs": "^7.5.4",
|
||||||
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^30.1.3",
|
"jest": "^30.1.3",
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,18 @@
|
||||||
border: 1px solid white;
|
border: 1px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-traceroute-start {
|
||||||
|
background-color: #16a34a; /* green */
|
||||||
|
border-radius: 25px;
|
||||||
|
border: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-traceroute-end {
|
||||||
|
background-color: #dc2626; /* red */
|
||||||
|
border-radius: 25px;
|
||||||
|
border: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
.waypoint-label {
|
.waypoint-label {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
@ -1619,7 +1631,7 @@
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
// overlays enabled by default
|
// overlays enabled by default
|
||||||
return ["Legend", "Position History"];
|
return ["Legend", "Position History", "Traceroutes"];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2791,6 +2803,16 @@
|
||||||
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
iconSize: [16, 16], // increase from 12px to 16px to make hover easier
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var iconTracerouteStart = L.divIcon({
|
||||||
|
className: 'icon-traceroute-start',
|
||||||
|
iconSize: [16, 16],
|
||||||
|
});
|
||||||
|
|
||||||
|
var iconTracerouteEnd = L.divIcon({
|
||||||
|
className: 'icon-traceroute-end',
|
||||||
|
iconSize: [16, 16],
|
||||||
|
});
|
||||||
|
|
||||||
// create legend
|
// create legend
|
||||||
var legendLayerGroup = new L.LayerGroup();
|
var legendLayerGroup = new L.LayerGroup();
|
||||||
var legend = L.control({position: 'bottomleft'});
|
var legend = L.control({position: 'bottomleft'});
|
||||||
|
|
@ -2801,7 +2823,8 @@
|
||||||
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
|
div.innerHTML = `<div style="margin-bottom:6px;"><strong>Legend</strong></div>`
|
||||||
+ `<div style="display:flex"><div class="icon-mediumfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MediumFast</div>`
|
+ `<div style="display:flex"><div class="icon-mediumfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> MediumFast</div>`
|
||||||
+ `<div style="display:flex"><div class="icon-longfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> LongFast</div>`
|
+ `<div style="display:flex"><div class="icon-longfast" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> LongFast</div>`
|
||||||
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`;
|
+ `<div style="display:flex"><div class="icon-offline" style="width:12px;height:12px;margin-right:4px;margin-top:auto;margin-bottom:auto;"></div> Offline Too Long</div>`
|
||||||
|
+ `<div style="display:flex"><svg width="16" height="3" style="margin-right:4px;margin-top:auto;margin-bottom:auto;"><line x1="0" y1="1.5" x2="16" y2="1.5" stroke="#f97316" stroke-width="4"/></svg> Traceroute</div>`;
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3865,8 +3888,6 @@
|
||||||
|
|
||||||
function onTracerouteEdgesUpdated(edges) {
|
function onTracerouteEdgesUpdated(edges) {
|
||||||
|
|
||||||
traceroutesLayerGroup.clearLayers();
|
|
||||||
|
|
||||||
tracerouteEdgesCache = edges;
|
tracerouteEdgesCache = edges;
|
||||||
|
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
|
|
@ -3910,23 +3931,6 @@
|
||||||
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
+ `<br/><br/>Terrain images from <a href="http://www.heywhatsthat.com" target="_blank">HeyWhatsThat.com</a>`
|
||||||
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
+ `<br/><a href="${terrainImageUrl}" target="_blank"><img src="${terrainImageUrl}" width="100%"></a>`;
|
||||||
|
|
||||||
const line = L.polyline([
|
|
||||||
fromMarker.getLatLng(),
|
|
||||||
toMarker.getLatLng(),
|
|
||||||
], {
|
|
||||||
color: colour,
|
|
||||||
opacity: 0.9,
|
|
||||||
}).addTo(traceroutesLayerGroup);
|
|
||||||
|
|
||||||
line.bindTooltip(tooltip, {
|
|
||||||
sticky: true,
|
|
||||||
opacity: 1,
|
|
||||||
interactive: true,
|
|
||||||
}).bindPopup(tooltip)
|
|
||||||
.on('click', function(event) {
|
|
||||||
event.target.closeTooltip();
|
|
||||||
});
|
|
||||||
|
|
||||||
// additional line for backbone neighbours
|
// additional line for backbone neighbours
|
||||||
const backboneNeighbourLine = L.polyline([
|
const backboneNeighbourLine = L.polyline([
|
||||||
fromMarker.getLatLng(),
|
fromMarker.getLatLng(),
|
||||||
|
|
@ -3954,7 +3958,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if(fromNode.is_backbone && toNode.is_backbone) {
|
if(fromNode.is_backbone && toNode.is_backbone) {
|
||||||
console.log("Adding to backbone neighbours layer group");
|
|
||||||
backboneNeighbourLine.addTo(backboneNeighboursLayerGroup);
|
backboneNeighbourLine.addTo(backboneNeighboursLayerGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4328,6 +4331,192 @@
|
||||||
// reload and go to provided node id
|
// reload and go to provided node id
|
||||||
reload(queryNodeId, queryZoom);
|
reload(queryNodeId, queryZoom);
|
||||||
|
|
||||||
|
// WebSocket connection for real-time messages
|
||||||
|
var ws = null;
|
||||||
|
var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown)
|
||||||
|
var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates
|
||||||
|
var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
// Determine WebSocket URL - use same hostname as current page, port 8081
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsHost = window.location.hostname;
|
||||||
|
const wsPort = '8081';
|
||||||
|
const wsUrl = `${wsProtocol}//${wsHost}:${wsPort}`;
|
||||||
|
|
||||||
|
console.log('Connecting to WebSocket:', wsUrl);
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
handleWebSocketMessage(message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing WebSocket message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function() {
|
||||||
|
console.log('WebSocket disconnected, reconnecting in 5 seconds...');
|
||||||
|
setTimeout(connectWebSocket, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWebSocketMessage(message) {
|
||||||
|
if (message.type === 'traceroute') {
|
||||||
|
handleTraceroute(message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTraceroute(data) {
|
||||||
|
// Only visualize traceroutes where want_response is false (the reply coming back)
|
||||||
|
if (data.want_response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When want_response is false, from and to are swapped from the original request
|
||||||
|
// The path goes from 'to' (original sender) through route to 'from' (original destination)
|
||||||
|
const originalSender = data.to; // This was the original sender
|
||||||
|
const originalDestination = data.from; // This was the original destination
|
||||||
|
const route = data.route || [];
|
||||||
|
const snrTowards = data.snr_towards || [];
|
||||||
|
|
||||||
|
// Deduplicate: ignore traceroutes from the same original sender for 20 seconds
|
||||||
|
const now = Date.now();
|
||||||
|
if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) {
|
||||||
|
return; // Still in cooldown period
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique key for this traceroute path to prevent duplicate visualizations
|
||||||
|
// Use original sender (to), original destination (from), and route to create unique key
|
||||||
|
// (ignoring gateway_id since multiple gateways can receive same route)
|
||||||
|
const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`;
|
||||||
|
if (activeTracerouteKeys.has(routeKey)) {
|
||||||
|
return; // Already visualizing this route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as active and set cooldown
|
||||||
|
activeTracerouteKeys.add(routeKey);
|
||||||
|
tracerouteCooldown[originalSender] = now;
|
||||||
|
|
||||||
|
// Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination)
|
||||||
|
const path = [originalSender]; // Start from original sender
|
||||||
|
if (route.length > 0) {
|
||||||
|
path.push(...route);
|
||||||
|
}
|
||||||
|
path.push(originalDestination); // End at original destination
|
||||||
|
|
||||||
|
// Visualize the traceroute with animated hops
|
||||||
|
visualizeTraceroute(path, snrTowards, routeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualizeTraceroute(path, snrTowards, routeKey) {
|
||||||
|
// Verify all nodes in path exist on map
|
||||||
|
const pathMarkers = [];
|
||||||
|
for (const nodeId of path) {
|
||||||
|
const marker = findNodeMarkerById(nodeId);
|
||||||
|
if (!marker) {
|
||||||
|
// Node not on map, skip this traceroute
|
||||||
|
activeTracerouteKeys.delete(routeKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pathMarkers.push(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store lines and overlays for this route key for cleanup
|
||||||
|
const routeElements = {
|
||||||
|
lines: [],
|
||||||
|
startOverlay: null,
|
||||||
|
endOverlay: null,
|
||||||
|
};
|
||||||
|
tracerouteLines[routeKey] = routeElements;
|
||||||
|
|
||||||
|
// Color starting node (first in path) green and destination node (last in path) red
|
||||||
|
const startMarker = pathMarkers[0];
|
||||||
|
const endMarker = pathMarkers[pathMarkers.length - 1];
|
||||||
|
|
||||||
|
const startOverlay = L.marker(startMarker.getLatLng(), {
|
||||||
|
icon: iconTracerouteStart,
|
||||||
|
zIndexOffset: 10000, // Ensure it's on top
|
||||||
|
}).addTo(traceroutesLayerGroup);
|
||||||
|
|
||||||
|
const endOverlay = L.marker(endMarker.getLatLng(), {
|
||||||
|
icon: iconTracerouteEnd,
|
||||||
|
zIndexOffset: 10000, // Ensure it's on top
|
||||||
|
}).addTo(traceroutesLayerGroup);
|
||||||
|
|
||||||
|
// Store overlays for cleanup
|
||||||
|
routeElements.startOverlay = startOverlay;
|
||||||
|
routeElements.endOverlay = endOverlay;
|
||||||
|
|
||||||
|
// Animate each hop sequentially
|
||||||
|
let hopIndex = 0;
|
||||||
|
const animateNextHop = () => {
|
||||||
|
if (hopIndex >= pathMarkers.length - 1) {
|
||||||
|
// All hops animated, cleanup after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tracerouteLines[routeKey]) {
|
||||||
|
const routeElements = tracerouteLines[routeKey];
|
||||||
|
// Remove all lines
|
||||||
|
if (routeElements.lines) {
|
||||||
|
routeElements.lines.forEach(line => {
|
||||||
|
line.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove node overlays
|
||||||
|
if (routeElements.startOverlay) {
|
||||||
|
routeElements.startOverlay.remove();
|
||||||
|
}
|
||||||
|
if (routeElements.endOverlay) {
|
||||||
|
routeElements.endOverlay.remove();
|
||||||
|
}
|
||||||
|
delete tracerouteLines[routeKey];
|
||||||
|
}
|
||||||
|
activeTracerouteKeys.delete(routeKey);
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromMarker = pathMarkers[hopIndex];
|
||||||
|
const toMarker = pathMarkers[hopIndex + 1];
|
||||||
|
const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null;
|
||||||
|
|
||||||
|
// Use orange color for all traceroute lines
|
||||||
|
const lineColor = '#f97316'; // orange
|
||||||
|
|
||||||
|
// Create animated polyline for this hop with orange dotted style
|
||||||
|
const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], {
|
||||||
|
color: lineColor,
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0, // Start invisible
|
||||||
|
// dashArray: '10, 5', // Dotted line style
|
||||||
|
zIndexOffset: 10000,
|
||||||
|
}).addTo(traceroutesLayerGroup);
|
||||||
|
|
||||||
|
// Fade in animation
|
||||||
|
line.setStyle({ opacity: 1.0 });
|
||||||
|
tracerouteLines[routeKey].lines.push(line);
|
||||||
|
|
||||||
|
// Animate next hop after 600ms delay
|
||||||
|
hopIndex++;
|
||||||
|
setTimeout(animateNextHop, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
animateNextHop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect WebSocket when page loads
|
||||||
|
connectWebSocket();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Google tag (gtag.js) -->
|
<!-- Google tag (gtag.js) -->
|
||||||
|
|
|
||||||
313
src/ws.js
Normal file
313
src/ws.js
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const path = require("path");
|
||||||
|
const http = require("http");
|
||||||
|
const mqtt = require("mqtt");
|
||||||
|
const protobufjs = require("protobufjs");
|
||||||
|
const commandLineArgs = require("command-line-args");
|
||||||
|
const commandLineUsage = require("command-line-usage");
|
||||||
|
const { WebSocketServer } = require("ws");
|
||||||
|
|
||||||
|
const optionsList = [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
alias: 'h',
|
||||||
|
type: Boolean,
|
||||||
|
description: 'Display this usage guide.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mqtt-broker-url",
|
||||||
|
type: String,
|
||||||
|
description: "MQTT Broker URL (e.g: mqtt://mqtt.meshtastic.org)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mqtt-username",
|
||||||
|
type: String,
|
||||||
|
description: "MQTT Username (e.g: meshdev)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mqtt-password",
|
||||||
|
type: String,
|
||||||
|
description: "MQTT Password (e.g: large4cats)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mqtt-client-id",
|
||||||
|
type: String,
|
||||||
|
description: "MQTT Client ID (e.g: map.example.com)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mqtt-topic",
|
||||||
|
type: String,
|
||||||
|
multiple: true,
|
||||||
|
typeLabel: '<topic> ...',
|
||||||
|
description: "MQTT Topic to subscribe to (e.g: msh/#)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "decryption-keys",
|
||||||
|
type: String,
|
||||||
|
multiple: true,
|
||||||
|
typeLabel: '<base64DecryptionKey> ...',
|
||||||
|
description: "Decryption keys encoded in base64 to use when decrypting service envelopes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ws-port",
|
||||||
|
type: Number,
|
||||||
|
description: "WebSocket server port (default: 8081)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// parse command line args
|
||||||
|
const options = commandLineArgs(optionsList);
|
||||||
|
|
||||||
|
// show help
|
||||||
|
if(options.help){
|
||||||
|
const usage = commandLineUsage([
|
||||||
|
{
|
||||||
|
header: 'Meshtastic WebSocket Publisher',
|
||||||
|
content: 'Publishes real-time Meshtastic packets via WebSocket.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Options',
|
||||||
|
optionList: optionsList,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
console.log(usage);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get options and fallback to default values
|
||||||
|
const mqttBrokerUrl = options["mqtt-broker-url"] ?? "mqtt://mqtt.meshtastic.org";
|
||||||
|
const mqttUsername = options["mqtt-username"] ?? "meshdev";
|
||||||
|
const mqttPassword = options["mqtt-password"] ?? "large4cats";
|
||||||
|
const mqttClientId = options["mqtt-client-id"] ?? null;
|
||||||
|
const mqttTopics = options["mqtt-topic"] ?? ["msh/#"];
|
||||||
|
const decryptionKeys = options["decryption-keys"] ?? [
|
||||||
|
"1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key
|
||||||
|
];
|
||||||
|
const wsPort = options["ws-port"] ?? 8081;
|
||||||
|
|
||||||
|
// create mqtt client
|
||||||
|
const client = mqtt.connect(mqttBrokerUrl, {
|
||||||
|
username: mqttUsername,
|
||||||
|
password: mqttPassword,
|
||||||
|
clientId: mqttClientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// load protobufs
|
||||||
|
const root = new protobufjs.Root();
|
||||||
|
root.resolvePath = (origin, target) => path.join(__dirname, "protobufs", target);
|
||||||
|
root.loadSync('meshtastic/mqtt.proto');
|
||||||
|
const Data = root.lookupType("Data");
|
||||||
|
const ServiceEnvelope = root.lookupType("ServiceEnvelope");
|
||||||
|
const RouteDiscovery = root.lookupType("RouteDiscovery");
|
||||||
|
|
||||||
|
// create HTTP server for WebSocket
|
||||||
|
const server = http.createServer();
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
// track connected clients
|
||||||
|
const clients = new Set();
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
clients.add(ws);
|
||||||
|
console.log(`WebSocket client connected. Total clients: ${clients.size}`);
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clients.delete(ws);
|
||||||
|
console.log(`WebSocket client disconnected. Total clients: ${clients.size}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
clients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// broadcast message to all connected clients
|
||||||
|
function broadcast(message) {
|
||||||
|
const messageStr = JSON.stringify(message);
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === 1) { // WebSocket.OPEN
|
||||||
|
try {
|
||||||
|
client.send(messageStr);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message to client:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNonce(packetId, fromNode) {
|
||||||
|
// Expand packetId to 64 bits
|
||||||
|
const packetId64 = BigInt(packetId);
|
||||||
|
|
||||||
|
// Initialize block counter (32-bit, starts at zero)
|
||||||
|
const blockCounter = 0;
|
||||||
|
|
||||||
|
// Create a buffer for the nonce
|
||||||
|
const buf = Buffer.alloc(16);
|
||||||
|
|
||||||
|
// Write packetId, fromNode, and block counter to the buffer
|
||||||
|
buf.writeBigUInt64LE(packetId64, 0);
|
||||||
|
buf.writeUInt32LE(fromNode, 8);
|
||||||
|
buf.writeUInt32LE(blockCounter, 12);
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* References:
|
||||||
|
* https://github.com/crypto-smoke/meshtastic-go/blob/develop/radio/aes.go#L42
|
||||||
|
* https://github.com/pdxlocations/Meshtastic-MQTT-Connect/blob/main/meshtastic-mqtt-connect.py#L381
|
||||||
|
*/
|
||||||
|
function decrypt(packet) {
|
||||||
|
// attempt to decrypt with all available decryption keys
|
||||||
|
for(const decryptionKey of decryptionKeys){
|
||||||
|
try {
|
||||||
|
// convert encryption key to buffer
|
||||||
|
const key = Buffer.from(decryptionKey, "base64");
|
||||||
|
|
||||||
|
// create decryption iv/nonce for this packet
|
||||||
|
const nonceBuffer = createNonce(packet.id, packet.from);
|
||||||
|
|
||||||
|
// determine algorithm based on key length
|
||||||
|
var algorithm = null;
|
||||||
|
if(key.length === 16){
|
||||||
|
algorithm = "aes-128-ctr";
|
||||||
|
} else if(key.length === 32){
|
||||||
|
algorithm = "aes-256-ctr";
|
||||||
|
} else {
|
||||||
|
// skip this key, try the next one...
|
||||||
|
console.error(`Skipping decryption key with invalid length: ${key.length}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create decipher
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, key, nonceBuffer);
|
||||||
|
|
||||||
|
// decrypt encrypted packet
|
||||||
|
const decryptedBuffer = Buffer.concat([decipher.update(packet.encrypted), decipher.final()]);
|
||||||
|
|
||||||
|
// parse as data message
|
||||||
|
return Data.decode(decryptedBuffer);
|
||||||
|
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// couldn't decrypt
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* converts hex id to numeric id, for example: !FFFFFFFF to 4294967295
|
||||||
|
* @param hexId a node id in hex format with a prepended "!"
|
||||||
|
* @returns {bigint} the node id in numeric form
|
||||||
|
*/
|
||||||
|
function convertHexIdToNumericId(hexId) {
|
||||||
|
return BigInt('0x' + hexId.replaceAll("!", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe to everything when connected
|
||||||
|
client.on("connect", () => {
|
||||||
|
console.log("Connected to MQTT broker");
|
||||||
|
for(const mqttTopic of mqttTopics){
|
||||||
|
client.subscribe(mqttTopic);
|
||||||
|
console.log(`Subscribed to MQTT topic: ${mqttTopic}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// handle message received
|
||||||
|
client.on("message", async (topic, message) => {
|
||||||
|
try {
|
||||||
|
// decode service envelope
|
||||||
|
const envelope = ServiceEnvelope.decode(message);
|
||||||
|
if(!envelope.packet){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to decrypt encrypted packets
|
||||||
|
const isEncrypted = envelope.packet.encrypted?.length > 0;
|
||||||
|
if(isEncrypted){
|
||||||
|
const decoded = decrypt(envelope.packet);
|
||||||
|
if(decoded){
|
||||||
|
envelope.packet.decoded = decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get portnum from decoded packet
|
||||||
|
const portnum = envelope.packet?.decoded?.portnum;
|
||||||
|
|
||||||
|
// check if we can see the decrypted packet data
|
||||||
|
if(envelope.packet.decoded == null){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle traceroutes (portnum 70)
|
||||||
|
if(portnum === 70) {
|
||||||
|
try {
|
||||||
|
const routeDiscovery = RouteDiscovery.decode(envelope.packet.decoded.payload);
|
||||||
|
|
||||||
|
const traceroute = {
|
||||||
|
type: "traceroute",
|
||||||
|
data: {
|
||||||
|
to: envelope.packet.to,
|
||||||
|
from: envelope.packet.from,
|
||||||
|
want_response: envelope.packet.decoded.wantResponse,
|
||||||
|
route: routeDiscovery.route,
|
||||||
|
snr_towards: routeDiscovery.snrTowards,
|
||||||
|
route_back: routeDiscovery.routeBack,
|
||||||
|
snr_back: routeDiscovery.snrBack,
|
||||||
|
channel_id: envelope.channelId,
|
||||||
|
gateway_id: envelope.gatewayId ? Number(convertHexIdToNumericId(envelope.gatewayId)) : null,
|
||||||
|
packet_id: envelope.packet.id,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
broadcast(traceroute);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error processing traceroute:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Error processing MQTT message:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// start WebSocket server
|
||||||
|
server.listen(wsPort, () => {
|
||||||
|
console.log(`WebSocket server running on port ${wsPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown handlers
|
||||||
|
function gracefulShutdown(signal) {
|
||||||
|
console.log(`Received ${signal}. Starting graceful shutdown...`);
|
||||||
|
|
||||||
|
// Close all WebSocket connections
|
||||||
|
clients.forEach((client) => {
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
clients.clear();
|
||||||
|
|
||||||
|
// Close WebSocket server
|
||||||
|
wss.close(() => {
|
||||||
|
console.log('WebSocket server closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close HTTP server
|
||||||
|
server.close(() => {
|
||||||
|
console.log('HTTP server closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close MQTT client
|
||||||
|
client.end(false, () => {
|
||||||
|
console.log('MQTT client disconnected');
|
||||||
|
console.log('Graceful shutdown completed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SIGTERM (Docker, systemd, etc.)
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
|
||||||
|
// Handle SIGINT (Ctrl+C)
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue