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:
+5
-2
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@ export * from './runtime/apps/program.js';
|
|||||||
export * from './runtime/apps/server.js';
|
export * from './runtime/apps/server.js';
|
||||||
export * from './runtime/apps/echo.js';
|
export * from './runtime/apps/echo.js';
|
||||||
export * from './runtime/apps/web.js';
|
export * from './runtime/apps/web.js';
|
||||||
|
export * from './runtime/apps/routerPages.js';
|
||||||
export * from './runtime/http.js';
|
export * from './runtime/http.js';
|
||||||
export * from './util/equal.js';
|
export * from './util/equal.js';
|
||||||
export * from './util/javaBase64.js';
|
export * from './util/javaBase64.js';
|
||||||
|
|||||||
@@ -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
|
// not reachable" when its request cannot go out or the answer cannot be
|
||||||
// read (Java shows nothing).
|
// read (Java shows nothing).
|
||||||
//
|
//
|
||||||
// Not yet: the pages a router's or home router's server makes itself
|
// A server may make pages itself (Java's plug-ins, WebServerPlugIn): a
|
||||||
// (Java's plug-ins: firewall log and settings, routing table).
|
// router's or home router's firewall log and routing table (routerPages.ts).
|
||||||
|
|
||||||
import type { Id } from '../../ids.js';
|
import type { Id } from '../../ids.js';
|
||||||
import type { VirtualFileSystem } from '../../model/virtualFileSystem.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). */
|
/** The port FILIUS' browser always connects to (WebBrowser.initConnection). */
|
||||||
export const WEB_BROWSER_PORT = 80;
|
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. */
|
/** WebServer's table of virtual hosts: five rows of host name and folder. */
|
||||||
const VIRTUAL_HOST_ROWS = 5;
|
const VIRTUAL_HOST_ROWS = 5;
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ export class WebServer extends TcpServerProgram {
|
|||||||
nodeId: Id,
|
nodeId: Id,
|
||||||
configuration: WebServerSnapshot,
|
configuration: WebServerSnapshot,
|
||||||
private readonly fileSystem: VirtualFileSystem | undefined,
|
private readonly fileSystem: VirtualFileSystem | undefined,
|
||||||
|
private readonly pages: ReadonlyMap<string, WebServerPage> = new Map(),
|
||||||
) {
|
) {
|
||||||
super(engine, nodeId, configuration);
|
super(engine, nodeId, configuration);
|
||||||
this.documentRoot = configuration.documentRoot;
|
this.documentRoot = configuration.documentRoot;
|
||||||
@@ -131,6 +135,12 @@ export class WebServer extends TcpServerProgram {
|
|||||||
if (!folder.endsWith('/')) folder += '/';
|
if (!folder.endsWith('/')) folder += '/';
|
||||||
file = folder + file;
|
file = folder + file;
|
||||||
if (file.startsWith('/')) file = file.slice(1);
|
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}`);
|
const absolute = evaluatePath(`${this.documentRoot}/${file}`);
|
||||||
if (!absolute.toLowerCase().startsWith(`${this.documentRoot.toLowerCase()}/`)) return response(404);
|
if (!absolute.toLowerCase().startsWith(`${this.documentRoot.toLowerCase()}/`)) return response(404);
|
||||||
let content: string;
|
let content: string;
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ const ICMP_MESSAGES: Record<IcmpMessage['kind'], string> = {
|
|||||||
timeExceeded: 'ICMP Time Exceeded (poof)',
|
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],
|
echoReply: [0, 0],
|
||||||
destinationNetworkUnreachable: [3, 0],
|
destinationNetworkUnreachable: [3, 0],
|
||||||
destinationHostUnreachable: [3, 1],
|
destinationHostUnreachable: [3, 1],
|
||||||
|
|||||||
@@ -107,13 +107,16 @@ import {
|
|||||||
type DnsQuestion,
|
type DnsQuestion,
|
||||||
} from './dns.js';
|
} from './dns.js';
|
||||||
import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolicy } from './capture.js';
|
import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolicy } from './capture.js';
|
||||||
|
import { ICMP_TYPE_CODE } from './captureRows.js';
|
||||||
import { JavaCompatibleRandom } from './random.js';
|
import { JavaCompatibleRandom } from './random.js';
|
||||||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||||||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||||||
import { RuntimeSpeed } from './speed.js';
|
import { RuntimeSpeed } from './speed.js';
|
||||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||||
import { EchoServer, SimpleClient } from './apps/echo.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 {
|
import {
|
||||||
PRODUCTION_TRACE_RETENTION,
|
PRODUCTION_TRACE_RETENTION,
|
||||||
type HeaderField,
|
type HeaderField,
|
||||||
@@ -305,6 +308,23 @@ export interface FirewallDecision {
|
|||||||
readonly ruleIndex?: number;
|
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 RuntimePhase = 'stopped' | 'running';
|
||||||
|
|
||||||
export type SocketProtocol = 'udp' | 'tcp';
|
export type SocketProtocol = 'udp' | 'tcp';
|
||||||
@@ -863,6 +883,7 @@ export class NetworkRuntimeEngine {
|
|||||||
private readonly cablesCarryOneFrameAtATime: boolean;
|
private readonly cablesCarryOneFrameAtATime: boolean;
|
||||||
private readonly swiftArp: boolean;
|
private readonly swiftArp: boolean;
|
||||||
private readonly swiftSends: boolean;
|
private readonly swiftSends: boolean;
|
||||||
|
private readonly swiftRouters: boolean;
|
||||||
private readonly packetCapture: PacketCapture;
|
private readonly packetCapture: PacketCapture;
|
||||||
/** The programs' threads (threads.ts), and the wake-ups they wait for, by id. */
|
/** The programs' threads (threads.ts), and the wake-ups they wait for, by id. */
|
||||||
private readonly applicationThreads = new ApplicationThreads({
|
private readonly applicationThreads = new ApplicationThreads({
|
||||||
@@ -888,6 +909,8 @@ export class NetworkRuntimeEngine {
|
|||||||
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
||||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
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). */
|
/** Accepted connections a program has taken (acceptTcpSteps). */
|
||||||
private readonly handedOutTcpSocketIds = new Set<Id>();
|
private readonly handedOutTcpSocketIds = new Set<Id>();
|
||||||
private nextApplicationWakeId = 1;
|
private nextApplicationWakeId = 1;
|
||||||
@@ -901,6 +924,8 @@ export class NetworkRuntimeEngine {
|
|||||||
*
|
*
|
||||||
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
|
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
|
||||||
* when the network is quiet, likewise only for the comparison.
|
* 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.
|
* `captureRetention`: how many frames each card's packet capture keeps.
|
||||||
*/
|
*/
|
||||||
@@ -911,6 +936,7 @@ export class NetworkRuntimeEngine {
|
|||||||
readonly swiftCables?: boolean;
|
readonly swiftCables?: boolean;
|
||||||
readonly swiftArp?: boolean;
|
readonly swiftArp?: boolean;
|
||||||
readonly swiftSends?: boolean;
|
readonly swiftSends?: boolean;
|
||||||
|
readonly swiftRouters?: boolean;
|
||||||
readonly captureRetention?: CaptureRetentionPolicy;
|
readonly captureRetention?: CaptureRetentionPolicy;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -918,6 +944,7 @@ export class NetworkRuntimeEngine {
|
|||||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||||
this.swiftArp = options.swiftArp === true;
|
this.swiftArp = options.swiftArp === true;
|
||||||
this.swiftSends = options.swiftSends === true;
|
this.swiftSends = options.swiftSends === true;
|
||||||
|
this.swiftRouters = options.swiftRouters === true;
|
||||||
const normalizedSeed = toSeed(seed);
|
const normalizedSeed = toSeed(seed);
|
||||||
this.stateValue = initialState(normalizedSeed);
|
this.stateValue = initialState(normalizedSeed);
|
||||||
this.random = new JavaCompatibleRandom(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. */
|
/** The node's echo server while the simulation runs, if it has one installed. */
|
||||||
echoServer(nodeId: Id): EchoServer | undefined {
|
echoServer(nodeId: Id): EchoServer | undefined {
|
||||||
return this.echoServersByNodeId.get(nodeId);
|
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)));
|
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 (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. */
|
/** Begins a program's thread (threads.ts): it runs at once up to its first wait. */
|
||||||
startThread(steps: Steps<unknown>): void {
|
startThread(steps: Steps<unknown>): void {
|
||||||
if (this.stateValue.phase !== 'running') return;
|
if (this.stateValue.phase !== 'running') return;
|
||||||
@@ -2035,9 +2108,7 @@ export class NetworkRuntimeEngine {
|
|||||||
if (payload.length === 0) {
|
if (payload.length === 0) {
|
||||||
chunks.push(new Uint8Array());
|
chunks.push(new Uint8Array());
|
||||||
} else {
|
} else {
|
||||||
for (let offset = 0; offset < payload.length; offset += TCP_MAXIMUM_SEGMENT_SIZE) {
|
chunks.push(...javaTextPieces(payload, TCP_MAXIMUM_SEGMENT_SIZE));
|
||||||
chunks.push(payload.subarray(offset, Math.min(offset + TCP_MAXIMUM_SEGMENT_SIZE, payload.length)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const [index, chunk] of chunks.entries()) {
|
for (const [index, chunk] of chunks.entries()) {
|
||||||
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return 'connectionLost';
|
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return 'connectionLost';
|
||||||
@@ -2681,6 +2752,7 @@ export class NetworkRuntimeEngine {
|
|||||||
this.ripRoutersByNodeId.clear();
|
this.ripRoutersByNodeId.clear();
|
||||||
for (const node of state.topologySnapshot.nodes) {
|
for (const node of state.topologySnapshot.nodes) {
|
||||||
if (!this.isRipEnabledRouter(node.id)) continue;
|
if (!this.isRipEnabledRouter(node.id)) continue;
|
||||||
|
const primaryIPAddress = this.networkInterfaces(node.id)[0]?.ipAddress;
|
||||||
state.ripTablesByNodeId.set(
|
state.ripTablesByNodeId.set(
|
||||||
node.id,
|
node.id,
|
||||||
this.networkInterfaces(node.id).flatMap((networkInterface) => {
|
this.networkInterfaces(node.id).flatMap((networkInterface) => {
|
||||||
@@ -2696,6 +2768,7 @@ export class NetworkRuntimeEngine {
|
|||||||
origin: 'connected' as const,
|
origin: 'connected' as const,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
metric: 0,
|
metric: 0,
|
||||||
|
...(primaryIPAddress === undefined ? {} : { hopPublicIPAddress: primaryIPAddress }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
@@ -2829,14 +2902,15 @@ export class NetworkRuntimeEngine {
|
|||||||
if (route !== undefined) {
|
if (route !== undefined) {
|
||||||
const routeHops = route.metric ?? RIP_INFINITY;
|
const routeHops = route.metric ?? RIP_INFINITY;
|
||||||
if (route.nextHop !== message.ipAddress && routeHops <= hops) continue;
|
if (route.nextHop !== message.ipAddress && routeHops <= hops) continue;
|
||||||
let { nextHop, interfaceIPAddress: routeInterface } = route;
|
let { nextHop, interfaceIPAddress: routeInterface, hopPublicIPAddress } = route;
|
||||||
if (routeHops > hops) {
|
if (routeHops > hops) {
|
||||||
nextHop = message.ipAddress;
|
nextHop = message.ipAddress;
|
||||||
routeInterface = interfaceIPAddress;
|
routeInterface = interfaceIPAddress;
|
||||||
|
hopPublicIPAddress = message.publicIPAddress;
|
||||||
} else if (routeHops < hops) {
|
} else if (routeHops < hops) {
|
||||||
announce = true;
|
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) {
|
} else if (hops < RIP_INFINITY) {
|
||||||
table.push({
|
table.push({
|
||||||
destinationNetwork: entry.network,
|
destinationNetwork: entry.network,
|
||||||
@@ -2847,6 +2921,7 @@ export class NetworkRuntimeEngine {
|
|||||||
isEditable: false,
|
isEditable: false,
|
||||||
metric: hops,
|
metric: hops,
|
||||||
expiresAtMilliseconds,
|
expiresAtMilliseconds,
|
||||||
|
hopPublicIPAddress: message.publicIPAddress,
|
||||||
});
|
});
|
||||||
announce = true;
|
announce = true;
|
||||||
}
|
}
|
||||||
@@ -4928,6 +5003,21 @@ export class NetworkRuntimeEngine {
|
|||||||
|
|
||||||
// ── Routing ──────────────────────────────────────────────────────────────
|
// ── 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 {
|
private routeForPacket(nodeId: Id, targetIPAddress: string, preferredInterfaceId?: Id): PacketRoute {
|
||||||
const interfaces = this.networkInterfaces(nodeId);
|
const interfaces = this.networkInterfaces(nodeId);
|
||||||
if (targetIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return { type: 'broadcast' };
|
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 (!configuration.isActive) return decide(true, undefined, 'firewall inactive');
|
||||||
|
|
||||||
if (packet.protocol === 'icmp') {
|
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(
|
return decide(
|
||||||
!configuration.dropIcmp,
|
!configuration.dropIcmp,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -5057,6 +5151,7 @@ export class NetworkRuntimeEngine {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
this.logFirewallRule(nodeId, index, rule);
|
||||||
return decide(rule.action === 'accept', index, `TCP matched rule #${index + 1}`);
|
return decide(rule.action === 'accept', index, `TCP matched rule #${index + 1}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -5083,6 +5178,7 @@ export class NetworkRuntimeEngine {
|
|||||||
nodeId,
|
nodeId,
|
||||||
);
|
);
|
||||||
if (!requestMatches && !responseMatches) continue;
|
if (!requestMatches && !responseMatches) continue;
|
||||||
|
this.logFirewallRule(nodeId, index, rule);
|
||||||
return decide(rule.action === 'accept', index, `UDP matched rule #${index + 1}`);
|
return decide(rule.action === 'accept', index, `UDP matched rule #${index + 1}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -5355,7 +5451,48 @@ function tcpSegmentRequiresAcknowledgement(segment: TcpSegment): boolean {
|
|||||||
export function nextTcpSequenceNumber(segment: TcpSegment): number {
|
export function nextTcpSequenceNumber(segment: TcpSegment): number {
|
||||||
let next = segment.sequenceNumber;
|
let next = segment.sequenceNumber;
|
||||||
if ((segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish)) !== 0) next = uint32Add(next, 1);
|
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 {
|
function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface RouteRow {
|
|||||||
readonly isEditable: boolean;
|
readonly isEditable: boolean;
|
||||||
readonly metric?: number;
|
readonly metric?: number;
|
||||||
readonly expiresAtMilliseconds?: 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';
|
export type RouteTableColumn = 'destination' | 'subnetMask' | 'nextGateway' | 'interface';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
WebServerConfiguration,
|
WebServerConfiguration,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import type { VirtualFileSystem } from '../model/virtualFileSystem.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). */
|
/** An installed web server: its configuration, and whether it is switched on (Java: WebServer.aktiv). */
|
||||||
export interface WebServerSnapshot extends WebServerConfiguration {
|
export interface WebServerSnapshot extends WebServerConfiguration {
|
||||||
@@ -89,6 +90,8 @@ export interface TopologySnapshot {
|
|||||||
readonly webBrowserNodeIds: ReadonlySet<Id>;
|
readonly webBrowserNodeIds: ReadonlySet<Id>;
|
||||||
/** Each node's files, which its programs read (a web server its pages). */
|
/** Each node's files, which its programs read (a web server its pages). */
|
||||||
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
|
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 {
|
export interface TopologySnapshotInput {
|
||||||
@@ -110,6 +113,7 @@ export interface TopologySnapshotInput {
|
|||||||
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
|
||||||
webBrowserNodeIds?: ReadonlySet<Id>;
|
webBrowserNodeIds?: ReadonlySet<Id>;
|
||||||
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
|
||||||
|
language?: PageLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot {
|
export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot {
|
||||||
@@ -132,6 +136,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
|
|||||||
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
webServersByNodeId: input.webServersByNodeId ?? new Map(),
|
||||||
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
|
||||||
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
|
||||||
|
language: input.language ?? 'de',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2100,8 +2100,8 @@ describe('RIP', () => {
|
|||||||
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
|
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
|
||||||
const engine = ripTopology();
|
const engine = ripTopology();
|
||||||
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
|
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
|
||||||
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0 },
|
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0, hopPublicIPAddress: '10.0.0.1' },
|
||||||
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0 },
|
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0, hopPublicIPAddress: '10.0.0.1' },
|
||||||
]);
|
]);
|
||||||
expect(beacons(engine, routerAId)).toEqual([1_000]);
|
expect(beacons(engine, routerAId)).toEqual([1_000]);
|
||||||
// The server's socket on 520, the beacon's on 521.
|
// The server's socket on 520, the beacon's on 521.
|
||||||
@@ -2113,7 +2113,7 @@ describe('RIP', () => {
|
|||||||
const engine = ripTopology();
|
const engine = ripTopology();
|
||||||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||||
|
|
||||||
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000 });
|
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000, hopPublicIPAddress: '10.0.0.2' });
|
||||||
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
|
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
|
||||||
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
|
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
|
||||||
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
|
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { expect } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
captureCommentText,
|
captureCommentText,
|
||||||
captureRows,
|
captureRows,
|
||||||
|
javaTextLength,
|
||||||
NetworkRuntimeEngine,
|
NetworkRuntimeEngine,
|
||||||
TCP_FLAG,
|
TCP_FLAG,
|
||||||
type CapturedFrame,
|
type CapturedFrame,
|
||||||
@@ -112,7 +113,7 @@ export function engineText(frame: EthernetFrame): string {
|
|||||||
if (packet.payload.kind === 'tcp') {
|
if (packet.payload.kind === 'tcp') {
|
||||||
const segment = packet.payload.segment;
|
const segment = packet.payload.segment;
|
||||||
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
|
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
|
||||||
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${segment.payload.length}`;
|
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${javaTextLength(segment.payload)}`;
|
||||||
}
|
}
|
||||||
const datagram = packet.payload.datagram;
|
const datagram = packet.payload.datagram;
|
||||||
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
// follows those threads, so it is not compared. Free ports — a client's, and the WAN port
|
// follows those threads, so it is not compared. Free ports — a client's, and the WAN port
|
||||||
// the router draws for an entry — are Java's random and the engine's seed:
|
// the router draws for an entry — are Java's random and the engine's seed:
|
||||||
// both are compared as "port". Java numbers the TCP sockets' initial
|
// both are compared as "port". Java numbers the TCP sockets' initial
|
||||||
// sequence numbers over all sockets, and its home router's web server (not
|
// sequence numbers over all sockets; its home router's web server takes the
|
||||||
// in the engine yet) takes the first: each is compared by the order it first
|
// first when it listens — in these runs it did not (setAktiv wakes its thread
|
||||||
// shows in. Frames that show within TIE_MILLISECONDS of each other Java's
|
// only when it already waits), in the engine it always does: each initial
|
||||||
|
// number is compared by the order it first shows in, and in the tables only
|
||||||
|
// below the millions. Frames that show within TIE_MILLISECONDS of each other Java's
|
||||||
// packet log writes in any order; they are compared sorted by their text.
|
// packet log writes in any order; they are compared sorted by their text.
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -265,6 +267,7 @@ describe('NAT as Java FILIUS runs it', () => {
|
|||||||
expectSameCaptures(engine, scenario, java.frames, {
|
expectSameCaptures(engine, scenario, java.frames, {
|
||||||
normalized: withPlaceholders,
|
normalized: withPlaceholders,
|
||||||
tables: measured,
|
tables: measured,
|
||||||
|
cell: (text) => text.replace(/\b\d+\.(\d{3}\.\d{3})\b/g, 'n.$1'),
|
||||||
tolerance: TCP_STORIES.has(name) ? TCP_TOLERANCE_MILLISECONDS : GAP_TOLERANCE_MILLISECONDS,
|
tolerance: TCP_STORIES.has(name) ? TCP_TOLERANCE_MILLISECONDS : GAP_TOLERANCE_MILLISECONDS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function observationJson(item: IcmpObservation): Record<string, unknown> {
|
|||||||
|
|
||||||
function run(scenario: Scenario): Record<string, unknown> {
|
function run(scenario: Scenario): Record<string, unknown> {
|
||||||
// Swift's cables carry all frames at once; everything else is compared as it is.
|
// Swift's cables carry all frames at once; everything else is compared as it is.
|
||||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true, swiftSends: true });
|
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true, swiftSends: true, swiftRouters: true });
|
||||||
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
|
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
|
||||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// message bundle says them: for comparing the core's structured lines with
|
// message bundle says them: for comparing the core's structured lines with
|
||||||
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
|
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
|
||||||
|
|
||||||
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry } from '../src/index.js';
|
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry, WebBrowserLogEntry } from '../src/index.js';
|
||||||
|
|
||||||
/** Java's client and server look every 100 ms whether they are connected, or switched off; the core at once. */
|
/** Java's client and server look every 100 ms whether they are connected, or switched off; the core at once. */
|
||||||
export const POLL_MILLISECONDS = 100;
|
export const POLL_MILLISECONDS = 100;
|
||||||
@@ -71,3 +71,20 @@ export function clientText(entry: SimpleClientLogEntry): string {
|
|||||||
return 'Keine Verbindung hergestellt!';
|
return 'Keine Verbindung hergestellt!';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_TYPES = new Set(['png', 'gif', 'jpg', 'bmp']);
|
||||||
|
|
||||||
|
/** A browser's line as its window took it: a page (with its text only for 200), or "update" for an image. */
|
||||||
|
export function browserText(entry: WebBrowserLogEntry): string | undefined {
|
||||||
|
switch (entry.kind) {
|
||||||
|
case 'page':
|
||||||
|
return `page ${entry.statusCode} ${entry.contentType ?? 'null'}${entry.statusCode === 200 ? `\n${entry.body ?? 'null'}` : ''}`;
|
||||||
|
case 'unreachable':
|
||||||
|
return 'page 0 null';
|
||||||
|
case 'image': {
|
||||||
|
// WebBrowser.retrieveImages tells the window of an image that came, or of a status other than 200.
|
||||||
|
const isImage = entry.contentType !== undefined && IMAGE_TYPES.has(entry.contentType.toLowerCase().replace('image/', ''));
|
||||||
|
return entry.statusCode !== 200 || isImage ? 'update' : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// The pages of routers and home routers (apps/routerPages.ts), the firewall
|
||||||
|
// log behind them, and TCP counting Java's characters, where
|
||||||
|
// routerWebJava.test.ts's stories do not go.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
deviceConfiguration,
|
||||||
|
firewallConfiguration,
|
||||||
|
firewallLogPage,
|
||||||
|
firewallRule,
|
||||||
|
firewallRuleText,
|
||||||
|
forwardingTablePage,
|
||||||
|
interfaceKey,
|
||||||
|
javaTextLength,
|
||||||
|
NetworkRuntimeEngine,
|
||||||
|
topologySnapshot,
|
||||||
|
portSnapshot,
|
||||||
|
ripTablePage,
|
||||||
|
routerIndexPage,
|
||||||
|
type RouteRow,
|
||||||
|
type TopologySnapshotInput,
|
||||||
|
} from '../src/index.js';
|
||||||
|
import { link, pc, startedEngine, uuid } from './fixtures.js';
|
||||||
|
|
||||||
|
const row = (fields: Partial<RouteRow>): RouteRow => ({
|
||||||
|
destinationNetwork: '10.0.0.0',
|
||||||
|
subnetMask: '255.255.255.0',
|
||||||
|
nextHop: '10.0.0.1',
|
||||||
|
interfaceIPAddress: '10.0.0.1',
|
||||||
|
origin: 'connected',
|
||||||
|
isEditable: false,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the pages of a router', () => {
|
||||||
|
it('colours the forwarding table: own networks and localhost green with 0 hops, the default gateway blue, others with "?"', () => {
|
||||||
|
const page = forwardingTablePage(
|
||||||
|
[
|
||||||
|
row({}),
|
||||||
|
row({ destinationNetwork: '127.0.0.0', subnetMask: '255.0.0.0', nextHop: '127.0.0.1', interfaceIPAddress: '127.0.0.1' }),
|
||||||
|
row({ destinationNetwork: '0.0.0.0', subnetMask: '0.0.0.0', nextHop: '10.0.0.254' }),
|
||||||
|
row({ destinationNetwork: '30.0.0.0', nextHop: '10.0.0.9' }),
|
||||||
|
],
|
||||||
|
'de',
|
||||||
|
);
|
||||||
|
expect(page).toContain(
|
||||||
|
"<tr style='background-color:#aaffaa'><td>10.0.0.0</td><td>255.255.255.0</td><td> 0 </td><td>unbegrenzt</td><td><a href=\"http://10.0.0.1/routes\">10.0.0.1</a></td><td> - </td></tr>" +
|
||||||
|
"<tr style='background-color:#aaffaa'><td>127.0.0.0</td>",
|
||||||
|
);
|
||||||
|
expect(page).toContain("<tr style='background-color:#aaaaff'><td>0.0.0.0</td><td>0.0.0.0</td><td> ? </td>");
|
||||||
|
expect(page).toContain('<tr><td>30.0.0.0</td><td>255.255.255.0</td><td> ? </td>');
|
||||||
|
// A route over the loopback interface counts as local, whatever its next hop.
|
||||||
|
expect(forwardingTablePage([row({ destinationNetwork: '50.0.0.0', nextHop: '10.0.0.7', interfaceIPAddress: '127.0.0.1' })], 'de')).toContain(
|
||||||
|
"<tr style='background-color:#aaffaa'><td>50.0.0.0</td><td>255.255.255.0</td><td> 0 </td>",
|
||||||
|
);
|
||||||
|
expect(page).toContain('Routing-/Weiterleitungs-Tabelle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a RIP route in red at 16 hops, its time left in whole seconds', () => {
|
||||||
|
const page = ripTablePage(
|
||||||
|
[row({ metric: 0, hopPublicIPAddress: '10.0.0.1' }), row({ destinationNetwork: '40.0.0.0', nextHop: '10.0.0.2', metric: 16, expiresAtMilliseconds: 10_999, hopPublicIPAddress: '10.0.0.2' })],
|
||||||
|
1_000,
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(page).toContain("<tr style='background-color:#aaffaa'><td>10.0.0.0</td><td>255.255.255.0</td><td>0</td><td>unlimited</td>");
|
||||||
|
expect(page).toContain("<tr style='background-color:#ffaaaa'><td>40.0.0.0</td><td>255.255.255.0</td><td>16</td><td>9</td><td>10.0.0.2</td><td><a href=\"http://10.0.0.2/routes\">10.0.0.2</a></td></tr>");
|
||||||
|
expect(page).toContain('Routing Table');
|
||||||
|
expect(page).toContain('This table is generated and updated by the Routing Information Protocol (RIP).');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the index pages in the project language', () => {
|
||||||
|
expect(routerIndexPage('router', 'en')).toContain('<title>Router Administration</title>');
|
||||||
|
expect(routerIndexPage('gateway', 'de')).toBe(
|
||||||
|
'<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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a rule as Java does: empty fields as *, "directly connected" by the first network', () => {
|
||||||
|
const first = { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' };
|
||||||
|
expect(firewallRuleText(firewallRule({ protocol: 'all', action: 'accept' }), first)).toBe('*/* -> */*; *:* => ACCEPT');
|
||||||
|
expect(firewallRuleText(firewallRule({ sourceIPAddress: '999.999.999.999', destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', port: 53 }), first)).toBe(
|
||||||
|
'10.0.0.0/255.255.255.0 -> 20.0.0.0/255.0.0.0; UDP:53 => DROP',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ROUTER = uuid(0x4001);
|
||||||
|
const LEFT = uuid(0x4002);
|
||||||
|
const RIGHT = uuid(0x4003);
|
||||||
|
|
||||||
|
/** 10.0.0.2 — router 10.0.0.1/20.0.0.1 with a firewall — 20.0.0.2. */
|
||||||
|
function network(changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
|
||||||
|
return {
|
||||||
|
nodes: [{ id: ROUTER, kind: 'router', ports: [portSnapshot(uuid(0x4011), 'eth0'), portSnapshot(uuid(0x4012), 'eth1')] }, pc(LEFT, uuid(0x4021)), pc(RIGHT, uuid(0x4031))],
|
||||||
|
links: [link(ROUTER, uuid(0x4011), LEFT, uuid(0x4021)), link(ROUTER, uuid(0x4012), RIGHT, uuid(0x4031))],
|
||||||
|
deviceConfigurations: new Map([
|
||||||
|
[ROUTER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
|
||||||
|
[LEFT, deviceConfiguration('10.0.0.2', '255.255.255.0', '10.0.0.1', '')],
|
||||||
|
[RIGHT, deviceConfiguration('20.0.0.2', '255.255.255.0', '20.0.0.1', '')],
|
||||||
|
]),
|
||||||
|
interfaceConfigurations: new Map([
|
||||||
|
[interfaceKey(ROUTER, uuid(0x4011)), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||||||
|
[interfaceKey(ROUTER, uuid(0x4012)), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
||||||
|
]),
|
||||||
|
firewallConfigurationsByNodeId: new Map([
|
||||||
|
[ROUTER, firewallConfiguration({ isActive: true, defaultPolicy: 'accept', rules: [firewallRule({ protocol: 'udp', port: 7, action: 'drop' })] })],
|
||||||
|
]),
|
||||||
|
...changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a router's firewall log", () => {
|
||||||
|
it('logs a UDP rule that matched, and keeps the log over a new start, as Java does', () => {
|
||||||
|
const engine = startedEngine(network());
|
||||||
|
const send = () => engine.sendUdp(engine.bindUdpSocket(LEFT, { remoteIPAddress: '20.0.0.2', remotePort: 7 })!, new TextEncoder().encode('x'));
|
||||||
|
send();
|
||||||
|
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||||
|
engine.handle({ type: 'stop' });
|
||||||
|
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: 14, initialTimeMilliseconds: 0 });
|
||||||
|
send();
|
||||||
|
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||||
|
expect(engine.firewallLog(ROUTER)).toHaveLength(2);
|
||||||
|
expect(firewallLogPage(engine.firewallLog(ROUTER), 'en')).toBe(
|
||||||
|
'<html>\n\t<head>\n\t\t<title>Log-data set entry of the firewall</title>\n\t</head>\n\t<body>\n\t\t<h2>Log-data set entry of the firewall</h2>\n\n' +
|
||||||
|
'<br /> \nFirewall rule matched: #1 (*/* -> */*; UDP:7 => DROP) -> drop<br /> \nFirewall rule matched: #1 (*/* -> */*; UDP:7 => DROP) -> drop' +
|
||||||
|
'\n\n\t</body>\n</html>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the router switch on its web server at the start; with swiftRouters none, as in Swift', () => {
|
||||||
|
const engine = startedEngine(network());
|
||||||
|
expect(engine.webServer(ROUTER)?.isActive).toBe(true);
|
||||||
|
expect(engine.webServer(ROUTER)?.log.all.map((line) => line.kind)).toEqual(['acceptingStarted']);
|
||||||
|
expect(engine.webServer(LEFT)).toBeUndefined();
|
||||||
|
const swift = new NetworkRuntimeEngine(14, undefined, { swiftRouters: true });
|
||||||
|
swift.handle({ type: 'start', snapshot: topologySnapshot(network()), seed: 14, initialTimeMilliseconds: 0 });
|
||||||
|
expect(swift.webServer(ROUTER)).toBeUndefined();
|
||||||
|
expect(swift.state.socketsById.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a RIP route's public address (hopPublicIp)", () => {
|
||||||
|
it('is the telling router\'s, also when a shorter way replaces the route', () => {
|
||||||
|
const engine = startedEngine(network({ ripEnabledByNodeId: new Map([[ROUTER, true]]), firewallConfigurationsByNodeId: new Map() }));
|
||||||
|
type Learn = (nodeId: string, message: unknown) => void;
|
||||||
|
const learn = (engine as unknown as { learnRipRoutes: Learn }).learnRipRoutes.bind(engine);
|
||||||
|
const message = (ipAddress: string, publicIPAddress: string, hops: number) => ({
|
||||||
|
ipAddress,
|
||||||
|
publicIPAddress,
|
||||||
|
infinity: 16,
|
||||||
|
timeoutMilliseconds: 75_000,
|
||||||
|
routes: [{ network: '60.0.0.0', mask: '255.0.0.0', hops }],
|
||||||
|
});
|
||||||
|
const route = () => engine.ripTable(ROUTER)!.find((each) => each.destinationNetwork === '60.0.0.0');
|
||||||
|
learn(ROUTER, message('10.0.0.5', '10.0.0.5', 2));
|
||||||
|
expect(route()).toMatchObject({ metric: 3, nextHop: '10.0.0.5', hopPublicIPAddress: '10.0.0.5' });
|
||||||
|
learn(ROUTER, message('20.0.0.6', '30.0.0.6', 0));
|
||||||
|
expect(route()).toMatchObject({ metric: 1, nextHop: '20.0.0.6', hopPublicIPAddress: '30.0.0.6' });
|
||||||
|
learn(ROUTER, message('10.0.0.5', '10.0.0.5', 0));
|
||||||
|
expect(route()).toMatchObject({ metric: 1, nextHop: '20.0.0.6', hopPublicIPAddress: '30.0.0.6' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TCP as Java counts it', () => {
|
||||||
|
it('counts characters, not bytes, and never cuts a character of two units in halves', () => {
|
||||||
|
expect(javaTextLength(new TextEncoder().encode('Grüße'))).toBe(5);
|
||||||
|
expect(javaTextLength(new Uint8Array([0xff, 0x00]))).toBe(2);
|
||||||
|
|
||||||
|
const engine = startedEngine(network({ firewallConfigurationsByNodeId: new Map() }));
|
||||||
|
const listener = engine.openTcpServerSocket(RIGHT, 80)!;
|
||||||
|
const client = engine.openTcpClientSocket(LEFT, '20.0.0.2', 80)!;
|
||||||
|
expect(engine.connectTcpWithResult(client)).toBe('connected');
|
||||||
|
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)![0]!;
|
||||||
|
const text = `${'ä'.repeat(1_459)}😀${'b'.repeat(10)}`;
|
||||||
|
expect(engine.sendTcp(client, new TextEncoder().encode(text))).toBe(true);
|
||||||
|
expect(new TextDecoder().decode(engine.receiveTcp(accepted))).toBe(text);
|
||||||
|
const lengths = engine.state.packetTraces
|
||||||
|
.filter((trace) => trace.nodeId === LEFT && trace.detail?.startsWith('TCP data') === true)
|
||||||
|
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
|
||||||
|
// 1459 two-byte characters (the emoji does not fit whole), then the emoji and the rest.
|
||||||
|
expect(lengths).toEqual([2 * 1_459, 4 + 10]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// The pages of routers and home routers as Java FILIUS serves them.
|
||||||
|
// tools/java-oracle's RouterWebRun let a host's browser ask a router's or
|
||||||
|
// home router's web server for its index page, its routing table and its
|
||||||
|
// firewall log (routerweb-java-<story>.java.json). The engine plays the same
|
||||||
|
// stories: each card's frames must come in the same order, with the same
|
||||||
|
// content and rows, at about the same time; the server's lines and the
|
||||||
|
// browser's pages likewise. The pages are Java's German ones.
|
||||||
|
//
|
||||||
|
// The router's server switches itself on when the simulation starts (Java
|
||||||
|
// writes so into its window): lines from before the story's first call are
|
||||||
|
// not compared. Java's router server sometimes never listens (setAktiv wakes
|
||||||
|
// its thread only when it already waits), and takes no initial sequence
|
||||||
|
// number then; the engine's always listens: sequence numbers are compared
|
||||||
|
// below the millions. The settings page (konfig) comes with F3.2c.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { LogLine, NetworkRuntimeEngine } from '../src/index.js';
|
||||||
|
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
|
||||||
|
import { browserText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
|
||||||
|
import { snapshotOf, type Scenario } from './scenario.js';
|
||||||
|
|
||||||
|
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||||
|
|
||||||
|
const id = (suffix: number) => `00000000-0000-0000-0000-${String(suffix).padStart(12, '0')}`;
|
||||||
|
|
||||||
|
type Program = 'server' | 'browser';
|
||||||
|
|
||||||
|
interface JavaRun extends JavaTables {
|
||||||
|
frames: Record<string, JavaFrame[]>;
|
||||||
|
events: [number, string, string][];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Story {
|
||||||
|
readonly scenario: string;
|
||||||
|
readonly browser: string;
|
||||||
|
readonly router: string;
|
||||||
|
readonly acts: readonly [number, (engine: NetworkRuntimeEngine, browser: string) => void][];
|
||||||
|
}
|
||||||
|
|
||||||
|
const open =
|
||||||
|
(typed: string) =>
|
||||||
|
(engine: NetworkRuntimeEngine, browser: string): void => {
|
||||||
|
engine.webBrowser(browser)!.open(typed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORIES: Record<string, Story> = {
|
||||||
|
routerIndex: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1')]] },
|
||||||
|
routerRoutes: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/routes')]] },
|
||||||
|
routerLog: {
|
||||||
|
scenario: 'router-web',
|
||||||
|
browser: id(301),
|
||||||
|
router: id(302),
|
||||||
|
acts: [
|
||||||
|
[500, (engine, browser) => engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 })],
|
||||||
|
[3500, open('10.0.0.1')],
|
||||||
|
[7000, open('10.0.0.1/log')],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gatewayIndex: {
|
||||||
|
scenario: 'nat-home',
|
||||||
|
browser: id(501),
|
||||||
|
router: id(502),
|
||||||
|
acts: [
|
||||||
|
[500, open('192.168.0.1')],
|
||||||
|
[4000, open('192.168.0.1/routes')],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gatewayFromWan: { scenario: 'nat-home', browser: id(504), router: id(502), acts: [[500, open('42.0.0.10')]] },
|
||||||
|
ripRoutes: { scenario: 'rip-chain', browser: id(401), router: id(402), acts: [[40_000, open('10.0.1.1/routes')]] },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sequence numbers below the millions: which socket drew which initial number differs (see above). */
|
||||||
|
const withoutInitialNumbers = (text: string) =>
|
||||||
|
withClientPortText(text)
|
||||||
|
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
|
||||||
|
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Java errs: its run corrected, as Java means it. routerLog: the
|
||||||
|
* German log page closes its title with </titel> (sw_firewallweblog_msg1);
|
||||||
|
* the engine writes </title>.
|
||||||
|
*/
|
||||||
|
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
||||||
|
routerLog: (java) => {
|
||||||
|
const fixed = (text: string | null) => text?.replace('</titel>', '</title>') ?? null;
|
||||||
|
const rows = (byCard: Record<string, (string | null)[][]> | undefined) =>
|
||||||
|
Object.fromEntries(Object.entries(byCard ?? {}).map(([card, cardRows]) => [card, cardRows.map((row) => row.map(fixed))]));
|
||||||
|
return { ...java, events: java.events.map(([time, who, text]) => [time, who, fixed(text)!]), rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ripRoutes: how long a learned route has left. Java's routers start one
|
||||||
|
* after the other and draw their beacons' pauses in another order (rip-chain
|
||||||
|
* in ripJava.test.ts: R2 sends three times at the start, not twice); a
|
||||||
|
* neighbour's beacon refreshes the route some seconds apart. Compared as a
|
||||||
|
* placeholder; the page's other cells exactly.
|
||||||
|
*/
|
||||||
|
const PAGE_PLACEHOLDERS: Record<string, (text: string) => string> = {
|
||||||
|
ripRoutes: (text) => text.replace(/<td>-?\d+<\/td>(<td>[\d.]+<\/td><td><a )/g, '<td>validity</td>$1'),
|
||||||
|
};
|
||||||
|
|
||||||
|
type Lines = Record<Program, [number, string][]>;
|
||||||
|
|
||||||
|
function javaLines(java: JavaRun): Lines {
|
||||||
|
const lines: Lines = { server: [], browser: [] };
|
||||||
|
const firstCall = Math.min(...java.events.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time));
|
||||||
|
for (const [time, program, text] of java.events) {
|
||||||
|
if (program !== 'server' && program !== 'browser') continue;
|
||||||
|
if (time < firstCall || text === 'null' || text.startsWith('call ')) continue;
|
||||||
|
const page = /^page (\d+) (\S+)\n/.exec(text);
|
||||||
|
lines[program].push([time, withClientPortText(page !== null && page[1] !== '200' ? `page ${page[1]} ${page[2]}` : text)]);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineLines(engine: NetworkRuntimeEngine, story: Story, firstCall: number): Lines {
|
||||||
|
const lines = <E>(log: readonly LogLine<E>[], text: (entry: E) => string | undefined): [number, string][] =>
|
||||||
|
log.flatMap((line) => {
|
||||||
|
const each = line.timeMilliseconds < firstCall ? undefined : text(line);
|
||||||
|
return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
server: lines(engine.webServer(story.router)!.log.all, serverText),
|
||||||
|
browser: lines(engine.webBrowser(story.browser)!.log.all, browserText),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the pages of routers and home routers as Java FILIUS serves them', () => {
|
||||||
|
it('corrects Java only where it errs, and puts in placeholders only where they change something', () => {
|
||||||
|
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
||||||
|
const java = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||||
|
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
||||||
|
}
|
||||||
|
for (const [name, placeholders] of Object.entries(PAGE_PLACEHOLDERS)) {
|
||||||
|
const text = readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8');
|
||||||
|
expect(placeholders(text), name).not.toBe(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, story] of Object.entries(STORIES)) {
|
||||||
|
it(name, () => {
|
||||||
|
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||||
|
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
||||||
|
const placeholders = PAGE_PLACEHOLDERS[name] ?? ((text: string) => text);
|
||||||
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${story.scenario}.scenario.json`), 'utf8')) as Scenario;
|
||||||
|
const snapshot = { ...snapshotOf(scenario), webBrowserNodeIds: new Set([story.browser]) };
|
||||||
|
const { engine } = watchedEngine(scenario, snapshot);
|
||||||
|
for (const [time, act] of story.acts) {
|
||||||
|
engine.handle({ type: 'advance', toMilliseconds: time });
|
||||||
|
act(engine, story.browser);
|
||||||
|
}
|
||||||
|
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
|
||||||
|
|
||||||
|
const firstCall = story.acts[0]![0];
|
||||||
|
// Before the story, only what the network does by itself (RIP); compared from the first call.
|
||||||
|
expectSameCaptures(engine, scenario, java.frames, {
|
||||||
|
text: withoutInitialNumbers,
|
||||||
|
cell: (text) => placeholders(withoutInitialNumbers(text)),
|
||||||
|
tables: java,
|
||||||
|
from: firstCall,
|
||||||
|
starts: { java: 0, engine: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const withPlaceholders = (lines: Lines): Lines => ({
|
||||||
|
server: lines.server.map(([time, text]) => [time, placeholders(text)]),
|
||||||
|
browser: lines.browser.map(([time, text]) => [time, placeholders(text)]),
|
||||||
|
});
|
||||||
|
const expected = withPlaceholders(javaLines(java));
|
||||||
|
const actual = withPlaceholders(engineLines(engine, story, firstCall));
|
||||||
|
for (const program of ['server', 'browser'] as const) {
|
||||||
|
expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text));
|
||||||
|
for (const [index, [javaTime, text]] of expected[program].entries()) {
|
||||||
|
const late = javaTime - actual[program][index]![0];
|
||||||
|
const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + GAP_TOLERANCE_MILLISECONDS;
|
||||||
|
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeGreaterThanOrEqual(-GAP_TOLERANCE_MILLISECONDS);
|
||||||
|
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeLessThanOrEqual(most);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"seed": 71,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000301",
|
||||||
|
"kind": "pc",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000311",
|
||||||
|
"label": "eth0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000302",
|
||||||
|
"kind": "router",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000321",
|
||||||
|
"label": "eth0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000322",
|
||||||
|
"label": "eth1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000303",
|
||||||
|
"kind": "pc",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000313",
|
||||||
|
"label": "eth0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000304",
|
||||||
|
"kind": "networkSwitch",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000341",
|
||||||
|
"label": "sw1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000342",
|
||||||
|
"label": "sw2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000343",
|
||||||
|
"label": "sw3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-00000000F301",
|
||||||
|
"sourceNode": "00000000-0000-0000-0000-000000000301",
|
||||||
|
"sourcePort": "00000000-0000-0000-0000-000000000311",
|
||||||
|
"targetNode": "00000000-0000-0000-0000-000000000304",
|
||||||
|
"targetPort": "00000000-0000-0000-0000-000000000341"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-00000000F302",
|
||||||
|
"sourceNode": "00000000-0000-0000-0000-000000000302",
|
||||||
|
"sourcePort": "00000000-0000-0000-0000-000000000321",
|
||||||
|
"targetNode": "00000000-0000-0000-0000-000000000304",
|
||||||
|
"targetPort": "00000000-0000-0000-0000-000000000342"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-00000000F303",
|
||||||
|
"sourceNode": "00000000-0000-0000-0000-000000000302",
|
||||||
|
"sourcePort": "00000000-0000-0000-0000-000000000322",
|
||||||
|
"targetNode": "00000000-0000-0000-0000-000000000303",
|
||||||
|
"targetPort": "00000000-0000-0000-0000-000000000313"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"devices": {
|
||||||
|
"00000000-0000-0000-0000-000000000301": {
|
||||||
|
"ip": "10.0.0.2",
|
||||||
|
"mask": "255.255.255.0",
|
||||||
|
"gateway": "10.0.0.1"
|
||||||
|
},
|
||||||
|
"00000000-0000-0000-0000-000000000302": {
|
||||||
|
"ip": "10.0.0.1",
|
||||||
|
"mask": "255.255.255.0"
|
||||||
|
},
|
||||||
|
"00000000-0000-0000-0000-000000000303": {
|
||||||
|
"ip": "20.0.0.2",
|
||||||
|
"mask": "255.255.255.0",
|
||||||
|
"gateway": "20.0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"node": "00000000-0000-0000-0000-000000000302",
|
||||||
|
"port": "00000000-0000-0000-0000-000000000321",
|
||||||
|
"ip": "10.0.0.1",
|
||||||
|
"mask": "255.255.255.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "00000000-0000-0000-0000-000000000302",
|
||||||
|
"port": "00000000-0000-0000-0000-000000000322",
|
||||||
|
"ip": "20.0.0.1",
|
||||||
|
"mask": "255.255.255.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"firewalls": {
|
||||||
|
"00000000-0000-0000-0000-000000000302": {
|
||||||
|
"active": true,
|
||||||
|
"defaultPolicy": "accept",
|
||||||
|
"dropIcmp": true,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"source": "10.0.0.0",
|
||||||
|
"sourceMask": "255.255.255.0",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"port": 80,
|
||||||
|
"action": "accept"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,9 @@ import {
|
|||||||
withSnapshot,
|
withSnapshot,
|
||||||
type LogLine,
|
type LogLine,
|
||||||
type NetworkRuntimeEngine,
|
type NetworkRuntimeEngine,
|
||||||
type WebBrowserLogEntry,
|
|
||||||
} from '../src/index.js';
|
} from '../src/index.js';
|
||||||
import { expectSameCaptures, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
|
import { expectSameCaptures, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
|
||||||
import { clientText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
|
import { browserText, clientText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
|
||||||
import { snapshotOf, type Scenario } from './scenario.js';
|
import { snapshotOf, type Scenario } from './scenario.js';
|
||||||
|
|
||||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||||
@@ -107,21 +106,6 @@ function fileSystemOf(files: [string, string, string][]): VirtualFileSystem {
|
|||||||
return fileSystem;
|
return fileSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A browser's line as its window took it: a page (with its text only for 200), or "update" for an image. */
|
|
||||||
function browserText(entry: WebBrowserLogEntry): string | undefined {
|
|
||||||
switch (entry.kind) {
|
|
||||||
case 'page':
|
|
||||||
return `page ${entry.statusCode} ${entry.contentType ?? 'null'}${entry.statusCode === 200 ? `\n${entry.body ?? 'null'}` : ''}`;
|
|
||||||
case 'unreachable':
|
|
||||||
return 'page 0 null';
|
|
||||||
case 'image': {
|
|
||||||
// WebBrowser.retrieveImages tells the window of an image that came, or of a status other than 200.
|
|
||||||
const isImage = entry.contentType !== undefined && IMAGE_TYPES.has(entry.contentType.toLowerCase().replace('image/', ''));
|
|
||||||
return entry.statusCode !== 200 || isImage ? 'update' : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Lines = Record<Program, [number, string][]>;
|
type Lines = Record<Program, [number, string][]>;
|
||||||
|
|
||||||
function javaLines(java: JavaRun): Lines {
|
function javaLines(java: JavaRun): Lines {
|
||||||
|
|||||||
@@ -76,3 +76,6 @@ packages/core/src/runtime/apps/::tcpsocket
|
|||||||
packages/core/src/runtime/apps/::senden
|
packages/core/src/runtime/apps/::senden
|
||||||
packages/core/test/echoJava.test.ts::tcpsocket
|
packages/core/test/echoJava.test.ts::tcpsocket
|
||||||
packages/core/test/programLines.ts::tcpsocket
|
packages/core/test/programLines.ts::tcpsocket
|
||||||
|
# Java's German firewall log page closes its title with the typo </titel>; quoted where the core names the departure.
|
||||||
|
packages/core/src/runtime/apps/routerPages.ts::titel
|
||||||
|
packages/core/test/routerWebJava.test.ts::titel
|
||||||
|
|||||||
@@ -617,3 +617,32 @@ Gemessen (01.10.2026):
|
|||||||
* Die Fehlerseiten macht der Browser selbst (im Messaufbau Javas Ersatztext,
|
* Die Fehlerseiten macht der Browser selbst (im Messaufbau Javas Ersatztext,
|
||||||
weil die Vorlage fehlt); der Vergleich sieht nur den Status.
|
weil die Vorlage fehlt); der Vergleich sieht nur den Status.
|
||||||
|
|
||||||
|
## Seiten der Router und Heimrouter (F3.2b)
|
||||||
|
|
||||||
|
`RouterWebRun` lässt den Browser eines Rechners die Seiten eines Routers
|
||||||
|
oder Heimrouters holen: auf `router-web` (`arp-routed` mit Firewall auf dem
|
||||||
|
Router: ICMP verwerfen, TCP-Port 80 aus 10.0.0.0/24 erlauben) Index,
|
||||||
|
`routes`, `log` (nach einem verworfenen Ping und zwei Verbindungen) und
|
||||||
|
`konfig`; auf `nat-home` den Index des Heimrouters von innen und von außen
|
||||||
|
und `routes` (gibt es dort nicht: 404); auf `rip-chain` nach 40 s die
|
||||||
|
RIP-Tabelle (die Router wie bei `RipRun` gesetzt). `ScenarioNetwork` baut
|
||||||
|
dafür jetzt auch Firewalls nach dem Szenario-Feld `firewalls`.
|
||||||
|
|
||||||
|
Gemessen (01.10.2026):
|
||||||
|
|
||||||
|
* Router und Heimrouter schalten ihren Webserver beim Start ein
|
||||||
|
(`setAktiv(true)`, Fensterzeile „Annahme … gestartet“). Ob er lauscht, ist
|
||||||
|
ein Wettlauf: `setAktiv` weckt den Thread nur, wenn er schon wartet. In
|
||||||
|
einem von drei RIP-Läufen blieb das SYN an den Router unbeantwortet; in den
|
||||||
|
NAT-Aufnahmen hat er nie gelauscht (er nahm keine Startnummer).
|
||||||
|
* Index-Seiten aus Javas Vorlagen, zeilenweise gelesen und mit `\n`
|
||||||
|
wieder zusammengesetzt (auch die Windows-Zeilenenden der deutschen).
|
||||||
|
* `log`: ein Eintrag je Regeltreffer bei TCP und UDP und je verworfenem
|
||||||
|
ICMP-Paket; die deutsche Seite schließt den Titel mit `</titel>`.
|
||||||
|
* `routes` ohne RIP: Javas Weiterleitungstabelle (eigene Adressen, Netze,
|
||||||
|
localhost, manuelle Routen, Standardgateway), grün, was direkt ist, blau das
|
||||||
|
Standardgateway; mit RIP: Hops, Gültigkeit in ganzen Sekunden, nächster Hop
|
||||||
|
und die erste Adresse des meldenden Routers.
|
||||||
|
* Java zählt TCP-Sequenznummern und Segmente in Zeichen seiner Strings: Ein
|
||||||
|
„ä“ auf der Routen-Seite ist eine Stelle, nicht zwei Bytes.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
|||||||
fi
|
fi
|
||||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||||
mkdir -p "$here/.build"
|
mkdir -p "$here/.build"
|
||||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java"
|
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java"
|
||||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||||
mkdir -p "$here/.build/home"
|
mkdir -p "$here/.build/home"
|
||||||
# Removed first: a crashed run must not leave the previous result standing.
|
# Removed first: a crashed run must not leave the previous result standing.
|
||||||
@@ -219,6 +219,23 @@ for run in page:8000 byName:12000 notFound:6000 vhost:14000 noServer:14000 unres
|
|||||||
test -s "$output"
|
test -s "$output"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The pages of routers and home routers as Java FILIUS serves them
|
||||||
|
# (RouterWebRun): router-web, nat-home and rip-chain. Java's router server
|
||||||
|
# sometimes never listens (a race in setAktiv): a run without a page is
|
||||||
|
# repeated.
|
||||||
|
for run in router-web:routerIndex:6000 router-web:routerRoutes:6000 router-web:routerLog:12000 router-web:routerKonfig:6000 \
|
||||||
|
nat-home:gatewayIndex:9000 nat-home:gatewayFromWan:15000 rip-chain:ripRoutes:46000; do
|
||||||
|
network="${run%%:*}"; rest="${run#*:}"; story="${rest%%:*}"
|
||||||
|
output="$root/packages/core/test/scenarios/routerweb-java-$story.java.json"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" RouterWebRun \
|
||||||
|
"$root/packages/core/test/scenarios/$network.scenario.json" "$story" "${rest##*:}" "$output" 2>&1 | grep -v 'logback\|^$' || true
|
||||||
|
grep -q '"page 200' "$output" && break
|
||||||
|
done
|
||||||
|
test -s "$output"
|
||||||
|
done
|
||||||
|
|
||||||
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
||||||
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts).
|
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts).
|
||||||
for run in internetwork_komplett:routing:12000 einfaches_rechnernetz_komplett:dhcp:20000 dns_server:dns:12000 \
|
for run in internetwork_komplett:routing:12000 einfaches_rechnernetz_komplett:dhcp:20000 dns_server:dns:12000 \
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Runs a story with the web pages of FILIUS' routers and home routers: each
|
||||||
|
// starts a web server on port 80 with its own index page and the plug-ins
|
||||||
|
// log (the firewall's log), konfig (the firewall's settings) and, on a
|
||||||
|
// router, routes (VermittlungWeb: forwarding or RIP table). A host's web
|
||||||
|
// browser asks for them; recorded are every frame each card saw, the
|
||||||
|
// router's web server's window lines and everything the browser told its
|
||||||
|
// window.
|
||||||
|
//
|
||||||
|
// RouterWebRun <scenario.json> <story> <milliseconds> <out.json>
|
||||||
|
//
|
||||||
|
// router-web (router 10.0.0.1/20.0.0.1 with a firewall: drops ICMP,
|
||||||
|
// accepts TCP to port 80 from 10.0.0.0/24; browser on 10.0.0.2):
|
||||||
|
// routerIndex 10.0.0.1
|
||||||
|
// routerRoutes 10.0.0.1/routes (the forwarding table)
|
||||||
|
// routerLog a ping to 20.0.0.2 (dropped), the index, then
|
||||||
|
// 10.0.0.1/log
|
||||||
|
// routerKonfig 10.0.0.1/konfig
|
||||||
|
// nat-home (home router 42.0.0.10/192.168.0.1):
|
||||||
|
// gatewayIndex from the LAN PC 192.168.0.10: 192.168.0.1, then
|
||||||
|
// 192.168.0.1/routes (no such page on a home router)
|
||||||
|
// gatewayFromWan from the WAN host 42.0.0.20: 42.0.0.10
|
||||||
|
// rip-chain (three RIP routers, each seeded as RipRun seeds it):
|
||||||
|
// ripRoutes from 10.0.1.2, after 40 s: 10.0.1.1/routes
|
||||||
|
|
||||||
|
import filius.hardware.knoten.Knoten;
|
||||||
|
import filius.rahmenprogramm.EingabenUeberpruefung;
|
||||||
|
import filius.software.rip.RIPBeacon;
|
||||||
|
import filius.software.system.InternetKnotenBetriebssystem;
|
||||||
|
import filius.software.system.VermittlungsrechnerBetriebssystem;
|
||||||
|
import filius.software.www.HTTPNachricht;
|
||||||
|
import filius.software.www.WebBrowser;
|
||||||
|
import filius.software.www.WebServer;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
public class RouterWebRun {
|
||||||
|
static ScenarioNetwork network;
|
||||||
|
static WebBrowser browser;
|
||||||
|
static final List<Object[]> events = new ArrayList<>();
|
||||||
|
|
||||||
|
static synchronized void event(String who, String what) {
|
||||||
|
events.add(new Object[] { System.currentTimeMillis() - network.start, who, what });
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleepUntil(long milliseconds) throws InterruptedException {
|
||||||
|
long rest = network.start + milliseconds - System.currentTimeMillis();
|
||||||
|
if (rest > 0) Thread.sleep(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void open(String typed) throws Exception {
|
||||||
|
event("browser", "call holeWebseite " + typed);
|
||||||
|
String[] parts = typed.split("/");
|
||||||
|
String path = "";
|
||||||
|
for (int i = 1; i < parts.length; i++) path = path + "/" + parts[i];
|
||||||
|
if (!EingabenUeberpruefung.isGueltig(parts[0], EingabenUeberpruefung.musterIpAdresse)) throw new IllegalArgumentException(typed);
|
||||||
|
browser.holeWebseite(new URL("http", parts[0], path.isEmpty() ? "/" : path));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String id(int suffix) {
|
||||||
|
return String.format("00000000-0000-0000-0000-%012d", suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
network = ScenarioNetwork.read(args[0]);
|
||||||
|
String story = args[1];
|
||||||
|
long end = Long.parseLong(args[2]);
|
||||||
|
String browserNode;
|
||||||
|
String routerNode;
|
||||||
|
switch (story) {
|
||||||
|
case "gatewayIndex":
|
||||||
|
browserNode = id(501);
|
||||||
|
routerNode = id(502);
|
||||||
|
break;
|
||||||
|
case "gatewayFromWan":
|
||||||
|
browserNode = id(504);
|
||||||
|
routerNode = id(502);
|
||||||
|
break;
|
||||||
|
case "ripRoutes":
|
||||||
|
browserNode = id(401);
|
||||||
|
routerNode = id(402);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
browserNode = id(301);
|
||||||
|
routerNode = id(302);
|
||||||
|
}
|
||||||
|
InternetKnotenBetriebssystem host = network.system(browserNode);
|
||||||
|
host.installApp("filius.software.www.WebBrowser", false);
|
||||||
|
browser = (WebBrowser) host.holeSoftware("filius.software.www.WebBrowser");
|
||||||
|
WebServer server = (WebServer) network.system(routerNode).holeSoftware("filius.software.www.WebServer");
|
||||||
|
server.hinzuBeobachter((observable, data) -> event("server", String.valueOf(data)));
|
||||||
|
browser.hinzuBeobachter((observable, data) -> {
|
||||||
|
if (data instanceof HTTPNachricht) {
|
||||||
|
HTTPNachricht page = (HTTPNachricht) data;
|
||||||
|
event("browser", "page " + page.getStatusCode() + " " + page.getContentType() + "\n" + page.getDaten());
|
||||||
|
} else {
|
||||||
|
event("browser", data == null ? "update" : String.valueOf(data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
network.start();
|
||||||
|
if (story.equals("ripRoutes")) {
|
||||||
|
long seed = ScenarioNetwork.seed(args[0]);
|
||||||
|
Field beaconField = VermittlungsrechnerBetriebssystem.class.getDeclaredField("ripbeacon");
|
||||||
|
beaconField.setAccessible(true);
|
||||||
|
Field randomField = RIPBeacon.class.getDeclaredField("rand");
|
||||||
|
randomField.setAccessible(true);
|
||||||
|
for (Map.Entry<String, Knoten> entry : network.nodes.entrySet()) {
|
||||||
|
if (!(entry.getValue().getSystemSoftware() instanceof VermittlungsrechnerBetriebssystem)) continue;
|
||||||
|
VermittlungsrechnerBetriebssystem os = (VermittlungsrechnerBetriebssystem) entry.getValue().getSystemSoftware();
|
||||||
|
if (os.isRipEnabled()) randomField.set(beaconField.get(os), new Random(RipRun.ripSeed(seed, entry.getKey())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleepUntil(500);
|
||||||
|
|
||||||
|
switch (story) {
|
||||||
|
case "routerIndex":
|
||||||
|
open("10.0.0.1");
|
||||||
|
break;
|
||||||
|
case "routerRoutes":
|
||||||
|
open("10.0.0.1/routes");
|
||||||
|
break;
|
||||||
|
case "routerLog": {
|
||||||
|
Thread ping = new Thread(() -> {
|
||||||
|
event("ping", "call sendProbe 20.0.0.2");
|
||||||
|
Object answer = host.holeICMP().sendProbe("20.0.0.2", 64, 1, 1);
|
||||||
|
event("ping", "returned " + (answer == null ? "null" : "answer"));
|
||||||
|
});
|
||||||
|
ping.start();
|
||||||
|
sleepUntil(3500);
|
||||||
|
open("10.0.0.1");
|
||||||
|
sleepUntil(7000);
|
||||||
|
open("10.0.0.1/log");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "routerKonfig":
|
||||||
|
open("10.0.0.1/konfig");
|
||||||
|
break;
|
||||||
|
case "gatewayIndex":
|
||||||
|
open("192.168.0.1");
|
||||||
|
sleepUntil(4000);
|
||||||
|
open("192.168.0.1/routes");
|
||||||
|
break;
|
||||||
|
case "gatewayFromWan":
|
||||||
|
open("42.0.0.10");
|
||||||
|
break;
|
||||||
|
case "ripRoutes":
|
||||||
|
sleepUntil(40000);
|
||||||
|
open("10.0.1.1/routes");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("unknown story " + story);
|
||||||
|
}
|
||||||
|
sleepUntil(end);
|
||||||
|
|
||||||
|
ScenarioNetwork.Json out = new ScenarioNetwork.Json();
|
||||||
|
out.open('{');
|
||||||
|
network.writeFrames(out);
|
||||||
|
synchronized (RouterWebRun.class) {
|
||||||
|
out.key("events").open('[');
|
||||||
|
for (Object[] each : events) out.open('[').number((Long) each[0]).string((String) each[1]).string((String) each[2]).close(']');
|
||||||
|
out.close(']');
|
||||||
|
}
|
||||||
|
out.close('}');
|
||||||
|
network.stop();
|
||||||
|
ScenarioNetwork.write(args[3], out);
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import filius.hardware.knoten.Vermittlungsrechner;
|
|||||||
import filius.rahmenprogramm.nachrichten.Lauscher;
|
import filius.rahmenprogramm.nachrichten.Lauscher;
|
||||||
import filius.software.dhcp.DHCPServer;
|
import filius.software.dhcp.DHCPServer;
|
||||||
import filius.software.dns.DNSServer;
|
import filius.software.dns.DNSServer;
|
||||||
|
import filius.software.firewall.Firewall;
|
||||||
|
import filius.software.firewall.FirewallRule;
|
||||||
import filius.software.netzzugangsschicht.EthernetFrame;
|
import filius.software.netzzugangsschicht.EthernetFrame;
|
||||||
import filius.software.system.Betriebssystem;
|
import filius.software.system.Betriebssystem;
|
||||||
import filius.software.system.GatewayFirmware;
|
import filius.software.system.GatewayFirmware;
|
||||||
@@ -233,6 +235,29 @@ public class ScenarioNetwork {
|
|||||||
for (Object each : list(scenario.getOrDefault("rip", new ArrayList<>()))) {
|
for (Object each : list(scenario.getOrDefault("rip", new ArrayList<>()))) {
|
||||||
((VermittlungsrechnerBetriebssystem) system(nodes.get((String) each))).setRipEnabled(true);
|
((VermittlungsrechnerBetriebssystem) system(nodes.get((String) each))).setRipEnabled(true);
|
||||||
}
|
}
|
||||||
|
// Firewalls: the core's defaults (inactive, drop, ICMP through, SYN only, UDP filtered) and its rules.
|
||||||
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("firewalls", new HashMap<>())).entrySet()) {
|
||||||
|
Map<String, Object> config = map(entry.getValue());
|
||||||
|
InternetKnotenBetriebssystem os = system(nodes.get(entry.getKey()));
|
||||||
|
Firewall firewall = os instanceof VermittlungsrechnerBetriebssystem ? ((VermittlungsrechnerBetriebssystem) os).holeFirewall()
|
||||||
|
: os instanceof GatewayFirmware ? ((GatewayFirmware) os).holeFirewall()
|
||||||
|
: (Firewall) os.holeSoftware("filius.software.firewall.Firewall");
|
||||||
|
firewall.setActivated(Boolean.TRUE.equals(config.get("active")));
|
||||||
|
firewall.setDefaultPolicy("accept".equals(config.get("defaultPolicy")) ? FirewallRule.ACCEPT : FirewallRule.DROP);
|
||||||
|
firewall.setDropICMP(Boolean.TRUE.equals(config.get("dropIcmp")));
|
||||||
|
firewall.setFilterSYNSegmentsOnly(!Boolean.FALSE.equals(config.get("synOnly")));
|
||||||
|
firewall.setFilterUdp(!Boolean.FALSE.equals(config.get("filterUdp")));
|
||||||
|
for (Object each : list(config.getOrDefault("rules", new ArrayList<>()))) {
|
||||||
|
Map<String, Object> rule = map(each);
|
||||||
|
String protocol = (String) rule.getOrDefault("protocol", "tcp");
|
||||||
|
short number = protocol.equals("all") ? FirewallRule.ALL_PROTOCOLS
|
||||||
|
: protocol.equals("icmp") ? FirewallRule.ICMP : protocol.equals("udp") ? FirewallRule.UDP : FirewallRule.TCP;
|
||||||
|
firewall.addRule(new FirewallRule((String) rule.getOrDefault("source", ""), (String) rule.getOrDefault("sourceMask", ""),
|
||||||
|
(String) rule.getOrDefault("destination", ""), (String) rule.getOrDefault("destinationMask", ""),
|
||||||
|
rule.containsKey("port") ? ((Number) rule.get("port")).intValue() : FirewallRule.ALL_PORTS, number,
|
||||||
|
"accept".equals(rule.get("action")) ? FirewallRule.ACCEPT : FirewallRule.DROP));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user