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>
185 lines
9.7 KiB
TypeScript
185 lines
9.7 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,
|
|
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]);
|
|
});
|
|
});
|