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:
@@ -107,13 +107,16 @@ import {
|
||||
type DnsQuestion,
|
||||
} from './dns.js';
|
||||
import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolicy } from './capture.js';
|
||||
import { ICMP_TYPE_CODE } from './captureRows.js';
|
||||
import { JavaCompatibleRandom } from './random.js';
|
||||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||||
import { RuntimeSpeed } from './speed.js';
|
||||
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
|
||||
import { EchoServer, SimpleClient } from './apps/echo.js';
|
||||
import { WebBrowser, WebServer } from './apps/web.js';
|
||||
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
|
||||
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
|
||||
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
|
||||
import {
|
||||
PRODUCTION_TRACE_RETENTION,
|
||||
type HeaderField,
|
||||
@@ -305,6 +308,23 @@ export interface FirewallDecision {
|
||||
readonly ruleIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line of a firewall's log (Firewall.benachrichtigeBeobachter, shown by
|
||||
* the router's page "log"): a rule that matched a TCP or UDP packet, with
|
||||
* the node's first interface (the rule's text names its network for "any
|
||||
* directly connected network"), or an ICMP packet dropped by the general
|
||||
* setting.
|
||||
*/
|
||||
export type FirewallLogEntry =
|
||||
| {
|
||||
readonly kind: 'ruleMatched';
|
||||
readonly ruleNumber: number;
|
||||
readonly rule: FirewallRule;
|
||||
readonly firstInterface: { readonly ipAddress: string; readonly subnetMask: string } | undefined;
|
||||
readonly accepted: boolean;
|
||||
}
|
||||
| { readonly kind: 'icmpDropped'; readonly senderIPAddress: string; readonly receiverIPAddress: string; readonly type: number; readonly code: number };
|
||||
|
||||
export type RuntimePhase = 'stopped' | 'running';
|
||||
|
||||
export type SocketProtocol = 'udp' | 'tcp';
|
||||
@@ -863,6 +883,7 @@ export class NetworkRuntimeEngine {
|
||||
private readonly cablesCarryOneFrameAtATime: boolean;
|
||||
private readonly swiftArp: boolean;
|
||||
private readonly swiftSends: boolean;
|
||||
private readonly swiftRouters: boolean;
|
||||
private readonly packetCapture: PacketCapture;
|
||||
/** The programs' threads (threads.ts), and the wake-ups they wait for, by id. */
|
||||
private readonly applicationThreads = new ApplicationThreads({
|
||||
@@ -888,6 +909,8 @@ export class NetworkRuntimeEngine {
|
||||
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
|
||||
private readonly webServersByNodeId = new Map<Id, WebServer>();
|
||||
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
|
||||
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
|
||||
private readonly firewallLogsByNodeId = new Map<Id, FirewallLogEntry[]>();
|
||||
/** Accepted connections a program has taken (acceptTcpSteps). */
|
||||
private readonly handedOutTcpSocketIds = new Set<Id>();
|
||||
private nextApplicationWakeId = 1;
|
||||
@@ -901,6 +924,8 @@ export class NetworkRuntimeEngine {
|
||||
*
|
||||
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
|
||||
* when the network is quiet, likewise only for the comparison.
|
||||
* `swiftRouters`: routers and home routers start no web server (Swift's
|
||||
* web administration answers without a socket), likewise.
|
||||
*
|
||||
* `captureRetention`: how many frames each card's packet capture keeps.
|
||||
*/
|
||||
@@ -911,6 +936,7 @@ export class NetworkRuntimeEngine {
|
||||
readonly swiftCables?: boolean;
|
||||
readonly swiftArp?: boolean;
|
||||
readonly swiftSends?: boolean;
|
||||
readonly swiftRouters?: boolean;
|
||||
readonly captureRetention?: CaptureRetentionPolicy;
|
||||
} = {},
|
||||
) {
|
||||
@@ -918,6 +944,7 @@ export class NetworkRuntimeEngine {
|
||||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||||
this.swiftArp = options.swiftArp === true;
|
||||
this.swiftSends = options.swiftSends === true;
|
||||
this.swiftRouters = options.swiftRouters === true;
|
||||
const normalizedSeed = toSeed(seed);
|
||||
this.stateValue = initialState(normalizedSeed);
|
||||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||||
@@ -1048,6 +1075,27 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** The node's firewall log, since the engine was made. */
|
||||
firewallLog(nodeId: Id): readonly FirewallLogEntry[] {
|
||||
return this.firewallLogsByNodeId.get(nodeId) ?? [];
|
||||
}
|
||||
|
||||
private logFirewall(nodeId: Id, entry: FirewallLogEntry): void {
|
||||
getOrCreate(this.firewallLogsByNodeId, nodeId, () => []).push(entry);
|
||||
}
|
||||
|
||||
/** Firewall.notifyRuleApplication. */
|
||||
private logFirewallRule(nodeId: Id, index: number, rule: FirewallRule): void {
|
||||
const first = this.networkInterfaces(nodeId)[0];
|
||||
this.logFirewall(nodeId, {
|
||||
kind: 'ruleMatched',
|
||||
ruleNumber: index + 1,
|
||||
rule,
|
||||
firstInterface: first === undefined ? undefined : { ipAddress: first.ipAddress, subnetMask: first.subnetMask },
|
||||
accepted: rule.action === 'accept',
|
||||
});
|
||||
}
|
||||
|
||||
/** The node's echo server while the simulation runs, if it has one installed. */
|
||||
echoServer(nodeId: Id): EchoServer | undefined {
|
||||
return this.echoServersByNodeId.get(nodeId);
|
||||
@@ -1088,9 +1136,34 @@ export class NetworkRuntimeEngine {
|
||||
this.webServersByNodeId.set(node.id, new WebServer(this, node.id, webServer, snapshot.fileSystemsByNodeId.get(node.id)));
|
||||
}
|
||||
if (snapshot.webBrowserNodeIds.has(node.id)) this.webBrowsersByNodeId.set(node.id, new WebBrowser(this, node.id));
|
||||
if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VermittlungsrechnerBetriebssystem, GatewayFirmware: a router and a home
|
||||
* router switch on their web server on port 80 when they start, with
|
||||
* their index page and the pages log and, on a router, routes. Java's
|
||||
* server sometimes never listens (setAktiv wakes its thread only when it
|
||||
* already waits); the core's always does.
|
||||
*/
|
||||
private startRouterWebServer(nodeId: Id, kind: 'router' | 'gateway'): void {
|
||||
const language = this.stateValue.topologySnapshot.language;
|
||||
const files = new VirtualFileSystem();
|
||||
files.createDirectory('/webserver', true);
|
||||
files.writeTextFile('/webserver/index.html', routerIndexPage(kind, language));
|
||||
const pages = new Map<string, WebServerPage>([['log', () => firewallLogPage(this.firewallLog(nodeId), language)]]);
|
||||
if (kind === 'router') {
|
||||
pages.set('routes', () => {
|
||||
const rip = this.ripTable(nodeId);
|
||||
return rip === undefined ? forwardingTablePage(this.forwardingTable(nodeId), language) : ripTablePage(rip, this.stateValue.currentTimeMilliseconds, language);
|
||||
});
|
||||
}
|
||||
const server = new WebServer(this, nodeId, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }, files, pages);
|
||||
this.webServersByNodeId.set(nodeId, server);
|
||||
server.setActive(true);
|
||||
}
|
||||
|
||||
/** Begins a program's thread (threads.ts): it runs at once up to its first wait. */
|
||||
startThread(steps: Steps<unknown>): void {
|
||||
if (this.stateValue.phase !== 'running') return;
|
||||
@@ -2035,9 +2108,7 @@ export class NetworkRuntimeEngine {
|
||||
if (payload.length === 0) {
|
||||
chunks.push(new Uint8Array());
|
||||
} else {
|
||||
for (let offset = 0; offset < payload.length; offset += TCP_MAXIMUM_SEGMENT_SIZE) {
|
||||
chunks.push(payload.subarray(offset, Math.min(offset + TCP_MAXIMUM_SEGMENT_SIZE, payload.length)));
|
||||
}
|
||||
chunks.push(...javaTextPieces(payload, TCP_MAXIMUM_SEGMENT_SIZE));
|
||||
}
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return 'connectionLost';
|
||||
@@ -2681,6 +2752,7 @@ export class NetworkRuntimeEngine {
|
||||
this.ripRoutersByNodeId.clear();
|
||||
for (const node of state.topologySnapshot.nodes) {
|
||||
if (!this.isRipEnabledRouter(node.id)) continue;
|
||||
const primaryIPAddress = this.networkInterfaces(node.id)[0]?.ipAddress;
|
||||
state.ripTablesByNodeId.set(
|
||||
node.id,
|
||||
this.networkInterfaces(node.id).flatMap((networkInterface) => {
|
||||
@@ -2696,6 +2768,7 @@ export class NetworkRuntimeEngine {
|
||||
origin: 'connected' as const,
|
||||
isEditable: false,
|
||||
metric: 0,
|
||||
...(primaryIPAddress === undefined ? {} : { hopPublicIPAddress: primaryIPAddress }),
|
||||
},
|
||||
];
|
||||
}),
|
||||
@@ -2829,14 +2902,15 @@ export class NetworkRuntimeEngine {
|
||||
if (route !== undefined) {
|
||||
const routeHops = route.metric ?? RIP_INFINITY;
|
||||
if (route.nextHop !== message.ipAddress && routeHops <= hops) continue;
|
||||
let { nextHop, interfaceIPAddress: routeInterface } = route;
|
||||
let { nextHop, interfaceIPAddress: routeInterface, hopPublicIPAddress } = route;
|
||||
if (routeHops > hops) {
|
||||
nextHop = message.ipAddress;
|
||||
routeInterface = interfaceIPAddress;
|
||||
hopPublicIPAddress = message.publicIPAddress;
|
||||
} else if (routeHops < hops) {
|
||||
announce = true;
|
||||
}
|
||||
table[index] = { ...route, nextHop, interfaceIPAddress: routeInterface, metric: hops, expiresAtMilliseconds };
|
||||
table[index] = { ...route, nextHop, interfaceIPAddress: routeInterface, metric: hops, expiresAtMilliseconds, ...(hopPublicIPAddress === undefined ? {} : { hopPublicIPAddress }) };
|
||||
} else if (hops < RIP_INFINITY) {
|
||||
table.push({
|
||||
destinationNetwork: entry.network,
|
||||
@@ -2847,6 +2921,7 @@ export class NetworkRuntimeEngine {
|
||||
isEditable: false,
|
||||
metric: hops,
|
||||
expiresAtMilliseconds,
|
||||
hopPublicIPAddress: message.publicIPAddress,
|
||||
});
|
||||
announce = true;
|
||||
}
|
||||
@@ -4928,6 +5003,21 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
// ── Routing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Weiterleitungstabelle.holeTabelle: the node's own routes, its manual ones and its default gateway. */
|
||||
forwardingTable(nodeId: Id): RouteRow[] {
|
||||
const snapshot = this.stateValue.topologySnapshot;
|
||||
return routeTableRows(
|
||||
this.networkInterfaces(nodeId).map((candidate) => ({ ipAddress: candidate.ipAddress, subnetMask: candidate.subnetMask })),
|
||||
snapshot.manualRoutesByNodeId.get(nodeId) ?? [],
|
||||
snapshot.deviceConfigurations.get(nodeId)?.defaultGateway,
|
||||
);
|
||||
}
|
||||
|
||||
/** The RIP table of a router with RIP switched on, while the simulation runs. */
|
||||
ripTable(nodeId: Id): readonly RouteRow[] | undefined {
|
||||
return this.stateValue.ripTablesByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
private routeForPacket(nodeId: Id, targetIPAddress: string, preferredInterfaceId?: Id): PacketRoute {
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
if (targetIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return { type: 'broadcast' };
|
||||
@@ -5029,6 +5119,10 @@ export class NetworkRuntimeEngine {
|
||||
if (!configuration.isActive) return decide(true, undefined, 'firewall inactive');
|
||||
|
||||
if (packet.protocol === 'icmp') {
|
||||
if (configuration.dropIcmp && packet.payload.kind === 'icmp') {
|
||||
const [type, code] = ICMP_TYPE_CODE[packet.payload.message.kind];
|
||||
this.logFirewall(nodeId, { kind: 'icmpDropped', senderIPAddress: packet.senderIPAddress, receiverIPAddress: packet.receiverIPAddress, type, code });
|
||||
}
|
||||
return decide(
|
||||
!configuration.dropIcmp,
|
||||
undefined,
|
||||
@@ -5057,6 +5151,7 @@ export class NetworkRuntimeEngine {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
this.logFirewallRule(nodeId, index, rule);
|
||||
return decide(rule.action === 'accept', index, `TCP matched rule #${index + 1}`);
|
||||
}
|
||||
break;
|
||||
@@ -5083,6 +5178,7 @@ export class NetworkRuntimeEngine {
|
||||
nodeId,
|
||||
);
|
||||
if (!requestMatches && !responseMatches) continue;
|
||||
this.logFirewallRule(nodeId, index, rule);
|
||||
return decide(rule.action === 'accept', index, `UDP matched rule #${index + 1}`);
|
||||
}
|
||||
break;
|
||||
@@ -5355,7 +5451,48 @@ function tcpSegmentRequiresAcknowledgement(segment: TcpSegment): boolean {
|
||||
export function nextTcpSequenceNumber(segment: TcpSegment): number {
|
||||
let next = segment.sequenceNumber;
|
||||
if ((segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish)) !== 0) next = uint32Add(next, 1);
|
||||
return uint32Add(next, segment.payload.length);
|
||||
return uint32Add(next, javaTextLength(segment.payload));
|
||||
}
|
||||
|
||||
/** The payload as text, when it is UTF-8 (all of FILIUS' data are Strings); undefined for other bytes. */
|
||||
function payloadText(payload: Uint8Array): string | undefined {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(payload);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A TCP payload's length as Java counts it: its data are a String, its
|
||||
* sequence numbers and segments count UTF-16 units, not bytes ("ä" is one).
|
||||
* Bytes that are no text count as bytes.
|
||||
*/
|
||||
export function javaTextLength(payload: Uint8Array): number {
|
||||
return payloadText(payload)?.length ?? payload.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* TCPSocket.erstelleSegmente: the message in pieces of at most `size` UTF-16
|
||||
* units (bytes that are no text: of `size` bytes). Java may cut a character
|
||||
* of two units in halves; the core keeps it whole.
|
||||
*/
|
||||
function javaTextPieces(payload: Uint8Array, size: number): Uint8Array[] {
|
||||
const text = payloadText(payload);
|
||||
const pieces: Uint8Array[] = [];
|
||||
if (text === undefined) {
|
||||
for (let offset = 0; offset < payload.length; offset += size) pieces.push(payload.subarray(offset, Math.min(offset + size, payload.length)));
|
||||
return pieces;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
for (let start = 0; start < text.length; ) {
|
||||
let end = Math.min(start + size, text.length);
|
||||
const last = text.charCodeAt(end - 1);
|
||||
if (end < text.length && end - start > 1 && last >= 0xd800 && last <= 0xdbff) end -= 1;
|
||||
pieces.push(encoder.encode(text.slice(start, end)));
|
||||
start = end;
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
||||
|
||||
Reference in New Issue
Block a user