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);
}
}
@@ -50,6 +50,7 @@ export const TERMINAL_TEXTS = {
sw_terminal_msg38: 'Datei konnte nicht gelöscht werden\n',
sw_terminal_msg4: 'IP Adresse . . . :',
sw_terminal_msg45: 'Paketstatistik',
sw_terminal_msg46: 'Paket(e) gesendet',
sw_terminal_msg47: 'Paket(e) empfangen',
sw_terminal_msg48: 'Paketverlust',
sw_terminal_msg49: '| Proto | Lokale Adresse | Entfernte Adresse | Zustand |\n',
@@ -157,6 +158,7 @@ export const TERMINAL_TEXTS = {
sw_terminal_msg38: 'File could not be deleted\n',
sw_terminal_msg4: 'IP address . . .:',
sw_terminal_msg45: 'packet statistics',
sw_terminal_msg46: 'packet(s) transmitted',
sw_terminal_msg47: 'packet(s) received',
sw_terminal_msg48: 'packet loss',
sw_terminal_msg49: '| Proto | Local Address | Remote Address | State |\n',
+184 -28
View File
@@ -348,6 +348,8 @@ export interface SocketRecord {
tcpState: TcpSocketState | undefined;
/** A socket a TCP listener accepted: that listener. */
readonly parentListenerSocketId: Id | undefined;
/** UDP: bound to its port only, a server's (Java's ServerSocket); its remote is the last sender. */
readonly listening?: boolean;
}
/** Swift's TopologyRuntimeTCPSessionRecord; sequence numbers are unsigned 32-bit. */
@@ -379,9 +381,13 @@ export type TcpReceived = { readonly type: 'message'; readonly payload: Uint8Arr
/** Java's DNSQueryAgent waits five round trips for an answer. */
export const DNS_QUERY_TIMEOUT_ROUND_TRIPS = 5;
export type DnsResolution = { readonly type: 'answered'; readonly records: readonly DnsMessageRecord[] } | { readonly type: 'timedOut' };
/** A resolution as it goes on the events: the answer and authority records apart, as Java keeps them. */
type DnsLookup =
| { readonly type: 'answered'; readonly answers: readonly DnsMessageRecord[]; readonly authority: readonly DnsMessageRecord[] }
/**
* A resolution as it goes on the events: the answer and authority records
* apart, as Java keeps them; isLocal for localhost, answered without asking
* (DNSNachricht.isLocal).
*/
export type DnsLookup =
| { readonly type: 'answered'; readonly answers: readonly DnsMessageRecord[]; readonly authority: readonly DnsMessageRecord[]; readonly isLocal: boolean }
| { readonly type: 'timedOut' };
export type DnsAddressResolution = { readonly type: 'resolved'; readonly address: string | undefined } | { readonly type: 'timedOut' };
@@ -610,7 +616,7 @@ function natWithDestination(packet: IPv4Packet, endpoint: NatEndpoint): IPv4Pack
}
/** VermittlungsProtokoll.isBroadcast: the limited broadcast, or the broadcast address of the sender's network. */
function isBroadcastFrom(receiverIPAddress: string, senderIPAddress: string, subnetMask: string): boolean {
export function isBroadcastFrom(receiverIPAddress: string, senderIPAddress: string, subnetMask: string): boolean {
if (receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return true;
const receiver = parseIPv4(receiverIPAddress);
const sender = parseIPv4(senderIPAddress);
@@ -808,6 +814,8 @@ interface ArpLookup {
readonly ipAddress: string;
readonly networkInterface: NetworkInterface;
attemptsLeft: number;
/** Its round trip is over and it waits for what else happens at that time (Java's thread wakes after it). */
woken?: boolean;
/** Swift, for the comparison: a lookup made while events run waits in a list emptied by the answer itself. */
readonly swiftQueued: boolean;
readonly done: (macAddress: string | undefined) => void;
@@ -871,6 +879,10 @@ export class NetworkRuntimeEngine {
private tcpPortRandom: JavaCompatibleRandom;
private linkLocalRandom: JavaCompatibleRandom;
private dnsIdRandom: JavaCompatibleRandom;
/** Draws the command line's first ICMP identifier (Terminal.setIdentifier: new Random()). */
private icmpIdentifierRandom: JavaCompatibleRandom;
/** Nodes whose ARP table lost its broadcast row (arp -d 255.255.255.255), until the table is reset. */
private readonly arpBroadcastRowRemoved = new Set<Id>();
private runtimeSpeed: RuntimeSpeed;
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
private isProcessingScheduledEvents = false;
@@ -967,6 +979,7 @@ export class NetworkRuntimeEngine {
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
this.icmpIdentifierRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4943_4d50n);
this.runtimeSpeed = new RuntimeSpeed();
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
@@ -1020,6 +1033,8 @@ export class NetworkRuntimeEngine {
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
this.icmpIdentifierRandom = new JavaCompatibleRandom(seed ^ 0x4943_4d50n);
this.arpBroadcastRowRemoved.clear();
this.isProcessingScheduledEvents = false;
this.applicationThreads.clear();
this.applicationWakes.clear();
@@ -1054,6 +1069,7 @@ export class NetworkRuntimeEngine {
this.dnsQueriesBySocketId.clear();
state.pendingEvents = [];
state.arpCachesByNodeId.clear();
this.arpBroadcastRowRemoved.clear();
state.switchForwardingTablesByNodeId.clear();
state.switchForwardingUpdatedAtMillisecondsByNodeId.clear();
state.switchSeenFrameIdentitiesByNodeId.clear();
@@ -1530,6 +1546,7 @@ export class NetworkRuntimeEngine {
remotePort: options.remotePort,
tcpState: undefined,
parentListenerSocketId: undefined,
listening: options.remoteIPAddress === undefined,
});
state.udpReceiveQueuesBySocketId.set(id, []);
return id;
@@ -1919,7 +1936,7 @@ export class NetworkRuntimeEngine {
private beginDnsResolution(nodeId: Id, name: string, type: string, server: string | undefined, done: (resolution: DnsLookup) => void): void {
const domainName = name.endsWith('.') ? name : `${name}.`;
if (/^localhost\.?$/i.test(domainName)) {
done({ type: 'answered', answers: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }], authority: [] });
done({ type: 'answered', answers: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }], authority: [], isLocal: true });
return;
}
const answers: DnsMessageRecord[] = [];
@@ -1927,7 +1944,7 @@ export class NetworkRuntimeEngine {
const queried = new Set<string>();
const ask = (serverToQuery: string | undefined): void => {
if (serverToQuery === undefined || serverToQuery === '') {
done({ type: 'answered', answers, authority });
done({ type: 'answered', answers, authority, isLocal: false });
return;
}
this.queryDnsServer(nodeId, domainName, type, serverToQuery, (response) => {
@@ -1943,7 +1960,7 @@ export class NetworkRuntimeEngine {
answers.push(...findDnsRecords(data[0]!.data, 'A', response.answers));
authority.push(...findDnsRecords(data[0]!.data, 'A', response.authority));
}
done({ type: 'answered', answers, authority });
done({ type: 'answered', answers, authority, isLocal: false });
return;
}
const all = [...response.answers, ...response.authority, ...response.additional];
@@ -1951,7 +1968,7 @@ export class NetworkRuntimeEngine {
const next = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', all)[0]?.data;
// Java remembers only the servers it was referred to, not the first one.
if (next === undefined || queried.has(next)) {
done({ type: 'answered', answers, authority });
done({ type: 'answered', answers, authority, isLocal: false });
return;
}
queried.add(next);
@@ -1969,17 +1986,22 @@ export class NetworkRuntimeEngine {
return { type: 'resolved', address: findDnsRecords(domainName, 'A', resolution.records)[0]?.data };
}
/** Resolver.holeIPAdresse in a program's thread. */
*resolveDnsAddressSteps(nodeId: Id, name: string): Steps<DnsAddressResolution> {
const domainName = name.endsWith('.') ? name : `${name}.`;
/** Resolver.resolve in a program's thread: asks the node's DNS server, or `serverIPAddress`. */
*resolveDnsSteps(nodeId: Id, name: string, type: string, serverIPAddress?: string): Steps<DnsLookup> {
let lookup: DnsLookup | undefined;
const server = this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, domainName, 'A', server, (result) => {
const server = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, name, type, server, (result) => {
lookup = result;
});
yield* waitUntil(() => lookup !== undefined || this.stateValue.phase !== 'running');
const result = lookup as DnsLookup | undefined;
if (result === undefined || result.type === 'timedOut') return { type: 'timedOut' };
return (lookup as DnsLookup | undefined) ?? { type: 'timedOut' };
}
/** Resolver.holeIPAdresse in a program's thread. */
*resolveDnsAddressSteps(nodeId: Id, name: string): Steps<DnsAddressResolution> {
const domainName = name.endsWith('.') ? name : `${name}.`;
const result = yield* this.resolveDnsSteps(nodeId, domainName, 'A');
if (result.type === 'timedOut') return result;
return { type: 'resolved', address: findDnsRecords(domainName, 'A', [...result.answers, ...result.authority])[0]?.data };
}
@@ -2007,14 +2029,8 @@ export class NetworkRuntimeEngine {
/** Resolver.holeIPAdresseMailServer in a program's thread (the e-mail server sending a mail on). */
*resolveDnsMailServerSteps(nodeId: Id, domain: string): Steps<DnsAddressResolution> {
const domainName = domain.endsWith('.') ? domain : `${domain}.`;
let lookup: DnsLookup | undefined;
const server = this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, domainName, 'MX', server, (result) => {
lookup = result;
});
yield* waitUntil(() => lookup !== undefined || this.stateValue.phase !== 'running');
const result = lookup as DnsLookup | undefined;
if (result === undefined || result.type === 'timedOut') return { type: 'timedOut' };
const result = yield* this.resolveDnsSteps(nodeId, domainName, 'MX');
if (result.type === 'timedOut') return result;
const records = [...result.answers, ...result.authority];
const exchange = findDnsRecords(domainName, 'MX', records)[0];
return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', records)[0]?.data };
@@ -4018,6 +4034,28 @@ export class NetworkRuntimeEngine {
}
}
/**
* The node's ARP table as Java's holds it (ARP.holeARPTabelle): the
* broadcast row Java puts in at each reset, then each learned address, in
* the order learned (Java: a HashMap's order).
*/
arpTable(nodeId: Id): { readonly ipAddress: string; readonly macAddress: string }[] {
const learned = [...(this.stateValue.arpCachesByNodeId.get(nodeId)?.values() ?? [])].map(({ ipAddress, macAddress }) => ({ ipAddress, macAddress }));
return this.arpBroadcastRowRemoved.has(nodeId) ? learned : [{ ipAddress: LIMITED_BROADCAST_IP_ADDRESS, macAddress: ETHERNET_BROADCAST_MAC_ADDRESS }, ...learned];
}
/** ARP.removeARPTableEntry: forgets the address (the broadcast row too, until the table is reset). */
removeArpEntry(nodeId: Id, ipAddress: string): void {
if (ipAddress === LIMITED_BROADCAST_IP_ADDRESS) this.arpBroadcastRowRemoved.add(nodeId);
this.stateValue.arpCachesByNodeId.get(nodeId)?.delete(ipAddress);
}
/** ARP.resetArpTable: every learned address forgotten, the broadcast row back. */
resetArpTable(nodeId: Id): void {
this.clearArpCache(nodeId);
this.arpBroadcastRowRemoved.delete(nodeId);
}
/**
* The MAC address for `targetIPAddress`, asking by ARP broadcast when it is
* not cached. Blocks like Java: each request waits a round trip for its
@@ -4103,10 +4141,19 @@ export class NetworkRuntimeEngine {
for (const lookup of answered) lookup.done(macAddress);
}
/** No answer within the round trip: ask again, or give up. */
/**
* No answer within the round trip: ask again, or give up — after what
* else happens at that time (an answer coming then still counts).
*/
private handleArpTimeout(lookupId: number): void {
const lookup = this.arpLookups.find((each) => each.id === lookupId);
if (lookup === undefined) return;
if (lookup.woken !== true) {
lookup.woken = true;
this.schedule(this.stateValue.currentTimeMilliseconds, { kind: 'arpTimeout', lookupId });
return;
}
lookup.woken = false;
if (lookup.attemptsLeft > 0) {
lookup.attemptsLeft -= 1;
this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId });
@@ -4667,6 +4714,88 @@ export class NetworkRuntimeEngine {
return result;
}
/** The first ICMP identifier of a command line (Terminal.setIdentifier: new Random().nextInt(43) * 23). */
drawIcmpIdentifier(): number {
return this.icmpIdentifierRandom.nextInt(43) * 23;
}
/**
* InternetKnotenBetriebssystem.determineRoute: the row of the forwarding
* table with the longest mask that holds the address, the first of equal
* ones; undefined when none does (RouteNotFoundException).
*/
routeRow(nodeId: Id, ipAddress: string): RouteRow | undefined {
const target = parseIPv4(ipAddress);
if (target === undefined) return undefined;
let best: RouteRow | undefined;
let bestMask = -1;
for (const row of this.forwardingTable(nodeId)) {
const mask = parseIPv4(row.subnetMask);
if (mask === undefined || mask <= bestMask || parseIPv4(row.destinationNetwork) !== maskAddress(target, mask)) continue;
best = row;
bestMask = mask;
}
return best;
}
/**
* ICMP.startSinglePing and sendProbe in a program's thread: the node's
* queue of ICMP answers emptied, an echo request sent (its ARP waits as
* Java's send does; a broadcast of the route's network goes to every card
* with TTL 1, ICMP.sendBroadcast), then a round trip waited for the first
* ICMP message to come (ICMPThread.waitForIcmpResponse), which it returns,
* whatever it answers; undefined when none came.
*/
*icmpEchoSteps(
nodeId: Id,
targetIPAddress: string,
options: { readonly identifier: number; readonly sequenceNumber: number; readonly timeToLive?: number },
): Steps<IcmpObservation | undefined> {
const interfaces = this.networkInterfaces(nodeId);
const route = this.routeForPacket(nodeId, targetIPAddress);
const sourceInterface = route.type === 'local' || route.type === 'egress' ? (route.interface ?? interfaces[0]) : interfaces[0];
if (sourceInterface === undefined) return undefined;
const packet: IPv4Packet = {
identity: this.allocatePacketIdentity(),
senderIPAddress: sourceInterface.ipAddress,
receiverIPAddress: targetIPAddress,
timeToLive: options.timeToLive ?? 64,
protocol: 'icmp',
payload: {
kind: 'icmp',
message: icmpMessage('echoRequest', { identifier: options.identifier, sequenceNumber: options.sequenceNumber, data: new Uint8Array() }),
},
};
this.recordTrace({
packetIdentity: packet.identity,
nodeId,
interfaceId: sourceInterface.portId,
direction: 'outbound',
layer: 'network',
operation: 'created',
afterHeaders: ipv4Headers(packet),
detail: 'ICMP echo request',
});
const observations = () => this.stateValue.icmpObservationsByNodeId.get(nodeId) ?? [];
const queued = observations().length;
let sentAt: number | undefined;
if (route.type === 'egress' && isBroadcastFrom(targetIPAddress, sourceInterface.ipAddress, sourceInterface.subnetMask)) {
this.transmitIPv4Packet(nodeId, { ...packet, timeToLive: 1 }, sourceInterface, ETHERNET_BROADCAST_MAC_ADDRESS, 'sent');
sentAt = this.stateValue.currentTimeMilliseconds;
} else {
this.sendIPv4PacketWithOptions(nodeId, packet, sourceInterface.portId, true, 'sent', {
onFinished: () => {
sentAt = this.stateValue.currentTimeMilliseconds;
},
});
}
const running = () => this.stateValue.phase === 'running';
yield* waitUntil(() => sentAt !== undefined || !running());
if (sentAt === undefined) return undefined;
yield* waitUntil(() => observations().length > queued || !running(), sentAt + this.runtimeSpeed.roundTripTimeMilliseconds);
return observations()[queued];
}
/** `traceroute`: echoes with TTL 1, 2, … until one is answered. */
traceIcmpEcho(
fromNodeId: Id,
@@ -4894,10 +5023,16 @@ export class NetworkRuntimeEngine {
const node = this.nodeSnapshot(nodeId)!;
const packetForProcessing = packet;
const localAddresses = new Set(this.networkInterfaces(nodeId).map((candidate) => candidate.ipAddress));
const interfaces = this.networkInterfaces(nodeId);
const localAddresses = new Set(interfaces.map((candidate) => candidate.ipAddress));
// ICMPThread: a broadcast of a network of the node's, from that network, is the node's; one of another network is dropped.
const isIcmp = packetForProcessing.payload.kind === 'icmp';
const isBroadcastOf = (candidate: NetworkInterface) => isBroadcastFrom(packetForProcessing.receiverIPAddress, candidate.ipAddress, candidate.subnetMask);
if (
localAddresses.has(packetForProcessing.receiverIPAddress) ||
packetForProcessing.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS
packetForProcessing.receiverIPAddress === LOCALHOST_IP_ADDRESS ||
packetForProcessing.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS ||
(isIcmp && interfaces.some((candidate) => isBroadcastOf(candidate) && sameSubnet(packetForProcessing.senderIPAddress, candidate.ipAddress, candidate.subnetMask)))
) {
if (
isPcClassEndpoint(node.kind) &&
@@ -4912,6 +5047,21 @@ export class NetworkRuntimeEngine {
if (!this.deliverIPv4Locally(packetForProcessing, nodeId, networkInterface, finished)) finished();
return;
}
if (isIcmp && interfaces.some(isBroadcastOf)) {
this.recordTrace({
frameIdentity,
packetIdentity: packetForProcessing.identity,
nodeId,
interfaceId: networkInterface.portId,
direction: 'inbound',
layer: 'network',
operation: 'dropped',
beforeHeaders: ipv4Headers(packetForProcessing),
detail: 'ICMP broadcast of another network',
});
finished();
return;
}
// Java: routers and home routers forward, a host when its IP forwarding is on (ipForwardingEnabled).
if (!(this.stateValue.topologySnapshot.ipForwardingByNodeId.get(nodeId) ?? forwardsIPv4ByDefault(node.kind))) {
this.recordTrace({
@@ -4957,8 +5107,9 @@ export class NetworkRuntimeEngine {
},
onFinished: finished,
});
// As for host unreachable, the packet as the router would have forwarded it (Java: half made, without its sender).
if (result.type === 'dropped' && this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress).type === 'unavailable') {
this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', packet);
this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', forwarded);
}
}
@@ -4971,9 +5122,14 @@ export class NetworkRuntimeEngine {
switch (message.kind) {
case 'echoRequest': {
if (packet.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS && packet.timeToLive !== 1) return false;
// From the address asked (RFC 792: the addresses reversed); for a broadcast, the card's. Java answers from the
// address its route back gives: from the near card when a router's far address was asked, from 127.0.0.1 when the
// node's own address was.
const askedOwnAddress =
packet.receiverIPAddress === LOCALHOST_IP_ADDRESS || this.networkInterfaces(nodeId).some((candidate) => candidate.ipAddress === packet.receiverIPAddress);
const reply: IPv4Packet = {
identity: this.allocatePacketIdentity(),
senderIPAddress: networkInterface.ipAddress,
senderIPAddress: askedOwnAddress ? packet.receiverIPAddress : networkInterface.ipAddress,
receiverIPAddress: packet.senderIPAddress,
timeToLive: 64,
protocol: 'icmp',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
{
"frames": {
"00000000-0000-0000-0000-000000000401": [],
"00000000-0000-0000-0000-000000000402": [],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000404": [],
"00000000-0000-0000-0000-000000000405": [],
"00000000-0000-0000-0000-000000000405#1": [],
"00000000-0000-0000-0000-000000000406": []
},
"rows": {
"00000000-0000-0000-0000-000000000401": [],
"00000000-0000-0000-0000-000000000402": [],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000404": [],
"00000000-0000-0000-0000-000000000405": [],
"00000000-0000-0000-0000-000000000405#1": [],
"00000000-0000-0000-0000-000000000406": []
},
"layeredRows": {
"00000000-0000-0000-0000-000000000401": [],
"00000000-0000-0000-0000-000000000402": [],
"00000000-0000-0000-0000-000000000403": [],
"00000000-0000-0000-0000-000000000404": [],
"00000000-0000-0000-0000-000000000405": [],
"00000000-0000-0000-0000-000000000405#1": [],
"00000000-0000-0000-0000-000000000406": []
},
"acts": [
[
500,
"00000000-0000-0000-0000-000000000401",
"ping 192.168.0.20"
],
[
6000,
"00000000-0000-0000-0000-000000000401",
"ping localhost"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"ping 127.0.0.1"
]
],
"events": [
[
500,
"00000000-0000-0000-0000-000000000401",
"call",
"ping 192.168.0.20"
],
[
502,
"00000000-0000-0000-0000-000000000401",
"busy",
"true"
],
[
502,
"00000000-0000-0000-0000-000000000401",
"text",
"PING 192.168.0.20 (192.168.0.20)"
],
[
508,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=1 ttl=64 time=4ms"
],
[
1504,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=2 ttl=64 time=0ms"
],
[
2505,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=3 ttl=64 time=1ms"
],
[
3504,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=4 ttl=64 time=0ms"
],
[
4506,
"00000000-0000-0000-0000-000000000401",
"text",
"\n--- 192.168.0.20 Paketstatistik ---\n4 Paket(e) gesendet, 4 Paket(e) empfangen, 0% Paketverlust\n"
],
[
4506,
"00000000-0000-0000-0000-000000000401",
"busy",
"false"
],
[
6000,
"00000000-0000-0000-0000-000000000401",
"call",
"ping localhost"
],
[
6001,
"00000000-0000-0000-0000-000000000401",
"busy",
"true"
],
[
6001,
"00000000-0000-0000-0000-000000000401",
"text",
"PING localhost (127.0.0.1)"
],
[
6001,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=1 ttl=64 time=0ms"
],
[
7002,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=2 ttl=64 time=1ms"
],
[
8001,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=3 ttl=64 time=0ms"
],
[
9002,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=4 ttl=64 time=1ms"
],
[
10001,
"00000000-0000-0000-0000-000000000401",
"text",
"\n--- localhost Paketstatistik ---\n4 Paket(e) gesendet, 4 Paket(e) empfangen, 0% Paketverlust\n"
],
[
10001,
"00000000-0000-0000-0000-000000000401",
"busy",
"false"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"call",
"ping 127.0.0.1"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"busy",
"true"
],
[
11500,
"00000000-0000-0000-0000-000000000401",
"text",
"PING 127.0.0.1 (127.0.0.1)"
],
[
11501,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=1 ttl=64 time=1ms"
],
[
12500,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=2 ttl=64 time=0ms"
],
[
13501,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=3 ttl=64 time=1ms"
],
[
14501,
"00000000-0000-0000-0000-000000000401",
"text",
"\nFrom 127.0.0.1: icmp_seq=4 ttl=64 time=0ms"
],
[
15503,
"00000000-0000-0000-0000-000000000401",
"text",
"\n--- 127.0.0.1 Paketstatistik ---\n4 Paket(e) gesendet, 4 Paket(e) empfangen, 0% Paketverlust\n"
],
[
15503,
"00000000-0000-0000-0000-000000000401",
"busy",
"false"
]
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
{
"seed": 91,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000401",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000411",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000402",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000412",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000403",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000413",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000404",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000414",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000405",
"kind": "router",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000451",
"label": "eth0"
},
{
"id": "00000000-0000-0000-0000-000000000452",
"label": "eth1"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000406",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000416",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000407",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000471",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000472",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000473",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000474",
"label": "sw4"
},
{
"id": "00000000-0000-0000-0000-000000000475",
"label": "sw5"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F401",
"sourceNode": "00000000-0000-0000-0000-000000000401",
"sourcePort": "00000000-0000-0000-0000-000000000411",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000471"
},
{
"id": "00000000-0000-0000-0000-00000000F402",
"sourceNode": "00000000-0000-0000-0000-000000000402",
"sourcePort": "00000000-0000-0000-0000-000000000412",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000472"
},
{
"id": "00000000-0000-0000-0000-00000000F403",
"sourceNode": "00000000-0000-0000-0000-000000000403",
"sourcePort": "00000000-0000-0000-0000-000000000413",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000473"
},
{
"id": "00000000-0000-0000-0000-00000000F404",
"sourceNode": "00000000-0000-0000-0000-000000000404",
"sourcePort": "00000000-0000-0000-0000-000000000414",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000474"
},
{
"id": "00000000-0000-0000-0000-00000000F405",
"sourceNode": "00000000-0000-0000-0000-000000000405",
"sourcePort": "00000000-0000-0000-0000-000000000451",
"targetNode": "00000000-0000-0000-0000-000000000407",
"targetPort": "00000000-0000-0000-0000-000000000475"
},
{
"id": "00000000-0000-0000-0000-00000000F406",
"sourceNode": "00000000-0000-0000-0000-000000000405",
"sourcePort": "00000000-0000-0000-0000-000000000452",
"targetNode": "00000000-0000-0000-0000-000000000406",
"targetPort": "00000000-0000-0000-0000-000000000416"
}
],
"devices": {
"00000000-0000-0000-0000-000000000401": {
"ip": "192.168.0.20",
"mask": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000402": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000403": {
"ip": "192.168.0.30",
"mask": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000404": {
"ip": "192.168.0.31",
"mask": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "192.168.0.10"
},
"00000000-0000-0000-0000-000000000405": {
"ip": "192.168.0.1",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000406": {
"ip": "10.0.0.2",
"mask": "255.255.255.0",
"gateway": "10.0.0.1",
"dns": "192.168.0.53"
}
},
"interfaces": [
{
"node": "00000000-0000-0000-0000-000000000405",
"port": "00000000-0000-0000-0000-000000000451",
"ip": "192.168.0.1",
"mask": "255.255.255.0"
},
{
"node": "00000000-0000-0000-0000-000000000405",
"port": "00000000-0000-0000-0000-000000000452",
"ip": "10.0.0.1",
"mask": "255.255.255.0"
}
],
"dnsServers": {
"00000000-0000-0000-0000-000000000402": {
"records": [
"www.filius.de A 192.168.0.30",
"filius.de A 192.168.0.31",
"filius.de MX mail.filius.de",
"mail.filius.de A 192.168.0.30",
"multi.filius.de A 192.168.0.30",
"multi.filius.de A 192.168.0.31",
"fern.filius.de A 10.0.0.2",
"weg.filius.de A 192.168.0.99",
"post.filius.de MX mail.filius.de"
]
}
}
}
+5
View File
@@ -217,6 +217,11 @@ describe('the command line as Java FILIUS answers it', () => {
const cases = javaCases(language);
const text = (key: TerminalTextKey) => TERMINAL_TEXTS[language][key];
it(`has Java's texts (${language})`, () => {
const javaTexts = (JSON.parse(readFileSync(join(here, `terminal.${language}.java.json`), 'utf8')) as { texts: Record<string, string> }).texts;
for (const [key, value] of Object.entries(TERMINAL_TEXTS[language])) expect(value, key).toBe(javaTexts[key]);
});
it(`corrects Java only where it errs (${language})`, () => {
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
const javaCase = cases.find((each) => each.name === name)!;
+411
View File
@@ -0,0 +1,411 @@
// The command line's network commands as Java FILIUS runs them.
// tools/java-oracle's TerminalRun typed lines into FILIUS' command line on
// the hosts of terminal-net at set times and wrote every frame each card
// saw, Java's tables of them, and everything each command line told its
// window (terminal-java-<story>.java.json). The engine types the same lines
// at the same times (apps/terminal.ts): each card's frames in the same
// order, with the same content and rows, at about the same time; the same
// texts and busy marks, at about the same time.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { NetworkRuntimeEngine, withSnapshot } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const CLIENT = '00000000-0000-0000-0000-000000000401';
const DNS = '00000000-0000-0000-0000-000000000402';
const WEB = '00000000-0000-0000-0000-000000000403';
const ECHO = '00000000-0000-0000-0000-000000000404';
const FAR = '00000000-0000-0000-0000-000000000406';
const HOSTS = [CLIENT, DNS, WEB, ECHO, FAR];
type Told = ['text' | 'busy', string];
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
acts: [number, string, string][];
/** time, host, kind (call, text or busy), text */
events: [number, string, string, string][];
}
const STORIES = ['pingLan', 'pingRouted', 'pingNobody', 'pingUnreachable', 'pingBroadcast', 'pingSelf', 'pingErrors', 'interrupt', 'dns', 'dnsTimeout', 'arp', 'netstat'] as const;
type Lines = Record<string, [number, Told][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = Object.fromEntries(HOSTS.map((host) => [host, []]));
for (const [time, host, kind, text] of java.events) {
if (kind === 'call') continue;
lines[host]!.push([time, [kind as Told[0], text]]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine): Lines {
return Object.fromEntries(
HOSTS.map((host) => [
host,
engine.terminal(host)!.log.all.map((line): [number, Told] => [line.timeMilliseconds, line.kind === 'text' ? ['text', line.text] : ['busy', String(line.busy)]]),
]),
);
}
/**
* A text with placeholders: a client's port; a ping's time (compared on its
* own); the rows of an ARP or netstat table sorted (Java lists them in a
* HashMap's order, the engine in the order learned, or made).
*/
function lineText(text: string): string {
const table = /^(\| [^\n]*\|\n-+\n)((?:\|[^\n]*\n)*)(-+\n)?$/.exec(text);
// Sorted within each protocol of netstat's, the protocols in the order they come (TCP, then UDP, in Java too).
const rows = table?.[2]!.split('\n').filter(Boolean) ?? [];
const protocols = [...new Set(rows.map((row) => /^\| (TCP|UDP) /.exec(row)?.[1] ?? ''))];
const protocol = (row: string) => protocols.indexOf(/^\| (TCP|UDP) /.exec(row)?.[1] ?? '');
rows.sort((lhs, rhs) => protocol(lhs) - protocol(rhs) || (lhs < rhs ? -1 : lhs > rhs ? 1 : 0));
const sorted = table === null ? text : `${table[1]}${rows.join('\n')}\n${table[3] ?? ''}`;
return withClientPortText(sorted).replace(/time=\d+ms/, 'time=…ms');
}
const pingTime = (text: string) => Number(/time=(\d+)ms/.exec(text)?.[1] ?? 0);
/** The identifier a ping draws at random (Java: new Random()). */
const cellText = (text: string) => text.replace(/Identifier: \d+/, 'Identifier: x').replace(/identifier=\d+/, 'identifier=x');
/** Java's run with what each line told replaced where Java errs. */
function withTold(java: JavaRun, change: (host: string, text: string, time: number) => string | undefined): JavaRun {
return {
...java,
events: java.events.flatMap(([time, host, kind, text]): [number, string, string, string][] => {
if (kind !== 'text') return [[time, host, kind, text]];
const changed = change(host, text, time);
return changed === undefined ? [] : [[time, host, kind, changed]];
}),
};
}
/** The answers to an echo from `from` to the client, in Java's frames and rows, as from `to`. */
function withEchoReplySource(java: JavaRun, from: string, to: string, after: number): JavaRun {
const frames = Object.fromEntries(
Object.entries(java.frames).map(([card, list]) => [
card,
list.map((frame) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? { ...frame, sourceIp: to } : frame)),
]),
);
const replies = (card: string) =>
new Set(java.frames[card]!.flatMap((frame, index) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? [String(index + 1)] : [])));
const rows = (tables: Record<string, (string | null)[][]> | undefined) =>
tables === undefined
? undefined
: Object.fromEntries(
Object.entries(tables).map(([card, list]) => [card, list.map((row) => (replies(card).has(row[0]!) && row[1] === from ? [row[0]!, to, ...row.slice(2)] : row))]),
);
return { ...java, frames, rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
}
/** Java's tables with each cell changed. */
function withCells(java: JavaRun, change: (cell: string) => string): JavaRun {
const tables = (list: Record<string, (string | null)[][]> | undefined) =>
list === undefined
? undefined
: Object.fromEntries(Object.entries(list).map(([card, rows]) => [card, rows.map((row) => row.map((cell) => (cell === null ? null : change(cell))))]));
return { ...java, rows: tables(java.rows), layeredRows: tables(java.layeredRows) };
}
const USAGE_PING = 'ping - test connection to other computer\nUsage: ping <remote host> \n <remote host> the remote address as IP address or host name\n';
/**
* Where Java errs: its run corrected, as Java means it.
* - Java's ping names its first parameter where it means the host (-b in the
* statistics of ping -b), and takes any first word for -b (ping -x pings).
* - An echo's answer comes from the address asked (RFC 792: the addresses
* reversed): Java answers from 127.0.0.1 when the host's own address is
* asked, from the near card when a router's far address is.
* - netstat on a UDP server lists its listening socket only: Java also lists
* a socket for each address and port it ever answered (never closed).
* - A router's network unreachable carries the echo as it would have
* forwarded it, with its sender (Java's is half made: sender null).
*/
const JAVA_BUGS: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => JavaRun>> = {
pingBroadcast: (java) =>
withTold(java, (host, text, time) => {
const target = host === CLIENT && time >= 7_000 ? '255.255.255.255' : '192.168.0.255';
return text.replace('--- -b ', `--- ${target} `).replace('From -b (', `From ${target} (`);
}),
pingErrors: (java) => {
const typed = java.acts.find(([, , line]) => line.startsWith('ping -x'))![0];
const corrected = withTold(java, (_, text, time) => (time < typed ? text : undefined));
return {
...corrected,
frames: Object.fromEntries(Object.entries(java.frames).map(([card, list]) => [card, list.filter((frame) => frame.time < typed)])),
events: [...corrected.events.filter(([time, , kind]) => time < typed || kind !== 'busy'), [typed, CLIENT, 'text', USAGE_PING]],
};
},
pingSelf: (java) => withTold(java, (_, text, time) => (time < 6_000 ? text.replace('From 127.0.0.1:', 'From 192.168.0.20:') : text)),
pingRouted: (java) => {
const typed = java.acts.find(([, , line]) => line === 'ping 10.0.0.1')![0];
return withEchoReplySource(
withTold(java, (_, text, time) => (time >= typed ? text.replace('From 192.168.0.1:', 'From 10.0.0.1:') : text)),
'192.168.0.1',
'10.0.0.1',
typed,
);
},
pingUnreachable: (java) => withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;')),
netstat: (java) => withTold(java, (host, text) => (host === DNS ? text.replace(/\| UDP {5}\| {4}192\.168\.0\.10:53 {4}\|[^\n]*CONNECTED +\|\n/g, '') : text)),
};
/** Where the frames are compared only up to a time (Java's): pingErrors, the ping Java sends for ping -x. */
const UNTIL: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => number>> = {
pingErrors: (java) => java.acts.find(([, , line]) => line.startsWith('ping -x'))![0],
};
/** The hosts that answer a broadcast echo at once: which answer comes first is a race of Java's threads. */
const BROADCAST_ANSWER = /From 192\.168\.0\.(1|10|30|31): icmp_seq/;
/**
* pingBroadcast: the hosts answer each broadcast echo at once, and the order
* of their ARP requests and answers on the cables is a race of Java's
* threads. Its frames are compared second by second (each ping's), as a
* set; Java's tables, which go frame by frame, are left out.
*/
const RACING = new Set(['pingBroadcast']);
const bySecond = (frames: SeenFrame[]): SeenFrame[] =>
frames
.map((frame) => ({ time: Math.floor((frame.time - 900) / 1_000) * 1_000 + 900, text: frame.text }))
.sort((lhs, rhs) => lhs.time - rhs.time || lhs.text.localeCompare(rhs.text));
describe('the command line on the network as Java FILIUS runs it', () => {
it('corrects Java only where it errs, and cuts only after the story began', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
expect(UNTIL[name as (typeof STORIES)[number]]?.(java) ?? Number.POSITIVE_INFINITY, name).toBeGreaterThan(1_000);
}
});
// ICMPThread.waitForIcmpResponse misses its wake-up when the answer is there before it waits (a race of Java's
// threads: an echo to the host itself, the host unreachable a host gives itself), and waits a whole round trip.
// The engine answers at once; a recording with a missed wake-up is made again (tools/java-oracle/run.sh).
it("was recorded without a missed wake-up of Java's", () => {
const told = (name: string) =>
(JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun).events.filter(([, , kind]) => kind === 'text');
for (const [, , , text] of told('pingSelf')) expect(pingTime(text), `pingSelf: ${JSON.stringify(text)}`).toBeLessThan(1_000);
const timeouts = told('pingNobody').filter(([, , , text]) => text.includes('Timeout!'));
for (const [index, [time, , , text]] of timeouts.slice(1).entries()) {
if (text.includes('icmp_seq=1 ')) continue;
expect(time - timeouts[index]![0], 'pingNobody: two ARP round trips per echo').toBeLessThan(6_000);
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
for (const name of STORIES) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const snapshot = withSnapshot(snapshotOf(scenario), {
language: 'de',
terminalNodeIds: new Set(HOSTS),
...(name === 'netstat'
? {
echoServersByNodeId: new Map([
[ECHO, { port: 55555, isActive: true }],
[DNS, { port: 55555, isActive: true }],
]),
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]),
}
: {}),
});
const { engine } = watchedEngine(scenario, snapshot);
const acts: [number, () => void][] = java.acts.map(([time, host, line]) => [
time,
() => {
if (line === '^C') engine.terminal(host)!.interrupt();
else if (line === 'disconnect') engine.simpleClient(CLIENT)!.disconnect();
else engine.terminal(host)!.enter(line);
},
]);
if (name === 'netstat') acts.unshift([500, () => engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555)]);
for (const [time, act] of acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act();
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
const racing = RACING.has(name);
expectSameCaptures(engine, scenario, java.frames, {
text: (text) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x'),
...(racing ? { normalized: bySecond, starts: { java: 0, engine: 0 } } : { tables: java }),
cell: cellText,
until: UNTIL[name]?.(measured),
});
if (racing) {
// ICMP.sendBroadcast: with TTL 1, to every card.
const echoes = engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [[frame.destinationMacAddress, packet.timeToLive]] : [];
});
expect(echoes).toEqual(Array.from({ length: 8 }, () => ['FF:FF:FF:FF:FF:FF', 1]));
}
const expected = javaLines(java);
const actual = engineLines(engine);
const told = ([kind, text]: Told): Told => [kind, lineText(name === 'pingBroadcast' ? text.replace(BROADCAST_ANSWER, 'From <broadcast>: icmp_seq') : text)];
for (const host of HOSTS) {
expect(
actual[host]!.map(([, line]) => told(line)),
`${host}'s lines`,
).toEqual(expected[host]!.map(([, line]) => told(line)));
for (const [index, [javaTime, line]] of expected[host]!.entries()) {
const [engineTime, engineLine] = actual[host]![index]!;
const late = javaTime - engineTime;
expect(Math.abs(late), `${host}'s line ${JSON.stringify(line[1].slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
expect(Math.abs(pingTime(line[1]) - pingTime(engineLine[1])), `${host}'s ping time in ${JSON.stringify(line[1])}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
}
});
}
});
describe('the command line on the network beyond the stories', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
const started = () => watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) })).engine;
const texts = (engine: NetworkRuntimeEngine) => engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
const echoIdentifiers = (engine: NetworkRuntimeEngine) =>
engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [packet.payload.message.identifier] : [];
});
it('sends no echo for a ping whose Ctrl+C came before it began', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('host www.filius.de');
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.terminal(CLIENT)!.interrupt();
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(texts(engine).slice(-2)).toEqual([
'PING 192.168.0.31 (192.168.0.31)',
'\n--- 192.168.0.31 Paketstatistik ---\n0 Paket(e) gesendet, 0 Paket(e) empfangen, 0% Paketverlust\n',
]);
expect(echoIdentifiers(engine)).toEqual([]);
});
it("counts no answer to another ping's echo, and waits out its second", () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
// An echo reply with the first echo's number, but another identifier (a multiple of 23 is drawn), first in the queue.
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber: 1, data: new Uint8Array() } as const;
const packet = {
identity: 0,
senderIPAddress: '192.168.0.31',
receiverIPAddress: '192.168.0.20',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: reply },
} as const;
const queue = engine.state.icmpObservationsByNodeId;
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
expect(lines.map(([, text]) => text.replace(/time=\d+ms/, 'time=…ms'))).toEqual([
'PING 192.168.0.31 (192.168.0.31)',
'\nFrom 192.168.0.31: icmp_seq=2 ttl=64 time=…ms',
'\nFrom 192.168.0.31: icmp_seq=3 ttl=64 time=…ms',
'\nFrom 192.168.0.31: icmp_seq=4 ttl=64 time=…ms',
'\n--- 192.168.0.31 Paketstatistik ---\n4 Paket(e) gesendet, 3 Paket(e) empfangen, 25% Paketverlust\n',
]);
expect(lines[1]![0]).toBeGreaterThanOrEqual(1_000);
});
it('goes on with the next identifier, from one drawn at random', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.terminal(CLIENT)!.enter('ping 192.168.0.30');
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const identifiers = echoIdentifiers(engine);
expect(identifiers).toHaveLength(8);
expect(identifiers[0]! % 23).toBe(0);
expect(identifiers[0]!).toBeLessThanOrEqual(42 * 23);
expect(identifiers).toEqual([...Array(4).fill(identifiers[0]), ...Array(4).fill(identifiers[0]! + 1)]);
});
/** An ICMP message put first in the client's queue, as if it had come (an answer to another echo). */
const queueEchoReply = (engine: NetworkRuntimeEngine, sequenceNumber: number) => {
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber, data: new Uint8Array() } as const;
const packet = {
identity: 0,
senderIPAddress: '192.168.0.31',
receiverIPAddress: '192.168.0.20',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: reply },
} as const;
const queue = engine.state.icmpObservationsByNodeId;
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
};
it('takes an echo reply with another number for a timeout, and sends the next echo at once', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
queueEchoReply(engine, 9);
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
expect(lines[1]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=1 -- Timeout!');
// The next echo goes at once, and the late answer to the first comes first: a timeout too.
expect(lines[2]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=2 -- Timeout!');
expect(lines[2]![0] - lines[1]![0]).toBeLessThan(500);
});
it('takes the first ICMP message that came, not the last', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.99');
// First the answer to another echo, then (when ARP gives up) the host unreachable the client gives itself.
queueEchoReply(engine, 1);
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
expect(texts(engine).slice(1)).toEqual([
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=2 -- Timeout!',
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=3 -- Timeout!',
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=4 -- Timeout!',
'\n--- 192.168.0.99 Paketstatistik ---\n4 Paket(e) gesendet, 0 Paket(e) empfangen, 100% Paketverlust\n',
]);
});
it('rounds the loss (Math.round)', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
queueEchoReply(engine, 1);
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
queueEchoReply(engine, 2);
engine.handle({ type: 'advance', toMilliseconds: 2_500 });
engine.terminal(CLIENT)!.interrupt();
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(texts(engine).at(-1)).toBe('\n--- 192.168.0.31 Paketstatistik ---\n3 Paket(e) gesendet, 1 Paket(e) empfangen, 67% Paketverlust\n');
});
it("refuses the broadcast of the route's network, whatever its mask", () => {
const wide = { ...scenario, devices: { ...scenario.devices, [CLIENT]: { ...scenario.devices![CLIENT]!, mask: '255.255.0.0' } } } as Scenario;
const engine = watchedEngine(wide, withSnapshot(snapshotOf(wide), { language: 'de', terminalNodeIds: new Set([CLIENT]) })).engine;
engine.terminal(CLIENT)!.enter('ping 192.168.255.255');
expect(texts(engine)).toEqual(['Ping zu Broadcast-Adressen wird nicht unterstützt.']);
});
it('draws the first identifier at random', () => {
const drawn = new Set(Array.from({ length: 10 }, (_, seed) => new NetworkRuntimeEngine(seed).drawIcmpIdentifier()));
expect(drawn.size).toBeGreaterThan(1);
});
it('routes by the first of rows with the same mask (determineRouteFromStaticRoutingTable)', () => {
const snapshot = withSnapshot(snapshotOf(scenario), {
manualRoutesByNodeId: new Map([[CLIENT, [{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', gateway: '192.168.0.99', interfaceIPAddress: '192.168.0.20' }]]]),
});
const engine = watchedEngine(scenario, snapshot).engine;
const rows = engine.forwardingTable(CLIENT).filter((row) => row.destinationNetwork === '192.168.0.0' && row.subnetMask === '255.255.255.0');
expect(rows).toHaveLength(2);
expect(engine.routeRow(CLIENT, '192.168.0.31')).toEqual(rows[0]);
});
});