feat(core): DHCP in the simulation core, as in Swift
engine.ts ports Swift's "DHCP server and automatic client": an active server listens on port 67 from the start and offers a static assignment before its range, round from the address after the last one offered; offers last 10 s, dynamic leases a day, and a server whose offer another server beat blacklists that address for a while. A client starts as an event: DISCOVER, OFFER, a check by ARP, DECLINE, REQUEST, ACK or NAK, 2.5 s per wait, and after ten failures its old address back. The snapshot carries the DHCP settings of clients and servers. Four scenarios match the Swift engine. One Swift error is named in the oracle test: a client run directly is advanced a second time, to FINISH, by the server's answer within its own DISCOVER, and the outer run then writes back its stale copy of the status (Swift's value types); the address and every trace agree, only the reported status is wrong in Swift. Swift's three DHCP engine tests are ported, with tests for stopping and the message format. 15 mutation probes, 14 caught; the other concerns the home router, which runs only once NAT is ported. Two departures from Java remain, to be fixed next: from the event queue the client's ARP check does not wait, so an address conflict is never found, and each answer that wakes the client makes it send its state's message again (a second DISCOVER and REQUEST, the second answered with NAK). Java waits a round trip and sends once per attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,14 @@
|
||||
// "Deterministic runtime engine" (lines ~984–1284)
|
||||
// "Ethernet, ARP, IPv4, and ICMP packet runtime" (lines ~1284–3585,
|
||||
// without remote link, wireless and gateway NAT; with the firewall)
|
||||
// "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)
|
||||
// processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
|
||||
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
|
||||
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware.
|
||||
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
|
||||
// filius.software.transportschicht (UDP, TCP), filius.software.dhcp.
|
||||
//
|
||||
// The Swift engine is a value type driven by a reducer; here it is a class
|
||||
// with the same inputs and outputs. Time is virtual: nothing sleeps, every
|
||||
@@ -14,26 +19,24 @@
|
||||
// Calls that block in Java (resolving an address, waiting for an echo reply)
|
||||
// advance the virtual clock inside the call, exactly as the Swift port does.
|
||||
//
|
||||
// "UDP sockets and Router RIP" (lines ~4472–5187, the
|
||||
// UDP sockets; RIP not yet)
|
||||
// "Java-compatible TCP sockets" (lines ~5187–5913)
|
||||
//
|
||||
// Not ported yet — each place throws NotPortedError instead of guessing:
|
||||
// gateway NAT, RIP routing, remote links. DHCP is not in the
|
||||
// snapshot yet, so Swift's DHCP handling of a datagram has nothing to do.
|
||||
// gateway NAT, RIP routing, remote links.
|
||||
|
||||
import { compareIds, type Id } from '../ids.js';
|
||||
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
|
||||
import type {
|
||||
DeviceConfiguration,
|
||||
DhcpServerConfiguration,
|
||||
FirewallConfiguration,
|
||||
FirewallRule,
|
||||
InterfaceConfiguration,
|
||||
InterfaceKey,
|
||||
ManualRoute,
|
||||
} from './config.js';
|
||||
import {
|
||||
ALL_PORTS,
|
||||
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
|
||||
dhcpServerConfiguration,
|
||||
DIRECTLY_CONNECTED_SOURCE_MARKER,
|
||||
FIREWALL_PROTOCOL,
|
||||
firewallConfiguration,
|
||||
@@ -268,6 +271,67 @@ export interface TcpSession {
|
||||
|
||||
export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' | 'timedOut';
|
||||
|
||||
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;
|
||||
export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000;
|
||||
export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 10;
|
||||
|
||||
export type DhcpMessageType = 'DHCPDISCOVER' | 'DHCPREQUEST' | 'DHCPACK' | 'DHCPNAK' | 'DHCPOFFER' | 'DHCPDECLINE';
|
||||
const DHCP_MESSAGE_TYPES: readonly DhcpMessageType[] = ['DHCPDISCOVER', 'DHCPREQUEST', 'DHCPACK', 'DHCPNAK', 'DHCPOFFER', 'DHCPDECLINE'];
|
||||
|
||||
/** Swift's TopologyRuntimeDHCPMessage: lines of text in a UDP datagram. */
|
||||
export interface DhcpMessage {
|
||||
readonly type: DhcpMessageType;
|
||||
yourIPAddress: string;
|
||||
clientMacAddress?: string | undefined;
|
||||
subnetMask?: string | undefined;
|
||||
routerIPAddress?: string | undefined;
|
||||
dnsServerIPAddress?: string | undefined;
|
||||
serverIdentifier?: string | undefined;
|
||||
requestedIPAddress?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DhcpOfferRecord {
|
||||
readonly clientMacAddress: string;
|
||||
readonly offeredIPAddress: string;
|
||||
readonly serverNodeId: Id;
|
||||
readonly expiresAtMilliseconds: number;
|
||||
}
|
||||
|
||||
export interface DhcpLeaseRecord {
|
||||
readonly clientMacAddress: string;
|
||||
readonly ipAddress: string;
|
||||
readonly serverNodeId: Id;
|
||||
/** Undefined for a static assignment. */
|
||||
readonly expiresAtMilliseconds: number | undefined;
|
||||
}
|
||||
|
||||
export type DhcpClientState = 'INIT' | 'DISCOVER' | 'VALIDATE' | 'DECLINE' | 'REQUEST' | 'ASSIGN_IP' | 'FINISH';
|
||||
|
||||
export interface DhcpClientStatus {
|
||||
state: DhcpClientState;
|
||||
errorCount: number;
|
||||
selectedServerIPAddress: string | undefined;
|
||||
offeredIPAddress: string | undefined;
|
||||
succeeded: boolean;
|
||||
}
|
||||
|
||||
export interface DhcpClientContext {
|
||||
readonly interfaceId: Id;
|
||||
readonly socketId: Id;
|
||||
readonly clientMacAddress: string;
|
||||
readonly oldDeviceConfiguration: DeviceConfiguration | undefined;
|
||||
readonly oldInterfaceConfiguration: InterfaceConfiguration | undefined;
|
||||
selectedOffer: DhcpMessage | undefined;
|
||||
}
|
||||
|
||||
/** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */
|
||||
function sameIgnoringCase(lhs: string | undefined, rhs: string | undefined): boolean {
|
||||
return lhs !== undefined && rhs !== undefined && lhs.toLowerCase() === rhs.toLowerCase();
|
||||
}
|
||||
|
||||
export const TCP_MAXIMUM_SEGMENT_SIZE = 1_460;
|
||||
export const TCP_MAXIMUM_SEND_ATTEMPTS = 3;
|
||||
export const TCP_ROUND_TRIP_TIME_MILLISECONDS = 2_500;
|
||||
@@ -319,6 +383,13 @@ export interface RuntimeState {
|
||||
udpReceiveQueuesBySocketId: Map<Id, UdpReceivedDatagram[]>;
|
||||
tcpSessionsById: Map<Id, TcpSession>;
|
||||
tcpAcceptedSocketIdsByListenerId: Map<Id, Id[]>;
|
||||
dhcpOffersByIPAddress: Map<string, DhcpOfferRecord>;
|
||||
dhcpLeasesByIPAddress: Map<string, DhcpLeaseRecord>;
|
||||
dhcpBlacklistByServerNodeId: Map<Id, Map<string, number>>;
|
||||
dhcpLastOfferedAddressByServerNodeId: Map<Id, string>;
|
||||
dhcpServerSocketIdsByNodeId: Map<Id, Id>;
|
||||
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
|
||||
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
|
||||
nextSocketSequenceNumber: number;
|
||||
nextTcpInitialSequenceValue: number;
|
||||
nextEventSequenceNumber: number;
|
||||
@@ -349,6 +420,13 @@ function initialState(seed: bigint): RuntimeState {
|
||||
udpReceiveQueuesBySocketId: new Map(),
|
||||
tcpSessionsById: new Map(),
|
||||
tcpAcceptedSocketIdsByListenerId: new Map(),
|
||||
dhcpOffersByIPAddress: new Map(),
|
||||
dhcpLeasesByIPAddress: new Map(),
|
||||
dhcpBlacklistByServerNodeId: new Map(),
|
||||
dhcpLastOfferedAddressByServerNodeId: new Map(),
|
||||
dhcpServerSocketIdsByNodeId: new Map(),
|
||||
dhcpClientStatusesByNodeId: new Map(),
|
||||
dhcpClientContextsByNodeId: new Map(),
|
||||
nextSocketSequenceNumber: 1,
|
||||
nextTcpInitialSequenceValue: 1,
|
||||
nextEventSequenceNumber: 0,
|
||||
@@ -487,6 +565,7 @@ export class NetworkRuntimeEngine {
|
||||
state.udpReceiveQueuesBySocketId.clear();
|
||||
state.tcpSessionsById.clear();
|
||||
state.tcpAcceptedSocketIdsByListenerId.clear();
|
||||
this.clearDhcpState();
|
||||
state.firewallDecisions = [];
|
||||
return [this.output({ type: 'stopped' })];
|
||||
}
|
||||
@@ -658,8 +737,11 @@ export class NetworkRuntimeEngine {
|
||||
case 'ripBeacon':
|
||||
throw new NotPortedError('RIP beacons');
|
||||
case 'dhcpClientStart':
|
||||
this.runDhcpClient(kind.nodeId);
|
||||
return;
|
||||
case 'dhcpTimeout':
|
||||
throw new NotPortedError('DHCP client');
|
||||
this.handleDhcpClientTimeout(kind.nodeId);
|
||||
return;
|
||||
case 'tcpTimeout':
|
||||
this.handleTcpTimeout(kind.sessionId);
|
||||
return;
|
||||
@@ -895,6 +977,7 @@ export class NetworkRuntimeEngine {
|
||||
) {
|
||||
throw new NotPortedError('RIP advertisements');
|
||||
}
|
||||
this.processDhcpDatagram(packet, datagram, nodeId, networkInterface);
|
||||
const state = this.stateValue;
|
||||
const matching = [...state.socketsById.values()]
|
||||
.filter((socket) => socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === datagram.destinationPort)
|
||||
@@ -918,6 +1001,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── TCP sockets ──────────────────────────────────────────────────────────
|
||||
@@ -1544,8 +1630,542 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private clearDhcpState(): void {
|
||||
const state = this.stateValue;
|
||||
state.dhcpOffersByIPAddress.clear();
|
||||
state.dhcpLeasesByIPAddress.clear();
|
||||
state.dhcpBlacklistByServerNodeId.clear();
|
||||
state.dhcpLastOfferedAddressByServerNodeId.clear();
|
||||
state.dhcpServerSocketIdsByNodeId.clear();
|
||||
state.dhcpClientStatusesByNodeId.clear();
|
||||
state.dhcpClientContextsByNodeId.clear();
|
||||
}
|
||||
|
||||
/** Every active server listens on port 67; every enabled client starts now. */
|
||||
private initializeDhcpState(): void {
|
||||
// DHCP (F2): no DHCP configuration exists in the snapshot yet.
|
||||
const state = this.stateValue;
|
||||
this.clearDhcpState();
|
||||
const snapshot = state.topologySnapshot;
|
||||
for (const node of snapshot.nodes) {
|
||||
if (snapshot.dhcpServerConfigurationsByNodeId.get(node.id)?.isActive !== true) continue;
|
||||
const serverInterface = this.dhcpServerInterface(node.id);
|
||||
if (serverInterface === undefined) continue;
|
||||
const socketId = this.bindUdpSocket(node.id, {
|
||||
localPort: DHCP_SERVER_PORT,
|
||||
localIPAddress: serverInterface.ipAddress,
|
||||
remoteIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||||
remotePort: DHCP_CLIENT_PORT,
|
||||
});
|
||||
if (socketId !== undefined) state.dhcpServerSocketIdsByNodeId.set(node.id, socketId);
|
||||
}
|
||||
for (const node of snapshot.nodes) {
|
||||
if (snapshot.dhcpClientConfigurationsByNodeId.get(node.id)?.isEnabled !== true) continue;
|
||||
if (!isPcClassEndpoint(node.kind) && node.kind !== 'gateway') continue;
|
||||
state.dhcpClientStatusesByNodeId.set(node.id, dhcpClientStatus());
|
||||
this.schedule(state.currentTimeMilliseconds, { kind: 'dhcpClientStart', nodeId: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
*/
|
||||
runDhcpClient(nodeId: Id): DhcpClientStatus | undefined {
|
||||
const state = this.stateValue;
|
||||
const node = this.nodeSnapshot(nodeId);
|
||||
if (
|
||||
state.phase !== 'running' ||
|
||||
state.topologySnapshot.dhcpClientConfigurationsByNodeId.get(nodeId)?.isEnabled !== true ||
|
||||
node === undefined ||
|
||||
(!isPcClassEndpoint(node.kind) && node.kind !== 'gateway')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const clientInterface = this.dhcpClientInterface(nodeId);
|
||||
if (clientInterface === undefined) return undefined;
|
||||
if (state.dhcpClientContextsByNodeId.has(nodeId)) {
|
||||
this.advanceDhcpClient(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);
|
||||
const resetInterface = this.networkInterfaces(nodeId).find((each) => each.portId === clientInterface.portId);
|
||||
const socketId =
|
||||
resetInterface === undefined
|
||||
? undefined
|
||||
: this.bindUdpSocket(nodeId, {
|
||||
localPort: DHCP_CLIENT_PORT,
|
||||
localIPAddress: resetInterface.ipAddress,
|
||||
remoteIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||||
remotePort: DHCP_SERVER_PORT,
|
||||
});
|
||||
if (resetInterface === undefined || socketId === undefined) {
|
||||
this.restoreDhcpClientOldIPAddress(nodeId, clientInterface.portId, oldDeviceConfiguration, oldInterfaceConfiguration);
|
||||
return undefined;
|
||||
}
|
||||
state.dhcpClientContextsByNodeId.set(nodeId, {
|
||||
interfaceId: clientInterface.portId,
|
||||
socketId,
|
||||
clientMacAddress: resetInterface.macAddress,
|
||||
oldDeviceConfiguration,
|
||||
oldInterfaceConfiguration,
|
||||
selectedOffer: undefined,
|
||||
});
|
||||
state.dhcpClientStatusesByNodeId.set(nodeId, dhcpClientStatus());
|
||||
this.advanceDhcpClient(nodeId);
|
||||
return state.dhcpClientStatusesByNodeId.get(nodeId);
|
||||
}
|
||||
|
||||
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';
|
||||
};
|
||||
switch (status.state) {
|
||||
case 'INIT':
|
||||
status.state = 'DISCOVER';
|
||||
break;
|
||||
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';
|
||||
break;
|
||||
}
|
||||
case 'VALIDATE': {
|
||||
const offer = context.selectedOffer;
|
||||
if (offer === undefined) {
|
||||
backToDiscover();
|
||||
break;
|
||||
}
|
||||
const conflictMac = this.resolveMacAddress(nodeId, offer.yourIPAddress, {
|
||||
preferredInterfaceId: context.interfaceId,
|
||||
maxRetries: 1,
|
||||
selectInterfaceByAddress: false,
|
||||
});
|
||||
status.state = conflictMac === undefined ? 'REQUEST' : 'DECLINE';
|
||||
break;
|
||||
}
|
||||
case 'DECLINE': {
|
||||
const offer = context.selectedOffer;
|
||||
if (offer === undefined) {
|
||||
backToDiscover();
|
||||
break;
|
||||
}
|
||||
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();
|
||||
break;
|
||||
}
|
||||
this.sendDhcpMessage(context.socketId, {
|
||||
type: 'DHCPREQUEST',
|
||||
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
|
||||
clientMacAddress: context.clientMacAddress,
|
||||
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';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ASSIGN_IP': {
|
||||
const offer = context.selectedOffer;
|
||||
if (offer === undefined) {
|
||||
backToDiscover();
|
||||
break;
|
||||
}
|
||||
this.applyDhcpConfiguration(nodeId, context.interfaceId, offer);
|
||||
status.succeeded = true;
|
||||
status.state = 'FINISH';
|
||||
this.finishDhcpClient(nodeId, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDhcpTimeout(nodeId: Id): void {
|
||||
this.removeDhcpTimeouts(nodeId);
|
||||
this.schedule(this.stateValue.currentTimeMilliseconds + DHCP_ROUND_TRIP_TIME_MILLISECONDS, { kind: 'dhcpTimeout', nodeId });
|
||||
}
|
||||
|
||||
private removeDhcpTimeouts(nodeId: Id): void {
|
||||
this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter(
|
||||
(event) => !(event.kind.kind === 'dhcpTimeout' && event.kind.nodeId === nodeId),
|
||||
);
|
||||
}
|
||||
|
||||
private handleDhcpClientTimeout(nodeId: Id): void {
|
||||
const state = this.stateValue;
|
||||
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;
|
||||
}
|
||||
this.advanceDhcpClient(nodeId);
|
||||
}
|
||||
|
||||
private finishDhcpClient(nodeId: Id, restoreOldIPAddress: boolean): 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);
|
||||
}
|
||||
|
||||
/** The server's part: answers a client's DISCOVER, REQUEST and DECLINE on its server interface. */
|
||||
private processDhcpDatagram(packet: IPv4Packet, datagram: UdpDatagram, nodeId: Id, receivingInterface: NetworkInterface): void {
|
||||
const state = this.stateValue;
|
||||
if (
|
||||
datagram.destinationPort !== DHCP_SERVER_PORT ||
|
||||
datagram.sourcePort !== DHCP_CLIENT_PORT ||
|
||||
state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(nodeId)?.isActive !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const serverInterface = this.dhcpServerInterface(nodeId);
|
||||
if (serverInterface === undefined || serverInterface.portId !== receivingInterface.portId) return;
|
||||
const socketId = state.dhcpServerSocketIdsByNodeId.get(nodeId);
|
||||
if (socketId === undefined) return;
|
||||
const message = decodeDhcpMessage(datagram.payload);
|
||||
const clientMacAddress = message?.clientMacAddress;
|
||||
if (message === undefined || clientMacAddress === undefined) return;
|
||||
switch (message.type) {
|
||||
case 'DHCPDISCOVER': {
|
||||
const offeredIPAddress = this.offerDhcpAddress(nodeId, clientMacAddress);
|
||||
if (offeredIPAddress === undefined) return;
|
||||
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(nodeId) ?? dhcpServerConfiguration();
|
||||
this.sendDhcpMessage(socketId, {
|
||||
type: 'DHCPOFFER',
|
||||
yourIPAddress: offeredIPAddress,
|
||||
clientMacAddress,
|
||||
subnetMask: serverInterface.subnetMask,
|
||||
routerIPAddress: this.determineDhcpGateway(nodeId, serverInterface, configuration),
|
||||
dnsServerIPAddress: this.determineDhcpDnsServer(nodeId, configuration),
|
||||
serverIdentifier: serverInterface.ipAddress,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'DHCPREQUEST': {
|
||||
const requestedIPAddress = message.requestedIPAddress;
|
||||
const selectedServer = message.serverIdentifier;
|
||||
if (requestedIPAddress === undefined || selectedServer === undefined) return;
|
||||
if (sameIgnoringCase(selectedServer, serverInterface.ipAddress)) {
|
||||
const accepted = this.requestDhcpAddress(nodeId, clientMacAddress, requestedIPAddress);
|
||||
this.sendDhcpMessage(socketId, {
|
||||
type: accepted ? 'DHCPACK' : 'DHCPNAK',
|
||||
yourIPAddress: requestedIPAddress,
|
||||
clientMacAddress,
|
||||
serverIdentifier: serverInterface.ipAddress,
|
||||
});
|
||||
} else {
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'DHCPDECLINE':
|
||||
this.recordTrace({
|
||||
packetIdentity: packet.identity,
|
||||
nodeId,
|
||||
interfaceId: receivingInterface.portId,
|
||||
direction: 'local',
|
||||
layer: 'application',
|
||||
operation: 'accepted',
|
||||
detail: 'DHCPDECLINE ignored by Java server worker',
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** A static assignment for the MAC, else the next free address of the range after the last one offered. */
|
||||
private offerDhcpAddress(serverNodeId: Id, clientMacAddress: string): string | undefined {
|
||||
const state = this.stateValue;
|
||||
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(serverNodeId);
|
||||
if (configuration === undefined) return undefined;
|
||||
const staticAddress = configuration.staticAssignments.find((entry) => sameIgnoringCase(entry.macAddress, clientMacAddress))?.ipAddress;
|
||||
if (staticAddress !== undefined) return staticAddress;
|
||||
const lower = parseIPv4(configuration.lowerBoundIPAddress);
|
||||
const upper = parseIPv4(configuration.upperBoundIPAddress);
|
||||
if (lower === undefined || upper === undefined || lower > upper) return undefined;
|
||||
const last = state.dhcpLastOfferedAddressByServerNodeId.get(serverNodeId);
|
||||
const lastValue = last === undefined ? undefined : parseIPv4(last);
|
||||
const firstCandidate = lastValue === undefined ? lower : lastValue < upper ? lastValue + 1 : lower;
|
||||
let candidate = firstCandidate;
|
||||
do {
|
||||
const address = ipv4String(candidate);
|
||||
state.dhcpLastOfferedAddressByServerNodeId.set(serverNodeId, address);
|
||||
if (this.isDhcpAddressAvailable(serverNodeId, address)) {
|
||||
state.dhcpOffersByIPAddress.set(dhcpRecordKey(serverNodeId, address), {
|
||||
clientMacAddress,
|
||||
offeredIPAddress: address,
|
||||
serverNodeId,
|
||||
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS,
|
||||
});
|
||||
return address;
|
||||
}
|
||||
candidate = candidate < upper ? candidate + 1 : lower;
|
||||
} while (candidate !== firstCandidate);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private requestDhcpAddress(serverNodeId: Id, clientMacAddress: string, requestedIPAddress: string): boolean {
|
||||
const state = this.stateValue;
|
||||
this.cleanupDhcpAssignments(serverNodeId);
|
||||
if (state.dhcpBlacklistByServerNodeId.get(serverNodeId)?.has(requestedIPAddress.toLowerCase()) === true) return false;
|
||||
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(serverNodeId) ?? dhcpServerConfiguration();
|
||||
if (
|
||||
configuration.staticAssignments.some(
|
||||
(entry) => sameIgnoringCase(entry.macAddress, clientMacAddress) && sameIgnoringCase(entry.ipAddress, requestedIPAddress),
|
||||
)
|
||||
) {
|
||||
this.storeDhcpLease({ clientMacAddress, ipAddress: requestedIPAddress, serverNodeId, expiresAtMilliseconds: undefined });
|
||||
return true;
|
||||
}
|
||||
const key = dhcpRecordKey(serverNodeId, requestedIPAddress);
|
||||
const offer = state.dhcpOffersByIPAddress.get(key);
|
||||
let accepted: boolean;
|
||||
if (offer !== undefined) {
|
||||
if (!sameIgnoringCase(offer.clientMacAddress, clientMacAddress)) return false;
|
||||
state.dhcpOffersByIPAddress.delete(key);
|
||||
accepted = true;
|
||||
} else {
|
||||
accepted = this.isDhcpAddressAvailable(serverNodeId, requestedIPAddress);
|
||||
}
|
||||
if (!accepted) return false;
|
||||
this.storeDhcpLease({
|
||||
clientMacAddress,
|
||||
ipAddress: requestedIPAddress,
|
||||
serverNodeId,
|
||||
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A client's new lease replaces its earlier one at the same server. */
|
||||
private storeDhcpLease(lease: DhcpLeaseRecord): void {
|
||||
const leases = this.stateValue.dhcpLeasesByIPAddress;
|
||||
for (const [key, existing] of leases) {
|
||||
if (existing.serverNodeId === lease.serverNodeId && sameIgnoringCase(existing.clientMacAddress, lease.clientMacAddress)) {
|
||||
leases.delete(key);
|
||||
}
|
||||
}
|
||||
leases.set(dhcpRecordKey(lease.serverNodeId, lease.ipAddress), lease);
|
||||
}
|
||||
|
||||
private isDhcpAddressAvailable(serverNodeId: Id, ipAddress: string): boolean {
|
||||
const state = this.stateValue;
|
||||
this.cleanupDhcpAssignments(serverNodeId);
|
||||
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(serverNodeId) ?? dhcpServerConfiguration();
|
||||
if (configuration.staticAssignments.some((entry) => sameIgnoringCase(entry.ipAddress, ipAddress))) return false;
|
||||
if (state.dhcpBlacklistByServerNodeId.get(serverNodeId)?.has(ipAddress.toLowerCase()) === true) return false;
|
||||
const key = dhcpRecordKey(serverNodeId, ipAddress);
|
||||
return !state.dhcpOffersByIPAddress.has(key) && !state.dhcpLeasesByIPAddress.has(key);
|
||||
}
|
||||
|
||||
/** Forgets the server's expired offers, dynamic leases and blacklist entries. */
|
||||
private cleanupDhcpAssignments(serverNodeId: Id): void {
|
||||
const state = this.stateValue;
|
||||
const now = state.currentTimeMilliseconds;
|
||||
for (const [key, offer] of state.dhcpOffersByIPAddress) {
|
||||
if (offer.serverNodeId === serverNodeId && offer.expiresAtMilliseconds < now) state.dhcpOffersByIPAddress.delete(key);
|
||||
}
|
||||
for (const [key, lease] of state.dhcpLeasesByIPAddress) {
|
||||
if (lease.serverNodeId === serverNodeId && lease.expiresAtMilliseconds !== undefined && lease.expiresAtMilliseconds < now) {
|
||||
state.dhcpLeasesByIPAddress.delete(key);
|
||||
}
|
||||
}
|
||||
const blacklist = state.dhcpBlacklistByServerNodeId.get(serverNodeId);
|
||||
if (blacklist !== undefined) {
|
||||
for (const [address, expiry] of blacklist) if (expiry < now) blacklist.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
/** The first queued answer of an accepted type for the MAC (and from the server, when given). */
|
||||
private receiveDhcpResponse(
|
||||
socketId: Id,
|
||||
clientMacAddress: string,
|
||||
serverIdentifier: string | undefined,
|
||||
acceptedTypes: readonly DhcpMessageType[],
|
||||
): DhcpMessage | undefined {
|
||||
for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) {
|
||||
const message = decodeDhcpMessage(received.datagram.payload);
|
||||
if (message === undefined || !acceptedTypes.includes(message.type) || !sameIgnoringCase(message.clientMacAddress, clientMacAddress)) {
|
||||
continue;
|
||||
}
|
||||
if (serverIdentifier !== undefined && !sameIgnoringCase(message.serverIdentifier, serverIdentifier)) continue;
|
||||
return message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private sendDhcpMessage(socketId: Id, message: DhcpMessage): IPv4DeliveryResult | undefined {
|
||||
const socket = this.stateValue.socketsById.get(socketId);
|
||||
if (socket === undefined) return undefined;
|
||||
this.recordTrace({ nodeId: socket.nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: message.type });
|
||||
return this.sendUdp(socketId, encodeDhcpMessage(message), {
|
||||
ipAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||||
port: socket.localPort === DHCP_SERVER_PORT ? DHCP_CLIENT_PORT : DHCP_SERVER_PORT,
|
||||
});
|
||||
}
|
||||
|
||||
/** A PC's or notebook's interface, a home router's LAN (index 1). */
|
||||
private dhcpServerInterface(nodeId: Id): NetworkInterface | undefined {
|
||||
const kind = this.nodeSnapshot(nodeId)?.kind;
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
if (kind === 'pc' || kind === 'notebook') return interfaces[0];
|
||||
if (kind === 'gateway') return interfaces.find((each) => each.index === 1);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A PC's or notebook's interface, a home router's WAN (index 0). */
|
||||
private dhcpClientInterface(nodeId: Id): NetworkInterface | undefined {
|
||||
const kind = this.nodeSnapshot(nodeId)?.kind;
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
if (kind === 'pc' || kind === 'notebook') return interfaces[0];
|
||||
if (kind === 'gateway') return interfaces.find((each) => each.index === 0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private determineDhcpGateway(serverNodeId: Id, serverInterface: NetworkInterface, configuration: DhcpServerConfiguration): string {
|
||||
if (this.nodeSnapshot(serverNodeId)?.kind === 'gateway') return serverInterface.ipAddress;
|
||||
if (configuration.useOwnSettings) return configuration.gatewayIPAddress;
|
||||
const gateway = this.stateValue.topologySnapshot.deviceConfigurations.get(serverNodeId)?.defaultGateway ?? '';
|
||||
return gateway === '' ? UNSPECIFIED_IP_ADDRESS : gateway;
|
||||
}
|
||||
|
||||
private determineDhcpDnsServer(serverNodeId: Id, configuration: DhcpServerConfiguration): string {
|
||||
if (this.nodeSnapshot(serverNodeId)?.kind === 'gateway' || configuration.useOwnSettings) return configuration.dnsServerIPAddress;
|
||||
const dns = this.stateValue.topologySnapshot.deviceConfigurations.get(serverNodeId)?.dnsServer ?? '';
|
||||
return dns === '' ? UNSPECIFIED_IP_ADDRESS : dns;
|
||||
}
|
||||
|
||||
private applyDhcpConfiguration(nodeId: Id, interfaceId: Id, offer: DhcpMessage): void {
|
||||
const node = this.nodeSnapshot(nodeId);
|
||||
if (node === undefined) return;
|
||||
const mask = offer.subnetMask ?? '0.0.0.0';
|
||||
const gateway = offer.routerIPAddress ?? UNSPECIFIED_IP_ADDRESS;
|
||||
const dns = offer.dnsServerIPAddress ?? UNSPECIFIED_IP_ADDRESS;
|
||||
const snapshot = this.stateValue.topologySnapshot;
|
||||
const devices = new Map(snapshot.deviceConfigurations);
|
||||
const interfaces = new Map(snapshot.interfaceConfigurations);
|
||||
if (isPcClassEndpoint(node.kind)) {
|
||||
devices.set(nodeId, { ipAddress: offer.yourIPAddress, subnetMask: mask, defaultGateway: gateway, dnsServer: dns });
|
||||
} else if (node.kind === 'gateway') {
|
||||
interfaces.set(interfaceKey(nodeId, interfaceId), { ipAddress: offer.yourIPAddress, subnetMask: mask });
|
||||
const current = devices.get(nodeId);
|
||||
devices.set(nodeId, {
|
||||
ipAddress: current?.ipAddress ?? '',
|
||||
subnetMask: current?.subnetMask ?? '',
|
||||
defaultGateway: gateway,
|
||||
dnsServer: dns,
|
||||
});
|
||||
}
|
||||
this.replaceTopologyConfigurations(devices, interfaces);
|
||||
}
|
||||
|
||||
private setDhcpClientIPAddress(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);
|
||||
const currentInterface = interfaces.get(interfaceKey(nodeId, interfaceId));
|
||||
if (isPcClassEndpoint(node.kind) && current !== undefined) {
|
||||
devices.set(nodeId, { ...current, ipAddress });
|
||||
} else if (node.kind === 'gateway' && currentInterface !== undefined) {
|
||||
interfaces.set(interfaceKey(nodeId, interfaceId), { ipAddress, subnetMask: currentInterface.subnetMask });
|
||||
}
|
||||
this.replaceTopologyConfigurations(devices, interfaces);
|
||||
}
|
||||
|
||||
private restoreDhcpClientOldIPAddress(
|
||||
nodeId: Id,
|
||||
interfaceId: Id,
|
||||
deviceConfiguration: DeviceConfiguration | undefined,
|
||||
interfaceConfiguration: InterfaceConfiguration | undefined,
|
||||
): 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);
|
||||
const currentInterface = interfaces.get(interfaceKey(nodeId, interfaceId));
|
||||
if (isPcClassEndpoint(node.kind) && deviceConfiguration !== undefined && current !== undefined) {
|
||||
devices.set(nodeId, { ...current, ipAddress: deviceConfiguration.ipAddress });
|
||||
} else if (node.kind === 'gateway' && interfaceConfiguration !== undefined && currentInterface !== undefined) {
|
||||
interfaces.set(interfaceKey(nodeId, interfaceId), {
|
||||
ipAddress: interfaceConfiguration.ipAddress,
|
||||
subnetMask: currentInterface.subnetMask,
|
||||
});
|
||||
}
|
||||
this.replaceTopologyConfigurations(devices, interfaces);
|
||||
}
|
||||
|
||||
private replaceTopologyConfigurations(
|
||||
deviceConfigurations: ReadonlyMap<Id, DeviceConfiguration>,
|
||||
interfaceConfigurations: ReadonlyMap<InterfaceKey, InterfaceConfiguration>,
|
||||
): void {
|
||||
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { deviceConfigurations, interfaceConfigurations });
|
||||
}
|
||||
|
||||
private initializeNatState(): void {
|
||||
@@ -2864,6 +3484,77 @@ export function ipv4Headers(packet: IPv4Packet): HeaderField[] {
|
||||
];
|
||||
}
|
||||
|
||||
function dhcpClientStatus(): DhcpClientStatus {
|
||||
return { state: 'INIT', errorCount: 0, selectedServerIPAddress: undefined, offeredIPAddress: undefined, succeeded: false };
|
||||
}
|
||||
|
||||
function dhcpRecordKey(serverNodeId: Id, ipAddress: string): string {
|
||||
return `${serverNodeId.toLowerCase()}|${ipAddress.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function ipv4String(address: number): string {
|
||||
return [24, 16, 8, 0].map((shift) => String((address >>> shift) & 0xff)).join('.');
|
||||
}
|
||||
|
||||
/** Swift's encodeDHCPMessage: the type, then `key=value` lines for the fields that are set. */
|
||||
export function encodeDhcpMessage(message: DhcpMessage): Uint8Array {
|
||||
const lines = [message.type, `yiaddr=${message.yourIPAddress}`];
|
||||
const add = (key: string, value: string | undefined) => {
|
||||
if (value !== undefined && value !== '') lines.push(`${key}=${value}`);
|
||||
};
|
||||
add('chaddr', message.clientMacAddress);
|
||||
add('router', message.routerIPAddress);
|
||||
add('subnetmask', message.subnetMask);
|
||||
add('dnsserver', message.dnsServerIPAddress);
|
||||
add('requested', message.requestedIPAddress);
|
||||
add('serverident', message.serverIdentifier);
|
||||
return new TextEncoder().encode(lines.join('\n'));
|
||||
}
|
||||
|
||||
/** Swift's decodeDHCPMessage: undefined for text that is not UTF-8 or of no known type. */
|
||||
export function decodeDhcpMessage(data: Uint8Array): DhcpMessage | undefined {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const [command, ...fields] = text.split('\n');
|
||||
const type = DHCP_MESSAGE_TYPES.find((each) => sameIgnoringCase(each, command));
|
||||
if (type === undefined) return undefined;
|
||||
const message: DhcpMessage = { type, yourIPAddress: UNSPECIFIED_IP_ADDRESS };
|
||||
for (const line of fields) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const value = line.slice(separator + 1);
|
||||
switch (line.slice(0, separator)) {
|
||||
case 'yiaddr':
|
||||
message.yourIPAddress = value;
|
||||
break;
|
||||
case 'chaddr':
|
||||
message.clientMacAddress = value;
|
||||
break;
|
||||
case 'router':
|
||||
message.routerIPAddress = value;
|
||||
break;
|
||||
case 'subnetmask':
|
||||
message.subnetMask = value;
|
||||
break;
|
||||
case 'dnsserver':
|
||||
message.dnsServerIPAddress = value;
|
||||
break;
|
||||
case 'requested':
|
||||
message.requestedIPAddress = value;
|
||||
break;
|
||||
case 'serverident':
|
||||
message.serverIdentifier = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
function tcpSegmentRequiresAcknowledgement(segment: TcpSegment): boolean {
|
||||
return (
|
||||
(segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish | TCP_FLAG.push)) !== 0 || segment.payload.length > 0
|
||||
|
||||
Reference in New Issue
Block a user