# dhcp ![dhcp logo](res/logo.png) A dependency-free, type-safe DHCPv4 client, server, and traffic watcher for Node.js. The package ships native ESM, CommonJS, and TypeScript declarations. Use `dhcp` to implement or observe DHCPv4 directly in a Node.js process, including relays, PXE options, multiple subnets, static allocation, and RFC 3118 authentication. It is not a wrapper around the operating system's DHCP service and deliberately does not configure host interfaces after a lease is obtained. For ordinary application networking, leave address configuration to the operating system rather than embedding a DHCP client. ## Requirements - Node.js 20 or newer - Permission to bind UDP ports 67 and 68 (usually root or an appropriate capability) - An interface and firewall configuration that permit IPv4 broadcast traffic ## Install ```bash npm install dhcp ``` ## Server ```js import { createServer } from 'dhcp'; const server = createServer({ range: ['192.168.3.10', '192.168.3.99'], server: '192.168.3.1', router: ['192.168.3.1'], dns: ['1.1.1.1', '8.8.8.8'], netmask: '255.255.255.0', leaseTime: 86400, static: { '11:22:33:44:55:66': '192.168.3.100' }, }); server.on('bound', (leases) => console.log(leases)); server.on('poolExhausted', (error, request) => console.error(error.message, request.chaddr)); server.listen(); ``` The range is inclusive. Static addresses and the server address are never handed out as part of the dynamic range and existing leases are reused, requested addresses are honored when available, and expired leases are reclaimed. Additionally, a full pool emits `poolExhausted` and does not produce an invalid packet. Static bindings are reserved before dynamic allocation, including bindings returned by a `static()` factory. Duplicate static addresses and collisions with the DHCP server address are rejected during startup. `static(request)` is evaluated for every address selection with the actual DHCP packet. Every returned mapping is permanently added to that pool's reservation table and for conditional resolvers whose future addresses cannot yet be inferred, you have to provide the complete reservation set through `staticReservations`: ```js const bindings = { '00:11:22:33:44:55': '192.168.1.20' }; const server = createServer({ range: ['192.168.1.10', '192.168.1.99'], staticReservations: bindings, static(request) { return request?.vendorClassId === 'managed-device' ? bindings : {}; }, }); ``` The server's normal DISCOVER and REQUEST paths always use this packet-aware selection. `_selectAddress(mac, requestedAddress)` remains only as a compatibility helper for direct single-pool use and therefore has no request context. Static assignments may be outside the dynamic range. Exhausting the dynamic range emits `poolExhausted` for unregistered clients but does not stop the UDP socket or block subsequently recognized static clients. Set `allocationPolicy: 'static-only'` on the server or an individual subnet to serve only recognized MAC, packet-aware static, or Relay Agent bindings. Unknown DISCOVER and REQUEST messages are ignored without an OFFER, ACK, NAK, or lease mutation. The server emits `clientIgnored(request, subnetId, 'static-only')` for observability. ```js const server = createServer({ range: ['192.168.1.10', '192.168.1.99'], allocationPolicy: 'static-only', static: { '00:11:22:33:44:55': '192.168.1.20' }, }); ``` ## Address conflict detection RFC 2131 recommends probing a newly selected address before sending DHCPOFFER. Enable the built-in system-assisted ARP probe explicitly: ```js const server = createServer({ range: ['192.168.1.10', '192.168.1.99'], server: '192.168.1.1', addressProbe: true, addressProbeTimeout: 250, addressConflictHoldTime: 600, }); server.on('addressConflict', (address, request, subnetId) => { console.warn(`${address} is already in use on ${subnetId}`); }); ``` The built-in `SystemArpProbe` sends an empty UDP datagram to trigger the operating system's neighbor discovery, then waits for the configured timeout, and checks the platform neighbor cache with `ip neigh` on Linux or `arp` on macOS and Windows. Node.js has no portable raw-ARP API (yet), so missing tools, inaccessible caches, and inconclusive results are fail-open: the server emits `addressProbeInconclusive` and still offers the address. For strict environments, inject an `AddressProbe` backed by a native raw-socket implementation: ```js addressProbe: { async probe(address, { packet, subnetId, timeoutMs }) { return nativeProbe(address, { packet, subnetId, timeoutMs }); // Return 'available', 'in-use', or 'unknown'. }, }, ``` Only new selections on directly attached subnets are probed. Known leases are reused without probing, and requests received through `giaddr` are not probed from the server because ARP does not cross routers. Confirmed dynamic conflicts are quarantined for `addressConflictHoldTime` and selection continues with the next candidate. A conflicting static assignment is marked declined and is never silently replaced by a dynamic address. Set `addressProbe: false` or omit it to disable probing. `broadcast` configures DHCP option 28, the broadcast address installed by the client. It does not select the UDP destination. Initial OFFER/ACK messages use the RFC-preferred limited broadcast `255.255.255.255`; relayed replies use `giaddr`, while renew and DHCPINFORM replies use `ciaddr`. Without a valid Maximum DHCP Message Size option 57, server replies are limited to the RFC 2131 baseline of 576 bytes. A client value of at least 576 is honored up to the server's configured `maxMessageSize`; the client advertises 576 by default and can advertise a larger receive capacity explicitly, for example `maxMessageSize: 1472` on an Ethernet path with a 1500-byte MTU. Formatted packet buffers contain only bytes written on the wire, not the unused capacity. Every DHCP option with a `config` name in the exported `options` registry can be set in the server configuration. Values may also be callbacks: ```js const server = createServer({ range: ['192.168.3.10', '192.168.3.99'], bootFile(packet) { return packet?.clientId === 'sensor' ? 'sensor.bin' : 'default.bin'; }, forceOptions: ['bootFile'], }); ``` Defaults include a netmask derived from the range, the range's first address as server/router, Cloudflare-independent legacy DNS defaults (`8.8.8.8`, `8.8.4.4`), a one-day lease, and random address selection. ## Client ```js import { createClient } from 'dhcp'; const client = createClient({ mac: '12:34:56:78:90:AB', clientId: 'asset-123', vendorClassId: 'PXEClient', features: ['hostname', 'domainName', 'broadcast'], }); client.on('bound', (lease) => console.log(lease)); client.listen(() => client.sendDiscover()); ``` When `features` is omitted or empty, the client requests the standard option set: subnet mask (1), routers (3), lease time (51), server identifier (54), and DNS servers (6). Option code 0 is never inserted into the Parameter Request List. The client automatically schedules renewal at T1, rebinding at T2, and lease expiry. Set `autoRenew: false` to manage these transitions manually with `sendRenew()` and `sendRebind()`. `sendRelease()` releases a bound lease. Listen for a successful change from one bound address to another with `addressChanged`: ```js client.on('addressChanged', (previousAddress, currentAddress, lease) => { console.log(`${previousAddress} -> ${currentAddress}`); }); ``` The event reports the lease observed by this DHCP client. The package deliberately does not modify or monitor the operating system's interface configuration; applications remain responsible for applying the lease to the host. Options 60 and 61 are client-supplied identifiers. Server configuration callbacks read them as `packet.vendorClassId` and `packet.clientId`; they are deliberately not ordinary server response configuration keys. In accordance with RFC 6842, a received client identifier is echoed unchanged in DHCPOFFER, DHCPACK, and DHCPNAK, and clients discard responses containing a different identifier. Servers can request renewal from a bound client with `server.sendForceRenew(mac)`. Clients honor RFC 3203 `DHCPFORCERENEW` after a randomized delay; `forceRenewDelay` can override the delay for controlled environments. Vendor-specific option 43 remains byte-oriented, while `encodeVendorOptions()` and `decodeVendorOptions()` provide safe TLV suboption handling for PXE and hardware-specific configurations. ## Client authentication RFC 3118 option 90 is supported at the server's network boundary. Delayed Authentication is the recommended mode available in that RFC: each client has an out-of-band per-client secret and secret ID, messages are authenticated with HMAC-MD5, and monotonically increasing replay values are enforced. ```js const keys = new Map([ ['client-1', { secretId: 1, secret: Buffer.from(process.env.CLIENT_1_KEY, 'hex') }], ]); const server = createServer({ range: ['192.168.1.10', '192.168.1.99'], authentication: { mode: 'delayed', required: true, getKey(packet, secretId) { const key = keys.get(packet.clientId); return key && (secretId === undefined || secretId === key.secretId) ? key : undefined; }, }, }); server.on('authenticationFailed', (reason, packet) => { console.warn(`Rejected ${packet.chaddr}: ${reason}`); }); ``` Authentication happens before `message`, address selection, and lease mutation. Invalid, missing, modified, or replayed credentials are silently discarded from DHCP processing and reported through `authenticationFailed`. Relay changes to `hops` and `giaddr`, and Relay Agent Information option 82, are excluded from HMAC input exactly as required by RFC 3118. Server replies use the selected client key and are signed automatically. RFC 3118 does not define usernames. Protocol 0 is merely a shared clear-text configuration token and provides no message integrity. It is available only for compatibility: ```js authentication: { mode: 'token', token: process.env.DHCP_CONFIGURATION_TOKEN, required: true, } ``` Do not treat this token mode as password authentication. Delayed Authentication itself is a legacy intradomain protocol fixed by RFC 3118 to HMAC-MD5; use unique client keys, provision them out of band, protect key storage, and combine DHCP authentication with network controls such as DHCP snooping. The remote DHCP client must itself support and be configured for RFC 3118 option 90; merely adding a username/password to server options does not enable authentication. The bundled DHCP client does not currently originate RFC 3118 exchanges. See `examples/authenticated-server.mjs` for a complete server setup. Helpers such as `parseAuthentication()`, `createDelayedAuthenticationRequest()`, `computeDelayedAuthenticationMac()`, and `authenticationMessageForMac()` are exported for integrations and interoperability testing. ## Custom options Unknown DHCP options are valid and are preserved without warnings as `Uint8Array` values in `packet.options[code]`. Repeated instances are concatenated before decoding, as required by RFC 2131. Raw options can be sent again unchanged: ```js packet.options[128] = Uint8Array.from([1, 2, 3]); ``` Register a typed option per server or client instance when its format is known. Registries are immutable and never modify the package-wide standard option table: ```js const server = createServer({ range: ['192.168.1.10', '192.168.1.99'], optionDefinitions: { 209: { name: 'PXELINUX Config File', type: 'ASCII', config: 'pxelinuxConfig', }, }, pxelinuxConfig: 'menu.c32', forceOptions: ['pxelinuxConfig'], }); ``` Low-level users can create a registry with `createOptionRegistry()` and pass it to `protocol.parse()`, `protocol.format()`, `SequentialBuffer.getOptions()`, or `SequentialBuffer.addOptions()`. Only malformed TLV lengths are errors; an unknown code by itself is not. ## ACK-only options Use `ackOptions` for options that must appear only in DHCPACK, such as a WPAD URL. It accepts an object or a packet-aware callback: ```js const server = createServer({ range: ['192.168.1.10', '192.168.1.99'], optionDefinitions: { 252: { name: 'WPAD URL', type: 'ASCII' }, }, ackOptions: (request) => ({ 252: `http://wpad.example/${request.chaddr}/wpad.dat`, }), }); ``` For a manually constructed response, call `server.sendAck(request, { address, options })`. Explicit options override configured ACK values, while DHCP message type 53 and echoed Relay Agent Information remain protected. ## Multiple subnets One server can manage independent pools with `subnets`. The matching pool is selected from an explicit `match(packet)` policy, subnet-selection option 118, `giaddr`, requested address, or `ciaddr`, in that order. Each pool has independent leases, static reservations, defaults, and response options: ```js const server = createServer({ subnets: [ { id: 'office', range: ['10.10.0.10', '10.10.0.99'], server: '10.10.0.1', netmask: '255.255.255.0', router: ['10.10.0.1'], }, { id: 'lab', range: ['10.20.0.10', '10.20.0.99'], server: '10.20.0.1', netmask: '255.255.255.0', router: ['10.20.0.1'], }, ], }); ``` DHCP broadcasts do not cross routers, so relay agents with `giaddr` are the normal way to serve remote subnets. A bare DISCOVER with multiple configured pools and no selection signal is rejected rather than assigned from an arbitrary subnet. Node's UDP API does not expose the destination interface portably; directly attached interfaces should use separate bound server instances or an explicit `match` policy. See `examples/multi-subnet-server.mjs`. `server.leases` returns a combined map whose keys are prefixed with the subnet ID. Use `server.leasesForSubnet(id)` for one pool. If the same MAC exists in multiple pools, call `server.sendForceRenew(mac, subnetId)` to identify the intended lease explicitly. ## Relay agents RFC 3046 Relay Agent Information (option 82) is decoded into ordered opaque suboptions. Common Circuit ID and Remote ID values are available as `packet.relayAgentInformation.circuitId` and `.remoteId`; every other valid code, including suboption 9, remains available in `packet.relayAgentInformation.suboptions`. Configuration callbacks can use exact byte matches for policy decisions. The server qualifies relayed lease identities by `giaddr`, sends replies to the relay on UDP port 67, echoes option 82 byte-for-byte in every reply, and writes it as the final option before END. Declarative `relayBindings` match opaque identifiers byte-for-byte and always require the relay's `giaddr`. Use byte arrays or explicit `{ hex }` and `{ base64 }` encodings; implicit text decoding is deliberately avoided: ```js const server = createServer({ range: ['10.20.0.10', '10.20.0.99'], allocationPolicy: 'static-only', relayBindings: [{ address: '10.20.0.20', giaddr: '10.20.0.1', circuitId: { hex: '000102' }, remoteId: { base64: 'qrs=' }, }], }); ``` `relayBindings(context)` can implement external inventory lookups and receives `packet`, `subnetId`, `giaddr`, `circuitId`, and `remoteId`. List every possible callback result in `relayReservations` so those addresses are unavailable before the first matching request. Declarative binding addresses are reserved automatically. Duplicate bindings and server-address collisions are rejected. Use `decodeRelayAgentInformation()` and `encodeRelayAgentInformation()` for low-level access. `decodeCiscoRemoteId()` optionally unwraps the subtype/length envelope used by Cisco Remote IDs without changing the RFC 3046 codec. Pad and End suboptions are rejected because RFC 3046 does not define them inside option 82. See `examples/relay-policy.mjs`. ## PXE clients RFC 4578 options are built in with their binary wire formats: - 93: `clientSystemArchitecture`, a non-empty list of 16-bit architecture IDs - 94: `clientNetworkInterface`, the three-byte type/major/minor identifier - 97: `clientMachineIdentifier`, normally type 0 followed by a 16-byte GUID They are exposed on configuration callback packets and can also be configured in PXE server responses. Options 128–135 requested by PXE clients remain valid opaque custom options; if they are not configured, the server silently omits them instead of attempting `config(undefined)` or crashing. Malformed lengths for known PXE options are rejected as packet errors. ## Logging By default, structured JSON logs are written to the console at `info` level. Select `debug`, `info`, `warn`, `error`, or `silent` with `logLevel`, or inject any compatible logger: ```js const server = createServer({ range: ['192.168.3.10', '192.168.3.99'], logger: { debug: (message, context) => appLogger.debug(context, message), info: (message, context) => appLogger.info(context, message), warn: (message, context) => appLogger.warn(context, message), error: (message, context) => appLogger.error(context, message), }, }); ``` ## Events Server events: `message`, `state`, `stage`, `bound`, `released`, `declined`, `poolExhausted`, `addressConflict`, `addressProbeInconclusive`, `authenticationFailed`, `listening`, and `error`. Client events: `message`, `state`, `stage`, `bound`, `nak`, `released`, `expired`, `listening`, and `error`. `state` and `stage` are aliases with identical arguments. The `examples/lease-monitor.mjs` watcher emits newline-delimited JSON for passive inventory and diagnostics. Errors are always logged. The special Node.js `error` event is emitted only when a listener is registered, preventing an operational packet error from terminating the process by itself. `bound` is emitted after the DHCPACK has been sent. A one-shot server can therefore close safely from that listener. `close()` is idempotent and accepts an optional completion callback: ```js server.on('bound', () => { server.close(() => console.log('Server closed')); }); ``` Closing stops the UDP service; it does not close a per-client connection because DHCP itself is connectionless. The server also emits `close` when its socket closes. ## Traffic watcher ```js import { createBroadcastHandler, DHCPDISCOVER } from 'dhcp'; const watcher = createBroadcastHandler(); watcher.on('message', (packet) => { if (packet.options[53] === DHCPDISCOVER) console.log(packet.chaddr); }); watcher.listen(); ``` DHCP limited broadcasts are received portably only when the UDP socket binds to `0.0.0.0`. Consequently, `listen(null, '192.0.2.10')` and `listen({ host: '192.0.2.10' })` still bind wildcard by default; the operating system routing table chooses the outgoing interface. Use `listen({ host: '192.0.2.10', receiveBroadcast: false })` only for strict unicast binding, which may not receive `255.255.255.255` traffic. ## Command line ```bash sudo dhcpd --range 192.168.1.10-192.168.1.99 --server 192.168.1.1 --router 192.168.1.1 --dns 1.1.1.1 8.8.8.8 sudo dhcp hostname dns --mac 12:34:56:78:90:AB ``` `dhcpd` is silent during normal operation. Add `-v` or `--verbose` for readable DHCP transitions, or `-vv`/`--debug` for transitions plus structured internal logs. Operational errors are always written to stderr. ## Compatibility The established CommonJS API remains available: ```js const dhcp = require('dhcp'); const server = dhcp.createServer({ range: ['192.168.1.10', '192.168.1.99'] }); ``` Message constants and the `createServer`, `createClient`, and `createBroadcastHandler` factories retain their original names. Protocol, options, tools, and sequential-buffer modules are available as package subpath exports. ## Development ```bash nvm use npm test npm run test:coverage npm run typecheck ``` ## Copyright and Licensing Copyright (c) 2026, [Robert Eisele](https://raw.org/) Licensed under the MIT license.