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
+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>