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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user