feat: F4.6g3 a computer's firewall window, measured against Java's with traffic from two networks
The firewall-pc scenario and FirewallWindowRun use Java's window as a person does and send a ping, a TCP connection and a DNS question from the server's network and from behind the router after each step. The core adds and removes port rules (personalFirewall.ts) and writes both into the firewall's log, which now carries each line's time; the room takes the window's actions and sends its view and the log as lines; the editor shows Java's window with its two tabs. Not adopted from Java: any integer as a port, a port after a space refused without a word, a log one can type into. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,9 @@ import {
|
||||
DEFAULT_SPEED_PERCENT,
|
||||
NetworkRuntimeEngine,
|
||||
captureRows,
|
||||
firewallRuleText,
|
||||
interfaceKey,
|
||||
portRuleSender,
|
||||
stableGeneratedMacAddress,
|
||||
type CapturedFrame,
|
||||
type CaptureRow,
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
type EmailServerView,
|
||||
type DnsServerView,
|
||||
type EchoServerView,
|
||||
type FirewallView,
|
||||
type SimpleClientView,
|
||||
type TraceFrame,
|
||||
type WebServerView,
|
||||
@@ -463,6 +466,8 @@ export class SimulationRoom {
|
||||
return this.actInSimpleClient(input);
|
||||
case 'dnsServer':
|
||||
return this.actInDnsServer(input);
|
||||
case 'firewall':
|
||||
return this.actInFirewall(input);
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
@@ -498,6 +503,36 @@ export class SimulationRoom {
|
||||
}
|
||||
}
|
||||
|
||||
/** A computer's firewall window: its three boxes, "Hinzufügen" with the port, the protocol and the senders chosen, "Löschen" of the row chosen. */
|
||||
private actInFirewall(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const configuration = this.engine.personalFirewall(input.node);
|
||||
if (configuration === undefined) return 'unknownApp';
|
||||
const { args } = input;
|
||||
const set = (fields: Partial<typeof configuration>) => this.engine.setFirewallConfiguration(input.node, { ...configuration, ...fields });
|
||||
switch (input.action) {
|
||||
case 'setActive':
|
||||
if (typeof args.active !== 'boolean') return 'badArgs';
|
||||
set({ isActive: args.active });
|
||||
return undefined;
|
||||
case 'setIcmpAllowed':
|
||||
if (typeof args.allowed !== 'boolean') return 'badArgs';
|
||||
set({ dropIcmp: !args.allowed });
|
||||
return undefined;
|
||||
case 'setFilterUdp':
|
||||
if (typeof args.enabled !== 'boolean') return 'badArgs';
|
||||
set({ filterUdp: args.enabled });
|
||||
return undefined;
|
||||
case 'addRule':
|
||||
if (!isPort(args.port) || args.port === 0 || (args.protocol !== 'tcp' && args.protocol !== 'udp' && args.protocol !== 'all') || typeof args.sameNetworkOnly !== 'boolean') return 'badArgs';
|
||||
this.engine.addFirewallPortRule(input.node, args.port, args.protocol, args.sameNetworkOnly);
|
||||
return undefined;
|
||||
case 'removeRule':
|
||||
return Number.isInteger(args.row) && this.engine.removeFirewallRule(input.node, args.row as number) ? undefined : 'badArgs';
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
}
|
||||
|
||||
/** The start button with the port typed (Java's window sets the port before and after it starts the server). */
|
||||
private actInEchoServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const server = this.engine.echoServer(input.node);
|
||||
@@ -680,6 +715,14 @@ export class SimulationRoom {
|
||||
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
|
||||
return view;
|
||||
}
|
||||
case 'firewall': {
|
||||
const configuration = this.engine.personalFirewall(node);
|
||||
if (configuration === undefined) return null;
|
||||
const first = this.engine.networkInterfaces(node)[0];
|
||||
const rules = configuration.rules.map((rule) => ({ protocol: rule.protocol, port: rule.port, sender: portRuleSender(rule) ?? null, text: firewallRuleText(rule, first) }));
|
||||
const view: FirewallView = { active: configuration.isActive, icmpAllowed: !configuration.dropIcmp, filterUdp: configuration.filterUdp, rules };
|
||||
return view;
|
||||
}
|
||||
case 'echoServer': {
|
||||
const server = this.engine.echoServer(node);
|
||||
if (server === undefined) return null;
|
||||
@@ -725,6 +768,9 @@ export class SimulationRoom {
|
||||
return this.engine.simpleClient(node);
|
||||
case 'gnutella':
|
||||
return this.engine.gnutella(node);
|
||||
case 'firewall':
|
||||
// Its log outlives a stop, as the engine keeps it.
|
||||
return this.engine.personalFirewall(node) === undefined ? undefined : { log: { all: this.engine.firewallLog(node) } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// A computer's firewall window in the room, on FILIUS' example
|
||||
// email_komplett with a firewall installed on its web server: its view (the
|
||||
// three boxes, the rules as the table shows them), its actions, its log as
|
||||
// the window's lines (also over a stop), and what a run leaves in the
|
||||
// project. The firewall's steps against Java's window are
|
||||
// packages/core/test/firewallWindow.test.ts.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { DIRECTLY_CONNECTED_SOURCE_MARKER, firewallRule } from '@mdg-filius/core';
|
||||
import { addFirewallRule, readProject, setSoftware, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import type { FirewallView, HostMessage } from '@mdg-filius/protocol';
|
||||
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpus = join(here, '../../../corpus/java-examples');
|
||||
|
||||
function setUp(name = 'Web-Server', fromFile = false) {
|
||||
let ids = 0;
|
||||
const doc = new Y.Doc();
|
||||
writeProject(
|
||||
doc,
|
||||
openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'email_komplett.fls'))), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`),
|
||||
);
|
||||
const idOf = (displayName: string) => readProject(doc).graph.nodes.find((node) => node.displayName === displayName)!.id;
|
||||
setSoftware(doc, idOf('Web-Server'), { install: ['firewall'], remove: [] }, 'de', 'me');
|
||||
// A rule as a file may hold it and the window cannot make: TCP, every port, dropped.
|
||||
if (fromFile) addFirewallRule(doc, idOf('Web-Server'), 'me');
|
||||
const id = idOf(name);
|
||||
let now = 0;
|
||||
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 });
|
||||
let view: FirewallView | null | undefined;
|
||||
const errors: string[] = [];
|
||||
const lines: unknown[] = [];
|
||||
const client = room.connect((message: HostMessage) => {
|
||||
if (message.kind === 'app' && message.app === 'firewall') {
|
||||
lines.length = message.from;
|
||||
lines.push(...message.lines);
|
||||
}
|
||||
if (message.kind === 'view' && message.app === 'firewall') view = message.view as FirewallView | null;
|
||||
if (message.kind === 'error') errors.push(message.code);
|
||||
});
|
||||
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
|
||||
send({ kind: 'sub', topics: [`app:${id}/firewall`] });
|
||||
send({ kind: 'start' });
|
||||
const run = (milliseconds: number) => {
|
||||
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
|
||||
now += TICK_MILLISECONDS;
|
||||
room.tick();
|
||||
}
|
||||
};
|
||||
run(TICK_MILLISECONDS);
|
||||
const act = (action: string, args: object = {}) => send({ kind: 'input', node: id, app: 'firewall', action, args, reqId: action });
|
||||
return { doc, id, view: () => view, errors, lines, act, run, send, now: () => now };
|
||||
}
|
||||
|
||||
describe("a computer's firewall window in the room", () => {
|
||||
it('shows the boxes and the rules, takes its actions, and sends its log as lines', () => {
|
||||
const { view, act, errors, lines, send, run, now } = setUp();
|
||||
// Active as Java installs it.
|
||||
expect(view()).toEqual({ active: true, icmpAllowed: true, filterUdp: true, rules: [] });
|
||||
run(1000);
|
||||
act('addRule', { port: 80, protocol: 'tcp', sameNetworkOnly: false });
|
||||
// A line carries the time of the simulation it was written at.
|
||||
expect(lines).toEqual([{ kind: 'portRuleAdded', port: 80, protocol: 'tcp', timeMilliseconds: now() }]);
|
||||
act('addRule', { port: 53, protocol: 'all', sameNetworkOnly: true });
|
||||
expect(view()!.rules).toEqual([
|
||||
{ protocol: 'tcp', port: 80, sender: 'all', text: '*/* -> */*; TCP:80 => ACCEPT' },
|
||||
{ protocol: 'all', port: 53, sender: 'sameNetwork', text: '141.99.50.128/255.255.255.128 -> */*; *:53 => ACCEPT' },
|
||||
]);
|
||||
act('setIcmpAllowed', { allowed: false });
|
||||
act('setFilterUdp', { enabled: false });
|
||||
act('setActive', { active: false });
|
||||
expect(view()).toMatchObject({ active: false, icmpAllowed: false, filterUdp: false });
|
||||
act('removeRule', { row: 0 });
|
||||
expect(view()!.rules.map((rule) => rule.port)).toEqual([53]);
|
||||
expect(errors).toEqual([]);
|
||||
expect(lines).toMatchObject([{ kind: 'portRuleAdded', port: 80, protocol: 'tcp' }, { kind: 'portRuleAdded', port: 53, protocol: 'all' }, { kind: 'portRuleRemoved' }]);
|
||||
// The log outlives a stop and a new start, as the engine keeps it.
|
||||
send({ kind: 'stop' });
|
||||
send({ kind: 'start' });
|
||||
act('addRule', { port: 25, protocol: 'tcp', sameNetworkOnly: false });
|
||||
expect(lines.map((line) => (line as { kind: string }).kind)).toEqual(['portRuleAdded', 'portRuleAdded', 'portRuleRemoved', 'portRuleAdded']);
|
||||
});
|
||||
|
||||
it('shows a rule the window cannot make as what it is', () => {
|
||||
const { view } = setUp('Web-Server', true);
|
||||
expect(view()!.rules).toEqual([{ protocol: 'tcp', port: -1, sender: null, text: '*/* -> */*; TCP:* => DROP' }]);
|
||||
});
|
||||
|
||||
it('leaves in the project what the run made of the firewall', () => {
|
||||
const { doc, id, act, send } = setUp();
|
||||
act('addRule', { port: 80, protocol: 'udp', sameNetworkOnly: true });
|
||||
act('setIcmpAllowed', { allowed: false });
|
||||
send({ kind: 'stop' });
|
||||
expect(readProject(doc).firewallConfigurationsByNodeId.get(id)).toMatchObject({
|
||||
isActive: true,
|
||||
dropIcmp: true,
|
||||
filterUdp: true,
|
||||
rules: [firewallRule({ port: 80, protocol: 'udp', action: 'accept', sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER })],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses what the window never hands over', () => {
|
||||
const { act, errors } = setUp();
|
||||
act('setActive', { active: 'no' });
|
||||
act('setIcmpAllowed', {});
|
||||
act('setFilterUdp', { enabled: 1 });
|
||||
act('addRule', { port: 0, protocol: 'tcp', sameNetworkOnly: false });
|
||||
act('addRule', { port: 65_536, protocol: 'tcp', sameNetworkOnly: false });
|
||||
act('addRule', { port: 80, protocol: 'icmp', sameNetworkOnly: false });
|
||||
act('addRule', { port: 80, protocol: 'tcp' });
|
||||
act('removeRule', { row: 0 });
|
||||
act('removeRule', { row: 'x' });
|
||||
act('restart');
|
||||
expect(errors).toEqual(['badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'unknownAction']);
|
||||
});
|
||||
|
||||
it('has no window on a computer without a firewall', () => {
|
||||
const { view, act, errors } = setUp('DNS-Server');
|
||||
expect(view()).toBeNull();
|
||||
act('setActive', { active: false });
|
||||
expect(errors).toEqual(['unknownApp']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user