npm.io
1.0.0 • Published 8h agoCLI

dhcp

Licence
MIT
Version
1.0.0
Deps
0
Size
544 kB
Vulns
0
Weekly
0
Stars
305

dhcp

dhcp logo

A dependency-free, type-safe DHCPv4 client, server, and traffic watcher for Node.js. The package ships native ESM, CommonJS, and TypeScript declarations.

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

npm install dhcp

Server

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:

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.

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:

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:

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:

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

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:

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.

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:

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:

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:

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:

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:

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:

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:

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:

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

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

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:

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

nvm use
npm test
npm run test:coverage
npm run typecheck

Copyright (c) 2026, Robert Eisele Licensed under the MIT license.

Keywords