feat(editor): F4.5c the DHCP server and the port forwarding in the device sheet

Measured first: SheetRun now types into FILIUS' DHCP dialog and keeps rows
in its port forwarding dialog. Java's DHCP server keeps its bounds as typed
(".0.0.1" too), never takes an empty gateway or DNS server, hands out the
device's gateway and DNS server unless its own (a home router always its
LAN address, where "manual settings" do nothing), drops a second static
assignment of a MAC address silently and lets "_" through its MAC pattern;
the port forwarding dialog keeps any row, its firmware forwards only the
valid ones. Here a value passes its rule or is not taken, with a word why.

- doc: DHCP server addresses and flags, static assignments (a MAC address
  once, ignoring case), port forwarding rows (TCP or UDP, musterPort, the
  LAN address); the sheet shows what the server hands out and which rows
  Java's firmware forwards (the core's parsePortForwardingRow, exported)
- editor: Java's dialogs as pages of the sheet with a back button, the
  three row tables on one RowsEditor (the forwarding table rebuilt on it);
  a sheet per device, so an open page closes when another device is chosen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:16:04 +00:00
co-authored by Claude Opus 5.5
parent eff9433673
commit c8edb87210
22 changed files with 1525 additions and 143 deletions
+222 -7
View File
@@ -9,8 +9,10 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { javaIpCheck } from '@mdg-filius/core';
import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls';
import {
toY,
addDevice,
checkField,
connect,
@@ -29,7 +31,15 @@ import {
setSwitchRetention,
setSwitchSsid,
addRoute,
addDhcpStaticAssignment,
addPortForwarding,
removeDhcpStaticAssignment,
removePortForwarding,
removeRoute,
setDhcpServerAddress,
setDhcpServerFlag,
setDhcpStaticCell,
setPortForwardingCell,
setInterfaceAddress,
setRip,
setRouteCell,
@@ -43,7 +53,7 @@ import {
} from '../src/index.js';
interface JavaCase {
readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter';
readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter' | 'dhcpHost' | 'dhcpHome';
readonly field: string;
readonly input: string;
readonly start: string;
@@ -72,6 +82,14 @@ const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)
};
readonly tables: Readonly<Record<string, readonly JavaRow[]>>;
readonly edits: Readonly<Record<string, { readonly manual: readonly (readonly (string | null)[])[]; readonly rowsShown?: number }>>;
readonly dhcp: {
readonly dhcpHostShown: Readonly<Record<string, readonly [string, boolean]>>;
readonly dhcpHomeShown: Readonly<Record<string, readonly [string, boolean]>>;
readonly hostWithoutGatewayShown: readonly [string, string];
readonly staticAssignments: readonly (readonly [string, string])[];
readonly addable: readonly boolean[];
};
readonly forwarding: { readonly rows: readonly (readonly [string, string, string, string])[]; readonly valid: readonly boolean[] };
};
const RULES: Readonly<Record<string, FieldRule>> = {
@@ -95,8 +113,18 @@ const RULES: Readonly<Record<string, FieldRule>> = {
'homeRouter wanMask': 'subnetMask',
'homeRouter lanIp': 'ipAddress',
'homeRouter lanMask': 'subnetMask',
// The DHCP dialog checks every address with musterIpAdresse: none may be empty.
'dhcpHost lower': 'ipAddress',
'dhcpHost upper': 'ipAddress',
'dhcpHost gateway': 'ipAddress',
'dhcpHost dns': 'ipAddress',
'dhcpHome lower': 'ipAddress',
'dhcpHome dns': 'ipAddress',
};
/** Fields Java's dialog shows but never takes: a home router hands out its LAN address as the gateway. */
const NOT_OFFERED = new Set(['dhcpHome gateway']);
/** Fields whose text is judged without the spaces around it. */
const TRIMMED = new Set(
Object.entries(RULES)
@@ -105,10 +133,13 @@ const TRIMMED = new Set(
);
/** Whether Java's device took the text: its value changed (to an address: null is a card that lost its own), or was the text already. */
const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start);
const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start) && (!keptAsTyped(each) || javaIpCheck(each.stored) !== null);
/** What Java's model holds in the units of the sheet's value: seconds for the retention time. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored);
/** A DHCP server keeps its addresses as typed ("010.0.0.1"); the model keeps IP.ipCheck's form. */
const keptAsTyped = (each: JavaCase) => each.device.startsWith('dhcp');
/** What Java's model holds in the units of the sheet's value: seconds for the retention time, an address in IP.ipCheck's form. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : keptAsTyped(each) ? javaIpCheck(each.stored!) : each.stored);
/** The case Java judged for a text: the trimmed text's own case where spaces are left out. */
function judged(each: JavaCase): JavaCase {
@@ -121,13 +152,14 @@ function judged(each: JavaCase): JavaCase {
describe("a field's rule, against FILIUS' settings panels", () => {
it('has a case for every field the sheet checks', () => {
expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set(Object.keys(RULES)));
expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set([...Object.keys(RULES), ...NOT_OFFERED]));
expect(java.cases.length).toBeGreaterThan(70);
});
it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => {
for (const each of java.cases) {
const key = `${each.device} ${each.field}`;
if (NOT_OFFERED.has(key)) continue;
const verdict = judged(each);
// Taken: the value changed, or the text was the value already.
const took = tookIt(verdict);
@@ -143,9 +175,13 @@ describe("a field's rule, against FILIUS' settings panels", () => {
});
it("names where Java's two judgements disagree, which here are one", () => {
const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each));
const disagreeing = java.cases.filter(
(each) => !NOT_OFFERED.has(`${each.device} ${each.field}`) && RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each),
);
const kept = (each: JavaCase) => each.stored !== null && each.stored !== each.start && !tookIt(each);
// Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address.
const verdictOf = (each: JavaCase) => (each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : 'refused');
const verdictOf = (each: JavaCase) =>
each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : kept(each) ? 'kept, no address' : 'refused';
expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([
'host ip "10..0.0.1" marked, taken',
'host ip ".0.0.1" refused',
@@ -167,6 +203,7 @@ describe("a field's rule, against FILIUS' settings panels", () => {
'router gateway "192.168..0.1" marked, taken',
'homeRouter gateway "" marked, taken',
'homeRouter wanIp ".1.2.3" lost',
'dhcpHost lower ".0.0.1" kept, no address',
]);
const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start);
expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']);
@@ -208,6 +245,19 @@ describe("a PC's sheet", () => {
ssid: '',
wirelessPossible: true,
networks: [],
dhcpServer: {
isActive: false,
lowerBoundIPAddress: '0.0.0.0',
upperBoundIPAddress: '0.0.0.0',
subnetMask: '255.255.255.0',
gateway: '0.0.0.0',
gatewayEditable: false,
dnsServer: '0.0.0.0',
dnsServerEditable: false,
useOwnSettings: false,
ownSettingsOffered: true,
staticAssignments: [],
},
});
expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
expect(deviceSheet(doc, 'nobody')).toBeUndefined();
@@ -706,3 +756,168 @@ describe("a home router's sheet", () => {
expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1');
});
});
describe("a DHCP server's sheet", () => {
/** The devices of SheetRun's DHCP flows: a host 192.168.0.10/24 with gateway and DNS, a home router; the server with known settings. */
function servers() {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
setHostAddress(doc, pc, 'defaultGateway', '192.168.0.254', 'me');
setHostAddress(doc, pc, 'dnsServer', '192.168.0.253', 'me');
const homeId = add(doc, 'gateway');
for (const id of [pc, homeId]) {
setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me');
setDhcpServerAddress(doc, id, 'upperBoundIPAddress', '192.168.0.200', 'me');
setDhcpServerAddress(doc, id, 'gatewayIPAddress', '10.0.0.1', 'me');
setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '10.0.0.2', 'me');
}
return { doc, pc, homeId };
}
it("shows what Java's dialog shows: the device's gateway and DNS server unless its own, a home router's LAN address", () => {
const { doc, pc, homeId } = servers();
const shown = (sheet: ReturnType<typeof host>['dhcpServer']) => ({
tfUntergrenze: [sheet.lowerBoundIPAddress, true],
tfObergrenze: [sheet.upperBoundIPAddress, true],
tfNetzmaske: [sheet.subnetMask, false],
tfGateway: [sheet.gateway, sheet.gatewayEditable],
tfDNSServer: [sheet.dnsServer, sheet.dnsServerEditable],
});
expect(shown(host(doc, pc).dhcpServer)).toEqual(java.dhcp.dhcpHostShown);
expect(shown(home(doc, homeId).dhcpServer)).toEqual(java.dhcp.dhcpHomeShown);
expect(home(doc, homeId).dhcpServer.ownSettingsOffered).toBe(false);
// A home router's choice has no effect (Java's dialog keeps the flag, the gateway stays its LAN address).
setDhcpServerFlag(doc, homeId, 'useOwnSettings', true, 'me');
expect(home(doc, homeId).dhcpServer).toMatchObject({ useOwnSettings: true, gateway: '192.168.0.10', gatewayEditable: false });
const bare = add(doc, 'pc');
expect([host(doc, bare).dhcpServer.gateway, host(doc, bare).dhcpServer.dnsServer]).toEqual(java.dhcp.hostWithoutGatewayShown);
setDhcpServerFlag(doc, pc, 'useOwnSettings', true, 'me');
expect(host(doc, pc).dhcpServer).toMatchObject({ gateway: '10.0.0.1', gatewayEditable: true, dnsServer: '10.0.0.2', dnsServerEditable: true, useOwnSettings: true });
setDhcpServerFlag(doc, pc, 'isActive', true, 'me');
expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({ isActive: true, useOwnSettings: true, lowerBoundIPAddress: '192.168.0.100' });
});
it("keeps a static assignment once both cells pass and its MAC address is free, unlike Java's silent drops", () => {
const { doc, pc } = servers();
// Java's add button takes the duplicate and the "_F" too (musterMacAddress); its server drops the duplicate on saving.
expect(java.dhcp.addable).toEqual([true, true, true, false, true, false]);
const rows = [
['aa:bb:cc:dd:ee:01', '192.168.0.50'],
['AA:BB:CC:DD:EE:01', '192.168.0.51'],
['AA:BB:CC:DD:EE:_F', '192.168.0.52'],
['AA:BB:CC:DD:EE:02', '192.168.0.256'],
['AA:BB:CC:DD:EE:03', '010.0.0.1'],
['AABBCCDDEE04', '192.168.0.54'],
] as const;
const outcomes = rows.map(([macAddress, ipAddress], index) => {
const result = addDhcpStaticAssignment(doc, pc, { macAddress, ipAddress }, `assignment-${index}`, 'me');
return 'assignment' in result ? 'taken' : 'error' in result ? result.error : `invalid ${result.invalid.join(' ')}`;
});
expect(outcomes).toEqual(['taken', 'macTaken', 'invalid macAddress', 'invalid ipAddress', 'taken', 'invalid macAddress']);
// Java kept the first, the "_F" its pattern lets through, and "010.0.0.1" as typed; here upper case and IP.ipCheck's form.
expect(java.dhcp.staticAssignments).toEqual([
['aa:bb:cc:dd:ee:01', '192.168.0.50'],
['AA:BB:CC:DD:EE:_F', '192.168.0.52'],
['AA:BB:CC:DD:EE:03', '010.0.0.1'],
]);
expect(host(doc, pc).dhcpServer.staticAssignments).toEqual([
{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' },
{ macAddress: 'AA:BB:CC:DD:EE:03', ipAddress: '10.0.0.1' },
]);
expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:01', 'me')).toEqual({ invalid: true });
expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:09', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:09' });
expect(setDhcpStaticCell(doc, pc, 0, 'ipAddress', '192.168.0.60', 'me')).toEqual({ value: '192.168.0.60' });
expect(setDhcpStaticCell(doc, pc, 5, 'ipAddress', '192.168.0.61', 'me')).toEqual({ value: '192.168.0.61' });
removeDhcpStaticAssignment(doc, pc, 7, 'me');
removeDhcpStaticAssignment(doc, pc, 0, 'me');
expect(readProject(doc).dhcpServerConfigurations.get(pc)?.staticAssignments).toEqual([{ id: 'assignment-4', macAddress: 'AA:BB:CC:DD:EE:09', ipAddress: '10.0.0.1' }]);
expect(addDhcpStaticAssignment(doc, 'nobody', { macAddress: 'AA:BB:CC:DD:EE:0A', ipAddress: '10.0.0.3' }, 'x', 'me')).toEqual({ invalid: [] });
});
it("compares MAC addresses ignoring case, as Java does, a file's lower case too, and lets a row keep its own", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me');
// A file keeps a MAC address as Java's dialog took it, in lower case.
const entry = ((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<Y.Map<unknown>>).get('dhcpServer')!.get('staticAssignments') as Y.Array<Y.Map<unknown>>).get(0);
entry.set('macAddress', 'aa:bb:cc:dd:ee:01');
expect(addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.6' }, 'b', 'me')).toEqual({ error: 'macTaken' });
expect(setDhcpStaticCell(doc, pc, 0, 'macAddress', 'AA:BB:CC:DD:EE:01', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:01' });
});
it('takes a static assignment on a device without a server in the document, made as Java makes it', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
expect('assignment' in addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me')).toBe(true);
expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({
isActive: false,
lowerBoundIPAddress: '0.0.0.0',
staticAssignments: [{ id: 'a', macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }],
});
});
});
describe("a home router's port forwarding", () => {
const JAVA_ROWS = () => java.forwarding.rows.map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue }));
it('takes a row once all four cells pass, where Java keeps any row and its firmware forwards only the valid ones', () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
const outcomes = JAVA_ROWS().map((row) => {
const result = addPortForwarding(doc, id, row, 'me');
return 'row' in result ? 'taken' : result.invalid.join(' ');
});
// Java's firmware forwards the first only ("17" it would take, but not the leading zero of "05353").
expect(java.forwarding.valid).toEqual([true, false, false, false, false]);
expect(outcomes).toEqual(['taken', 'publicPortValue', 'publicPortValue', 'protocolValue lanIPAddress', 'protocolValue lanPortValue']);
expect(home(doc, id).portForwarding).toEqual([{ ...JAVA_ROWS()[0]!, forwarded: true }]);
expect(addPortForwarding(doc, 'nobody', JAVA_ROWS()[0]!, 'me')).toEqual({ invalid: [] });
// Java's dialog offers TCP and UDP; Java's firmware would take "tcp", "6" or "17" from a file.
for (const protocolValue of ['tcp', '6', 'ICMP']) expect(addPortForwarding(doc, id, { ...JAVA_ROWS()[0]!, protocolValue }, 'me')).toEqual({ invalid: ['protocolValue'] });
});
it("marks the rows a file brought that Java's firmware would not forward, and edits and removes rows", () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
const list = new Y.Array<Y.Map<unknown>>();
settings.set('portForwarding', list);
list.push(JAVA_ROWS().map((row) => toY({ ...row }) as Y.Map<unknown>));
expect(home(doc, id).portForwarding.map((row) => row.forwarded)).toEqual(java.forwarding.valid);
expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', '0053', 'me')).toEqual({ invalid: true });
expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', ' 53', 'me')).toEqual({ value: '53' });
expect(setPortForwardingCell(doc, id, 1, 'protocolValue', 'ICMP', 'me')).toEqual({ invalid: true });
expect(setPortForwardingCell(doc, id, 0, 'protocolValue', 'UDP', 'me')).toEqual({ value: 'UDP' });
expect(home(doc, id).portForwarding.map((row) => [row.protocolValue, row.publicPortValue, row.forwarded])).toEqual([
['UDP', '80', true],
['UDP', '53', true],
['TCP', 'abc', false],
['tcp', '443', false],
['17', '5353', false],
]);
for (const index of [9, 4, 3, 2, 1]) removePortForwarding(doc, id, index, 'me');
expect(readProject(doc).portForwardingRowsByNodeId.get(id)).toHaveLength(1);
removePortForwarding(doc, id, 0, 'me');
expect(settings.has('portForwarding')).toBe(false);
});
it('keeps the DHCP server and the port forwarding through a saved file', () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me');
setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '8.8.8.8', 'me');
setDhcpServerFlag(doc, id, 'isActive', true, 'me');
addDhcpStaticAssignment(doc, id, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }, 'a', 'me');
addPortForwarding(doc, id, JAVA_ROWS()[0]!, 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `50000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const again = reopened.graph.nodes[0]!.id;
expect(reopened.dhcpServerConfigurations.get(again)).toMatchObject({
isActive: true,
lowerBoundIPAddress: '192.168.0.100',
dnsServerIPAddress: '8.8.8.8',
staticAssignments: [{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }],
});
expect(reopened.portForwardingRowsByNodeId.get(again)).toEqual([JAVA_ROWS()[0]]);
});
});