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
+10 -1
View File
@@ -20,7 +20,7 @@
import { IP_ADDRESS_PATTERN, javaIpCheck } from '@mdg-filius/core';
export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port';
export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port' | 'macAddress';
/** 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 };
@@ -31,6 +31,12 @@ const SUBNET_MASK_PATTERN = /^(0*([1-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0
const SSID_PATTERN = /^[ -~]+$/;
/** EingabenUeberpruefung.musterPort: 1 to 65535, no leading zero. */
const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/;
/**
* EingabenUeberpruefung.musterMacAddress, whose class [0-9a-fA-f] also lets
* the six characters between Z and a through ("AA:BB:CC:DD:EE:_F"); here
* hexadecimal digits only.
*/
const MAC_ADDRESS_PATTERN = /^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$/;
/** EingabenUeberpruefung.musterNurZahlen, at least one digit (Java's Long.parseLong refuses none). */
const DIGITS_PATTERN = /^[0-9]+$/;
@@ -65,5 +71,8 @@ export function checkField(rule: FieldRule, text: string): CheckedField {
}
case 'port':
return PORT_PATTERN.test(text.trim()) ? { value: text.trim() } : INVALID;
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;
}
}
+164 -2
View File
@@ -20,12 +20,27 @@
// Nor: an interface address that passes the panel's pattern but not
// IP.ipCheck (".0.0.1") becomes null in Java, and a router's panel then
// throws; a table cell Java refuses is stored as null. Here neither is taken.
// Nor that a DHCP server keeps its bounds as typed (".0.0.1" too), drops a
// 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.
import * as Y from 'yjs';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type ManualRoute, type NodeKind } from '@mdg-filius/core';
import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration,
dhcpServerConfiguration,
forwardsIPv4ByDefault,
type DhcpStaticAssignment,
type GatewayPortForwardingRow,
type Id,
type ManualRoute,
type NodeKind,
} from '@mdg-filius/core';
import { modemPair } from '@mdg-filius/fls';
import { connect, type ConnectError } from './commands.js';
import { checkField, type CheckedField } from './fields.js';
import { checkField, type CheckedField, type FieldRule } from './fields.js';
import { inOrder, ROOTS } from './projectDoc.js';
import { toY } from './values.js';
@@ -319,3 +334,150 @@ export function removeRoute(doc: Y.Doc, id: Id, index: number, origin: unknown):
if (routes.length === 0) settingsOf(node).delete('routes');
}, origin);
}
/** Checks a row's cells by their rules: the values, or the columns that do not pass. */
function checkRow<Column extends string>(
columns: readonly Column[],
rule: (column: Column) => FieldRule,
cells: Readonly<Record<Column, string>>,
): { readonly values: Record<Column, string> } | { readonly invalid: readonly Column[] } {
const checked = columns.map((column) => [column, checkField(rule(column), cells[column])] as const);
const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column);
if (invalid.length > 0) return { invalid };
return { values: Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as Record<Column, string> };
}
/** A device's DHCP server, made as Java's constructor leaves it when the device has none in the document. */
function dhcpServerOf(node: Y.Map<unknown>): Y.Map<unknown> {
let server = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
if (server === undefined) {
server = toY({ ...dhcpServerConfiguration(), staticAssignments: [] }) as Y.Map<unknown>;
settingsOf(node).set('dhcpServer', server);
}
return server;
}
export type DhcpServerField = 'lowerBoundIPAddress' | 'upperBoundIPAddress' | 'gatewayIPAddress' | 'dnsServerIPAddress';
/** An address of a DHCP server (JDHCPKonfiguration: musterIpAdresse, never empty), as Java's IP.ipCheck writes it. */
export function setDhcpServerAddress(doc: Y.Doc, id: Id, field: DhcpServerField, text: string, origin: unknown): CheckedField {
return setChecked(doc, id, checkField('ipAddress', text), (node, value) => dhcpServerOf(node).set(field, value), origin);
}
/** A DHCP server switched on or off, or handing out its own gateway and DNS server instead of the device's. */
export function setDhcpServerFlag(doc: Y.Doc, id: Id, flag: 'isActive' | 'useOwnSettings', on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node !== undefined) doc.transact(() => dhcpServerOf(node).set(flag, on), origin);
}
export type StaticAssignmentColumn = 'macAddress' | 'ipAddress';
export const STATIC_ASSIGNMENT_COLUMNS: readonly StaticAssignmentColumn[] = ['macAddress', 'ipAddress'];
const staticRule = (column: StaticAssignmentColumn): FieldRule => (column === 'macAddress' ? 'macAddress' : 'ipAddress');
const staticAssignmentsOf = (node: Y.Map<unknown>) => {
const server = dhcpServerOf(node);
let list = server.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (list === undefined) {
list = new Y.Array<Y.Map<unknown>>();
server.set('staticAssignments', list);
}
return list;
};
/** Whether another static assignment has this MAC address (Java compares ignoring case). */
function macTaken(list: Y.Array<Y.Map<unknown>>, mac: string, except: number): boolean {
return list.toArray().some((entry, index) => index !== except && (entry.get('macAddress') as string).toUpperCase() === mac.toUpperCase());
}
/** A static assignment of the DHCP server: once both cells pass and the MAC address has none yet. */
export function addDhcpStaticAssignment(
doc: Y.Doc,
id: Id,
cells: Readonly<Record<StaticAssignmentColumn, string>>,
assignmentId: Id,
origin: unknown,
): { readonly assignment: DhcpStaticAssignment } | { readonly invalid: readonly StaticAssignmentColumn[] } | { readonly error: 'macTaken' } {
const checked = checkRow(STATIC_ASSIGNMENT_COLUMNS, staticRule, cells);
const node = nodes(doc).get(id);
if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] };
const existing = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
const list = existing?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (list !== undefined && macTaken(list, checked.values.macAddress, -1)) return { error: 'macTaken' };
const assignment = { id: assignmentId, ...checked.values };
doc.transact(() => staticAssignmentsOf(node).push([toY(assignment) as Y.Map<unknown>]), origin);
return { assignment };
}
/** A cell of a static assignment; a MAC address another assignment has is not taken. */
export function setDhcpStaticCell(doc: Y.Doc, id: Id, index: number, column: StaticAssignmentColumn, text: string, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
const list = (node?.get('settings') as Y.Map<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined;
const entries = list?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
const checked = checkField(staticRule(column), text);
if (entries === undefined || index < 0 || index >= entries.length) return checked;
if ('value' in checked && column === 'macAddress' && macTaken(entries, checked.value, index)) return { invalid: true };
return setChecked(doc, id, checked, () => entries.get(index).set(column, (checked as { value: string }).value), origin);
}
export function removeDhcpStaticAssignment(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const entries = ((node?.get('settings') as Y.Map<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined)?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (entries === undefined || index < 0 || index >= entries.length) return;
doc.transact(() => entries.delete(index, 1), origin);
}
export type PortForwardingColumn = keyof GatewayPortForwardingRow;
export const PORT_FORWARDING_COLUMNS: readonly PortForwardingColumn[] = ['protocolValue', 'publicPortValue', 'lanIPAddress', 'lanPortValue'];
/** Java's protocols of a port forwarding row, as its dialog offers them. */
export const PORT_FORWARDING_PROTOCOLS = ['TCP', 'UDP'] as const;
/** Each column's rule (GatewayFirmware.validateData): a protocol, ports by musterPort, the LAN address by musterIpAdresse. */
const forwardingRule = (column: PortForwardingColumn): FieldRule => (column === 'lanIPAddress' ? 'ipAddress' : column === 'protocolValue' ? 'name' : 'port');
function checkForwardingRow(cells: Readonly<Record<PortForwardingColumn, string>>) {
const checked = checkRow(PORT_FORWARDING_COLUMNS, forwardingRule, cells);
const protocolTaken = (PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(cells.protocolValue);
if ('invalid' in checked) return protocolTaken ? checked : { invalid: ['protocolValue' as const, ...checked.invalid] };
return protocolTaken ? checked : { invalid: ['protocolValue' as const] };
}
const forwardingOf = (node: Y.Map<unknown>) => {
let list = settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined;
if (list === undefined) {
list = new Y.Array<Y.Map<unknown>>();
settingsOf(node).set('portForwarding', list);
}
return list;
};
/** A home router's port forwarding row, once all four cells pass (Java keeps any row; its firmware forwards only the valid ones). */
export function addPortForwarding(
doc: Y.Doc,
id: Id,
cells: Readonly<Record<PortForwardingColumn, string>>,
origin: unknown,
): { readonly row: GatewayPortForwardingRow } | { readonly invalid: readonly PortForwardingColumn[] } {
const checked = checkForwardingRow(cells);
const node = nodes(doc).get(id);
if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] };
const row = checked.values as GatewayPortForwardingRow;
doc.transact(() => forwardingOf(node).push([toY({ ...row }) as Y.Map<unknown>]), origin);
return { row };
}
export function setPortForwardingCell(doc: Y.Doc, id: Id, index: number, column: PortForwardingColumn, text: string, origin: unknown): CheckedField {
const checked: CheckedField =
column === 'protocolValue' ? ((PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(text) ? { value: text } : { invalid: true }) : checkField(forwardingRule(column), text);
return setChecked(doc, id, checked, (node, value) => (settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined)?.get(index)?.set(column, value), origin);
}
/** Removes a port forwarding row; the list goes with its last row, as the project keeps it. */
export function removePortForwarding(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const list = node === undefined ? undefined : (settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined);
if (node === undefined || list === undefined || index < 0 || index >= list.length) return;
doc.transact(() => {
list.delete(index, 1);
if (list.length === 0) settingsOf(node).delete('portForwarding');
}, origin);
}
+53
View File
@@ -7,8 +7,12 @@ import * as Y from 'yjs';
import {
defaultSwitchConfiguration,
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
dhcpServerConfiguration,
forwardsIPv4ByDefault,
parsePortForwardingRow,
routeTableRows,
type DhcpServerConfiguration,
type GatewayPortForwardingRow,
stableGeneratedMacAddress,
type HostLabelPolicy,
type ManualRoute,
@@ -20,6 +24,25 @@ import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js';
import { nodeLabel } from './view.js';
/** A device's DHCP server as Java's dialog shows it (JDHCPKonfiguration). */
export interface DhcpServerSheet {
readonly isActive: boolean;
readonly lowerBoundIPAddress: string;
readonly upperBoundIPAddress: string;
/** The device's own mask (the LAN card's on a home router), shown, not changed. */
readonly subnetMask: string;
/** What the server hands out (DHCPServer.determineGatewayip): its own setting, the device's gateway (0.0.0.0 without one), a home router's LAN address. */
readonly gateway: string;
readonly gatewayEditable: boolean;
/** determineDnsserverip: its own setting (always on a home router), else the device's DNS server or 0.0.0.0. */
readonly dnsServer: string;
readonly dnsServerEditable: boolean;
readonly useOwnSettings: boolean;
/** A home router's dialog leaves the choice without effect; the sheet offers it on a PC or notebook only. */
readonly ownSettingsOffered: boolean;
readonly staticAssignments: readonly { readonly macAddress: string; readonly ipAddress: string }[];
}
export interface HostSheet {
readonly kind: 'pc' | 'notebook';
readonly name: string;
@@ -40,6 +63,7 @@ export interface HostSheet {
readonly wirelessPossible: boolean;
/** The switches' SSIDs, in the order of the devices, each once: the networks to choose from. */
readonly networks: readonly string[];
readonly dhcpServer: DhcpServerSheet;
}
export interface SwitchSheet {
@@ -89,6 +113,9 @@ export interface HomeRouterSheet {
readonly dhcpClient: boolean;
readonly wan: InterfaceSheet;
readonly lan: InterfaceSheet;
readonly dhcpServer: DhcpServerSheet;
/** The port forwarding rows, each with whether Java's firmware forwards it (GatewayFirmware.validateData). */
readonly portForwarding: readonly (GatewayPortForwardingRow & { readonly forwarded: boolean })[];
}
export interface NameSheet {
@@ -123,6 +150,27 @@ function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSh
});
}
/** The DHCP server's sheet, from the device's own address settings (or a home router's LAN card). */
function dhcpServerSheet(node: Y.Map<unknown>, own: { readonly subnetMask: string; readonly gateway: string; readonly dnsServer: string }, home: boolean): DhcpServerSheet {
const stored = (fromY(settingsOf(node).get('dhcpServer')) ?? {}) as Partial<DhcpServerConfiguration>;
const server = dhcpServerConfiguration(stored);
const unset = (value: string) => (value === '' ? '0.0.0.0' : value);
const ownSettings = !home && server.useOwnSettings;
return {
isActive: server.isActive,
lowerBoundIPAddress: server.lowerBoundIPAddress,
upperBoundIPAddress: server.upperBoundIPAddress,
subnetMask: own.subnetMask,
gateway: home ? own.gateway : ownSettings ? server.gatewayIPAddress : unset(own.gateway),
gatewayEditable: ownSettings,
dnsServer: home || ownSettings ? server.dnsServerIPAddress : unset(own.dnsServer),
dnsServerEditable: home || ownSettings,
useOwnSettings: server.useOwnSettings,
ownSettingsOffered: !home,
staticAssignments: server.staticAssignments.map(({ macAddress, ipAddress }) => ({ macAddress, ipAddress })),
};
}
/** The sheet of a device, or undefined when the document has no such device. */
export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
@@ -160,6 +208,7 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
ssid: (wireless?.get('ssid') as string | undefined) ?? '',
wirelessPossible: isWireless || !plugged,
networks: [...networks],
dhcpServer: dhcpServerSheet(node, { subnetMask: text(device, 'subnetMask'), gateway: text(device, 'defaultGateway'), dnsServer: text(device, 'dnsServer') }, false),
};
}
case 'networkSwitch': {
@@ -192,6 +241,7 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
}
case 'gateway': {
const [wan, lan] = interfacesOf(doc, id, node);
const rows = (fromY(settingsOf(node).get('portForwarding')) ?? []) as unknown as GatewayPortForwardingRow[];
return {
kind,
name,
@@ -200,6 +250,9 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true,
wan: wan!,
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),
portForwarding: rows.map((row) => ({ ...row, forwarded: parsePortForwardingRow(row) !== undefined })),
};
}
case 'unsupported':