Files
julian.kiedaischandClaude Opus 5.5 8438b42018 feat(core): the firewall settings page of routers after Java, measured against Java
The page konfig changes the firewall at run time (into the snapshot, back
into the project through projectWithSimulationChanges). KonfigCases feeds
Java's plug-in directly (25 cases, de and en); RouterWebRun plays two
stories over the network. Ten Java bugs named, not adopted. The web server
answers a method not written GET or POST with 501, as Java does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:13:31 +00:00

222 lines
12 KiB
TypeScript

// 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,
firewallSettingsPage,
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&auml;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 router's settings page (konfig)", () => {
const pageOf = (engine: NetworkRuntimeEngine) => {
const pages = engine.webBrowser(LEFT)!.log.all.flatMap((line) => (line.kind === 'page' ? [line] : []));
return pages[pages.length - 1];
};
const ask = (engine: NetworkRuntimeEngine, data?: string) => {
engine.webBrowser(LEFT)!.open('10.0.0.1/konfig', data);
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5_000 });
return pageOf(engine);
};
it('changes the firewall at run time, into the snapshot; a page without data changes nothing', () => {
const engine = startedEngine({ ...network(), webBrowserNodeIds: new Set([LEFT]) });
const before = engine.state.topologySnapshot;
expect(ask(engine)).toMatchObject({ statusCode: 200, contentType: 'text/html' });
expect(engine.state.topologySnapshot).toBe(before);
const page = ask(engine, 'firewallActivate=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop');
expect(engine.state.topologySnapshot.firewallConfigurationsByNodeId.get(ROUTER)).toEqual(
firewallConfiguration({ isActive: true, defaultPolicy: 'accept', filterSynSegmentsOnly: false, rules: [firewallRule({ protocol: 'udp', port: 7, action: 'drop' })] }),
);
// The browser reads the answer as HTTPNachricht does: without its last empty lines.
expect(page?.body).toBe(firewallSettingsPage(engine.firewallConfiguration(ROUTER), 'de').replace(/\n+$/, ''));
});
it("answers in the project's language, and on a home router as on a router", () => {
const gateway = network({ language: 'en', webBrowserNodeIds: new Set([LEFT]) });
const engine = startedEngine({ ...gateway, nodes: gateway.nodes.map((node) => (node.id === ROUTER ? { ...node, kind: 'gateway' as const } : node)) });
const page = ask(engine, 'srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=22&action=accept&addrule=add');
expect(page?.body).toContain('<title>Router Firewall Configuration</title>');
expect(engine.firewallConfiguration(ROUTER).rules).toEqual([firewallRule({ protocol: 'udp', port: 7, action: 'drop' }), firewallRule({ protocol: 'tcp', port: 22, action: 'accept' })]);
const rejected = ask(engine, 'srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=0&action=accept&addrule=add');
expect(rejected?.body).toContain('<font color="red">Erroneous input(s):<ul><li>Port invalid</li></ul></font><br>');
expect(engine.firewallConfiguration(ROUTER).rules).toHaveLength(2);
});
});
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]);
});
});