2025-04-07 20:52:59 +02:00
|
|
|
const crypto = require("crypto");
|
2025-03-30 16:43:20 +02:00
|
|
|
const path = require('path');
|
2025-03-30 16:17:26 +02:00
|
|
|
const express = require('express');
|
|
|
|
|
const router = express.Router();
|
2025-03-30 16:43:20 +02:00
|
|
|
const protobufjs = require("protobufjs");
|
2025-03-30 16:17:26 +02:00
|
|
|
|
|
|
|
|
// create prisma db client
|
|
|
|
|
const { PrismaClient } = require("@prisma/client");
|
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
|
|
2025-03-30 16:43:20 +02:00
|
|
|
// load protobufs
|
|
|
|
|
const root = new protobufjs.Root();
|
|
|
|
|
root.resolvePath = (origin, target) => path.join(__dirname, "protos", target);
|
|
|
|
|
root.loadSync('meshtastic/mqtt.proto');
|
2025-04-07 20:52:59 +02:00
|
|
|
const Data = root.lookupType("Data");
|
2025-03-30 16:43:20 +02:00
|
|
|
const HardwareModel = root.lookupEnum("HardwareModel");
|
2025-04-07 20:52:59 +02:00
|
|
|
const ServiceEnvelope = root.lookupType("ServiceEnvelope");
|
|
|
|
|
|
|
|
|
|
const decryptionKeys = [
|
|
|
|
|
"1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decrypt(packet) {
|
|
|
|
|
|
|
|
|
|
// attempt to decrypt with all available decryption keys
|
|
|
|
|
for(const decryptionKey of decryptionKeys){
|
|
|
|
|
try {
|
|
|
|
|
const key = Buffer.from(decryptionKey, "base64");
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const decipher = crypto.createDecipheriv(algorithm, key, nonceBuffer);
|
|
|
|
|
const decryptedBuffer = Buffer.concat([decipher.update(packet.encrypted), decipher.final()]);
|
|
|
|
|
|
|
|
|
|
return Data.decode(decryptedBuffer);
|
|
|
|
|
|
|
|
|
|
} catch(e){}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// couldn't decrypt
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PORTNUM_LABELS = {
|
|
|
|
|
0: "UNKNOWN_APP",
|
|
|
|
|
1: "TEXT_MESSAGE_APP",
|
|
|
|
|
2: "REMOTE_HARDWARE_APP",
|
|
|
|
|
3: "POSITION_APP",
|
|
|
|
|
4: "NODEINFO_APP",
|
|
|
|
|
5: "ROUTING_APP",
|
|
|
|
|
6: "ADMIN_APP",
|
|
|
|
|
7: "TEXT_MESSAGE_COMPRESSED_APP",
|
|
|
|
|
8: "WAYPOINT_APP",
|
|
|
|
|
9: "AUDIO_APP",
|
|
|
|
|
10: "DETECTION_SENSOR_APP",
|
|
|
|
|
32: "REPLY_APP",
|
|
|
|
|
33: "IP_TUNNEL_APP",
|
|
|
|
|
34: "PAXCOUNTER_APP",
|
|
|
|
|
64: "SERIAL_APP",
|
|
|
|
|
65: "STORE_FORWARD_APP",
|
|
|
|
|
66: "RANGE_TEST_APP",
|
|
|
|
|
67: "TELEMETRY_APP",
|
|
|
|
|
68: "ZPS_APP",
|
|
|
|
|
69: "SIMULATOR_APP",
|
|
|
|
|
70: "TRACEROUTE_APP",
|
|
|
|
|
71: "NEIGHBORINFO_APP",
|
|
|
|
|
72: "ATAK_PLUGIN",
|
|
|
|
|
256: "PRIVATE_APP",
|
|
|
|
|
257: "ATAK_FORWARDER"
|
|
|
|
|
};
|
2025-03-30 16:17:26 +02:00
|
|
|
|
|
|
|
|
router.get('/hardware-models', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
|
|
// get nodes from db
|
|
|
|
|
const results = await prisma.node.groupBy({
|
|
|
|
|
by: ['hardware_model'],
|
|
|
|
|
orderBy: {
|
|
|
|
|
_count: {
|
|
|
|
|
hardware_model: 'desc',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
_count: {
|
|
|
|
|
hardware_model: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hardwareModelStats = results.map((result) => {
|
|
|
|
|
return {
|
|
|
|
|
count: result._count.hardware_model,
|
|
|
|
|
hardware_model: result.hardware_model,
|
|
|
|
|
hardware_model_name: HardwareModel.valuesById[result.hardware_model] ?? "UNKNOWN",
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
hardware_model_stats: hardwareModelStats,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch(err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
message: "Something went wrong, try again later.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.get('/messages-per-hour', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const hours = 168;
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000);
|
|
|
|
|
|
|
|
|
|
const messages = await prisma.textMessage.findMany({
|
|
|
|
|
where: { created_at: { gte: startTime } },
|
|
|
|
|
select: { packet_id: true, created_at: true },
|
|
|
|
|
distinct: ['packet_id'], // Ensures only unique packet_id entries are counted
|
|
|
|
|
orderBy: { created_at: 'asc' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Pre-fill `uniqueCounts` with zeros for all hours
|
|
|
|
|
const uniqueCounts = Object.fromEntries(
|
|
|
|
|
Array.from({ length: hours }, (_, i) => {
|
|
|
|
|
const hourTime = new Date(now.getTime() - (hours - i) * 60 * 60 * 1000);
|
|
|
|
|
const hourString = hourTime.toISOString().slice(0, 13); // YYYY-MM-DD HH
|
|
|
|
|
return [hourString, 0];
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Populate actual message counts
|
|
|
|
|
messages.forEach(({ created_at }) => {
|
|
|
|
|
const hourString = created_at.toISOString().slice(0, 13); // YYYY-MM-DD HH
|
|
|
|
|
uniqueCounts[hourString]++;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Convert to final result format
|
|
|
|
|
const result = Object.entries(uniqueCounts).map(([hour, count]) => ({ hour, count }));
|
|
|
|
|
|
|
|
|
|
res.json(result);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching messages:', error);
|
|
|
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-03-30 16:17:51 +02:00
|
|
|
router.get('/position-precision', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const sevenDaysAgo = new Date();
|
|
|
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
|
|
|
|
|
|
|
|
const result = await prisma.node.groupBy({
|
|
|
|
|
by: ['position_precision'],
|
|
|
|
|
where: {
|
|
|
|
|
position_updated_at: { gte: sevenDaysAgo },
|
|
|
|
|
position_precision: { not: null },
|
|
|
|
|
},
|
|
|
|
|
_count: {
|
|
|
|
|
position_precision: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const formatted = result.map(r => ({
|
|
|
|
|
position_precision: r.position_precision,
|
|
|
|
|
count: r._count.position_precision,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
res.set('Cache-Control', 'public, max-age=600'); // 10 min cache
|
|
|
|
|
res.json(formatted);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching data:', error);
|
|
|
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-03-31 21:11:49 +02:00
|
|
|
router.get('/most-active-nodes', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await prisma.$queryRaw`
|
|
|
|
|
SELECT n.long_name, COUNT(*) AS count
|
|
|
|
|
FROM service_envelopes se
|
|
|
|
|
JOIN nodes n ON se.from = n.node_id
|
|
|
|
|
WHERE
|
|
|
|
|
se.created_at >= NOW() - INTERVAL 1 DAY
|
|
|
|
|
AND se.mqtt_topic NOT LIKE '%/map/'
|
|
|
|
|
GROUP BY n.long_name
|
|
|
|
|
ORDER BY count DESC
|
|
|
|
|
LIMIT 25;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
res.set('Cache-Control', 'public, max-age=600'); // 10 min cache
|
|
|
|
|
res.json(result);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching data:', error);
|
|
|
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-04-07 20:52:59 +02:00
|
|
|
router.get('/portnum-counts', async (req, res) => {
|
|
|
|
|
const nodeId = req.query.nodeId ? parseInt(req.query.nodeId, 10) : null;
|
|
|
|
|
const hours = 24;
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const messages = await prisma.serviceEnvelope.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
created_at: { gte: startTime },
|
|
|
|
|
...(Number.isInteger(nodeId) ? { from: nodeId } : {})
|
|
|
|
|
},
|
|
|
|
|
select: { protobuf: true }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const counts = {};
|
|
|
|
|
for (const row of messages) {
|
|
|
|
|
try {
|
|
|
|
|
const envelope = ServiceEnvelope.decode(row.protobuf);
|
|
|
|
|
const packet = envelope.packet;
|
|
|
|
|
|
|
|
|
|
if (!packet?.encrypted) {
|
|
|
|
|
counts[0] = (counts[0] || 0) + 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dataMessage = decrypt(packet);
|
|
|
|
|
|
|
|
|
|
if (dataMessage?.portnum !== undefined) {
|
|
|
|
|
const portnum = dataMessage.portnum;
|
|
|
|
|
counts[portnum] = (counts[portnum] || 0) + 1;
|
|
|
|
|
} else {
|
|
|
|
|
// couldn't decrypt or no portnum in decrypted message
|
|
|
|
|
counts[0] = (counts[0] || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn("Decode error:", err.message);
|
|
|
|
|
counts[0] = (counts[0] || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = Object.entries(counts).map(([portnum, count]) => ({
|
|
|
|
|
portnum: parseInt(portnum),
|
|
|
|
|
count,
|
|
|
|
|
label: PORTNUM_LABELS[portnum] || "UNKNOWN"
|
|
|
|
|
})).sort((a, b) => a.portnum - b.portnum);
|
|
|
|
|
|
|
|
|
|
res.json(result);
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Error in /portnum-counts:", err);
|
|
|
|
|
res.status(500).json({ message: "Internal server error" });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-03-31 21:11:49 +02:00
|
|
|
|
2025-03-30 16:17:26 +02:00
|
|
|
module.exports = router;
|