A fast, single-binary DHCPv4 server written in Go (built on insomniacslk/dhcp) with:
- JSON configuration (strictly validated; shows line/column on errors)
- Address pools, exclusions, reservations (with notes & metadata)
- Sticky leases, optional compaction, and tolerant migration of older lease formats
- Per-device overrides (DNS/TFTP/Bootfile)
- Classless static routes (121), optional mirror to 249, custom vendor option 43 payloads
- Live config reload (watcher +
SIGHUP) - Banned MAC handling (from config and/or env var)
- Rich CLI: run server, view leases, stats, full subnet grid, config checking, config management (add/remove), and reload
- Clear logging (file and/or console), PID file, graceful shutdown
- Quick start
- Build
- Why root or CAP_NET_BIND_SERVICE?
- Configuration
- Leases DB
- Running
- Commands
- Flags
- Environment variables
- Signals & lifecycle
- Logging
- Security notes
- Appendix: IP selection policy
# 1) Build
go build -o dhcplane .
# 2) Prepare config and empty lease DB
cp config.example.json config.json
echo '{"by_ip":{},"by_mac":{}}' > leases.json
# 3) Allow binding to UDP:67 without root (Linux)
sudo setcap 'cap_net_bind_service=+ep' "$(pwd)/dhcplane"
# 4) Run
./dhcplane serve --consoleTip: add
--consoleto expose the interactive console socket while still seeing logs on stdout/stderr.
Requires Go 1.21+.
go build -o dhcplane .Binary is self-contained; no external services needed.
DHCPv4 servers listen on UDP port 67 (privileged). Options:
- Run as root, or
- Grant the binary capability (Linux):
sudo setcap 'cap_net_bind_service=+ep' /path/to/dhcplaneThis attaches to that specific binary. Recompiling creates a new file, so you must re-apply setcap to the new binary path.
The server loads a strict JSON file (unknown fields are rejected). On serve startup and on every reload/watch event, config is validated.
{
"interface": "eth0",
"server_ip": "192.168.178.1",
"subnet_cidr": "192.168.178.0/24",
"gateway": "192.168.178.1",
"compact_on_load": false,
"dns": ["1.1.1.1", "9.9.9.9"],
"domain": "lan",
"lease_db_path": "leases.json",
"pid_file": "dhcplane.pid",
"lease_seconds": 86400,
"lease_sticky_seconds": 86400,
"auto_reload": true,
"pools": [
{"start": "192.168.178.50", "end": "192.168.178.199"}
],
"exclusions": ["192.168.178.100"],
"reservations": {
"aa:bb:cc:dd:ee:ff": {
"ip": "192.168.178.10",
"note": "laser printer",
"first_seen": 1725550000,
"equipment_type": "Printer",
"manufacturer": "HP",
"management_type": "web",
"management_interface": "http://192.168.178.10"
}
},
"ntp": ["192.168.178.1"],
"mtu": 1500,
"tftp_server_name": "192.168.178.2",
"bootfile_name": "pxelinux.0",
"wpad_url": "http://wpad.lan/wpad.dat",
"wins": ["192.168.178.3"],
"domain_search": ["lan", "corp.lan"],
"static_routes": [
{"cidr": "10.10.0.0/16", "gateway": "192.168.178.254"}
],
"mirror_routes_to_249": true,
"vendor_specific_43_hex": "01:04:de:ad:be:ef",
"device_overrides": {
"00-11-22-33-44-55": {
"dns": ["192.168.178.53"],
"tftp_server_name": "192.168.178.2",
"bootfile_name": "special.efi"
}
},
"banned_macs": {
"dc:ed:83:f3:68:5b": {
"first_seen": 1725550000,
"note": "guest device blocked",
"equipment_type": "Gateway",
"manufacturer": "Unknown"
}
},
"equipment_types": ["Switch","Router","AP","Modem","Gateway","Printer"],
"management_types": ["ssh","web","telnet","serial","console"]
}Defaults: Defaults:
lease_seconds:86400(24h) if ≤ 0lease_sticky_seconds:86400(sticky window) if ≤ 0- At least one pool is required
- Unknown fields are rejected (strict mode)
lease_db_path: defaults toleases.jsonwhen omittedpid_file: defaults todhcplane.pidwhen omitted
lease_seconds:86400(24h) if ≤ 0lease_sticky_seconds:86400(sticky window) if ≤ 0- At least one pool is required
- Unknown fields are rejected (strict mode)
{
"interface": "",
"server_ip": "192.168.178.1",
"subnet_cidr": "192.168.178.0/24",
"gateway": "192.168.178.1",
"dns": ["1.1.1.1","9.9.9.9"],
"lease_seconds": 86400,
"lease_sticky_seconds": 86400,
"auto_reload": true,
"pools": [
{"start":"192.168.178.50","end":"192.168.178.199"}
],
"exclusions": ["192.168.178.100"],
"reservations": {
"aa:bb:cc:dd:ee:ff":{"ip":"192.168.178.10","note":"printer"}
},
"domain": "lan",
"ntp": ["192.168.178.1"],
"mtu": 1500,
"tftp_server_name": "192.168.178.2",
"bootfile_name": "pxelinux.0",
"wpad_url": "",
"wins": [],
"domain_search": ["lan"],
"static_routes": [],
"mirror_routes_to_249": false,
"vendor_specific_43_hex": "",
"device_overrides": {},
"banned_macs": {}
}reservations keys are MACs (accept aa:bb:..., aa-bb-..., or aabb...). Values:
{
"ip": "192.168.178.10",
"note": "human note",
"first_seen": 1725550000,
"equipment_type": "Switch",
"manufacturer": "Ubiquiti",
"management_type": "web",
"management_interface": "https://192.168.178.10"
}Backwards compatible: the legacy
{"mac":"ip"}style is also accepted on load.
Banned MACs can be declared in config under banned_macs (with optional metadata), and/or via env var dhcplane_BANNED_MACS (comma/space/newline-separated list, any delimiter style, e.g. aabbccddeeff, aa:bb:..., aa-bb-...).
Banned MACs:
- Are logged on contact
- Receive a NAK on DHCP REQUEST when the config’s
authoritativeflag is true (default) - Are marked in the grid and details outputs
device_overrides lets you override only:
- DNS servers (option 6)
- TFTP server name (66)
- Bootfile name (67)
Global config remains in effect for everything else.
static_routes becomes Option 121 (RFC 3442). If mirror_routes_to_249 is true, the same payload is also sent as proprietary Microsoft option 249.
"static_routes": [
{"cidr":"10.10.0.0/16","gateway":"192.168.178.254"}
]Provide a raw hex payload in many styles: "01:04:de:ad:be:ef", "01 04 de ad be ef", "hex:0104deadbeef", "0x01,0x04,0xDE...".
leases.json is a simple map persisted by the server:
{
"by_ip": {
"192.168.178.100": {
"mac": "aa:bb:cc:dd:ee:ff",
"ip": "192.168.178.100",
"hostname": "host-name",
"allocated_at": 1725551111,
"expiry": 1725637511,
"first_seen": 1725550000
}
},
"by_mac": {
"aa:bb:cc:dd:ee:ff": { /* same structure as above */ }
}
}All timestamps are epoch seconds. Formatting to local time happens only when printing.
If you used a prior version that stored RFC3339 timestamps, the server will tolerantly read and coerce them to epoch on load.
- Sticky window (
lease_sticky_seconds): influences IP selection so a device tends to get the same address again (even after expiry within the sticky window), as long as it’s safe. - Compaction removes leases that expired longer than the sticky window ago. If
compact_on_loadis true, compaction runs at startup and on reload.
The server listens on UDP:67 on the configured interface (or all interfaces if empty). It is authoritative by default (sends NAKs on invalid requests).
Serve with console echo and file log:
./dhcplane serve \
--config ./config.json \
--consoleCheck the config:
./dhcplane check -c ./config.jsonLive reload via PID file (default dhcplane.pid):
./dhcplane reload -c ./config.jsonList current leases (pretty JSON):
./dhcplane leases -c ./config.jsonStats & tables:
# Summary + leased/expiring/expired tables
./dhcplane stats -c ./config.json
# Full subnet table (hides free addresses) with Type column
./dhcplane stats -c ./config.json --details
# colour grid of the whole subnet
./dhcplane stats -c ./config.json --gridAdd or update a reservation:
# MAC, IP, then an optional free-form note
./dhcplane manage add dc:ed:83:f3:68:5b 192.168.178.55 "kitchen display"Remove a reservation:
./dhcplane manage remove dc:ed:83:f3:68:5bStart the DHCP server. Validates config before binding. Creates/updates a PID file. Supports SIGHUP reload and graceful termination.
Key features at runtime:
- Sticky leases
- Reservation enforcement over leases
- Per-device overrides (DNS/TFTP/Bootfile)
- Vendor option 43
- Routes 121 (+249 mirror)
- WPAD (252), WINS (44), MTU (26), Domain(15), Domain search (119)
- Auto-reload (filesystem watcher) when
auto_reloadis true
Print leases from leases.json as a JSON array with formatted timestamps (local time), including AllocatedAt, Expiry, and FirstSeen.
Print allocation rates for the last 1m/1h/24h/7d/30d and lease groupings.
Flags:
--details: show a unified table across the whole subnet with Type classification:leased(active leases)reserved(IP is fixed in config, not currently leased)banned-mac(active lease owned by a banned MAC)banned-ip(network/broadcast/server/gateway/exclusions/declined quarantine)free(hidden in details output)
--grid: render a colour block grid (green free, red leased, brown reserved, light-gray banned-mac, dark-gray banned/excluded IPs). Requires a colour terminal.
Strictly validate config.json. Unknown fields, wrong types, etc., return precise line/column.
Reads PID from pid_file in the config and sends SIGHUP. Before signaling, re-validates the config and refuses to reload if invalid.
Manipulate reservations in config.json:
manage add <mac> <ip> [note...]- Validates MAC and IPv4
- Warns if IP is out of subnet or currently leased to a different MAC
- Inserts/updates with
first_seen(epoch) if missing, preserves any existing metadata
manage remove <mac>- Deletes the reservation if present
Edits are written atomically via config.json.tmp → rename.
Global flags (apply to all commands unless noted):
-c, --config stringPath to JSON config (defaultconfig.json)--consoleServe the interactive console over UNIX socket (logs always print to stdout/stderr)
Command-specific flags:
stats --detailsstats --gridconsole attach --transparent
-
dhcplane_BANNED_MACSExtra banned MACs; separated by comma/space/newline. Acceptsaa:bb:...,aa-bb-..., oraabb.... -
COLUMNSIf set, used as terminal width hint for grid layout.
SIGHUPReload config. Ifcompact_on_loadis true, compaction runs post-reload.SIGINT/SIGTERMGraceful shutdown: server stops, leases DB is saved, watcher/log files closed.
When auto_reload is true, the process also watches the config file’s directory and applies changes shortly after writes (with validation and first-seen stamping for reservations/banned MACs where missing).
- File logger is created using the
loggingsection (defaultdhcplane.log); logs are always mirrored to stdout/stderr. - Internal errors are additionally printed to stderr in red for operator visibility.
- PID file is written at the
pid_filepath in the config.
Example log lines:
START iface="" bind=0.0.0.0:67 server_ip=192.168.178.1 subnet=192.168.178.0/24 gateway=192.168.178.1 lease=24h0m0s sticky=24h0m0s
AUTO-RELOAD: watching ./config.json
DISCOVER from aa:bb:cc:dd:ee:ff hostname="printer" xid=0x12345678
OFFER aa:bb:cc:dd:ee:ff -> 192.168.178.100
ACK aa:bb:cc:dd:ee:ff <- 192.168.178.100 lease=24h0m0s (alloc=2025/09/05 20:40:01, exp=2025/09/06 20:40:01)
- Only enable
authoritativein the config on networks where this server should NAK competing/invalid requests. - Use
exclusionsto protect infrastructure IPs that are inside pools. - Banned MACs are enforced at request time; keep in mind MAC spoofing is possible on L2.
- Consider running under a service account with only
cap_net_bind_servicecapability if possible, not full root. - Always validate
config.jsonwithcheckbefore deploying edits in production.
- Reservation wins: If the MAC has a reservation, use it (unless actively leased to a different MAC).
- Sticky preference: If the MAC had a prior lease, try that IP again, subject to safety (in-subnet, not excluded/declined/reserved for someone else, not actively leased by another MAC).
- Brand-new IPs: Prefer addresses never seen in the DB.
- Recycle expired: If no new IPs, reuse expired, safe IPs (respecting reservations).
- If none apply: pool exhausted → NAK (when authoritative) or silent failure.