mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-04 08:52:03 +02:00
Add packet feed clickable packet inspection. Closes #75 again.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user