feat(core): DHCP client after Java, measured with Java FILIUS
The client follows Java's DHCPClient instead of Swift: one DISCOVER or REQUEST per attempt, an answer that does not fit is read and dropped, the ARP check waits a round trip (an answer for the address ends it at once, then DECLINE), a timeout repeats the state's message, and the fifth turns to a link-local address 169.254.1.x/16 (ARP probe, ARP reply to all); no old address is given back. DHCP round trip times follow the speed as in Java (50 cables, 2750 ms at 90 %; Swift: fixed 2500 ms). Not taken from Java: the client gives up after five occupied link-local addresses (Java searches on without end), and an ARP packet for another address does not end the check. dhcpJava.test.ts compares the four DHCP scenarios with DhcpRun's Java runs; the Swift comparison only checks that they differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// DHCP as Java FILIUS runs it. tools/java-oracle's DhcpRun built each
|
||||
// scenario's network in Java FILIUS, ran it and wrote what every network card
|
||||
// saw (<name>.java.json, FILIUS' own packet log). The engine runs the same
|
||||
// network. For each DHCP client, the frames that concern it must come in the
|
||||
// same order, with the same content, at about the same distance from each
|
||||
// other, and every host must end with the same addresses.
|
||||
//
|
||||
// A frame counts when it first shows on any card (sent, or received), in
|
||||
// Java and in the engine alike. Not only on the client's own card: there,
|
||||
// FILIUS' log misses the DISCOVER of the client that sends second when two
|
||||
// start together (measured in four of four runs of dhcp-lan; the server
|
||||
// receives it) — a fault of the log, not of the network. Java logs no frame
|
||||
// identity; the same text within DUPLICATE_WINDOW_MILLISECONDS is one frame
|
||||
// (measured: a frame reaches the last card within some 130 ms; the closest
|
||||
// two distinct frames of the same text are the DISCOVERs around a DECLINE,
|
||||
// some 520 ms apart).
|
||||
//
|
||||
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
|
||||
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
|
||||
// few milliseconds, except where a card sends two frames at once: Java's
|
||||
// cable carries one frame at a time (SimplexVerbindung sleeps the delay with
|
||||
// the port's buffer locked, and the sender waits), the engine's cable all of
|
||||
// them together — up to three cables' delay, measured. A gap may differ by
|
||||
// GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not. Java draws the link-local
|
||||
// address with Math.random; it is compared as 169.254.1.x. Where several
|
||||
// clients start together, which one is first varies in Java (the first
|
||||
// thread pays for loading the classes); a client is therefore compared
|
||||
// without the other clients' frames and the answers to them.
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NetworkRuntimeEngine, stableMacAddress, type EthernetFrame } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
const GAP_TOLERANCE_MILLISECONDS = 200;
|
||||
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
||||
|
||||
interface JavaFrame {
|
||||
time: number;
|
||||
source: string;
|
||||
destination: string;
|
||||
arp?: number;
|
||||
senderIp?: string;
|
||||
targetIp?: string;
|
||||
sourceIp?: string;
|
||||
destinationIp?: string;
|
||||
protocol?: number;
|
||||
sourcePort?: number;
|
||||
destinationPort?: number;
|
||||
payload?: string;
|
||||
icmp?: number;
|
||||
}
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
devices: Record<string, [string, string, string, string]>;
|
||||
}
|
||||
|
||||
interface SeenFrame {
|
||||
readonly time: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
const LINK_LOCAL = /169\.254\.1\.\d+/g;
|
||||
|
||||
function javaText(frame: JavaFrame): string {
|
||||
const macs = `${frame.source} > ${frame.destination}`.toUpperCase();
|
||||
if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`;
|
||||
if (frame.icmp !== undefined) return `${macs} ICMP ${frame.icmp}`;
|
||||
return `${macs} UDP ${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort} ${frame.payload}`;
|
||||
}
|
||||
|
||||
function engineText(frame: EthernetFrame): string {
|
||||
const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase();
|
||||
if (frame.payload.kind === 'arp') {
|
||||
const arp = frame.payload.packet;
|
||||
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
|
||||
}
|
||||
const packet = frame.payload.packet;
|
||||
if (packet.payload.kind !== 'udp') return `${macs} ${packet.payload.kind.toUpperCase()}`;
|
||||
const datagram = packet.payload.datagram;
|
||||
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
||||
}
|
||||
|
||||
/** The engine's run of the scenario's network, without its steps, for `milliseconds`. */
|
||||
function runEngine(scenario: Scenario, milliseconds: number) {
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
const framesByIdentity = new Map<number, EthernetFrame>();
|
||||
const send = engine.sendEthernetFrame.bind(engine);
|
||||
engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => {
|
||||
framesByIdentity.set(frame.identity, frame);
|
||||
send(fromNodeId, outgoingPortId, frame);
|
||||
};
|
||||
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: milliseconds });
|
||||
|
||||
// Every frame when it first shows on a host's card (as FILIUS logs it: sent, or received).
|
||||
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
|
||||
const firstSeen = new Map<number, SeenFrame>();
|
||||
for (const event of engine.state.packetTraces) {
|
||||
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
|
||||
if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) continue;
|
||||
if (firstSeen.has(event.frameIdentity)) continue;
|
||||
firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) });
|
||||
}
|
||||
const devices = Object.fromEntries(
|
||||
[...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [
|
||||
node,
|
||||
[device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer],
|
||||
]),
|
||||
);
|
||||
return { frames: [...firstSeen.values()], devices };
|
||||
}
|
||||
|
||||
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
|
||||
function javaFrames(java: JavaRun): SeenFrame[] {
|
||||
const all = Object.values(java.frames)
|
||||
.flat()
|
||||
.map((frame) => ({ time: frame.time, text: javaText(frame) }))
|
||||
.sort((lhs, rhs) => lhs.time - rhs.time);
|
||||
const frames: SeenFrame[] = [];
|
||||
for (const frame of all) {
|
||||
if (frames.some((each) => each.text === frame.text && frame.time - each.time <= DUPLICATE_WINDOW_MILLISECONDS)) continue;
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Consecutive frames without the other clients' own: the text, and the time since the one before. */
|
||||
function steps(frames: readonly SeenFrame[], otherClientMacs: readonly string[]) {
|
||||
const theirs = (frame: SeenFrame) =>
|
||||
otherClientMacs.some((mac) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`));
|
||||
const own = frames.filter((frame) => !theirs(frame));
|
||||
return own.map((frame, index) => ({
|
||||
text: frame.text.replace(LINK_LOCAL, '169.254.1.x'),
|
||||
gap: index === 0 ? 0 : frame.time - own[index - 1]!.time,
|
||||
}));
|
||||
}
|
||||
|
||||
function clientMac(scenario: Scenario, nodeId: string): string {
|
||||
const port = scenario.nodes.find((node) => node.id === nodeId)!.ports[0]!;
|
||||
return (port.mac ?? stableMacAddress(port.id)).toUpperCase();
|
||||
}
|
||||
|
||||
const runs = readdirSync(scenarioDirectory)
|
||||
.filter((name) => name.endsWith('.java.json'))
|
||||
.map((name) => name.replace('.java.json', ''))
|
||||
.sort();
|
||||
|
||||
describe('DHCP as Java FILIUS runs it', () => {
|
||||
it('has Java runs', () => {
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const name of runs) {
|
||||
it(name, () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const lastJavaFrame = Math.max(...Object.values(java.frames).flatMap((frames) => frames.map((frame) => frame.time)));
|
||||
const engine = runEngine(scenario, lastJavaFrame + 10_000);
|
||||
|
||||
const clients = scenario.dhcpClients ?? [];
|
||||
for (const client of clients) {
|
||||
const others = clients.filter((each) => each !== client).map((each) => clientMac(scenario, each));
|
||||
const expected = steps(javaFrames(java), others);
|
||||
const actual = steps(engine.frames, others);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual(
|
||||
GAP_TOLERANCE_MILLISECONDS,
|
||||
);
|
||||
}
|
||||
}
|
||||
const linkLocal = (devices: Record<string, string[]>) =>
|
||||
Object.fromEntries(Object.entries(devices).map(([node, device]) => [node, device.map((value) => value.replace(LINK_LOCAL, '169.254.1.x'))]));
|
||||
expect(linkLocal(engine.devices)).toEqual(linkLocal(java.devices));
|
||||
});
|
||||
}
|
||||
|
||||
it('builds the MACs as DhcpRun does', () => {
|
||||
expect(stableMacAddress('00000000-0000-0000-0000-000000000112')).toBe('02:00:00:00:01:12');
|
||||
});
|
||||
});
|
||||
@@ -233,7 +233,7 @@ describe('event queue and virtual clock', () => {
|
||||
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })],
|
||||
]),
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
|
||||
|
||||
engine.handle({ type: 'stop' });
|
||||
@@ -377,6 +377,20 @@ describe('Java-compatible randomness', () => {
|
||||
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
|
||||
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
|
||||
});
|
||||
|
||||
it('matches java.util.Random.nextDouble', () => {
|
||||
// Measured on OpenJDK 21 (30.09.2026): nextDouble twice, then
|
||||
// Math.round(nextDouble() * 253f) as the DHCP client draws a link-local address.
|
||||
const cases: [number, number, number, number][] = [
|
||||
[42, 0.7275636800328681, 0.6832234717598454, 78],
|
||||
[0, 0.730967787376657, 0.24053641567148587, 161],
|
||||
[-7, 0.2691218093260761, 0.6731811902798038, 132],
|
||||
];
|
||||
for (const [seed, first, second, rounded] of cases) {
|
||||
const random = new JavaCompatibleRandom(seed);
|
||||
expect([random.nextDouble(), random.nextDouble(), Math.round(random.nextDouble() * 253)]).toEqual([first, second, rounded]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4 packets', () => {
|
||||
@@ -906,6 +920,9 @@ describe('TCP sockets', () => {
|
||||
});
|
||||
|
||||
describe('DHCP', () => {
|
||||
// Java's round trip time at the default speed of 90 %: 50 cables of 55 ms.
|
||||
const RTT = 2_750;
|
||||
|
||||
const serverAndClient = (first: number, serverIp: string, client: DeviceConfiguration, server: Partial<DhcpServerConfiguration>) => {
|
||||
const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string];
|
||||
const engine = startedEngine(
|
||||
@@ -921,7 +938,58 @@ describe('DHCP', () => {
|
||||
},
|
||||
16,
|
||||
);
|
||||
return { engine, clientId, clientPortId };
|
||||
return { engine, serverId, clientId, clientPortId };
|
||||
};
|
||||
|
||||
/** Hosts on one switch, each with its device configuration; the first is the DHCP client. */
|
||||
const onSwitch = (first: number, devices: DeviceConfiguration[], servers: Map<string, Partial<DhcpServerConfiguration>> = new Map()) => {
|
||||
const switchId = uuid(first);
|
||||
const hosts = devices.map((_, index) => ({ id: uuid(first + 1 + 2 * index), portId: uuid(first + 2 + 2 * index), switchPortId: uuid(first + 0x80 + index) }));
|
||||
const switchNode: NodeSnapshot = {
|
||||
id: switchId,
|
||||
kind: 'networkSwitch',
|
||||
ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)),
|
||||
};
|
||||
const hostIds = hosts.map((host) => host.id);
|
||||
const engine = startedEngine(
|
||||
{
|
||||
nodes: [switchNode, ...hosts.map((host) => pc(host.id, host.portId))],
|
||||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||||
deviceConfigurations: new Map(devices.map((each, index) => [hostIds[index]!, each])),
|
||||
dhcpClientConfigurationsByNodeId: new Map([[hostIds[0]!, { isEnabled: true }]]),
|
||||
dhcpServerConfigurationsByNodeId: new Map(
|
||||
[...servers].map(([ipAddress, server]) => [
|
||||
hostIds[devices.findIndex((each) => each.ipAddress === ipAddress)]!,
|
||||
dhcpServerConfiguration({ isActive: true, ...server }),
|
||||
]),
|
||||
),
|
||||
},
|
||||
16,
|
||||
);
|
||||
return { engine, hostIds };
|
||||
};
|
||||
|
||||
/** When the node sent a DHCP message of the type. */
|
||||
const sentAt = (engine: NetworkRuntimeEngine, nodeId: string, type: string) =>
|
||||
engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'application' && trace.operation === 'sent' && trace.detail === type)
|
||||
.map((trace) => trace.timeMilliseconds);
|
||||
|
||||
/** When the node broadcast an ARP packet with the detail. */
|
||||
const arpAt = (engine: NetworkRuntimeEngine, nodeId: string, detail: string) =>
|
||||
engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'dataLink' && trace.operation === 'created' && trace.detail === detail)
|
||||
.map((trace) => trace.timeMilliseconds);
|
||||
|
||||
/** The link-local addresses the client draws with seed 16, as Java's selectArbitraryLinkLocalAddress skips known ones. */
|
||||
const linkLocalDraws = (count: number) => {
|
||||
const random = new JavaCompatibleRandom(16n ^ 0x4c4c_4144n);
|
||||
const draws: string[] = [];
|
||||
while (draws.length < count) {
|
||||
const address = `169.254.1.${Math.round(random.nextDouble() * 253) + 1}`;
|
||||
if (!draws.includes(address)) draws.push(address);
|
||||
}
|
||||
return draws;
|
||||
};
|
||||
|
||||
it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => {
|
||||
@@ -934,17 +1002,23 @@ describe('DHCP', () => {
|
||||
});
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
// A client that runs goes on waiting; it does not start over.
|
||||
expect(engine.runDhcpClient(clientId)?.state).toBe('VALIDATE');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
|
||||
deviceConfiguration('192.168.50.20', '255.255.255.0', '192.168.50.1', '192.168.50.53'),
|
||||
);
|
||||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||||
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]);
|
||||
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 0, true, false]);
|
||||
const leaseExpiry = [...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds;
|
||||
expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds);
|
||||
expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS);
|
||||
const details = engine.state.packetTraces.map((trace) => trace.detail);
|
||||
for (const message of ['DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST', 'DHCPACK']) expect(details).toContain(message);
|
||||
// One message per attempt.
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
|
||||
// The OFFER comes after two cables' delay; the ARP check waits one round trip before the REQUEST.
|
||||
expect(arpAt(engine, clientId, 'ARP request for 192.168.50.20')).toEqual([110]);
|
||||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT]);
|
||||
});
|
||||
|
||||
it('matches a static assignment regardless of case, with a lease that does not expire', () => {
|
||||
@@ -955,20 +1029,19 @@ describe('DHCP', () => {
|
||||
staticAssignments: [{ id: 'static', macAddress: stableMacAddress(clientPortId).toLowerCase(), ipAddress: '10.0.0.99' }],
|
||||
});
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99');
|
||||
expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries ten timeouts and gives back only the old address', () => {
|
||||
it('takes a link-local address after the fifth timeout and does not give back the old one', () => {
|
||||
const clientId = uuid(0x1621);
|
||||
const oldConfiguration = deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53');
|
||||
const engine = startedEngine(
|
||||
{
|
||||
nodes: [pc(clientId, uuid(0x1622))],
|
||||
links: [],
|
||||
deviceConfigurations: new Map([[clientId, oldConfiguration]]),
|
||||
deviceConfigurations: new Map([[clientId, deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53')]]),
|
||||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||||
},
|
||||
16,
|
||||
@@ -977,17 +1050,186 @@ describe('DHCP', () => {
|
||||
engine.handle({ type: 'advance', toMilliseconds: 0 });
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('DISCOVER');
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(0);
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 25_000 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5 * RTT - 1 });
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(4);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||||
|
||||
const [address] = linkLocalDraws(1);
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, RTT, 2 * RTT, 3 * RTT, 4 * RTT]);
|
||||
expect(arpAt(engine, clientId, `ARP request for ${address}`)).toEqual([5 * RTT]);
|
||||
expect(arpAt(engine, clientId, `ARP reply for ${address}`)).toEqual([6 * RTT]);
|
||||
// Java: the address, its /16 mask and no gateway; the DNS server stays.
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
|
||||
deviceConfiguration(address!, '255.255.0.0', '', '169.254.0.53'),
|
||||
);
|
||||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||||
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(oldConfiguration);
|
||||
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 5, true, true]);
|
||||
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
|
||||
expect(engine.state.socketsById.size).toBe(0);
|
||||
});
|
||||
|
||||
it('sends the REQUEST again to the same server when it does not answer', () => {
|
||||
const { engine, serverId, clientId } = serverAndClient(0x1631, '10.0.0.1', device('0.0.0.0'), {
|
||||
lowerBoundIPAddress: '10.0.0.20',
|
||||
upperBoundIPAddress: '10.0.0.29',
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: 150 });
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
|
||||
engine.closeSocket(engine.state.dhcpServerSocketIdsByNodeId.get(serverId)!);
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 110 + 5 * RTT });
|
||||
|
||||
const requests = [1, 2, 3, 4, 5].map((attempt) => 110 + attempt * RTT);
|
||||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual(requests);
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
|
||||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||||
expect([status?.state, status?.errorCount, status?.offeredIPAddress]).toEqual(['REQUEST', 4, '10.0.0.20']);
|
||||
});
|
||||
|
||||
it('declines at once when another card answers for the offered address', () => {
|
||||
const { engine, hostIds } = onSwitch(
|
||||
0x1641,
|
||||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.100')],
|
||||
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
|
||||
);
|
||||
const clientId = hostIds[0]!;
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
|
||||
// The OFFER takes two cables to the client, the ARP request and the
|
||||
// holder's reply two each; the DECLINE follows the reply, not the round trip.
|
||||
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
|
||||
expect(check).toBe(220);
|
||||
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([check! + 220]);
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, check! + 220]);
|
||||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts over with DISCOVER after a NAK, which is no failure', () => {
|
||||
const { engine, serverId, clientId } = serverAndClient(0x1671, '10.0.0.1', device('0.0.0.0'), {
|
||||
lowerBoundIPAddress: '10.0.0.20',
|
||||
upperBoundIPAddress: '10.0.0.29',
|
||||
});
|
||||
engine.handle({ type: 'advance', toMilliseconds: 150 });
|
||||
// While the client checks 10.0.0.20, the server bars it: the REQUEST gets a NAK.
|
||||
engine.state.dhcpBlacklistByServerNodeId.set(serverId, new Map([['10.0.0.20', 1_000_000]]));
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
|
||||
const nak = 110 + RTT + 110;
|
||||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT, nak + 110 + RTT]);
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, nak]);
|
||||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||||
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.21');
|
||||
});
|
||||
|
||||
it('ignores an answer from another server than the one it asked', () => {
|
||||
const { engine, hostIds } = onSwitch(
|
||||
0x1681,
|
||||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.66')],
|
||||
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
|
||||
);
|
||||
const [clientId, , rogueId] = hostIds as [string, string, string];
|
||||
const clientMac = stableMacAddress(uuid(0x1681 + 2));
|
||||
const request = 220 + RTT;
|
||||
engine.handle({ type: 'advance', toMilliseconds: request + 10 });
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('REQUEST');
|
||||
|
||||
// A NAK for the client, but from 192.168.0.66; it arrives before the server's ACK.
|
||||
const rogue = engine.bindUdpSocket(rogueId, { localPort: 67 })!;
|
||||
const nak = encodeDhcpMessage({ type: 'DHCPNAK', yourIPAddress: '192.168.0.100', clientMacAddress: clientMac, serverIdentifier: '192.168.0.66' });
|
||||
engine.sendUdp(rogue, nak, { ipAddress: '255.255.255.255', port: 68 });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
|
||||
});
|
||||
|
||||
it("takes the other server's queued OFFER at once after a DECLINE", () => {
|
||||
// Server A offers the address a third card has, server B another one; B's
|
||||
// OFFER comes while the client checks A's and waits in its queue.
|
||||
const { engine, hostIds } = onSwitch(
|
||||
0x1691,
|
||||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.20'), device('192.168.0.100')],
|
||||
new Map([
|
||||
['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }],
|
||||
['192.168.0.20', { lowerBoundIPAddress: '192.168.0.200', upperBoundIPAddress: '192.168.0.200' }],
|
||||
]),
|
||||
);
|
||||
const clientId = hostIds[0]!;
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
|
||||
const decline = 220 + 220;
|
||||
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([decline]);
|
||||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, decline]);
|
||||
expect(arpAt(engine, clientId, 'ARP request for 192.168.0.200')).toEqual([decline]);
|
||||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([decline + RTT]);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.200');
|
||||
});
|
||||
|
||||
it('skips a link-local address the ARP cache knows', () => {
|
||||
const [known, next] = linkLocalDraws(2) as [string, string];
|
||||
const { engine, hostIds } = onSwitch(0x16a1, [
|
||||
device('0.0.0.0'),
|
||||
device(known, { mask: '255.255.0.0' }),
|
||||
device('169.254.9.9', { mask: '255.255.0.0' }),
|
||||
]);
|
||||
const clientId = hostIds[0]!;
|
||||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||||
engine.resolveMacAddress(hostIds[1]!, '169.254.9.9', { maxRetries: 1 });
|
||||
expect(engine.state.arpCachesByNodeId.get(clientId)?.has(known)).toBe(true);
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||||
|
||||
expect(arpAt(engine, clientId, `ARP request for ${known}`)).toEqual([]);
|
||||
expect(arpAt(engine, clientId, `ARP request for ${next}`)).toEqual([5 * RTT]);
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(5);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe(next);
|
||||
});
|
||||
|
||||
// Java's selectArbitraryLinkLocalAddress loop counts occupied addresses
|
||||
// but checks its limit only after a timeout, which cannot come there: it
|
||||
// searches without end. The engine gives up after the fifth, as
|
||||
// LINK_LOCAL_MAX_ERROR_COUNT intends, and keeps 0.0.0.0.
|
||||
it('gives up after five occupied link-local addresses (Java searches on without end)', () => {
|
||||
const occupied = linkLocalDraws(5);
|
||||
const { engine, hostIds } = onSwitch(0x1651, [device('0.0.0.0'), ...occupied.map((address) => device(address, { mask: '255.255.0.0' }))]);
|
||||
const clientId = hostIds[0]!;
|
||||
|
||||
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
|
||||
|
||||
for (const address of occupied) expect(arpAt(engine, clientId, `ARP request for ${address}`)).toHaveLength(1);
|
||||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||||
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
|
||||
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
|
||||
});
|
||||
|
||||
// Java's ARP lookup waits on the whole ARP table: any entry that changes
|
||||
// ends the wait, and the address counts as free. The engine waits for the
|
||||
// address it checks, or the round trip.
|
||||
it('waits out the address check when an unrelated ARP packet comes (Java would stop waiting)', () => {
|
||||
const { engine, hostIds } = onSwitch(
|
||||
0x1661,
|
||||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.30')],
|
||||
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
|
||||
);
|
||||
const clientId = hostIds[0]!;
|
||||
engine.handle({ type: 'advance', toMilliseconds: 300 });
|
||||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
|
||||
|
||||
engine.resolveMacAddress(hostIds[2]!, '192.168.0.77', { maxRetries: 1 });
|
||||
expect(engine.state.arpCachesByNodeId.get(clientId)?.has('192.168.0.30')).toBe(true);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
|
||||
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
|
||||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([check! + RTT]);
|
||||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
|
||||
});
|
||||
|
||||
it("writes and reads a message in Swift's text format", () => {
|
||||
const message = {
|
||||
type: 'DHCPOFFER' as const,
|
||||
|
||||
@@ -8,178 +8,16 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deviceConfiguration,
|
||||
dhcpServerConfiguration,
|
||||
firewallConfiguration,
|
||||
firewallRule,
|
||||
interfaceKey,
|
||||
NetworkRuntimeEngine,
|
||||
portSnapshot,
|
||||
switchConfiguration,
|
||||
topologySnapshot,
|
||||
type DhcpClientStatus,
|
||||
type FirewallConfiguration,
|
||||
type IPv4DeliveryResult,
|
||||
type IcmpObservation,
|
||||
type ManualRoute,
|
||||
type NodeKind,
|
||||
type TopologySnapshot,
|
||||
type TraceEvent,
|
||||
} from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
|
||||
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[];
|
||||
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;
|
||||
}[];
|
||||
}
|
||||
|
||||
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 }])),
|
||||
switchConfigurationsByNodeId: new Map(
|
||||
Object.entries(scenario.switches ?? {}).map(([node, config]) => [
|
||||
node,
|
||||
config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention),
|
||||
]),
|
||||
),
|
||||
firewallConfigurationsByNodeId: firewalls,
|
||||
});
|
||||
}
|
||||
|
||||
const upper = (id: string) => id.toUpperCase();
|
||||
|
||||
function dhcpStatusJson(status: DhcpClientStatus) {
|
||||
@@ -439,24 +277,22 @@ const scenarioNames = readdirSync(scenarioDirectory)
|
||||
.sort();
|
||||
|
||||
/**
|
||||
* Where the Swift engine is wrong, per scenario: its result corrected.
|
||||
*
|
||||
* dhcp-lan: a DHCP client run directly (not from the event queue) sends its
|
||||
* DISCOVER at once; the server's OFFER arrives within that send, and the
|
||||
* UDP delivery advances the client again — to FINISH, with the address set.
|
||||
* The outer run then writes back its stale copy of the status (Swift's
|
||||
* value types): DISCOVER, not succeeded. The address and every trace are
|
||||
* the same; only the reported status is wrong in Swift.
|
||||
* Where the Swift engine is wrong, per scenario: its result corrected. None
|
||||
* at the moment. (Until F2.3b: dhcp-lan, the status a directly run DHCP
|
||||
* client reported — Swift wrote back a stale copy after a nested run.)
|
||||
*/
|
||||
const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
'dhcp-lan': (expected) => {
|
||||
const client = '00000000-0000-0000-0000-000000000102';
|
||||
const finished = { errors: 0, offered: '192.168.0.102', server: '192.168.0.10', state: 'FINISH', succeeded: true };
|
||||
const results = [...(expected.results as unknown[])];
|
||||
results[1] = finished;
|
||||
const dhcp = expected.dhcp as { clients: Record<string, unknown> };
|
||||
return { ...expected, results, dhcp: { ...dhcp, clients: { ...dhcp.clients, [client]: finished } } };
|
||||
},
|
||||
const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record<string, unknown>> = {};
|
||||
|
||||
/**
|
||||
* Scenarios where the engine follows Java instead of Swift; the Java
|
||||
* comparison checks them (dhcpJava.test.ts, measured with Java FILIUS). Here
|
||||
* they must differ from Swift, or the departure is not real.
|
||||
*/
|
||||
const JAVA_INSTEAD_OF_SWIFT: Record<string, string> = {
|
||||
'dhcp-lan': 'DHCP client: one message per attempt, the ARP check waits a round trip, which follows the speed',
|
||||
'dhcp-two-servers': 'DHCP client: the second OFFER is read and dropped, no REQUEST twice (Swift: answered with NAK)',
|
||||
'dhcp-conflict': 'DHCP client: the ARP check finds the address taken and declines (Swift: it does not wait, two cards share the address)',
|
||||
'dhcp-no-server': 'DHCP client: a link-local address after five timeouts (Swift: the old address after ten)',
|
||||
};
|
||||
|
||||
describe('same result as the Swift engine', () => {
|
||||
@@ -471,7 +307,15 @@ describe('same result as the Swift engine', () => {
|
||||
expect(scenarioNames.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const name of scenarioNames) {
|
||||
for (const [name, reason] of Object.entries(JAVA_INSTEAD_OF_SWIFT)) {
|
||||
it(`${name} follows Java instead: ${reason}`, () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
||||
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
|
||||
expect(run(scenario)).not.toEqual(swift);
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) {
|
||||
it(name, () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
||||
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// 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,
|
||||
} 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[];
|
||||
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 }])),
|
||||
switchConfigurationsByNodeId: new Map(
|
||||
Object.entries(scenario.switches ?? {}).map(([node, config]) => [
|
||||
node,
|
||||
config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention),
|
||||
]),
|
||||
),
|
||||
firewallConfigurationsByNodeId: firewalls,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user