feat(core): the pages of routers and home routers after Java, measured against Java
Routers and home routers start a web server on port 80 with Java's index, log and routes pages in the project's language (routerPages.ts); the firewall keeps a log per node that outlives a new start; RIP routes carry the telling router's public address. TCP counts sequence numbers and segments in Java's characters, not bytes. RouterWebRun records six stories, with firewalls now built by ScenarioNetwork. Not taken from Java: its router server sometimes never listening (a race in setAktiv), and </titel>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// The pages of FILIUS' routers and home routers (filius.software.system:
|
||||
// VermittlungsrechnerBetriebssystem, GatewayFirmware; the web server's
|
||||
// plug-ins FirewallWebLog and VermittlungWeb), written as Java writes them:
|
||||
// from its templates (tmpl/vermittlung_index, gateway_index, routing) and the
|
||||
// texts of its message bundle, in the project's language. The pages go over
|
||||
// the simulated network as they are, so they are written here, not by the
|
||||
// editor.
|
||||
//
|
||||
// Departure from Java: the German log page closes its title with </title>
|
||||
// (Java's bundle writes </titel>).
|
||||
|
||||
import type { FirewallRule } from '../config.js';
|
||||
import type { FirewallLogEntry } from '../engine.js';
|
||||
import { formatIPv4, maskAddress, parseIPv4 } from '../ipv4.js';
|
||||
import type { RouteRow } from '../routeTable.js';
|
||||
import { DIRECTLY_CONNECTED_SOURCE_MARKER } from '../config.js';
|
||||
|
||||
/** The languages FILIUS' pages come in (Java also has French). */
|
||||
export type PageLanguage = 'de' | 'en';
|
||||
|
||||
/** Java's templates as its WebServer and WebServerPlugIn read them: each line ended by "\n". */
|
||||
const TEMPLATES: Record<'routerIndex' | 'gatewayIndex' | 'routing', Record<PageLanguage, string>> = {
|
||||
routerIndex: {
|
||||
de: "<html>\n <head>\n <title>Administration des Vermittlungsrechners</title>\n </head>\n <body>\n\n <h1>Firewall-Administration</h1>\n <p> <a href=\"konfig\">Konfiguration</a> </p>\n <p> <a href=\"log\">Log-Einträge anzeigen</a> </p>\n\n <h1> Weiterleitung/Routing </h1>\n <p> <a href=\"routes\"> Anzeige des aktuellen Status der Weiterleitungs-/Routing-Tabelle </a></p>\n </body>\n</html>\n",
|
||||
en: "<html>\n <head>\n <title>Router Administration</title>\n </head>\n <body>\n <h1>Firewall Administration</h1>\n <p> <a href=\"konfig\">Configuration</a> </p>\n <p> <a href=\"log\">Show Log</a> </p>\n\n <h1> Routing/Forwarding </h1>\n <p> <a href=\"routes\"> Show current routing/forwarding table </a> </p>\n \n </body>\n</html>\n",
|
||||
},
|
||||
gatewayIndex: {
|
||||
de: "<html>\n <head>\n <title>Administration des Gateways</title>\n </head>\n <body>\n <h1>Firewall-Administration</h1>\n <p> <a href=\"konfig\">Konfiguration</a> </p>\n <p> <a href=\"log\">Log-Einträge anzeigen</a> </p>\n </body>\n</html>\n",
|
||||
en: "<html>\n <head>\n <title>Gateway Administration</title>\n </head>\n <body>\n <h1>Firewall Administration</h1>\n <p> <a href=\"konfig\">Configuration</a> </p>\n <p> <a href=\"log\">Show Log</a> </p> \n </body>\n</html>\n",
|
||||
},
|
||||
routing: {
|
||||
de: "<html>\n <head>\n <title>\n:title:\n </title>\n </head>\n <body>\n <h1>\n:title:\n </h1>\n\n <table border=1>\n <tr>\n <th colspan=2>Rechnernetz</th>\n <th>Hops</th>\n <th>Gültigkeit</th>\n <th colspan=2>Nächster Hop</th>\n </tr>\n <tr>\n <th>Adresse</th>\n <th>Netzmaske</th>\n <th></th>\n <th>(sec)</th>\n <th>Ziel</th>\n <th>Öffentliche Adresse</th>\n </tr>\n\n:routing_entries:\n\n </table>\n <p>\n:hint:\n </p>\n </body>\n</html>\n",
|
||||
en: "<html>\n <head>\n <title>\n:title:\n </title>\n </head>\n <body>\n <h1>\n:title:\n </h1>\n\n <table border=1>\n <tr>\n <th colspan=2>Network</th>\n <th>Hops</th>\n <th>Validity</th>\n <th colspan=2>Next Hop</th>\n </tr>\n <tr>\n <th>Address</th>\n <th>Netmask</th>\n <th></th>\n <th>(sec)</th>\n <th>Target</th>\n <th>Public Address</th>\n </tr>\n\n:routing_entries:\n\n </table>\n <p>\n:hint:\n </p>\n </body>\n</html>\n",
|
||||
},
|
||||
};
|
||||
|
||||
/** The texts of Java's message bundle the pages use. */
|
||||
interface PageTexts {
|
||||
readonly logHead: string;
|
||||
readonly logBody: string;
|
||||
readonly ruleMatched: string;
|
||||
readonly icmpDropped: string;
|
||||
readonly accept: string;
|
||||
readonly drop: string;
|
||||
readonly ripHint: string;
|
||||
readonly unlimited: string;
|
||||
readonly forwardingHint: string;
|
||||
readonly forwardingTitle: string;
|
||||
readonly ripTitle: string;
|
||||
}
|
||||
|
||||
const TEXTS: Record<PageLanguage, PageTexts> = {
|
||||
de: {
|
||||
/** sw_firewallweblog_msg1, msg2 (</title>, where Java writes </titel>). */
|
||||
logHead: '<html>\n\t<head>\n\t\t<title>Log-Einträge der Firewall</title>',
|
||||
logBody: '\n\t</head>\n\t<body>\n\t\t<h2>Log-Einträge der Firewall</h2>',
|
||||
/** sw_firewall_msg8, firewallthread_msg1, jfirewalldialog_msg33, msg34. */
|
||||
ruleMatched: 'Firewall-Regel hat gegriffen:',
|
||||
icmpDropped: 'ICMP-Paket verworfen:',
|
||||
accept: 'akzeptieren',
|
||||
drop: 'verwerfen',
|
||||
/** sw_vermittlungweb_msg1, msg3, msg4, msg5, msg6. */
|
||||
ripHint: 'Diese Tabelle wird durch das Routing Information Protocol (RIP) aufgebaut und aktualisiert.',
|
||||
unlimited: 'unbegrenzt',
|
||||
forwardingHint: 'Diese Tabelle wurde aus der IP-Konfiguration des Vermittlungsrechners und manuellen Einträgen erstellt.',
|
||||
forwardingTitle: 'Routing-/Weiterleitungs-Tabelle',
|
||||
ripTitle: 'Routing-Tabelle',
|
||||
},
|
||||
en: {
|
||||
logHead: '<html>\n\t<head>\n\t\t<title>Log-data set entry of the firewall</title>',
|
||||
logBody: '\n\t</head>\n\t<body>\n\t\t<h2>Log-data set entry of the firewall</h2>',
|
||||
ruleMatched: 'Firewall rule matched:',
|
||||
icmpDropped: 'Dropped ICMP packet:',
|
||||
accept: 'accept',
|
||||
drop: 'drop',
|
||||
ripHint: 'This table is generated and updated by the Routing Information Protocol (RIP).',
|
||||
unlimited: 'unlimited',
|
||||
forwardingHint: 'This table was created from the IP configuration of the router and manually added entries of the forwarding table.',
|
||||
forwardingTitle: 'Routing/Forwarding Table',
|
||||
ripTitle: 'Routing Table',
|
||||
},
|
||||
};
|
||||
|
||||
/** The index page a router or home router keeps in /webserver (WebServer.erzeugeIndexDatei). */
|
||||
export function routerIndexPage(kind: 'router' | 'gateway', language: PageLanguage): string {
|
||||
return TEMPLATES[kind === 'router' ? 'routerIndex' : 'gatewayIndex'][language];
|
||||
}
|
||||
|
||||
/** FirewallRule.toString(nics): the rule as the log names it. */
|
||||
export function firewallRuleText(rule: FirewallRule, firstInterface: { readonly ipAddress: string; readonly subnetMask: string } | undefined): string {
|
||||
const sameNetwork = rule.sourceIPAddress === DIRECTLY_CONNECTED_SOURCE_MARKER && firstInterface !== undefined;
|
||||
let text = '';
|
||||
if (rule.sourceIPAddress === '') {
|
||||
text += '*/';
|
||||
} else if (sameNetwork) {
|
||||
const address = parseIPv4(firstInterface.ipAddress);
|
||||
const mask = parseIPv4(firstInterface.subnetMask);
|
||||
const network = address === undefined || mask === undefined ? firstInterface.ipAddress : formatIPv4(maskAddress(address, mask));
|
||||
text += `${network}/${firstInterface.subnetMask} -> `;
|
||||
} else {
|
||||
text += `${rule.sourceIPAddress}/`;
|
||||
}
|
||||
if (!sameNetwork) text += rule.sourceSubnetMask === '' ? '* -> ' : `${rule.sourceSubnetMask} -> `;
|
||||
text += rule.destinationIPAddress === '' ? '*/' : `${rule.destinationIPAddress}/`;
|
||||
text += rule.destinationSubnetMask === '' ? '*; ' : `${rule.destinationSubnetMask}; `;
|
||||
text += rule.protocol === 'all' ? '*:' : rule.protocol === 'tcp' ? 'TCP:' : rule.protocol === 'udp' ? 'UDP:' : '?:';
|
||||
text += rule.port >= 0 ? `${rule.port} => ` : '* => ';
|
||||
return text + (rule.action === 'accept' ? 'ACCEPT' : 'DROP');
|
||||
}
|
||||
|
||||
/** A line of the firewall's log, as Firewall tells its observers. */
|
||||
export function firewallLogText(entry: FirewallLogEntry, language: PageLanguage): string {
|
||||
const texts = TEXTS[language];
|
||||
if (entry.kind === 'icmpDropped') {
|
||||
return `${texts.icmpDropped} ${entry.senderIPAddress} -> ${entry.receiverIPAddress} (code: ${entry.code}, type: ${entry.type})`;
|
||||
}
|
||||
return `${texts.ruleMatched} #${entry.ruleNumber} (${firewallRuleText(entry.rule, entry.firstInterface)}) -> ${entry.accepted ? texts.accept : texts.drop}`;
|
||||
}
|
||||
|
||||
/** FirewallWebLog.holeHtmlSeite: the log since the engine was made. */
|
||||
export function firewallLogPage(entries: readonly FirewallLogEntry[], language: PageLanguage): string {
|
||||
const texts = TEXTS[language];
|
||||
const lines = entries.map((entry) => `<br /> \n${firewallLogText(entry, language)}`).join('');
|
||||
return `${texts.logHead}${texts.logBody}\n\n${lines}\n\n\t</body>\n</html>`;
|
||||
}
|
||||
|
||||
function routingPage(title: string, entries: string, hint: string, language: PageLanguage): string {
|
||||
return TEMPLATES.routing[language].replaceAll(':title:', title).replaceAll(':routing_entries:', entries).replaceAll(':hint:', hint);
|
||||
}
|
||||
|
||||
/** VermittlungWeb.createForwardingTablePage: a router without RIP. */
|
||||
export function forwardingTablePage(rows: readonly RouteRow[], language: PageLanguage): string {
|
||||
const texts = TEXTS[language];
|
||||
const entries = rows
|
||||
.map((row) => {
|
||||
const direct = row.nextHop === row.interfaceIPAddress;
|
||||
const localhost = row.interfaceIPAddress === '127.0.0.1';
|
||||
const cells =
|
||||
`<td>${row.destinationNetwork}</td><td>${row.subnetMask}</td>` +
|
||||
(direct || localhost ? '<td> 0 </td>' : '<td> ? </td>') +
|
||||
`<td>${texts.unlimited}</td>` +
|
||||
`<td><a href="http://${row.nextHop}/routes">${row.nextHop}</a></td><td> - </td>`;
|
||||
if (direct || localhost) return `<tr style='background-color:#aaffaa'>${cells}</tr>`;
|
||||
if (row.subnetMask === '0.0.0.0') return `<tr style='background-color:#aaaaff'>${cells}</tr>`;
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join('');
|
||||
return routingPage(texts.forwardingTitle, entries, texts.forwardingHint, language);
|
||||
}
|
||||
|
||||
/** VermittlungWeb.createRoutingTablePage: a router with RIP; how long each learned route has left, in whole seconds. */
|
||||
export function ripTablePage(rows: readonly RouteRow[], nowMilliseconds: number, language: PageLanguage): string {
|
||||
const texts = TEXTS[language];
|
||||
const entries = rows
|
||||
.map((row) => {
|
||||
const hops = row.metric ?? 16;
|
||||
const validity = row.expiresAtMilliseconds === undefined ? texts.unlimited : String(Math.trunc((row.expiresAtMilliseconds - nowMilliseconds) / 1_000));
|
||||
const publicAddress = row.hopPublicIPAddress ?? '';
|
||||
const cells =
|
||||
`<td>${row.destinationNetwork}</td><td>${row.subnetMask}</td><td>${hops}</td><td>${validity}</td><td>${row.nextHop}</td>` +
|
||||
`<td><a href="http://${publicAddress}/routes">${publicAddress}</a></td>`;
|
||||
if (hops === 0) return `<tr style='background-color:#aaffaa'>${cells}</tr>`;
|
||||
if (hops === 16) return `<tr style='background-color:#ffaaaa'>${cells}</tr>`;
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join('');
|
||||
return routingPage(texts.ripTitle, entries, texts.ripHint, language);
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
// not reachable" when its request cannot go out or the answer cannot be
|
||||
// read (Java shows nothing).
|
||||
//
|
||||
// Not yet: the pages a router's or home router's server makes itself
|
||||
// (Java's plug-ins: firewall log and settings, routing table).
|
||||
// A server may make pages itself (Java's plug-ins, WebServerPlugIn): a
|
||||
// router's or home router's firewall log and routing table (routerPages.ts).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import type { VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
||||
@@ -37,6 +37,9 @@ import { TcpServerProgram } from './server.js';
|
||||
/** The port FILIUS' browser always connects to (WebBrowser.initConnection). */
|
||||
export const WEB_BROWSER_PORT = 80;
|
||||
|
||||
/** A page the server makes itself (WebServerPlugIn.holeHtmlSeite): from a POST's data, or undefined; undefined for none (500). */
|
||||
export type WebServerPage = (postData: string | undefined) => string | undefined;
|
||||
|
||||
/** WebServer's table of virtual hosts: five rows of host name and folder. */
|
||||
const VIRTUAL_HOST_ROWS = 5;
|
||||
|
||||
@@ -81,6 +84,7 @@ export class WebServer extends TcpServerProgram {
|
||||
nodeId: Id,
|
||||
configuration: WebServerSnapshot,
|
||||
private readonly fileSystem: VirtualFileSystem | undefined,
|
||||
private readonly pages: ReadonlyMap<string, WebServerPage> = new Map(),
|
||||
) {
|
||||
super(engine, nodeId, configuration);
|
||||
this.documentRoot = configuration.documentRoot;
|
||||
@@ -131,6 +135,12 @@ export class WebServer extends TcpServerProgram {
|
||||
if (!folder.endsWith('/')) folder += '/';
|
||||
file = folder + file;
|
||||
if (file.startsWith('/')) file = file.slice(1);
|
||||
// WebServer.holePlugin: a page of its own by its exact path.
|
||||
const page = this.pages.get(file);
|
||||
if (page !== undefined) {
|
||||
const html = page(parsed.request.method === HTTP_POST ? parsed.request.data : undefined);
|
||||
return html === undefined ? response(500) : response(200, 'text/html', html);
|
||||
}
|
||||
const absolute = evaluatePath(`${this.documentRoot}/${file}`);
|
||||
if (!absolute.toLowerCase().startsWith(`${this.documentRoot.toLowerCase()}/`)) return response(404);
|
||||
let content: string;
|
||||
|
||||
@@ -154,7 +154,8 @@ const ICMP_MESSAGES: Record<IcmpMessage['kind'], string> = {
|
||||
timeExceeded: 'ICMP Time Exceeded (poof)',
|
||||
};
|
||||
|
||||
const ICMP_TYPE_CODE: Record<IcmpMessage['kind'], [number, number]> = {
|
||||
/** An ICMP message's type and code. */
|
||||
export const ICMP_TYPE_CODE: Readonly<Record<IcmpMessage['kind'], readonly [number, number]>> = {
|
||||
echoReply: [0, 0],
|
||||
destinationNetworkUnreachable: [3, 0],
|
||||
destinationHostUnreachable: [3, 1],
|
||||
|
||||
@@ -107,13 +107,16 @@ import {
|
||||
type DnsQuestion,
|
||||
} from './dns.js';
|
||||
import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolicy } from './capture.js';
|
||||
import { ICMP_TYPE_CODE } from './captureRows.js';
|
||||
import { JavaCompatibleRandom } from './random.js';
|
||||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||||
import { RuntimeSpeed } from './speed.js';
|
||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||
import { WebBrowser, WebServer } from './apps/web.js';
|
||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
|
||||
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
|
||||
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import {
|
||||
PRODUCTION_TRACE_RETENTION,
|
||||
type HeaderField,
|
||||
@@ -305,6 +308,23 @@ export interface FirewallDecision {
|
||||
readonly ruleIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line of a firewall's log (Firewall.benachrichtigeBeobachter, shown by
|
||||
* the router's page "log"): a rule that matched a TCP or UDP packet, with
|
||||
* the node's first interface (the rule's text names its network for "any
|
||||
* directly connected network"), or an ICMP packet dropped by the general
|
||||
* setting.
|
||||
*/
|
||||
export type FirewallLogEntry =
|
||||
| {
|
||||
readonly kind: 'ruleMatched';
|
||||
readonly ruleNumber: number;
|
||||
readonly rule: FirewallRule;
|
||||
readonly firstInterface: { readonly ipAddress: string; readonly subnetMask: string } | undefined;
|
||||
readonly accepted: boolean;
|
||||
}
|
||||
| { readonly kind: 'icmpDropped'; readonly senderIPAddress: string; readonly receiverIPAddress: string; readonly type: number; readonly code: number };
|
||||
|
||||
export type RuntimePhase = 'stopped' | 'running';
|
||||
|
||||
export type SocketProtocol = 'udp' | 'tcp';
|
||||
@@ -863,6 +883,7 @@ export class NetworkRuntimeEngine {
|
||||
private readonly cablesCarryOneFrameAtATime: boolean;
|
||||
private readonly swiftArp: boolean;
|
||||
private readonly swiftSends: boolean;
|
||||
private readonly swiftRouters: boolean;
|
||||
private readonly packetCapture: PacketCapture;
|
||||
/** The programs' threads (threads.ts), and the wake-ups they wait for, by id. */
|
||||
private readonly applicationThreads = new ApplicationThreads({
|
||||
@@ -888,6 +909,8 @@ export class NetworkRuntimeEngine {
|
||||
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
||||
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
|
||||
private readonly firewallLogsByNodeId = new Map<Id, FirewallLogEntry[]>();
|
||||
/** Accepted connections a program has taken (acceptTcpSteps). */
|
||||
private readonly handedOutTcpSocketIds = new Set<Id>();
|
||||
private nextApplicationWakeId = 1;
|
||||
@@ -901,6 +924,8 @@ export class NetworkRuntimeEngine {
|
||||
*
|
||||
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
|
||||
* when the network is quiet, likewise only for the comparison.
|
||||
* `swiftRouters`: routers and home routers start no web server (Swift's
|
||||
* web administration answers without a socket), likewise.
|
||||
*
|
||||
* `captureRetention`: how many frames each card's packet capture keeps.
|
||||
*/
|
||||
@@ -911,6 +936,7 @@ export class NetworkRuntimeEngine {
|
||||
readonly swiftCables?: boolean;
|
||||
readonly swiftArp?: boolean;
|
||||
readonly swiftSends?: boolean;
|
||||
readonly swiftRouters?: boolean;
|
||||
readonly captureRetention?: CaptureRetentionPolicy;
|
||||
} = {},
|
||||
) {
|
||||
@@ -918,6 +944,7 @@ export class NetworkRuntimeEngine {
|
||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||
this.swiftArp = options.swiftArp === true;
|
||||
this.swiftSends = options.swiftSends === true;
|
||||
this.swiftRouters = options.swiftRouters === true;
|
||||
const normalizedSeed = toSeed(seed);
|
||||
this.stateValue = initialState(normalizedSeed);
|
||||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||||
@@ -1048,6 +1075,27 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** The node's firewall log, since the engine was made. */
|
||||
firewallLog(nodeId: Id): readonly FirewallLogEntry[] {
|
||||
return this.firewallLogsByNodeId.get(nodeId) ?? [];
|
||||
}
|
||||
|
||||
private logFirewall(nodeId: Id, entry: FirewallLogEntry): void {
|
||||
getOrCreate(this.firewallLogsByNodeId, nodeId, () => []).push(entry);
|
||||
}
|
||||
|
||||
/** Firewall.notifyRuleApplication. */
|
||||
private logFirewallRule(nodeId: Id, index: number, rule: FirewallRule): void {
|
||||
const first = this.networkInterfaces(nodeId)[0];
|
||||
this.logFirewall(nodeId, {
|
||||
kind: 'ruleMatched',
|
||||
ruleNumber: index + 1,
|
||||
rule,
|
||||
firstInterface: first === undefined ? undefined : { ipAddress: first.ipAddress, subnetMask: first.subnetMask },
|
||||
accepted: rule.action === 'accept',
|
||||
});
|
||||
}
|
||||
|
||||
/** The node's echo server while the simulation runs, if it has one installed. */
|
||||
echoServer(nodeId: Id): EchoServer | undefined {
|
||||
return this.echoServersByNodeId.get(nodeId);
|
||||
@@ -1088,9 +1136,34 @@ export class NetworkRuntimeEngine {
|
||||
this.webServersByNodeId.set(node.id, new WebServer(this, node.id, webServer, snapshot.fileSystemsByNodeId.get(node.id)));
|
||||
}
|
||||
if (snapshot.webBrowserNodeIds.has(node.id)) this.webBrowsersByNodeId.set(node.id, new WebBrowser(this, node.id));
|
||||
if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VermittlungsrechnerBetriebssystem, GatewayFirmware: a router and a home
|
||||
* router switch on their web server on port 80 when they start, with
|
||||
* their index page and the pages log and, on a router, routes. Java's
|
||||
* server sometimes never listens (setAktiv wakes its thread only when it
|
||||
* already waits); the core's always does.
|
||||
*/
|
||||
private startRouterWebServer(nodeId: Id, kind: 'router' | 'gateway'): void {
|
||||
const language = this.stateValue.topologySnapshot.language;
|
||||
const files = new VirtualFileSystem();
|
||||
files.createDirectory('/webserver', true);
|
||||
files.writeTextFile('/webserver/index.html', routerIndexPage(kind, language));
|
||||
const pages = new Map<string, WebServerPage>([['log', () => firewallLogPage(this.firewallLog(nodeId), language)]]);
|
||||
if (kind === 'router') {
|
||||
pages.set('routes', () => {
|
||||
const rip = this.ripTable(nodeId);
|
||||
return rip === undefined ? forwardingTablePage(this.forwardingTable(nodeId), language) : ripTablePage(rip, this.stateValue.currentTimeMilliseconds, language);
|
||||
});
|
||||
}
|
||||
const server = new WebServer(this, nodeId, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }, files, pages);
|
||||
this.webServersByNodeId.set(nodeId, server);
|
||||
server.setActive(true);
|
||||
}
|
||||
|
||||
/** Begins a program's thread (threads.ts): it runs at once up to its first wait. */
|
||||
startThread(steps: Steps<unknown>): void {
|
||||
if (this.stateValue.phase !== 'running') return;
|
||||
@@ -2035,9 +2108,7 @@ export class NetworkRuntimeEngine {
|
||||
if (payload.length === 0) {
|
||||
chunks.push(new Uint8Array());
|
||||
} else {
|
||||
for (let offset = 0; offset < payload.length; offset += TCP_MAXIMUM_SEGMENT_SIZE) {
|
||||
chunks.push(payload.subarray(offset, Math.min(offset + TCP_MAXIMUM_SEGMENT_SIZE, payload.length)));
|
||||
}
|
||||
chunks.push(...javaTextPieces(payload, TCP_MAXIMUM_SEGMENT_SIZE));
|
||||
}
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return 'connectionLost';
|
||||
@@ -2681,6 +2752,7 @@ export class NetworkRuntimeEngine {
|
||||
this.ripRoutersByNodeId.clear();
|
||||
for (const node of state.topologySnapshot.nodes) {
|
||||
if (!this.isRipEnabledRouter(node.id)) continue;
|
||||
const primaryIPAddress = this.networkInterfaces(node.id)[0]?.ipAddress;
|
||||
state.ripTablesByNodeId.set(
|
||||
node.id,
|
||||
this.networkInterfaces(node.id).flatMap((networkInterface) => {
|
||||
@@ -2696,6 +2768,7 @@ export class NetworkRuntimeEngine {
|
||||
origin: 'connected' as const,
|
||||
isEditable: false,
|
||||
metric: 0,
|
||||
...(primaryIPAddress === undefined ? {} : { hopPublicIPAddress: primaryIPAddress }),
|
||||
},
|
||||
];
|
||||
}),
|
||||
@@ -2829,14 +2902,15 @@ export class NetworkRuntimeEngine {
|
||||
if (route !== undefined) {
|
||||
const routeHops = route.metric ?? RIP_INFINITY;
|
||||
if (route.nextHop !== message.ipAddress && routeHops <= hops) continue;
|
||||
let { nextHop, interfaceIPAddress: routeInterface } = route;
|
||||
let { nextHop, interfaceIPAddress: routeInterface, hopPublicIPAddress } = route;
|
||||
if (routeHops > hops) {
|
||||
nextHop = message.ipAddress;
|
||||
routeInterface = interfaceIPAddress;
|
||||
hopPublicIPAddress = message.publicIPAddress;
|
||||
} else if (routeHops < hops) {
|
||||
announce = true;
|
||||
}
|
||||
table[index] = { ...route, nextHop, interfaceIPAddress: routeInterface, metric: hops, expiresAtMilliseconds };
|
||||
table[index] = { ...route, nextHop, interfaceIPAddress: routeInterface, metric: hops, expiresAtMilliseconds, ...(hopPublicIPAddress === undefined ? {} : { hopPublicIPAddress }) };
|
||||
} else if (hops < RIP_INFINITY) {
|
||||
table.push({
|
||||
destinationNetwork: entry.network,
|
||||
@@ -2847,6 +2921,7 @@ export class NetworkRuntimeEngine {
|
||||
isEditable: false,
|
||||
metric: hops,
|
||||
expiresAtMilliseconds,
|
||||
hopPublicIPAddress: message.publicIPAddress,
|
||||
});
|
||||
announce = true;
|
||||
}
|
||||
@@ -4928,6 +5003,21 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
// ── Routing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Weiterleitungstabelle.holeTabelle: the node's own routes, its manual ones and its default gateway. */
|
||||
forwardingTable(nodeId: Id): RouteRow[] {
|
||||
const snapshot = this.stateValue.topologySnapshot;
|
||||
return routeTableRows(
|
||||
this.networkInterfaces(nodeId).map((candidate) => ({ ipAddress: candidate.ipAddress, subnetMask: candidate.subnetMask })),
|
||||
snapshot.manualRoutesByNodeId.get(nodeId) ?? [],
|
||||
snapshot.deviceConfigurations.get(nodeId)?.defaultGateway,
|
||||
);
|
||||
}
|
||||
|
||||
/** The RIP table of a router with RIP switched on, while the simulation runs. */
|
||||
ripTable(nodeId: Id): readonly RouteRow[] | undefined {
|
||||
return this.stateValue.ripTablesByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
private routeForPacket(nodeId: Id, targetIPAddress: string, preferredInterfaceId?: Id): PacketRoute {
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
if (targetIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return { type: 'broadcast' };
|
||||
@@ -5029,6 +5119,10 @@ export class NetworkRuntimeEngine {
|
||||
if (!configuration.isActive) return decide(true, undefined, 'firewall inactive');
|
||||
|
||||
if (packet.protocol === 'icmp') {
|
||||
if (configuration.dropIcmp && packet.payload.kind === 'icmp') {
|
||||
const [type, code] = ICMP_TYPE_CODE[packet.payload.message.kind];
|
||||
this.logFirewall(nodeId, { kind: 'icmpDropped', senderIPAddress: packet.senderIPAddress, receiverIPAddress: packet.receiverIPAddress, type, code });
|
||||
}
|
||||
return decide(
|
||||
!configuration.dropIcmp,
|
||||
undefined,
|
||||
@@ -5057,6 +5151,7 @@ export class NetworkRuntimeEngine {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.logFirewallRule(nodeId, index, rule);
|
||||
return decide(rule.action === 'accept', index, `TCP matched rule #${index + 1}`);
|
||||
}
|
||||
break;
|
||||
@@ -5083,6 +5178,7 @@ export class NetworkRuntimeEngine {
|
||||
nodeId,
|
||||
);
|
||||
if (!requestMatches && !responseMatches) continue;
|
||||
this.logFirewallRule(nodeId, index, rule);
|
||||
return decide(rule.action === 'accept', index, `UDP matched rule #${index + 1}`);
|
||||
}
|
||||
break;
|
||||
@@ -5355,7 +5451,48 @@ function tcpSegmentRequiresAcknowledgement(segment: TcpSegment): boolean {
|
||||
export function nextTcpSequenceNumber(segment: TcpSegment): number {
|
||||
let next = segment.sequenceNumber;
|
||||
if ((segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish)) !== 0) next = uint32Add(next, 1);
|
||||
return uint32Add(next, segment.payload.length);
|
||||
return uint32Add(next, javaTextLength(segment.payload));
|
||||
}
|
||||
|
||||
/** The payload as text, when it is UTF-8 (all of FILIUS' data are Strings); undefined for other bytes. */
|
||||
function payloadText(payload: Uint8Array): string | undefined {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(payload);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A TCP payload's length as Java counts it: its data are a String, its
|
||||
* sequence numbers and segments count UTF-16 units, not bytes ("ä" is one).
|
||||
* Bytes that are no text count as bytes.
|
||||
*/
|
||||
export function javaTextLength(payload: Uint8Array): number {
|
||||
return payloadText(payload)?.length ?? payload.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* TCPSocket.erstelleSegmente: the message in pieces of at most `size` UTF-16
|
||||
* units (bytes that are no text: of `size` bytes). Java may cut a character
|
||||
* of two units in halves; the core keeps it whole.
|
||||
*/
|
||||
function javaTextPieces(payload: Uint8Array, size: number): Uint8Array[] {
|
||||
const text = payloadText(payload);
|
||||
const pieces: Uint8Array[] = [];
|
||||
if (text === undefined) {
|
||||
for (let offset = 0; offset < payload.length; offset += size) pieces.push(payload.subarray(offset, Math.min(offset + size, payload.length)));
|
||||
return pieces;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
for (let start = 0; start < text.length; ) {
|
||||
let end = Math.min(start + size, text.length);
|
||||
const last = text.charCodeAt(end - 1);
|
||||
if (end < text.length && end - start > 1 && last >= 0xd800 && last <= 0xdbff) end -= 1;
|
||||
pieces.push(encoder.encode(text.slice(start, end)));
|
||||
start = end;
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface RouteRow {
|
||||
readonly isEditable: boolean;
|
||||
readonly metric?: number;
|
||||
readonly expiresAtMilliseconds?: number;
|
||||
/** A RIP route: the router that told it, by its first address (Java's hopPublicIp, a hint for the administrator). */
|
||||
readonly hopPublicIPAddress?: string;
|
||||
}
|
||||
|
||||
export type RouteTableColumn = 'destination' | 'subnetMask' | 'nextGateway' | 'interface';
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
WebServerConfiguration,
|
||||
} from './config.js';
|
||||
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import type { PageLanguage } from './apps/routerPages.js';
|
||||
|
||||
/** An installed web server: its configuration, and whether it is switched on (Java: WebServer.aktiv). */
|
||||
export interface WebServerSnapshot extends WebServerConfiguration {
|
||||
@@ -89,6 +90,8 @@ export interface TopologySnapshot {
|
||||
readonly webBrowserNodeIds: ReadonlySet<Id>;
|
||||
/** Each node's files, which its programs read (a web server its pages). */
|
||||
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
/** The language of the pages the devices write themselves (a router's), as Java's in the locale it runs in. */
|
||||
readonly language: PageLanguage;
|
||||
}
|
||||
|
||||
export interface TopologySnapshotInput {
|
||||
@@ -110,6 +113,7 @@ export interface TopologySnapshotInput {
|
||||
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
||||
webBrowserNodeIds?: ReadonlySet<Id>;
|
||||
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
||||
language?: PageLanguage;
|
||||
}
|
||||
|
||||
export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot {
|
||||
@@ -132,6 +136,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
|
||||
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
||||
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
||||
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
||||
language: input.language ?? 'de',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user