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
+183 -16
View File
@@ -9,7 +9,11 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
defaultDisplayName,
defaultSwitchConfiguration,
firewallConfiguration,
FIREWALL_PROTOCOL,
interfaceKey,
deepEqual,
NetworkRuntimeEngine,
portSnapshot,
topologySnapshot,
@@ -54,6 +58,50 @@ function importFile(file: string): ImportedProject {
return importProject(readTopology(values), counter());
}
/**
* describeSystem's shape. A firewall as the core has it without one
* (inactive, drop, no rules) is left out: Swift keeps a configuration only
* when the file sets a field, and the core treats both alike.
*/
function systemOf(project: ImportedProject, nodeId: Id) {
const result: Record<string, unknown> = {};
const switchConfiguration = project.switchConfigurations.get(nodeId);
if (switchConfiguration !== undefined) {
result.switch = {
ssid: switchConfiguration.ssid === defaultSwitchConfiguration(nodeId).ssid ? '<from node id>' : switchConfiguration.ssid,
retentionTimeMilliseconds: switchConfiguration.retentionTimeMilliseconds,
};
}
const wireless = project.hostWirelessConfigurations.get(nodeId);
if (wireless !== undefined) result.hostWireless = wireless;
const routes = project.manualRoutesByNodeId.get(nodeId);
if (routes !== undefined) {
result.manualRoutes = routes.map((route) => [route.destinationNetwork, route.subnetMask, route.gateway, route.interfaceIPAddress]);
}
const rip = project.ripEnabledByNodeId.get(nodeId);
if (rip !== undefined) result.ripEnabled = rip;
const firewall = project.firewallConfigurationsByNodeId.get(nodeId);
if (firewall !== undefined && !deepEqual(firewall, firewallConfiguration())) {
result.firewall = {
isActive: firewall.isActive,
defaultPolicy: firewall.defaultPolicy === 'accept' ? 1 : 0,
dropICMP: firewall.dropIcmp,
filterSYNSegmentsOnly: firewall.filterSynSegmentsOnly,
filterUDP: firewall.filterUdp,
rules: firewall.rules.map((rule) => [
rule.sourceIPAddress,
rule.sourceSubnetMask,
rule.destinationIPAddress,
rule.destinationSubnetMask,
rule.port,
FIREWALL_PROTOCOL[rule.protocol],
rule.action === 'accept' ? 1 : 0,
]),
};
}
return result;
}
/** The shape describeImportedState (tools/swift-oracle) writes: indices instead of random ids. */
function asSwiftState(project: ImportedProject) {
const { nodes, links } = project.graph;
@@ -68,6 +116,7 @@ function asSwiftState(project: ImportedProject) {
ports: node.ports.map((port) => ({ label: port.label, mac: port.importedMacAddress ?? null, occupied: port.isOccupied })),
device: project.deviceConfigurations.get(node.id) ?? null,
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null),
system: systemOf(project, node.id),
})),
links: links.map((link) => [
nodeIndex.get(link.sourceNodeId),
@@ -175,31 +224,61 @@ describe('corpus: every file imports completely', () => {
}
});
/** The core started on an imported project (the editor will derive this snapshot from its state). */
function startedEngine(project: ImportedProject): NetworkRuntimeEngine {
const engine = new NetworkRuntimeEngine(14);
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: project.graph.nodes.map((node) => ({
id: node.id,
kind: node.kind,
ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.importedMacAddress)),
})),
links: project.graph.links,
deviceConfigurations: project.deviceConfigurations,
interfaceConfigurations: project.interfaceConfigurations,
manualRoutesByNodeId: project.manualRoutesByNodeId,
ripEnabledByNodeId: project.ripEnabledByNodeId,
firewallConfigurationsByNodeId: project.firewallConfigurationsByNodeId,
switchConfigurationsByNodeId: project.switchConfigurations,
}),
seed: 14,
initialTimeMilliseconds: 0,
});
return engine;
}
describe('an imported file runs in the simulation core', () => {
it('pings across the router from the notebook with a gateway, not from the one without', () => {
const project = importFile(join(corpusRoot, 'java-examples/zwei_rechnernetze_komplett.fls'));
const byName = (displayName: string) => project.graph.nodes.find((node) => node.displayName === displayName)!.id;
const engine = new NetworkRuntimeEngine(14);
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: project.graph.nodes.map((node) => ({
id: node.id,
kind: node.kind,
ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.importedMacAddress)),
})),
links: project.graph.links,
deviceConfigurations: project.deviceConfigurations,
interfaceConfigurations: project.interfaceConfigurations,
}),
seed: 14,
initialTimeMilliseconds: 0,
});
const engine = startedEngine(project);
expect(engine.sendIcmpEcho(byName('Notebook 1.10'), '141.99.2.10').type).toBe('delivered');
expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.2.10')).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.1.10').type).toBe('delivered');
});
it('pings across three routers on manual routes and the routers\' default gateways', () => {
// Router 1 (141.99.1/2) sends everything else to 2.2; router 2 has manual
// routes to 1.0 and 5.0; router 3 (141.99.4/5) sends everything else to
// 4.1. The way back needs router 3's default gateway — which Swift drops.
const project = importFile(join(corpusRoot, 'java-examples/internetwork_komplett.fls'));
const notebook = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!.id;
const routers = project.graph.nodes.filter((node) => node.kind === 'router').map((node) => node.id);
expect(routers.map((router) => project.deviceConfigurations.get(router)?.defaultGateway)).toEqual([
'141.99.2.2',
'141.99.4.2',
'141.99.4.1',
]);
expect(startedEngine(project).sendIcmpEcho(notebook, '141.99.5.10').type).toBe('delivered');
const withoutRouterGateways = { ...project, deviceConfigurations: new Map(project.deviceConfigurations) };
for (const router of routers) withoutRouterGateways.deviceConfigurations.delete(router);
expect(startedEngine(withoutRouterGateways).sendIcmpEcho(notebook, '141.99.5.10').type).not.toBe('delivered');
});
});
// ── Rules the corpus does not exercise ─────────────────────────────────────
@@ -295,6 +374,94 @@ describe('importProject rules', () => {
expect(project.documentationItems[0]).toMatchObject({ isBold: true, fontSize: 18, color: { alpha: 0.2 } });
});
it("maps a router's firewall, RIP and routes, and says what it cannot map", () => {
const project = importProject(
topology({
nodes: [
flsNode('Vermittlungsrechner', {
interfaces: [nic('10.0.0.1')],
system: {
ripEnabled: true,
manualRoutes: [
['10.1.0.0', '255.255.0.0', '10.0.0.2', '10.0.0.1'],
['10.2.0.0', '255.255.0.0', null, '10.0.0.1'],
],
firewall: {
activated: true,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
rules: [
{ srcIP: '10.0.0.0', srcMask: '255.0.0.0', destIP: null, destMask: '', port: 80, protocol: -1, action: 1 },
{ srcIP: '', srcMask: '', destIP: '', destMask: '', port: -1, protocol: 99, action: 0 },
],
},
},
}),
],
}),
counter(),
);
const router = project.graph.nodes[0]!.id;
expect(project.ripEnabledByNodeId.get(router)).toBe(true);
expect(project.manualRoutesByNodeId.get(router)).toEqual([
{ destinationNetwork: '10.1.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' },
]);
expect(project.firewallConfigurationsByNodeId.get(router)).toEqual({
isActive: true,
defaultPolicy: 'accept',
dropIcmp: true,
filterSynSegmentsOnly: false,
filterUdp: false,
rules: [
{
sourceIPAddress: '10.0.0.0',
sourceSubnetMask: '255.0.0.0',
destinationIPAddress: '',
destinationSubnetMask: '',
port: 80,
protocol: 'all',
action: 'accept',
},
{
sourceIPAddress: '',
sourceSubnetMask: '',
destinationIPAddress: '',
destinationSubnetMask: '',
port: -1,
protocol: 'tcp',
action: 'drop',
},
],
});
expect(project.warnings).toEqual([
"node 'Vermittlungsrechner': a manual route without four entries, left out",
"node 'Vermittlungsrechner': firewall protocol 99 unknown, used TCP",
]);
});
it("gives a switch without SSID one from its id, and joins a host to a wireless network only with an SSID", () => {
const project = importProject(
topology({
nodes: [
flsNode('Switch', { portCount: 24, system: { ssid: undefined, retentionTime: 2000 } }),
flsNode('Switch', { portCount: 24, system: { ssid: ' MyWiFi ', retentionTime: 300_000 } }),
flsNode('Notebook', { interfaces: [nic('10.0.0.1')], system: { ssid: 'MyWiFi', manualRoutes: [] } }),
flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { ssid: ' ', manualRoutes: [] } }),
],
}),
counter(),
);
const [first, second, notebook, pc] = project.graph.nodes.map((node) => node.id);
expect(project.switchConfigurations.get(first!)).toEqual({ ssid: defaultSwitchConfiguration(first!).ssid, retentionTimeMilliseconds: 2000 });
expect(project.switchConfigurations.get(second!)).toEqual({ ssid: 'MyWiFi', retentionTimeMilliseconds: 300_000 });
expect(project.hostWirelessConfigurations.get(notebook!)).toEqual({ isEnabled: true, ssid: 'MyWiFi' });
expect(project.hostWirelessConfigurations.has(pc!)).toBe(false);
});
it('leaves out a cable to itself and one to a port that already has a cable', () => {
const project = importProject(
topology({