GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the line of GnutellaRun's story "search". The core cancels a search as Java does (every connection of the program's own requests closed, passed-on queries kept), lets the number of peers change and lists /peer2peer's files; the room takes the window's actions and sends its view; the editor shows Java's four tabs with Java's symbols. Not adopted from Java: a number of peers of one digit only, a results table that keeps the last search's rows, a files table that lags behind the folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
959 lines
44 KiB
TypeScript
959 lines
44 KiB
TypeScript
// A simulation room (plan §6): one engine for one project, the clients that
|
|
// watch it, and what goes to them. The same room runs on F for everyone in a
|
|
// project and in a web worker for one (LocalSimHost); it takes the
|
|
// document's snapshot when the simulation starts, so that changes made while
|
|
// it runs come in at the next start, as in Java.
|
|
//
|
|
// Time: the engine's clock follows the wall clock, a millisecond for a
|
|
// millisecond, as Java's threads do; the speed is Java's slider, the delay of
|
|
// a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3).
|
|
// The engine counts whole milliseconds, as Java's clock does; the wall
|
|
// clock's fractions are carried to the next tick, so that no time is lost.
|
|
// A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept,
|
|
// the simulation goes on from where it was rather than running the missed
|
|
// minutes in one go.
|
|
//
|
|
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
|
|
// the next start goes on at the time it stopped, so that the capture's times
|
|
// keep rising (plan §6.2). So do the command lines' and browsers' windows, as
|
|
// Java's do (TerminalWindow, BrowserWindow).
|
|
//
|
|
// A program's window may show more than its lines (plan §7.3): the browser's
|
|
// page, the web server's button and table, the e-mail program's account and
|
|
// folders, the e-mail server's domain and accounts, the echo server's button
|
|
// and port, the generic client's connection go to the clients as a view,
|
|
// whole, when they change.
|
|
//
|
|
// While the simulation runs, the document's programs are watched: a program
|
|
// installed or removed on the desktop (Java's software installation) is
|
|
// installed or removed in the running engine at once, as Java starts or ends
|
|
// it; any other change comes in at the next start.
|
|
//
|
|
// What a run leaves that Java's project keeps (files, which servers run,
|
|
// accounts, mails, a DHCP client's address …) goes back into the document,
|
|
// as `origin: 'sim'`: at most once a second while it runs, after each action
|
|
// in a window and when it stops (writeRunState). So the next start, and every
|
|
// client, sees it — as Java's project saved after a run holds it.
|
|
//
|
|
// Files go both ways while it runs (plan §7.3: the text editor writes the
|
|
// document's Y.Text): what a person writes into the document goes into the
|
|
// engine's files at once (readFilesInto), and what the engine changed in its
|
|
// files goes into the document at the tick it changed, not a second later —
|
|
// so the document holds every change of the engine before a person's next
|
|
// one comes in, and a text two write at once keeps both.
|
|
|
|
import * as Y from 'yjs';
|
|
import {
|
|
accountOfForm,
|
|
DEFAULT_SPEED_PERCENT,
|
|
NetworkRuntimeEngine,
|
|
captureRows,
|
|
firewallRuleText,
|
|
gnutellaPeerCountOfText,
|
|
gnutellaResultRow,
|
|
interfaceKey,
|
|
IP_ADDRESS_PATTERN,
|
|
portRuleSender,
|
|
stableGeneratedMacAddress,
|
|
type CapturedFrame,
|
|
type CaptureRow,
|
|
type Id,
|
|
type InstallableProgram,
|
|
type LogLine,
|
|
type PageLanguage,
|
|
type TopologySnapshot,
|
|
} from '@mdg-filius/core';
|
|
import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, readFilesInto, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
|
|
import {
|
|
parseClientMessage,
|
|
SIM_PROTOCOL_VERSION,
|
|
type AppKind,
|
|
type ClientMessage,
|
|
type ErrorCode,
|
|
type HostMessage,
|
|
type NodeSettings,
|
|
type SimMode,
|
|
type Topic,
|
|
type EmailClientView,
|
|
type EmailServerView,
|
|
type DnsServerView,
|
|
type EchoServerView,
|
|
type FirewallView,
|
|
type GnutellaView,
|
|
type SimpleClientView,
|
|
type TraceFrame,
|
|
type WebServerView,
|
|
} from '@mdg-filius/protocol';
|
|
import { BrowserWindow } from './browserWindow.js';
|
|
import { TerminalWindow } from './terminalWindow.js';
|
|
|
|
export const TICK_MILLISECONDS = 200;
|
|
/** How often a running simulation writes what it left into the document. */
|
|
export const WRITE_BACK_MILLISECONDS = 1_000;
|
|
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
|
|
|
|
export interface RoomOptions {
|
|
/** The wall clock, in milliseconds. */
|
|
readonly now: () => number;
|
|
/** A seed for each start. */
|
|
readonly seed: () => number;
|
|
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
|
|
readonly writeBackMilliseconds?: number;
|
|
}
|
|
|
|
interface Client {
|
|
readonly send: (message: HostMessage) => void;
|
|
readonly topics: Set<Topic>;
|
|
/** Per card, the number of the last frame sent. */
|
|
readonly traceSent: Map<Id, number>;
|
|
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
|
|
readonly appSent: Map<string, number>;
|
|
/** Per computer, the settings last sent (as text). */
|
|
readonly nodeSent: Map<Id, string>;
|
|
/** Per program window, the view last sent: the browser's as it is, others as text. */
|
|
readonly viewSent: Map<string, unknown>;
|
|
}
|
|
|
|
/** A program with a window: its lines. */
|
|
interface WithLog {
|
|
readonly log: { readonly all: readonly LogLine<unknown>[] };
|
|
}
|
|
|
|
const message = <T extends Omit<HostMessage, 'v'>>(fields: T) => ({ v: SIM_PROTOCOL_VERSION, ...fields }) as unknown as HostMessage;
|
|
|
|
export class SimulationRoom {
|
|
private readonly engine: NetworkRuntimeEngine;
|
|
private mode: SimMode = 'design';
|
|
private speed = DEFAULT_SPEED_PERCENT;
|
|
private readonly clients = new Map<number, Client>();
|
|
private nextClient = 1;
|
|
private lastWall: number | undefined;
|
|
/** The cable each card is plugged into, as the simulation started. */
|
|
private linkOfPort = new Map<Id, Id>();
|
|
/** Per card, how many frames it held at the last tick. */
|
|
private framesSeen = new Map<Id, number>();
|
|
private lastActivity: string[] = [];
|
|
/** Each computer's command line window, kept over stops. */
|
|
private readonly terminalWindows = new Map<Id, TerminalWindow>();
|
|
/** Each computer's browser window, kept over stops. */
|
|
private readonly browserWindows = new Map<Id, BrowserWindow>();
|
|
/** The language of the last start (the pages the devices write themselves). */
|
|
private language: PageLanguage | undefined;
|
|
/** Each node's file system and its revision when it was last written back. */
|
|
private writtenFiles = new Map<Id, { readonly files: object; readonly revision: number }>();
|
|
/** The wall clock's time of the last write-back. */
|
|
private lastWriteBack = 0;
|
|
/** Each computer's programs as the running engine has them. */
|
|
private software = new Map<Id, readonly InstallableProgram[]>();
|
|
|
|
constructor(
|
|
private readonly doc: Y.Doc,
|
|
private readonly options: RoomOptions,
|
|
) {
|
|
this.engine = new NetworkRuntimeEngine(options.seed(), undefined, { wallClock: options.now });
|
|
doc.on('update', () => {
|
|
if (this.mode === 'simulation') this.guarded(() => this.syncSoftware());
|
|
});
|
|
doc.getMap<Y.Map<unknown>>(ROOTS.nodes).observeDeep((events, transaction) => {
|
|
// The room's own writes hold what the engine has.
|
|
if (transaction.origin === SIM_ORIGIN) return;
|
|
const nodes = new Set<Id>();
|
|
for (const event of events) {
|
|
const [node, key] = event.path as string[];
|
|
// A device added or removed comes in at the next start.
|
|
if (node !== undefined && (key === 'files' || (key === undefined && event.keys.has('files')))) nodes.add(node);
|
|
}
|
|
if (nodes.size > 0) this.guarded(() => this.syncFiles(nodes));
|
|
});
|
|
}
|
|
|
|
/** The document's files of the nodes into the running engine's (a person wrote them). */
|
|
private syncFiles(nodes: ReadonlySet<Id>): void {
|
|
const fileSystems = this.engine.state.topologySnapshot.fileSystemsByNodeId;
|
|
for (const id of nodes) {
|
|
const files = fileSystems.get(id);
|
|
// A node whose programs use no files has none in the engine; it reads the document's at the next start.
|
|
if (files !== undefined && readFilesInto(this.doc, id, files)) this.writtenFiles.set(id, { files, revision: files.revision });
|
|
}
|
|
}
|
|
|
|
/** Whether a node's files changed since they were last written back; marked written if so. */
|
|
private takeChangedFiles(id: Id, files: { readonly revision: number } | undefined): boolean {
|
|
const written = this.writtenFiles.get(id);
|
|
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
|
|
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
|
|
return changed;
|
|
}
|
|
|
|
/** The computers' programs in the document. */
|
|
private documentSoftware(): Map<Id, readonly InstallableProgram[]> {
|
|
const software = new Map<Id, readonly InstallableProgram[]>();
|
|
for (const id of this.doc.getMap(ROOTS.nodes).keys()) {
|
|
const programs = installedSoftware(this.doc, id);
|
|
if (programs !== undefined) software.set(id, programs);
|
|
}
|
|
return software;
|
|
}
|
|
|
|
/** Installs and removes in the running engine what the document's programs changed (Java's software installation). */
|
|
private syncSoftware(): void {
|
|
const now = this.documentSoftware();
|
|
const changes: { readonly node: Id; readonly installed: readonly InstallableProgram[]; readonly removed: readonly InstallableProgram[] }[] = [];
|
|
for (const [node, programs] of now) {
|
|
const before = this.software.get(node);
|
|
// A computer added since the start comes in at the next start.
|
|
if (before === undefined) continue;
|
|
const installed = programs.filter((program) => !before.includes(program));
|
|
const removed = before.filter((program) => !programs.includes(program));
|
|
if (installed.length > 0 || removed.length > 0) changes.push({ node, installed, removed });
|
|
}
|
|
if (changes.length === 0) return;
|
|
const source = this.snapshot();
|
|
for (const { node, installed, removed } of changes) {
|
|
for (const program of removed) this.engine.removeProgram(node, program);
|
|
for (const program of installed) this.engine.installProgram(node, program, source);
|
|
this.software.set(node, now.get(node)!);
|
|
}
|
|
this.syncWindows();
|
|
this.flush([...this.clients.values()], false);
|
|
}
|
|
|
|
/**
|
|
* What the run left of each node that Java's project keeps: its files (when
|
|
* they changed since the last write), whether its servers run, a web
|
|
* server's virtual hosts, an e-mail server's domain and accounts, an
|
|
* e-mail program's account and folders, a firewall, Gnutella's number of
|
|
* peers, a DHCP client's address.
|
|
*/
|
|
private runState(): Map<Id, NodeRunState> {
|
|
const snapshot = this.engine.state.topologySnapshot;
|
|
const states = new Map<Id, NodeRunState>();
|
|
const nodes = this.doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
|
for (const { id } of snapshot.nodes) {
|
|
const node = nodes.get(id);
|
|
if (node === undefined) continue;
|
|
const setting = (key: string) => fromY((node.get('settings') as Y.Map<unknown>).get(key));
|
|
const active: NonNullable<NodeRunState['active']> = {};
|
|
const settings: Record<string, unknown> = {};
|
|
const web = this.engine.webServer(id);
|
|
const webSettings = snapshot.webServersByNodeId.get(id);
|
|
if (web !== undefined && webSettings !== undefined) {
|
|
active.webServer = web.isActive;
|
|
settings.webServer = { port: web.port, documentRoot: webSettings.documentRoot, virtualHosts: web.useVirtualHosts };
|
|
}
|
|
const mailServer = this.engine.emailServer(id);
|
|
const mailServerSettings = snapshot.emailServersByNodeId.get(id);
|
|
const configured = setting('emailServer');
|
|
if (mailServer !== undefined && mailServerSettings !== undefined && configured !== undefined) {
|
|
active.emailServer = mailServer.isActive;
|
|
settings.emailServer = emailServerAfterRun(configured as never, mailServerSettings);
|
|
}
|
|
const echo = this.engine.echoServer(id);
|
|
if (echo !== undefined) {
|
|
active.echoServer = echo.isActive;
|
|
settings.echoServer = { port: echo.port };
|
|
}
|
|
const dns = this.engine.dnsServer(id);
|
|
const dnsSettings = setting('dnsServer');
|
|
if (dns !== undefined && dnsSettings !== undefined) {
|
|
active.dnsServer = dns.isActive;
|
|
settings.dnsServer = { ...(dnsSettings as object), recursiveResolutionEnabled: dns.recursiveResolutionEnabled, records: dns.answering };
|
|
}
|
|
const client = this.engine.simpleClient(id);
|
|
if (client !== undefined) settings.simpleClient = { targetAddress: client.targetAddress, targetPort: client.targetPort };
|
|
const gnutella = this.engine.gnutella(id);
|
|
if (gnutella !== undefined) settings.gnutella = { maximumKnownPeers: gnutella.maximumKnownPeers };
|
|
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
|
|
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
|
|
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
|
|
const files = snapshot.fileSystemsByNodeId.get(id);
|
|
states.set(id, { ...(this.takeChangedFiles(id, files) && files !== undefined ? { files } : {}), active, settings });
|
|
}
|
|
return states;
|
|
}
|
|
|
|
/** Writes what the run left into the document (origin 'sim'). */
|
|
private writeBack(): void {
|
|
if (this.engine.state.phase !== 'running') return;
|
|
this.lastWriteBack = this.options.now();
|
|
writeRunState(this.doc, this.runState(), SIM_ORIGIN);
|
|
}
|
|
|
|
/** Writes the files the engine changed since they were last written (origin 'sim'); nothing when none changed. */
|
|
private writeFilesBack(): void {
|
|
const states = new Map<Id, NodeRunState>();
|
|
for (const [id, files] of this.engine.state.topologySnapshot.fileSystemsByNodeId) if (this.takeChangedFiles(id, files)) states.set(id, { files });
|
|
if (states.size > 0) writeRunState(this.doc, states, SIM_ORIGIN);
|
|
}
|
|
|
|
/** The document as the engine takes it, in the language of the start. */
|
|
private snapshot(): TopologySnapshot {
|
|
const snapshot = docSnapshot(this.doc);
|
|
return this.language === undefined ? snapshot : { ...snapshot, language: this.language };
|
|
}
|
|
|
|
/** A window for each command line and browser the engine runs, the same over stops (TerminalWindow, BrowserWindow). */
|
|
private syncWindows(): void {
|
|
for (const node of this.engine.state.topologySnapshot.nodes) {
|
|
const terminal = this.engine.terminal(node.id);
|
|
if (terminal !== undefined) {
|
|
const window = this.terminalWindows.get(node.id);
|
|
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
|
|
else window.attach(terminal);
|
|
}
|
|
const browser = this.engine.webBrowser(node.id);
|
|
if (browser !== undefined) {
|
|
const window = this.browserWindows.get(node.id);
|
|
if (window === undefined) this.browserWindows.set(node.id, new BrowserWindow(browser));
|
|
else window.attach(browser);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A new client; it gets the room's state at once. */
|
|
connect(send: (message: HostMessage) => void): number {
|
|
const id = this.nextClient++;
|
|
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
|
|
send(this.state());
|
|
return id;
|
|
}
|
|
|
|
disconnect(client: number): void {
|
|
this.clients.delete(client);
|
|
}
|
|
|
|
/** How many clients the room has (F stops a room nobody watches). */
|
|
get clientCount(): number {
|
|
return this.clients.size;
|
|
}
|
|
|
|
get currentMode(): SimMode {
|
|
return this.mode;
|
|
}
|
|
|
|
/** A client's message, as it came over the wire. */
|
|
receive(clientId: number, raw: unknown): void {
|
|
const client = this.clients.get(clientId);
|
|
if (client === undefined) return;
|
|
const parsed = parseClientMessage(raw);
|
|
if ('error' in parsed) {
|
|
const reqId = typeof (raw as { reqId?: unknown } | null)?.reqId === 'string' ? (raw as { reqId: string }).reqId : undefined;
|
|
client.send(message({ kind: 'error', code: parsed.error.code, detail: parsed.error.detail, ...(reqId === undefined ? {} : { reqId }) }));
|
|
return;
|
|
}
|
|
this.handle(client, parsed.message);
|
|
}
|
|
|
|
private handle(client: Client, input: ClientMessage): void {
|
|
switch (input.kind) {
|
|
case 'sub':
|
|
for (const topic of input.topics) {
|
|
client.topics.add(topic);
|
|
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
|
|
if (topic.startsWith('app:')) {
|
|
client.appSent.delete(topic);
|
|
client.viewSent.delete(topic);
|
|
}
|
|
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
|
|
}
|
|
this.flush([client], true);
|
|
return;
|
|
case 'unsub':
|
|
for (const topic of input.topics) client.topics.delete(topic);
|
|
return;
|
|
case 'start':
|
|
this.start(input.language);
|
|
return;
|
|
case 'stop':
|
|
this.stop();
|
|
return;
|
|
case 'speed':
|
|
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
|
|
this.engine.setSimulationSpeed(this.speed);
|
|
this.broadcast(this.state());
|
|
return;
|
|
case 'input': {
|
|
let failure: ErrorCode | undefined = 'engineFailed';
|
|
if (!this.guarded(() => (failure = this.act(input)))) return;
|
|
if (failure === undefined) this.guarded(() => this.writeBack());
|
|
client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId }));
|
|
this.flush([...this.clients.values()], false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private state(): HostMessage {
|
|
return message({ kind: 'state', mode: this.mode, speed: this.speed, t: this.engine.state.currentTimeMilliseconds });
|
|
}
|
|
|
|
private broadcast(output: HostMessage): void {
|
|
for (const client of this.clients.values()) client.send(output);
|
|
}
|
|
|
|
start(language?: PageLanguage): void {
|
|
if (this.mode === 'simulation') return;
|
|
this.language = language;
|
|
const snapshot = this.snapshot();
|
|
this.engine.setSimulationSpeed(this.speed);
|
|
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
|
|
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
|
|
this.software = this.documentSoftware();
|
|
this.syncWindows();
|
|
this.mode = 'simulation';
|
|
this.lastWall = this.options.now();
|
|
this.lastWriteBack = this.lastWall;
|
|
this.broadcast(this.state());
|
|
this.flush([...this.clients.values()], false);
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.mode === 'design') return;
|
|
// A failure already stopped the engine and went back to the design.
|
|
if (!this.guarded(() => this.writeBack())) return;
|
|
this.engine.handle({ type: 'stop' });
|
|
this.mode = 'design';
|
|
this.lastWall = undefined;
|
|
this.broadcast(this.state());
|
|
this.flush([...this.clients.values()], false);
|
|
}
|
|
|
|
/** Lets the simulation run to the wall clock's time, then sends what came of it. */
|
|
tick(): void {
|
|
if (this.mode !== 'simulation') return;
|
|
const now = this.options.now();
|
|
const behind = Math.max(0, now - (this.lastWall ?? now));
|
|
// Whole milliseconds, as the engine counts them; the wall clock's fraction is kept for the next tick.
|
|
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.floor(behind));
|
|
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
|
|
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
|
|
this.flush([...this.clients.values()], false);
|
|
if (now - this.lastWriteBack >= (this.options.writeBackMilliseconds ?? WRITE_BACK_MILLISECONDS)) this.guarded(() => this.writeBack());
|
|
else this.guarded(() => this.writeFilesBack());
|
|
}
|
|
|
|
/**
|
|
* Runs a step of the engine. When the core throws (a part it has not got
|
|
* yet), the room tells every client and goes back to design mode instead of
|
|
* dying with its worker; false then.
|
|
*/
|
|
private guarded(step: () => void): boolean {
|
|
try {
|
|
step();
|
|
return true;
|
|
} catch (error) {
|
|
this.broadcast(message({ kind: 'error', code: 'engineFailed', detail: error instanceof Error ? error.message : String(error) }));
|
|
if (this.engine.state.phase === 'running') this.engine.handle({ type: 'stop' });
|
|
this.mode = 'design';
|
|
this.lastWall = undefined;
|
|
this.broadcast(this.state());
|
|
this.flush([...this.clients.values()], false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** An action in a program's window; the failure, if any. */
|
|
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
if (this.mode !== 'simulation') return 'notRunning';
|
|
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
|
|
switch (input.app) {
|
|
case 'terminal':
|
|
return this.actInTerminal(input);
|
|
case 'webBrowser':
|
|
return this.actInBrowser(input);
|
|
case 'webServer':
|
|
return this.actInWebServer(input);
|
|
case 'emailClient':
|
|
return this.actInEmailClient(input);
|
|
case 'emailServer':
|
|
return this.actInEmailServer(input);
|
|
case 'echoServer':
|
|
return this.actInEchoServer(input);
|
|
case 'simpleClient':
|
|
return this.actInSimpleClient(input);
|
|
case 'dnsServer':
|
|
return this.actInDnsServer(input);
|
|
case 'firewall':
|
|
return this.actInFirewall(input);
|
|
case 'gnutella':
|
|
return this.actInGnutella(input);
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The start button, the box for recursive resolution, a table's "Hinzufügen", "Auswahl entfernen" and a cell written. */
|
|
private actInDnsServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const server = this.engine.dnsServer(input.node);
|
|
if (server === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
const type = args.type === 'A' || args.type === 'MX' || args.type === 'NS' ? args.type : undefined;
|
|
const row = Number.isInteger(args.row) ? (args.row as number) : undefined;
|
|
switch (input.action) {
|
|
case 'setActive':
|
|
if (typeof args.active !== 'boolean') return 'badArgs';
|
|
server.setActive(args.active);
|
|
return undefined;
|
|
case 'setRecursive':
|
|
if (typeof args.enabled !== 'boolean') return 'badArgs';
|
|
server.setRecursiveResolutionEnabled(args.enabled);
|
|
return undefined;
|
|
case 'addRecord':
|
|
if (type === undefined || typeof args.name !== 'string' || typeof args.data !== 'string') return 'badArgs';
|
|
return server.addRecord(type, args.name, args.data) ? undefined : 'badArgs';
|
|
case 'removeRecord':
|
|
if (type === undefined || row === undefined) return 'badArgs';
|
|
return server.removeRecord(type, row) ? undefined : 'badArgs';
|
|
case 'changeRecord':
|
|
if (type === undefined || row === undefined || (args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
|
|
return server.changeRecord(type, row, args.column, args.value) ? undefined : 'badArgs';
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The Gnutella window: "Netz beitreten" with the address, "Netz verlassen", "Suchen" with the words, "Stopp!", "Liste leeren", "Herunterladen" of the row chosen, the number of peers saved. */
|
|
private actInGnutella(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const peer = this.engine.gnutella(input.node);
|
|
if (peer === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
switch (input.action) {
|
|
case 'join':
|
|
if (typeof args.address !== 'string' || !IP_ADDRESS_PATTERN.test(args.address)) return 'badArgs';
|
|
peer.join(args.address);
|
|
return undefined;
|
|
case 'leave':
|
|
peer.reset();
|
|
return undefined;
|
|
case 'search':
|
|
if (typeof args.criteria !== 'string') return 'badArgs';
|
|
peer.search(args.criteria);
|
|
return undefined;
|
|
case 'cancelSearch':
|
|
peer.cancelSearch();
|
|
return undefined;
|
|
case 'clearResults':
|
|
peer.clearResults();
|
|
return undefined;
|
|
case 'download':
|
|
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= peer.results.length) return 'badArgs';
|
|
peer.download(args.row as number);
|
|
return undefined;
|
|
case 'setMaximumPeers': {
|
|
const count = Number.isInteger(args.count) ? gnutellaPeerCountOfText(String(args.count)) : undefined;
|
|
if (count === undefined) return 'badArgs';
|
|
peer.setMaximumKnownPeers(count);
|
|
return undefined;
|
|
}
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** A computer's firewall window: its three boxes, "Hinzufügen" with the port, the protocol and the senders chosen, "Löschen" of the row chosen. */
|
|
private actInFirewall(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const configuration = this.engine.personalFirewall(input.node);
|
|
if (configuration === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
const set = (fields: Partial<typeof configuration>) => this.engine.setFirewallConfiguration(input.node, { ...configuration, ...fields });
|
|
switch (input.action) {
|
|
case 'setActive':
|
|
if (typeof args.active !== 'boolean') return 'badArgs';
|
|
set({ isActive: args.active });
|
|
return undefined;
|
|
case 'setIcmpAllowed':
|
|
if (typeof args.allowed !== 'boolean') return 'badArgs';
|
|
set({ dropIcmp: !args.allowed });
|
|
return undefined;
|
|
case 'setFilterUdp':
|
|
if (typeof args.enabled !== 'boolean') return 'badArgs';
|
|
set({ filterUdp: args.enabled });
|
|
return undefined;
|
|
case 'addRule':
|
|
if (!isPort(args.port) || args.port === 0 || (args.protocol !== 'tcp' && args.protocol !== 'udp' && args.protocol !== 'all') || typeof args.sameNetworkOnly !== 'boolean') return 'badArgs';
|
|
this.engine.addFirewallPortRule(input.node, args.port, args.protocol, args.sameNetworkOnly);
|
|
return undefined;
|
|
case 'removeRule':
|
|
return Number.isInteger(args.row) && this.engine.removeFirewallRule(input.node, args.row as number) ? undefined : 'badArgs';
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The start button with the port typed (Java's window sets the port before and after it starts the server). */
|
|
private actInEchoServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const server = this.engine.echoServer(input.node);
|
|
if (server === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
if (input.action !== 'setActive') return 'unknownAction';
|
|
if (typeof args.active !== 'boolean') return 'badArgs';
|
|
if (!args.active) {
|
|
server.setActive(false);
|
|
return undefined;
|
|
}
|
|
if (!isPort(args.port)) return 'badArgs';
|
|
server.setPort(args.port);
|
|
server.setActive(true);
|
|
return undefined;
|
|
}
|
|
|
|
/** "Verbinden" with the address and port typed, "Trennen", "Senden". */
|
|
private actInSimpleClient(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const client = this.engine.simpleClient(input.node);
|
|
if (client === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
switch (input.action) {
|
|
case 'connect':
|
|
if (typeof args.address !== 'string' || !isPort(args.port)) return 'badArgs';
|
|
client.connect(args.address, args.port);
|
|
return undefined;
|
|
case 'disconnect':
|
|
client.disconnect();
|
|
return undefined;
|
|
case 'send':
|
|
if (typeof args.text !== 'string') return 'badArgs';
|
|
client.send(args.text);
|
|
return undefined;
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The account dialog (setAccount), the compose window (send), fetching and deleting a mail of a folder. */
|
|
private actInEmailClient(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const client = this.engine.emailClient(input.node);
|
|
if (client === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
const text = (...names: string[]) => names.every((name) => typeof args[name] === 'string');
|
|
switch (input.action) {
|
|
case 'setAccount': {
|
|
if (!text('name', 'email', 'pop3Host', 'pop3Port', 'smtpHost', 'smtpPort', 'username', 'password')) return 'badArgs';
|
|
const account = accountOfForm(args as unknown as Parameters<typeof accountOfForm>[0]);
|
|
if (account === undefined) return 'badArgs';
|
|
client.setAccount(account);
|
|
return undefined;
|
|
}
|
|
case 'send':
|
|
if (!text('to', 'cc', 'bcc', 'subject', 'text')) return 'badArgs';
|
|
return client.send(args as unknown as Parameters<typeof client.send>[0]) === undefined ? undefined : 'badArgs';
|
|
case 'fetch':
|
|
// Without an account Java's button does nothing.
|
|
client.fetch();
|
|
return undefined;
|
|
case 'removeMail':
|
|
if ((args.folder !== 'inbox' && args.folder !== 'sent') || !Number.isInteger(args.index)) return 'badArgs';
|
|
client.removeMail(args.folder, args.index as number);
|
|
return undefined;
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The start button, the domain (only while the server is off, as Java's field), a new account and the removal of one. */
|
|
private actInEmailServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const server = this.engine.emailServer(input.node);
|
|
if (server === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
switch (input.action) {
|
|
case 'setActive':
|
|
if (typeof args.active !== 'boolean') return 'badArgs';
|
|
server.setActive(args.active, true);
|
|
return undefined;
|
|
case 'setDomain':
|
|
if (typeof args.domain !== 'string') return 'badArgs';
|
|
return !server.isActive && server.setDomain(args.domain) ? undefined : 'badArgs';
|
|
case 'addAccount': {
|
|
const { username, password, firstName, lastName } = args;
|
|
if (typeof username !== 'string' || typeof password !== 'string' || typeof firstName !== 'string' || typeof lastName !== 'string') return 'badArgs';
|
|
// The window's checks: a user name and a password, the name without white space (musterKeineLeerzeichen).
|
|
if (username === '' || password === '' || /[ \t\n\v\f\r]/.test(username)) return 'badArgs';
|
|
return server.addAccount({ username, password, firstName, lastName }) ? undefined : 'badArgs';
|
|
}
|
|
case 'removeAccount':
|
|
if (typeof args.username !== 'string') return 'badArgs';
|
|
return server.removeAccount(args.username) ? undefined : 'badArgs';
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
private actInTerminal(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
// A window has a program while the simulation runs (checked above), from its first start on.
|
|
const window = this.terminalWindows.get(input.node);
|
|
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
|
|
switch (input.action) {
|
|
case 'enter':
|
|
if (typeof input.args.line !== 'string') return 'badArgs';
|
|
window.enter(input.args.line);
|
|
return undefined;
|
|
case 'interrupt':
|
|
window.interrupt();
|
|
return undefined;
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The address line (open) and the page's links and forms (follow). */
|
|
private actInBrowser(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const window = this.browserWindows.get(input.node);
|
|
if (window === undefined || this.engine.webBrowser(input.node) === undefined) return 'unknownApp';
|
|
switch (input.action) {
|
|
case 'open':
|
|
if (typeof input.args.address !== 'string') return 'badArgs';
|
|
window.open(input.args.address);
|
|
return undefined;
|
|
case 'follow': {
|
|
const { href, post } = input.args;
|
|
if (typeof href !== 'string' || (post !== undefined && typeof post !== 'string')) return 'badArgs';
|
|
window.follow(href, post);
|
|
return undefined;
|
|
}
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** The start button, the virtual hosts' check box and a cell of their table. */
|
|
private actInWebServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
|
const server = this.engine.webServer(input.node);
|
|
if (server === undefined) return 'unknownApp';
|
|
const { args } = input;
|
|
switch (input.action) {
|
|
case 'setActive':
|
|
if (typeof args.active !== 'boolean') return 'badArgs';
|
|
server.setActive(args.active);
|
|
return undefined;
|
|
case 'useVirtualHosts':
|
|
if (typeof args.use !== 'boolean') return 'badArgs';
|
|
server.setUseVirtualHosts(args.use);
|
|
return undefined;
|
|
case 'setVirtualHost':
|
|
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= server.virtualHostTable.length) return 'badArgs';
|
|
if ((args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
|
|
server.setVirtualHost(args.row as number, args.column, args.value);
|
|
return undefined;
|
|
default:
|
|
return 'unknownAction';
|
|
}
|
|
}
|
|
|
|
/** What a program's window shows besides its lines; null when it has nothing (the program does not run, a browser never ran). */
|
|
private viewOf(node: Id, app: AppKind): unknown {
|
|
switch (app) {
|
|
case 'webBrowser':
|
|
return this.browserWindows.get(node)?.view ?? null;
|
|
case 'webServer': {
|
|
const server = this.engine.webServer(node);
|
|
if (server === undefined) return null;
|
|
const view: WebServerView = { active: server.isActive, useVirtualHosts: server.useVirtualHosts, virtualHosts: server.virtualHostTable.map(([host, folder]) => [host, folder] as const) };
|
|
return view;
|
|
}
|
|
case 'emailClient': {
|
|
const client = this.engine.emailClient(node);
|
|
if (client === undefined) return null;
|
|
const view: EmailClientView = { account: client.account, inbox: client.inbox.map((entry) => entry.mail), sent: client.sent.map((entry) => entry.mail) };
|
|
return view;
|
|
}
|
|
case 'dnsServer': {
|
|
const server = this.engine.dnsServer(node);
|
|
if (server === undefined) return null;
|
|
const rows = (type: 'A' | 'MX' | 'NS') => server.rows(type).map(({ record }) => [record.domainName, record.rdata] as const);
|
|
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
|
|
return view;
|
|
}
|
|
case 'gnutella': {
|
|
const peer = this.engine.gnutella(node);
|
|
if (peer === undefined) return null;
|
|
const view: GnutellaView = { peers: peer.peers, results: peer.results.map(gnutellaResultRow), files: peer.fileRows(), maximumKnownPeers: peer.maximumKnownPeers };
|
|
return view;
|
|
}
|
|
case 'firewall': {
|
|
const configuration = this.engine.personalFirewall(node);
|
|
if (configuration === undefined) return null;
|
|
const first = this.engine.networkInterfaces(node)[0];
|
|
const rules = configuration.rules.map((rule) => ({ protocol: rule.protocol, port: rule.port, sender: portRuleSender(rule) ?? null, text: firewallRuleText(rule, first) }));
|
|
const view: FirewallView = { active: configuration.isActive, icmpAllowed: !configuration.dropIcmp, filterUdp: configuration.filterUdp, rules };
|
|
return view;
|
|
}
|
|
case 'echoServer': {
|
|
const server = this.engine.echoServer(node);
|
|
if (server === undefined) return null;
|
|
const view: EchoServerView = { active: server.isActive, port: server.port };
|
|
return view;
|
|
}
|
|
case 'simpleClient': {
|
|
const client = this.engine.simpleClient(node);
|
|
if (client === undefined) return null;
|
|
const view: SimpleClientView = { connected: client.isConnected, address: client.targetAddress, port: client.targetPort };
|
|
return view;
|
|
}
|
|
case 'emailServer': {
|
|
const server = this.engine.emailServer(node);
|
|
if (server === undefined) return null;
|
|
const view: EmailServerView = {
|
|
active: server.isActive,
|
|
domain: server.domain,
|
|
accounts: server.state.accounts.map((account) => ({ username: account.username, mails: account.mailbox.length })),
|
|
};
|
|
return view;
|
|
}
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private program(node: Id, app: AppKind): WithLog | undefined {
|
|
switch (app) {
|
|
case 'terminal':
|
|
return this.engine.terminal(node);
|
|
case 'webBrowser':
|
|
return this.engine.webBrowser(node);
|
|
case 'webServer':
|
|
return this.engine.webServer(node);
|
|
case 'emailClient':
|
|
return this.engine.emailClient(node);
|
|
case 'emailServer':
|
|
return this.engine.emailServer(node);
|
|
case 'echoServer':
|
|
return this.engine.echoServer(node);
|
|
case 'simpleClient':
|
|
return this.engine.simpleClient(node);
|
|
case 'gnutella':
|
|
return this.engine.gnutella(node);
|
|
case 'firewall':
|
|
// Its log outlives a stop, as the engine keeps it.
|
|
return this.engine.personalFirewall(node) === undefined ? undefined : { log: { all: this.engine.firewallLog(node) } };
|
|
}
|
|
}
|
|
|
|
/** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */
|
|
private flush(clients: readonly Client[], subscribing: boolean): void {
|
|
if (!subscribing) this.updateActivity();
|
|
for (const window of this.terminalWindows.values()) window.pull();
|
|
for (const window of this.browserWindows.values()) window.pull();
|
|
const rowsCache = new Map<Id, TraceFrame[]>();
|
|
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
|
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
|
|
if (frames.length === 0) return [];
|
|
if (after === 0 && rowsCache.has(portId)) return rowsCache.get(portId)!;
|
|
const result = traceFramesOf(frames);
|
|
if (after === 0) rowsCache.set(portId, result);
|
|
return result;
|
|
};
|
|
for (const client of clients) {
|
|
for (const topic of client.topics) {
|
|
if (topic === 'activity') {
|
|
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
|
|
} else if (topic.startsWith('node:')) {
|
|
const node = topic.slice('node:'.length);
|
|
const settings = this.nodeSettings(node);
|
|
if (settings === undefined) continue;
|
|
const text = JSON.stringify(settings);
|
|
if (client.nodeSent.get(node) === text) continue;
|
|
client.nodeSent.set(node, text);
|
|
client.send(message({ kind: 'node', node, settings }));
|
|
} else if (topic.startsWith('trace:')) {
|
|
const portId = topic.slice('trace:'.length);
|
|
const reset = !client.traceSent.has(portId);
|
|
const frames = traceFrames(portId, client.traceSent.get(portId) ?? 0);
|
|
if (frames.length === 0 && !reset) continue;
|
|
client.traceSent.set(portId, frames.at(-1)?.number ?? client.traceSent.get(portId) ?? 0);
|
|
client.send(message({ kind: 'trace', portId, frames, reset }));
|
|
} else {
|
|
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
|
|
const view = this.viewOf(node!, app as AppKind);
|
|
if (view !== undefined) {
|
|
// The browser's view is made anew only when it changes; the server's is small, compared as text.
|
|
const key = app === 'webBrowser' ? view : JSON.stringify(view);
|
|
if (!client.viewSent.has(topic) || client.viewSent.get(topic) !== key) {
|
|
client.viewSent.set(topic, key);
|
|
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
|
|
}
|
|
// The browser's lines are its view; the DNS server's and Gnutella's windows have none.
|
|
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella') continue;
|
|
}
|
|
const sent = client.appSent.get(topic);
|
|
if (app === 'terminal') {
|
|
// The window's lines, kept over stops: a client goes on from those it has.
|
|
const lines = this.terminalWindows.get(node!)?.lines ?? [];
|
|
const from = sent ?? 0;
|
|
if (sent !== undefined && lines.length === from) continue;
|
|
client.appSent.set(topic, lines.length);
|
|
client.send(message({ kind: 'app', node: node!, app: 'terminal', from, lines: lines.slice(from) }));
|
|
continue;
|
|
}
|
|
const program = this.program(node!, app as AppKind);
|
|
if (program === undefined) {
|
|
// A window of a program that is not running: it starts empty with the next start.
|
|
if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] }));
|
|
client.appSent.delete(topic);
|
|
continue;
|
|
}
|
|
// The window goes on from the lines it has; after a stop (none sent) it begins anew.
|
|
const from = sent ?? 0;
|
|
const lines = program.log.all.slice(from);
|
|
if (sent !== undefined && lines.length === 0) continue;
|
|
client.appSent.set(topic, program.log.all.length);
|
|
client.send(message({ kind: 'app', node: node!, app: app as AppKind, from, lines }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A device's first card as the engine runs it (a computer's settings, which
|
|
* DHCP may change); undefined for a device the simulation never had.
|
|
*/
|
|
private nodeSettings(id: Id): NodeSettings | undefined {
|
|
const snapshot = this.engine.state.topologySnapshot;
|
|
const node = snapshot.nodes.find((each) => each.id === id);
|
|
if (node === undefined) return undefined;
|
|
const port = node.ports[0];
|
|
const device = snapshot.deviceConfigurations.get(id);
|
|
const card = port === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, port.id));
|
|
return {
|
|
ipAddress: device?.ipAddress ?? card?.ipAddress ?? '',
|
|
subnetMask: device?.subnetMask ?? card?.subnetMask ?? '',
|
|
defaultGateway: device?.defaultGateway ?? '',
|
|
dnsServer: device?.dnsServer ?? '',
|
|
macAddress: port === undefined ? '' : (port.macAddress ?? stableGeneratedMacAddress(port.id)),
|
|
};
|
|
}
|
|
|
|
/** The cables whose cards got frames since the last tick; sent when that changes. */
|
|
private updateActivity(): void {
|
|
const links = new Set<string>();
|
|
for (const portId of this.engine.capture.portIds()) {
|
|
const frames = this.engine.capture.frames(portId);
|
|
const count = frames.at(-1)?.number ?? 0;
|
|
if (count > (this.framesSeen.get(portId) ?? 0)) {
|
|
const link = this.linkOfPort.get(portId);
|
|
if (link !== undefined) links.add(link);
|
|
}
|
|
this.framesSeen.set(portId, count);
|
|
}
|
|
const activity = [...links].sort();
|
|
if (activity.join() === this.lastActivity.join()) return;
|
|
this.lastActivity = activity;
|
|
for (const client of this.clients.values()) if (client.topics.has('activity')) client.send(message({ kind: 'activity', links: activity }));
|
|
}
|
|
}
|
|
|
|
/** A port a socket can have. */
|
|
function isPort(value: unknown): value is number {
|
|
return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 65_535;
|
|
}
|
|
|
|
/** A card's frames as the capture window shows them, both views. */
|
|
export function traceFramesOf(frames: readonly CapturedFrame[]): TraceFrame[] {
|
|
const byNumber = (rows: readonly CaptureRow[]) => {
|
|
const grouped = new Map<number, CaptureRow[]>();
|
|
for (const row of rows) grouped.set(row.number, [...(grouped.get(row.number) ?? []), row]);
|
|
return grouped;
|
|
};
|
|
const rows = byNumber(captureRows(frames, { withAddresses: true }));
|
|
const layered = byNumber(captureRows(frames, { withAddresses: false }));
|
|
return frames.map((frame) => ({ number: frame.number, rows: rows.get(frame.number) ?? [], layeredRows: layered.get(frame.number) ?? [] }));
|
|
}
|