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 })),
};
}
+610 -1
View File
@@ -1209,7 +1209,7 @@
}
],
"flows": {
"useMacAsNameThenOff": "26:0A:1B:EE:FE:4B",
"useMacAsNameThenOff": "5E:C4:F7:30:10:4A",
"useIpAsNameThenOff": "PC-A",
"dhcpClientStopsServer": true,
"dhcpClient": true,
@@ -1674,6 +1674,615 @@
false
]
},
"firewall": {
"newRule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"newRuleShown": [
"1",
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
],
"newFirewall": [
false,
0,
false,
true
],
"cells": [
{
"column": 1,
"input": "10.0.0.0",
"rule": {
"srcIP": "10.0.0.0",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"10.0.0.0",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 1,
"input": "abc",
"rule": {
"srcIP": "abc",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": false,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 1,
"input": ".0.0.1",
"rule": {
"srcIP": ".0.0.1",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
".0.0.1",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 1,
"input": "999.999.999.999",
"rule": {
"srcIP": "999.999.999.999",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"999.999.999.999",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 1,
"input": "010.0.0.0",
"rule": {
"srcIP": "010.0.0.0",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"010.0.0.0",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 1,
"input": " ",
"rule": {
"srcIP": " ",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
" ",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 2,
"input": "255.255.0.0",
"rule": {
"srcIP": "",
"srcMask": "255.255.0.0",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"255.255.0.0",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 2,
"input": "255.0.255.0",
"rule": {
"srcIP": "",
"srcMask": "255.0.255.0",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": false,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 2,
"input": "0.0.0.0",
"rule": {
"srcIP": "",
"srcMask": "0.0.0.0",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": false,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 2,
"input": "255.255.255.255",
"rule": {
"srcIP": "",
"srcMask": "255.255.255.255",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"255.255.255.255",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 2,
"input": "0255.255.0.0",
"rule": {
"srcIP": "",
"srcMask": "0255.255.0.0",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"0255.255.0.0",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 3,
"input": "192.168.0.1",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "192.168.0.1",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"192.168.0.1",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 3,
"input": "999.999.999.999",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "999.999.999.999",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": false,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 4,
"input": "255.255.255.0",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "255.255.255.0",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"255.255.255.0",
"TCP",
"",
"verwerfen"
]
},
{
"column": 6,
"input": "80",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": 80,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"",
"TCP",
"80",
"verwerfen"
]
},
{
"column": 6,
"input": "abc",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 6,
"input": "0",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": 0,
"protocol": 6,
"action": 0
},
"valid": false,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 6,
"input": "080",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": 80,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"",
"TCP",
"80",
"verwerfen"
]
},
{
"column": 6,
"input": "",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 6,
"input": "65536",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": 65536,
"protocol": 6,
"action": 0
},
"valid": false,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"verwerfen"
]
},
{
"column": 5,
"input": "UDP",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 17,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"",
"UDP",
"",
"verwerfen"
]
},
{
"column": 5,
"input": "*",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": -1,
"action": 0
},
"valid": true,
"shown": [
"",
"",
"",
"",
"*",
"",
"verwerfen"
]
},
{
"column": 7,
"input": "#0",
"rule": {
"srcIP": "",
"srcMask": "",
"destIP": "",
"destMask": "",
"port": -1,
"protocol": 6,
"action": 1
},
"valid": true,
"shown": [
"",
"",
"",
"",
"TCP",
"",
"akzeptieren"
]
}
],
"moves": [
[
"up",
1,
false,
[
"1.1.1.1",
"2.2.2.2",
"3.3.3.3"
]
],
[
"up",
3,
true,
[
"1.1.1.1",
"3.3.3.3",
"2.2.2.2"
]
],
[
"down",
3,
false,
[
"1.1.1.1",
"3.3.3.3",
"2.2.2.2"
]
],
[
"down",
1,
true,
[
"3.3.3.3",
"1.1.1.1",
"2.2.2.2"
]
],
[
"delete",
2,
null,
[
"3.3.3.3",
"2.2.2.2"
]
]
]
},
"edits": {
"invalidCell": {
"manual": [
+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' }],
});
}
});
});
+60
View File
@@ -348,6 +348,66 @@ test("keeps a home router's port forwarding on its own page, and shows its DHCP
await expect(sheet(page).getByRole('tab', { name: 'LAN: 192.168.0.10' })).toHaveAttribute('aria-selected', 'true');
});
test("sets up a router's firewall on its own page: the switches, the default, rules edited, moved and removed", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="router"]').click();
await page.getByRole('dialog').getByRole('button', { name: '2', exact: true }).click();
const id = (await drawing(page)).nodes[0]!.id;
const firewall = async () => ((await sheetOf(page, id)) as RouterSheet).firewall;
await sheet(page).getByRole('button', { name: 'Firewall einrichten' }).click();
await expect(sheet(page).getByRole('heading')).toHaveText('Firewall einrichten');
await expect(sheet(page).getByRole('tab')).toHaveText(['Allgemein', 'Firewall-Regeln']);
await sheet(page).getByLabel('Firewall aktivieren').check();
await sheet(page).getByLabel('nur SYN-Pakete verwerfen').uncheck();
await expect.poll(firewall).toMatchObject({ isActive: true, filterSynSegmentsOnly: false });
await sheet(page).getByRole('tab', { name: 'Firewall-Regeln' }).click();
await sheet(page).locator('select[name="defaultPolicy"]').selectOption('accept');
await sheet(page).getByRole('button', { name: 'Neue Regel' }).click();
const rows = sheet(page).locator('table[data-rows="firewall"] tbody tr');
await expect(rows.first().locator('td')).toHaveText(['1', '', '', '', '', 'TCP', '', 'verwerfen']);
await expect(sheet(page).getByRole('button', { name: 'rauf' })).toBeDisabled();
await field(page, 'firewall-sourceIPAddress').fill('abc');
await field(page, 'firewall-sourceIPAddress').press('Enter');
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin (leer).');
await field(page, 'firewall-sourceIPAddress').fill('10.0.0.0');
await field(page, 'firewall-sourceIPAddress').press('Enter');
await field(page, 'firewall-sourceSubnetMask').fill('255.0.255.0');
await field(page, 'firewall-sourceSubnetMask').press('Enter');
await expect(field(page, 'firewall-sourceSubnetMask')).toHaveAttribute('aria-invalid', 'true');
await field(page, 'firewall-sourceSubnetMask').fill('255.0.0.0');
await field(page, 'firewall-sourceSubnetMask').press('Enter');
await field(page, 'firewall-port').fill('80');
await field(page, 'firewall-port').press('Enter');
await sheet(page).locator('select[name="firewall-protocol"]').selectOption('udp');
await sheet(page).locator('select[name="firewall-action"]').selectOption('accept');
await expect(rows.first().locator('td')).toHaveText(['1', '10.0.0.0', '255.0.0.0', '', '', 'UDP', '80', 'akzeptieren']);
await sheet(page).getByRole('button', { name: 'Neue Regel' }).click();
await field(page, 'firewall-destinationIPAddress').fill('192.168.0.1');
await field(page, 'firewall-destinationIPAddress').press('Enter');
await expect(sheet(page).getByRole('button', { name: 'runter' })).toBeDisabled();
await sheet(page).getByRole('button', { name: 'rauf' }).click();
await expect.poll(async () => (await firewall()).rules.map((rule) => rule.destinationIPAddress)).toEqual(['192.168.0.1', '']);
await expect(rows.first()).toHaveAttribute('aria-selected', 'true');
await sheet(page).getByRole('button', { name: 'Regel löschen' }).click();
await expect.poll(async () => (await firewall()).rules.map((rule) => rule.sourceIPAddress)).toEqual(['10.0.0.0']);
// Java keeps a row chosen: the one that took the removed one's place.
await expect(rows.first()).toHaveAttribute('aria-selected', 'true');
await expect(field(page, 'firewall-sourceIPAddress')).toHaveValue('10.0.0.0');
await expect(await firewall()).toMatchObject({ defaultPolicy: 'accept' });
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
await expect(sheet(page).getByRole('heading')).toHaveText('Vermittlungsrechner');
});
test("opens a home router's firewall from its general tab", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="gateway"]').click();
await sheet(page).getByRole('button', { name: 'Firewall einrichten' }).click();
await expect(sheet(page).getByRole('heading')).toHaveText('Firewall einrichten');
await expect(sheet(page).getByLabel('Firewall aktivieren')).not.toBeChecked();
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
+24
View File
@@ -42,6 +42,29 @@
"dhcp.static": "Statische Adresszuweisung",
"dhcp.subnetMask": "Netzmaske",
"dhcp.upperBound": "Adress-Obergrenze",
"firewall.accept": "akzeptieren",
"firewall.action": "Aktion",
"firewall.active": "Firewall aktivieren",
"firewall.activeHint": "Erst durch Aktivieren der Firewall werden die Firewall-Regeln angewendet. Bei deaktivierter Firewall findet keine Filterung statt.",
"firewall.add": "Neue Regel",
"firewall.defaultPolicy": "Standardaktion, wenn keine Regel greift",
"firewall.delete": "Regel löschen",
"firewall.destinationIPAddress": "Ziel-IP",
"firewall.destinationSubnetMask": "Netzmaske",
"firewall.down": "runter",
"firewall.drop": "verwerfen",
"firewall.dropIcmp": "ICMP-Pakete filtern",
"firewall.dropIcmpHint": "Alle ICMP Pakete, z.B. Ping-Anfragen, werden von der Firewall verworfen.",
"firewall.filterSyn": "nur SYN-Pakete verwerfen",
"firewall.filterSynHint": "Neue Verbindungsanfragen zu gesperrten Ports werden verworfen.\nDer Rückkanal für erlaubte Verbindungsanfragen wird akzeptiert.",
"firewall.intro": "Die folgenden Regeln werden von oben nach unten durchlaufen. Sobald eine Regel greift, wird die gewählte Aktion ausgeführt.\nAlle Angaben (außer Protokoll und Aktion) in einer Regel sind optional. Leere Felder werden zur Filterung nicht ausgewertet.\nWenn eine IP-Adresse angegeben wird, muss auch die Netzwerkmaske angegeben sein! Mit einer Maske ungleich 255.255.255.255 lassen sich ganze Netzbereiche adressieren.\nÜber die Standard-Aktion kann zwischen Blacklisting- und Whitelisting-Ansatz gewechselt werden.",
"firewall.number": "ID",
"firewall.port": "Port",
"firewall.protocol": "Protokoll",
"firewall.rules": "Firewall-Regeln",
"firewall.sourceIPAddress": "Quell-IP",
"firewall.sourceSubnetMask": "Netzmaske",
"firewall.up": "rauf",
"forwarding.lanIPAddress": "LAN-Adresse",
"forwarding.lanPortValue": "LAN-Port",
"forwarding.protocolValue": "Protokoll",
@@ -71,6 +94,7 @@
"sheet.dhcpServer": "DHCP-Server einrichten",
"sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(leer)",
"sheet.firewall": "Firewall einrichten",
"sheet.forwardingTable": "Weiterleitungstabelle",
"sheet.general": "Allgemein",
"sheet.invalidValue": "Dieser Wert ist ungültig.",
+24
View File
@@ -42,6 +42,29 @@
"dhcp.static": "Static Address Assignment",
"dhcp.subnetMask": "Netmask",
"dhcp.upperBound": "Upper bound of address",
"firewall.accept": "accept",
"firewall.action": "Action",
"firewall.active": "Activate firewall",
"firewall.activeHint": "Firewall rules will only be applied if the firewall is activated. In the case of a not activated firewall no packet filtering will take place.",
"firewall.add": "add new rule",
"firewall.defaultPolicy": "Default action, if no rule matches",
"firewall.delete": "delete rule",
"firewall.destinationIPAddress": "Dest. IP",
"firewall.destinationSubnetMask": "Netmask",
"firewall.down": "move down",
"firewall.drop": "drop",
"firewall.dropIcmp": "Filter ICMP packets",
"firewall.dropIcmpHint": "All ICMP packets, e.g., ping requests, will be dropped.",
"firewall.filterSyn": "only drop SYN packets",
"firewall.filterSynHint": "New connection requests to blocked ports will be dropped.\nBack channels for allowed connection requests will be accepted.",
"firewall.intro": "The following rules are evaluated from top to bottom. For the first matching rule the given action will be used.\nAll fields (except protocol and action) are optional. Empty fields will not be considered for packet filtering.\nIf an IP address is used, then the subnet mask has to be filled in as well! A subnet mask different to 255.255.255.255 can be used to address whole subnet ranges.\nUsing the default policy switch the approach can be toggled from blacklisting to whitelisting and vice versa.",
"firewall.number": "ID",
"firewall.port": "Port",
"firewall.protocol": "Protocol",
"firewall.rules": "Firewall Rules",
"firewall.sourceIPAddress": "Source IP",
"firewall.sourceSubnetMask": "Netmask",
"firewall.up": "move up",
"forwarding.lanIPAddress": "LAN Address",
"forwarding.lanPortValue": "LAN Port",
"forwarding.protocolValue": "Protocol",
@@ -71,6 +94,7 @@
"sheet.dhcpServer": "DHCP server setup",
"sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(empty)",
"sheet.firewall": "Firewall settings",
"sheet.forwardingTable": "Forwarding table",
"sheet.general": "General",
"sheet.invalidValue": "This value is invalid.",
+8 -3
View File
@@ -30,6 +30,7 @@ import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import { NameField, type Change } from './NameField.js';
import { HomeRouterFields, RouterFields } from './RouterSheets.js';
import { FirewallPanel } from './FirewallPanel.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js';
import './sheet.css';
@@ -61,7 +62,7 @@ export interface DeviceSheetProps {
}
/** A page of the sheet that Java opens as a dialog. */
export type SheetPage = 'dhcp' | 'forwarding';
export type SheetPage = 'dhcp' | 'forwarding' | 'firewall';
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
const t = useTranslate();
@@ -91,7 +92,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
‹
</button>
)}
<h2>{open === 'dhcp' ? t('sheet.dhcpServer') : open === 'forwarding' ? t('sheet.portForwarding') : title}</h2>
<h2>{open === 'dhcp' ? t('sheet.dhcpServer') : open === 'forwarding' ? t('sheet.portForwarding') : open === 'firewall' ? t('sheet.firewall') : title}</h2>
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
×
</button>
@@ -105,7 +106,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} />
) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : (
@@ -116,6 +117,10 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
<div key={`${id}-dhcp`}>
<DhcpServerPanel server={sheet.dhcpServer} change={change} />
</div>
) : open === 'firewall' && (sheet.kind === 'router' || sheet.kind === 'gateway') ? (
<div key={`${id}-firewall`}>
<FirewallPanel firewall={sheet.firewall} change={change} />
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} />
+193
View File
@@ -0,0 +1,193 @@
// A router's or home router's firewall as a page of the device sheet, after
// Java's dialog (JFirewallDialog): a general tab with the three switches and
// their explanations, a tab with the default action and the rules — a rule
// chosen to edit its cells, moved up or down, a new rule at the end as
// Java's (TCP, all ports, drop), a rule removed. Java's first tab says
// "network interfaces" though it holds the general switches; here it says so.
import { useState } from 'react';
import {
addFirewallRule,
FIREWALL_RULE_COLUMNS,
firewallCellRule,
moveFirewallRule,
removeFirewallRule,
setFirewallDefaultPolicy,
setFirewallFlag,
setFirewallRuleCell,
setFirewallRuleChoice,
checkField,
type FirewallSheet,
} from '@mdg-filius/doc';
import type { FirewallAction, FirewallProtocol, FirewallRule } from '@mdg-filius/core';
import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
const PROTOCOL_NAMES: Readonly<Record<FirewallProtocol, string>> = { all: '*', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
/** Java's list offers all, TCP and UDP; ICMP comes only from a file, and is offered where a rule has it. */
const OFFERED: readonly FirewallProtocol[] = ['all', 'tcp', 'udp'];
const cellText = (rule: FirewallRule, column: (typeof FIREWALL_RULE_COLUMNS)[number]) => (column === 'port' ? (rule.port < 0 ? '' : String(rule.port)) : rule[column]);
/** A text with Java's line breaks as paragraphs. */
function Note({ text }: { readonly text: string }) {
return (
<>
{text.split('\n').map((line, index) => (
<p key={index} className="sheet-note">
{line}
</p>
))}
</>
);
}
export function FirewallPanel({ firewall, change }: { readonly firewall: FirewallSheet; readonly change: Change }) {
const t = useTranslate();
const [tab, setTab] = useState('general');
const [chosen, setChosen] = useState<number | null>(null);
const rule = chosen === null ? undefined : firewall.rules[chosen];
const flag = (name: 'isActive' | 'dropIcmp' | 'filterSynSegmentsOnly') => (on: boolean) => change.run(() => setFirewallFlag(change.doc, change.id, name, on, change.origin));
const move = (step: -1 | 1) => {
if (chosen !== null && change.run(() => moveFirewallRule(change.doc, change.id, chosen, step, change.origin))) setChosen(chosen + step);
};
return (
<>
<SheetTabs
tabs={[
{ key: 'general', label: t('sheet.general') },
{ key: 'rules', label: t('firewall.rules') },
]}
selected={tab}
onSelect={setTab}
/>
<div role="tabpanel" key={tab}>
{tab === 'general' ? (
<>
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
<Note text={t('firewall.activeHint')} />
<SheetCheck name="dropIcmp" label={t('firewall.dropIcmp')} checked={firewall.dropIcmp} onChange={flag('dropIcmp')} />
<Note text={t('firewall.dropIcmpHint')} />
<SheetCheck name="filterSyn" label={t('firewall.filterSyn')} checked={firewall.filterSynSegmentsOnly} onChange={flag('filterSynSegmentsOnly')} />
<Note text={t('firewall.filterSynHint')} />
</>
) : (
<>
<Note text={t('firewall.intro')} />
<label className="sheet-field">
<span>{t('firewall.defaultPolicy')}</span>
<select
name="defaultPolicy"
value={firewall.defaultPolicy}
onChange={(event) => change.run(() => setFirewallDefaultPolicy(change.doc, change.id, event.target.value as FirewallAction, change.origin))}
>
<option value="accept">{t('firewall.accept')}</option>
<option value="drop">{t('firewall.drop')}</option>
</select>
</label>
<div className="route-table-frame">
<table className="route-table" data-rows="firewall">
<thead>
<tr>
<th scope="col">{t('firewall.number')}</th>
{FIREWALL_RULE_COLUMNS.slice(0, 4).map((column) => (
<th key={column} scope="col">
{t(`firewall.${column}`)}
</th>
))}
<th scope="col">{t('firewall.protocol')}</th>
<th scope="col">{t('firewall.port')}</th>
<th scope="col">{t('firewall.action')}</th>
</tr>
</thead>
<tbody>
{firewall.rules.map((each, index) => (
<tr key={index} data-manual={String(index)} aria-selected={chosen === index} onClick={() => setChosen(index)}>
<td>{index + 1}</td>
{FIREWALL_RULE_COLUMNS.slice(0, 4).map((column) => (
// A cell its rule refuses (from a file) is marked, not mended.
<td key={column} data-invalid={'invalid' in checkField(firewallCellRule(column), cellText(each, column)) ? 'true' : undefined}>
{cellText(each, column)}
</td>
))}
<td>{PROTOCOL_NAMES[each.protocol]}</td>
<td data-invalid={'invalid' in checkField('portOrEmpty', cellText(each, 'port')) ? 'true' : undefined}>{cellText(each, 'port')}</td>
<td>{t(each.action === 'accept' ? 'firewall.accept' : 'firewall.drop')}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sheet-buttons">
<button type="button" disabled={chosen === null || chosen === 0} onClick={() => move(-1)}>
{t('firewall.up')}
</button>
<button type="button" disabled={chosen === null || chosen === firewall.rules.length - 1} onClick={() => move(1)}>
{t('firewall.down')}
</button>
<button type="button" onClick={() => setChosen(change.run(() => addFirewallRule(change.doc, change.id, change.origin)) ?? null)}>
{t('firewall.add')}
</button>
<button
type="button"
disabled={chosen === null}
onClick={() => {
if (chosen === null) return;
change.run(() => removeFirewallRule(change.doc, change.id, chosen, change.origin));
// Java keeps the row chosen, or the last one when the last went.
const left = firewall.rules.length - 1;
setChosen(left === 0 ? null : Math.min(chosen, left - 1));
}}
>
{t('firewall.delete')}
</button>
</div>
{rule === undefined ? null : (
<fieldset className="sheet-group" key={`rule-${String(chosen)}`}>
{FIREWALL_RULE_COLUMNS.map((column) => (
<SheetField
key={column}
name={`firewall-${column}`}
label={t(`firewall.${column}`)}
value={cellText(rule, column)}
rule={firewallCellRule(column)}
inputMode={column === 'port' ? 'numeric' : 'decimal'}
onCommit={(text) => change.run(() => setFirewallRuleCell(change.doc, change.id, chosen!, column, text, change.origin))}
/>
))}
<label className="sheet-field">
<span>{t('firewall.protocol')}</span>
<select
name="firewall-protocol"
value={rule.protocol}
onChange={(event) => change.run(() => setFirewallRuleChoice(change.doc, change.id, chosen!, { protocol: event.target.value as FirewallProtocol }, change.origin))}
>
{(OFFERED.includes(rule.protocol) ? OFFERED : [...OFFERED, rule.protocol]).map((protocol) => (
<option key={protocol} value={protocol}>
{PROTOCOL_NAMES[protocol]}
</option>
))}
</select>
</label>
<label className="sheet-field">
<span>{t('firewall.action')}</span>
<select
name="firewall-action"
value={rule.action}
onChange={(event) => change.run(() => setFirewallRuleChoice(change.doc, change.id, chosen!, { action: event.target.value as FirewallAction }, change.origin))}
>
<option value="accept">{t('firewall.accept')}</option>
<option value="drop">{t('firewall.drop')}</option>
</select>
</label>
</fieldset>
)}
</>
)}
</div>
</>
);
}
+20 -2
View File
@@ -65,7 +65,17 @@ function GatewayField({ value, change }: { readonly value: string; readonly chan
);
}
export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: RouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
export function RouterFields({
sheet,
change,
onHighlight,
onOpen,
}: {
readonly sheet: RouterSheet;
readonly change: Change;
readonly onHighlight: (link: string | null) => void;
readonly onOpen: (page: 'firewall') => void;
}) {
const t = useTranslate();
const [tab, setTab] = useState('general');
// Java greys the table out while RIP runs; an open table goes back to the general tab.
@@ -89,6 +99,11 @@ export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: R
<GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="rip" label={t('sheet.rip')} checked={sheet.rip} onChange={(on) => change.run(() => setRip(change.doc, change.id, on, change.origin))} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
<div className="sheet-buttons">
<button type="button" onClick={() => onOpen('firewall')}>
{t('sheet.firewall')}
</button>
</div>
</>
) : card !== undefined ? (
<InterfaceFields card={card} change={change} />
@@ -109,7 +124,7 @@ export function HomeRouterFields({
readonly sheet: HomeRouterSheet;
readonly change: Change;
readonly onHighlight: (link: string | null) => void;
readonly onOpen: (page: 'dhcp' | 'forwarding') => void;
readonly onOpen: (page: 'dhcp' | 'forwarding' | 'firewall') => void;
}) {
const t = useTranslate();
const [tab, setTab] = useState('general');
@@ -131,6 +146,9 @@ export function HomeRouterFields({
<GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
<div className="sheet-buttons">
<button type="button" onClick={() => onOpen('firewall')}>
{t('sheet.firewall')}
</button>
<button type="button" onClick={() => onOpen('forwarding')}>
{t('sheet.portForwarding')}
</button>
+27
View File
@@ -85,6 +85,26 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'forwarding.publicPortValue': 'jportforwarding_msg5',
'forwarding.lanIPAddress': 'jportforwarding_msg6',
'forwarding.lanPortValue': 'jportforwarding_msg7',
'sheet.firewall': 'jvermittlungsrechnerkonfiguration_msg4',
'firewall.rules': 'jfirewalldialog_msg21',
'firewall.up': 'jfirewalldialog_msg22',
'firewall.down': 'jfirewalldialog_msg23',
'firewall.add': 'jfirewalldialog_msg24',
'firewall.delete': 'jfirewalldialog_msg25',
'firewall.number': 'jfirewalldialog_msg26',
'firewall.sourceIPAddress': 'jfirewalldialog_msg27',
'firewall.sourceSubnetMask': 'jfirewalldialog_msg28',
'firewall.destinationIPAddress': 'jfirewalldialog_msg29',
'firewall.destinationSubnetMask': 'jfirewalldialog_msg30',
'firewall.protocol': 'jfirewalldialog_msg31',
'firewall.accept': 'jfirewalldialog_msg33',
'firewall.drop': 'jfirewalldialog_msg34',
'firewall.port': 'jfirewalldialog_msg35',
'firewall.active': 'jfirewalldialog_msg38',
'firewall.activeHint': 'jfirewalldialog_msg39',
'firewall.dropIcmp': 'jfirewalldialog_msg40',
'firewall.dropIcmpHint': 'jfirewalldialog_msg41',
'firewall.filterSyn': 'jfirewalldialog_msg42',
};
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
@@ -97,6 +117,13 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
// Java's word, then what it joins or which card.
'sheet.connectedTo': ['jvermittlungsrechnerkonfiguration_msg6', (java) => `${java} {device}`],
'sheet.nic': ['jvermittlungsrechnerkonfiguration_msg10', (java) => `${java} {number}`],
// A column's title without Java's question mark ("Aktion?").
'firewall.action': ['jfirewalldialog_msg32', (java) => java.replace(/\?$/, '')],
// A label above its list, without the colon.
'firewall.defaultPolicy': ['jfirewalldialog_msg36', (java) => java.replace(/:$/, '')],
// Java's "\n" as line breaks; the last sentence of the German starts with a small letter there.
'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')],
'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')],
};
describe('the catalogs', () => {