diff --git a/src/server/routes/meshcoreRoutes.test.ts b/src/server/routes/meshcoreRoutes.test.ts index 94b7007bc..d87e1ec97 100644 --- a/src/server/routes/meshcoreRoutes.test.ts +++ b/src/server/routes/meshcoreRoutes.test.ts @@ -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' }, ]; @@ -2340,9 +2350,10 @@ 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( @@ -2350,6 +2361,36 @@ describe('MeshCore Routes', () => { ); }); + 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', diff --git a/src/server/routes/meshcoreRoutes.ts b/src/server/routes/meshcoreRoutes.ts index 776b26a70..bdbeded52 100644 --- a/src/server/routes/meshcoreRoutes.ts +++ b/src/server/routes/meshcoreRoutes.ts @@ -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 @@ -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', @@ -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}`);