// 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 = { 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). */ export 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 = { 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 = { 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; 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 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 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; 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 ?? ''; 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); } }