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:
+330
-121
@@ -7,7 +7,10 @@
|
||||
// "UDP sockets and Router RIP" (lines ~4472–5187, the
|
||||
// UDP sockets; RIP not yet)
|
||||
// "Java-compatible TCP sockets" (lines ~5187–5913)
|
||||
// "DHCP server and automatic client" (lines ~5913–6694)
|
||||
// "DHCP server and automatic client" (lines ~5913–6694; the
|
||||
// client rebuilt after Java's DHCPClient, measured with Java FILIUS:
|
||||
// one message per attempt, an ARP check that waits a round trip,
|
||||
// link-local after five failures; round trip times follow the speed)
|
||||
// processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
|
||||
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
|
||||
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
|
||||
@@ -273,10 +276,15 @@ export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable'
|
||||
|
||||
export const DHCP_SERVER_PORT = 67;
|
||||
export const DHCP_CLIENT_PORT = 68;
|
||||
export const DHCP_ROUND_TRIP_TIME_MILLISECONDS = 2_500;
|
||||
export const DHCP_OFFER_LIFETIME_MILLISECONDS = 4 * DHCP_ROUND_TRIP_TIME_MILLISECONDS;
|
||||
/** Java: an offer, and a blacklisted address, is kept 4 round trip times. */
|
||||
export const DHCP_OFFER_LIFETIME_ROUND_TRIPS = 4;
|
||||
export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000;
|
||||
export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 10;
|
||||
/** Java's DHCP_MAX_ERROR_COUNT: the client's failures before it takes a link-local address. */
|
||||
export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 5;
|
||||
/** Java's LINK_LOCAL_MAX_ERROR_COUNT: occupied link-local addresses before the client gives up. */
|
||||
export const DHCP_LINK_LOCAL_MAXIMUM_ERROR_COUNT = 5;
|
||||
export const LINK_LOCAL_ADDRESS_PREFIX = '169.254.1.';
|
||||
export const LINK_LOCAL_SUBNET_MASK = '255.255.0.0';
|
||||
|
||||
export type DhcpMessageType = 'DHCPDISCOVER' | 'DHCPREQUEST' | 'DHCPACK' | 'DHCPNAK' | 'DHCPOFFER' | 'DHCPDECLINE';
|
||||
const DHCP_MESSAGE_TYPES: readonly DhcpMessageType[] = ['DHCPDISCOVER', 'DHCPREQUEST', 'DHCPACK', 'DHCPNAK', 'DHCPOFFER', 'DHCPDECLINE'];
|
||||
@@ -308,23 +316,47 @@ export interface DhcpLeaseRecord {
|
||||
readonly expiresAtMilliseconds: number | undefined;
|
||||
}
|
||||
|
||||
export type DhcpClientState = 'INIT' | 'DISCOVER' | 'VALIDATE' | 'DECLINE' | 'REQUEST' | 'ASSIGN_IP' | 'FINISH';
|
||||
/** Java's DHCPClient.State; FINISH is Java's SUCCEED and its giving up. */
|
||||
export type DhcpClientState =
|
||||
| 'INIT'
|
||||
| 'DISCOVER'
|
||||
| 'VALIDATE'
|
||||
| 'DECLINE'
|
||||
| 'REQUEST'
|
||||
| 'ASSIGN_IP'
|
||||
| 'LINK_LOCAL_INIT'
|
||||
| 'LINK_LOCAL_VALIDATE'
|
||||
| 'LINK_LOCAL_ANNOUNCE'
|
||||
| 'FINISH';
|
||||
|
||||
export interface DhcpClientStatus {
|
||||
state: DhcpClientState;
|
||||
/** Java's failureCount: timeouts while waiting for a server, then occupied link-local addresses. */
|
||||
errorCount: number;
|
||||
selectedServerIPAddress: string | undefined;
|
||||
offeredIPAddress: string | undefined;
|
||||
/** Configured, by a server or with a link-local address. */
|
||||
succeeded: boolean;
|
||||
linkLocal: boolean;
|
||||
}
|
||||
|
||||
/** What the client waits for, at most until the deadline: a server's answer, or an ARP answer for an address. */
|
||||
export type DhcpClientWait =
|
||||
| {
|
||||
readonly kind: 'response';
|
||||
readonly acceptedTypes: readonly DhcpMessageType[];
|
||||
readonly serverIdentifier: string | undefined;
|
||||
readonly deadlineMilliseconds: number;
|
||||
}
|
||||
| { readonly kind: 'arp'; readonly ipAddress: string; readonly deadlineMilliseconds: number };
|
||||
|
||||
export interface DhcpClientContext {
|
||||
readonly interfaceId: Id;
|
||||
readonly socketId: Id;
|
||||
readonly clientMacAddress: string;
|
||||
readonly oldDeviceConfiguration: DeviceConfiguration | undefined;
|
||||
readonly oldInterfaceConfiguration: InterfaceConfiguration | undefined;
|
||||
selectedOffer: DhcpMessage | undefined;
|
||||
linkLocalIPAddress: string | undefined;
|
||||
waiting: DhcpClientWait | undefined;
|
||||
}
|
||||
|
||||
/** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */
|
||||
@@ -489,6 +521,7 @@ export class NetworkRuntimeEngine {
|
||||
private random: JavaCompatibleRandom;
|
||||
private udpPortRandom: JavaCompatibleRandom;
|
||||
private tcpPortRandom: JavaCompatibleRandom;
|
||||
private linkLocalRandom: JavaCompatibleRandom;
|
||||
private runtimeSpeed: RuntimeSpeed;
|
||||
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
|
||||
private isProcessingScheduledEvents = false;
|
||||
@@ -503,6 +536,7 @@ export class NetworkRuntimeEngine {
|
||||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||||
this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n);
|
||||
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
|
||||
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
|
||||
this.runtimeSpeed = new RuntimeSpeed();
|
||||
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
|
||||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||||
@@ -539,6 +573,7 @@ export class NetworkRuntimeEngine {
|
||||
this.random = new JavaCompatibleRandom(seed);
|
||||
this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n);
|
||||
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
|
||||
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
|
||||
this.isProcessingScheduledEvents = false;
|
||||
this.pendingIPv4TransmissionsByArpKey.clear();
|
||||
this.initializeRipState();
|
||||
@@ -1001,9 +1036,7 @@ export class NetworkRuntimeEngine {
|
||||
beforeHeaders: udpHeaders(datagram),
|
||||
detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued',
|
||||
});
|
||||
if (datagram.destinationPort === DHCP_CLIENT_PORT && state.dhcpClientContextsByNodeId.has(nodeId)) {
|
||||
this.advanceDhcpClient(nodeId);
|
||||
}
|
||||
if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId);
|
||||
}
|
||||
|
||||
// ── TCP sockets ──────────────────────────────────────────────────────────
|
||||
@@ -1669,9 +1702,9 @@ export class NetworkRuntimeEngine {
|
||||
// ── DHCP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Starts (or goes on with) the node's DHCP client: its address set to
|
||||
* 0.0.0.0, a socket on port 68, then the client's states as far as the
|
||||
* answers so far allow.
|
||||
* Starts the node's DHCP client as Java's DHCPClient.configure: its address
|
||||
* set to 0.0.0.0, a socket on port 68, then the client's states as far as
|
||||
* the answers so far allow. A client that is running goes on waiting.
|
||||
*/
|
||||
runDhcpClient(nodeId: Id): DhcpClientStatus | undefined {
|
||||
const state = this.stateValue;
|
||||
@@ -1686,10 +1719,7 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
const clientInterface = this.dhcpClientInterface(nodeId);
|
||||
if (clientInterface === undefined) return undefined;
|
||||
if (state.dhcpClientContextsByNodeId.has(nodeId)) {
|
||||
this.advanceDhcpClient(nodeId);
|
||||
return state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
}
|
||||
if (state.dhcpClientContextsByNodeId.has(nodeId)) return state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
const oldDeviceConfiguration = state.topologySnapshot.deviceConfigurations.get(nodeId);
|
||||
const oldInterfaceConfiguration = state.topologySnapshot.interfaceConfigurations.get(interfaceKey(nodeId, clientInterface.portId));
|
||||
this.setDhcpClientIPAddress(nodeId, clientInterface.portId, UNSPECIFIED_IP_ADDRESS);
|
||||
@@ -1711,127 +1741,253 @@ export class NetworkRuntimeEngine {
|
||||
interfaceId: clientInterface.portId,
|
||||
socketId,
|
||||
clientMacAddress: resetInterface.macAddress,
|
||||
oldDeviceConfiguration,
|
||||
oldInterfaceConfiguration,
|
||||
selectedOffer: undefined,
|
||||
linkLocalIPAddress: undefined,
|
||||
waiting: undefined,
|
||||
});
|
||||
state.dhcpClientStatusesByNodeId.set(nodeId, dhcpClientStatus());
|
||||
this.advanceDhcpClient(nodeId);
|
||||
return state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's configure loop, one state after the other until the client waits
|
||||
* (for a server's answer or an ARP answer) or is done. Each attempt sends
|
||||
* its message once; an answer that does not fit is read and dropped, and
|
||||
* the client waits on.
|
||||
*/
|
||||
private advanceDhcpClient(nodeId: Id): void {
|
||||
const state = this.stateValue;
|
||||
for (;;) {
|
||||
const context = state.dhcpClientContextsByNodeId.get(nodeId);
|
||||
const status = state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
if (state.phase !== 'running' || context === undefined || status === undefined || status.state === 'FINISH') return;
|
||||
const backToDiscover = () => {
|
||||
status.errorCount += 1;
|
||||
status.state = 'DISCOVER';
|
||||
};
|
||||
if (state.phase !== 'running' || context === undefined || status === undefined) return;
|
||||
if (status.state === 'FINISH' || context.waiting !== undefined) return;
|
||||
switch (status.state) {
|
||||
case 'INIT':
|
||||
status.state = 'DISCOVER';
|
||||
break;
|
||||
case 'DISCOVER': {
|
||||
case 'DISCOVER':
|
||||
this.sendDhcpMessage(context.socketId, {
|
||||
type: 'DHCPDISCOVER',
|
||||
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
|
||||
clientMacAddress: context.clientMacAddress,
|
||||
});
|
||||
const offer = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, undefined, ['DHCPOFFER']);
|
||||
if (offer === undefined) {
|
||||
this.scheduleDhcpTimeout(nodeId);
|
||||
return;
|
||||
}
|
||||
context.selectedOffer = offer;
|
||||
status.selectedServerIPAddress = offer.serverIdentifier;
|
||||
status.offeredIPAddress = offer.yourIPAddress;
|
||||
status.state = 'VALIDATE';
|
||||
this.waitForDhcpResponse(nodeId, ['DHCPOFFER'], undefined);
|
||||
break;
|
||||
}
|
||||
case 'VALIDATE': {
|
||||
const offer = context.selectedOffer;
|
||||
if (offer === undefined) {
|
||||
backToDiscover();
|
||||
status.state = 'DISCOVER';
|
||||
break;
|
||||
}
|
||||
const conflictMac = this.resolveMacAddress(nodeId, offer.yourIPAddress, {
|
||||
preferredInterfaceId: context.interfaceId,
|
||||
maxRetries: 1,
|
||||
selectInterfaceByAddress: false,
|
||||
});
|
||||
status.state = conflictMac === undefined ? 'REQUEST' : 'DECLINE';
|
||||
this.checkDhcpClientAddress(nodeId, offer.yourIPAddress);
|
||||
break;
|
||||
}
|
||||
case 'DECLINE': {
|
||||
const offer = context.selectedOffer;
|
||||
if (offer === undefined) {
|
||||
backToDiscover();
|
||||
break;
|
||||
if (offer !== undefined) {
|
||||
this.sendDhcpMessage(context.socketId, {
|
||||
type: 'DHCPDECLINE',
|
||||
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
|
||||
clientMacAddress: context.clientMacAddress,
|
||||
serverIdentifier: offer.serverIdentifier,
|
||||
requestedIPAddress: offer.yourIPAddress,
|
||||
});
|
||||
}
|
||||
this.sendDhcpMessage(context.socketId, {
|
||||
type: 'DHCPDECLINE',
|
||||
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
|
||||
clientMacAddress: context.clientMacAddress,
|
||||
serverIdentifier: offer.serverIdentifier,
|
||||
requestedIPAddress: offer.yourIPAddress,
|
||||
});
|
||||
context.selectedOffer = undefined;
|
||||
status.selectedServerIPAddress = undefined;
|
||||
status.offeredIPAddress = undefined;
|
||||
status.state = 'DISCOVER';
|
||||
break;
|
||||
}
|
||||
case 'REQUEST': {
|
||||
const offer = context.selectedOffer;
|
||||
const serverIdentifier = offer?.serverIdentifier;
|
||||
if (offer === undefined || serverIdentifier === undefined) {
|
||||
backToDiscover();
|
||||
if (offer === undefined) {
|
||||
status.state = 'DISCOVER';
|
||||
break;
|
||||
}
|
||||
this.sendDhcpMessage(context.socketId, {
|
||||
type: 'DHCPREQUEST',
|
||||
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
|
||||
clientMacAddress: context.clientMacAddress,
|
||||
serverIdentifier,
|
||||
serverIdentifier: offer.serverIdentifier,
|
||||
requestedIPAddress: offer.yourIPAddress,
|
||||
});
|
||||
const response = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, serverIdentifier, ['DHCPACK', 'DHCPNAK']);
|
||||
if (response === undefined) {
|
||||
this.scheduleDhcpTimeout(nodeId);
|
||||
return;
|
||||
}
|
||||
if (response.type === 'DHCPACK') {
|
||||
status.state = 'ASSIGN_IP';
|
||||
} else {
|
||||
context.selectedOffer = undefined;
|
||||
status.selectedServerIPAddress = undefined;
|
||||
status.offeredIPAddress = undefined;
|
||||
status.state = 'DISCOVER';
|
||||
}
|
||||
this.waitForDhcpResponse(nodeId, ['DHCPACK', 'DHCPNAK'], offer.serverIdentifier);
|
||||
break;
|
||||
}
|
||||
case 'ASSIGN_IP': {
|
||||
const offer = context.selectedOffer;
|
||||
if (offer === undefined) {
|
||||
backToDiscover();
|
||||
status.state = 'DISCOVER';
|
||||
break;
|
||||
}
|
||||
this.applyDhcpConfiguration(nodeId, context.interfaceId, offer);
|
||||
status.succeeded = true;
|
||||
status.state = 'FINISH';
|
||||
this.finishDhcpClient(nodeId, false);
|
||||
this.finishDhcpClient(nodeId);
|
||||
return;
|
||||
}
|
||||
case 'LINK_LOCAL_INIT':
|
||||
context.linkLocalIPAddress = this.selectLinkLocalAddress(nodeId);
|
||||
status.state = 'LINK_LOCAL_VALIDATE';
|
||||
break;
|
||||
case 'LINK_LOCAL_VALIDATE':
|
||||
this.checkDhcpClientAddress(nodeId, context.linkLocalIPAddress ?? UNSPECIFIED_IP_ADDRESS);
|
||||
break;
|
||||
case 'LINK_LOCAL_ANNOUNCE':
|
||||
this.announceLinkLocalAddress(nodeId, context);
|
||||
status.succeeded = true;
|
||||
status.linkLocal = true;
|
||||
status.state = 'FINISH';
|
||||
this.finishDhcpClient(nodeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDhcpTimeout(nodeId: Id): void {
|
||||
/** Java's receiveResponse: waits one RTT, reading what is queued first. */
|
||||
private waitForDhcpResponse(nodeId: Id, acceptedTypes: readonly DhcpMessageType[], serverIdentifier: string | undefined): void {
|
||||
const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId);
|
||||
if (context === undefined) return;
|
||||
const deadlineMilliseconds = this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds;
|
||||
context.waiting = { kind: 'response', acceptedTypes, serverIdentifier, deadlineMilliseconds };
|
||||
this.scheduleDhcpTimeout(nodeId, deadlineMilliseconds);
|
||||
this.readDhcpResponse(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the client's queue while it waits for a server: drops what does not
|
||||
* fit and takes the first answer that does. False when there is none yet.
|
||||
*/
|
||||
private readDhcpResponse(nodeId: Id): boolean {
|
||||
const state = this.stateValue;
|
||||
const context = state.dhcpClientContextsByNodeId.get(nodeId);
|
||||
const status = state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
const wait = context?.waiting;
|
||||
if (context === undefined || status === undefined || wait?.kind !== 'response') return false;
|
||||
const response = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, wait.serverIdentifier, wait.acceptedTypes);
|
||||
if (response === undefined) return false;
|
||||
context.waiting = undefined;
|
||||
this.removeDhcpTimeouts(nodeId);
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + DHCP_ROUND_TRIP_TIME_MILLISECONDS, { kind: 'dhcpTimeout', nodeId });
|
||||
switch (response.type) {
|
||||
case 'DHCPOFFER':
|
||||
context.selectedOffer = response;
|
||||
status.selectedServerIPAddress = response.serverIdentifier;
|
||||
status.offeredIPAddress = response.yourIPAddress;
|
||||
status.state = 'VALIDATE';
|
||||
break;
|
||||
case 'DHCPACK':
|
||||
status.state = 'ASSIGN_IP';
|
||||
break;
|
||||
default:
|
||||
context.selectedOffer = undefined;
|
||||
status.selectedServerIPAddress = undefined;
|
||||
status.offeredIPAddress = undefined;
|
||||
status.state = 'DISCOVER';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's validateOfferedAddress: whether another card has the address. A
|
||||
* known answer (the node's own address, the ARP cache) decides at once;
|
||||
* otherwise one ARP request, and the client waits one RTT for its answer.
|
||||
*/
|
||||
private checkDhcpClientAddress(nodeId: Id, ipAddress: string): void {
|
||||
const state = this.stateValue;
|
||||
const context = state.dhcpClientContextsByNodeId.get(nodeId);
|
||||
if (context === undefined) return;
|
||||
if (this.knownMacAddress(nodeId, ipAddress) !== undefined) {
|
||||
this.finishDhcpClientAddressCheck(nodeId, true);
|
||||
return;
|
||||
}
|
||||
const clientInterface = this.networkInterfaces(nodeId).find((each) => each.portId === context.interfaceId);
|
||||
if (clientInterface !== undefined) this.sendArpRequest(nodeId, clientInterface, ipAddress);
|
||||
const deadlineMilliseconds = state.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds;
|
||||
context.waiting = { kind: 'arp', ipAddress, deadlineMilliseconds };
|
||||
this.scheduleDhcpTimeout(nodeId, deadlineMilliseconds);
|
||||
}
|
||||
|
||||
/** An ARP packet taught the node `ipAddress`: a client checking that address has its answer. */
|
||||
private dhcpClientLearnedAddress(nodeId: Id, ipAddress: string): void {
|
||||
const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId);
|
||||
const wait = context?.waiting;
|
||||
if (context === undefined || wait?.kind !== 'arp' || wait.ipAddress !== ipAddress) return;
|
||||
context.waiting = undefined;
|
||||
this.removeDhcpTimeouts(nodeId);
|
||||
this.finishDhcpClientAddressCheck(nodeId, true);
|
||||
this.advanceDhcpClient(nodeId);
|
||||
}
|
||||
|
||||
private finishDhcpClientAddressCheck(nodeId: Id, occupied: boolean): void {
|
||||
const state = this.stateValue;
|
||||
const context = state.dhcpClientContextsByNodeId.get(nodeId);
|
||||
const status = state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
if (context === undefined || status === undefined) return;
|
||||
if (status.state === 'VALIDATE') {
|
||||
status.state = occupied ? 'DECLINE' : 'REQUEST';
|
||||
return;
|
||||
}
|
||||
if (status.state !== 'LINK_LOCAL_VALIDATE') return;
|
||||
if (!occupied) {
|
||||
this.applyLinkLocalConfiguration(nodeId, context.interfaceId, context.linkLocalIPAddress ?? UNSPECIFIED_IP_ADDRESS);
|
||||
status.state = 'LINK_LOCAL_ANNOUNCE';
|
||||
return;
|
||||
}
|
||||
status.errorCount += 1;
|
||||
// Java counts on without end here (its limit is checked only after a
|
||||
// timeout); the client gives up after LINK_LOCAL_MAX_ERROR_COUNT, as Java intends.
|
||||
if (status.errorCount >= DHCP_MAXIMUM_CLIENT_ERROR_COUNT + DHCP_LINK_LOCAL_MAXIMUM_ERROR_COUNT) {
|
||||
status.state = 'FINISH';
|
||||
this.finishDhcpClient(nodeId);
|
||||
return;
|
||||
}
|
||||
status.state = 'LINK_LOCAL_INIT';
|
||||
}
|
||||
|
||||
/** Java's selectArbitraryLinkLocalAddress: 169.254.1.1 to .254 at random, not one the ARP cache knows. */
|
||||
private selectLinkLocalAddress(nodeId: Id): string {
|
||||
const cache = this.stateValue.arpCachesByNodeId.get(nodeId);
|
||||
let lastByte = 0;
|
||||
for (let attempt = 0; attempt < 500 && lastByte === 0; attempt += 1) {
|
||||
lastByte = Math.round(this.linkLocalRandom.nextDouble() * 253) + 1;
|
||||
if (cache?.has(LINK_LOCAL_ADDRESS_PREFIX + lastByte) === true) lastByte = 0;
|
||||
}
|
||||
return LINK_LOCAL_ADDRESS_PREFIX + lastByte;
|
||||
}
|
||||
|
||||
/** Java's LINK_LOCAL_ANNOUNCE: an ARP reply with the new address to every card. */
|
||||
private announceLinkLocalAddress(nodeId: Id, context: DhcpClientContext): void {
|
||||
const clientInterface = this.networkInterfaces(nodeId).find((each) => each.portId === context.interfaceId);
|
||||
if (clientInterface === undefined) return;
|
||||
const reply: ArpPacket = {
|
||||
operation: 'reply',
|
||||
senderMacAddress: clientInterface.macAddress,
|
||||
senderIPAddress: clientInterface.ipAddress,
|
||||
targetMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||||
targetIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||||
};
|
||||
const frame: EthernetFrame = {
|
||||
identity: this.allocateFrameIdentity(),
|
||||
sourceMacAddress: clientInterface.macAddress,
|
||||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||||
payload: { kind: 'arp', packet: reply },
|
||||
};
|
||||
this.recordTrace({
|
||||
frameIdentity: frame.identity,
|
||||
nodeId,
|
||||
interfaceId: clientInterface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'dataLink',
|
||||
operation: 'created',
|
||||
afterHeaders: ethernetHeaders(frame),
|
||||
detail: `ARP reply for ${clientInterface.ipAddress}`,
|
||||
});
|
||||
this.sendEthernetFrame(nodeId, clientInterface.portId, frame);
|
||||
}
|
||||
|
||||
private scheduleDhcpTimeout(nodeId: Id, deadlineMilliseconds: number): void {
|
||||
this.removeDhcpTimeouts(nodeId);
|
||||
this.schedule(deadlineMilliseconds, { kind: 'dhcpTimeout', nodeId });
|
||||
}
|
||||
|
||||
private removeDhcpTimeouts(nodeId: Id): void {
|
||||
@@ -1840,27 +1996,38 @@ export class NetworkRuntimeEngine {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait is over. No ARP answer: the address is free. No server's answer
|
||||
* (Java's TimeOutException): one failure more, and the state sends its
|
||||
* message again — DISCOVER, or REQUEST to the same server; the fifth
|
||||
* failure turns to a link-local address.
|
||||
*/
|
||||
private handleDhcpClientTimeout(nodeId: Id): void {
|
||||
const state = this.stateValue;
|
||||
const context = state.dhcpClientContextsByNodeId.get(nodeId);
|
||||
const status = state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
if (!state.dhcpClientContextsByNodeId.has(nodeId) || status === undefined || status.state === 'FINISH') return;
|
||||
status.errorCount += 1;
|
||||
if (status.errorCount >= DHCP_MAXIMUM_CLIENT_ERROR_COUNT) {
|
||||
status.state = 'FINISH';
|
||||
status.succeeded = false;
|
||||
this.finishDhcpClient(nodeId, true);
|
||||
return;
|
||||
const wait = context?.waiting;
|
||||
if (context === undefined || status === undefined || wait === undefined) return;
|
||||
context.waiting = undefined;
|
||||
if (wait.kind === 'arp') {
|
||||
this.finishDhcpClientAddressCheck(nodeId, this.knownMacAddress(nodeId, wait.ipAddress) !== undefined);
|
||||
} else {
|
||||
status.errorCount += 1;
|
||||
if (status.errorCount === DHCP_MAXIMUM_CLIENT_ERROR_COUNT) {
|
||||
context.selectedOffer = undefined;
|
||||
status.selectedServerIPAddress = undefined;
|
||||
status.offeredIPAddress = undefined;
|
||||
status.state = 'LINK_LOCAL_INIT';
|
||||
}
|
||||
}
|
||||
this.advanceDhcpClient(nodeId);
|
||||
}
|
||||
|
||||
private finishDhcpClient(nodeId: Id, restoreOldIPAddress: boolean): void {
|
||||
/** Java closes the socket and keeps what the client set: an address, or 0.0.0.0. */
|
||||
private finishDhcpClient(nodeId: Id): void {
|
||||
const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId);
|
||||
if (context === undefined) return;
|
||||
this.stateValue.dhcpClientContextsByNodeId.delete(nodeId);
|
||||
if (restoreOldIPAddress) {
|
||||
this.restoreDhcpClientOldIPAddress(nodeId, context.interfaceId, context.oldDeviceConfiguration, context.oldInterfaceConfiguration);
|
||||
}
|
||||
this.closeSocket(context.socketId);
|
||||
this.removeDhcpTimeouts(nodeId);
|
||||
}
|
||||
@@ -1914,7 +2081,7 @@ export class NetworkRuntimeEngine {
|
||||
// Another server was chosen: this one keeps the address back for a while.
|
||||
getOrCreate(state.dhcpBlacklistByServerNodeId, nodeId, () => new Map()).set(
|
||||
requestedIPAddress.toLowerCase(),
|
||||
state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS,
|
||||
state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -1957,7 +2124,7 @@ export class NetworkRuntimeEngine {
|
||||
clientMacAddress,
|
||||
offeredIPAddress: address,
|
||||
serverNodeId,
|
||||
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS,
|
||||
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds,
|
||||
});
|
||||
return address;
|
||||
}
|
||||
@@ -2121,6 +2288,28 @@ export class NetworkRuntimeEngine {
|
||||
this.replaceTopologyConfigurations(devices, interfaces);
|
||||
}
|
||||
|
||||
/** Java: the link-local address with its /16 mask, and no gateway; the DNS server stays. */
|
||||
private applyLinkLocalConfiguration(nodeId: Id, interfaceId: Id, ipAddress: string): void {
|
||||
const node = this.nodeSnapshot(nodeId);
|
||||
if (node === undefined) return;
|
||||
const snapshot = this.stateValue.topologySnapshot;
|
||||
const devices = new Map(snapshot.deviceConfigurations);
|
||||
const interfaces = new Map(snapshot.interfaceConfigurations);
|
||||
const current = devices.get(nodeId);
|
||||
if (isPcClassEndpoint(node.kind)) {
|
||||
devices.set(nodeId, {
|
||||
ipAddress,
|
||||
subnetMask: LINK_LOCAL_SUBNET_MASK,
|
||||
defaultGateway: '',
|
||||
dnsServer: current?.dnsServer ?? '',
|
||||
});
|
||||
} else if (node.kind === 'gateway') {
|
||||
interfaces.set(interfaceKey(nodeId, interfaceId), { ipAddress, subnetMask: LINK_LOCAL_SUBNET_MASK });
|
||||
if (current !== undefined) devices.set(nodeId, { ...current, defaultGateway: '' });
|
||||
}
|
||||
this.replaceTopologyConfigurations(devices, interfaces);
|
||||
}
|
||||
|
||||
private setDhcpClientIPAddress(nodeId: Id, interfaceId: Id, ipAddress: string): void {
|
||||
const node = this.nodeSnapshot(nodeId);
|
||||
if (node === undefined) return;
|
||||
@@ -2247,13 +2436,9 @@ export class NetworkRuntimeEngine {
|
||||
const maxRetries = options.maxRetries ?? 2;
|
||||
const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true;
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
const primaryInterface = interfaces[0];
|
||||
if (primaryInterface === undefined) return undefined;
|
||||
if (targetIPAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress;
|
||||
const localInterface = interfaces.find((candidate) => candidate.ipAddress === targetIPAddress);
|
||||
if (localInterface !== undefined) return localInterface.macAddress;
|
||||
const cached = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
|
||||
if (cached !== undefined) return cached.macAddress;
|
||||
if (interfaces.length === 0) return undefined;
|
||||
const known = this.knownMacAddress(nodeId, targetIPAddress);
|
||||
if (known !== undefined) return known;
|
||||
|
||||
let selectedInterface: NetworkInterface | undefined;
|
||||
if (options.preferredInterfaceId !== undefined) {
|
||||
@@ -2266,30 +2451,7 @@ export class NetworkRuntimeEngine {
|
||||
if (selectedInterface === undefined) return undefined;
|
||||
|
||||
for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) {
|
||||
const request: ArpPacket = {
|
||||
operation: 'request',
|
||||
senderMacAddress: selectedInterface.macAddress,
|
||||
senderIPAddress: selectedInterface.ipAddress,
|
||||
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
||||
targetIPAddress,
|
||||
};
|
||||
const frame: EthernetFrame = {
|
||||
identity: this.allocateFrameIdentity(),
|
||||
sourceMacAddress: selectedInterface.macAddress,
|
||||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||||
payload: { kind: 'arp', packet: request },
|
||||
};
|
||||
this.recordTrace({
|
||||
frameIdentity: frame.identity,
|
||||
nodeId,
|
||||
interfaceId: selectedInterface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'dataLink',
|
||||
operation: 'created',
|
||||
afterHeaders: ethernetHeaders(frame),
|
||||
detail: `ARP request for ${targetIPAddress}`,
|
||||
});
|
||||
this.sendEthernetFrame(nodeId, selectedInterface.portId, frame);
|
||||
this.sendArpRequest(nodeId, selectedInterface, targetIPAddress);
|
||||
this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress);
|
||||
const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
|
||||
if (resolved !== undefined) return resolved.macAddress;
|
||||
@@ -2297,6 +2459,45 @@ export class NetworkRuntimeEngine {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */
|
||||
private knownMacAddress(nodeId: Id, ipAddress: string): string | undefined {
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
const primaryInterface = interfaces[0];
|
||||
if (primaryInterface === undefined) return undefined;
|
||||
if (ipAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress;
|
||||
const localInterface = interfaces.find((candidate) => candidate.ipAddress === ipAddress);
|
||||
if (localInterface !== undefined) return localInterface.macAddress;
|
||||
return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress;
|
||||
}
|
||||
|
||||
/** One ARP request for `targetIPAddress`, broadcast from the interface. */
|
||||
private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void {
|
||||
const request: ArpPacket = {
|
||||
operation: 'request',
|
||||
senderMacAddress: networkInterface.macAddress,
|
||||
senderIPAddress: networkInterface.ipAddress,
|
||||
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
||||
targetIPAddress,
|
||||
};
|
||||
const frame: EthernetFrame = {
|
||||
identity: this.allocateFrameIdentity(),
|
||||
sourceMacAddress: networkInterface.macAddress,
|
||||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||||
payload: { kind: 'arp', packet: request },
|
||||
};
|
||||
this.recordTrace({
|
||||
frameIdentity: frame.identity,
|
||||
nodeId,
|
||||
interfaceId: networkInterface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'dataLink',
|
||||
operation: 'created',
|
||||
afterHeaders: ethernetHeaders(frame),
|
||||
detail: `ARP request for ${targetIPAddress}`,
|
||||
});
|
||||
this.sendEthernetFrame(nodeId, networkInterface.portId, frame);
|
||||
}
|
||||
|
||||
private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void {
|
||||
if (this.isProcessingScheduledEvents) return;
|
||||
this.advancePendingRemoteLinkEvents(
|
||||
@@ -2638,6 +2839,7 @@ export class NetworkRuntimeEngine {
|
||||
updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds,
|
||||
});
|
||||
this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, packet.senderMacAddress);
|
||||
this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress);
|
||||
}
|
||||
this.recordTrace({
|
||||
frameIdentity,
|
||||
@@ -3485,7 +3687,14 @@ export function ipv4Headers(packet: IPv4Packet): HeaderField[] {
|
||||
}
|
||||
|
||||
function dhcpClientStatus(): DhcpClientStatus {
|
||||
return { state: 'INIT', errorCount: 0, selectedServerIPAddress: undefined, offeredIPAddress: undefined, succeeded: false };
|
||||
return {
|
||||
state: 'INIT',
|
||||
errorCount: 0,
|
||||
selectedServerIPAddress: undefined,
|
||||
offeredIPAddress: undefined,
|
||||
succeeded: false,
|
||||
linkLocal: false,
|
||||
};
|
||||
}
|
||||
|
||||
function dhcpRecordKey(serverNodeId: Id, ipAddress: string): string {
|
||||
|
||||
@@ -50,6 +50,11 @@ export class JavaCompatibleRandom {
|
||||
return Math.fround(this.next(24) / (1 << 24));
|
||||
}
|
||||
|
||||
/** Java's nextDouble (what Math.random draws): 53 random bits over 2^53. */
|
||||
nextDouble(): number {
|
||||
return (this.next(26) * 2 ** 27 + this.next(27)) / 2 ** 53;
|
||||
}
|
||||
|
||||
/** Java RIP: 30 s · (f/3 + 0.84), truncated — somewhere between 25.2 s and 35.2 s. */
|
||||
nextRipJitterMilliseconds(): number {
|
||||
const javaFloat = this.nextFloat();
|
||||
|
||||
@@ -7,6 +7,8 @@ export const MINIMUM_SPEED_PERCENT = 1;
|
||||
export const MAXIMUM_SPEED_PERCENT = 100;
|
||||
export const DEFAULT_SPEED_PERCENT = 90;
|
||||
export const JAVA_MINIMUM_LINK_DELAY_MILLISECONDS = 5;
|
||||
/** Java's Verbindung.MAX_HOPS: the round trip time is this many cables. */
|
||||
export const JAVA_MAXIMUM_HOPS = 50;
|
||||
|
||||
export class RuntimeSpeed {
|
||||
readonly percent: number;
|
||||
@@ -25,6 +27,14 @@ export class RuntimeSpeed {
|
||||
return JAVA_MINIMUM_LINK_DELAY_MILLISECONDS * this.javaDelayFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's Verbindung.holeRTT: how long an answer may take, 50 cables (2750 ms
|
||||
* at the default 90 %). Java's RTT factor (a start option, 1 to 5) is 1.
|
||||
*/
|
||||
get roundTripTimeMilliseconds(): number {
|
||||
return JAVA_MAXIMUM_HOPS * this.linkTransmissionDelayMilliseconds;
|
||||
}
|
||||
|
||||
/** A base delay scaled like a cable, or undefined past the safe integer range. */
|
||||
scaledLinkDelay(baseMilliseconds: number): number | undefined {
|
||||
const delay = baseMilliseconds * this.javaDelayFactor;
|
||||
|
||||
Reference in New Issue
Block a user