feat(fls): import DHCP, port forwarding and modem settings

importProject carries the DHCP client switch and server settings (hosts
and home routers), a home router's port forwarding rows and a modem's
pairing into the model; the core gains the types, ported from Swift.
The Swift oracle writes them too, and they match on the 13 files Swift
reads completely. Configuration text is now trimmed throughout, as Swift
trims every value it reads (routes and firewall rules included).

Outside those files Swift switches every DHCP server of the Internet
examples off: their "aktiv" refers to a shared Boolean it cannot follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:09:08 +00:00
co-authored by Claude Opus 5.5
parent 4dcc7e09fc
commit cf47cf8f9d
6 changed files with 406 additions and 30 deletions
+108 -13
View File
@@ -20,11 +20,18 @@
// - every router and home router has a firewall configuration, as every one
// has a firewall in Java (Swift keeps one only when the file sets a field;
// without one the core behaves as with an inactive one).
// Not here yet: DHCP, NAT, remote links, file systems, applications.
// - configuration text is trimmed, as Swift trims every value it reads;
// names stay as Java has them;
// - a Java modem becomes an in-project remote link pair keyed by its port,
// as in Swift — two FILIUS instances on different hosts are not joined.
// Not here yet: file systems and applications.
import {
canonicalMacAddress,
deepEqual,
defaultSwitchConfiguration,
dhcpServerConfiguration,
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
deterministicDocumentationUuid,
documentationColor,
documentationItem,
@@ -35,6 +42,10 @@ import {
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
type DeviceConfiguration,
type DhcpClientConfiguration,
type DhcpServerConfiguration,
type GatewayPortForwardingRow,
type RemoteLinkConfiguration,
type FirewallAction,
type FirewallConfiguration,
type FirewallProtocol,
@@ -67,6 +78,11 @@ export interface ImportedProject {
/** Only routers with RIP switched on. */
ripEnabledByNodeId: Map<Id, boolean>;
firewallConfigurationsByNodeId: Map<Id, FirewallConfiguration>;
dhcpClientConfigurations: Map<Id, DhcpClientConfiguration>;
/** Only servers whose settings differ from Java's constructor. */
dhcpServerConfigurations: Map<Id, DhcpServerConfiguration>;
portForwardingRowsByNodeId: Map<Id, GatewayPortForwardingRow[]>;
remoteLinkConfigurations: Map<Id, RemoteLinkConfiguration>;
/** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[];
/** The topology's warnings, then this step's. */
@@ -98,11 +114,16 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
manualRoutesByNodeId: new Map(),
ripEnabledByNodeId: new Map(),
firewallConfigurationsByNodeId: new Map(),
dhcpClientConfigurations: new Map(),
dhcpServerConfigurations: new Map(),
portForwardingRowsByNodeId: new Map(),
remoteLinkConfigurations: new Map(),
};
const nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
registerSystemConfigurations(source, node, system, warnings);
registerServiceConfigurations(source, node, system, newId, warnings);
return node;
});
@@ -280,8 +301,87 @@ type SystemConfigurations = Pick<
| 'manualRoutesByNodeId'
| 'ripEnabledByNodeId'
| 'firewallConfigurationsByNodeId'
| 'dhcpClientConfigurations'
| 'dhcpServerConfigurations'
| 'portForwardingRowsByNodeId'
| 'remoteLinkConfigurations'
>;
/** A row of text cells, trimmed, or undefined unless it has exactly four. */
function fourCells(row: (string | null)[]): [string, string, string, string] | undefined {
if (row.length !== 4 || row.some((cell) => cell === null)) return undefined;
return row.map((cell) => cell!.trim()) as [string, string, string, string];
}
/**
* The DHCP, NAT and modem parts of Swift's finalizeCurrentNodeCandidate and
* registerRemoteLinkConfiguration.
*/
function registerServiceConfigurations(
source: FlsNode,
node: TopologyNode,
system: SystemConfigurations,
newId: () => Id,
warnings: string[],
): void {
const where = `node '${node.displayName}'`;
const dhcpCapable = node.kind === 'pc' || node.kind === 'notebook' || node.kind === 'gateway';
if (source.system.dhcpClient === true) {
if (dhcpCapable) system.dhcpClientConfigurations.set(node.id, { isEnabled: true });
else warnings.push(`${where}: DHCP client on a ${node.kind}, ignored`);
}
const server = source.system.dhcpServer;
if (server !== undefined) {
const configuration = dhcpServerConfiguration({
isActive: server.isActive,
lowerBoundIPAddress: server.lowerBound?.trim() ?? '',
upperBoundIPAddress: server.upperBound?.trim() ?? '',
gatewayIPAddress: server.gateway?.trim() ?? '',
dnsServerIPAddress: server.dnsServer?.trim() ?? '',
useOwnSettings: server.useOwnSettings,
staticAssignments: server.staticAssignedAddresses.map((entry) => ({
id: newId(),
macAddress: entry.mac,
ipAddress: entry.ip,
})),
});
if (!deepEqual(configuration, dhcpServerConfiguration())) {
if (dhcpCapable) system.dhcpServerConfigurations.set(node.id, configuration);
else warnings.push(`${where}: DHCP server settings on a ${node.kind}, ignored`);
}
}
if (node.kind === 'gateway') {
const rows: GatewayPortForwardingRow[] = [];
for (const row of source.system.staticNAT ?? []) {
const cells = fourCells(row);
if (cells === undefined) {
warnings.push(`${where}: a port forwarding row without four entries, left out`);
continue;
}
const [protocolValue, publicPortValue, lanIPAddress, lanPortValue] = cells;
rows.push({ protocolValue, publicPortValue, lanIPAddress, lanPortValue });
}
if (rows.length > 0) system.portForwardingRowsByNodeId.set(node.id, rows);
}
if (node.kind === 'remoteLink') {
const modem = source.system.modem;
let port = modem?.port ?? 12_345;
if (port < 1 || port > 65_535) {
warnings.push(`${where}: modem port ${port} is invalid, used 12345`);
port = 12_345;
}
if (modem !== undefined && modem.mode !== 1 && modem.mode !== 2) {
warnings.push(`${where}: modem mode ${modem.mode} unknown, treated as client`);
}
// Java pairs a server and a client modem over TCP on this port.
system.remoteLinkConfigurations.set(node.id, {
pairIdentifier: `java-modem-port-${port}`,
latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
isEnabled: true,
});
}
}
/**
* Swift's registerSwitchAndWirelessConfigurations, registerManualRoutes and
* the RIP and firewall parts of finalizeCurrentNodeCandidate.
@@ -306,17 +406,12 @@ function registerSystemConfigurations(
}
const routes: ManualRoute[] = [];
for (const row of source.system.manualRoutes ?? []) {
const [destinationNetwork, subnetMask, gateway, interfaceIPAddress] = row;
if (
row.length !== 4 ||
destinationNetwork == null ||
subnetMask == null ||
gateway == null ||
interfaceIPAddress == null
) {
const cells = fourCells(row);
if (cells === undefined) {
warnings.push(`${where}: a manual route without four entries, left out`);
continue;
}
const [destinationNetwork, subnetMask, gateway, interfaceIPAddress] = cells;
routes.push({ destinationNetwork, subnetMask, gateway, interfaceIPAddress });
}
if (routes.length > 0) system.manualRoutesByNodeId.set(node.id, routes);
@@ -340,10 +435,10 @@ function firewallConfigurationOf(firewall: FlsFirewall, where: string, warnings:
const protocol = PROTOCOLS[rule.protocol];
if (protocol === undefined) warnings.push(`${where}: firewall protocol ${rule.protocol} unknown, used TCP`);
return {
sourceIPAddress: rule.srcIP ?? '',
sourceSubnetMask: rule.srcMask ?? '',
destinationIPAddress: rule.destIP ?? '',
destinationSubnetMask: rule.destMask ?? '',
sourceIPAddress: rule.srcIP?.trim() ?? '',
sourceSubnetMask: rule.srcMask?.trim() ?? '',
destinationIPAddress: rule.destIP?.trim() ?? '',
destinationSubnetMask: rule.destMask?.trim() ?? '',
port: rule.port,
protocol: protocol ?? 'tcp',
action: action(rule.action, 'action'),
+87
View File
@@ -80,6 +80,21 @@ function systemOf(project: ImportedProject, nodeId: Id) {
}
const rip = project.ripEnabledByNodeId.get(nodeId);
if (rip !== undefined) result.ripEnabled = rip;
const dhcpClient = project.dhcpClientConfigurations.get(nodeId);
if (dhcpClient !== undefined) result.dhcpClient = dhcpClient.isEnabled;
const dhcpServer = project.dhcpServerConfigurations.get(nodeId);
if (dhcpServer !== undefined) {
result.dhcpServer = {
...dhcpServer,
staticAssignments: dhcpServer.staticAssignments.map((entry) => [entry.macAddress, entry.ipAddress]),
};
}
const forwarding = project.portForwardingRowsByNodeId.get(nodeId);
if (forwarding !== undefined) {
result.portForwarding = forwarding.map((row) => [row.protocolValue, row.publicPortValue, row.lanIPAddress, row.lanPortValue]);
}
const remoteLink = project.remoteLinkConfigurations.get(nodeId);
if (remoteLink !== undefined) result.remoteLink = remoteLink;
const firewall = project.firewallConfigurationsByNodeId.get(nodeId);
if (firewall !== undefined && !deepEqual(firewall, firewallConfiguration())) {
result.firewall = {
@@ -462,6 +477,78 @@ describe('importProject rules', () => {
expect(project.hostWirelessConfigurations.has(pc!)).toBe(false);
});
it('takes DHCP settings for hosts and home routers, trimmed, and only when they are not the defaults', () => {
const defaults = { isActive: false, lowerBound: '0.0.0.0', upperBound: '0.0.0.0', gateway: '0.0.0.0', dnsServer: '0.0.0.0', useOwnSettings: false, staticAssignedAddresses: [] };
const project = importProject(
topology({
nodes: [
flsNode('Rechner', {
interfaces: [nic('10.0.0.1')],
system: {
dhcpClient: false,
dhcpServer: {
...defaults,
isActive: true,
lowerBound: ' 10.0.0.100 ',
upperBound: '10.0.0.150',
staticAssignedAddresses: [{ mac: '0A:00:00:00:00:01', ip: '10.0.0.101' }],
},
},
}),
flsNode('Notebook', { interfaces: [nic('10.0.0.2')], system: { dhcpClient: true, dhcpServer: defaults } }),
flsNode('Vermittlungsrechner', {
interfaces: [nic('10.0.0.3')],
system: { dhcpClient: true, dhcpServer: { ...defaults, isActive: true } },
}),
],
}),
counter(),
);
const [pc, notebook, router] = project.graph.nodes.map((node) => node.id);
expect(project.dhcpServerConfigurations.get(pc!)).toMatchObject({
isActive: true,
lowerBoundIPAddress: '10.0.0.100',
upperBoundIPAddress: '10.0.0.150',
staticAssignments: [{ macAddress: '0A:00:00:00:00:01', ipAddress: '10.0.0.101' }],
});
expect([...project.dhcpServerConfigurations.keys()]).toEqual([pc]);
expect([...project.dhcpClientConfigurations]).toEqual([[notebook, { isEnabled: true }]]);
expect(project.warnings).toEqual([
"node 'Vermittlungsrechner': DHCP client on a router, ignored",
"node 'Vermittlungsrechner': DHCP server settings on a router, ignored",
]);
expect(router).toBeDefined();
});
it('takes port forwarding rows of a home router and pairs modems by port', () => {
const project = importProject(
topology({
nodes: [
flsNode('Gateway', {
interfaces: [nic('42.0.0.10'), nic('192.168.0.1')],
system: { staticNAT: [[' TCP', '80', '192.168.0.10', '8080 '], ['UDP', null, '192.168.0.11', '53']] },
}),
flsNode('Modem', { portCount: 1, system: { modem: { mode: 1, port: 4242, ipAddress: 'localhost' } } }),
flsNode('Modem', { portCount: 1, system: { modem: { mode: 7, port: 70_000, ipAddress: 'localhost' } } }),
],
}),
counter(),
);
const [gateway, server, broken] = project.graph.nodes.map((node) => node.id);
expect(project.portForwardingRowsByNodeId.get(gateway!)).toEqual([
{ protocolValue: 'TCP', publicPortValue: '80', lanIPAddress: '192.168.0.10', lanPortValue: '8080' },
]);
expect(project.remoteLinkConfigurations.get(server!)).toEqual({ pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true });
expect(project.remoteLinkConfigurations.get(broken!)?.pairIdentifier).toBe('java-modem-port-12345');
expect(project.warnings).toEqual([
"node 'Gateway': a port forwarding row without four entries, left out",
"node 'Modem': modem port 70000 is invalid, used 12345",
"node 'Modem': modem mode 7 unknown, treated as client",
]);
});
it('leaves out a cable to itself and one to a port that already has a cable', () => {
const project = importProject(
topology({