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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user