Examples
This page collects a few short patterns that are useful when you start wiring pydhcp into a real service.
Server config file
pydhcp --config FILE server (or PYDHCP_CONFIG) accepts JSON, TOML, YAML or INI, selected by the file's ending (.json, .toml, .yaml, .yml, .ini, .cfg). The option goes before the command name. The file holds a section named for the command, server, whose keys are the command's field names (listen, per_interface, lease_file); relay and capture have sections of their own. A section or key the command does not have is an error. A TOML file:
[server]
listen = "*"
pydhcp --config server.toml server
The same file in YAML, named the same way:
# server.yaml
server:
listen: "*"
pydhcp --config server.yaml server
listen is text ("127.0.0.1:6767,127.0.0.2:6768"), an [address, port] pair
(listen = ["127.0.0.1", 6767]) or a list of either. None/null as the address is the
wildcard; an empty value, a boolean or a bare number is an error. Text that is not an
IPv4 address names an interface (see Listening on one
interface).
TOML files need Python 3.11+ (stdlib tomllib) or pip install "duho[config]" on older versions, and YAML files pip install "duho[yaml]". What each format holds is in duho/AGENTS.md inside the installed package; an INI file's [DEFAULT] keys are top-level keys, which no command's section is.
Custom lease backend
The server accepts a pluggable lease backend. That makes it easy to persist leases in memory for tests and swap in a file-backed store for simple deployments.
from pydhcp.lease import InMemoryLeaseBackend
from pydhcp.server import DHCPServer
server = DHCPServer(lease_backend=InMemoryLeaseBackend())
server.serve_forever()
Custom server policy
DHCPServer is designed as a base implementation. Override acquire_lease() when your
application owns address-pool selection, reservations, or site-specific options.
The base implementation offers only what it knows first-hand from the receiving
interface — SUBNET_MASK and BROADCAST_ADDRESS. In particular it does not send
ROUTER or DNS: the machine running the server is not necessarily a gateway or a
resolver, and naming it as both would point clients' off-link traffic and name lookups at
a host that handles neither. Supply the real values here, as below.
from datetime import datetime, timedelta, timezone
from pydhcp import DHCPLease, DHCPOptions
from pydhcp.options import DHCPOptionCode
from ipaddress import IPv4Address as IPv4
from pydhcp.server import DHCPServer
class FixedLeaseServer(DHCPServer):
def acquire_lease(self, client_id, server_id, msg, *, commit):
options = DHCPOptions()
options[DHCPOptionCode.DNS] = [IPv4("1.1.1.1")]
return DHCPLease(
IPv4("192.0.2.50"),
datetime.now(timezone.utc) + timedelta(hours=1),
options,
)
acquire_lease() runs on the server itself, so an attribute it keeps (a counter for the
next free host, say) is the server's own. commit says what kind of call it is: a
DHCPDISCOVER makes one call with commit=False, and a DHCPREQUEST makes two, commit=False
to decide and commit=True to commit the ACK. An override that writes to a store of its
own, or that extends a binding, does that only when commit is true.
A server that keeps its leases in a store of its own (as above, where nothing is stored) is
asked about a client in four places: INIT-REBOOT, RELEASE, DECLINE and a REQUEST that
names another server. The first goes through acquire_lease(); the others ask
lookup_lease(client_id), which reads lease_backend unless you override it, and a
release reaches your store through release_lease(). Override those two when the leases
are not in lease_backend. A DHCPDECLINE quarantines the address it names whoever sent it, and
lookup_lease() says whether the sender's own record is released with it
(DECLINE_REQUIRES_LEASE = True marks only an address the sender holds). When you do store leases in lease_backend, answer a
commit=False call with offer and a commit=True call with commit, so that an address
offered to a client that never accepts is given back.
A backend you pass in is yours: close() leaves it open (flush a FileLeaseBackend
yourself, or use it as a context manager); a backend the server made is closed with it.
For DHCPINFORM-only customization, override get_inform_options() so clients can receive
configuration options without allocating an address.
Listening on all interfaces
To serve every local IPv4 interface, listen on the wildcard: '*' or 0.0.0.0 (the
default). One socket receives broadcasts from every segment and learns which interface each
datagram arrived on.
from pydhcp.server import DHCPServer
server = DHCPServer(listen="*")
server.serve_forever()
per_interface=True, like naming an address in listen, binds one socket per address
instead. On Linux such a socket hears no broadcast, so a client that has no address yet is
not served through it (macOS and the BSDs are expected to behave the same, unmeasured;
Windows delivers the broadcast). Use it for unicast traffic and for tests, not to serve
unconfigured clients; pydhcp warns once per process when it binds an address.
Listening on one interface
To serve one interface and not the others, name it: an adapter name, a MAC or a
netimps.Interface, with an optional port.
from pydhcp.server import DHCPServer
server = DHCPServer(listen="eth1") # the adapter named eth1, default ports
server = DHCPServer(listen="eth1:6767") # ... on port 6767
server = DHCPServer(listen="aa-bb-cc-dd-ee-ff:6767") # the adapter with that MAC
server = DHCPServer(listen=("aa:bb:cc:dd:ee:ff", 6767))
server.serve_forever()
pydhcp server --listen eth1
The text is read as an IPv4 address first, then as a MAC (aa:bb:cc:dd:ee:ff,
aa-bb-cc-dd-ee-ff, aabb.ccdd.eeff, aabbccddeeff), and otherwise as an adapter
name; a host name is never resolved, so localhost is looked for among the
adapters. The port follows the last colon (eth0:1 is eth0, port 1): a colon-spelled MAC
takes its port in a pair, and an adapter whose name holds a colon, or looks like an
address or a MAC, is passed as a netimps.Interface. The adapter is looked up when the
listener binds, and one that does not exist is an error then.
This is one wildcard socket: a socket bound to an address hears no broadcast on Linux, so
the listener has to hear every interface to hear a client that has no address. On Linux
the socket is bound to the device, so the kernel delivers only that interface's datagrams;
elsewhere the listener drops, before decoding, a datagram that arrived on another interface,
and counts it in metrics.packets_dropped_other_interface (which stays 0 on Linux in normal
operation). Naming the wildcard on the same port ("*:67")
removes the limit.
For deterministic local testing or tools that need several explicit sockets, pass a list of endpoints or a tuple with multiple ports.
from pydhcp.server import DHCPServer
server = DHCPServer(listen=[("127.0.0.1", [6767, 6768])], per_interface=True)
server.serve_forever()
The CLI accepts comma-separated endpoint strings for the same workflow.
pydhcp server --listen 127.0.0.1:6767,127.0.0.1:6768 -v
Basic packet client
DHCPClient is a small packet client for tests and troubleshooting. It can build and send
common DHCP client messages and queue matching replies, but it does not configure the
operating system network stack.
from pydhcp.client import DHCPClient
from pydhcp.options import DHCPOptionCode
client = DHCPClient(listen=("127.0.0.1", 6768))
client.start() # replies reach the queue only while the receive loop runs
discover = client.build_discover(
b"\x00\x11\x22\x33\x44\x55",
parameter_request_list=[DHCPOptionCode.SUBNET_MASK, DHCPOptionCode.ROUTER],
)
client.send(discover, dst="127.0.0.1", port=6767)
reply = client.next_reply(timeout=5) # (message, context), or None after five seconds
client.close()
The example uses high ports so it can run without privileged DHCP ports during local tests. Real DHCP traffic on ports 67/68 may require elevated privileges depending on your OS.
Full DORA exchange in one call
DHCPClient.dora() runs DISCOVER → OFFER → REQUEST → ACK and returns the final DHCPACK. It
raises DHCPTimeoutError (also a TimeoutError) when no usable OFFER or ACK arrives, and
DHCPRefusedError when the server answers the REQUEST with a DHCPNAK (the NAK is its nak
attribute); a NAK ends the exchange at once. timeout is the first retransmission interval and
deadline bounds the whole call in seconds. The listener loop must be running (start()) so
replies reach the client's internal queue.
from pydhcp import DHCPRefusedError, DHCPTimeoutError
from pydhcp.client import DHCPClient
client = DHCPClient(listen=("0.0.0.0", 68))
client.start()
try:
ack = client.dora(b"\x00\x11\x22\x33\x44\x55", timeout=2.0, retries=2, deadline=30)
print(f"Leased {ack.yiaddr}")
except DHCPTimeoutError:
print("no server answered")
except DHCPRefusedError as refused:
print(f"the server refused: {refused}")
finally:
client.close()
Use discover_offer() instead if you only need the DHCPOFFER without following through with a
DHCPREQUEST.
Relaying DHCP across subnets
DHCPRelay implements the RFC 1542 / RFC 2131 §4.1 / RFC 3046 relay agent role: it forwards
client broadcasts to one or more configured upstream DHCP servers (stamping giaddr and
incrementing hops), and forwards server replies back to the original client.
from pydhcp.relay import DHCPRelay
relay = DHCPRelay(
listen="*",
server_addresses=["10.0.0.53", ("10.0.1.53", 6767)],
max_hops=16,
)
relay.serve_forever()
Pass insert_relay_agent_info=True with circuit_id and/or remote_id to tag the requests that
reach the relay straight from a client with RELAY_AGENT_INFORMATION (option 82) per RFC 3046. A
request another relay already stamped (its giaddr is set) is forwarded without a second tag, and a
request that would not fit the option within the relay's max_packet_size is forwarded without it
and counted in metrics.relay_info_omitted. The flag without an id, or an id without the flag, is a
ValueError.
pydhcp relay --listen 127.0.0.1:6767 --server 127.0.0.1:6768 --insert-relay-agent-info --circuit-id aabbcc
Capturing DHCP packets
The capture command listens with the same endpoint syntax as the server and writes structured
packet data. Use - for stdout.
pydhcp capture --listen 127.0.0.1:6767 --filter msg_type=DHCPDISCOVER --output -
Capture filters are key=value or key!=value clauses joined by and; a comma in a value means
"any of" (except in option.NAME). A value that no packet could match, such as a message type name that
does not exist, is refused when the capture starts.
pydhcp capture --filter "op=BOOTREQUEST and sport=68"
pydhcp capture --filter "client_id=01:AA:BB:CC:DD:EE:FF"
pydhcp capture --filter "option.DHCP_MESSAGE_TYPE=DHCPREQUEST"
pydhcp capture --filter "msg_type=DHCPDISCOVER,DHCPREQUEST and src!=192.0.2.1"
Write one file containing all accepted captures:
pydhcp capture --listen 127.0.0.1:6767 --output captures.json --format json
Write the datagrams themselves, as the clients sent them, in a file that tcpdump and Wireshark
open. The ending names the format (.pcap, .cap or .pcapng), or --format does; a capture
file is replaced, not appended to, and --output - writes it to standard output.
pydhcp capture --listen 127.0.0.1:6767 --output heard.pcap
pydhcp capture --listen 127.0.0.1:6767 --output heard.pcapng --count 10
tcpdump -r heard.pcap -n reads the first back.
A message put back together is not the packet that arrived (it is padded to 300 octets), so a capture file keeps the received octets; a command hook beside it is given the record as JSON.
From Python, sniff_dhcp is the source and pktcap's copy_frames writes it:
import pktcap
from pydhcp.capture import capture_dissector, sniff_dhcp
reader = capture_dissector((67, 68, 6767)) # DHCP is read on the port listened on too
frames = sniff_dhcp("127.0.0.1:6767", dissector=reader)
with pktcap.CaptureWriter("heard.pcap") as writer: # the ending names the format
pktcap.copy_frames(frames, writer, limit=10)
Write one file per accepted packet:
pydhcp capture --listen 127.0.0.1:6767 --format json --output "output/{client_id}/{timestamp}_{msg_type}.{format}" --per-record
The filename pattern uses values the client chooses, so one run creates at most --max-files
distinct files (1000 by default). A packet that needs one more is not written and is counted: the
capture goes on, and ends with status 1 and a summary line saying how many records were refused;
raise the limit, or use a pattern without {client_id}. A value in a filename is cut at 64 characters and ends in a hash, so a long client
identifier still gets its own file. A record that cannot be written (the directory is a file, the
disk is full) ends the capture with status 1 and one line that says why.
Read a capture file back through the same filter, output and hook, with no socket bound, or send its requests again to a server you name (the addresses in the capture are never sent to):
pydhcp capture --read heard.pcap --filter "msg_type=DHCPDISCOVER" --format yaml
pydhcp replay --input heard.pcap --to 192.0.2.1 --no-delay
A capture file is also read with pktcap, with DHCP dissected as a layer. Register the dissector in a registry of your own (or in pktcap's process-wide one by passing none):
import pktcap
from pydhcp.capture import DHCPLayer, register_dhcp_dissector
registry = pktcap.DissectorRegistry()
register_dhcp_dissector(registry) # UDP ports 67 and 68
dissector = pktcap.FrameDissector(registry)
for frame in pktcap.read_dissected("heard.pcap", dissector=dissector):
layer = frame.layer(DHCPLayer)
if layer is not None:
print(layer.message_type, layer.client_id, hex(layer.xid))
pktcap's commands load the same registration, and the filter keys of pydhcp capture --filter
(op, msg_type, xid, client_id, chaddr and option.NAME_OR_CODE), when told to: name
pydhcp.capture as a plugin. Nothing registers until pktcap loads it; pydhcp capture and
pydhcp replay load it themselves.
PKTCAP_LOAD=pydhcp.capture pktcap convert -i heard.pcap -f "msg_type=DHCPDISCOVER"
Hooks can be trusted Python callables (module:function, called with each
pktcap.DissectedFrame) or commands. A command hook receives the record of the frame on stdin
and metadata in PKTCAP_HOOK_* environment variables (PKTCAP_HOOK_CLIENT_ID,
PKTCAP_HOOK_MSG_TYPE, PKTCAP_HOOK_XID and PKTCAP_HOOK_FORMAT, each value cleaned as it
is for a file name, so a client identifier reads 01_02_00_00_99_00_02); it is started with no
arguments and no shell, and killed, with any process it started, after --hook-timeout
(10 seconds). It is pktcap's command_hook. A command with a
directory in its name (./on-dhcp-capture, /opt/hooks/export) is that file, found relative
to the working directory when the capture starts; a bare name (export) is looked up on
PATH, and nowhere else. A file in the working directory is therefore written ./name, and
a module:function hook is imported as written, so its module must be on PYTHONPATH.
pydhcp capture --hook myhooks:on_capture
pydhcp capture --hook ./on-dhcp-capture
Custom options
DHCPOptions behaves like an ordered mapping, so you can build option sets explicitly and preserve encode order.
from pydhcp import DHCPOptions
from pydhcp.options import DHCPOptionCode
options = DHCPOptions()
options[DHCPOptionCode.HOSTNAME] = "workstation-01"
options[DHCPOptionCode.DOMAIN_NAME] = "example.internal"
For more typed examples across the built-in option families, see Common DHCP Options.
Inspecting packets
The CLI can decode packets and print their fields, which is handy when you are debugging client behavior or validating captures.
pydhcp packet --decode --input - --format json
For terminal inspection, use the summary output.
pydhcp packet --decode --input - --format summary
--input/--output default to - (stdin/stdout), following standard POSIX convention, so both
can be omitted when piping. Structured packet text can also be encoded back to packet hex, and
--input/--output accept a file path in place of -.
pydhcp packet --encode --input packet.json --format json --output packet.hex
A message as YAML, TOML or INI text is written and read by pktcap: pip install "pydhcp[pktcap]" "pktcap[yaml,toml]". JSON needs nothing.