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
+3 -3
View File
@@ -12,7 +12,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/model/node.ts` | `Model/TopologyNode.swift` | übertragen; Standard-Ports und -Namen wie Java; ein gegebener Name bleibt, auch leer (Java lässt Router unbenannt) | | `src/model/node.ts` | `Model/TopologyNode.swift` | übertragen; Standard-Ports und -Namen wie Java; ein gegebener Name bleibt, auch leer (Java lässt Router unbenannt) |
| `src/model/link.ts`, `src/model/graph.ts` | `Model/TopologyLink.swift`, `Model/TopologyGraph.swift` | übertragen, bisher nur die Daten (Abfragen folgen mit dem Editor) | | `src/model/link.ts`, `src/model/graph.ts` | `Model/TopologyLink.swift`, `Model/TopologyGraph.swift` | übertragen, bisher nur die Daten (Abfragen folgen mit dem Editor) |
| `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft | | `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft |
| `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, Firewall) | übertragen; Java-Beschriftungen der Oberfläche weggelassen | | `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, Firewall) | übertragen; Java-Beschriftungen der Oberfläche weggelassen |
| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, UDP, TCP, DHCP, DNS, RIP, Remote Link, WLAN | | `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, UDP, TCP, DHCP, DNS, RIP, Remote Link, WLAN |
| `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen | | `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen |
| `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | | `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` |
@@ -33,8 +33,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen | | `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen |
| `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | | `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene |
| `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen | | `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen |
| `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard; gegen Swifts importiertes Modell auf 13 Dateien geprüft | | `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen; gegen Swifts importiertes Modell auf 13 Dateien geprüft |
| `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, zwei begründete Abweichungen), Vollständigkeit aller 23, Ping über den Router im Kern | | `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, zwei begründete Abweichungen), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) |
| `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht); gegen Java geprüft | | `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht); gegen Java geprüft |
| `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen | | `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen |
| `src/topology.ts` | Java `SzenarioVerwaltung.netzwerkLaden`, Konstruktoren und Setter der Geräte; Swift `Persistence/TopologyProjectStore.swift` (Parser) als Vergleich | neu nach Java statt Übertragung: Geräteart aus der Klasse statt aus der Beschriftung, Geräte ohne Koordinaten bei 0,0 statt verworfen, Werte durch Javas Setter | | `src/topology.ts` | Java `SzenarioVerwaltung.netzwerkLaden`, Konstruktoren und Setter der Geräte; Swift `Persistence/TopologyProjectStore.swift` (Parser) als Vergleich | neu nach Java statt Übertragung: Geräteart aus der Klasse statt aus der Beschriftung, Geräte ohne Koordinaten bei 0,0 statt verworfen, Werte durch Javas Setter |
+917 -183
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -84,6 +84,12 @@ export function defaultSwitchConfiguration(nodeId: Id): SwitchConfiguration {
return switchConfiguration(compact.slice(0, 6)); return switchConfiguration(compact.slice(0, 6));
} }
/** A PC or notebook joining a switch's wireless network (Java Betriebssystem.ssid). */
export interface HostWirelessConfiguration {
readonly isEnabled: boolean;
readonly ssid: string;
}
// ── Firewall (Java filius.software.firewall.Firewall, FirewallRule) ───────── // ── Firewall (Java filius.software.firewall.Firewall, FirewallRule) ─────────
export const FIREWALL_PROTOCOL = { all: -1, icmp: 1, tcp: 6, udp: 17 } as const; export const FIREWALL_PROTOCOL = { all: -1, icmp: 1, tcp: 6, udp: 17 } as const;
+131 -2
View File
@@ -15,11 +15,16 @@
// and put a default in place of an empty one); // and put a default in place of an empty one);
// - an address Java's setter rejected (null) becomes Swift's default, as // - an address Java's setter rejected (null) becomes Swift's default, as
// Swift does for an empty one; Swift would keep invalid text verbatim. // Swift does for an empty one; Swift would keep invalid text verbatim.
// Not here yet: switch and wireless settings, routes, firewall, DHCP, remote // - a router's default gateway (its first interface's, as in Java) — Swift
// links and everything else under a node's system software. // 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 { import {
canonicalMacAddress, canonicalMacAddress,
defaultSwitchConfiguration,
deterministicDocumentationUuid, deterministicDocumentationUuid,
documentationColor, documentationColor,
documentationItem, documentationItem,
@@ -30,6 +35,13 @@ import {
DOCUMENTATION_BLACK, DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW, DOCUMENTATION_PALE_YELLOW,
type DeviceConfiguration, type DeviceConfiguration,
type FirewallAction,
type FirewallConfiguration,
type FirewallProtocol,
type FirewallRule,
type HostWirelessConfiguration,
type ManualRoute,
type SwitchConfiguration,
type DocumentationItem, type DocumentationItem,
type HostLabelPolicy, type HostLabelPolicy,
type Id, type Id,
@@ -41,6 +53,7 @@ import {
type TopologyLink, type TopologyLink,
type TopologyNode, type TopologyNode,
} from '@mdg-filius/core'; } from '@mdg-filius/core';
import type { FlsFirewall } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js'; import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
export interface ImportedProject { export interface ImportedProject {
@@ -48,6 +61,12 @@ export interface ImportedProject {
graph: TopologyGraph; graph: TopologyGraph;
deviceConfigurations: Map<Id, DeviceConfiguration>; deviceConfigurations: Map<Id, DeviceConfiguration>;
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>; 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. */ /** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[]; documentationItems: DocumentationItem[];
/** The topology's warnings, then this step's. */ /** 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 deviceConfigurations = new Map<Id, DeviceConfiguration>();
const interfaceConfigurations = new Map<InterfaceKey, InterfaceConfiguration>(); 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 nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings); const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations); registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
registerSystemConfigurations(source, node, system, warnings);
return node; return node;
}); });
@@ -122,6 +149,7 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
graph: { nodes, links }, graph: { nodes, links },
deviceConfigurations, deviceConfigurations,
interfaceConfigurations, interfaceConfigurations,
...system,
documentationItems: inDeterministicRenderOrder(documentationItems), documentationItems: inDeterministicRenderOrder(documentationItems),
warnings, warnings,
}; };
@@ -218,6 +246,21 @@ function registerConfigurations(
subnetMask: orDefault(nic?.mask ?? null, wan ? '255.0.0.0' : '255.255.255.0'), 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]; const wan = source.interfaces[0];
if (node.kind === 'gateway' && wan !== undefined) { if (node.kind === 'gateway' && wan !== undefined) {
const wanConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id)); 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 * Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
* inside the editor's world; the id from the item's values and its order. * inside the editor's world; the id from the item's values and its order.
+171 -4
View File
@@ -9,7 +9,11 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
defaultDisplayName, defaultDisplayName,
defaultSwitchConfiguration,
firewallConfiguration,
FIREWALL_PROTOCOL,
interfaceKey, interfaceKey,
deepEqual,
NetworkRuntimeEngine, NetworkRuntimeEngine,
portSnapshot, portSnapshot,
topologySnapshot, topologySnapshot,
@@ -54,6 +58,50 @@ function importFile(file: string): ImportedProject {
return importProject(readTopology(values), counter()); 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. */ /** The shape describeImportedState (tools/swift-oracle) writes: indices instead of random ids. */
function asSwiftState(project: ImportedProject) { function asSwiftState(project: ImportedProject) {
const { nodes, links } = project.graph; 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 })), ports: node.ports.map((port) => ({ label: port.label, mac: port.importedMacAddress ?? null, occupied: port.isOccupied })),
device: project.deviceConfigurations.get(node.id) ?? null, device: project.deviceConfigurations.get(node.id) ?? null,
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.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) => [ links: links.map((link) => [
nodeIndex.get(link.sourceNodeId), nodeIndex.get(link.sourceNodeId),
@@ -175,10 +224,8 @@ describe('corpus: every file imports completely', () => {
} }
}); });
describe('an imported file runs in the simulation core', () => { /** The core started on an imported project (the editor will derive this snapshot from its state). */
it('pings across the router from the notebook with a gateway, not from the one without', () => { function startedEngine(project: ImportedProject): NetworkRuntimeEngine {
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); const engine = new NetworkRuntimeEngine(14);
engine.handle({ engine.handle({
type: 'start', type: 'start',
@@ -191,15 +238,47 @@ describe('an imported file runs in the simulation core', () => {
links: project.graph.links, links: project.graph.links,
deviceConfigurations: project.deviceConfigurations, deviceConfigurations: project.deviceConfigurations,
interfaceConfigurations: project.interfaceConfigurations, interfaceConfigurations: project.interfaceConfigurations,
manualRoutesByNodeId: project.manualRoutesByNodeId,
ripEnabledByNodeId: project.ripEnabledByNodeId,
firewallConfigurationsByNodeId: project.firewallConfigurationsByNodeId,
switchConfigurationsByNodeId: project.switchConfigurations,
}), }),
seed: 14, seed: 14,
initialTimeMilliseconds: 0, 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 = startedEngine(project);
expect(engine.sendIcmpEcho(byName('Notebook 1.10'), '141.99.2.10').type).toBe('delivered'); 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.2.10')).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.1.10').type).toBe('delivered'); 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 ───────────────────────────────────── // ── 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 } }); 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', () => { it('leaves out a cable to itself and one to a port that already has a cable', () => {
const project = importProject( const project = importProject(
topology({ topology({
@@ -323,6 +323,7 @@ func describeImportedState(_ state: TopologyEditorState) -> JSON {
}, },
"device": device, "device": device,
"interfaces": interfaces, "interfaces": interfaces,
"system": describeSystem(state, node),
] ]
} }
let links: [[Int]] = state.graph.links.map { link in let links: [[Int]] = state.graph.links.map { link in
@@ -347,6 +348,40 @@ func describeImportedState(_ state: TopologyEditorState) -> JSON {
return ["nodes": nodes, "links": links, "documentation": documentation] return ["nodes": nodes, "links": links, "documentation": documentation]
} }
/// The system software settings of a node in the imported state. A switch
/// SSID Swift derives from the (random) node id is written as a marker.
func describeSystem(_ state: TopologyEditorState, _ node: TopologyNode) -> JSON {
var json: JSON = [:]
if let configuration = state.switchConfigurationsByNodeID[node.id] {
let derived = TopologySwitchConfiguration.defaultConfiguration(nodeID: node.id).ssid
json["switch"] = [
"ssid": configuration.ssid == derived ? "<from node id>" : configuration.ssid,
"retentionTimeMilliseconds": Int(configuration.retentionTimeMilliseconds),
] as JSON
}
if let wireless = state.hostWirelessConfigurationsByNodeID[node.id] {
json["hostWireless"] = ["isEnabled": wireless.isEnabled, "ssid": wireless.ssid] as JSON
}
if let routes = state.runtimeManualRoutesByNodeID[node.id] {
json["manualRoutes"] = routes.map { [$0.destinationNetwork, $0.subnetMask, $0.gateway, $0.interfaceIPAddress] }
}
if let rip = state.runtimeRIPEnabledByNodeID[node.id] { json["ripEnabled"] = rip }
if let firewall = state.runtimeFirewallConfigurationsByNodeID[node.id] {
json["firewall"] = [
"isActive": firewall.isActive,
"defaultPolicy": firewall.defaultPolicy.rawValue,
"dropICMP": firewall.dropICMP,
"filterSYNSegmentsOnly": firewall.filterSYNSegmentsOnly,
"filterUDP": firewall.filterUDP,
"rules": firewall.rules.map { rule -> [Any] in
[rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask,
rule.port, rule.protocolType.rawValue, rule.action.rawValue]
},
] as JSON
}
return json
}
// MARK: - Archive path folding // MARK: - Archive path folding
struct PathCase: Decodable { let name: String; let first: String; let second: String } struct PathCase: Decodable { let name: String; let first: String; let second: String }