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