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