// 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 => ({ 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( "10.0.0.0255.255.255.0 0 unbegrenzt10.0.0.1 - " + "127.0.0.0", ); expect(page).toContain("0.0.0.00.0.0.0 ? "); expect(page).toContain('30.0.0.0255.255.255.0 ? '); // 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( "50.0.0.0255.255.255.0 0 ", ); 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("10.0.0.0255.255.255.00unlimited"); expect(page).toContain("40.0.0.0255.255.255.016910.0.0.210.0.0.2"); 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('Router Administration'); expect(routerIndexPage('gateway', 'de')).toBe( '\n \n Administration des Gateways\n \n \n

Firewall-Administration

\n' + '

Konfiguration

\n

Log-Einträge anzeigen

\n \n\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 { 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( '\n\t\n\t\tLog-data set entry of the firewall\n\t\n\t\n\t\t

Log-data set entry of the firewall

\n\n' + '
\nFirewall rule matched: #1 (*/* -> */*; UDP:7 => DROP) -> drop
\nFirewall rule matched: #1 (*/* -> */*; UDP:7 => DROP) -> drop' + '\n\n\t\n', ); }); 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]); }); });