pydhcpctl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. pydhcpctl-0.1.0/.gitignore +53 -0
  2. pydhcpctl-0.1.0/CHANGELOG.md +143 -0
  3. pydhcpctl-0.1.0/LICENSE +21 -0
  4. pydhcpctl-0.1.0/PKG-INFO +227 -0
  5. pydhcpctl-0.1.0/README.md +165 -0
  6. pydhcpctl-0.1.0/docs/api/reference.md +50 -0
  7. pydhcpctl-0.1.0/docs/backends.md +65 -0
  8. pydhcpctl-0.1.0/docs/changelog.md +1 -0
  9. pydhcpctl-0.1.0/docs/index.md +1 -0
  10. pydhcpctl-0.1.0/mkdocs.yml +52 -0
  11. pydhcpctl-0.1.0/pyproject.toml +100 -0
  12. pydhcpctl-0.1.0/src/dhcpctl/AGENTS.md +524 -0
  13. pydhcpctl-0.1.0/src/dhcpctl/__init__.py +109 -0
  14. pydhcpctl-0.1.0/src/dhcpctl/__main__.py +4 -0
  15. pydhcpctl-0.1.0/src/dhcpctl/_completion.py +136 -0
  16. pydhcpctl-0.1.0/src/dhcpctl/_conditions.py +156 -0
  17. pydhcpctl-0.1.0/src/dhcpctl/_model.py +231 -0
  18. pydhcpctl-0.1.0/src/dhcpctl/_options.py +343 -0
  19. pydhcpctl-0.1.0/src/dhcpctl/_paths.py +26 -0
  20. pydhcpctl-0.1.0/src/dhcpctl/_registry.py +144 -0
  21. pydhcpctl-0.1.0/src/dhcpctl/_server.py +254 -0
  22. pydhcpctl-0.1.0/src/dhcpctl/backends/__init__.py +30 -0
  23. pydhcpctl-0.1.0/src/dhcpctl/backends/_dhcpd.py +655 -0
  24. pydhcpctl-0.1.0/src/dhcpctl/backends/_dnsmasq.py +555 -0
  25. pydhcpctl-0.1.0/src/dhcpctl/backends/_kea.py +551 -0
  26. pydhcpctl-0.1.0/src/dhcpctl/backends/_windhcp.py +1107 -0
  27. pydhcpctl-0.1.0/src/dhcpctl/cli/__init__.py +36 -0
  28. pydhcpctl-0.1.0/src/dhcpctl/cli/_common.py +160 -0
  29. pydhcpctl-0.1.0/src/dhcpctl/cli/_root.py +23 -0
  30. pydhcpctl-0.1.0/src/dhcpctl/cli/backends.py +39 -0
  31. pydhcpctl-0.1.0/src/dhcpctl/cli/operations.py +58 -0
  32. pydhcpctl-0.1.0/src/dhcpctl/cli/recipe.py +27 -0
  33. pydhcpctl-0.1.0/src/dhcpctl/cli/render.py +33 -0
  34. pydhcpctl-0.1.0/src/dhcpctl/exceptions.py +54 -0
  35. pydhcpctl-0.1.0/src/dhcpctl/py.typed +0 -0
  36. pydhcpctl-0.1.0/src/dhcpctl/testing.py +156 -0
  37. pydhcpctl-0.1.0/tests/conftest.py +177 -0
  38. pydhcpctl-0.1.0/tests/peers/dhcpd_session.sh +89 -0
  39. pydhcpctl-0.1.0/tests/peers/dnsmasq_exchange.sh +50 -0
  40. pydhcpctl-0.1.0/tests/peers/dnsmasq_twice.sh +59 -0
  41. pydhcpctl-0.1.0/tests/peers/kea_session.sh +103 -0
  42. pydhcpctl-0.1.0/tests/test_cli.py +121 -0
  43. pydhcpctl-0.1.0/tests/test_completion.py +317 -0
  44. pydhcpctl-0.1.0/tests/test_conditions.py +795 -0
  45. pydhcpctl-0.1.0/tests/test_contract.py +31 -0
  46. pydhcpctl-0.1.0/tests/test_dhcpd.py +363 -0
  47. pydhcpctl-0.1.0/tests/test_dhcpd_real.py +198 -0
  48. pydhcpctl-0.1.0/tests/test_dnsmasq.py +544 -0
  49. pydhcpctl-0.1.0/tests/test_dnsmasq_real.py +217 -0
  50. pydhcpctl-0.1.0/tests/test_kea.py +405 -0
  51. pydhcpctl-0.1.0/tests/test_kea_real.py +133 -0
  52. pydhcpctl-0.1.0/tests/test_mapping.py +148 -0
  53. pydhcpctl-0.1.0/tests/test_options.py +522 -0
  54. pydhcpctl-0.1.0/tests/test_registry.py +407 -0
  55. pydhcpctl-0.1.0/tests/test_render.py +106 -0
  56. pydhcpctl-0.1.0/tests/test_secrets.py +172 -0
  57. pydhcpctl-0.1.0/tests/test_server.py +233 -0
  58. pydhcpctl-0.1.0/tests/test_windhcp.py +746 -0
  59. pydhcpctl-0.1.0/tests/test_windhcp_delivery.py +62 -0
  60. pydhcpctl-0.1.0/tests/test_windhcp_live.py +115 -0
  61. pydhcpctl-0.1.0/tests/test_windhcp_powershell.py +317 -0
@@ -0,0 +1,53 @@
1
+ # Dotfiles are opt-in, as a category: a new secret-bearing dotfile (.env,
2
+ # .envrc, .pypirc, .netrc) is ignored before anyone has to remember it.
3
+ /.*
4
+ !/.gitignore
5
+ !/.gitattributes
6
+ !/.github/
7
+
8
+ # Personal, per-machine overrides: never committed, never packaged (the
9
+ # manifest excludes them too -- a dotfile pattern does not match these names).
10
+ *.local.*
11
+
12
+ # Agent configs / private notes, for nested occurrences the rule above cannot
13
+ # reach. `.agents` is slashless on purpose: it is usually a symlink, which a
14
+ # directory-only `.agents/` would not match.
15
+ .agents
16
+ CLAUDE*
17
+ .claude
18
+
19
+ # Virtualenvs
20
+ .venv*/
21
+ .pyvenv/
22
+ venv/
23
+ env/
24
+
25
+ # Byte-compiled / optimized
26
+ __pycache__/
27
+ *.py[cod]
28
+ *$py.class
29
+
30
+ # Distribution / packaging
31
+ build/
32
+ dist/
33
+ *.egg-info/
34
+ *.egg
35
+ .eggs/
36
+
37
+ # Test / coverage / type-check caches
38
+ .pytest_cache/
39
+ .hypothesis/
40
+ .mypy_cache/
41
+ .ruff_cache/
42
+ .coverage*
43
+ htmlcov/
44
+ .tox/
45
+
46
+ # Docs site build output
47
+ /site/
48
+
49
+ # Editors / OS
50
+ .vscode/
51
+ .idea/
52
+ *.swp
53
+ .DS_Store
@@ -0,0 +1,143 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-19
11
+
12
+ ### Added
13
+
14
+ - `DHCPServer.render(reservation, action="add")`: what an operation would
15
+ send, contacting nothing, on every shipped backend.
16
+ - `dhcpctl.testing.BackendContract`: the suite every shipped backend passes,
17
+ for a third-party backend to run unmodified; it talks to no server.
18
+ - The `dhcpctl` command line (`pydhcpctl[cli]`): `render`, `add`,
19
+ `complete`, `remove`, `recipe` and `backends`, a reservation given by flags
20
+ or a document, secrets from `DHCPCTL_*` variables.
21
+ - Published as `pydhcpctl`; the import package is `dhcpctl`.
22
+ - `connect(uri, *, secrets=None, owner="dhcpctl")`, `register_backend`,
23
+ `unregister_backend`, `backend_names`, `get_backend` and the
24
+ `dhcpctl.backends` entry-point group.
25
+ - `DHCPServer`: `add`, `remove`, `keep`, `complete`, `ensure_condition`,
26
+ `condition_recipe`, `remove_condition_member`, `extras`,
27
+ `secret`, `redacted_uri`. A secret is the caller's when it gives one, else
28
+ the URI's user or password, so one URI serves several logins; a password is
29
+ masked in `str`, `repr` and messages.
30
+ - `Reservation`, `Target`, `Scope`, `Condition`, `Completion`,
31
+ `parse_conditions`, `parse_completion`. What each serves is a pydhcp
32
+ `DHCPLease` (re-exported): the address (`0.0.0.0` for a MAC-only
33
+ reservation), the options, and the BOOTP header fields `siaddr`, `sname` and
34
+ `file`, which are not options 66 and 67; each backend writes them as its
35
+ server spells the fields. `Reservation.to_mapping` / `from_mapping` and
36
+ `to_text` / `from_text` write and read a reservation as plain data or a
37
+ JSON, YAML, TOML or INI document (all but JSON with the `formats` extra),
38
+ each lease in pydhcp's `DHCPLease.to_mapping` form.
39
+ - Options are pydhcp's `DHCPOptions` and `DHCPOptionCode`, re-exported, and
40
+ reach a backend as pydhcp objects for it to write as its server needs.
41
+ A codemap (`option_codes`, an `OptionCodes` enum, pydhcp's
42
+ `OptionCodeEnum`, its names read by pydhcp's `resolve`) reads a config's option names: declared options with a
43
+ code and a pydhcp type of their own, aliases of names or codes, over pydhcp's
44
+ names, values read by pydhcp's `DHCPOptions.from_mapping` (an enum member
45
+ by name, octets as hex); the Kea and dhcpd backends add their own spellings (`OPTION_NAMES`),
46
+ and `connect(uri, codes=...)` the caller's; `codes.lease(mapping)` takes
47
+ the header fields (`next-server`, `file`, an alias) out of a config's options. Declared types belong to the
48
+ codemap, which starts from the codecs its base has when it is made. netsh's
49
+ data types come from the codecs. An unset address is `UNSPECIFIED`
50
+ (pydhcp's `UNSPECIFIED_ADDRESS`, `0.0.0.0`), as in BOOTP.
51
+ - The dnsmasq backend writes its hosts and options files as dnsmasq reads
52
+ them: each line the value of `--dhcp-host` / `--dhcp-option` without the
53
+ key (netboot wrote `dhcp-host=` and `dhcp-option=` prefixes, which dnsmasq
54
+ rejects as "bad hex constant", so no reservation took effect), and the boot
55
+ file and next server as `option:bootfile-name` / `option:server-ip-address`
56
+ in place of a `dhcp-boot` line. An extras line for the options file is
57
+ written the same way. Checked against a real dnsmasq and DHCP client
58
+ (`tests/test_dnsmasq_real.py`).
59
+ - A dnsmasq directory (`--dhcp-hostsdir`) is SIGHUPed when an entry it may have
60
+ read is removed or changed: dnsmasq only ever adds what such a directory
61
+ holds, so a completed client kept its boot file. A new entry still needs no
62
+ signal. A reservation with options and no `optsfile=` is refused instead of
63
+ arming the client without them; a trailing `/` on a path that does not exist
64
+ yet means a directory; names that reduce to one tag get distinct tags; a
65
+ missing or hung reload command, an unreadable pidfile and a platform without
66
+ SIGHUP are errors instead of a stray exception or SIGTERM. A local hosts or
67
+ options file is rewritten under a lock on `.<file>.lock`, so two runs at once
68
+ no longer drop each other's entries.
69
+ - Every option reaches each server in a form it reads. dhcpd: an option it
70
+ defines is written under its name in its own syntax (119 codes, taken from
71
+ dhcpd 4.4 itself); netboot wrote `unknown-<n>` and octets for all but a
72
+ dozen, which dhcpd parses as the option's own type, so it refused the
73
+ host's statements and served none of them (70 of 134 options). Negative
74
+ numbers and domain names are written bare, and octets for an option dhcpd
75
+ defines with a structure of its own are a `ConfigError`. dnsmasq: a flag,
76
+ a number list and octets are written as octets (`True`, `576,1500` and
77
+ `0a0b` were text or refused). Kea: octets go with `csv-format` off, a flag
78
+ as `true`. Windows: a flag is 1 or 0, and a negative number the DWORD it
79
+ stores (it refused `-3600` for option 2). Checked by sending every option of a
80
+ plain codec through a real dhcpd (`dhcpd -t`) and dnsmasq, and one of each
81
+ codec to a real client (`tests/test_dhcpd_real.py`,
82
+ `tests/test_dnsmasq_real.py`).
83
+ - Re-arming a client replaces what the server holds for it, on every
84
+ backend: dhcpd and Kea refused, and the Windows cmdlet threw.
85
+ - Kea conditions work: a condition is a client class of the client's own,
86
+ tested on its MAC and `option[n].hex` (iPXE's flat option 77 never matched
87
+ `.text`), and what it overrides is served by a second class of the
88
+ client's when it does not match, since Kea serves a reservation's values
89
+ over any class's. The reservation names no `client-classes`, which put the
90
+ client in the class whatever its test said: on a real Kea every client was
91
+ handed the reservation's boot file, iPXE or not. `?target=memory` (host_cmds'
92
+ `operation-target`) arms a Kea with no hosts database. Checked against a
93
+ real Kea 3.0.4 (`tests/test_kea_real.py`).
94
+ - Commands run through hostctl hosts: the Windows backend's local, ssh and
95
+ WinRM transports (`hostctl.LocalConfig`, `SSHConfig`, `WinRMConfig`) and a
96
+ dnsmasq `reload=`, locally or over ssh. hostctl delivers a PowerShell script
97
+ whole; netboot piped it into `powershell -Command -`, which runs only the
98
+ first line of a multi-line block and exits 0 even when a cmdlet fails, so
99
+ reservations were reported armed and were not. A script that ends without
100
+ printing its closing `{"ok": true}` is a `ServerError`, as are an
101
+ unreachable host, a refused login and a timeout (`timeout=`, default 120 s).
102
+ ssh is asyncssh, through `hostctl[ssh]`, rather than the system `ssh`
103
+ client; it reads `~/.ssh/config` as `ssh` does, and the `ssh` extra now
104
+ serves windhcp over ssh too. Checked against real Windows PowerShell 5.1
105
+ (`tests/test_windhcp_delivery.py`), and against a live Windows Server 2025
106
+ DHCP server over ssh with both methods: arm, re-arm, keep and remove, the
107
+ policies included (`tests/test_windhcp_live.py`, opt-in).
108
+ - The Windows backend removes a target's own policies when it removes the
109
+ target (they kept answering its MAC); leaves an existing shared policy as it
110
+ is, and trusts it on a host without the DhcpServer module; prints, in target
111
+ mode, the MAC-matched policies it would create rather than a scope-wide one,
112
+ with every value quoted against injection (typographic quotes included);
113
+ reads back under `method=netsh` the options it actually set; keeps a
114
+ reservation through netsh when `method=netsh`; and warns about option 119 and
115
+ a next-server it cannot set.
116
+ - The Kea backend keeps a reservation and detaches its classes with
117
+ `reservation-update` (or `reservation-del` then `reservation-add` where Kea
118
+ lacks it), checking every result: `reservation-add` on an existing
119
+ reservation was refused, and the detach's answer was ignored. Kea's keep
120
+ writes the `remove`-phase extras, as the other backends' do; a subnet inside
121
+ a `shared-networks` entry is found; `service=` other than `dhcp4` is
122
+ refused.
123
+ - The dhcpd backend keeps a host by deleting and creating it again (dhcpd
124
+ refuses to create a host that exists, so keeping and leaving a group failed);
125
+ creates a host in its group in one create, after finding the group by lookup
126
+ or creating it, and arms nothing when the group cannot be made; in group mode
127
+ puts each value a condition overrides in the `else` of its test, since a
128
+ host's statements outrank its group's; writes an option dhcpd has no name
129
+ for as `unknown-<n>` (`dhcp-option-<n>` does not parse); and refuses to arm a
130
+ host that exists rather than failing deep in pypureomapi. Checked against a
131
+ real ISC dhcpd 4.4 and DHCP client (`tests/test_dhcpd_real.py`).
132
+ - Backends for dnsmasq, Kea (`kea`/`keas`), ISC dhcpd over OMAPI and Windows
133
+ DHCP (PowerShell or netsh; local, ssh or WinRM), taken from netboot 0.4,
134
+ sending what netboot sent, except that Kea's `option-data` names options by
135
+ code (netboot sent names Kea does not define: `router`, `lease-time`), and a
136
+ Kea client class carries the boot file as its `boot-file-name` field, as a
137
+ reservation does, rather than as option 67.
138
+ - Errors under `DHCPCtlError`: `ConfigError`, `UnknownBackendError`,
139
+ `BackendUnavailableError`, `MissingMACError`, `ServerError`,
140
+ `ConditionUnsupportedError`, `ConditionMissingError`.
141
+
142
+ [Unreleased]: https://github.com/jose-pr/dhcpctl/compare/v0.1.0...HEAD
143
+ [0.1.0]: https://github.com/jose-pr/dhcpctl/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jose A.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.5
2
+ Name: pydhcpctl
3
+ Version: 0.1.0
4
+ Summary: One interface to DHCP servers: arm a reservation on dnsmasq, Kea, ISC dhcpd or Windows DHCP, then remove or keep it.
5
+ Project-URL: Homepage, https://github.com/jose-pr/dhcpctl/
6
+ Project-URL: Documentation, https://jose-pr.github.io/dhcpctl/
7
+ Project-URL: Issues, https://github.com/jose-pr/dhcpctl/issues
8
+ Author: jose-pr
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: System :: Networking
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: hostctl<0.5,>=0.4.0
26
+ Requires-Dist: netimps<0.5,>=0.4.1
27
+ Requires-Dist: pathapi-uri<0.2,>=0.1.0
28
+ Requires-Dist: pathapi<0.2,>=0.1.2
29
+ Requires-Dist: pydhcp<0.10,>=0.9.0
30
+ Provides-Extra: cli
31
+ Requires-Dist: duho<0.8,>=0.7.6; extra == 'cli'
32
+ Provides-Extra: dev
33
+ Requires-Dist: black; extra == 'dev'
34
+ Requires-Dist: build; extra == 'dev'
35
+ Requires-Dist: duho<0.8,>=0.7.6; extra == 'dev'
36
+ Requires-Dist: hostctl[ssh]<0.5,>=0.4.0; extra == 'dev'
37
+ Requires-Dist: hostctl[winrm]<0.5,>=0.4.0; extra == 'dev'
38
+ Requires-Dist: pktcap[toml,yaml]<0.2,>=0.1.1; extra == 'dev'
39
+ Requires-Dist: pydhcp[pktcap]; extra == 'dev'
40
+ Requires-Dist: pypureomapi<2,>=0.8; extra == 'dev'
41
+ Requires-Dist: pytest; extra == 'dev'
42
+ Requires-Dist: requests<3,>=2.32.3; extra == 'dev'
43
+ Requires-Dist: sftppath[uri]<0.2,>=0.1.3; extra == 'dev'
44
+ Provides-Extra: dhcpd
45
+ Requires-Dist: pypureomapi<2,>=0.8; extra == 'dhcpd'
46
+ Provides-Extra: docs
47
+ Requires-Dist: mkdocs-material; extra == 'docs'
48
+ Requires-Dist: mkdocs<2; extra == 'docs'
49
+ Requires-Dist: mkdocstrings[python]; extra == 'docs'
50
+ Requires-Dist: pymdown-extensions<12.2; (python_version < '3.11') and extra == 'docs'
51
+ Provides-Extra: formats
52
+ Requires-Dist: pktcap[toml,yaml]<0.2,>=0.1.1; extra == 'formats'
53
+ Requires-Dist: pydhcp[pktcap]; extra == 'formats'
54
+ Provides-Extra: kea
55
+ Requires-Dist: requests<3,>=2.32.3; extra == 'kea'
56
+ Provides-Extra: ssh
57
+ Requires-Dist: hostctl[ssh]<0.5,>=0.4.0; extra == 'ssh'
58
+ Requires-Dist: sftppath[uri]<0.2,>=0.1.3; extra == 'ssh'
59
+ Provides-Extra: winrm
60
+ Requires-Dist: hostctl[winrm]<0.5,>=0.4.0; extra == 'winrm'
61
+ Description-Content-Type: text/markdown
62
+
63
+ # dhcpctl
64
+
65
+ [![Version](https://img.shields.io/pypi/v/pydhcpctl.svg)](https://pypi.org/project/pydhcpctl/)
66
+ [![Python versions](https://img.shields.io/pypi/pyversions/pydhcpctl.svg)](https://pypi.org/project/pydhcpctl/)
67
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/dhcpctl/blob/main/LICENSE)
68
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/dhcpctl/)
69
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/dhcpctl/test.yml)](https://github.com/jose-pr/dhcpctl/actions/workflows/test.yml)
70
+
71
+ One interface to DHCP servers: **arm a reservation** for a client on dnsmasq,
72
+ ISC Kea, ISC dhcpd or Windows DHCP, then **remove it or keep it** without its
73
+ boot options. A server is a URI: its scheme picks the backend and its query says how to
74
+ reach it. The options to serve belong to each reservation.
75
+ Documentation: <https://jose-pr.github.io/dhcpctl/>.
76
+
77
+ ## Features
78
+
79
+ - **Four servers, one call** — `add`, `remove`, `complete` on dnsmasq (files,
80
+ local or over `sftp://`), Kea (control agent), dhcpd (OMAPI) and Windows DHCP
81
+ (PowerShell cmdlets or netsh, locally, over ssh or WinRM).
82
+ - **pydhcp's lease** — what a client is served is a [pydhcp](https://github.com/jose-pr/pydhcp)
83
+ `DHCPLease`: its address, its `DHCPOptions` named by pydhcp's registry
84
+ (`router`, `dns`, `option-<n>`) or an alias, each value read by its pydhcp
85
+ codec before any server sees it, and the BOOTP header fields (`next-server`,
86
+ `file`); each backend translates them, and backend-native text in a
87
+ reservation's `extras` rides through untouched.
88
+ - **Conditions** — options served only to clients that match a test (the iPXE
89
+ chainload), as a Windows policy, a dhcpd `if` or group, or a Kea client class;
90
+ a backend that cannot express one refuses rather than arming without it, and
91
+ hands over the server-side recipe when it lacks the rights to create it.
92
+ - **Completion that does not reinstall** — keep a finished client's reservation
93
+ and replace only its boot options.
94
+ - **Values are data, never source** — dhcpd statements are escaped, Windows
95
+ scripts get their parameters as a JSON payload, netsh arguments stay array
96
+ elements.
97
+ - **Nothing required** — each backend's library is an extra, imported only when
98
+ its scheme is used; secrets come from the URI or the caller, and a password
99
+ is masked wherever a server is shown.
100
+ - **Reservations as documents** — `Reservation.to_mapping` / `from_mapping`
101
+ and `to_text` / `from_text` (JSON, YAML, TOML, INI) round-trip a reservation
102
+ through pydhcp's own option form.
103
+ - **Pluggable** — a backend is a `DHCPServer` subclass registered for a scheme,
104
+ directly or through the `dhcpctl.backends` entry point.
105
+
106
+ ## Installation
107
+
108
+ ```bash
109
+ pip install pydhcpctl
110
+ ```
111
+
112
+ The distribution is `pydhcpctl`; the import package is `dhcpctl`.
113
+
114
+ | Scheme | Server | Install |
115
+ | --- | --- | --- |
116
+ | `dnsmasq://` | dnsmasq | nothing locally; `pydhcpctl[ssh]` for `sftp://` paths |
117
+ | `kea://`, `keas://` | ISC Kea control agent | `pydhcpctl[kea]` (requests) |
118
+ | `dhcpd://` | ISC dhcpd over OMAPI | `pydhcpctl[dhcpd]` (pypureomapi) |
119
+ | `windhcp://` | Windows DHCP | nothing locally; `pydhcpctl[ssh]` over ssh, `pydhcpctl[winrm]` over WinRM |
120
+
121
+ `pydhcpctl[formats]` adds YAML, TOML and INI to `Reservation.to_text` /
122
+ `from_text` (JSON needs nothing), and `pydhcpctl[cli]` the `dhcpctl` command.
123
+
124
+ Python 3.9 or later.
125
+
126
+ ## Usage
127
+
128
+ ```python
129
+ import dhcpctl
130
+ from ipaddress import ip_network
131
+
132
+ from netimps import MACAddress
133
+
134
+ server = dhcpctl.connect("kea://kea01:8000/?service=dhcp4") # how to reach it, nothing else
135
+
136
+ # Options by name, and the BOOTP header fields beside them (not options).
137
+ lease = server.codes.lease(
138
+ {"router": "10.0.0.1", "file": "undionly.kpxe", "next-server": "10.0.0.2"}
139
+ )
140
+ reservation = dhcpctl.Reservation(
141
+ target=dhcpctl.Target("web01", mac=MACAddress("aa:bb:cc:dd:ee:ff")),
142
+ scope=dhcpctl.Scope("lan", network=ip_network("10.0.0.0/24")),
143
+ lease=lease.replace(ip="10.0.0.10"),
144
+ completion=dhcpctl.Completion(keep=True), # keep the address when done
145
+ )
146
+
147
+ server.add(reservation) # arm
148
+ server.complete(reservation) # finished: keep the entry, drop its boot options
149
+ ```
150
+
151
+ A condition serves different options to clients that match a test:
152
+
153
+ ```python
154
+ import dataclasses
155
+
156
+ conditions = dhcpctl.parse_conditions(
157
+ {"ipxe": {"match": {"user-class": "iPXE"}, "options": {"file": "boot.ipxe"}}}
158
+ )
159
+ server.add(dataclasses.replace(reservation, conditions=conditions))
160
+ print(server.condition_recipe(reservation, conditions["ipxe"])) # what an admin applies
161
+ ```
162
+
163
+ A secret is the URI's when it carries one, else the caller's, asked for only
164
+ when needed — so it can stay out of a URI that ends up in config or logs:
165
+
166
+ ```python
167
+ server = dhcpctl.connect(
168
+ "windhcp://dhcp01/?transport=winrm", secrets={"user": "svc", "password": "..."}
169
+ )
170
+ ```
171
+
172
+ ## Command line
173
+
174
+ `pip install pydhcpctl[cli]` adds the `dhcpctl` command (also `python -m
175
+ dhcpctl`). A reservation is given by flags, or as a document with `--from`
176
+ (`.json`, `.yaml`, `.toml`, `.ini`, or `-` for JSON on standard input), which
177
+ can also carry conditions and a completion:
178
+
179
+ ```bash
180
+ # What arming web01 would send, contacting nothing:
181
+ dhcpctl render 'dhcpd://dhcp01/' -n web01 -m aa:bb:cc:dd:ee:ff --ip 10.0.0.10 \
182
+ -o router=10.0.0.1 -o file=undionly.kpxe
183
+ dhcpctl add 'kea://kea01/?target=memory' --from web01.json # arm (or re-arm)
184
+ dhcpctl complete 'kea://kea01/?target=memory' --from web01.json --keep
185
+ dhcpctl remove 'windhcp://dhcp01/?transport=winrm' -n web01 -m aa:bb:cc:dd:ee:ff
186
+ dhcpctl recipe 'windhcp://dhcp01/' --from web01.json # what an admin sets up once
187
+ dhcpctl backends --json
188
+ ```
189
+
190
+ `-o name=value` is an option or header field; a comma makes a list
191
+ (`dns=10.0.0.53,10.0.0.54`) and `json:` a JSON value (`lease-time=json:600`).
192
+ Secrets come from the environment, never a flag: `DHCPCTL_USER`,
193
+ `DHCPCTL_PASSWORD`, `DHCPCTL_KEYNAME`, `DHCPCTL_OMAPI_KEY`. Exit status 0 is
194
+ done, 1 the server refused or could not be reached, 2 the invocation or the
195
+ reservation was wrong.
196
+
197
+ ## Writing a backend
198
+
199
+ ```python
200
+ class Recorder(dhcpctl.DHCPServer):
201
+ def add(self, reservation): ...
202
+ def remove(self, reservation): ...
203
+
204
+ dhcpctl.register_backend("recorder", Recorder)
205
+ dhcpctl.connect("recorder://anything")
206
+ ```
207
+
208
+ or declare it in your package's metadata:
209
+
210
+ ```toml
211
+ [project.entry-points."dhcpctl.backends"]
212
+ recorder = "mypkg.dhcp:Recorder"
213
+ ```
214
+
215
+ A backend is done when it passes the contract the shipped ones pass, which
216
+ talks to no server (it needs pytest, and the backend to implement `render`):
217
+
218
+ ```python
219
+ from dhcpctl.testing import BackendContract
220
+
221
+ class TestRecorder(BackendContract):
222
+ uri = "recorder://anything"
223
+ ```
224
+
225
+ ## License
226
+
227
+ MIT
@@ -0,0 +1,165 @@
1
+ # dhcpctl
2
+
3
+ [![Version](https://img.shields.io/pypi/v/pydhcpctl.svg)](https://pypi.org/project/pydhcpctl/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pydhcpctl.svg)](https://pypi.org/project/pydhcpctl/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/dhcpctl/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/dhcpctl/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/dhcpctl/test.yml)](https://github.com/jose-pr/dhcpctl/actions/workflows/test.yml)
8
+
9
+ One interface to DHCP servers: **arm a reservation** for a client on dnsmasq,
10
+ ISC Kea, ISC dhcpd or Windows DHCP, then **remove it or keep it** without its
11
+ boot options. A server is a URI: its scheme picks the backend and its query says how to
12
+ reach it. The options to serve belong to each reservation.
13
+ Documentation: <https://jose-pr.github.io/dhcpctl/>.
14
+
15
+ ## Features
16
+
17
+ - **Four servers, one call** — `add`, `remove`, `complete` on dnsmasq (files,
18
+ local or over `sftp://`), Kea (control agent), dhcpd (OMAPI) and Windows DHCP
19
+ (PowerShell cmdlets or netsh, locally, over ssh or WinRM).
20
+ - **pydhcp's lease** — what a client is served is a [pydhcp](https://github.com/jose-pr/pydhcp)
21
+ `DHCPLease`: its address, its `DHCPOptions` named by pydhcp's registry
22
+ (`router`, `dns`, `option-<n>`) or an alias, each value read by its pydhcp
23
+ codec before any server sees it, and the BOOTP header fields (`next-server`,
24
+ `file`); each backend translates them, and backend-native text in a
25
+ reservation's `extras` rides through untouched.
26
+ - **Conditions** — options served only to clients that match a test (the iPXE
27
+ chainload), as a Windows policy, a dhcpd `if` or group, or a Kea client class;
28
+ a backend that cannot express one refuses rather than arming without it, and
29
+ hands over the server-side recipe when it lacks the rights to create it.
30
+ - **Completion that does not reinstall** — keep a finished client's reservation
31
+ and replace only its boot options.
32
+ - **Values are data, never source** — dhcpd statements are escaped, Windows
33
+ scripts get their parameters as a JSON payload, netsh arguments stay array
34
+ elements.
35
+ - **Nothing required** — each backend's library is an extra, imported only when
36
+ its scheme is used; secrets come from the URI or the caller, and a password
37
+ is masked wherever a server is shown.
38
+ - **Reservations as documents** — `Reservation.to_mapping` / `from_mapping`
39
+ and `to_text` / `from_text` (JSON, YAML, TOML, INI) round-trip a reservation
40
+ through pydhcp's own option form.
41
+ - **Pluggable** — a backend is a `DHCPServer` subclass registered for a scheme,
42
+ directly or through the `dhcpctl.backends` entry point.
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install pydhcpctl
48
+ ```
49
+
50
+ The distribution is `pydhcpctl`; the import package is `dhcpctl`.
51
+
52
+ | Scheme | Server | Install |
53
+ | --- | --- | --- |
54
+ | `dnsmasq://` | dnsmasq | nothing locally; `pydhcpctl[ssh]` for `sftp://` paths |
55
+ | `kea://`, `keas://` | ISC Kea control agent | `pydhcpctl[kea]` (requests) |
56
+ | `dhcpd://` | ISC dhcpd over OMAPI | `pydhcpctl[dhcpd]` (pypureomapi) |
57
+ | `windhcp://` | Windows DHCP | nothing locally; `pydhcpctl[ssh]` over ssh, `pydhcpctl[winrm]` over WinRM |
58
+
59
+ `pydhcpctl[formats]` adds YAML, TOML and INI to `Reservation.to_text` /
60
+ `from_text` (JSON needs nothing), and `pydhcpctl[cli]` the `dhcpctl` command.
61
+
62
+ Python 3.9 or later.
63
+
64
+ ## Usage
65
+
66
+ ```python
67
+ import dhcpctl
68
+ from ipaddress import ip_network
69
+
70
+ from netimps import MACAddress
71
+
72
+ server = dhcpctl.connect("kea://kea01:8000/?service=dhcp4") # how to reach it, nothing else
73
+
74
+ # Options by name, and the BOOTP header fields beside them (not options).
75
+ lease = server.codes.lease(
76
+ {"router": "10.0.0.1", "file": "undionly.kpxe", "next-server": "10.0.0.2"}
77
+ )
78
+ reservation = dhcpctl.Reservation(
79
+ target=dhcpctl.Target("web01", mac=MACAddress("aa:bb:cc:dd:ee:ff")),
80
+ scope=dhcpctl.Scope("lan", network=ip_network("10.0.0.0/24")),
81
+ lease=lease.replace(ip="10.0.0.10"),
82
+ completion=dhcpctl.Completion(keep=True), # keep the address when done
83
+ )
84
+
85
+ server.add(reservation) # arm
86
+ server.complete(reservation) # finished: keep the entry, drop its boot options
87
+ ```
88
+
89
+ A condition serves different options to clients that match a test:
90
+
91
+ ```python
92
+ import dataclasses
93
+
94
+ conditions = dhcpctl.parse_conditions(
95
+ {"ipxe": {"match": {"user-class": "iPXE"}, "options": {"file": "boot.ipxe"}}}
96
+ )
97
+ server.add(dataclasses.replace(reservation, conditions=conditions))
98
+ print(server.condition_recipe(reservation, conditions["ipxe"])) # what an admin applies
99
+ ```
100
+
101
+ A secret is the URI's when it carries one, else the caller's, asked for only
102
+ when needed — so it can stay out of a URI that ends up in config or logs:
103
+
104
+ ```python
105
+ server = dhcpctl.connect(
106
+ "windhcp://dhcp01/?transport=winrm", secrets={"user": "svc", "password": "..."}
107
+ )
108
+ ```
109
+
110
+ ## Command line
111
+
112
+ `pip install pydhcpctl[cli]` adds the `dhcpctl` command (also `python -m
113
+ dhcpctl`). A reservation is given by flags, or as a document with `--from`
114
+ (`.json`, `.yaml`, `.toml`, `.ini`, or `-` for JSON on standard input), which
115
+ can also carry conditions and a completion:
116
+
117
+ ```bash
118
+ # What arming web01 would send, contacting nothing:
119
+ dhcpctl render 'dhcpd://dhcp01/' -n web01 -m aa:bb:cc:dd:ee:ff --ip 10.0.0.10 \
120
+ -o router=10.0.0.1 -o file=undionly.kpxe
121
+ dhcpctl add 'kea://kea01/?target=memory' --from web01.json # arm (or re-arm)
122
+ dhcpctl complete 'kea://kea01/?target=memory' --from web01.json --keep
123
+ dhcpctl remove 'windhcp://dhcp01/?transport=winrm' -n web01 -m aa:bb:cc:dd:ee:ff
124
+ dhcpctl recipe 'windhcp://dhcp01/' --from web01.json # what an admin sets up once
125
+ dhcpctl backends --json
126
+ ```
127
+
128
+ `-o name=value` is an option or header field; a comma makes a list
129
+ (`dns=10.0.0.53,10.0.0.54`) and `json:` a JSON value (`lease-time=json:600`).
130
+ Secrets come from the environment, never a flag: `DHCPCTL_USER`,
131
+ `DHCPCTL_PASSWORD`, `DHCPCTL_KEYNAME`, `DHCPCTL_OMAPI_KEY`. Exit status 0 is
132
+ done, 1 the server refused or could not be reached, 2 the invocation or the
133
+ reservation was wrong.
134
+
135
+ ## Writing a backend
136
+
137
+ ```python
138
+ class Recorder(dhcpctl.DHCPServer):
139
+ def add(self, reservation): ...
140
+ def remove(self, reservation): ...
141
+
142
+ dhcpctl.register_backend("recorder", Recorder)
143
+ dhcpctl.connect("recorder://anything")
144
+ ```
145
+
146
+ or declare it in your package's metadata:
147
+
148
+ ```toml
149
+ [project.entry-points."dhcpctl.backends"]
150
+ recorder = "mypkg.dhcp:Recorder"
151
+ ```
152
+
153
+ A backend is done when it passes the contract the shipped ones pass, which
154
+ talks to no server (it needs pytest, and the backend to implement `render`):
155
+
156
+ ```python
157
+ from dhcpctl.testing import BackendContract
158
+
159
+ class TestRecorder(BackendContract):
160
+ uri = "recorder://anything"
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,50 @@
1
+ # API reference
2
+
3
+ ## Connecting
4
+
5
+ ::: dhcpctl.connect
6
+
7
+ ::: dhcpctl.register_backend
8
+
9
+ ::: dhcpctl.unregister_backend
10
+
11
+ ::: dhcpctl.backend_names
12
+
13
+ ::: dhcpctl.get_backend
14
+
15
+ ## The contract
16
+
17
+ ::: dhcpctl.DHCPServer
18
+
19
+ ## Reservations
20
+
21
+ ::: dhcpctl.Reservation
22
+
23
+ ::: dhcpctl.Target
24
+
25
+ ::: dhcpctl.Scope
26
+
27
+ ## What is served
28
+
29
+ `dhcpctl.DHCPLease`, `dhcpctl.DHCPOptions` and `dhcpctl.DHCPOptionCode` are
30
+ pydhcp's, re-exported: see [pydhcp's reference](https://jose-pr.github.io/pydhcp/).
31
+
32
+ ::: dhcpctl.OptionCodes
33
+
34
+ ::: dhcpctl.option_codes
35
+
36
+ ::: dhcpctl.plain_items
37
+
38
+ ## Conditions and completion
39
+
40
+ ::: dhcpctl.Condition
41
+
42
+ ::: dhcpctl.parse_conditions
43
+
44
+ ::: dhcpctl.Completion
45
+
46
+ ::: dhcpctl.parse_completion
47
+
48
+ ## Errors
49
+
50
+ ::: dhcpctl.exceptions