Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.1 - 2026-10-22
Changed
- The command line ends through
duho.run: Ctrl-C ends with status 130 and a closed standard output with status 1, both without a traceback.--help,--versionand a usage error still raiseSystemExit. dhcpctl backendstakes--format {text,json}besides--json, which prints the same JSON as before; the help text of--jsonnow reads "Same as --format json".- The
cliextra requiresduho>=0.7.8,<0.8.
0.1.0 - 2026-10-19
Added
DHCPServer.render(reservation, action="add"): what an operation would send, contacting nothing, on every shipped backend.dhcpctl.testing.BackendContract: the suite every shipped backend passes, for a third-party backend to run unmodified; it talks to no server.- The
dhcpctlcommand line (pydhcpctl[cli]):render,add,complete,remove,recipeandbackends, a reservation given by flags or a document, secrets fromDHCPCTL_*variables. - Published as
pydhcpctl; the import package isdhcpctl. connect(uri, *, secrets=None, owner="dhcpctl"),register_backend,unregister_backend,backend_names,get_backendand thedhcpctl.backendsentry-point group.DHCPServer:add,remove,keep,complete,ensure_condition,condition_recipe,remove_condition_member,extras,secret,redacted_uri. A secret is the caller's when it gives one, else the URI's user or password, so one URI serves several logins; a password is masked instr,reprand messages.Reservation,Target,Scope,Condition,Completion,parse_conditions,parse_completion. What each serves is a pydhcpDHCPLease(re-exported): the address (0.0.0.0for a MAC-only reservation), the options, and the BOOTP header fieldssiaddr,snameandfile, which are not options 66 and 67; each backend writes them as its server spells the fields.Reservation.to_mapping/from_mappingandto_text/from_textwrite and read a reservation as plain data or a JSON, YAML, TOML or INI document (all but JSON with theformatsextra), each lease in pydhcp'sDHCPLease.to_mappingform.- Options are pydhcp's
DHCPOptionsandDHCPOptionCode, re-exported, and reach a backend as pydhcp objects for it to write as its server needs. A codemap (option_codes, anOptionCodesenum, pydhcp'sOptionCodeEnum, its names read by pydhcp'sresolve) reads a config's option names: declared options with a code and a pydhcp type of their own, aliases of names or codes, over pydhcp's names, values read by pydhcp'sDHCPOptions.from_mapping(an enum member by name, octets as hex); the Kea and dhcpd backends add their own spellings (OPTION_NAMES), andconnect(uri, codes=...)the caller's;codes.lease(mapping)takes the header fields (next-server,file, an alias) out of a config's options. Declared types belong to the codemap, which starts from the codecs its base has when it is made. netsh's data types come from the codecs. An unset address isUNSPECIFIED(pydhcp'sUNSPECIFIED_ADDRESS,0.0.0.0), as in BOOTP. - The dnsmasq backend writes its hosts and options files as dnsmasq reads
them: each line the value of
--dhcp-host/--dhcp-optionwithout the key (netboot wrotedhcp-host=anddhcp-option=prefixes, which dnsmasq rejects as "bad hex constant", so no reservation took effect), and the boot file and next server asoption:bootfile-name/option:server-ip-addressin place of adhcp-bootline. An extras line for the options file is written the same way. Checked against a real dnsmasq and DHCP client (tests/test_dnsmasq_real.py). - A dnsmasq directory (
--dhcp-hostsdir) is SIGHUPed when an entry it may have read is removed or changed: dnsmasq only ever adds what such a directory holds, so a completed client kept its boot file. A new entry still needs no signal. A reservation with options and nooptsfile=is refused instead of arming the client without them; a trailing/on a path that does not exist yet means a directory; names that reduce to one tag get distinct tags; a missing or hung reload command, an unreadable pidfile and a platform without SIGHUP are errors instead of a stray exception or SIGTERM. A local hosts or options file is rewritten under a lock on.<file>.lock, so two runs at once no longer drop each other's entries. - Every option reaches each server in a form it reads. dhcpd: an option it
defines is written under its name in its own syntax (119 codes, taken from
dhcpd 4.4 itself); netboot wrote
unknown-<n>and octets for all but a dozen, which dhcpd parses as the option's own type, so it refused the host's statements and served none of them (70 of 134 options). Negative numbers and domain names are written bare, and octets for an option dhcpd defines with a structure of its own are aConfigError. dnsmasq: a flag, a number list and octets are written as octets (True,576,1500and0a0bwere text or refused). Kea: octets go withcsv-formatoff, a flag astrue. Windows: a flag is 1 or 0, and a negative number the DWORD it stores (it refused-3600for option 2). Checked by sending every option of a plain codec through a real dhcpd (dhcpd -t) and dnsmasq, and one of each codec to a real client (tests/test_dhcpd_real.py,tests/test_dnsmasq_real.py). - Re-arming a client replaces what the server holds for it, on every backend: dhcpd and Kea refused, and the Windows cmdlet threw.
- Kea conditions work: a condition is a client class of the client's own,
tested on its MAC and
option[n].hex(iPXE's flat option 77 never matched.text), and what it overrides is served by a second class of the client's when it does not match, since Kea serves a reservation's values over any class's. The reservation names noclient-classes, which put the client in the class whatever its test said: on a real Kea every client was handed the reservation's boot file, iPXE or not.?target=memory(host_cmds'operation-target) arms a Kea with no hosts database. Checked against a real Kea 3.0.4 (tests/test_kea_real.py). - Commands run through hostctl hosts: the Windows backend's local, ssh and
WinRM transports (
hostctl.LocalConfig,SSHConfig,WinRMConfig) and a dnsmasqreload=, locally or over ssh. hostctl delivers a PowerShell script whole; netboot piped it intopowershell -Command -, which runs only the first line of a multi-line block and exits 0 even when a cmdlet fails, so reservations were reported armed and were not. A script that ends without printing its closing{"ok": true}is aServerError, as are an unreachable host, a refused login and a timeout (timeout=, default 120 s). ssh is asyncssh, throughhostctl[ssh], rather than the systemsshclient; it reads~/.ssh/configassshdoes, and thesshextra now serves windhcp over ssh too. Checked against real Windows PowerShell 5.1 (tests/test_windhcp_delivery.py), and against a live Windows Server 2025 DHCP server over ssh with both methods: arm, re-arm, keep and remove, the policies included (tests/test_windhcp_live.py, opt-in). - The Windows backend removes a target's own policies when it removes the
target (they kept answering its MAC); leaves an existing shared policy as it
is, and trusts it on a host without the DhcpServer module; prints, in target
mode, the MAC-matched policies it would create rather than a scope-wide one,
with every value quoted against injection (typographic quotes included);
reads back under
method=netshthe options it actually set; keeps a reservation through netsh whenmethod=netsh; and warns about option 119 and a next-server it cannot set. - The Kea backend keeps a reservation and detaches its classes with
reservation-update(orreservation-delthenreservation-addwhere Kea lacks it), checking every result:reservation-addon an existing reservation was refused, and the detach's answer was ignored. Kea's keep writes theremove-phase extras, as the other backends' do; a subnet inside ashared-networksentry is found;service=other thandhcp4is refused. - The dhcpd backend keeps a host by deleting and creating it again (dhcpd
refuses to create a host that exists, so keeping and leaving a group failed);
creates a host in its group in one create, after finding the group by lookup
or creating it, and arms nothing when the group cannot be made; in group mode
puts each value a condition overrides in the
elseof its test, since a host's statements outrank its group's; writes an option dhcpd has no name for asunknown-<n>(dhcp-option-<n>does not parse); and refuses to arm a host that exists rather than failing deep in pypureomapi. Checked against a real ISC dhcpd 4.4 and DHCP client (tests/test_dhcpd_real.py). - Backends for dnsmasq, Kea (
kea/keas), ISC dhcpd over OMAPI and Windows DHCP (PowerShell or netsh; local, ssh or WinRM), taken from netboot 0.4, sending what netboot sent, except that Kea'soption-datanames options by code (netboot sent names Kea does not define:router,lease-time), and a Kea client class carries the boot file as itsboot-file-namefield, as a reservation does, rather than as option 67. - Errors under
DHCPCtlError:ConfigError,UnknownBackendError,BackendUnavailableError,MissingMACError,ServerError,ConditionUnsupportedError,ConditionMissingError.