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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user