feat(core): traceroute and tcpdump after Java, measured against Java

TerminalRun adds nine stories: traceroute in the LAN, through the router,
unreachable, without a route, to no one, to the host itself, errors and
Ctrl+C; tcpdump on two hosts. traceroute counts failures across hops as
Java does and takes an error that carries its probe; tcpdump tells each
frame's highest capture row with its time of day, at once.

Departures from Java: traceroute tells its summary before the end mark
(Java after it, where its window shows no text) and calls a host at the
20th hop reached. arpsend is not built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 10:46:18 +00:00
co-authored by Claude Opus 5.5
parent 495c449ef2
commit a867c33d64
16 changed files with 11983 additions and 16 deletions
+140 -4
View File
@@ -2,7 +2,7 @@
// handling of GUIApplicationTerminalWindow): a line's first word is the
// command, the others its parameters; the program tells its window texts,
// in the project's language, as Java writes them, and marks a command that
// runs on (ping, host, nslookup) busy and done. The files are the node's
// runs on (ping, host, nslookup, traceroute, tcpdump) busy and done. The files are the node's
// (the snapshot's file system, the same the project keeps); the network
// commands ask the engine (ARP table, sockets, DNS, ICMP echo).
//
@@ -13,7 +13,12 @@
// - arp and netstat list their rows in the order learned or made (Java: a
// HashMap's order); netstat lists a UDP server's listening socket only
// (Java also a socket for each address and port it ever answered, never
// closed).
// closed);
// - traceroute tells its summary before the end mark (Java after it, where
// its window shows no more text), and a host reached at the 20th hop is
// reached (Java: not reached);
// - tcpdump tells each frame at once (Java looks every 100 ms).
// arpsend is not built: the command is unknown here.
//
// Departures from Java on the files (terminal.test.ts names each):
// - a text file's size is its length in characters (Java decodes the text as
@@ -37,9 +42,10 @@ import type { Id } from '../../ids.js';
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
import { javaIpCheck } from '../../util/javaIp.js';
import { isBroadcastFrom, type NetworkRuntimeEngine, type SocketRecord } from '../engine.js';
import { captureCommentText, captureRows, ICMP_TYPE_CODE, type CaptureCommentWords } from '../captureRows.js';
import { isBroadcastFrom, type IcmpObservation, type NetworkRuntimeEngine, type SocketRecord } from '../engine.js';
import { javaTrim } from '../http.js';
import { pause, type Steps } from '../threads.js';
import { pause, waitUntil, type Steps } from '../threads.js';
import { javaBase64 } from '../../util/javaBase64.js';
import { CommandThread, ProgramLog } from './program.js';
import type { PageLanguage } from './routerPages.js';
@@ -122,11 +128,39 @@ const COMMANDS = new Set([
'pwd',
'rm',
'route',
'tcpdump',
'test',
'touch',
'traceroute',
'type',
]);
/** Terminal.traceroute: at most this many hops. */
const TRACEROUTE_MAXIMUM_HOPS = 20;
/** Terminal.traceroute: the first probe's number less one (42 * 23). */
const TRACEROUTE_FIRST_SEQUENCE_NUMBER = 42 * 23;
/** Lauscher's words for the capture's comments (rp_lauscher_msg12–14), and its numbers: the locale's grouping. */
const CAPTURE_WORDS: Record<PageLanguage, CaptureCommentWords> = {
de: { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' },
en: { protocol: 'Protocol', searchForMac: 'Search for MAC', macIs: 'MAC is' },
};
const CAPTURE_GROUPING: Record<PageLanguage, string> = { de: '.', en: ',' };
/** Lauscher.formatTimestamp: the time of day, HH:mm:ss.SSS. */
function timeOfDay(milliseconds: number): string {
const time = new Date(milliseconds);
const two = (value: number) => String(value).padStart(2, '0');
return `${two(time.getHours())}:${two(time.getMinutes())}:${two(time.getSeconds())}.${String(time.getMilliseconds()).padStart(3, '0')}`;
}
/** String.format with %s only: each in turn takes the next value. */
function formatted(template: string, ...values: readonly (string | number)[]): string {
let index = 0;
return template.replace(/%s/g, () => String(values[index++]));
}
/** Information.getPingRepeat: the echoes a ping sends. */
const PING_REPEAT = 4;
@@ -200,6 +234,7 @@ export class Terminal {
/** The window's Ctrl+C (Terminal.setInterrupt). */
interrupt(): void {
this.interrupted = true;
this.engine.resumeThreads();
}
private tell(text: string): void {
@@ -236,6 +271,8 @@ export class Terminal {
host: (each) => this.host(each),
nslookup: (each) => this.nslookup(each),
ping: (each) => this.ping(each),
tcpdump: () => this.tcpdump(),
traceroute: (each) => this.traceroute(each),
};
if (Object.hasOwn(network, command)) {
yield* network[command]!(parameters);
@@ -586,4 +623,103 @@ export class Terminal {
}
this.tell(text);
}
/**
* Terminal.traceroute: echo requests with TTL 1, 2, … (three tries a hop),
* each waiting for the first ICMP message back; a hop answers with time
* exceeded, the host with its echo reply, a router that cannot go on with
* unreachable. The summary comes before the end mark (Java's after it,
* where its window no longer shows text).
*/
private *traceroute(parameters: readonly string[]): Steps {
if (parameters.length !== 1) return this.usage('traceroute');
const target = parameters[0]!;
let address = javaIpCheck(target) ?? undefined;
if (address === undefined) {
const resolution = yield* this.engine.resolveDnsAddressSteps(this.nodeId, target);
if (resolution.type === 'timedOut') return this.tell(this.text('sw_terminal_trace_msg2'));
address = resolution.address;
}
if (address === undefined) return this.tell(this.text('sw_terminal_trace_msg3'));
this.busy(true);
this.tell(
address === target
? `${formatted(this.text('sw_terminal_trace_msg4'), target, TRACEROUTE_MAXIMUM_HOPS)}\n`
: `${formatted(this.text('sw_terminal_trace_msg5'), target, address, TRACEROUTE_MAXIMUM_HOPS)}\n`,
);
const identifier = this.nextIdentifier();
let sequenceNumber = TRACEROUTE_FIRST_SEQUENCE_NUMBER;
let failures = 0;
let response: IcmpObservation | undefined;
let hops = 1;
// An answer to the probe: its echo reply, or an error that carries it (Java asks the error for identifier and
// number 0 too, which every error has, RFC 792).
const answers = (observation: IcmpObservation | undefined) => {
const message = observation?.message;
if (message === undefined) return false;
if (message.sequenceNumber === sequenceNumber && message.identifier === identifier) return true;
const carried = message.embeddedOriginalPacket?.payload;
return carried?.kind === 'icmp' && carried.message.sequenceNumber === sequenceNumber && carried.message.identifier === identifier;
};
for (; hops <= TRACEROUTE_MAXIMUM_HOPS && !this.interrupted; hops += 1) {
this.tell(` ${hops} `);
for (let attempt = 0; attempt < 3 && !this.interrupted; attempt += 1) {
sequenceNumber += 1;
response = yield* this.engine.icmpEchoSteps(this.nodeId, address, { identifier, sequenceNumber, timeToLive: hops });
if (answers(response)) {
failures = 0;
break;
}
failures += 1;
this.tell('* ');
}
if (failures === 0) {
this.tell(response!.packet.senderIPAddress);
if (response!.message.kind !== 'timeExceeded') break;
} else if (failures > 5) {
break;
}
this.tell('\n');
}
const [type, code] = response === undefined ? [undefined, undefined] : ICMP_TYPE_CODE[response.message.kind];
let summary: string;
if (hops > TRACEROUTE_MAXIMUM_HOPS) summary = formatted(this.text('sw_terminal_trace_msg6'), target, TRACEROUTE_MAXIMUM_HOPS);
else if (this.interrupted) summary = this.text('sw_terminal_trace_msg7');
else if (type === 3)
summary = formatted(this.text(code === 0 ? 'sw_terminal_trace_msg8' : code === 1 ? 'sw_terminal_trace_msg9' : 'sw_terminal_trace_msg10'), response!.packet.senderIPAddress);
else if (failures === 0) summary = hops === 1 ? formatted(this.text('sw_terminal_trace_msg11'), target) : formatted(this.text('sw_terminal_trace_msg12'), target, hops);
else summary = this.text('sw_terminal_trace_msg13');
this.tell(`\n\n${summary}`);
this.busy(false);
}
/**
* Terminal.tcpdump: each frame the node's first card logs from now on, as
* the capture's highest row (time, protocol, source > destination,
* comment), until Ctrl+C. Each frame at once (Java looks every 100 ms).
*/
private *tcpdump(): Steps {
this.busy(true);
this.tell(this.text('sw_terminal_msg55'));
const card = this.engine.networkInterfaces(this.nodeId)[0];
const frames = () => (card === undefined ? [] : this.engine.capture.frames(card.portId));
const start = this.engine.state.currentTimeMilliseconds;
let shown = frames().findLast((frame) => frame.timeMilliseconds < start)?.number ?? 0;
const grouping = CAPTURE_GROUPING[this.language];
const number = (value: number) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, grouping);
while (!this.interrupted) {
yield* waitUntil(() => this.interrupted || (frames().at(-1)?.number ?? 0) > shown || this.engine.state.phase !== 'running');
if (this.engine.state.phase !== 'running') return;
for (const frame of frames().filter((each) => each.number > shown)) {
const row = captureRows([frame], { withAddresses: true }).at(-1)!;
const time = timeOfDay(this.engine.wallTime(frame.timeMilliseconds));
const text = `${time} ${row.protocol} ${row.source} > ${row.destination} ${captureCommentText(row.comment, CAPTURE_WORDS[this.language], number)}`;
this.tell(`\n ${javaTrim(text).replaceAll('\n', ' ')}`);
shown = frame.number;
}
}
this.busy(false);
}
}