feat(editor): F4.5d the firewall in the device sheet

Measured first: SheetRun drives FILIUS' firewall dialog down to its table
editor (Swing's own end of an edit). Java's table writes a cell into the
rule before it checks it, so that the firewall holds "abc", a mask of
255.0.255.0 or port 0 while the table shows the old value, and reads a
port that is no number as all ports. Here nothing the check refuses
reaches the rule.

- doc: the three switches, the default action, rules (new as Java's,
  cells by Java's checks: masks of ones then zeros, the directly connected
  marker as a source only, an empty port for all ports; protocol, action,
  moved up or down, removed)
- editor: the firewall as a page of the router's and home router's sheet,
  with Java's explanations; the general tab named so (Java: "network
  interfaces")

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:27:10 +00:00
co-authored by Claude Opus 5.5
parent c8edb87210
commit 4e062bb347
15 changed files with 1399 additions and 12 deletions
+168
View File
@@ -31,6 +31,14 @@ import {
setSwitchRetention,
setSwitchSsid,
addRoute,
addFirewallRule,
moveFirewallRule,
removeFirewallRule,
setFirewallDefaultPolicy,
setFirewallFlag,
setFirewallRuleCell,
setFirewallRuleChoice,
type FirewallRuleColumn,
addDhcpStaticAssignment,
addPortForwarding,
removeDhcpStaticAssignment,
@@ -90,8 +98,24 @@ const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)
readonly addable: readonly boolean[];
};
readonly forwarding: { readonly rows: readonly (readonly [string, string, string, string])[]; readonly valid: readonly boolean[] };
readonly firewall: {
readonly newRule: JavaFirewallRule;
readonly newFirewall: readonly [boolean, number, boolean, boolean];
readonly cells: readonly { readonly column: number; readonly input: string; readonly rule: JavaFirewallRule; readonly valid: boolean }[];
readonly moves: readonly (readonly [string, number, boolean | null, readonly string[]])[];
};
};
interface JavaFirewallRule {
readonly srcIP: string;
readonly srcMask: string;
readonly destIP: string;
readonly destMask: string;
readonly port: number;
readonly protocol: number;
readonly action: number;
}
const RULES: Readonly<Record<string, FieldRule>> = {
'host name': 'name',
'host ip': 'ipAddress',
@@ -921,3 +945,147 @@ describe("a home router's port forwarding", () => {
expect(reopened.portForwardingRowsByNodeId.get(again)).toEqual([JAVA_ROWS()[0]]);
});
});
describe("a router's firewall", () => {
const JAVA_PROTOCOLS: Readonly<Record<number, string>> = { [-1]: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp' };
const COLUMNS: Readonly<Record<number, FirewallRuleColumn>> = { 1: 'sourceIPAddress', 2: 'sourceSubnetMask', 3: 'destinationIPAddress', 4: 'destinationSubnetMask', 6: 'port' };
const ruleOfJava = (rule: JavaFirewallRule) => ({
sourceIPAddress: rule.srcIP,
sourceSubnetMask: rule.srcMask,
destinationIPAddress: rule.destIP,
destinationSubnetMask: rule.destMask,
port: rule.port,
protocol: JAVA_PROTOCOLS[rule.protocol],
action: rule.action === 1 ? 'accept' : 'drop',
});
function firewalled() {
const doc = new Y.Doc();
const id = add(doc, 'router');
const rules = () => router(doc, id).firewall.rules;
return { doc, id, rules };
}
it("starts as Java's, and makes a new rule as Java's does", () => {
const { doc, id, rules } = firewalled();
const [isActive, policy, dropIcmp, filterSyn] = java.firewall.newFirewall;
expect(router(doc, id).firewall).toEqual({ isActive, defaultPolicy: policy === 1 ? 'accept' : 'drop', dropIcmp, filterSynSegmentsOnly: filterSyn, rules: [] });
expect(addFirewallRule(doc, id, 'me')).toBe(0);
expect(rules()).toEqual([ruleOfJava(java.firewall.newRule)]);
expect(addFirewallRule(doc, 'nobody', 'me')).toBeUndefined();
});
it("takes a cell by Java's checks, never a value they refuse (Java's table keeps it in the rule)", () => {
// Java: what its rule holds after the cell is taken, and whether the rule then passes its own check.
expect(java.firewall.cells.filter((each) => each.column in COLUMNS).map((each) => [each.column, each.input, ruleOfJava(each.rule)[COLUMNS[each.column]!], each.valid])).toEqual([
[1, '10.0.0.0', '10.0.0.0', true],
[1, 'abc', 'abc', false],
[1, '.0.0.1', '.0.0.1', true],
[1, '999.999.999.999', '999.999.999.999', true],
[1, '010.0.0.0', '010.0.0.0', true],
[1, ' ', ' ', true],
[2, '255.255.0.0', '255.255.0.0', true],
[2, '255.0.255.0', '255.0.255.0', false],
[2, '0.0.0.0', '0.0.0.0', false],
[2, '255.255.255.255', '255.255.255.255', true],
[2, '0255.255.0.0', '0255.255.0.0', true],
[3, '192.168.0.1', '192.168.0.1', true],
[3, '999.999.999.999', '999.999.999.999', false],
[4, '255.255.255.0', '255.255.255.0', true],
[6, '80', 80, true],
[6, 'abc', -1, true],
[6, '0', 0, false],
[6, '080', 80, true],
[6, '', -1, true],
[6, '65536', 65_536, false],
]);
// Here: what the rule takes, or that it takes nothing. Not adopted: ".0.0.1" (no address), "abc" as all ports, "080" (musterPort).
const ours = java.firewall.cells
.filter((each) => each.column in COLUMNS)
.map((each) => {
const { doc, id, rules } = firewalled();
addFirewallRule(doc, id, 'me');
const result = setFirewallRuleCell(doc, id, 0, COLUMNS[each.column]!, each.input, 'me');
return 'invalid' in result ? 'refused' : rules()[0]![COLUMNS[each.column]!];
});
expect(ours).toEqual([
'10.0.0.0',
'refused',
'refused',
'999.999.999.999',
'10.0.0.0',
'',
'255.255.0.0',
'refused',
'refused',
'255.255.255.255',
'255.255.0.0',
'192.168.0.1',
'refused',
'255.255.255.0',
80,
'refused',
'refused',
'refused',
-1,
'refused',
]);
});
it("chooses a rule's protocol and action as Java's lists do", () => {
const choices = java.firewall.cells.filter((each) => !(each.column in COLUMNS)).map((each) => ruleOfJava(each.rule));
expect(choices.map((each) => [each.protocol, each.action])).toEqual([
['udp', 'drop'],
['all', 'drop'],
['tcp', 'accept'],
]);
const { doc, id, rules } = firewalled();
addFirewallRule(doc, id, 'me');
setFirewallRuleChoice(doc, id, 0, { protocol: 'udp' }, 'me');
setFirewallRuleChoice(doc, id, 0, { action: 'accept' }, 'me');
expect(rules()[0]).toMatchObject({ protocol: 'udp', action: 'accept' });
setFirewallRuleChoice(doc, id, 5, { protocol: 'all' }, 'me');
expect(rules()).toHaveLength(1);
});
it("moves and removes rules as Java's buttons do, the first going no higher and the last no lower", () => {
const { doc, id, rules } = firewalled();
for (const source of ['1.1.1.1', '2.2.2.2', '3.3.3.3']) setFirewallRuleCell(doc, id, addFirewallRule(doc, id, 'me')!, 'sourceIPAddress', source, 'me');
// Java's buttons pass the row's number (1 for the first); here its place.
const ours = java.firewall.moves.map(([step, number]) => {
const answer = step === 'delete' ? (removeFirewallRule(doc, id, number - 1, 'me'), null) : moveFirewallRule(doc, id, number - 1, step === 'up' ? -1 : 1, 'me');
return [step, number, answer, rules().map((rule) => rule.sourceIPAddress)];
});
expect(ours).toEqual(java.firewall.moves);
expect(moveFirewallRule(doc, id, 7, 1, 'me')).toBe(false);
removeFirewallRule(doc, id, 7, 'me');
expect(rules()).toHaveLength(2);
});
it('switches its flags and its default, and keeps all through a saved file', () => {
const { doc, id } = firewalled();
const homeId = add(doc, 'gateway');
for (const device of [id, homeId]) {
setFirewallFlag(doc, device, 'isActive', true, 'me');
setFirewallFlag(doc, device, 'dropIcmp', true, 'me');
setFirewallFlag(doc, device, 'filterSynSegmentsOnly', false, 'me');
setFirewallDefaultPolicy(doc, device, 'accept', 'me');
addFirewallRule(doc, device, 'me');
setFirewallRuleCell(doc, device, 0, 'destinationIPAddress', '10.0.0.0', 'me');
setFirewallRuleCell(doc, device, 0, 'destinationSubnetMask', '255.0.0.0', 'me');
setFirewallRuleCell(doc, device, 0, 'port', '80', 'me');
}
expect(home(doc, homeId).firewall).toMatchObject({ isActive: true, dropIcmp: true, filterSynSegmentsOnly: false, defaultPolicy: 'accept' });
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `60000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
for (const node of reopened.graph.nodes) {
expect(reopened.firewallConfigurationsByNodeId.get(node.id)).toMatchObject({
isActive: true,
dropIcmp: true,
filterSynSegmentsOnly: false,
defaultPolicy: 'accept',
rules: [{ sourceIPAddress: '', destinationIPAddress: '10.0.0.0', destinationSubnetMask: '255.0.0.0', port: 80, protocol: 'tcp', action: 'drop' }],
});
}
});
});