Add packet feed clickable packet inspection. Closes #75 again.

This commit is contained in:
Jack Kingsman
2026-03-19 09:43:57 -07:00
parent 41a297c944
commit b85d451e26
10 changed files with 1320 additions and 240 deletions
+388
View File
@@ -0,0 +1,388 @@
import {
MeshCoreDecoder,
PayloadType,
Utils,
type DecodedPacket,
type DecryptionOptions,
type HeaderBreakdown,
type PacketStructure,
} from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
export interface RawPacketSummary {
summary: string;
routeType: string;
details?: string;
}
export interface PacketByteField {
id: string;
scope: 'packet' | 'payload';
name: string;
description: string;
value: string;
decryptedMessage?: string;
startByte: number;
endByte: number;
absoluteStartByte: number;
absoluteEndByte: number;
headerBreakdown?: HeaderBreakdown;
}
export interface RawPacketInspection {
decoded: DecodedPacket | null;
structure: PacketStructure | null;
routeTypeName: string;
payloadTypeName: string;
payloadVersionName: string;
pathTokens: string[];
summary: RawPacketSummary;
validationErrors: string[];
packetFields: PacketByteField[];
payloadFields: PacketByteField[];
}
export function formatHexByHop(hex: string, hashSize: number | null | undefined): string {
const normalized = hex.trim().toUpperCase();
if (!normalized || !hashSize || hashSize < 1) {
return normalized;
}
const charsPerHop = hashSize * 2;
if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) {
return normalized;
}
const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g'));
return hops && hops.length > 1 ? hops.join(' → ') : normalized;
}
export function describeCiphertextStructure(
payloadType: PayloadType,
byteLength: number,
fallbackDescription: string
): string {
switch (payloadType) {
case PayloadType.GroupText:
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Flags (1 byte) - room-message flags byte
• Message (remaining bytes) - UTF-8 room message text`;
case PayloadType.TextMessage:
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Message (remaining bytes) - UTF-8 direct message text`;
case PayloadType.Response:
return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Tag (4 bytes) - request/response correlation tag
• Content (remaining bytes) - response body`;
default:
return fallbackDescription;
}
}
function getPathTokens(decoded: DecodedPacket): string[] {
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
return tracePayload?.pathHashes || decoded.path || [];
}
function formatUnixTimestamp(timestamp: number): string {
return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`;
}
function createPacketField(
scope: 'packet' | 'payload',
id: string,
field: {
name: string;
description: string;
value: string;
decryptedMessage?: string;
startByte: number;
endByte: number;
headerBreakdown?: HeaderBreakdown;
},
absoluteOffset: number
): PacketByteField {
return {
id,
scope,
name: field.name,
description: field.description,
value: field.value,
decryptedMessage: field.decryptedMessage,
startByte: field.startByte,
endByte: field.endByte,
absoluteStartByte: absoluteOffset + field.startByte,
absoluteEndByte: absoluteOffset + field.endByte,
headerBreakdown: field.headerBreakdown,
};
}
export function createDecoderOptions(
channels: Channel[] | null | undefined
): DecryptionOptions | undefined {
const channelSecrets =
channels
?.map((channel) => channel.key?.trim())
.filter((key): key is string => Boolean(key && key.length > 0)) ?? [];
if (channelSecrets.length === 0) {
return undefined;
}
return {
keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }),
attemptDecryption: true,
};
}
function safeValidate(hexData: string): string[] {
try {
const validation = MeshCoreDecoder.validate(hexData);
return validation.errors ?? [];
} catch (error) {
return [error instanceof Error ? error.message : 'Packet validation failed'];
}
}
export function decodePacketSummary(
packet: RawPacket,
decoderOptions?: DecryptionOptions
): RawPacketSummary {
try {
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
const pathTokens = getPathTokens(decoded);
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : '';
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
decrypted?: { sender?: string; message?: string };
} | null;
if (packet.decrypted_info?.channel_name) {
if (packet.decrypted_info.sender) {
summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`;
} else {
summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`;
}
} else if (payload?.decrypted?.sender) {
summary = `GT from ${payload.decrypted.sender}${pathStr}`;
} else if (payload?.decrypted?.message) {
summary = `GT decrypted${pathStr}`;
} else if (payload?.channelHash) {
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack:
summary = `ACK${pathStr}`;
break;
case PayloadType.Request:
summary = `Request${pathStr}`;
break;
case PayloadType.Response:
summary = `Response${pathStr}`;
break;
case PayloadType.Trace:
summary = `Trace${pathStr}`;
break;
case PayloadType.Path:
summary = `Path${pathStr}`;
break;
default:
summary = `${payloadTypeName}${pathStr}`;
break;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
export function inspectRawPacket(packet: RawPacket): RawPacketInspection {
return inspectRawPacketWithOptions(packet);
}
export function inspectRawPacketWithOptions(
packet: RawPacket,
decoderOptions?: DecryptionOptions
): RawPacketInspection {
const summary = decodePacketSummary(packet, decoderOptions);
const validationErrors = safeValidate(packet.data);
let decoded: DecodedPacket | null = null;
let structure: PacketStructure | null = null;
try {
decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
} catch {
decoded = null;
}
try {
structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions);
} catch {
structure = null;
}
const routeTypeName = decoded?.isValid
? Utils.getRouteTypeName(decoded.routeType)
: summary.routeType;
const payloadTypeName = decoded?.isValid
? Utils.getPayloadTypeName(decoded.payloadType)
: packet.payload_type;
const payloadVersionName = decoded?.isValid
? Utils.getPayloadVersionName(decoded.payloadVersion)
: 'Unknown';
const pathTokens = decoded?.isValid ? getPathTokens(decoded) : [];
const packetFields =
structure?.segments
.map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0))
.map((field) => {
if (field.name !== 'Path Data') {
return field;
}
const hashSize =
decoded?.pathHashSize ??
(decoded?.pathLength && decoded.pathLength > 0
? Math.max(1, field.value.length / 2 / decoded.pathLength)
: null);
return {
...field,
value: formatHexByHop(field.value, hashSize),
};
}) ?? [];
const payloadFields =
structure == null
? []
: (structure.payload.segments.length > 0
? structure.payload.segments
: structure.payload.hex.length > 0
? [
{
name: 'Payload Bytes',
description:
'Field-level payload breakdown is not available for this packet type.',
startByte: 0,
endByte: Math.max(0, structure.payload.hex.length / 2 - 1),
value: structure.payload.hex,
},
]
: []
).map((segment, index) =>
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
);
const enrichedPayloadFields =
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
? payloadFields.map((field) => {
if (field.name !== 'Ciphertext') {
return field;
}
const payload = decoded.payload.decoded as {
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
};
if (!payload.decrypted?.message) {
return field;
}
const detailLines = [
payload.decrypted.timestamp != null
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
: null,
payload.decrypted.flags != null
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
: null,
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
`Message: ${payload.decrypted.message}`,
].filter((line): line is string => line !== null);
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
decryptedMessage: detailLines.join('\n'),
};
})
: payloadFields.map((field) => {
if (!decoded?.isValid || field.name !== 'Ciphertext') {
return field;
}
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
};
});
return {
decoded,
structure,
routeTypeName,
payloadTypeName,
payloadVersionName,
pathTokens,
summary,
validationErrors:
validationErrors.length > 0
? validationErrors
: (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])),
packetFields,
payloadFields: enrichedPayloadFields,
};
}
+40
View File
@@ -51,6 +51,7 @@ export interface RawPacketStatsObservation {
sourceLabel: string | null;
pathTokenCount: number;
pathSignature: string | null;
hopByteWidth?: number | null;
}
export interface RawPacketStatsSessionState {
@@ -97,6 +98,7 @@ export interface RawPacketStatsSnapshot {
routeBreakdown: RankedPacketStat[];
topPacketTypes: RankedPacketStat[];
hopProfile: RankedPacketStat[];
hopByteWidthProfile: RankedPacketStat[];
strongestNeighbors: NeighborStat[];
mostActiveNeighbors: NeighborStat[];
newestNeighbors: NeighborStat[];
@@ -228,6 +230,7 @@ export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObs
sourceLabel: sourceInfo.sourceLabel,
pathTokenCount: pathTokens.length,
pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null,
hopByteWidth: pathTokens.length > 0 ? (decoded.pathHashSize ?? 1) : null,
};
} catch {
return {
@@ -242,10 +245,26 @@ export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObs
sourceLabel: null,
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
};
}
}
function inferHopByteWidth(packet: RawPacketStatsObservation): number | null {
if (packet.pathTokenCount <= 0) {
return null;
}
if (packet.hopByteWidth && packet.hopByteWidth > 0) {
return packet.hopByteWidth;
}
const firstToken = packet.pathSignature?.split('>')[0] ?? null;
if (!firstToken || firstToken.length % 2 !== 0) {
return null;
}
const inferred = firstToken.length / 2;
return inferred >= 1 && inferred <= 3 ? inferred : null;
}
function share(count: number, total: number): number {
if (total <= 0) return 0;
return count / total;
@@ -306,6 +325,13 @@ export function buildRawPacketStatsSnapshot(
['1 hop', 0],
['2+ hops', 0],
]);
const hopByteWidthCounts = new Map<string, number>([
['No path', 0],
['1 byte / hop', 0],
['2 bytes / hop', 0],
['3 bytes / hop', 0],
['Unknown width', 0],
]);
const neighborMap = new Map<string, NeighborStat>();
const rssiValues: number[] = [];
const rssiBucketCounts = new Map<string, number>([
@@ -328,6 +354,19 @@ export function buildRawPacketStatsSnapshot(
hopCounts.set('2+ hops', (hopCounts.get('2+ hops') ?? 0) + 1);
}
const hopByteWidth = inferHopByteWidth(packet);
if (packet.pathTokenCount <= 0) {
hopByteWidthCounts.set('No path', (hopByteWidthCounts.get('No path') ?? 0) + 1);
} else if (hopByteWidth === 1) {
hopByteWidthCounts.set('1 byte / hop', (hopByteWidthCounts.get('1 byte / hop') ?? 0) + 1);
} else if (hopByteWidth === 2) {
hopByteWidthCounts.set('2 bytes / hop', (hopByteWidthCounts.get('2 bytes / hop') ?? 0) + 1);
} else if (hopByteWidth === 3) {
hopByteWidthCounts.set('3 bytes / hop', (hopByteWidthCounts.get('3 bytes / hop') ?? 0) + 1);
} else {
hopByteWidthCounts.set('Unknown width', (hopByteWidthCounts.get('Unknown width') ?? 0) + 1);
}
if (packet.sourceKey && packet.sourceLabel) {
const existing = neighborMap.get(packet.sourceKey);
if (!existing) {
@@ -448,6 +487,7 @@ export function buildRawPacketStatsSnapshot(
routeBreakdown: rankedBreakdown(routeCounts, packetCount),
topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5),
hopProfile: rankedBreakdown(hopCounts, packetCount),
hopByteWidthProfile: rankedBreakdown(hopByteWidthCounts, packetCount),
strongestNeighbors,
mostActiveNeighbors,
newestNeighbors,