New oracle DocuRun on Java's JDocuElement. Italic is kept (the model knew only bold), an item keeps its file's size and font size (Swift's 32 x 24 and 8-72 changed Java's files), an item without a colour is Java's light grey. Java's new items, colour lists, font sizes and styles, the drag's smallest sizes and Java's stacking; document commands for the editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
792 lines
33 KiB
TypeScript
792 lines
33 KiB
TypeScript
// From Java's view of a .fls file (topology.ts) to the editor model the
|
|
// simulation core runs: nodes with kinds and ports, links between port ids,
|
|
// the IP configurations, documentation items.
|
|
//
|
|
// Ported from filius-ipad (GPLv2/GPLv3):
|
|
// Persistence/TopologyProjectStore.swift — importFiliusConfigurationXML,
|
|
// finalizeCurrentNodeCandidate, importedPorts, registerRuntimeConfigurations,
|
|
// resolveCableCandidates, finalizeCurrentDocumentationCandidate.
|
|
// Checked against the Swift import (corpus.swift.json, `state`) on every
|
|
// corpus file the Swift reader reads completely.
|
|
//
|
|
// Departures from Swift, where the input is Java's rather than Swift's own:
|
|
// - port counts of switches and routers are Java's (see importedPorts);
|
|
// - a node's name is Java's, kept as it is — also empty (Swift would trim it
|
|
// 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.
|
|
// - 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).
|
|
// - configuration text is trimmed, as Swift trims every value it reads;
|
|
// names stay as Java has them;
|
|
// - a host's wireless: enabled when its card is wireless, as in Java, which
|
|
// joins a network only then — also without an SSID; Swift enables it for
|
|
// every SSID;
|
|
// - a Java modem becomes an in-project remote link pair keyed by its port,
|
|
// as in Swift — two FILIUS instances on different hosts are not joined.
|
|
// - a PC or notebook whose file has no file system gets an empty one, as in
|
|
// Java — Swift gives it the demo files of a new device;
|
|
// - a file system's root is Java's root, whatever its user object says.
|
|
// - every installed application of FILIUS is kept: Swift drops the command
|
|
// prompt, text editor, file explorer, image viewer, echo server and
|
|
// generic client, and their settings (port, target) have a place here;
|
|
// - a web server's files stay in /webserver (its document root), where Swift
|
|
// moves them to /www, and it keeps Java's virtual hosts switch (Swift sets
|
|
// it off); and no program state is written into a host's files
|
|
// (Swift keeps Gnutella and e-mail state there as hidden JSON files).
|
|
// - e-mail: see importEmail.ts;
|
|
// - a DNS server's records are what Java's server answers from /dns/hosts
|
|
// (see dnsHosts.ts); Swift reads the file its own way — tabs, comments,
|
|
// "IN", names with a leading digit — and drops Java's duplicates.
|
|
|
|
import {
|
|
fileContentOfJavaType,
|
|
canonicalMacAddress,
|
|
forwardsIPv4ByDefault,
|
|
deepEqual,
|
|
defaultSwitchConfiguration,
|
|
dhcpServerConfiguration,
|
|
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
|
|
DEFAULT_WEB_SERVER_PORT,
|
|
deterministicDocumentationUuid,
|
|
documentationColor,
|
|
documentationItem,
|
|
inDeterministicRenderOrder,
|
|
interfaceKey,
|
|
isSafeFrame,
|
|
swiftBase64Decode,
|
|
swiftLowercased,
|
|
swiftPathExtension,
|
|
swiftTrim,
|
|
normalizedAbsolutePath,
|
|
VirtualFileSystem,
|
|
VirtualFileSystemError,
|
|
topologyNode,
|
|
DOCUMENTATION_LIGHT_GRAY,
|
|
type DeviceConfiguration,
|
|
type DhcpClientConfiguration,
|
|
type DhcpServerConfiguration,
|
|
type DnsServerConfiguration,
|
|
type EmailClientConfiguration,
|
|
type EmailServerConfiguration,
|
|
type EchoServerConfiguration,
|
|
type GnutellaConfiguration,
|
|
type InstallableProgram,
|
|
type SimpleClientConfiguration,
|
|
type WebServerConfiguration,
|
|
type GatewayPortForwardingRow,
|
|
type RemoteLinkConfiguration,
|
|
type FirewallAction,
|
|
type FirewallConfiguration,
|
|
type FirewallProtocol,
|
|
type FirewallRule,
|
|
type HostWirelessConfiguration,
|
|
type ManualRoute,
|
|
type SwitchConfiguration,
|
|
type DocumentationItem,
|
|
type HostLabelPolicy,
|
|
type Id,
|
|
type InterfaceConfiguration,
|
|
type InterfaceKey,
|
|
type NodeKind,
|
|
type PortMetadata,
|
|
type TopologyGraph,
|
|
type TopologyLink,
|
|
type TopologyNode,
|
|
type VirtualFileContent,
|
|
} from '@mdg-filius/core';
|
|
import { readFlsArchive } from './archive.js';
|
|
import { dnsRecordsOf, javaHostsFileContent, readDnsHosts } from './dnsHosts.js';
|
|
import { decodeJavaBeans } from './javabeans.js';
|
|
import { emailClientConfiguration, emailServerConfiguration } from './importEmail.js';
|
|
import type { FlsApplicationClass, FlsFileTree, FlsFirewall, FlsOwnApplication } from './systemSoftware.js';
|
|
import { readTopology, type FlsDocumentationItem, type FlsInterface, type FlsNode, type FlsNodeClass, type FlsTopology } from './topology.js';
|
|
|
|
export interface ImportedProject {
|
|
versionLine: string | null;
|
|
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>;
|
|
/** Nodes whose IP forwarding differs from their kind's (forwardsIPv4ByDefault). */
|
|
ipForwardingByNodeId: Map<Id, boolean>;
|
|
firewallConfigurationsByNodeId: Map<Id, FirewallConfiguration>;
|
|
dhcpClientConfigurations: Map<Id, DhcpClientConfiguration>;
|
|
/** Only servers whose settings differ from Java's constructor. */
|
|
dhcpServerConfigurations: Map<Id, DhcpServerConfiguration>;
|
|
portForwardingRowsByNodeId: Map<Id, GatewayPortForwardingRow[]>;
|
|
remoteLinkConfigurations: Map<Id, RemoteLinkConfiguration>;
|
|
/** Every PC and notebook. */
|
|
virtualFileSystems: Map<Id, VirtualFileSystem>;
|
|
installedPrograms: Map<Id, Set<InstallableProgram>>;
|
|
/** Server programs Java had switched on (aktiv). */
|
|
activePrograms: Map<Id, Set<InstallableProgram>>;
|
|
webServerConfigurations: Map<Id, WebServerConfiguration>;
|
|
gnutellaConfigurations: Map<Id, GnutellaConfiguration>;
|
|
dnsServerConfigurations: Map<Id, DnsServerConfiguration>;
|
|
emailClientConfigurations: Map<Id, EmailClientConfiguration>;
|
|
emailServerConfigurations: Map<Id, EmailServerConfiguration>;
|
|
echoServerConfigurations: Map<Id, EchoServerConfiguration>;
|
|
simpleClientConfigurations: Map<Id, SimpleClientConfiguration>;
|
|
/** In render order: rectangles below text, then by order. */
|
|
documentationItems: DocumentationItem[];
|
|
/**
|
|
* The archive's entries besides the configuration — the project's own Java
|
|
* applications (projekt/anwendungen) — carried unchanged to the next save:
|
|
* they cannot run here, but must not be lost.
|
|
*/
|
|
archiveEntries: ReadonlyMap<string, Uint8Array>;
|
|
/**
|
|
* A project's own applications on a PC or notebook, as their beans
|
|
* (beanCapture.ts): they cannot run here, but are written again as the
|
|
* file had them. Their classes are among the archive's entries.
|
|
*/
|
|
ownApplicationsByNodeId: Map<Id, FlsOwnApplication[]>;
|
|
/** The topology's warnings, then this step's. */
|
|
warnings: string[];
|
|
}
|
|
|
|
export const NODE_KIND_OF_CLASS: Readonly<Record<FlsNodeClass, NodeKind>> = {
|
|
Rechner: 'pc',
|
|
Notebook: 'notebook',
|
|
Switch: 'networkSwitch',
|
|
Vermittlungsrechner: 'router',
|
|
Gateway: 'gateway',
|
|
Modem: 'remoteLink',
|
|
};
|
|
|
|
/**
|
|
* The editor model of a topology. `newId` makes node, port and link ids
|
|
* (Swift uses random UUIDs; tests pass a counter). Documentation ids are
|
|
* derived from the item, as in Swift.
|
|
*/
|
|
export function importProject(
|
|
topology: FlsTopology,
|
|
newId: () => Id,
|
|
archiveEntries: ReadonlyMap<string, Uint8Array> = new Map(),
|
|
): ImportedProject {
|
|
const warnings = [...topology.warnings];
|
|
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(),
|
|
ipForwardingByNodeId: new Map(),
|
|
firewallConfigurationsByNodeId: new Map(),
|
|
dhcpClientConfigurations: new Map(),
|
|
dhcpServerConfigurations: new Map(),
|
|
portForwardingRowsByNodeId: new Map(),
|
|
remoteLinkConfigurations: new Map(),
|
|
virtualFileSystems: new Map(),
|
|
installedPrograms: new Map(),
|
|
activePrograms: new Map(),
|
|
webServerConfigurations: new Map(),
|
|
gnutellaConfigurations: new Map(),
|
|
dnsServerConfigurations: new Map(),
|
|
emailClientConfigurations: new Map(),
|
|
emailServerConfigurations: new Map(),
|
|
echoServerConfigurations: new Map(),
|
|
simpleClientConfigurations: new Map(),
|
|
};
|
|
const ownApplicationsByNodeId = new Map<Id, FlsOwnApplication[]>();
|
|
const nodes = topology.nodes.map((source) => {
|
|
const node = importNode(source, newId, warnings);
|
|
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
|
|
registerSystemConfigurations(source, node, system, warnings);
|
|
registerServiceConfigurations(source, node, system, newId, warnings);
|
|
if (node.kind === 'pc' || node.kind === 'notebook') {
|
|
system.virtualFileSystems.set(node.id, importFileSystem(source.system.fileSystem, node.displayName, warnings));
|
|
registerPrograms(source, node, system, warnings);
|
|
const own = source.system.ownApplications ?? [];
|
|
if (own.length > 0) ownApplicationsByNodeId.set(node.id, own);
|
|
}
|
|
return node;
|
|
});
|
|
|
|
const links: TopologyLink[] = [];
|
|
const occupied = new Set<Id>();
|
|
topology.links.forEach((link, index) => {
|
|
const [source, target] = link.ends.map((end) => {
|
|
const node = nodes[end.node];
|
|
const port = node?.ports[end.port];
|
|
return node === undefined || port === undefined ? undefined : { node, port };
|
|
});
|
|
const where = `cable ${index + 1}`;
|
|
if (source === undefined || target === undefined) {
|
|
warnings.push(`${where}: an end is not a port of the imported node, left out`);
|
|
return;
|
|
}
|
|
if (source.node.id === target.node.id) {
|
|
warnings.push(`${where}: connects a node to itself, left out`);
|
|
return;
|
|
}
|
|
if (occupied.has(source.port.id) || occupied.has(target.port.id)) {
|
|
warnings.push(`${where}: a port already has a cable, left out`);
|
|
return;
|
|
}
|
|
occupied.add(source.port.id);
|
|
occupied.add(target.port.id);
|
|
links.push({
|
|
id: newId(),
|
|
sourceNodeId: source.node.id,
|
|
sourcePortId: source.port.id,
|
|
targetNodeId: target.node.id,
|
|
targetPortId: target.port.id,
|
|
});
|
|
});
|
|
|
|
// Swift rejects the whole import when the files exceed the quotas.
|
|
VirtualFileSystem.validateProjectQuotas(system.virtualFileSystems.values());
|
|
|
|
const documentationItems: DocumentationItem[] = [];
|
|
for (const item of topology.documentation) {
|
|
const imported = importDocumentationItem(item, documentationItems.length, warnings);
|
|
if (imported !== undefined) documentationItems.push(imported);
|
|
}
|
|
|
|
return {
|
|
versionLine: topology.versionLine,
|
|
graph: { nodes, links },
|
|
deviceConfigurations,
|
|
interfaceConfigurations,
|
|
...system,
|
|
documentationItems: inDeterministicRenderOrder(documentationItems),
|
|
archiveEntries,
|
|
ownApplicationsByNodeId,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/** A whole .fls: its archive, Java's view of its configuration, the editor model with the archive's other entries. */
|
|
export function openProjectArchive(archive: Uint8Array, newId: () => Id): ImportedProject {
|
|
const contents = readFlsArchive(archive);
|
|
return importProject(readTopology(decodeJavaBeans(contents.configurationXml)), newId, contents.supplementalEntries);
|
|
}
|
|
|
|
const JAVA_MODEM_PAIR = /^java-modem-port-([0-9]{1,5})$/;
|
|
export const DEFAULT_MODEM_PORT = 12_345;
|
|
|
|
/** The pair of Java's modems on a port: two modems on one port belong together. */
|
|
export function modemPair(port: number): string {
|
|
return `java-modem-port-${port}`;
|
|
}
|
|
|
|
/** The port a modem pair's identifier names, 12345 when it names none. */
|
|
export function modemPort(pairIdentifier: string | undefined): number {
|
|
const match = JAVA_MODEM_PAIR.exec(pairIdentifier ?? '');
|
|
const port = match === null ? DEFAULT_MODEM_PORT : Number(match[1]);
|
|
return port >= 1 && port <= 65_535 ? port : DEFAULT_MODEM_PORT;
|
|
}
|
|
|
|
function hostLabelPolicy(node: FlsNode): HostLabelPolicy {
|
|
if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC';
|
|
if (node.useIpAsName) return 'ipAddress';
|
|
if (node.useMacAsName) return 'macAddress';
|
|
return 'manual';
|
|
}
|
|
|
|
/**
|
|
* Swift's importedPorts: the labels of a kind, the MAC addresses of the
|
|
* interfaces. Swift guesses a switch's and a router's port count from the
|
|
* references it saw (at least 24, at least one); Java's list is known here
|
|
* and counts — a file that sets a shorter list has fewer ports in Java too.
|
|
* A Gateway keeps two, since Java's own code needs WAN and LAN; a host and a
|
|
* modem have exactly one, as in Swift.
|
|
*/
|
|
function importedPorts(node: FlsNode, kind: NodeKind, newId: () => Id, warnings: string[]): PortMetadata[] {
|
|
const port = (label: string, nic?: FlsInterface): PortMetadata => {
|
|
const mac = canonicalMacAddress(nic?.mac);
|
|
const id = newId();
|
|
return mac === undefined ? { id, label, isOccupied: false } : { id, label, isOccupied: false, importedMacAddress: mac };
|
|
};
|
|
switch (kind) {
|
|
case 'pc':
|
|
case 'notebook':
|
|
if (node.interfaces.length > 1) {
|
|
warnings.push(`node '${node.name ?? ''}': only the first of ${node.interfaces.length} interfaces is kept`);
|
|
}
|
|
return [port('eth0', node.interfaces[0])];
|
|
case 'networkSwitch':
|
|
return Array.from({ length: node.portCount }, (_, index) => port(`sw${index + 1}`));
|
|
case 'router':
|
|
return Array.from({ length: node.interfaces.length }, (_, index) =>
|
|
port(`rt${index + 1}`, node.interfaces[index]),
|
|
);
|
|
case 'gateway':
|
|
return Array.from({ length: Math.max(2, node.interfaces.length) }, (_, index) =>
|
|
port(index === 0 ? 'wan0' : index === 1 ? 'lan0' : `gw${index}`, node.interfaces[index]),
|
|
);
|
|
case 'remoteLink':
|
|
return [port('remote0')];
|
|
case 'unsupported':
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function importNode(source: FlsNode, newId: () => Id, warnings: string[]): TopologyNode {
|
|
const kind = NODE_KIND_OF_CLASS[source.javaClass];
|
|
const ports = importedPorts(source, kind, newId, warnings);
|
|
return topologyNode({
|
|
id: newId(),
|
|
kind,
|
|
...(source.name === null ? {} : { displayName: source.name }),
|
|
hostLabelPolicy: hostLabelPolicy(source),
|
|
position: { x: source.x, y: source.y },
|
|
ports,
|
|
newId,
|
|
});
|
|
}
|
|
|
|
/** Swift's `value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""`. */
|
|
function trimmedOrEmpty(value: string | null | undefined): string {
|
|
return value == null ? '' : swiftTrim(value);
|
|
}
|
|
|
|
/** Swift's normalizedOrDefault; Java's null (a rejected address) counts as empty. */
|
|
function orDefault(value: string | null, fallback: string): string {
|
|
const trimmed = trimmedOrEmpty(value);
|
|
return trimmed === '' ? fallback : trimmed;
|
|
}
|
|
|
|
/** Swift's registerRuntimeConfigurations. */
|
|
function registerConfigurations(
|
|
source: FlsNode,
|
|
node: TopologyNode,
|
|
deviceConfigurations: Map<Id, DeviceConfiguration>,
|
|
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>,
|
|
): void {
|
|
if (node.kind === 'pc' || node.kind === 'notebook') {
|
|
const nic = source.interfaces[0];
|
|
deviceConfigurations.set(node.id, {
|
|
ipAddress: orDefault(nic?.ip ?? null, '192.168.0.10'),
|
|
subnetMask: orDefault(nic?.mask ?? null, '255.255.255.0'),
|
|
defaultGateway: trimmedOrEmpty(nic?.gateway),
|
|
dnsServer: trimmedOrEmpty(nic?.dns),
|
|
});
|
|
return;
|
|
}
|
|
if (node.kind !== 'router' && node.kind !== 'gateway') return;
|
|
node.ports.forEach((port, index) => {
|
|
const nic = source.interfaces[index];
|
|
const wan = node.kind === 'gateway' && index === 0;
|
|
interfaceConfigurations.set(interfaceKey(node.id, port.id), {
|
|
ipAddress: orDefault(nic?.ip ?? null, wan ? '42.0.0.10' : '192.168.0.10'),
|
|
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 = trimmedOrEmpty(first?.gateway);
|
|
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: trimmedOrEmpty(first.dns),
|
|
});
|
|
}
|
|
const wan = source.interfaces[0];
|
|
if (node.kind === 'gateway' && wan !== undefined) {
|
|
const wanConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
|
|
deviceConfigurations.set(node.id, {
|
|
ipAddress: wanConfiguration?.ipAddress ?? '',
|
|
subnetMask: wanConfiguration?.subnetMask ?? '',
|
|
defaultGateway: trimmedOrEmpty(wan.gateway),
|
|
dnsServer: trimmedOrEmpty(wan.dns),
|
|
});
|
|
}
|
|
}
|
|
|
|
type SystemConfigurations = Pick<
|
|
ImportedProject,
|
|
| 'switchConfigurations'
|
|
| 'hostWirelessConfigurations'
|
|
| 'manualRoutesByNodeId'
|
|
| 'ripEnabledByNodeId'
|
|
| 'ipForwardingByNodeId'
|
|
| 'firewallConfigurationsByNodeId'
|
|
| 'dhcpClientConfigurations'
|
|
| 'dhcpServerConfigurations'
|
|
| 'portForwardingRowsByNodeId'
|
|
| 'remoteLinkConfigurations'
|
|
| 'virtualFileSystems'
|
|
| 'installedPrograms'
|
|
| 'activePrograms'
|
|
| 'webServerConfigurations'
|
|
| 'gnutellaConfigurations'
|
|
| 'dnsServerConfigurations'
|
|
| 'emailClientConfigurations'
|
|
| 'emailServerConfigurations'
|
|
| 'echoServerConfigurations'
|
|
| 'simpleClientConfigurations'
|
|
>;
|
|
|
|
/** Java's application classes as Swift's programs. */
|
|
export const PROGRAM_OF_CLASS: Readonly<Record<FlsApplicationClass, InstallableProgram>> = {
|
|
'filius.software.clientserver.ClientBaustein': 'simpleClient',
|
|
'filius.software.clientserver.ServerBaustein': 'echoServer',
|
|
'filius.software.dateiaustausch.PeerToPeerAnwendung': 'gnutella',
|
|
'filius.software.dns.DNSServer': 'dnsServer',
|
|
'filius.software.email.EmailAnwendung': 'emailClient',
|
|
'filius.software.email.EmailServer': 'emailServer',
|
|
'filius.software.firewall.Firewall': 'firewall',
|
|
'filius.software.lokal.FileExplorer': 'fileExplorer',
|
|
'filius.software.lokal.ImageViewer': 'imageViewer',
|
|
'filius.software.lokal.Terminal': 'commandPrompt',
|
|
'filius.software.lokal.TextEditor': 'textEditor',
|
|
'filius.software.www.WebBrowser': 'webBrowser',
|
|
'filius.software.www.WebServer': 'webServer',
|
|
};
|
|
|
|
/** The web server's document root: where Java keeps its files. */
|
|
export const JAVA_WEB_ROOT = '/webserver';
|
|
|
|
function warnAt(node: TopologyNode, warnings: string[]): (message: string) => void {
|
|
return (message) => warnings.push(`node '${node.displayName}': ${message}`);
|
|
}
|
|
|
|
/** The program parts of Swift's finalizeCurrentNodeCandidate, for a PC or notebook. */
|
|
function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemConfigurations, warnings: string[]): void {
|
|
const installed = new Set<InstallableProgram>();
|
|
const active = new Set<InstallableProgram>();
|
|
for (const application of source.system.applications ?? []) {
|
|
const program = PROGRAM_OF_CLASS[application.javaClass];
|
|
installed.add(program);
|
|
if (application.isActive === true) active.add(program);
|
|
switch (program) {
|
|
case 'webServer':
|
|
system.webServerConfigurations.set(node.id, {
|
|
port: application.port ?? DEFAULT_WEB_SERVER_PORT,
|
|
documentRoot: JAVA_WEB_ROOT,
|
|
virtualHosts: application.useVirtualHosts === true,
|
|
});
|
|
break;
|
|
case 'gnutella':
|
|
// Without one in the file Java rolls 3, 4 or 5; Swift's default is 3.
|
|
system.gnutellaConfigurations.set(node.id, { maximumKnownPeers: application.maxPeers ?? 3 });
|
|
break;
|
|
case 'dnsServer': {
|
|
// Java's DNSServer forwards to the host's own DNS server setting;
|
|
// only one that differs from the host's own address is a forwarder.
|
|
const recursive = application.recursiveResolutionEnabled === true;
|
|
const forwarder = trimmedOrEmpty(source.interfaces[0]?.dns);
|
|
const primary = trimmedOrEmpty(source.interfaces[0]?.ip);
|
|
const forwarding = recursive && forwarder !== '' && forwarder !== primary;
|
|
const hosts = javaHostsFileContent(source.system.fileSystem);
|
|
const records = dnsRecordsOf(readDnsHosts(hosts ?? ''), warnAt(node, warnings));
|
|
system.dnsServerConfigurations.set(
|
|
node.id,
|
|
forwarding
|
|
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder, records }
|
|
: { recursiveResolutionEnabled: recursive, records },
|
|
);
|
|
break;
|
|
}
|
|
case 'emailClient':
|
|
system.emailClientConfigurations.set(node.id, emailClientConfiguration(application, warnAt(node, warnings)));
|
|
break;
|
|
case 'emailServer':
|
|
system.emailServerConfigurations.set(node.id, emailServerConfiguration(application, warnAt(node, warnings)));
|
|
break;
|
|
case 'firewall':
|
|
if (application.firewall !== undefined) {
|
|
system.firewallConfigurationsByNodeId.set(node.id, firewallConfigurationOf(application.firewall, `node '${node.displayName}'`, warnings));
|
|
}
|
|
break;
|
|
case 'echoServer':
|
|
system.echoServerConfigurations.set(node.id, { port: application.port ?? 55_555 });
|
|
break;
|
|
case 'simpleClient':
|
|
system.simpleClientConfigurations.set(node.id, {
|
|
targetAddress: trimmedOrEmpty(application.targetAddress),
|
|
targetPort: application.targetPort ?? 55_555,
|
|
});
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
if (installed.size > 0) system.installedPrograms.set(node.id, installed);
|
|
if (active.size > 0) system.activePrograms.set(node.id, active);
|
|
}
|
|
|
|
/** A row of text cells, trimmed, or undefined unless it has exactly four. */
|
|
function fourCells(row: (string | null)[]): [string, string, string, string] | undefined {
|
|
if (row.length !== 4 || row.some((cell) => cell === null)) return undefined;
|
|
return row.map((cell) => swiftTrim(cell!)) as [string, string, string, string];
|
|
}
|
|
|
|
/**
|
|
* The DHCP, NAT and modem parts of Swift's finalizeCurrentNodeCandidate and
|
|
* registerRemoteLinkConfiguration.
|
|
*/
|
|
function registerServiceConfigurations(
|
|
source: FlsNode,
|
|
node: TopologyNode,
|
|
system: SystemConfigurations,
|
|
newId: () => Id,
|
|
warnings: string[],
|
|
): void {
|
|
const where = `node '${node.displayName}'`;
|
|
const dhcpCapable = node.kind === 'pc' || node.kind === 'notebook' || node.kind === 'gateway';
|
|
if (source.system.dhcpClient === true) {
|
|
if (dhcpCapable) system.dhcpClientConfigurations.set(node.id, { isEnabled: true });
|
|
else warnings.push(`${where}: DHCP client on a ${node.kind}, ignored`);
|
|
}
|
|
const server = source.system.dhcpServer;
|
|
if (server !== undefined) {
|
|
const configuration = dhcpServerConfiguration({
|
|
isActive: server.isActive,
|
|
lowerBoundIPAddress: trimmedOrEmpty(server.lowerBound),
|
|
upperBoundIPAddress: trimmedOrEmpty(server.upperBound),
|
|
gatewayIPAddress: trimmedOrEmpty(server.gateway),
|
|
dnsServerIPAddress: trimmedOrEmpty(server.dnsServer),
|
|
useOwnSettings: server.useOwnSettings,
|
|
staticAssignments: server.staticAssignedAddresses.map((entry) => ({
|
|
id: newId(),
|
|
macAddress: entry.mac,
|
|
ipAddress: entry.ip,
|
|
})),
|
|
});
|
|
if (!deepEqual(configuration, dhcpServerConfiguration())) {
|
|
if (dhcpCapable) system.dhcpServerConfigurations.set(node.id, configuration);
|
|
else warnings.push(`${where}: DHCP server settings on a ${node.kind}, ignored`);
|
|
}
|
|
}
|
|
if (node.kind === 'gateway') {
|
|
const rows: GatewayPortForwardingRow[] = [];
|
|
for (const row of source.system.staticNAT ?? []) {
|
|
const cells = fourCells(row);
|
|
if (cells === undefined) {
|
|
warnings.push(`${where}: a port forwarding row without four entries, left out`);
|
|
continue;
|
|
}
|
|
const [protocolValue, publicPortValue, lanIPAddress, lanPortValue] = cells;
|
|
rows.push({ protocolValue, publicPortValue, lanIPAddress, lanPortValue });
|
|
}
|
|
if (rows.length > 0) system.portForwardingRowsByNodeId.set(node.id, rows);
|
|
}
|
|
if (node.kind === 'remoteLink') {
|
|
const modem = source.system.modem;
|
|
let port = modem?.port ?? DEFAULT_MODEM_PORT;
|
|
if (port < 1 || port > 65_535) {
|
|
warnings.push(`${where}: modem port ${port} is invalid, used ${DEFAULT_MODEM_PORT}`);
|
|
port = DEFAULT_MODEM_PORT;
|
|
}
|
|
if (modem !== undefined && modem.mode !== 1 && modem.mode !== 2) {
|
|
warnings.push(`${where}: modem mode ${modem.mode} unknown, treated as client`);
|
|
}
|
|
// Java pairs a server and a client modem over TCP on this port.
|
|
system.remoteLinkConfigurations.set(node.id, {
|
|
pairIdentifier: modemPair(port),
|
|
latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
|
|
isEnabled: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = trimmedOrEmpty(source.system.ssid);
|
|
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,
|
|
...(source.cloud ? { showsAsCloud: true } : {}),
|
|
});
|
|
}
|
|
// Java joins a wireless network when the card is wireless and an SSID is set
|
|
// (JHostKonfiguration.updateWifiConnection); the model keeps both.
|
|
const wireless = source.interfaces[0]?.wireless === true;
|
|
if ((node.kind === 'pc' || node.kind === 'notebook') && (ssid !== '' || wireless)) {
|
|
system.hostWirelessConfigurations.set(node.id, { isEnabled: wireless, ssid });
|
|
}
|
|
const routes: ManualRoute[] = [];
|
|
for (const row of source.system.manualRoutes ?? []) {
|
|
const cells = fourCells(row);
|
|
if (cells === undefined) {
|
|
warnings.push(`${where}: a manual route without four entries, left out`);
|
|
continue;
|
|
}
|
|
const [destinationNetwork, subnetMask, gateway, interfaceIPAddress] = cells;
|
|
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 (source.system.ipForwardingEnabled !== undefined && source.system.ipForwardingEnabled !== forwardsIPv4ByDefault(node.kind)) {
|
|
system.ipForwardingByNodeId.set(node.id, source.system.ipForwardingEnabled);
|
|
}
|
|
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: trimmedOrEmpty(rule.srcIP),
|
|
sourceSubnetMask: trimmedOrEmpty(rule.srcMask),
|
|
destinationIPAddress: trimmedOrEmpty(rule.destIP),
|
|
destinationSubnetMask: trimmedOrEmpty(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,
|
|
};
|
|
}
|
|
|
|
/** A usable name: not empty after trimming, no path separator. */
|
|
function safeName(name: string | null): string | undefined {
|
|
const trimmed = trimmedOrEmpty(name);
|
|
return trimmed === '' || trimmed.includes('/') || trimmed.includes('\\') ? undefined : trimmed;
|
|
}
|
|
|
|
/**
|
|
* Swift's registerVirtualFileSystem: the children of Java's root, depth
|
|
* first; an item that cannot be written is left out with a warning, the
|
|
* rest goes on.
|
|
*/
|
|
function importFileSystem(root: FlsFileTree | undefined, nodeName: string, warnings: string[]): VirtualFileSystem {
|
|
const fileSystem = new VirtualFileSystem();
|
|
const warn = (message: string) => warnings.push(message);
|
|
const describe = (error: unknown) => (error instanceof VirtualFileSystemError ? error.message : String(error));
|
|
|
|
const append = (tree: FlsFileTree, parent: string): void => {
|
|
if (tree.kind === 'file') {
|
|
const name = safeName(tree.name);
|
|
if (name === undefined) {
|
|
warn(`Ignored Java filesystem file with missing or unsafe name on '${nodeName}'.`);
|
|
return;
|
|
}
|
|
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
|
|
try {
|
|
const path = normalizedAbsolutePath(rawPath);
|
|
const content = fileContentOfJavaType(trimmedOrEmpty(tree.type), tree.content ?? '', path, warn);
|
|
switch (content.kind) {
|
|
case 'text':
|
|
fileSystem.writeTextFile(path, content.text, false);
|
|
break;
|
|
case 'binary':
|
|
fileSystem.writeBinaryFile(path, content.data, content.mediaType, false);
|
|
break;
|
|
case 'image':
|
|
fileSystem.writeImageFile(path, content.data, content.mediaType, false);
|
|
break;
|
|
case 'directory':
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
warn(`Ignored Java filesystem file '${rawPath}' on '${nodeName}': ${describe(error)}`);
|
|
}
|
|
return;
|
|
}
|
|
const name = tree.kind === 'directory' ? safeName(tree.name) : undefined;
|
|
if (name === undefined) {
|
|
warn(`Ignored Java filesystem directory with missing or unsafe name on '${nodeName}'.`);
|
|
return;
|
|
}
|
|
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
|
|
try {
|
|
const path = normalizedAbsolutePath(rawPath);
|
|
fileSystem.createDirectory(path, true);
|
|
for (const child of tree.children) append(child, path);
|
|
} catch (error) {
|
|
warn(`Ignored Java filesystem directory '${rawPath}' on '${nodeName}': ${describe(error)}`);
|
|
}
|
|
};
|
|
|
|
for (const child of root?.children ?? []) append(child, '/');
|
|
return fileSystem;
|
|
}
|
|
|
|
/**
|
|
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
|
|
* inside the editor's world; the id from the item's values and its order.
|
|
*/
|
|
function importDocumentationItem(
|
|
item: FlsDocumentationItem,
|
|
order: number,
|
|
warnings: string[],
|
|
): DocumentationItem | undefined {
|
|
if (item.width <= 0 || item.height <= 0) {
|
|
warnings.push('documentation item without size, left out');
|
|
return undefined;
|
|
}
|
|
const kind = item.type === 1 ? 'rectangle' : item.type === 2 ? 'text' : undefined;
|
|
if (kind === undefined) {
|
|
warnings.push(`documentation item of type ${item.type}, left out (known: RECT = 1, TEXT = 2)`);
|
|
return undefined;
|
|
}
|
|
const frame = { x: item.x, y: item.y, width: item.width, height: item.height };
|
|
if (!isSafeFrame(frame)) {
|
|
warnings.push('documentation item outside the editor bounds, left out');
|
|
return undefined;
|
|
}
|
|
const text = item.text ?? '';
|
|
const seed = `java-doc|${order}|${item.type}|${item.x}|${item.y}|${item.width}|${item.height}|${text}`;
|
|
let color;
|
|
if (item.color === null) {
|
|
// Java shows it light grey, either kind (JDocuElement.setColor(null)).
|
|
color = DOCUMENTATION_LIGHT_GRAY;
|
|
warnings.push(`documentation item ${order + 1} without a color, given the default`);
|
|
} else {
|
|
const [red, green, blue, alpha] = item.color;
|
|
color = documentationColor(red / 255, green / 255, blue / 255, alpha / 255);
|
|
}
|
|
return documentationItem({
|
|
id: deterministicDocumentationUuid(seed),
|
|
kind,
|
|
frame,
|
|
text,
|
|
color,
|
|
fontName: item.font?.name ?? 'Dialog',
|
|
fontSize: item.font?.size ?? 12,
|
|
isBold: ((item.font?.style ?? 0) & 1) === 1,
|
|
isItalic: ((item.font?.style ?? 0) & 2) === 2,
|
|
order,
|
|
});
|
|
}
|