Files
mdg-filius/packages/core/src/runtime/apps/terminal.ts
T
julian.kiedaischandClaude Opus 5.5 a867c33d64 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>
2026-10-02 10:46:18 +00:00

726 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FILIUS' command line (filius.software.lokal.Terminal, with the input
// 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, 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).
//
// Departures from Java on the network (terminalJava.test.ts names each):
// - ping names the host where Java names its first parameter (-b in the
// statistics of ping -b), and takes only -b before the host (Java takes
// any word, and pings a broadcast);
// - 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);
// - 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
// Base64 and counts the bytes: "Hallo Welt" is 6);
// - a folder that is not there is "not there" for ls (Java: "empty"), and
// ls / names the root (Java leaves the path out);
// - cd to a file is "not there" (Java makes the file the working directory);
// - touch and mkdir in a folder that is not there fail (Java says the file
// was made, or the folder is there already); cp onto a folder's name
// fails (Java throws);
// - ".." at the root stays there (Java finds nothing, or throws);
// - rm and mv take an absolute path as absolute (Java puts the working
// directory in front of the path it removes); the root cannot be removed (Java removes the working
// directory and says it removed the file); after the working directory
// is removed, the nearest folder above it that is left is the working
// directory (Java goes on in the removed one);
// - mv onto the file itself, also in another case, renames it (Java copies
// the file onto itself and removes it).
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 { 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, waitUntil, type Steps } from '../threads.js';
import { javaBase64 } from '../../util/javaBase64.js';
import { CommandThread, ProgramLog } from './program.js';
import type { PageLanguage } from './routerPages.js';
import { TERMINAL_TEXTS, type TerminalTextKey } from './terminalTexts.js';
import { IP_ADDRESS_PATTERN } from './web.js';
/** What the command line tells its window: a text, or whether a command runs on (Java's Boolean). */
export type TerminalLogEntry = { readonly kind: 'text'; readonly text: string } | { readonly kind: 'busy'; readonly busy: boolean };
/** What the window does itself with a line (GUIApplicationTerminalWindow). */
export type TerminalWindowAction = 'blank' | 'exit' | 'clear';
/** GUIApplicationTerminalWindow.MENU_LINE. */
export const TERMINAL_MENU_LINE = '==========================================================================\n';
/** The usage texts' keys by command (Terminal.initUsageInfo). */
const USAGE: Record<string, string> = {
arp: 'arp',
arpsend: 'arpsend',
cat: 'cat',
cd: 'cd',
copy: 'copy',
cp: 'copy',
del: 'del',
dir: 'dir',
help: 'help',
host: 'host',
ipconfig: 'ipconfig',
ls: 'dir',
mkdir: 'mkdir',
move: 'move',
mv: 'move',
netstat: 'netstat',
nslookup: 'nslookup',
ping: 'ping',
pwd: 'pwd',
rm: 'del',
route: 'route',
tcpdump: 'tcpdump',
touch: 'touch',
traceroute: 'traceroute',
type: 'cat',
};
/**
* Dateisystem.evaluatePathString for an absolute path: "." and empty parts
* left out, ".." one up (at the root it stays there).
*/
function evaluatedPath(path: string): string {
const parts: string[] = [];
for (const part of path.split('/')) {
if (part === '..') parts.pop();
else if (part !== '.' && part !== '') parts.push(part);
}
return `/${parts.join('/')}`;
}
/** A path's last part (Dateisystem.getBasename). */
const basenameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1);
/** The commands Terminal has a method for; the others are unknown (terminal_msg2). */
const COMMANDS = new Set([
'arp',
'cat',
'cd',
'copy',
'cp',
'del',
'dir',
'help',
'host',
'ipconfig',
'ls',
'mkdir',
'move',
'mv',
'netstat',
'nslookup',
'ping',
'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;
/** A line of 40 dashes under arp's head and at its end. */
const ARP_RULE = `${'-'.repeat(40)}\n`;
export class Terminal {
readonly log: ProgramLog<TerminalLogEntry>;
private directoryValue = '/';
/** The program's thread (Anwendung.ausfuehren): one command after the other. */
private readonly commands: CommandThread;
/** Ctrl+C was pressed (Terminal.interrupted): a ping ends before its next echo. */
private interrupted = false;
/** The last ICMP identifier, -1 before the first (Terminal.identifier). */
private identifier = -1;
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
private readonly files: VirtualFileSystem,
) {
this.log = new ProgramLog(engine);
this.commands = new CommandThread(engine);
}
private get language(): PageLanguage {
return this.engine.state.topologySnapshot.language;
}
private text(key: TerminalTextKey): string {
return TERMINAL_TEXTS[this.language][key];
}
/** The working directory, as Dateisystem.absoluterPfad writes it. */
get directory(): string {
return this.directoryValue;
}
/** The window's prompt. */
get prompt(): string {
return `${this.directoryValue}> `;
}
/** GUIApplicationTerminalWindow.showStartScreen. */
get startScreen(): string {
return `${this.text('sw_terminal_msg57')}\n${TERMINAL_MENU_LINE}${this.text('sw_terminal_msg26')}\n${TERMINAL_MENU_LINE}`;
}
/**
* A line typed into the window: blank, exit, cls and reset are the
* window's; any other line is the program's command, run by its thread.
*/
enter(line: string): TerminalWindowAction | undefined {
const words = line.split(' ').filter((word) => word !== '');
if (javaTrim(line) === '' || words.length === 0) return 'blank';
const [command, ...parameters] = words as [string, ...string[]];
if (command === 'exit') return 'exit';
if (command === 'reset' || command === 'cls') return 'clear';
// terminalEingabeAuswerten, in the window's thread: an unknown command is told at once, even while another runs;
// a known one clears Ctrl+C and is handed to the program's thread.
if (!COMMANDS.has(command)) {
this.tell(`${this.text('terminal_msg2')}\n${this.text('terminal_msg3')}`);
return undefined;
}
this.interrupted = false;
this.commands.hand(() => this.run(command, parameters));
this.engine.resumeThreads();
return undefined;
}
/** The window's Ctrl+C (Terminal.setInterrupt). */
interrupt(): void {
this.interrupted = true;
this.engine.resumeThreads();
}
private tell(text: string): void {
this.log.write({ kind: 'text', text });
}
private busy(busy: boolean): void {
this.log.write({ kind: 'busy', busy });
}
/** In the program's thread (Anwendung.ausfuehren): the method of the command's name. */
private *run(command: string, parameters: readonly string[]): Steps {
const local: Record<string, (parameters: readonly string[]) => void> = {
cat: (each) => this.cat(each),
cd: (each) => this.cd(each),
copy: (each) => this.copy(each, 'cp'),
cp: (each) => this.copy(each, 'cp'),
del: (each) => this.remove(each),
dir: (each) => this.list(each),
help: (each) => this.usage(each[0] ?? ''),
ipconfig: (each) => this.ipconfig(each),
ls: (each) => this.list(each),
mkdir: (each) => this.makeDirectory(each),
move: (each) => this.move(each),
mv: (each) => this.move(each),
pwd: (each) => (each.length === 0 ? this.tell(this.directoryValue) : this.usage('pwd')),
rm: (each) => this.remove(each),
route: (each) => this.route(each),
test: () => this.test(),
touch: (each) => this.touch(each),
type: (each) => this.cat(each),
};
const network: Record<string, (parameters: readonly string[]) => Steps> = {
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);
return;
}
if (command === 'arp') return this.arp(parameters);
if (command === 'netstat') return this.netstat();
local[command]!(parameters);
}
/** Terminal.usage: the command's usage, or the list of commands. */
private usage(command: string): void {
const key = Object.hasOwn(USAGE, command) ? USAGE[command] : undefined;
if (key === undefined) {
this.tell(this.text('sw_terminal_msg25'));
return;
}
const texts = TERMINAL_TEXTS[this.language] as Record<string, string>;
this.tell(`${texts[`sw_terminal_${key}`]}\n${texts[`sw_terminal_usage_${key}`]}\n`);
}
/** A parameter as an absolute path: from the root, or from the working directory. */
private absolute(path: string): string {
return evaluatedPath(path.startsWith('/') ? path : `${this.directoryValue}/${path}`);
}
/** The content at a path, found in any case; undefined when there is none. */
private content(path: string): { readonly path: string; readonly content: VirtualFileContent } | undefined {
try {
return this.files.entry(path);
} catch {
return undefined;
}
}
/** Datei.holeGroesse, a text in characters (see the departures above). */
private static size(content: VirtualFileContent): number {
return content.kind === 'text' ? content.text.length : content.kind === 'directory' ? 0 : content.data.length;
}
/** Terminal.dir. */
private list(parameters: readonly string[]): void {
if (parameters.length > 1) return this.usage('dir');
const shown = parameters.length === 0 ? this.directoryValue : this.absolute(parameters[0]!);
const found = this.content(shown);
if (found === undefined || found.content.kind !== 'directory') return this.tell(this.text('sw_terminal_msg20'));
const entries = this.files.entriesInCreationOrder(found.path);
if (entries.length === 0) return this.tell(this.text('sw_terminal_msg8'));
let text = `${this.text('sw_terminal_msg9')} ${shown}:`;
let directories = 0;
let files = 0;
for (const entry of entries) {
const name = entryName(entry.path);
if (entry.content.kind === 'directory') {
directories += 1;
text += `\n[${name}]`;
} else {
files += 1;
text += `\n${name}${'.'.repeat(Math.max(0, 40 - name.length))}${Terminal.size(entry.content)}`;
}
}
this.tell(`${text}\n${this.text('sw_terminal_msg10')}${directories}${this.text('sw_terminal_msg11')}${files}`);
}
/** Terminal.cd. */
private cd(parameters: readonly string[]): void {
if (parameters.length > 1) return this.usage('cd');
if (parameters.length === 0) return this.tell(this.directoryValue);
const found = this.content(this.absolute(parameters[0]!));
if (found === undefined || found.content.kind !== 'directory') return this.tell(this.text('sw_terminal_msg20'));
this.directoryValue = found.path;
this.tell('');
}
/** A new item at a path (touch, mkdir): the parent must be a folder, the name new. */
private create(parameters: readonly string[], command: 'touch' | 'mkdir', make: (path: string) => void): void {
if (parameters.length !== 1) return this.usage(command);
const keys =
command === 'touch'
? ({ noName: 'sw_terminal_msg15', exists: 'sw_terminal_msg14', made: 'sw_terminal_msg13', failed: 'sw_terminal_msg12' } as const)
: ({ noName: 'sw_terminal_msg19', exists: 'sw_terminal_msg18', made: 'sw_terminal_msg17', failed: 'sw_terminal_msg16' } as const);
const path = this.absolute(parameters[0]!);
if (basenameOf(path) === '') return this.tell(this.text(keys.noName));
if (this.content(path) !== undefined) return this.tell(this.text(keys.exists));
// The file system takes nothing in a folder that is not there (or is a file).
try {
make(path);
this.tell(this.text(keys.made));
} catch {
this.tell(this.text(keys.failed));
}
}
private touch(parameters: readonly string[]): void {
this.create(parameters, 'touch', (path) => this.files.writeTextFile(path, '', false));
}
private makeDirectory(parameters: readonly string[]): void {
this.create(parameters, 'mkdir', (path) => this.files.createDirectory(path));
}
/** Writes content as a new file or over a file of that name (Dateisystem.speicherDatei). */
private write(path: string, content: VirtualFileContent): void {
if (content.kind === 'text') this.files.writeTextFile(path, content.text);
else if (content.kind === 'image') this.files.writeImageFile(path, content.data, content.mediaType);
else if (content.kind === 'binary') this.files.writeBinaryFile(path, content.data, content.mediaType);
}
/** Terminal.pureCopy: a file to a new name or over a file; false where it cannot. */
private copied(parameters: readonly string[]): { readonly source: string; readonly destination: string } | undefined {
const source = this.content(this.absolute(parameters[0]!));
const destination = this.absolute(parameters[1]!);
if (source === undefined || source.content.kind === 'directory' || basenameOf(destination) === '') return undefined;
// The file system writes over a file only, in a folder that is there; onto the file itself it changes nothing.
try {
this.write(destination, source.content);
} catch {
return undefined;
}
return { source: source.path, destination };
}
private copy(parameters: readonly string[], command: 'cp'): void {
if (parameters.length !== 2) return this.usage(command);
this.tell(this.text(this.copied(parameters) === undefined ? 'sw_terminal_msg34' : 'sw_terminal_msg33'));
}
/** Terminal.mv: copied, then the source removed; onto the file itself, renamed. */
private move(parameters: readonly string[]): void {
if (parameters.length !== 2) return this.usage('mv');
const done = this.copied(parameters);
if (done === undefined) return this.tell(this.text('sw_terminal_msg36'));
if (done.source.toLowerCase() === done.destination.toLowerCase()) {
const content = this.files.entry(done.source).content;
this.files.deleteItem(done.source);
this.write(done.destination, content);
} else {
this.files.deleteItem(done.source, true);
}
this.tell(this.text('sw_terminal_msg35'));
}
/** Terminal.del: a file or a folder with all in it. */
private remove(parameters: readonly string[]): void {
if (parameters.length !== 1) return this.usage('del');
const found = this.content(this.absolute(parameters[0]!));
if (found === undefined || found.path === '/') return this.tell(this.text('sw_terminal_msg38'));
this.files.deleteItem(found.path, true);
while (this.content(this.directoryValue) === undefined) this.directoryValue = parentPath(this.directoryValue);
this.tell(this.text('sw_terminal_msg37'));
}
/** Terminal.cat: a file's content as Java keeps it (an image or binary file in Base64). */
private cat(parameters: readonly string[]): void {
if (parameters.length === 0) {
this.usage('cat');
this.tell('');
return;
}
const found = this.content(this.absolute(parameters[0]!));
if (found === undefined || found.content.kind === 'directory') return this.tell(this.text('sw_terminal_msg54'));
this.tell(found.content.kind === 'text' ? found.content.text : javaBase64(found.content.data));
}
/** Terminal.test: test.txt in the working directory. */
private test(): void {
try {
this.files.writeTextFile(`${this.directoryValue === '/' ? '' : this.directoryValue}/test.txt`, 'blaaa');
this.tell(this.text('sw_terminal_msg24'));
} catch {
this.tell(this.text('sw_terminal_msg23'));
}
}
/** Terminal.ipconfig: the node's first address, mask, MAC, gateway and DNS server. */
private ipconfig(parameters: readonly string[]): void {
if (parameters.length !== 0) return this.usage('ipconfig');
const configuration = this.engine.state.topologySnapshot.deviceConfigurations.get(this.nodeId);
const card = this.engine.networkInterfaces(this.nodeId)[0];
this.tell(
`${this.text('sw_terminal_msg4')} ${card?.ipAddress ?? configuration?.ipAddress ?? ''}\n` +
`${this.text('sw_terminal_msg5')} ${card?.subnetMask ?? configuration?.subnetMask ?? ''}\n` +
`${this.text('sw_terminal_msg6666')} ${card?.macAddress ?? ''}\n` +
`${this.text('sw_terminal_msg6')} ${configuration?.defaultGateway ?? ''}\n` +
`${this.text('sw_terminal_msg27')} ${configuration?.dnsServer ?? ''}\n`,
);
}
/** Terminal.route: the forwarding table, each cell padded to 15. */
private route(parameters: readonly string[]): void {
if (parameters.length !== 0) return this.usage('route');
const rows = this.engine.forwardingTable(this.nodeId);
const cell = (text: string) => `${text}${' '.repeat(Math.max(0, 15 - text.length))} | `;
const table = rows.map((row) => `| ${[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map(cell).join('')}\n`).join('');
this.tell(this.text('sw_terminal_msg7') + table);
}
/** Terminal.setIdentifier: at random the first time, then one more each time. */
private nextIdentifier(): number {
this.identifier = this.identifier === -1 ? this.engine.drawIcmpIdentifier() : this.identifier + 1;
return this.identifier;
}
/**
* Terminal.ping: the host's address (by DNS for a name), then four echo
* requests, one a second, each waiting for the first ICMP message back; a
* broadcast only with -b.
*/
private *ping(parameters: readonly string[]): Steps {
let target: string;
let broadcast = false;
if (parameters.length === 1) {
target = parameters[0]!;
} else if (parameters.length === 2 && parameters[0] === '-b') {
target = parameters[1]!;
broadcast = true;
} else {
return this.usage('ping');
}
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_msg31')} (DNS)`);
address = resolution.address;
if (address === undefined) return this.tell(this.text('sw_terminal_msg30'));
}
const route = this.engine.routeRow(this.nodeId, address);
if (route === undefined) return this.tell(this.text('sw_terminal_msg52'));
if (!broadcast && isBroadcastFrom(address, route.interfaceIPAddress, route.subnetMask)) return this.tell(this.text('sw_terminal_msg53'));
const identifier = this.nextIdentifier();
this.busy(true);
this.tell(`PING ${target} (${address})`);
let received = 0;
let sequenceNumber = 1;
for (; !this.interrupted && sequenceNumber <= PING_REPEAT; sequenceNumber += 1) {
const start = this.engine.state.currentTimeMilliseconds;
const response = yield* this.engine.icmpEchoSteps(this.nodeId, address, { identifier, sequenceNumber });
const message = response?.message;
// ICMP.startSinglePing: anything but the echo's reply is a timeout, and the next echo goes at once.
if (message?.kind !== 'echoReply' || message.sequenceNumber !== sequenceNumber) {
this.tell(`\nFrom ${target} (${address}): icmp_seq=${sequenceNumber} -- Timeout!`);
continue;
}
if (message.identifier === identifier) {
const time = Math.round(this.engine.state.currentTimeMilliseconds - start);
this.tell(`\nFrom ${response!.packet.senderIPAddress}: icmp_seq=${sequenceNumber} ttl=${response!.packet.timeToLive} time=${time}ms`);
received += 1;
}
yield pause(start + 1_000);
}
const sent = sequenceNumber - 1;
const loss = sent === 0 ? 0 : Math.round((1 - received / sent) * 100);
this.tell(
`\n--- ${target} ${this.text('sw_terminal_msg45')} ---\n${sent} ${this.text('sw_terminal_msg46')}, ${received} ${this.text('sw_terminal_msg47')}, ${loss}% ${this.text('sw_terminal_msg48')}\n`,
);
this.busy(false);
}
/** Terminal.host: the name's A records, then its MX records. */
private *host(parameters: readonly string[]): Steps {
this.busy(true);
if (parameters.length !== 1) {
this.usage('host');
} else {
yield* this.lookUp(parameters[0]!);
}
this.busy(false);
}
private *lookUp(name: string): Steps {
const addresses = yield* this.engine.resolveDnsSteps(this.nodeId, name, 'A');
if (addresses.type === 'timedOut') return this.tell(this.text('sw_terminal_msg31'));
let found = 0;
// A resolution holds only the name's records of the type (Resolver.copyResourceRecords); for MX also the exchange's A.
for (const record of [...addresses.answers, ...addresses.authority]) {
this.tell(`${record.name} ${this.text('sw_terminal_msg28')} ${record.data}\n`);
found += 1;
}
const exchanges = yield* this.engine.resolveDnsSteps(this.nodeId, name, 'MX');
if (exchanges.type === 'timedOut') return this.tell(this.text('sw_terminal_msg31'));
for (const record of [...exchanges.answers, ...exchanges.authority].filter((each) => each.type === 'MX')) {
this.tell(`${record.name} ${this.text('sw_terminal_msg58')} ${record.data}\n`);
found += 1;
}
if (found === 0) this.tell(this.text('sw_terminal_msg30'));
}
/** Terminal.nslookup: the server asked and the answer's records. */
private *nslookup(parameters: readonly string[]): Steps {
if (parameters.length !== 1) return this.usage('nslookup');
const server = this.engine.state.topologySnapshot.deviceConfigurations.get(this.nodeId)?.dnsServer ?? '';
this.busy(true);
const response = yield* this.engine.resolveDnsSteps(this.nodeId, parameters[0]!, 'A', server);
if (response.type === 'timedOut') {
this.tell(`${this.text('sw_terminal_msg31')}\n`);
} else {
const shown = response.isLocal ? '127.0.0.1' : server;
this.tell(`Server: ${shown}\n`);
this.tell(`Address: ${shown}\n`);
this.tell('\nNon-authoritative Answer:\n');
for (const record of response.answers) {
this.tell(`Name: ${record.name}\n`);
this.tell(`Address: ${record.data}\n`);
}
}
this.busy(false);
}
/** Terminal.arp: the ARP table; -a an address's row only, -d an address (or all) forgotten first. */
private arp(parameters: readonly string[]): void {
let filter: string | undefined;
if (parameters.length === 2 && IP_ADDRESS_PATTERN.test(parameters[1]!)) {
if (parameters[0] === '-d') this.engine.removeArpEntry(this.nodeId, parameters[1]!);
else if (parameters[0] === '-a') filter = parameters[1];
} else if (parameters.length === 1 && parameters[0] === '-d') {
this.engine.resetArpTable(this.nodeId);
}
const rows = this.engine.arpTable(this.nodeId).filter((row) => filter === undefined || row.ipAddress === filter);
const table = rows.map((row) => `| ${row.ipAddress.padEnd(15)} | ${row.macAddress.padEnd(17)} |\n`).join('');
this.tell(`${this.text('sw_terminal_msg50')}${ARP_RULE}${table}${ARP_RULE}`);
}
/**
* Terminal.netstat: the node's TCP sockets, then its UDP sockets; a
* listening one with the connections it accepted. A UDP server lists its
* listening socket only (Java also lists a socket for each address and
* port it ever answered, which it never closes).
*/
private netstat(): void {
const sockets = [...this.engine.state.socketsById.values()].filter((socket) => socket.nodeId === this.nodeId && socket.tcpState !== 'CLOSED');
const cell = (host: string, port: string) => `| ${host.padStart(15)}:${port.padEnd(5)} `;
const row = (protocol: string, socket: SocketRecord) => {
const name = `| ${protocol.padEnd(7)} `;
if (socket.remoteIPAddress === undefined || socket.listening === true) {
return `${name}${cell('0.0.0.0', String(socket.localPort))}${cell('-', '-')}| ${'LISTEN'.padEnd(12)} |\n`;
}
const local = this.engine.routeRow(this.nodeId, socket.remoteIPAddress)?.interfaceIPAddress ?? '<unknown>';
const state = socket.tcpState ?? 'CONNECTED';
return `${name}${cell(local, String(socket.localPort))}${cell(socket.remoteIPAddress, String(socket.remotePort))}| ${state.padEnd(12)} |\n`;
};
let text = `${this.text('sw_terminal_msg49')}${'-'.repeat(74)}\n`;
for (const protocol of ['tcp', 'udp'] as const) {
for (const socket of sockets.filter((each) => each.protocol === protocol && each.parentListenerSocketId === undefined)) {
text += row(protocol.toUpperCase(), socket);
for (const accepted of sockets.filter((each) => each.parentListenerSocketId === socket.id)) text += row(protocol.toUpperCase(), accepted);
}
}
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);
}
}