// A computer's firewall as Java's window changes it, against Java's // (firewallWindow.java.json, FirewallWindowRun) on firewall-pc's server: // Java's steps played on the engine — the boxes, the rules as the window's // table shows them, the log, and after the steps that ask, what reaches the // server from a computer of its network and one behind the router (a ping, // a TCP connection to its echo server, its DNS server asked) — and where // Java errs, what the firewall does instead (apps/personalFirewall.ts names // each): only a port is taken. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { DIRECTLY_CONNECTED_SOURCE_MARKER, firewallLogText, firewallPortOfText, firewallRule, JAVA_PERSONAL_FIREWALL_DEFAULTS, NetworkRuntimeEngine, portRuleSender, VirtualFileSystem, withoutRule, withPortRule, withSnapshot, type FirewallRule, type PortRuleProtocol, type Steps, } from '../src/index.js'; import { snapshotOf, type Scenario } from './scenario.js'; interface Reached { readonly ping: string; readonly tcp: string; readonly dns: string; } interface JavaStep { readonly step: string; readonly traffic?: { readonly local: Reached; readonly remote: Reached }; readonly active: { readonly selected: boolean; readonly enabled: boolean }; readonly icmp: { readonly selected: boolean; readonly enabled: boolean }; readonly udp: { readonly selected: boolean; readonly enabled: boolean }; readonly rows: readonly (readonly string[])[]; readonly log: string; readonly logEditable: boolean; } const here = dirname(fileURLToPath(import.meta.url)); const java = JSON.parse(readFileSync(join(here, 'firewallWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; const scenario = JSON.parse(readFileSync(join(here, 'scenarios/firewall-pc.scenario.json'), 'utf8')) as Scenario; const LOCAL = '00000000-0000-0000-0000-000000000201'; const REMOTE = '00000000-0000-0000-0000-000000000203'; const SERVER = '00000000-0000-0000-0000-000000000204'; const SERVER_ADDRESS = '10.0.0.10'; const TIMED_OUT = 'TimeOutException: Zeitüberschreitung beim Verbindungsaufbau'; /** Java's ruleToVector: protocol, port, sender. */ function javaRow(rule: FirewallRule): string[] { const protocol = rule.protocol === 'tcp' ? 'TCP' : rule.protocol === 'udp' ? 'UDP' : '*'; return [protocol, rule.port >= 0 ? String(rule.port) : '', rule.sourceIPAddress === DIRECTLY_CONNECTED_SOURCE_MARKER ? 'Nur lokales Rechnernetz' : 'Alle Absender']; } function setUp() { const engine = new NetworkRuntimeEngine(scenario.seed); engine.handle({ type: 'start', snapshot: withSnapshot(snapshotOf(scenario), { fileSystemsByNodeId: new Map([[SERVER, new VirtualFileSystem()]]), echoServersByNodeId: new Map([[SERVER, { port: 55555, isActive: true }]]), firewallConfigurationsByNodeId: new Map([[SERVER, JAVA_PERSONAL_FIREWALL_DEFAULTS]]), }), seed: scenario.seed, initialTimeMilliseconds: 0, }); const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds }); const blocking = (steps: Steps, milliseconds: number): T | undefined => { let result: T | undefined; engine.startThread( (function* (): Steps { result = yield* steps; })(), ); run(milliseconds); return result; }; let sequenceNumber = 1; const reached = (from: string): Reached => { const answer = blocking(engine.icmpEchoSteps(from, SERVER_ADDRESS, { identifier: 1, sequenceNumber: sequenceNumber++ }), 30_000); const ping = answer?.message.kind === 'echoReply' ? `ICMP 0/0 from ${answer.packet.senderIPAddress}` : 'null'; const socket = engine.openTcpClientSocket(from, SERVER_ADDRESS, 55555)!; const connected = blocking(engine.connectTcpSteps(socket), 60_000); // Held a moment, as the oracle holds it. run(500); engine.closeSocket(socket); run(1000); const tcp = connected === 'connected' ? 'connected' : connected === 'timedOut' ? TIMED_OUT : String(connected); const resolution = blocking(engine.resolveDnsAddressSteps(from, 'www.filius.de'), 30_000); const dns = resolution === undefined || resolution.type === 'timedOut' ? 'TimeOutException' : String(resolution.address ?? null); return { ping, tcp, dns }; }; return { engine, reached }; } describe("a computer's firewall window, against Java's", () => { it("plays Java's steps: the same where Java is right, as named where it errs", () => { const { engine, reached } = setUp(); const configuration = () => engine.personalFirewall(SERVER)!; const log = () => engine.firewallLog(SERVER).map((entry) => `${firewallLogText(entry, 'de')}\n`); // Java's log lines, less those of what Java takes and the firewall does not (and with those the firewall takes instead). let javaLines = (step: JavaStep) => step.log.split(/(?<=\n)/).filter((line) => line !== ''); const at = (name: string) => java.steps.find((step) => step.step === name)!; const check = (name: string, traffic: { readonly local: Reached; readonly remote: Reached } | undefined = at(name).traffic) => { const step = at(name); if (traffic !== undefined) expect({ local: reached(LOCAL), remote: reached(REMOTE) }, name).toEqual(traffic); expect({ active: configuration().isActive, icmp: !configuration().dropIcmp, udp: configuration().filterUdp, rows: configuration().rules.map(javaRow), log: log() }, name).toEqual({ active: step.active.selected, icmp: step.icmp.selected, udp: step.udp.selected, rows: step.rows, log: javaLines(step), }); }; const add = (port: string, protocol: PortRuleProtocol, sameNetworkOnly: boolean) => { const number = firewallPortOfText(port); return number !== undefined && engine.addFirewallPortRule(SERVER, number, protocol, sameNetworkOnly); }; const set = (fields: Partial>) => engine.setFirewallConfiguration(SERVER, { ...configuration(), ...fields }); // Active as Java installs it: a ping goes through, TCP and UDP only by a rule. check('open'); expect(at('open').logEditable).toBe(true); expect([add('abc', 'tcp', false), add('', 'tcp', false)]).toEqual([false, false]); check('add port abc'); check('add an empty port'); expect(add('55555', 'tcp', false)).toBe(true); check('add 55555 TCP for all senders'); expect(add('53', 'udp', true)).toBe(true); check('add 53 UDP for the same network'); set({ dropIcmp: true }); check('ICMP no longer allowed'); set({ filterUdp: false }); check('UDP filter off'); // No port, none taken (Java takes each: -1 a rule for every port of both protocols, 0 and 70000 rules that never match). expect([add('-1', 'all', false), add('0', 'tcp', false), add('70000', 'tcp', true)]).toEqual([false, false, false]); const notTaken = ['Portregel wurde hinzugefügt: -1/*\n', 'Portregel wurde hinzugefügt: 0/TCP\n', 'Portregel wurde hinzugefügt: 70000/TCP\n']; let javaRows = (step: JavaStep) => step.rows.filter(([, port]) => port !== '' && port !== '0' && port !== '70000'); const javaAll = javaLines; javaLines = (step) => javaAll(step).filter((line) => !notTaken.includes(line)); const sameAsJava = (name: string) => expect({ rows: configuration().rules.map(javaRow), log: log() }, name).toEqual({ rows: javaRows(at(name)), log: javaLines(at(name)) }); for (const name of ['add -1 for all protocols', 'add 0 TCP', 'add 70000 TCP for the same network']) sameAsJava(name); // A port after a space is taken (Java refuses it without a word). expect(add(' 80', 'tcp', false)).toBe(true); const tookPort = javaLines(at('add 70000 TCP for the same network')).length; const javaTaken = javaLines; javaLines = (step) => javaTaken(step).toSpliced(tookPort, 0, 'Portregel wurde hinzugefügt: 80/TCP\n'); const javaListed = javaRows; javaRows = (step) => [...javaListed(step), ['TCP', '80', 'Alle Absender']]; sameAsJava('add a port after a space'); // "Löschen" with no row chosen does nothing; row 0 goes. Without Java's rule for every port, TCP is no longer let through. sameAsJava('delete with no row chosen'); expect(engine.removeFirewallRule(SERVER, 0)).toBe(true); const javaKept = javaLines; javaLines = (step) => javaKept(step).filter((line) => !line.includes('*/* -> */*; *:*')); const javaDelete = at('delete row 0'); const closed = (reach: Reached): Reached => ({ ...reach, tcp: TIMED_OUT }); expect({ local: reached(LOCAL), remote: reached(REMOTE) }).toEqual({ local: closed(javaDelete.traffic!.local), remote: closed(javaDelete.traffic!.remote) }); sameAsJava('delete row 0'); // Off: everything goes through, nothing is written; on again. set({ isActive: false }); const javaOff = at('firewall off'); expect({ local: reached(LOCAL), remote: reached(REMOTE) }).toEqual(javaOff.traffic); sameAsJava('firewall off'); expect([javaOff.icmp.enabled, javaOff.udp.enabled, at('firewall on again').icmp.enabled]).toEqual([false, false, true]); set({ isActive: true }); expect(configuration()).toMatchObject({ isActive: true, dropIcmp: true, filterUdp: false }); }); }); describe("a computer's firewall beyond the steps", () => { it('takes a port of 1 to 65535 only, spaces around it allowed', () => { expect(['1', '65535', ' 80 ', '+80', '80a', '0', '65536', '-1', '', ' '].map(firewallPortOfText)).toEqual([ 1, 65535, 80, undefined, undefined, undefined, undefined, undefined, undefined, undefined, ]); }); it('adds Java’s rule and removes the row chosen only', () => { const added = withPortRule(withPortRule(JAVA_PERSONAL_FIREWALL_DEFAULTS, 80, 'tcp', false), 53, 'udp', true); expect(added.rules).toEqual([ firewallRule({ port: 80, protocol: 'tcp', action: 'accept' }), firewallRule({ port: 53, protocol: 'udp', action: 'accept', sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER }), ]); expect(withoutRule(added, 1)!.rules).toEqual([added.rules[0]]); expect([withoutRule(added, 2), withoutRule(added, -1), withoutRule(added, 0.5)]).toEqual([undefined, undefined, undefined]); }); it("names the window's sender for the rules it makes only", () => { expect( [ firewallRule({ action: 'accept' }), firewallRule({ action: 'accept', sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER, sourceSubnetMask: '255.0.0.0' }), firewallRule({ action: 'accept', sourceSubnetMask: '255.0.0.0' }), firewallRule({ action: 'drop' }), firewallRule({ action: 'accept', sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0' }), firewallRule({ action: 'accept', destinationIPAddress: '10.0.0.10' }), ].map(portRuleSender), ).toEqual(['all', 'sameNetwork', 'all', undefined, undefined, undefined]); }); it('has nothing for a computer without a firewall, nor a router', () => { const engine = new NetworkRuntimeEngine(scenario.seed); // The router has a firewall, as every one has in Java; it is no program of a computer. const ROUTER = '00000000-0000-0000-0000-000000000202'; engine.handle({ type: 'start', snapshot: withSnapshot(snapshotOf(scenario), { firewallConfigurationsByNodeId: new Map([[ROUTER, JAVA_PERSONAL_FIREWALL_DEFAULTS]]) }), seed: scenario.seed, initialTimeMilliseconds: 0, }); expect([engine.personalFirewall(SERVER), engine.personalFirewall(ROUTER)]).toEqual([undefined, undefined]); expect(engine.addFirewallPortRule(ROUTER, 80, 'tcp', false)).toBe(false); expect([engine.addFirewallPortRule(SERVER, 80, 'tcp', false), engine.removeFirewallRule(SERVER, 0)]).toEqual([false, false]); expect(engine.firewallLog(SERVER)).toEqual([]); }); });