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>
222 lines
12 KiB
TypeScript
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ä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]);
|
|
});
|
|
});
|