feat(fls): import installed programs and their settings
importProject carries a host's installed programs into the model with the settings Swift keeps (web server port, Gnutella peer limit, DNS recursion and forwarder, personal firewall) and two it does not (the echo server's port, the generic client's target), plus Java's "switched on". The Swift oracle writes programs and settings; they match on the 13 files Swift reads completely. Intended differences, named in the test: - every FILIUS program is kept; Swift drops command prompt, text editor, file explorer, image viewer, echo server and generic client; - a web server's files stay in /webserver, its document root; Swift moves them to /www. No program state goes into a host's files. - multicast_server.fls: maxTeilnehmerZahl refers to a shared Integer (a button's icon text gap, 4); Swift falls back to 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,13 @@
|
||||
// - 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.
|
||||
// Not here yet: applications (with them the web server's /webserver → /www).
|
||||
// - 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 no program state is written into a host's files
|
||||
// (Swift keeps Gnutella and e-mail state there as hidden JSON files).
|
||||
// Not here yet: DNS records (from /dns/hosts) and e-mail accounts.
|
||||
|
||||
import {
|
||||
canonicalMacAddress,
|
||||
@@ -35,6 +41,7 @@ import {
|
||||
defaultSwitchConfiguration,
|
||||
dhcpServerConfiguration,
|
||||
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
|
||||
DEFAULT_WEB_SERVER_PORT,
|
||||
deterministicDocumentationUuid,
|
||||
documentationColor,
|
||||
documentationItem,
|
||||
@@ -54,6 +61,12 @@ import {
|
||||
type DeviceConfiguration,
|
||||
type DhcpClientConfiguration,
|
||||
type DhcpServerConfiguration,
|
||||
type DnsServerConfiguration,
|
||||
type EchoServerConfiguration,
|
||||
type GnutellaConfiguration,
|
||||
type InstallableProgram,
|
||||
type SimpleClientConfiguration,
|
||||
type WebServerConfiguration,
|
||||
type GatewayPortForwardingRow,
|
||||
type RemoteLinkConfiguration,
|
||||
type FirewallAction,
|
||||
@@ -75,7 +88,7 @@ import {
|
||||
type TopologyNode,
|
||||
type VirtualFileContent,
|
||||
} from '@mdg-filius/core';
|
||||
import type { FlsFileTree, FlsFirewall } from './systemSoftware.js';
|
||||
import type { FlsApplicationClass, FlsFileTree, FlsFirewall } from './systemSoftware.js';
|
||||
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
|
||||
|
||||
export interface ImportedProject {
|
||||
@@ -96,6 +109,14 @@ export interface ImportedProject {
|
||||
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>;
|
||||
echoServerConfigurations: Map<Id, EchoServerConfiguration>;
|
||||
simpleClientConfigurations: Map<Id, SimpleClientConfiguration>;
|
||||
/** In render order: rectangles below text, then by order. */
|
||||
documentationItems: DocumentationItem[];
|
||||
/** The topology's warnings, then this step's. */
|
||||
@@ -132,6 +153,13 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
|
||||
portForwardingRowsByNodeId: new Map(),
|
||||
remoteLinkConfigurations: new Map(),
|
||||
virtualFileSystems: new Map(),
|
||||
installedPrograms: new Map(),
|
||||
activePrograms: new Map(),
|
||||
webServerConfigurations: new Map(),
|
||||
gnutellaConfigurations: new Map(),
|
||||
dnsServerConfigurations: new Map(),
|
||||
echoServerConfigurations: new Map(),
|
||||
simpleClientConfigurations: new Map(),
|
||||
};
|
||||
const nodes = topology.nodes.map((source) => {
|
||||
const node = importNode(source, newId, warnings);
|
||||
@@ -140,6 +168,7 @@ export function importProject(topology: FlsTopology, newId: () => Id): ImportedP
|
||||
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);
|
||||
}
|
||||
return node;
|
||||
});
|
||||
@@ -331,8 +360,91 @@ type SystemConfigurations = Pick<
|
||||
| 'portForwardingRowsByNodeId'
|
||||
| 'remoteLinkConfigurations'
|
||||
| 'virtualFileSystems'
|
||||
| 'installedPrograms'
|
||||
| 'activePrograms'
|
||||
| 'webServerConfigurations'
|
||||
| 'gnutellaConfigurations'
|
||||
| 'dnsServerConfigurations'
|
||||
| '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';
|
||||
|
||||
/** 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,
|
||||
});
|
||||
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;
|
||||
system.dnsServerConfigurations.set(
|
||||
node.id,
|
||||
forwarding
|
||||
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder }
|
||||
: { recursiveResolutionEnabled: recursive },
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
importProject,
|
||||
readFlsArchive,
|
||||
readTopology,
|
||||
type FlsApplication,
|
||||
type FlsFileTree,
|
||||
type FlsNode,
|
||||
type FlsTopology,
|
||||
@@ -91,6 +92,9 @@ function fileEntriesOf(fileSystem: VirtualFileSystem | undefined): unknown {
|
||||
const DEFAULT_FILES = describeFileEntries(VirtualFileSystem.defaultForDevice().allEntries());
|
||||
const EMPTY_FILES = describeFileEntries(new VirtualFileSystem().allEntries());
|
||||
|
||||
/** Programs Swift's import drops and this one keeps (Java has them installed). */
|
||||
const PROGRAMS_SWIFT_DROPS = new Set(['commandPrompt', 'fileExplorer', 'imageViewer', 'textEditor', 'echoServer', 'simpleClient']);
|
||||
|
||||
/**
|
||||
* describeSystem's shape. A firewall as the core has it without one
|
||||
* (inactive, drop, no rules) is left out: Swift keeps a configuration only
|
||||
@@ -126,6 +130,23 @@ function systemOf(project: ImportedProject, nodeId: Id) {
|
||||
if (forwarding !== undefined) {
|
||||
result.portForwarding = forwarding.map((row) => [row.protocolValue, row.publicPortValue, row.lanIPAddress, row.lanPortValue]);
|
||||
}
|
||||
const programs = project.installedPrograms.get(nodeId);
|
||||
const kept = [...(programs ?? [])].filter((program) => !PROGRAMS_SWIFT_DROPS.has(program)).sort();
|
||||
if (kept.length > 0) result.installedPrograms = kept;
|
||||
const web = project.webServerConfigurations.get(nodeId);
|
||||
if (web !== undefined) {
|
||||
// Swift serves the same files from /www after moving them there.
|
||||
result.webServer = { port: web.port, documentRoot: web.documentRoot === '/webserver' ? '/www' : web.documentRoot, virtualHosts: false };
|
||||
}
|
||||
const gnutella = project.gnutellaConfigurations.get(nodeId);
|
||||
if (gnutella !== undefined) result.gnutella = gnutella;
|
||||
const dns = project.dnsServerConfigurations.get(nodeId);
|
||||
if (dns !== undefined) {
|
||||
result.dnsServer = {
|
||||
recursiveResolutionEnabled: dns.recursiveResolutionEnabled,
|
||||
forwardingServerIPAddress: dns.forwardingServerIPAddress ?? null,
|
||||
};
|
||||
}
|
||||
const remoteLink = project.remoteLinkConfigurations.get(nodeId);
|
||||
if (remoteLink !== undefined) result.remoteLink = remoteLink;
|
||||
const firewall = project.firewallConfigurationsByNodeId.get(nodeId);
|
||||
@@ -217,23 +238,27 @@ function withJavaDefaultNames(state: unknown): unknown {
|
||||
|
||||
/**
|
||||
* Where the Swift import is wrong and Java (corpus.java.json) is right, per
|
||||
* file and node: the corrected fields.
|
||||
* file and node: the corrected node.
|
||||
*/
|
||||
const SWIFT_ERRORS: Record<string, Record<number, { displayName: string; hostLabelPolicy: string }>> = {
|
||||
// useIPAsName is `<object idref="Boolean0"/>`, the shared result of a
|
||||
// button's isEnabled() — true. Swift's reader cannot follow it: it shows
|
||||
// the label text as a manual name instead of the IP address.
|
||||
type SwiftNode = { displayName: string; hostLabelPolicy: string; system: Record<string, unknown> };
|
||||
const shownByIp = (displayName: string) => (node: SwiftNode) => ({ ...node, displayName, hostLabelPolicy: 'ipAddress' });
|
||||
const SWIFT_ERRORS: Record<string, Record<number, (node: SwiftNode) => SwiftNode>> = {
|
||||
'java-examples/multicast_server.fls': {
|
||||
0: { displayName: 'Rechner', hostLabelPolicy: 'ipAddress' },
|
||||
1: { displayName: 'Notebook', hostLabelPolicy: 'ipAddress' },
|
||||
2: { displayName: 'Notebook', hostLabelPolicy: 'ipAddress' },
|
||||
// useIPAsName is `<object idref="Boolean0"/>`, the shared result of a
|
||||
// button's isEnabled() — true. Swift's reader cannot follow it: it shows
|
||||
// the label text as a manual name instead of the IP address.
|
||||
0: shownByIp('Rechner'),
|
||||
// maxTeilnehmerZahl is `<object idref="Integer0"/>`, the shared result of
|
||||
// a button's getIconTextGap() — 4. Swift falls back to its default 3.
|
||||
1: (node) => ({ ...shownByIp('Notebook')(node), system: { ...node.system, gnutella: { maximumKnownPeers: 4 } } }),
|
||||
2: shownByIp('Notebook'),
|
||||
},
|
||||
};
|
||||
|
||||
function withSwiftErrorsCorrected(name: string, state: unknown): unknown {
|
||||
const corrections = SWIFT_ERRORS[name] ?? {};
|
||||
const { nodes, ...rest } = state as { nodes: object[] };
|
||||
return { ...rest, nodes: nodes.map((node, index) => ({ ...node, ...corrections[index] })) };
|
||||
const { nodes, ...rest } = state as { nodes: SwiftNode[] };
|
||||
return { ...rest, nodes: nodes.map((node, index) => corrections[index]?.(node) ?? node) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -716,6 +741,58 @@ describe('importProject rules', () => {
|
||||
expect(() => VirtualFileSystem.validateProjectQuotas(devices)).toThrow('totalSizeQuotaExceeded(100663296, 67108864)');
|
||||
});
|
||||
|
||||
it("keeps every installed program with its settings, and forwards DNS only to another server", () => {
|
||||
const source = flsNode('Rechner').source;
|
||||
const application = (javaClass: FlsApplication['javaClass'], fields: Partial<FlsApplication> = {}): FlsApplication => ({
|
||||
key: javaClass,
|
||||
javaClass,
|
||||
source,
|
||||
...fields,
|
||||
});
|
||||
const host = (dns: string, recursive: boolean) =>
|
||||
flsNode('Rechner', {
|
||||
interfaces: [{ ip: '10.0.0.1', mask: '255.255.255.0', mac: undefined, gateway: '', dns }],
|
||||
system: { applications: [application('filius.software.dns.DNSServer', { isActive: true, port: 53, recursiveResolutionEnabled: recursive })] },
|
||||
});
|
||||
const project = importProject(
|
||||
topology({
|
||||
nodes: [
|
||||
flsNode('Notebook', {
|
||||
interfaces: [nic('10.0.0.2')],
|
||||
system: {
|
||||
applications: [
|
||||
application('filius.software.lokal.Terminal'),
|
||||
application('filius.software.www.WebServer', { isActive: true, port: 8080, useVirtualHosts: false }),
|
||||
application('filius.software.clientserver.ServerBaustein', { isActive: false, port: 7 }),
|
||||
application('filius.software.clientserver.ClientBaustein', { targetAddress: ' 10.0.0.9 ', targetPort: 7 }),
|
||||
application('filius.software.firewall.Firewall', {
|
||||
firewall: { activated: true, defaultPolicy: 1, dropICMP: false, filterSYNSegmentsOnly: true, filterUdp: true, rules: [] },
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
host('8.8.8.8', true),
|
||||
host('10.0.0.1', true),
|
||||
host('8.8.8.8', false),
|
||||
],
|
||||
}),
|
||||
counter(),
|
||||
);
|
||||
const [notebook, forwarding, self, iterative] = project.graph.nodes.map((node) => node.id);
|
||||
|
||||
expect([...project.installedPrograms.get(notebook!)!].sort()).toEqual(['commandPrompt', 'echoServer', 'firewall', 'simpleClient', 'webServer']);
|
||||
expect([...project.activePrograms.get(notebook!)!]).toEqual(['webServer']);
|
||||
expect(project.webServerConfigurations.get(notebook!)).toEqual({ port: 8080, documentRoot: '/webserver' });
|
||||
expect(project.echoServerConfigurations.get(notebook!)).toEqual({ port: 7 });
|
||||
expect(project.simpleClientConfigurations.get(notebook!)).toEqual({ targetAddress: '10.0.0.9', targetPort: 7 });
|
||||
expect(project.firewallConfigurationsByNodeId.get(notebook!)).toMatchObject({ isActive: true, defaultPolicy: 'accept' });
|
||||
expect([forwarding, self, iterative].map((id) => project.dnsServerConfigurations.get(id!))).toEqual([
|
||||
{ recursiveResolutionEnabled: true, forwardingServerIPAddress: '8.8.8.8' },
|
||||
{ recursiveResolutionEnabled: true },
|
||||
{ recursiveResolutionEnabled: 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