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>
726 lines
32 KiB
TypeScript
726 lines
32 KiB
TypeScript
// 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);
|
||
}
|
||
}
|