feat(fls): import switch, wireless, routes, RIP and firewall settings

importProject carries the system software settings into the model the
core runs: switch SSID and learning time (an SSID Java would roll comes
from the node id, as in Swift), a host's wireless network, manual routes,
RIP and the firewall of every router and home router. The Swift oracle
writes these settings too; they match on the 13 files Swift reads
completely.

A router's default gateway — its first interface's, which Java routes to
(Weiterleitungstabelle.holeTabelle) — is imported into the router's
device configuration. Swift's model drops it; in internetwork_komplett
the way back from 141.99.5.10 depends on it. A test pings across three
routers in the core, with and without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:00:42 +00:00
co-authored by Claude Opus 5.5
parent 9a389810f9
commit ef3bb94cb9
6 changed files with 1277 additions and 206 deletions
+131 -2
View File
@@ -15,11 +15,16 @@
// and put a default in place of an empty one);
// - an address Java's setter rejected (null) becomes Swift's default, as
// Swift does for an empty one; Swift would keep invalid text verbatim.
// Not here yet: switch and wireless settings, routes, firewall, DHCP, remote
// links and everything else under a node's system software.
// - a router's default gateway (its first interface's, as in Java) — Swift
// drops it, and routed examples lose their way back;
// - 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.
import {
canonicalMacAddress,
defaultSwitchConfiguration,
deterministicDocumentationUuid,
documentationColor,
documentationItem,
@@ -30,6 +35,13 @@ import {
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
type DeviceConfiguration,
type FirewallAction,
type FirewallConfiguration,
type FirewallProtocol,
type FirewallRule,
type HostWirelessConfiguration,
type ManualRoute,
type SwitchConfiguration,
type DocumentationItem,
type HostLabelPolicy,
type Id,
@@ -41,6 +53,7 @@ import {
type TopologyLink,
type TopologyNode,
} from '@mdg-filius/core';
import type { FlsFirewall } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
export interface ImportedProject {
@@ -48,6 +61,12 @@ export interface ImportedProject {
graph: TopologyGraph;
deviceConfigurations: Map<Id, DeviceConfiguration>;
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>;
switchConfigurations: Map<Id, SwitchConfiguration>;
hostWirelessConfigurations: Map<Id, HostWirelessConfiguration>;
manualRoutesByNodeId: Map<Id, ManualRoute[]>;
/** Only routers with RIP switched on. */
ripEnabledByNodeId: Map<Id, boolean>;
firewallConfigurationsByNodeId: Map<Id, FirewallConfiguration>;
/** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[];
/** The topology's warnings, then this step's. */
@@ -73,9 +92,17 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
const deviceConfigurations = new Map<Id, DeviceConfiguration>();
const interfaceConfigurations = new Map<InterfaceKey, InterfaceConfiguration>();
const system: SystemConfigurations = {
switchConfigurations: new Map(),
hostWirelessConfigurations: new Map(),
manualRoutesByNodeId: new Map(),
ripEnabledByNodeId: new Map(),
firewallConfigurationsByNodeId: new Map(),
};
const nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
registerSystemConfigurations(source, node, system, warnings);
return node;
});
@@ -122,6 +149,7 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
graph: { nodes, links },
deviceConfigurations,
interfaceConfigurations,
...system,
documentationItems: inDeterministicRenderOrder(documentationItems),
warnings,
};
@@ -218,6 +246,21 @@ function registerConfigurations(
subnetMask: orDefault(nic?.mask ?? null, wan ? '255.0.0.0' : '255.255.255.0'),
});
});
// Java routes a router's traffic without a better route to the gateway of
// its first interface (Weiterleitungstabelle.holeTabelle, getStandardGateway).
// Swift's model has no router gateway at all; the core reads it from the
// router's device configuration, which is set only when there is one.
const first = source.interfaces[0];
const routerGateway = first?.gateway?.trim() ?? '';
if (node.kind === 'router' && first !== undefined && routerGateway !== '') {
const firstConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
deviceConfigurations.set(node.id, {
ipAddress: firstConfiguration?.ipAddress ?? '',
subnetMask: firstConfiguration?.subnetMask ?? '',
defaultGateway: routerGateway,
dnsServer: first.dns?.trim() ?? '',
});
}
const wan = source.interfaces[0];
if (node.kind === 'gateway' && wan !== undefined) {
const wanConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
@@ -230,6 +273,92 @@ function registerConfigurations(
}
}
type SystemConfigurations = Pick<
ImportedProject,
| 'switchConfigurations'
| 'hostWirelessConfigurations'
| 'manualRoutesByNodeId'
| 'ripEnabledByNodeId'
| 'firewallConfigurationsByNodeId'
>;
/**
* Swift's registerSwitchAndWirelessConfigurations, registerManualRoutes and
* the RIP and firewall parts of finalizeCurrentNodeCandidate.
*/
function registerSystemConfigurations(
source: FlsNode,
node: TopologyNode,
system: SystemConfigurations,
warnings: string[],
): void {
const where = `node '${node.displayName}'`;
const ssid = source.system.ssid?.trim() ?? '';
if (node.kind === 'networkSwitch') {
// Without an SSID in the file, Java rolls one; Swift derives it from the node id.
system.switchConfigurations.set(node.id, {
ssid: ssid === '' ? defaultSwitchConfiguration(node.id).ssid : ssid,
retentionTimeMilliseconds: source.system.retentionTime ?? 300_000,
});
}
if ((node.kind === 'pc' || node.kind === 'notebook') && ssid !== '') {
system.hostWirelessConfigurations.set(node.id, { isEnabled: true, ssid });
}
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
) {
warnings.push(`${where}: a manual route without four entries, left out`);
continue;
}
routes.push({ destinationNetwork, subnetMask, gateway, interfaceIPAddress });
}
if (routes.length > 0) system.manualRoutesByNodeId.set(node.id, routes);
if (node.kind === 'router' && source.system.ripEnabled === true) system.ripEnabledByNodeId.set(node.id, true);
if ((node.kind === 'router' || node.kind === 'gateway') && source.system.firewall !== undefined) {
system.firewallConfigurationsByNodeId.set(node.id, firewallConfigurationOf(source.system.firewall, where, warnings));
}
}
const PROTOCOLS: Readonly<Record<number, FirewallProtocol>> = { [-1]: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp' };
const ACTIONS: Readonly<Record<number, FirewallAction>> = { 0: 'drop', 1: 'accept' };
/** Java's numbers as the core's names; an unknown number keeps Swift's default (TCP, drop). */
function firewallConfigurationOf(firewall: FlsFirewall, where: string, warnings: string[]): FirewallConfiguration {
const action = (value: number, what: string): FirewallAction => {
const known = ACTIONS[value];
if (known === undefined) warnings.push(`${where}: firewall ${what} ${value} unknown, used drop`);
return known ?? 'drop';
};
const rules: FirewallRule[] = firewall.rules.map((rule) => {
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 ?? '',
port: rule.port,
protocol: protocol ?? 'tcp',
action: action(rule.action, 'action'),
};
});
return {
isActive: firewall.activated,
defaultPolicy: action(firewall.defaultPolicy, 'default policy'),
dropIcmp: firewall.dropICMP,
filterSynSegmentsOnly: firewall.filterSYNSegmentsOnly,
filterUdp: firewall.filterUdp,
rules,
};
}
/**
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
* inside the editor's world; the id from the item's values and its order.