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
+28 -2
View File
@@ -18,9 +18,23 @@
// " 10.0.0.1"; a phone's keyboard puts a space after a word). A name and an
// SSID are taken as typed, spaces included, as Java takes them.
import { IP_ADDRESS_PATTERN, javaIpCheck } from '@mdg-filius/core';
import { DIRECTLY_CONNECTED_SOURCE_MARKER, IP_ADDRESS_PATTERN, javaIpCheck, parseIPv4 } from '@mdg-filius/core';
export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port' | 'macAddress';
export type FieldRule =
| 'name'
| 'ipAddress'
| 'ipAddressOrEmpty'
| 'subnetMask'
| 'ssid'
| 'seconds'
| 'port'
| 'macAddress'
/** A firewall rule's source: empty, an address, or Java's marker for "a directly connected network". */
| 'firewallSource'
/** A firewall rule's mask: empty, or ones then zeros (EingabenUeberpruefung.isValidSubnetmask). */
| 'firewallMask'
/** A firewall rule's port: empty for all ports, or musterPort. */
| 'portOrEmpty';
/** What a field's text comes to: the value the device takes, or that it takes none. */
export type CheckedField = { readonly value: string } | { readonly invalid: true };
@@ -71,6 +85,18 @@ export function checkField(rule: FieldRule, text: string): CheckedField {
}
case 'port':
return PORT_PATTERN.test(text.trim()) ? { value: text.trim() } : INVALID;
case 'firewallSource':
if (text.trim() === DIRECTLY_CONNECTED_SOURCE_MARKER) return { value: DIRECTLY_CONNECTED_SOURCE_MARKER };
return checkField('ipAddressOrEmpty', text);
case 'firewallMask': {
if (text.trim() === '') return { value: '' };
const checked = address(text.trim(), SUBNET_MASK_PATTERN);
if ('invalid' in checked) return checked;
// Ones, then zeros, at least one one (Java: the 32 bits match "^11*0*$").
return /^1+0*$/.test(parseIPv4(checked.value)!.toString(2).padStart(32, '0')) ? checked : INVALID;
}
case 'portOrEmpty':
return text.trim() === '' ? { value: '' } : checkField('port', text);
case 'macAddress':
// Upper case, as the model keeps MAC addresses (Java keeps them as typed and compares ignoring case).
return MAC_ADDRESS_PATTERN.test(text.trim()) ? { value: text.trim().toUpperCase() } : INVALID;
+105 -2
View File
@@ -24,14 +24,22 @@
// second static assignment of a MAC address without a word, and that a home
// router keeps any port forwarding row, valid or not (its firmware then
// forwards only the valid ones): here a value passes its rule or is not
// taken, and the sheet says why.
// taken, and the sheet says why. Nor that Java's firewall table writes a
// cell into the rule before it checks it, so that a rule holds "abc" or a
// mask of 255.0.255.0 while the table shows the old value, and reads a port
// that is no number as "all ports".
import * as Y from 'yjs';
import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration,
ALL_PORTS,
dhcpServerConfiguration,
firewallConfiguration,
firewallRule,
forwardsIPv4ByDefault,
type FirewallAction,
type FirewallProtocol,
type DhcpStaticAssignment,
type GatewayPortForwardingRow,
type Id,
@@ -42,7 +50,7 @@ import { modemPair } from '@mdg-filius/fls';
import { connect, type ConnectError } from './commands.js';
import { checkField, type CheckedField, type FieldRule } from './fields.js';
import { inOrder, ROOTS } from './projectDoc.js';
import { toY } from './values.js';
import { fromY, toY, type PlainValue } from './values.js';
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
@@ -481,3 +489,98 @@ export function removePortForwarding(doc: Y.Doc, id: Id, index: number, origin:
if (list.length === 0) settingsOf(node).delete('portForwarding');
}, origin);
}
/** A router's or home router's firewall, made as Java's constructor leaves it when the device has none in the document. */
function firewallOf(node: Y.Map<unknown>): Y.Map<unknown> {
let firewall = settingsOf(node).get('firewall') as Y.Map<unknown> | undefined;
if (firewall === undefined) {
firewall = toY({ ...firewallConfiguration(), rules: [] }) as Y.Map<unknown>;
settingsOf(node).set('firewall', firewall);
}
return firewall;
}
const rulesOf = (node: Y.Map<unknown>) => {
const firewall = firewallOf(node);
let rules = firewall.get('rules') as Y.Array<Y.Map<unknown>> | undefined;
if (rules === undefined) {
rules = new Y.Array<Y.Map<unknown>>();
firewall.set('rules', rules);
}
return rules;
};
/** The firewall switched on, dropping ICMP, or dropping only SYN segments (JFirewallDialog's general tab). */
export function setFirewallFlag(doc: Y.Doc, id: Id, flag: 'isActive' | 'dropIcmp' | 'filterSynSegmentsOnly', on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node !== undefined) doc.transact(() => firewallOf(node).set(flag, on), origin);
}
/** What the firewall does when no rule matches. */
export function setFirewallDefaultPolicy(doc: Y.Doc, id: Id, policy: FirewallAction, origin: unknown): void {
const node = nodes(doc).get(id);
if (node !== undefined) doc.transact(() => firewallOf(node).set('defaultPolicy', policy), origin);
}
/** A new rule at the end, as Java's makes it (Firewall.addRule: every field empty, TCP, all ports, drop); its place. */
export function addFirewallRule(doc: Y.Doc, id: Id, origin: unknown): number | undefined {
const node = nodes(doc).get(id);
if (node === undefined) return undefined;
let place = 0;
doc.transact(() => {
const rules = rulesOf(node);
rules.push([toY({ ...firewallRule() }) as Y.Map<unknown>]);
place = rules.length - 1;
}, origin);
return place;
}
export type FirewallRuleColumn = 'sourceIPAddress' | 'sourceSubnetMask' | 'destinationIPAddress' | 'destinationSubnetMask' | 'port';
export const FIREWALL_RULE_COLUMNS: readonly FirewallRuleColumn[] = ['sourceIPAddress', 'sourceSubnetMask', 'destinationIPAddress', 'destinationSubnetMask', 'port'];
/** Each text column's rule (FirewallRule.validateExistingAttributes; the marker only as a source). */
export const firewallCellRule = (column: FirewallRuleColumn): FieldRule =>
column === 'sourceIPAddress' ? 'firewallSource' : column === 'destinationIPAddress' ? 'ipAddressOrEmpty' : column === 'port' ? 'portOrEmpty' : 'firewallMask';
const ruleAt = (node: Y.Map<unknown>, index: number) => ((settingsOf(node).get('firewall') as Y.Map<unknown> | undefined)?.get('rules') as Y.Array<Y.Map<unknown>> | undefined)?.get(index);
/** A text cell of a rule; an empty port means all ports. */
export function setFirewallRuleCell(doc: Y.Doc, id: Id, index: number, column: FirewallRuleColumn, text: string, origin: unknown): CheckedField {
return setChecked(
doc,
id,
checkField(firewallCellRule(column), text),
(node, value) => ruleAt(node, index)?.set(column, column === 'port' ? (value === '' ? ALL_PORTS : Number(value)) : value),
origin,
);
}
/** A rule's protocol or action (Java's lists: *, TCP, UDP; accept, drop). */
export function setFirewallRuleChoice(doc: Y.Doc, id: Id, index: number, choice: { readonly protocol: FirewallProtocol } | { readonly action: FirewallAction }, origin: unknown): void {
const node = nodes(doc).get(id);
const rule = node === undefined ? undefined : ruleAt(node, index);
if (rule === undefined) return;
doc.transact(() => ('protocol' in choice ? rule.set('protocol', choice.protocol) : rule.set('action', choice.action)), origin);
}
/** Moves a rule one place up (-1) or down (+1); the first goes no higher, the last no lower (Firewall.moveUp, moveDown). */
export function moveFirewallRule(doc: Y.Doc, id: Id, index: number, step: -1 | 1, origin: unknown): boolean {
const node = nodes(doc).get(id);
const rules = node === undefined ? undefined : ((settingsOf(node).get('firewall') as Y.Map<unknown> | undefined)?.get('rules') as Y.Array<Y.Map<unknown>> | undefined);
const target = index + step;
if (rules === undefined || index < 0 || index >= rules.length || target < 0 || target >= rules.length) return false;
doc.transact(() => {
// A Yjs type cannot be inserted twice: the rule moves as a copy of its fields.
const moved = fromY(rules.get(index)) as PlainValue;
rules.delete(index, 1);
rules.insert(target, [toY(moved) as Y.Map<unknown>]);
}, origin);
return true;
}
export function removeFirewallRule(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const rules = node === undefined ? undefined : ((settingsOf(node).get('firewall') as Y.Map<unknown> | undefined)?.get('rules') as Y.Array<Y.Map<unknown>> | undefined);
if (rules === undefined || index < 0 || index >= rules.length) return;
doc.transact(() => rules.delete(index, 1), origin);
}
+14
View File
@@ -8,7 +8,9 @@ import {
defaultSwitchConfiguration,
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
dhcpServerConfiguration,
firewallConfiguration,
forwardsIPv4ByDefault,
type FirewallConfiguration,
parsePortForwardingRow,
routeTableRows,
type DhcpServerConfiguration,
@@ -90,6 +92,14 @@ export interface InterfaceSheet {
readonly connected: { readonly device: string; readonly link: string } | null;
}
/** A router's or home router's firewall as Java's dialog shows it (JFirewallDialog). */
export type FirewallSheet = Pick<FirewallConfiguration, 'isActive' | 'dropIcmp' | 'filterSynSegmentsOnly' | 'defaultPolicy' | 'rules'>;
function firewallSheet(node: Y.Map<unknown>): FirewallSheet {
const { isActive, dropIcmp, filterSynSegmentsOnly, defaultPolicy, rules } = firewallConfiguration((fromY(settingsOf(node).get('firewall')) ?? {}) as Partial<FirewallConfiguration>);
return { isActive, dropIcmp, filterSynSegmentsOnly, defaultPolicy, rules };
}
export interface RouterSheet {
readonly kind: 'router';
readonly name: string;
@@ -100,6 +110,7 @@ export interface RouterSheet {
readonly interfaces: readonly InterfaceSheet[];
/** The manual rows, in their order. */
readonly routes: readonly ManualRoute[];
readonly firewall: FirewallSheet;
/** The whole table as Java shows it (Weiterleitungstabelle.holeTabelle): own addresses and networks, localhost, the manual rows, the gateway. */
readonly table: readonly RouteRow[];
}
@@ -114,6 +125,7 @@ export interface HomeRouterSheet {
readonly wan: InterfaceSheet;
readonly lan: InterfaceSheet;
readonly dhcpServer: DhcpServerSheet;
readonly firewall: FirewallSheet;
/** The port forwarding rows, each with whether Java's firmware forwards it (GatewayFirmware.validateData). */
readonly portForwarding: readonly (GatewayPortForwardingRow & { readonly forwarded: boolean })[];
}
@@ -237,6 +249,7 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
interfaces,
routes,
table: routeTableRows(interfaces, routes, gateway),
firewall: firewallSheet(node),
};
}
case 'gateway': {
@@ -252,6 +265,7 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
lan: lan!,
// Java's home router hands out its LAN address as the gateway, with the LAN card's mask.
dhcpServer: dhcpServerSheet(node, { subnetMask: lan!.subnetMask, gateway: lan!.ipAddress, dnsServer: '' }, true),
firewall: firewallSheet(node),
portForwarding: rows.map((row) => ({ ...row, forwarded: parsePortForwardingRow(row) !== undefined })),
};
}