Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions src/server/routes/meshcoreRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2322,7 +2322,17 @@ describe('MeshCore Routes', () => {
});

describe('GET /api/sources/test-source/meshcore/packets/export', () => {
// A fully-formed ADVERT OTA packet (FLOOD + ADVERT, direct path) carrying
// pubkey 01..20, ts 1700000000, a 64-byte signature, LATLON+NAME flags,
// lat 37.5 / lon -122.25, and name "Repeater-1". See
// meshcorePacketDecode.test.ts for the byte layout (issue #3937).
const advertHex =
'11ff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2000f15365' +
'808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5' +
'a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf9260343c02f09cb6f852' +
'657065617465722d3100';
const samplePackets = [
{ id: 3, sourceId: 'test-source', timestamp: 1700000003000, payloadType: 4, routeType: 1, rawHex: advertHex },
{ id: 2, sourceId: 'test-source', timestamp: 1700000002000, payloadType: 1, routeType: 0, rawHex: 'beef' },
{ id: 1, sourceId: 'test-source', timestamp: 1700000001000, payloadType: 2, routeType: 1, rawHex: 'cafe' },
];
Expand All @@ -2340,16 +2350,47 @@ describe('MeshCore Routes', () => {
expect(response.headers['content-disposition']).toMatch(/attachment; filename="meshcore-packet-monitor-.*\.jsonl"/);

const lines = response.text.trim().split('\n');
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0])).toMatchObject({ id: 2, payloadType: 1 });
expect(JSON.parse(lines[1])).toMatchObject({ id: 1, payloadType: 2 });
expect(lines).toHaveLength(3);
expect(JSON.parse(lines[0])).toMatchObject({ id: 3, payloadType: 4 });
expect(JSON.parse(lines[1])).toMatchObject({ id: 2, payloadType: 1 });
expect(JSON.parse(lines[2])).toMatchObject({ id: 1, payloadType: 2 });

// Exports up to the retention cap, scoped to this source, offset 0.
expect(mockPacketService.getPackets).toHaveBeenCalledWith(
expect.objectContaining({ sourceId: 'test-source', offset: 0, limit: 1000 }),
);
});

it('attaches a decoded ADVERT (name/lat/lon/pubkey) while preserving rawHex (#3937)', async () => {
const response = await authenticatedAgent.get('/api/sources/test-source/meshcore/packets/export');

expect(response.status).toBe(200);
const advertLine = JSON.parse(response.text.trim().split('\n')[0]);

// Raw row is preserved so nothing is lost for callers doing their own analysis.
expect(advertLine.rawHex).toBe(advertHex);

// The unencrypted ADVERT payload is decoded into the exported line.
expect(advertLine.decoded).toBeDefined();
expect(advertLine.decoded.header.payloadTypeName).toBe('ADVERT');
const advert = advertLine.decoded.payload.advert;
expect(advert.name).toBe('Repeater-1');
expect(advert.latitude).toBeCloseTo(37.5, 5);
expect(advert.longitude).toBeCloseTo(-122.25, 5);
expect(advert.publicKey).toBe('0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20');
expect(advertLine.decoded.errors).toEqual([]);
});

it('attaches decoded structure even when rawHex is short/undecodable payload (#3937)', async () => {
const response = await authenticatedAgent.get('/api/sources/test-source/meshcore/packets/export');

// Encrypted/short rows still get a `decoded` header block; the encrypted
// body simply is not decoded (no advert), by design.
const line = JSON.parse(response.text.trim().split('\n')[2]);
expect(line.decoded).toBeDefined();
expect(line.decoded.payload.advert).toBeUndefined();
});

it('passes filters through and marks the filename as filtered', async () => {
const response = await authenticatedAgent.get(
'/api/sources/test-source/meshcore/packets/export?payload_type=1&route_type=0',
Expand Down
18 changes: 17 additions & 1 deletion src/server/routes/meshcoreRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import meshcorePacketLogService from '../services/meshcorePacketLogService.js';
import meshcorePositionHistoryService from '../services/meshcorePositionHistoryService.js';
import { resolveAutoAckPreSendDelaySeconds } from '../autoAckDelay.js';
import { isNullIsland } from '../../utils/nullIsland.js';
import { decodeMeshCorePacket } from '../../utils/meshcorePacketDecode.js';

/**
* Resolve the manager for a request. Mounted only under
Expand Down Expand Up @@ -3594,6 +3595,12 @@ router.get(
* same payload_type / route_type / since filters as the list endpoint. Streams
* one JSON object per line as an attachment download — the MeshCore analogue of
* the Meshtastic packet-monitor export (issue #3391).
*
* Each line is the raw DB row plus a `decoded` field carrying the decoded
* unencrypted on-wire data (ADVERT name/lat/lon/pubkey/flags, ACK codes, and
* the plaintext dest/src hash prefix of encrypted messages), matching the
* Packet Monitor's decode modal (issue #3937). Encrypted message bodies stay
* undecoded by design.
*/
router.get(
'/packets/export',
Expand Down Expand Up @@ -3630,7 +3637,16 @@ router.get(
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);

for (const packet of packets) {
res.write(JSON.stringify(packet) + '\n');
// Attach a decoded view of the (unencrypted) on-wire fields alongside
// the raw DB row so exports carry the same information the Packet
// Monitor's "click to decode" modal shows — full ADVERT decode
// (name, lat/lon, pubkey, flags), ACK codes, and the plaintext
// dest/src hash prefix of encrypted message payloads. Encrypted
// message bodies remain undecoded by design. `rawHex` is preserved so
// nothing is lost for callers doing their own analysis. Decoding never
// throws — failures surface as null / a `.errors` array (issue #3937).
const decoded = decodeMeshCorePacket(packet.rawHex);
res.write(JSON.stringify({ ...packet, decoded }) + '\n');
}
res.end();
logger.debug(`[API] Exported ${packets.length} MeshCore packets to ${filename}`);
Expand Down
Loading