forked from iarv/Remote-Terminal-for-MeshCore
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
|
|
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types';
|
|
import { hashString } from './contactAvatar';
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export type NodeType = 'self' | 'repeater' | 'client';
|
|
type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?';
|
|
|
|
export interface Particle {
|
|
linkKey: string;
|
|
progress: number;
|
|
speed: number;
|
|
color: string;
|
|
label: PacketLabel;
|
|
fromNodeId: string;
|
|
toNodeId: string;
|
|
}
|
|
|
|
interface ObservedPath {
|
|
nodes: string[];
|
|
snr: number | null;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface PendingPacket {
|
|
key: string;
|
|
label: PacketLabel;
|
|
paths: ObservedPath[];
|
|
firstSeen: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
export interface ParsedPacket {
|
|
payloadType: number;
|
|
messageHash: string | null;
|
|
pathBytes: string[];
|
|
srcHash: string | null;
|
|
dstHash: string | null;
|
|
advertPubkey: string | null;
|
|
groupTextSender: string | null;
|
|
anonRequestPubkey: string | null;
|
|
}
|
|
|
|
// Traffic pattern tracking for smarter repeater disambiguation
|
|
interface TrafficObservation {
|
|
source: string; // Node that originated traffic (could be resolved node ID or ambiguous)
|
|
nextHop: string | null; // Next hop after this repeater (null if final hop before self)
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface RepeaterTrafficData {
|
|
hopKey: string; // The observed hop token (e.g. "32", "aa11", or "bbccdd")
|
|
observations: TrafficObservation[];
|
|
}
|
|
|
|
// Analysis result for whether to split an ambiguous repeater
|
|
interface RepeaterSplitAnalysis {
|
|
shouldSplit: boolean;
|
|
// If shouldSplit, maps nextHop -> the sources that exclusively route through it
|
|
disjointGroups: Map<string, Set<string>> | null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
export const COLORS = {
|
|
background: '#0a0a0a',
|
|
link: '#4b5563',
|
|
ambiguous: '#9ca3af',
|
|
particleAD: '#f59e0b', // amber - advertisements
|
|
particleGT: '#06b6d4', // cyan - group text
|
|
particleDM: '#8b5cf6', // purple - direct messages
|
|
particleACK: '#22c55e', // green - acknowledgments
|
|
particleTR: '#f97316', // orange - trace packets
|
|
particleRQ: '#ec4899', // pink - requests
|
|
particleRS: '#14b8a6', // teal - responses
|
|
particleUnknown: '#6b7280', // gray - unknown
|
|
} as const;
|
|
|
|
export const PARTICLE_COLOR_MAP: Record<PacketLabel, string> = {
|
|
AD: COLORS.particleAD,
|
|
GT: COLORS.particleGT,
|
|
DM: COLORS.particleDM,
|
|
ACK: COLORS.particleACK,
|
|
TR: COLORS.particleTR,
|
|
RQ: COLORS.particleRQ,
|
|
RS: COLORS.particleRS,
|
|
'?': COLORS.particleUnknown,
|
|
};
|
|
|
|
export const PARTICLE_SPEED = 0.008;
|
|
// Traffic pattern analysis thresholds
|
|
// Be conservative - once split, we can't unsplit, so require strong evidence
|
|
const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group
|
|
const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory
|
|
const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned
|
|
|
|
export const PACKET_LEGEND_ITEMS = [
|
|
{ label: 'AD', color: COLORS.particleAD, description: 'Advertisement' },
|
|
{ label: 'GT', color: COLORS.particleGT, description: 'Group Text' },
|
|
{ label: 'DM', color: COLORS.particleDM, description: 'Direct Message' },
|
|
{ label: 'ACK', color: COLORS.particleACK, description: 'Acknowledgment' },
|
|
{ label: 'TR', color: COLORS.particleTR, description: 'Trace' },
|
|
{ label: 'RQ', color: COLORS.particleRQ, description: 'Request' },
|
|
{ label: 'RS', color: COLORS.particleRS, description: 'Response' },
|
|
{ label: '?', color: COLORS.particleUnknown, description: 'Other' },
|
|
] as const;
|
|
|
|
export interface PathStep {
|
|
nodeId: string | null;
|
|
markHiddenLinkWhenOmitted?: boolean;
|
|
hiddenLabel?: string | null;
|
|
}
|
|
|
|
export function normalizeHopToken(hop: string | null | undefined): string | null {
|
|
const normalized = hop?.trim().toLowerCase() ?? '';
|
|
return normalized.length > 0 ? normalized : null;
|
|
}
|
|
|
|
export function buildAmbiguousRepeaterNodeId(hop: string, nextHop?: string | null): string {
|
|
const hopKey = normalizeHopToken(hop);
|
|
if (!hopKey) {
|
|
return '?';
|
|
}
|
|
|
|
const nextHopKey = normalizeHopToken(nextHop);
|
|
return nextHopKey ? `?${hopKey}:>${nextHopKey}` : `?${hopKey}`;
|
|
}
|
|
|
|
export function buildAmbiguousRepeaterLabel(hop: string, nextHop?: string | null): string {
|
|
const hopKey = normalizeHopToken(hop)?.toUpperCase();
|
|
if (!hopKey) {
|
|
return '?';
|
|
}
|
|
|
|
const nextHopKey = normalizeHopToken(nextHop)?.toUpperCase();
|
|
return nextHopKey ? `${hopKey}:>${nextHopKey}` : hopKey;
|
|
}
|
|
|
|
// =============================================================================
|
|
// UTILITY FUNCTIONS (Data Layer)
|
|
// =============================================================================
|
|
|
|
export function parsePacket(hexData: string): ParsedPacket | null {
|
|
try {
|
|
const decoded = MeshCoreDecoder.decode(hexData);
|
|
if (!decoded.isValid) return null;
|
|
const tracePayload =
|
|
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
|
? (decoded.payload.decoded as { pathHashes?: string[] })
|
|
: null;
|
|
|
|
const result: ParsedPacket = {
|
|
payloadType: decoded.payloadType,
|
|
messageHash: decoded.messageHash || null,
|
|
// TRACE reuses the outer packet path field for SNR samples, not hop identities.
|
|
// For visualization, use the trace payload's actual node hashes instead.
|
|
pathBytes: tracePayload?.pathHashes || decoded.path || [],
|
|
srcHash: null,
|
|
dstHash: null,
|
|
advertPubkey: null,
|
|
groupTextSender: null,
|
|
anonRequestPubkey: null,
|
|
};
|
|
|
|
if (decoded.payloadType === PayloadType.TextMessage && decoded.payload.decoded) {
|
|
const payload = decoded.payload.decoded as { sourceHash?: string; destinationHash?: string };
|
|
result.srcHash = payload.sourceHash || null;
|
|
result.dstHash = payload.destinationHash || null;
|
|
} else if (decoded.payloadType === PayloadType.Advert && decoded.payload.decoded) {
|
|
result.advertPubkey = (decoded.payload.decoded as { publicKey?: string }).publicKey || null;
|
|
} else if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
|
|
const payload = decoded.payload.decoded as { decrypted?: { sender?: string } };
|
|
result.groupTextSender = payload.decrypted?.sender || null;
|
|
} else if (decoded.payloadType === PayloadType.AnonRequest && decoded.payload.decoded) {
|
|
const payload = decoded.payload.decoded as { senderPublicKey?: string };
|
|
result.anonRequestPubkey = payload.senderPublicKey || null;
|
|
}
|
|
|
|
return result;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getPacketLabel(payloadType: number): PacketLabel {
|
|
switch (payloadType) {
|
|
case PayloadType.Advert:
|
|
return 'AD';
|
|
case PayloadType.GroupText:
|
|
return 'GT';
|
|
case PayloadType.TextMessage:
|
|
return 'DM';
|
|
case PayloadType.Ack:
|
|
return 'ACK';
|
|
case PayloadType.Trace:
|
|
return 'TR';
|
|
case PayloadType.Request:
|
|
case PayloadType.AnonRequest:
|
|
return 'RQ';
|
|
case PayloadType.Response:
|
|
return 'RS';
|
|
default:
|
|
return '?';
|
|
}
|
|
}
|
|
|
|
export function generatePacketKey(parsed: ParsedPacket, rawPacket: RawPacket): string {
|
|
const contentHash = (
|
|
parsed.messageHash || hashString(rawPacket.data).toString(16).padStart(8, '0')
|
|
).slice(0, 8);
|
|
|
|
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
|
return `ad:${parsed.advertPubkey.slice(0, 12)}`;
|
|
}
|
|
if (parsed.payloadType === PayloadType.GroupText) {
|
|
const sender = parsed.groupTextSender || rawPacket.decrypted_info?.sender || '?';
|
|
const channel = rawPacket.decrypted_info?.channel_name || '?';
|
|
return `gt:${channel}:${sender}:${contentHash}`;
|
|
}
|
|
if (parsed.payloadType === PayloadType.TextMessage) {
|
|
return `dm:${parsed.srcHash || '?'}:${parsed.dstHash || '?'}:${contentHash}`;
|
|
}
|
|
if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
|
return `rq:${parsed.anonRequestPubkey.slice(0, 12)}:${contentHash}`;
|
|
}
|
|
return `other:${contentHash}`;
|
|
}
|
|
|
|
export function getLinkId<
|
|
T extends { source: string | { id: string }; target: string | { id: string } },
|
|
>(link: T): { sourceId: string; targetId: string } {
|
|
return {
|
|
sourceId: typeof link.source === 'string' ? link.source : link.source.id,
|
|
targetId: typeof link.target === 'string' ? link.target : link.target.id,
|
|
};
|
|
}
|
|
|
|
export function buildLinkKey(sourceId: string, targetId: string): string {
|
|
return [sourceId, targetId].sort().join('->');
|
|
}
|
|
|
|
export function getNodeType(contact: Contact | null | undefined): NodeType {
|
|
return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client';
|
|
}
|
|
|
|
export function dedupeConsecutive<T>(arr: T[]): T[] {
|
|
return arr.filter((item, i) => i === 0 || item !== arr[i - 1]);
|
|
}
|
|
|
|
export function compactPathSteps(steps: PathStep[]): {
|
|
nodes: string[];
|
|
dashedLinkDetails: Map<string, string[]>;
|
|
} {
|
|
const nodes: string[] = [];
|
|
const dashedLinkDetails = new Map<string, string[]>();
|
|
let pendingHiddenLink = false;
|
|
let pendingHiddenLabels: string[] = [];
|
|
|
|
for (const step of steps) {
|
|
if (step.nodeId) {
|
|
const previousNodeId = nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
|
if (previousNodeId && pendingHiddenLink && previousNodeId !== step.nodeId) {
|
|
const key = buildLinkKey(previousNodeId, step.nodeId);
|
|
const existing = dashedLinkDetails.get(key) ?? [];
|
|
for (const label of pendingHiddenLabels) {
|
|
if (!existing.includes(label)) {
|
|
existing.push(label);
|
|
}
|
|
}
|
|
dashedLinkDetails.set(key, existing);
|
|
}
|
|
if (previousNodeId !== step.nodeId) {
|
|
nodes.push(step.nodeId);
|
|
}
|
|
pendingHiddenLink = false;
|
|
pendingHiddenLabels = [];
|
|
continue;
|
|
}
|
|
|
|
if (step.markHiddenLinkWhenOmitted && nodes.length > 0) {
|
|
pendingHiddenLink = true;
|
|
if (step.hiddenLabel && !pendingHiddenLabels.includes(step.hiddenLabel)) {
|
|
pendingHiddenLabels.push(step.hiddenLabel);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { nodes, dashedLinkDetails };
|
|
}
|
|
|
|
/**
|
|
* Analyze traffic patterns for an ambiguous repeater prefix to determine if it
|
|
* should be split into multiple nodes.
|
|
*/
|
|
export function analyzeRepeaterTraffic(data: RepeaterTrafficData): RepeaterSplitAnalysis {
|
|
const now = Date.now();
|
|
|
|
// Filter out old observations
|
|
const recentObservations = data.observations.filter(
|
|
(obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS
|
|
);
|
|
|
|
// Group by nextHop (use "self" for null nextHop - final repeater)
|
|
const byNextHop = new Map<string, Set<string>>();
|
|
for (const obs of recentObservations) {
|
|
const hopKey = obs.nextHop ?? 'self';
|
|
if (!byNextHop.has(hopKey)) {
|
|
byNextHop.set(hopKey, new Set());
|
|
}
|
|
byNextHop.get(hopKey)!.add(obs.source);
|
|
}
|
|
|
|
// If only one nextHop group, no need to split
|
|
if (byNextHop.size <= 1) {
|
|
return { shouldSplit: false, disjointGroups: null };
|
|
}
|
|
|
|
// Check if any source appears in multiple groups (evidence of hub behavior)
|
|
const allSources = new Map<string, string[]>(); // source -> list of nextHops it uses
|
|
for (const [nextHop, sources] of byNextHop) {
|
|
for (const source of sources) {
|
|
if (!allSources.has(source)) {
|
|
allSources.set(source, []);
|
|
}
|
|
allSources.get(source)!.push(nextHop);
|
|
}
|
|
}
|
|
|
|
// If any source routes to multiple nextHops, this is a hub - don't split
|
|
for (const [, nextHops] of allSources) {
|
|
if (nextHops.length > 1) {
|
|
return { shouldSplit: false, disjointGroups: null };
|
|
}
|
|
}
|
|
|
|
// Check if we have enough observations in each group to be confident
|
|
for (const [, sources] of byNextHop) {
|
|
if (sources.size < MIN_OBSERVATIONS_TO_SPLIT) {
|
|
// Not enough evidence yet - be conservative, don't split
|
|
return { shouldSplit: false, disjointGroups: null };
|
|
}
|
|
}
|
|
|
|
// Source sets are disjoint and we have enough data - split!
|
|
return { shouldSplit: true, disjointGroups: byNextHop };
|
|
}
|
|
|
|
/**
|
|
* Record a traffic observation for an ambiguous repeater prefix.
|
|
* Prunes old observations and limits total count.
|
|
*/
|
|
export function recordTrafficObservation(
|
|
trafficData: Map<string, RepeaterTrafficData>,
|
|
hopKey: string,
|
|
source: string,
|
|
nextHop: string | null
|
|
): void {
|
|
const normalizedHopKey = normalizeHopToken(hopKey);
|
|
if (!normalizedHopKey) {
|
|
return;
|
|
}
|
|
|
|
const normalizedNextHop = normalizeHopToken(nextHop);
|
|
const now = Date.now();
|
|
|
|
if (!trafficData.has(normalizedHopKey)) {
|
|
trafficData.set(normalizedHopKey, { hopKey: normalizedHopKey, observations: [] });
|
|
}
|
|
|
|
const data = trafficData.get(normalizedHopKey)!;
|
|
|
|
// Add new observation
|
|
data.observations.push({ source, nextHop: normalizedNextHop, timestamp: now });
|
|
|
|
// Prune old observations
|
|
data.observations = data.observations.filter(
|
|
(obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS
|
|
);
|
|
|
|
// Limit total count
|
|
if (data.observations.length > MAX_TRAFFIC_OBSERVATIONS) {
|
|
data.observations = data.observations.slice(-MAX_TRAFFIC_OBSERVATIONS);
|
|
}
|
|
}
|