feat: F4.6g3 a computer's firewall window, measured against Java's with traffic from two networks

The firewall-pc scenario and FirewallWindowRun use Java's window as a person
does and send a ping, a TCP connection and a DNS question from the server's
network and from behind the router after each step. The core adds and
removes port rules (personalFirewall.ts) and writes both into the firewall's
log, which now carries each line's time; the room takes the window's
actions and sends its view and the log as lines; the editor shows Java's
window with its two tabs. Not adopted from Java: any integer as a port,
a port after a space refused without a word, a log one can type into.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 21:18:05 +00:00
co-authored by Claude Opus 5.5
parent 065b42430d
commit 2b85e7432c
25 changed files with 2092 additions and 19 deletions
@@ -41,6 +41,7 @@ import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
import { FileExplorerPanel } from './FileExplorerPanel.js';
import { EchoServerPanel, SimpleClientPanel } from './EchoPanels.js';
import { DnsServerPanel } from './DnsServerPanel.js';
import { PersonalFirewallPanel } from './PersonalFirewallPanel.js';
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
@@ -71,7 +72,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer', 'firewall']);
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
@@ -189,6 +190,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
{shown === 'echoServer' ? <EchoServerPanel node={id} simulation={simulation} /> : null}
{shown === 'simpleClient' ? <SimpleClientPanel node={id} simulation={simulation} /> : null}
{shown === 'dnsServer' ? <DnsServerPanel node={id} simulation={simulation} /> : null}
{shown === 'firewall' ? <PersonalFirewallPanel node={id} simulation={simulation} /> : null}
</ProgramFrame>
)}
{info ? (
@@ -0,0 +1,159 @@
// A computer's firewall, after Java's GUIApplicationFirewallWindow (measured
// with tools/java-oracle's FirewallWindowRun): two tabs. The first holds the
// three boxes — the firewall on, ICMP allowed, UDP filtered; the last two
// only while it is on —, the port with its protocol (TCP, UDP, "*") and its
// senders (all, or those of the same network), "Hinzufügen" (the field is
// emptied after it), "Löschen" of the row chosen, and the table of rules.
// The second holds the log: the firewall's lines and the rules added or
// removed. The firewall is the room's: the panel shows its view and lines
// and hands over what is done.
//
// Better than Java:
// - a port that is none (Java takes any number, and refuses one after a
// space without a word) is marked red and not handed over.
// - Java writes the firewall's explanation but never shows it; here it
// stands under the boxes.
// - a rule the window cannot make (from a file: one that drops, or names an
// address) shows what it is in the sender column; Java shows it as a port
// opened for all senders.
// - the log cannot be typed into (Java's can).
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
import { firewallPortOfText, type FirewallLogEntry, type LogLine, type PortRuleProtocol } from '@mdg-filius/core';
import type { FirewallView, FirewallViewRule } from '@mdg-filius/protocol';
import { useTranslate, type Translate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { firewallLine } from './serverLines.js';
const NO_LINES: readonly LogLine<FirewallLogEntry>[] = [];
const PROTOCOL_NAMES: Readonly<Record<FirewallViewRule['protocol'], string>> = { tcp: 'TCP', udp: 'UDP', all: '*', icmp: 'ICMP' };
/** Java's list: TCP, UDP, "*". */
const PROTOCOLS: readonly PortRuleProtocol[] = ['tcp', 'udp', 'all'];
/** A rule's cells as Java's ruleToVector writes them: protocol, port (none for every port), sender. */
export function firewallRow(rule: FirewallViewRule, t: Translate): readonly [string, string, string] {
const sender = rule.sender === 'all' ? t('personalFirewall.allSenders') : rule.sender === 'sameNetwork' ? t('personalFirewall.localNetwork') : rule.text;
return [PROTOCOL_NAMES[rule.protocol], rule.port >= 0 ? String(rule.port) : '', sender];
}
export function PersonalFirewallPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/firewall` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as FirewallView | null | undefined;
const [tab, setTab] = useState<'setup' | 'log'>('setup');
const [port, setPort] = useState('');
const [protocol, setProtocol] = useState<PortRuleProtocol>('tcp');
const [sameNetworkOnly, setSameNetworkOnly] = useState(false);
const [chosen, setChosen] = useState<number | null>(null);
const lines = (simulation.apps.get(topic as never)?.lines as readonly LogLine<FirewallLogEntry>[] | undefined) ?? NO_LINES;
const log = lines.map((each) => `${firewallLine(each, t)}\n`).join('');
const logElement = useRef<HTMLPreElement>(null);
useLayoutEffect(() => {
if (logElement.current !== null) logElement.current.scrollTop = logElement.current.scrollHeight;
}, [log, tab]);
if (view === null || view === undefined) return <div className="web-server" />;
const typed = firewallPortOfText(port);
const chosenRow = chosen !== null && chosen < view.rules.length ? chosen : null;
const add = () => {
if (typed === undefined) return;
act(node, 'firewall', 'addRule', { port: typed, protocol, sameNetworkOnly });
setPort('');
};
const remove = () => {
if (chosenRow === null) return;
act(node, 'firewall', 'removeRule', { row: chosenRow });
setChosen(null);
};
const choose = (row: number) => (event: KeyboardEvent<HTMLTableRowElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
setChosen(row);
};
return (
<div className="web-server personal-firewall">
<div className="mail-tabs" role="tablist">
{(['setup', 'log'] as const).map((each) => (
<button key={each} type="button" role="tab" aria-selected={tab === each} onClick={() => setTab(each)}>
{t(`personalFirewall.${each}`)}
</button>
))}
</div>
{tab === 'log' ? (
<pre className="web-server-log" ref={logElement} role="tabpanel" aria-label={t('personalFirewall.log')} tabIndex={0}>
{log}
</pre>
) : (
<div className="personal-firewall-page" role="tabpanel" aria-label={t('personalFirewall.setup')}>
<div className="personal-firewall-boxes">
<label>
<input type="checkbox" checked={view.active} onChange={(event) => act(node, 'firewall', 'setActive', { active: event.target.checked })} />
{t('firewall.active')}
</label>
<label>
<input type="checkbox" checked={view.icmpAllowed} disabled={!view.active} onChange={(event) => act(node, 'firewall', 'setIcmpAllowed', { allowed: event.target.checked })} />
{t('personalFirewall.allowIcmp')}
</label>
<label>
<input type="checkbox" checked={view.filterUdp} disabled={!view.active} onChange={(event) => act(node, 'firewall', 'setFilterUdp', { enabled: event.target.checked })} />
{t('personalFirewall.filterUdp')}
</label>
</div>
<p className="personal-firewall-hint">{t('personalFirewall.hint')}</p>
<div className="web-server-bar">
<label>
{t('personalFirewall.port')}
<input
value={port}
inputMode="numeric"
aria-invalid={port !== '' && typed === undefined ? 'true' : undefined}
onChange={(event) => setPort(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && add()}
/>
</label>
<select aria-label={t('firewall.protocol')} value={protocol} onChange={(event) => setProtocol(event.target.value as PortRuleProtocol)}>
{PROTOCOLS.map((each) => (
<option key={each} value={each}>
{PROTOCOL_NAMES[each]}
</option>
))}
</select>
<select aria-label={t('personalFirewall.sender')} value={sameNetworkOnly ? 'sameNetwork' : 'all'} onChange={(event) => setSameNetworkOnly(event.target.value === 'sameNetwork')}>
<option value="all">{t('personalFirewall.allSenders')}</option>
<option value="sameNetwork">{t('personalFirewall.sameNetwork')}</option>
</select>
</div>
<div className="web-server-bar">
<button type="button" disabled={typed === undefined} onClick={add}>
{t('personalFirewall.add')}
</button>
<button type="button" disabled={chosenRow === null} onClick={remove}>
{t('personalFirewall.delete')}
</button>
</div>
<table className="dns-server-table personal-firewall-table" aria-label={t('personalFirewall.rules')}>
<thead>
<tr>
<th>{t('firewall.protocol')}</th>
<th>{t('personalFirewall.portColumn')}</th>
<th>{t('personalFirewall.sender')}</th>
</tr>
</thead>
<tbody>
{view.rules.map((rule, row) => (
<tr key={row} tabIndex={0} aria-selected={row === chosenRow} onPointerDown={() => setChosen(row)} onKeyDown={choose(row)}>
{firewallRow(rule, t).map((cell, column) => (
<td key={column}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+62
View File
@@ -1140,3 +1140,65 @@
.dns-server-table tr[aria-selected='true'] input {
background: #b8cfe5;
}
/* A computer's firewall (GUIApplicationFirewallWindow): two tabs, the boxes,
* the port with its two lists, two buttons and the table of rules; the log. */
.personal-firewall-page {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.personal-firewall-boxes {
display: flex;
flex-direction: column;
}
.personal-firewall-boxes label {
display: flex;
align-items: center;
gap: 6px;
min-height: 44px;
cursor: pointer;
}
.personal-firewall-boxes input {
width: 20px;
height: 20px;
}
.personal-firewall-hint {
margin: 0;
}
.personal-firewall input:not([type='checkbox']),
.personal-firewall select {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px Dialog, sans-serif;
}
.personal-firewall input:not([type='checkbox']) {
width: 6em;
}
.personal-firewall [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
}
.personal-firewall-table td {
min-height: 32px;
padding: 6px;
overflow-wrap: anywhere;
}
.personal-firewall-table tr {
cursor: pointer;
}
+20 -2
View File
@@ -2,9 +2,10 @@
// ServerMitarbeiter, TCPSocket's failures), and the generic client's as its
// window writes them (ClientBaustein, Socket, TCPSocket), in the person's
// language: one line each (a failed connection two), as Java appends them to
// its window's text area.
// its window's text area. And a computer's firewall's log as its window
// writes it (Firewall.benachrichtigeBeobachter, GUIApplicationFirewallWindow).
import type { ServerLogEntry, SimpleClientLogEntry } from '@mdg-filius/core';
import { firewallRuleText, PORT_RULE_PROTOCOL_NAMES, type FirewallLogEntry, type ServerLogEntry, type SimpleClientLogEntry } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
export function serverLine(entry: ServerLogEntry, t: Translate): string {
@@ -45,3 +46,20 @@ export function simpleClientLine(entry: SimpleClientLogEntry, t: Translate): str
return t(`server.sendFailed.${entry.failure}`);
}
}
export function firewallLine(entry: FirewallLogEntry, t: Translate): string {
switch (entry.kind) {
case 'ruleMatched':
return t('personalFirewall.ruleMatched', {
number: entry.ruleNumber,
rule: firewallRuleText(entry.rule, entry.firstInterface),
action: t(entry.accepted ? 'firewall.accept' : 'firewall.drop'),
});
case 'icmpDropped':
return t('personalFirewall.icmpDropped', { sender: entry.senderIPAddress, receiver: entry.receiverIPAddress, code: entry.code, type: entry.type });
case 'portRuleAdded':
return t('personalFirewall.portRuleAdded', { port: entry.port, protocol: PORT_RULE_PROTOCOL_NAMES[entry.protocol] });
case 'portRuleRemoved':
return t('personalFirewall.portRuleRemoved');
}
}