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:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user