feat(core): the command line's network commands ping, host, nslookup, arp and netstat after Java, measured against Java

TerminalRun types lines into Java's command line on terminal-net at set
times; the engine types the same lines. New in the engine: an ICMP echo
in a program's thread (the first ICMP message after the send, whatever
it is), Java's route choice, the ARP table with Java's broadcast row, DNS
in a program's thread, ICMP broadcasts of a node's own network, and an
ARP wait that ends after what else happens at that millisecond.

Departures from Java: an echo is answered from the address asked
(RFC 792), network unreachable carries the packet with its sender, ping
names the host and takes only -b before it, arp and netstat list in the
order learned, netstat lists a UDP server's listening socket only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 08:03:10 +00:00
co-authored by Claude Opus 5.5
parent 016f9461df
commit 495c449ef2
22 changed files with 33994 additions and 42 deletions
+236 -10
View File
@@ -1,10 +1,21 @@
// 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. The files are the node's
// (the snapshot's file system, the same the project keeps).
// in the project's language, as Java writes them, and marks a command that
// runs on (ping, host, nslookup) busy and done. The files are the node's
// (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 (terminal.test.ts names each):
// 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).
//
// 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
@@ -25,13 +36,15 @@
import type { Id } from '../../ids.js';
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import { javaIpCheck } from '../../util/javaIp.js';
import { isBroadcastFrom, type NetworkRuntimeEngine, type SocketRecord } from '../engine.js';
import { javaTrim } from '../http.js';
import type { Steps } from '../threads.js';
import { pause, 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 };
@@ -87,11 +100,48 @@ function evaluatedPath(path: string): string {
/** 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',
'test',
'touch',
'type',
]);
/** 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,
@@ -135,16 +185,32 @@ export class Terminal {
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;
}
private tell(text: string): void {
this.log.write({ kind: 'text', text });
}
/** Terminal.terminalEingabeAuswerten: the method of the command's name, if Java has one. */
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),
@@ -166,12 +232,18 @@ export class Terminal {
touch: (each) => this.touch(each),
type: (each) => this.cat(each),
};
const handler = Object.hasOwn(local, command) ? local[command] : undefined;
if (handler === undefined) {
this.tell(`${this.text('terminal_msg2')}\n${this.text('terminal_msg3')}`);
const network: Record<string, (parameters: readonly string[]) => Steps> = {
host: (each) => this.host(each),
nslookup: (each) => this.nslookup(each),
ping: (each) => this.ping(each),
};
if (Object.hasOwn(network, command)) {
yield* network[command]!(parameters);
return;
}
handler(parameters);
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. */
@@ -360,4 +432,158 @@ export class Terminal {
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);
}
}