PSNanoDHCPServ is a single-file DHCP server, still small enough to read start to finish, that has grown from a minimal "Rescue Tool" into a fairly full-featured server: multiple DNS/router addresses, an extensible custom-option mechanism covering most of the standard DHCP option space, PXE/network boot, multi-scope JSON configuration, and basic operations tooling (log file, status snapshot, graceful Ctrl+C shutdown, packet statistics).
This server hands out network settings to PCs on a network that has no other DHCP server. It is not fully compliant with RFC2131, but implements most of what a small deployment needs.
It can be run temporarily (its original "rescue tool" use case) or left running as a small persistent DHCP server for a lab/home network with lease persistence, static reservations and multiple scopes.
It answers DHCPDISCOVER with DHCPOFFER, valid DHCPREQUEST/DHCPINFORM messages
with DHCPACK, and invalid address requests with DHCPNAK. Addresses are leased
from the configured range, reused for the same client while the lease is active,
and released on DHCPRELEASE or after the configured lease time expires.
Core RFC 2131 behaviors are implemented:
- Clients are tracked by their client identifier (option 61) when present, falling back to the hardware MAC.
- A
DHCPOFFERonly reserves the address for a short window (-offerTimeoutSeconds); the lease is committed when theDHCPREQUESTarrives. DHCPREQUESTis classified into its SELECTING / INIT-REBOOT / RENEWING-REBINDING client states, each with the reply rules from RFC 2131 §4.3.2 (including staying silent for unknown INIT-REBOOT clients and freeing the offer when the client selects another server).- Replies follow the §4.1 addressing rules: back through the relay agent (giaddr)
when present, unicast to
ciaddrfor renewals/informs, broadcast otherwise. - Reply options honor the parameter request list (option 55) and its order, and include renewal times T1/T2 (options 58/59). Packets are sized to their content (minimum 300 bytes, capped by the client's option 57).
sudo pwsh ./psnanodhcpserv.ps1 -clientIPAddresses 192.168.1.1-192.168.1.5 \
-subnetMask 255.255.255.0 -defaultGatewayAddress 192.168.1.254 -dnsServerAddress 8.8.8.8
- You must run as administrator (or root) because it binds to UDP port 67.
- Currently supports IPv4 addresses only.
-clientIPAddressesaccepts a single address (a.b.c.d) or an inclusive range (a.b.c.d-a.b.c.e).-defaultGatewayAddressand-dnsServerAddresseach accept a single address or a comma-separated list (192.168.1.1,192.168.1.2) to hand out multiple routers or DNS servers (options 3/6). When a list is given for the gateway, the first address is used as the DHCP server identifier (option 54) unless-serverIdentifieris set explicitly.- Use
-noreplymodeto simply listen to DHCP traffic on your network without replying.
| Parameter | Default | Description |
|---|---|---|
-domainName |
localdomain |
DHCP option 15 (Domain Name). |
-serverIdentifier |
first gateway | DHCP option 54. Defaults to the first -defaultGatewayAddress entry. |
-netbiosNameServer |
(none) | DHCP option 44. Omitted when not specified. |
-leaseTimeSeconds |
86400 |
DHCP option 51 (lease time), in seconds. |
-offerTimeoutSeconds |
60 |
How long a DHCPOFFER reserves an address. |
-leaseFile |
(none) | Persist leases to this JSON file and restore them on startup. |
-staticLeases |
(none) | Static reservations, each mac-or-clientid=a.b.c.d (MAC may use : or -). |
-excludeAddresses |
(none) | Addresses never handed out: a.b.c.d or a.b.c.d-a.b.c.e (repeatable). |
-conflictDetection |
off | Ping an address before offering it; quarantine ones that answer. |
-customOptions |
(none) | Extra DHCP options, each code=type:value. See Custom options below. |
-nextServer |
(none) | PXE next-server address (BOOTP siaddr). See PXE / network boot. |
-bootFile |
(none) | PXE boot file name (BOOTP file field + option 67). |
-tftpServerName |
(none) | PXE TFTP server name (option 66). |
-configFile |
(none) | JSON file describing one or more scopes; overrides all scope-related CLI parameters above. See Config file / multiple scopes. |
-logFile |
(none) | Append plain-text INFO/WARN/ERROR log lines here in addition to the console. |
-statusFile |
(none) | Write a JSON snapshot of leases/offers/stats to this path after every processed packet. |
- With
-leaseFile, the lease database (leases and declined addresses) is written as JSON after every change and restored on startup. Entries that are expired or no longer fit the configuration are dropped during restore. - Static reservations always win over dynamic allocation, may live outside the dynamic range, and their addresses are never handed to other clients.
- Conflict detection probes only fresh allocations (not existing leases, offers, or static reservations); an address that answers the ping is quarantined for one lease time, like a declined address.
-customOptions sends additional DHCP options (any code 1-254) that aren't
covered by a named parameter. Each entry has the form code=type:value; the
option is always included in replies, whether or not the client's parameter
request list (option 55) asked for it. Supported types:
The legacy static-route option (33) doesn't need a dedicated type: its wire
format is just alternating destination/gateway address pairs, which ip-list
already produces, e.g. 33=ip-list:10.0.1.0,10.0.0.1.
New-DhcpOption -Code <n> -Value <v> -Type <type> (used internally to encode
-customOptions) is also how any option code without a named parameter — e.g.
broadcast address (28) — can be sent: -customOptions @('28=ip:192.168.1.255').
-nextServer, -bootFile, and -tftpServerName configure PXE/network boot:
-nextServersets the BOOTPsiaddrfield (the "next server" clients should contact, typically the TFTP server's own address).-bootFilesets the BOOTPfilefield (max 127 bytes) and is sent as option 67 (Bootfile Name) for clients that look at the option instead.-tftpServerNameis sent as option 66 (TFTP Server Name).
None of these three are written into a DHCPNAK.
For more than one subnet (or just to avoid a long command line), pass
-configFile path/to/config.json instead of the scope-related CLI parameters.
Every top-level key except scopes is a default applied to all scopes; a key
with the same name inside a scope object overrides that default. Key names
match the CLI parameter names (clientIPAddresses, subnetMask,
defaultGatewayAddress, dnsServerAddress, domainName, serverIdentifier,
netbiosNameServer, leaseTimeSeconds, offerTimeoutSeconds, leaseFile,
staticLeases, excludeAddresses, conflictDetection, customOptions,
nextServer, bootFile, tftpServerName):
{
"leaseFile": "leases.json",
"conflictDetection": true,
"scopes": [
{
"clientIPAddresses": "192.168.1.100-192.168.1.199",
"subnetMask": "255.255.255.0",
"defaultGatewayAddress": "192.168.1.1",
"dnsServerAddress": "8.8.8.8"
},
{
"clientIPAddresses": "10.0.0.50-10.0.0.200",
"subnetMask": "255.255.255.0",
"defaultGatewayAddress": "10.0.0.1",
"dnsServerAddress": "1.1.1.1",
"domainName": "b.lan"
}
]
}scopesmust contain at least one entry.- The server binds one UDP socket and dispatches each incoming packet to the
matching scope: a relayed packet (non-zero
giaddr) is matched to the scope whose subnet contains the relay address; a packet with nogiaddr(a client on the server's own segment) always goes to the first scope. A relayed packet that matches no scope's subnet is dropped. With only one scope configured (whether via-configFileor the plain CLI parameters), this matching is skipped entirely and every packet goes to that one scope, preserving the original single-scope behavior regardless of relay topology. - A
leaseFileinherited from the top-level default gets a scope number inserted automatically so scopes never share one file (leases.jsonbecomesleases.1.json,leases.2.json, ...). A scope that sets its ownleaseFileexplicitly is used exactly as given, with no suffix.
-logFileappends every console line to a plain-text file as<UTC timestamp> [INFO|WARN|ERROR] <message>, in addition to printing to the console as before.-statusFilewrites a JSON snapshot (uptime, packet/message-type counters, and a per-scope summary of active leases/offers/declines) after every processed packet, for an external monitor to read.Ctrl+Crequests a graceful shutdown: the server stops accepting new packets, saves every scope's lease file and a final status snapshot, logs a stats summary, and closes the socket before exiting.
PowerShell 7+ (pwsh). Available on macOS, Linux and Windows.
For distributing to a Windows machine as a single .exe (no need for the target
PC to already have pwsh, and double-clicking can prompt for the admin
elevation the server needs), compile the script with
PS2EXE.
This step must be run on Windows. PS2EXE compiles the script with .NET's
CodeDom/CSharpCodeProvider, which throwsPlatformNotSupportedException("Operation is not supported on this platform") on macOS and Linux, even under PowerShell 7 — confirmed while writing these instructions. The module installs fine on any OS, but only Windows can actually produce the.exe.
# On Windows, in either Windows PowerShell 5.1 or PowerShell 7:
Install-Module -Name ps2exe -Scope CurrentUser -Force
Invoke-ps2exe -inputFile .\psnanodhcpserv.ps1 -outputFile .\psnanodhcpserv.exe `
-title 'PSNanoDHCPServ' -description 'Single-file DHCP server' `
-version '1.0.0.0' -requireAdmin-requireAdminembeds a manifest that triggers the UAC elevation prompt when the.exeis launched, since binding UDP/67 needs administrator rights (the same requirement as running the.ps1withsudo/an elevated shell).- Omit
-noConsole— the server logs every DISCOVER/OFFER/REQUEST/ACK to the console (and optionally-logFile), so keep the console window PS2EXE creates by default. - The parameters documented above (
-clientIPAddresses,-customOptions,-configFile,-logFile, etc.) all still work; call the.exethe same way you'd call the.ps1, e.g..\psnanodhcpserv.exe -clientIPAddresses 192.168.1.1-192.168.1.5 -subnetMask 255.255.255.0 -defaultGatewayAddress 192.168.1.254 -dnsServerAddress 8.8.8.8. - Re-run
Invoke-ps2exeafter every change topsnanodhcpserv.ps1— the.exeis a point-in-time compiled snapshot, not kept in sync automatically.
Tests use Pester 5.
Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force
Invoke-Pester ./tests -Output DetailedThe test suite dot-sources the script (which only loads its functions, without
starting the server) and verifies the IP/range parsing, lease allocation, DHCP
option encoding (including the generic -Type encoder, custom options, and
PXE fields), full DISCOVER→OFFER / REQUEST→ACK packet round-trips, and the
config-file/multi-scope loading and selection logic.
Start-DhcpServer itself (the socket bind, Ctrl+C handling, and receive loop)
is a thin I/O wrapper with no automated coverage — verify it by actually
running the server after making changes there.
MIT
