feat(core): the packet capture's table after Java's Lauscher, measured against Java's own tables
captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten): Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol recognition; with addresses and ports or ports only. The comment stays structured; captureCommentText says it with given words and numbers. ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten, German locale); all runtime runs are recorded again, NatRun has ttlOut. expectSameCaptures compares both views for every frame. Fixed where the engine differed: an ARP request's target MAC is the broadcast address, an ICMP error's identifier and sequence number are 0 (RFC 792), and host unreachable from a router carries the packet as forwarded. Not taken: Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo 'Exeeded'. RIP keeps its recordings: Java's staggered router start now gives a different startup on this machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// The packet capture's table as Java FILIUS shows it
|
||||
// (filius.rahmenprogramm.nachrichten.Lauscher.datenVorbereiten): per frame a
|
||||
// row for each layer it carries — Ethernet; then ARP, ICMP or IP; for IP the
|
||||
// TCP or UDP segment; and its data, when there are any. Seven columns: number,
|
||||
// time, source, destination, protocol, layer, comment.
|
||||
//
|
||||
// The comment stays structured, so that the editor can say it in the
|
||||
// viewer's language and format its numbers (Java: the words of its message
|
||||
// bundle, the numbers with the locale's grouping); captureCommentText says it
|
||||
// with given words, as Java does. The time is the frame's simulation time;
|
||||
// Java shows its wall clock.
|
||||
//
|
||||
// Departures from Java: "ICMP Time Exceeded (poof)", where Java misspells
|
||||
// "Exeeded"; an IP packet carries only TCP, UDP or ICMP here, so Java's row
|
||||
// for an unknown transport protocol (all empty) does not occur.
|
||||
|
||||
import type { CapturedFrame } from './capture.js';
|
||||
import { IPV4_PROTOCOL, TCP_FLAG, type ArpPacket, type IcmpMessage, type IPv4Packet } from './packets.js';
|
||||
|
||||
/** Java's four layers (Lauscher.PROTOKOLL_SCHICHTEN): network access, internet, transport, application. */
|
||||
export type CaptureLayer = 'networkAccess' | 'internet' | 'transport' | 'application';
|
||||
|
||||
export type CaptureComment =
|
||||
/** The Ethernet frame's type (EthernetFrame.IP, .ARP). */
|
||||
| { readonly kind: 'ethernetType'; readonly type: '0x800' | '0x806' }
|
||||
/** "Protocol: 17, TTL: 64". */
|
||||
| { readonly kind: 'ipProtocol'; readonly protocol: number; readonly timeToLive: number }
|
||||
/** "Search for MAC <target IP>" or "MAC is <sender MAC>", then the packet. */
|
||||
| { readonly kind: 'arp'; readonly operation: 'request' | 'reply'; readonly address: string; readonly packet: string }
|
||||
/** "SYN" or "FIN", then "SEQ: …" and, with the ACK flag, "ACK: …". */
|
||||
| { readonly kind: 'tcp'; readonly flag: 'SYN' | 'FIN' | undefined; readonly sequence: number; readonly acknowledgement: number | undefined }
|
||||
| { readonly kind: 'udp' }
|
||||
/** The ICMP message, its TTL, identifier, sequence number and the packet it carries. */
|
||||
| {
|
||||
readonly kind: 'icmp';
|
||||
readonly message: string;
|
||||
readonly timeToLive: number;
|
||||
readonly identifier: number;
|
||||
readonly sequenceNumber: number;
|
||||
readonly payload: string | undefined;
|
||||
}
|
||||
/** A segment's data, as text. */
|
||||
| { readonly kind: 'data'; readonly text: string };
|
||||
|
||||
export interface CaptureRow {
|
||||
readonly number: number;
|
||||
readonly timeMilliseconds: number;
|
||||
/** Null where Java leaves the cell empty (the data row without addresses). */
|
||||
readonly source: string | null;
|
||||
readonly destination: string | null;
|
||||
/** "" for Ethernet (Lauscher.ETHERNET) and data Java does not recognise. */
|
||||
readonly protocol: string;
|
||||
readonly layer: CaptureLayer;
|
||||
readonly comment: CaptureComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of a card's frames. `withAddresses`: a segment's ends as
|
||||
* address:port, also on its data row (the data exchange window); else ports
|
||||
* only, and the data row without ends (the layered view).
|
||||
*/
|
||||
export function captureRows(frames: readonly CapturedFrame[], options: { withAddresses: boolean }): CaptureRow[] {
|
||||
const rows: CaptureRow[] = [];
|
||||
for (const { number, timeMilliseconds, frame } of frames) {
|
||||
const row = (source: string | null, destination: string | null, protocol: string, layer: CaptureLayer, comment: CaptureComment) =>
|
||||
rows.push({ number, timeMilliseconds, source, destination, protocol, layer, comment });
|
||||
const payload = frame.payload;
|
||||
row(frame.sourceMacAddress, frame.destinationMacAddress, '', 'networkAccess', { kind: 'ethernetType', type: payload.kind === 'arp' ? '0x806' : '0x800' });
|
||||
if (payload.kind === 'arp') {
|
||||
const arp = payload.packet;
|
||||
row(arp.senderIPAddress, arp.targetIPAddress, 'ARP', 'internet', {
|
||||
kind: 'arp',
|
||||
operation: arp.operation,
|
||||
address: arp.operation === 'request' ? arp.targetIPAddress : arp.senderMacAddress,
|
||||
packet: arpText(arp),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const packet = payload.packet;
|
||||
if (packet.payload.kind === 'icmp') {
|
||||
const message = packet.payload.message;
|
||||
row(packet.senderIPAddress, packet.receiverIPAddress, 'ICMP', 'internet', {
|
||||
kind: 'icmp',
|
||||
message: ICMP_MESSAGES[message.kind],
|
||||
timeToLive: packet.timeToLive,
|
||||
identifier: message.identifier,
|
||||
sequenceNumber: message.sequenceNumber,
|
||||
payload: message.embeddedOriginalPacket === undefined ? undefined : packetText(message.embeddedOriginalPacket),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
row(packet.senderIPAddress, packet.receiverIPAddress, 'IP', 'internet', { kind: 'ipProtocol', protocol: IPV4_PROTOCOL[packet.protocol], timeToLive: packet.timeToLive });
|
||||
const segment = packet.payload.kind === 'tcp' ? packet.payload.segment : packet.payload.datagram;
|
||||
const end = (address: string, port: number) => (options.withAddresses ? `${address}:${port}` : String(port));
|
||||
const source = end(packet.senderIPAddress, segment.sourcePort);
|
||||
const destination = end(packet.receiverIPAddress, segment.destinationPort);
|
||||
if (packet.payload.kind === 'tcp') {
|
||||
const tcp = packet.payload.segment;
|
||||
const flag = (tcp.flags & TCP_FLAG.synchronize) !== 0 ? 'SYN' : (tcp.flags & TCP_FLAG.finish) !== 0 ? 'FIN' : undefined;
|
||||
const acknowledgement = (tcp.flags & TCP_FLAG.acknowledgement) !== 0 ? tcp.acknowledgementNumber : undefined;
|
||||
row(source, destination, 'TCP', 'transport', { kind: 'tcp', flag, sequence: tcp.sequenceNumber, acknowledgement });
|
||||
} else {
|
||||
row(source, destination, 'UDP', 'transport', { kind: 'udp' });
|
||||
}
|
||||
const text = new TextDecoder().decode(segment.payload);
|
||||
if (javaTrim(text) === '') continue;
|
||||
const protocol = applicationProtocol(text, segment.sourcePort, segment.destinationPort);
|
||||
row(options.withAddresses ? source : null, options.withAddresses ? destination : null, protocol, 'application', { kind: 'data', text });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The words Java's message bundle has for the comments (rp_lauscher_msg12–14). */
|
||||
export interface CaptureCommentWords {
|
||||
/** rp_lauscher_msg12 (English bundle: "Protocol") */
|
||||
readonly protocol: string;
|
||||
/** rp_lauscher_msg13 (English bundle: "Search for MAC") */
|
||||
readonly searchForMac: string;
|
||||
/** rp_lauscher_msg14 (English bundle: "MAC is") */
|
||||
readonly macIs: string;
|
||||
}
|
||||
|
||||
/** The comment as Java writes it, with these words and this number format (Java: the locale's grouping). */
|
||||
export function captureCommentText(comment: CaptureComment, words: CaptureCommentWords, formatNumber: (value: number) => string): string {
|
||||
switch (comment.kind) {
|
||||
case 'ethernetType':
|
||||
return comment.type;
|
||||
case 'ipProtocol':
|
||||
return `${words.protocol}: ${comment.protocol}, TTL: ${comment.timeToLive}`;
|
||||
case 'arp':
|
||||
return `${comment.operation === 'request' ? words.searchForMac : words.macIs} ${comment.address} ${comment.packet}`;
|
||||
case 'tcp': {
|
||||
const sequence = `SEQ: ${formatNumber(comment.sequence)}`;
|
||||
const acknowledgement = comment.acknowledgement === undefined ? '' : `, ACK: ${formatNumber(comment.acknowledgement)}`;
|
||||
return `${comment.flag === undefined ? '' : `${comment.flag}, `}${sequence}${acknowledgement}`;
|
||||
}
|
||||
case 'udp':
|
||||
return '';
|
||||
case 'icmp':
|
||||
return (
|
||||
`${comment.message}, TTL: ${comment.timeToLive}, Identifier: ${comment.identifier}, Seq.-No.: ${comment.sequenceNumber}` +
|
||||
(comment.payload === undefined ? '' : `, Payload: ${comment.payload}`)
|
||||
);
|
||||
case 'data':
|
||||
return comment.text;
|
||||
}
|
||||
}
|
||||
|
||||
const ICMP_MESSAGES: Record<IcmpMessage['kind'], string> = {
|
||||
echoReply: 'ICMP Echo Reply (pong)',
|
||||
destinationNetworkUnreachable: 'ICMP Network Unreachable',
|
||||
destinationHostUnreachable: 'ICMP Host Unreachable',
|
||||
echoRequest: 'ICMP Echo Request (ping)',
|
||||
timeExceeded: 'ICMP Time Exceeded (poof)',
|
||||
};
|
||||
|
||||
const ICMP_TYPE_CODE: Record<IcmpMessage['kind'], [number, number]> = {
|
||||
echoReply: [0, 0],
|
||||
destinationNetworkUnreachable: [3, 0],
|
||||
destinationHostUnreachable: [3, 1],
|
||||
echoRequest: [8, 0],
|
||||
timeExceeded: [11, 0],
|
||||
};
|
||||
|
||||
/** ArpPaket.toString. */
|
||||
function arpText(arp: ArpPacket): string {
|
||||
return `[op=${arp.operation === 'request' ? 'REQUEST' : 'REPLY'}, sender=${arp.senderMacAddress}|${arp.senderIPAddress}, target=${arp.targetMacAddress}|${arp.targetIPAddress}]`;
|
||||
}
|
||||
|
||||
/** IpPaket.toString, IcmpPaket.toString for an ICMP packet: the packet an ICMP error carries. */
|
||||
function packetText(packet: IPv4Packet): string {
|
||||
if (packet.payload.kind !== 'icmp') {
|
||||
return `[id=${packet.identity}, ttl=${packet.timeToLive}, protocol=${IPV4_PROTOCOL[packet.protocol]}, dest=${packet.receiverIPAddress}, src=${packet.senderIPAddress}]`;
|
||||
}
|
||||
const message = packet.payload.message;
|
||||
const [type, code] = ICMP_TYPE_CODE[message.kind];
|
||||
const carried = message.embeddedOriginalPacket === undefined ? '' : `, payload:${packetText(message.embeddedOriginalPacket)}`;
|
||||
return (
|
||||
`[id=${packet.identity}; ttl=${packet.timeToLive}; protocol=1; sender=${packet.senderIPAddress}; target=${packet.receiverIPAddress}; ` +
|
||||
`icmpType=${type}; icmpCode=${code}; identifier=${message.identifier}; seqNr=${message.sequenceNumber}${carried}]`
|
||||
);
|
||||
}
|
||||
|
||||
/** Java's String.trim: cuts characters up to U+0020 at both ends. */
|
||||
function javaTrim(text: string): string {
|
||||
let start = 0;
|
||||
let end = text.length;
|
||||
while (start < end && text.charCodeAt(start) <= 0x20) start += 1;
|
||||
while (end > start && text.charCodeAt(end - 1) <= 0x20) end -= 1;
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
function startsWithIgnoringCase(text: string, prefix: string): boolean {
|
||||
return text.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase();
|
||||
}
|
||||
|
||||
const POP3_COMMANDS = ['USER ', 'PASS ', 'STAT ', 'LIST ', 'RETR ', 'DELE ', 'NOOP', 'RSET', 'QUIT', 'TOP'];
|
||||
|
||||
/** Lauscher.classifyApplicationLayerProtocol: the protocol a segment's data belong to, "" when Java does not know. */
|
||||
function applicationProtocol(text: string, sourcePort: number, destinationPort: number): string {
|
||||
if (['//0x00//', '//0x01//', '//0x80//', '//0x81//', '//0x40//'].some((marker) => text.includes(marker))) return 'GNT';
|
||||
if (
|
||||
(sourcePort === 110 && (startsWithIgnoringCase(text, '+OK') || startsWithIgnoringCase(text, '-ERR'))) ||
|
||||
(destinationPort === 110 && POP3_COMMANDS.some((command) => startsWithIgnoringCase(text, command)))
|
||||
) {
|
||||
return 'POP3';
|
||||
}
|
||||
if ((sourcePort === 25 && ['1', '2', '3', '4', '5'].some((digit) => text.startsWith(digit))) || destinationPort === 25) return 'SMTP';
|
||||
if (text.includes('ID=') && text.includes('QR=') && text.includes('RCODE=')) return 'DNS';
|
||||
if (text.includes('DHCP')) return 'DHCP';
|
||||
if ((sourcePort === 521 && destinationPort === 520) || (sourcePort === 520 && destinationPort === 521)) return 'RIP';
|
||||
if ((destinationPort === 80 && text.toLowerCase().includes('http/')) || sourcePort === 80) return 'HTTP';
|
||||
return '';
|
||||
}
|
||||
@@ -3716,13 +3716,13 @@ export class NetworkRuntimeEngine {
|
||||
return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress;
|
||||
}
|
||||
|
||||
/** One ARP request for `targetIPAddress`, broadcast from the interface. */
|
||||
/** One ARP request for `targetIPAddress`, broadcast from the interface; its target MAC the broadcast address, as Java's (ARP.sendeARPBroadcast; Swift, and swiftArp: zeros). */
|
||||
private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void {
|
||||
const request: ArpPacket = {
|
||||
operation: 'request',
|
||||
senderMacAddress: networkInterface.macAddress,
|
||||
senderIPAddress: networkInterface.ipAddress,
|
||||
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
||||
targetMacAddress: this.swiftArp ? UNSPECIFIED_MAC_ADDRESS : ETHERNET_BROADCAST_MAC_ADDRESS,
|
||||
targetIPAddress,
|
||||
};
|
||||
const frame: EthernetFrame = {
|
||||
@@ -4210,7 +4210,7 @@ export class NetworkRuntimeEngine {
|
||||
const answered = () =>
|
||||
(this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? [])
|
||||
.slice(observationCount)
|
||||
.some((observation) => observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber);
|
||||
.some((observation) => answersEcho(observation.message, identifier, sequenceNumber));
|
||||
let sent: IPv4DeliveryResult | undefined;
|
||||
let sentAt = this.stateValue.currentTimeMilliseconds;
|
||||
const handedOn = this.sendIPv4PacketWithOptions(fromNodeId, packet, sourceInterface.portId, true, 'sent', {
|
||||
@@ -4229,9 +4229,7 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
const result = sent ?? handedOn;
|
||||
const newObservations = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(observationCount);
|
||||
const observation = newObservations.findLast(
|
||||
(candidate) => candidate.message.identifier === identifier && candidate.message.sequenceNumber === sequenceNumber,
|
||||
);
|
||||
const observation = newObservations.findLast((candidate) => answersEcho(candidate.message, identifier, sequenceNumber));
|
||||
if (observation !== undefined) {
|
||||
switch (observation.message.kind) {
|
||||
case 'echoReply':
|
||||
@@ -4533,11 +4531,12 @@ export class NetworkRuntimeEngine {
|
||||
detail: 'TTL decremented',
|
||||
});
|
||||
// Java: a router whose ARP finds no one answers host unreachable; for
|
||||
// ICMP only an echo request (ICMP.sendUnicastToNextHop).
|
||||
// ICMP only an echo request (ICMP.sendUnicastToNextHop). The error carries
|
||||
// the packet as the router forwarded it, its TTL lowered (measured).
|
||||
const echoOrNotIcmp = packet.payload.kind !== 'icmp' || packet.payload.message.kind === 'echoRequest';
|
||||
const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded', {
|
||||
onUnresolved: () => {
|
||||
if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', packet);
|
||||
if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', forwarded);
|
||||
},
|
||||
onFinished: finished,
|
||||
});
|
||||
@@ -4646,11 +4645,8 @@ export class NetworkRuntimeEngine {
|
||||
protocol: 'icmp',
|
||||
payload: {
|
||||
kind: 'icmp',
|
||||
message: icmpMessage(kind, {
|
||||
identifier: icmpIdentifier(originalPacket),
|
||||
sequenceNumber: icmpSequenceNumber(originalPacket),
|
||||
embeddedOriginalPacket: embeddedOriginal(originalPacket),
|
||||
}),
|
||||
// Identifier and sequence number unused, 0 (RFC 792, Java's sendeICMP for errors).
|
||||
message: icmpMessage(kind, { embeddedOriginalPacket: embeddedOriginal(originalPacket) }),
|
||||
},
|
||||
};
|
||||
this.recordTrace({
|
||||
@@ -4672,11 +4668,7 @@ export class NetworkRuntimeEngine {
|
||||
kind: IcmpMessageKind,
|
||||
originalPacket: IPv4Packet,
|
||||
): void {
|
||||
const message = icmpMessage(kind, {
|
||||
identifier: icmpIdentifier(originalPacket),
|
||||
sequenceNumber: icmpSequenceNumber(originalPacket),
|
||||
embeddedOriginalPacket: embeddedOriginal(originalPacket),
|
||||
});
|
||||
const message = icmpMessage(kind, { embeddedOriginalPacket: embeddedOriginal(originalPacket) });
|
||||
const packet: IPv4Packet = {
|
||||
identity: this.allocatePacketIdentity(),
|
||||
senderIPAddress: this.networkInterfaces(nodeId)[0]?.ipAddress ?? UNSPECIFIED_IP_ADDRESS,
|
||||
@@ -5010,13 +5002,13 @@ function embeddedOriginal(packet: IPv4Packet): IPv4Packet {
|
||||
return packet;
|
||||
}
|
||||
|
||||
function icmpIdentifier(packet: IPv4Packet): number {
|
||||
return packet.payload.kind === 'icmp' ? packet.payload.message.identifier : 0;
|
||||
/** The echo reply to an echo request, or an error about it (it carries the request). */
|
||||
function answersEcho(message: IcmpMessage, identifier: number, sequenceNumber: number): boolean {
|
||||
if (message.kind === 'echoReply') return message.identifier === identifier && message.sequenceNumber === sequenceNumber;
|
||||
const carried = message.embeddedOriginalPacket?.payload;
|
||||
return carried?.kind === 'icmp' && carried.message.kind === 'echoRequest' && carried.message.identifier === identifier && carried.message.sequenceNumber === sequenceNumber;
|
||||
}
|
||||
|
||||
function icmpSequenceNumber(packet: IPv4Packet): number {
|
||||
return packet.payload.kind === 'icmp' ? packet.payload.message.sequenceNumber : 0;
|
||||
}
|
||||
|
||||
export function ethernetHeaders(frame: EthernetFrame): HeaderField[] {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user