The core translates before the IP thread, as Java's NAT threads sit between card and Ethernet: out, the WAN address and the entry's port (ICMP keeps its identifier); in, by remote end, port and protocol, else by the port forwarding, which then makes a dynamic entry; a ping at the WAN address is answered by the router, packets from outside for the LAN are dropped, both sides pass the router's firewall, TTL 1 going out gets time exceeded. Dynamic entries expire at Java's check each second, 300 s times the delay factor old (55 minutes at 90 %), refreshed only by what comes in. The home router forwards as a router does. Measured and not taken: when a LAN endpoint sends to a second remote end, Java gives it the entry's WAN port but no entry, and the answer stays at the router; the core adds an entry for each new remote end on that port. The oracle now records every card of a node (the second card of routers was missing); the ARP and RIP runs are recorded anew and still match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
// A core scenario (test/scenarios/<name>.scenario.json): a network, its
|
|
// settings and the steps to run on it. Read by the Swift comparison
|
|
// (oracle.test.ts) and the Java comparison (dhcpJava.test.ts).
|
|
|
|
import {
|
|
deviceConfiguration,
|
|
dhcpServerConfiguration,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
interfaceKey,
|
|
portSnapshot,
|
|
switchConfiguration,
|
|
topologySnapshot,
|
|
type FirewallConfiguration,
|
|
type ManualRoute,
|
|
type NodeKind,
|
|
type TopologySnapshot,
|
|
type DnsRecordType,
|
|
} from '../src/index.js';
|
|
|
|
export interface Scenario {
|
|
seed: number;
|
|
speedPercent?: number;
|
|
nodes: { id: string; kind: NodeKind; ports: { id: string; label: string; mac?: string }[] }[];
|
|
links: { id: string; sourceNode: string; sourcePort: string; targetNode: string; targetPort: string }[];
|
|
devices?: Record<string, { ip: string; mask: string; gateway?: string; dns?: string }>;
|
|
interfaces?: { node: string; port: string; ip: string; mask: string }[];
|
|
routes?: Record<string, { destination: string; mask: string; gateway: string; interface: string }[]>;
|
|
switches?: Record<string, { ssid: string; retention?: number }>;
|
|
firewalls?: Record<
|
|
string,
|
|
{
|
|
active?: boolean;
|
|
defaultPolicy?: 'accept' | 'drop';
|
|
dropIcmp?: boolean;
|
|
synOnly?: boolean;
|
|
filterUdp?: boolean;
|
|
rules?: {
|
|
source?: string;
|
|
sourceMask?: string;
|
|
destination?: string;
|
|
destinationMask?: string;
|
|
port?: number;
|
|
protocol?: 'all' | 'icmp' | 'tcp' | 'udp';
|
|
action?: 'accept' | 'drop';
|
|
}[];
|
|
}
|
|
>;
|
|
dhcpServers?: Record<
|
|
string,
|
|
{ active?: boolean; lower?: string; upper?: string; gateway?: string; dns?: string; own?: boolean; staticAssignments?: [string, string][] }
|
|
>;
|
|
dhcpClients?: string[];
|
|
/** Home routers' port forwarding: rows of protocol, public port, LAN address, LAN port. */
|
|
portForwarding?: Record<string, [string, string, string, string][]>;
|
|
/** Routers with RIP switched on. */
|
|
rip?: string[];
|
|
/** Records as `name TYPE target` (Java's hinzuRecord: TTL 3600). */
|
|
dnsServers?: Record<string, { records: string[]; recursive?: boolean }>;
|
|
steps: {
|
|
op: string;
|
|
from?: string;
|
|
node?: string;
|
|
target?: string;
|
|
ttl?: number;
|
|
identifier?: number;
|
|
sequence?: number;
|
|
maxTtl?: number;
|
|
to?: number;
|
|
enabled?: boolean;
|
|
percent?: number;
|
|
// UDP: a socket is named by the id bindUdp returned.
|
|
port?: number;
|
|
localIp?: string;
|
|
remoteIp?: string;
|
|
remotePort?: number;
|
|
socket?: string;
|
|
payload?: string;
|
|
timeout?: number;
|
|
// TCP: the payload repeated this many times, for messages longer than a segment.
|
|
repeatCount?: number;
|
|
localPort?: number;
|
|
}[];
|
|
}
|
|
|
|
export function snapshotOf(scenario: Scenario): TopologySnapshot {
|
|
const firewalls = new Map<string, FirewallConfiguration>();
|
|
for (const [node, config] of Object.entries(scenario.firewalls ?? {})) {
|
|
firewalls.set(
|
|
node,
|
|
firewallConfiguration({
|
|
isActive: config.active ?? false,
|
|
defaultPolicy: config.defaultPolicy ?? 'drop',
|
|
dropIcmp: config.dropIcmp ?? false,
|
|
filterSynSegmentsOnly: config.synOnly ?? true,
|
|
filterUdp: config.filterUdp ?? true,
|
|
rules: (config.rules ?? []).map((rule) =>
|
|
firewallRule({
|
|
sourceIPAddress: rule.source ?? '',
|
|
sourceSubnetMask: rule.sourceMask ?? '',
|
|
destinationIPAddress: rule.destination ?? '',
|
|
destinationSubnetMask: rule.destinationMask ?? '',
|
|
...(rule.port === undefined ? {} : { port: rule.port }),
|
|
protocol: rule.protocol ?? 'tcp',
|
|
action: rule.action ?? 'drop',
|
|
}),
|
|
),
|
|
}),
|
|
);
|
|
}
|
|
return topologySnapshot({
|
|
nodes: scenario.nodes.map((node) => ({
|
|
id: node.id,
|
|
kind: node.kind,
|
|
ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.mac)),
|
|
})),
|
|
links: scenario.links.map((link) => ({
|
|
id: link.id,
|
|
sourceNodeId: link.sourceNode,
|
|
sourcePortId: link.sourcePort,
|
|
targetNodeId: link.targetNode,
|
|
targetPortId: link.targetPort,
|
|
})),
|
|
deviceConfigurations: new Map(
|
|
Object.entries(scenario.devices ?? {}).map(([node, device]) => [
|
|
node,
|
|
deviceConfiguration(device.ip, device.mask, device.gateway ?? '', device.dns ?? ''),
|
|
]),
|
|
),
|
|
interfaceConfigurations: new Map(
|
|
(scenario.interfaces ?? []).map((item) => [
|
|
interfaceKey(item.node, item.port),
|
|
{ ipAddress: item.ip, subnetMask: item.mask },
|
|
]),
|
|
),
|
|
manualRoutesByNodeId: new Map(
|
|
Object.entries(scenario.routes ?? {}).map(([node, rows]) => [
|
|
node,
|
|
rows.map(
|
|
(row): ManualRoute => ({
|
|
destinationNetwork: row.destination,
|
|
subnetMask: row.mask,
|
|
gateway: row.gateway,
|
|
interfaceIPAddress: row.interface,
|
|
}),
|
|
),
|
|
]),
|
|
),
|
|
dhcpServerConfigurationsByNodeId: new Map(
|
|
Object.entries(scenario.dhcpServers ?? {}).map(([node, config]) => [
|
|
node,
|
|
dhcpServerConfiguration({
|
|
isActive: config.active ?? false,
|
|
lowerBoundIPAddress: config.lower ?? '0.0.0.0',
|
|
upperBoundIPAddress: config.upper ?? '0.0.0.0',
|
|
gatewayIPAddress: config.gateway ?? '0.0.0.0',
|
|
dnsServerIPAddress: config.dns ?? '0.0.0.0',
|
|
useOwnSettings: config.own ?? false,
|
|
staticAssignments: (config.staticAssignments ?? []).map(([macAddress, ipAddress], index) => ({
|
|
id: `static-${index}`,
|
|
macAddress,
|
|
ipAddress,
|
|
})),
|
|
}),
|
|
]),
|
|
),
|
|
dhcpClientConfigurationsByNodeId: new Map((scenario.dhcpClients ?? []).map((node) => [node, { isEnabled: true }])),
|
|
ripEnabledByNodeId: new Map((scenario.rip ?? []).map((node) => [node, true])),
|
|
portForwardingRowsByNodeId: new Map(
|
|
Object.entries(scenario.portForwarding ?? {}).map(([node, rows]) => [
|
|
node,
|
|
rows.map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue })),
|
|
]),
|
|
),
|
|
switchConfigurationsByNodeId: new Map(
|
|
Object.entries(scenario.switches ?? {}).map(([node, config]) => [
|
|
node,
|
|
config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention),
|
|
]),
|
|
),
|
|
dnsServerConfigurationsByNodeId: new Map(
|
|
Object.entries(scenario.dnsServers ?? {}).map(([node, config]) => [
|
|
node,
|
|
{
|
|
recursiveResolutionEnabled: config.recursive ?? false,
|
|
records: config.records.map((line) => {
|
|
const [name = '', type = 'A', target = ''] = line.split(' ');
|
|
const nameServerOrMail = type === 'NS' || type === 'MX';
|
|
return {
|
|
name: name.replace(/\.$/, '').toLowerCase() || '.',
|
|
type: type as DnsRecordType,
|
|
ttlSeconds: 3600,
|
|
target: nameServerOrMail && !target.endsWith('.') ? `${target}.` : target,
|
|
};
|
|
}),
|
|
},
|
|
]),
|
|
),
|
|
firewallConfigurationsByNodeId: firewalls,
|
|
});
|
|
}
|