devnomads 0.1.1__tar.gz → 0.1.3__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 (35) hide show
  1. devnomads-0.1.3/PKG-INFO +464 -0
  2. devnomads-0.1.3/README.md +447 -0
  3. {devnomads-0.1.1 → devnomads-0.1.3}/pyproject.toml +1 -1
  4. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/__init__.py +8 -1
  5. {devnomads-0.1.1 → devnomads-0.1.3}/uv.lock +1 -1
  6. devnomads-0.1.1/PKG-INFO +0 -242
  7. devnomads-0.1.1/README.md +0 -225
  8. {devnomads-0.1.1 → devnomads-0.1.3}/.flake8 +0 -0
  9. {devnomads-0.1.1 → devnomads-0.1.3}/.gitignore +0 -0
  10. {devnomads-0.1.1 → devnomads-0.1.3}/.gitlab-ci.yml +0 -0
  11. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/__init__.py +0 -0
  12. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/challenge_server.py +0 -0
  13. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/client.py +0 -0
  14. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/dns01.py +0 -0
  15. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/errors.py +0 -0
  16. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/http01.py +0 -0
  17. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/keys.py +0 -0
  18. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/acme/verify.py +0 -0
  19. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/api/__init__.py +0 -0
  20. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/api/client.py +0 -0
  21. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/api/credentials.py +0 -0
  22. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/api/errors.py +0 -0
  23. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/dns/__init__.py +0 -0
  24. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/dns/errors.py +0 -0
  25. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/dns/names.py +0 -0
  26. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/dns/zones.py +0 -0
  27. {devnomads-0.1.1 → devnomads-0.1.3}/src/devnomads/py.typed +0 -0
  28. {devnomads-0.1.1 → devnomads-0.1.3}/tests/conftest.py +0 -0
  29. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_acme_challenges.py +0 -0
  30. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_acme_client.py +0 -0
  31. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_acme_keys.py +0 -0
  32. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_client.py +0 -0
  33. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_credentials.py +0 -0
  34. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_dns.py +0 -0
  35. {devnomads-0.1.1 → devnomads-0.1.3}/tests/test_names.py +0 -0
@@ -0,0 +1,464 @@
1
+ Metadata-Version: 2.4
2
+ Name: devnomads
3
+ Version: 0.1.3
4
+ Summary: Python client for the DevNomads API (transport, DNS, ACME)
5
+ Project-URL: Homepage, https://devnomads.nl
6
+ Author-email: Loek Geleijn <support@devnomads.nl>
7
+ License: MIT
8
+ Keywords: acme,api,devnomads,dns,powerdns
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27
11
+ Provides-Extra: acme
12
+ Requires-Dist: acme>=2.11; extra == 'acme'
13
+ Requires-Dist: cryptography>=42; extra == 'acme'
14
+ Requires-Dist: dnspython>=2.6; extra == 'acme'
15
+ Requires-Dist: josepy>=1.14; extra == 'acme'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # devnomads
19
+
20
+ Python client library for the [DevNomads](https://devnomads.nl) API:
21
+ HTTP transport, DNS zone/record management, and ACME certificate
22
+ issuance.
23
+
24
+ It is the shared foundation of the DevNomads command-line tools - `dnctl`
25
+ and `dnsync` - exposed so you can build against the same primitives
26
+ directly instead of reimplementing them.
27
+
28
+ ## Contents
29
+
30
+ - [Features](#features)
31
+ - [Installation](#installation)
32
+ - [Quick start](#quick-start)
33
+ - [Architecture](#architecture)
34
+ - [Authentication](#authentication)
35
+ - [Transport](#transport)
36
+ - [DNS](#dns)
37
+ - [Certificates](#certificates)
38
+ - [Errors](#errors)
39
+ - [Logging](#logging)
40
+ - [Design](#design)
41
+ - [Development](#development)
42
+ - [License](#license)
43
+
44
+ ## Features
45
+
46
+ - Authenticated HTTP transport with retries (`Retry-After` aware) and
47
+ Laravel resource-envelope unwrapping.
48
+ - DNS zone discovery (longest-suffix matching), merge-aware TXT updates,
49
+ and generic record management.
50
+ - ACME certificate issuance over DNS-01 and HTTP-01 - including
51
+ wildcards and per-identifier challenge mixing in a single order.
52
+ - Two HTTP-01 strategies: write challenge files to a web root, or answer
53
+ from a built-in standalone server.
54
+ - One credential scheme, compatible with `dnctl` and `dnsync`.
55
+ - Typed exceptions, standard-library logging, full type hints
56
+ (`py.typed`). No printing, no `sys.exit`.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ pip install devnomads # transport + DNS (httpx only)
62
+ pip install devnomads[acme] # + the ACME client and its dependencies
63
+ ```
64
+
65
+ Requires Python 3.10 or newer. The core install depends only on `httpx`.
66
+ The `acme` extra adds `acme`, `josepy`, `cryptography`, and `dnspython`.
67
+ `devnomads.acme` only imports with that extra present; without it the
68
+ import raises an `ImportError` naming the extra.
69
+
70
+ ## Quick start
71
+
72
+ ```python
73
+ from devnomads.api import Client
74
+ from devnomads.dns import Dns
75
+
76
+ with Client.from_environment() as client:
77
+ dns = Dns(client)
78
+ dns.set_txt("_acme-challenge.example.com", "token-value")
79
+ dns.unset_txt("_acme-challenge.example.com", "token-value")
80
+ ```
81
+
82
+ ## Architecture
83
+
84
+ The package is split into three layers by dependency weight. Import only
85
+ what you need.
86
+
87
+ ```text
88
+ devnomads.api transport: auth, retries, errors (httpx)
89
+
90
+ devnomads.dns zones, records, TXT/ACME challenges (httpx)
91
+
92
+ devnomads.acme ACME issuance: DNS-01 + HTTP-01 (+ acme extra deps)
93
+ ```
94
+
95
+ The DevNomads DNS API is a PowerDNS proxy scoped to the zones a key may
96
+ access, so zone ids and record names are absolute (trailing dot) and TXT
97
+ content is stored quoted; the library handles these conventions for you.
98
+
99
+ ## Authentication
100
+
101
+ Build a client from the environment, from explicit credentials, or from a
102
+ resolved `Credentials` object.
103
+
104
+ ```python
105
+ from devnomads.api import Client, resolve
106
+
107
+ client = Client.from_environment() # env + credentials file
108
+ client = Client("https://api.devnomads.nl", "dn_xxx") # explicit
109
+
110
+ creds = resolve(profile="work") # -> Credentials
111
+ client = Client.from_credentials(creds)
112
+ ```
113
+
114
+ `Client` is a context manager and holds one connection pool:
115
+
116
+ ```python
117
+ with Client.from_environment() as client:
118
+ ...
119
+ ```
120
+
121
+ ### Resolution order
122
+
123
+ The API key is resolved as:
124
+
125
+ 1. an explicit value passed to `Client`/`resolve`;
126
+ 2. `DN_API_KEY` (or the `DEVNOMADS_API_KEY` alias);
127
+ 3. the `api_key` of the selected profile in an INI credentials file.
128
+
129
+ The credentials file is `DN_CREDENTIALS_FILE` if set, otherwise the first
130
+ of `/etc/devnomads/credentials` and `<config dir>/credentials`. The config
131
+ directory is `DN_CONFIG_DIR`, else `$XDG_CONFIG_HOME/dnctl`, else
132
+ `~/.config/dnctl` - i.e. the file `dnctl configure` writes. The profile is
133
+ `DN_PROFILE`, else `default`.
134
+
135
+ ### Credentials file
136
+
137
+ ```ini
138
+ [default]
139
+ api_key = dn_xxxxxxxxxxxxxxxx
140
+
141
+ [work]
142
+ api_key = dn_yyyyyyyyyyyyyyyy
143
+ api_url = https://api.devnomads.nl
144
+ ```
145
+
146
+ ### Environment variables
147
+
148
+ - `DN_API_KEY` / `DEVNOMADS_API_KEY` - API key.
149
+ - `DN_API_URL` / `DEVNOMADS_API_URL` - base URL override.
150
+ - `DN_PROFILE` / `DEVNOMADS_PROFILE` - profile name (default `default`).
151
+ - `DN_CREDENTIALS_FILE` - explicit credentials file path.
152
+ - `DN_CONFIG_DIR` - config directory (default `~/.config/dnctl`).
153
+
154
+ `config_dir()` and `credentials_path()` expose the resolved paths.
155
+
156
+ ## Transport
157
+
158
+ `devnomads.api.Client` is a thin wrapper over `httpx` that owns auth,
159
+ retries, and error handling. Use it directly for endpoints the higher
160
+ layers don't cover.
161
+
162
+ ```python
163
+ Client(api_url, api_key, *, user_agent=None, timeout=30.0)
164
+ ```
165
+
166
+ ```python
167
+ with Client.from_environment() as client:
168
+ zones = client.request("GET", "/services/dns/zones")
169
+ client.request(
170
+ "PATCH",
171
+ "/services/dns/zones/example.com.",
172
+ json_body={"rrsets": [rrset]},
173
+ )
174
+ ```
175
+
176
+ `request(method, path, *, params=None, json_body=None)` returns parsed
177
+ JSON and:
178
+
179
+ - unwraps the Laravel `{"data": ...}` envelope;
180
+ - retries `429` and `5xx` with backoff, honouring `Retry-After`;
181
+ - returns `None` for `404` and for empty/`204` bodies, so a missing
182
+ resource reads as absence;
183
+ - raises `AuthError` on `401`/`403` and `ApiError` on any other `4xx`.
184
+
185
+ ## DNS
186
+
187
+ `Dns` wraps a `Client` with zone-scoped operations. Zone discovery lists
188
+ the accessible zones and selects the longest matching suffix, so a deep
189
+ host resolves to its flat parent zone automatically.
190
+
191
+ ```python
192
+ from devnomads.api import Client
193
+ from devnomads.dns import Dns
194
+
195
+ with Client.from_environment() as client:
196
+ dns = Dns(client)
197
+ ```
198
+
199
+ ### TXT records and ACME challenges
200
+
201
+ ```python
202
+ result = dns.set_txt("_acme-challenge.example.com", "token")
203
+ # result.zone, result.values, result.changed (a TxtResult)
204
+
205
+ dns.unset_txt("_acme-challenge.example.com", "token")
206
+ dns.unset_txt("_acme-challenge.example.com") # remove the whole rrset
207
+ ```
208
+
209
+ `set_txt` merges with any values already present - so a wildcard and its
210
+ base domain can validate the same name concurrently - and makes no request
211
+ when the value is already there. `unset_txt` removes one value (or the
212
+ whole rrset when no value is given) and deletes the rrset once its last
213
+ value is gone. Both return `TxtResult(zone, values, changed)` and accept
214
+ an optional `ttl` (default 60).
215
+
216
+ ### Generic records
217
+
218
+ ```python
219
+ dns.replace_records(
220
+ "host.example.com", "A", ["192.0.2.1", "192.0.2.2"], ttl=300
221
+ )
222
+ dns.delete_records("host.example.com", "A")
223
+ ```
224
+
225
+ `replace_records` sends content verbatim (use it for A/AAAA/CNAME/MX/...);
226
+ `set_txt` is the TXT-specific path that handles quoting and merging.
227
+
228
+ ### Zones and name helpers
229
+
230
+ ```python
231
+ names = dns.list_zone_names()
232
+ zone = dns.find_zone("_acme-challenge.deep.host.example.com")
233
+ data = dns.get_zone(zone)
234
+ dns.patch_rrset(zone, rrset) # low-level rrset change
235
+ ```
236
+
237
+ ```python
238
+ from devnomads.dns import challenge_name, fqdn, zone_id, current_txt_values
239
+
240
+ challenge_name("*.example.com") # "_acme-challenge.example.com"
241
+ fqdn("www", "example.com") # "www.example.com."
242
+ zone_id("example.com") # "example.com."
243
+ current_txt_values(data, "_acme-challenge.example.com") # ["token"]
244
+ ```
245
+
246
+ Also exported: `absolute`, `quote_txt`, `unquote_txt`, `DEFAULT_TXT_TTL`,
247
+ and the `ZoneNotFound` error.
248
+
249
+ ## Certificates
250
+
251
+ `devnomads.acme` (needs the `acme` extra) issues certificates from an ACME
252
+ CA - Let's Encrypt by default - over DNS-01, HTTP-01, or a mix of both in
253
+ one order. It manages the account key, builds the CSR, verifies DNS
254
+ propagation against the authoritative nameservers, and can select a
255
+ preferred chain.
256
+
257
+ ```python
258
+ AcmeClient(
259
+ account_key_path,
260
+ *,
261
+ directory_url=DEFAULT_DIRECTORY_URL, # Let's Encrypt production
262
+ account_key_algorithm="ec256", # rsa2048/rsa4096/ec256/ec384
263
+ contact_email=None,
264
+ preferred_chain=None, # match alternate chain by CN
265
+ recursive_nameservers=None, # resolvers for propagation
266
+ user_agent="devnomads",
267
+ )
268
+ ```
269
+
270
+ The account key at `account_key_path` is loaded if present and created
271
+ (mode 0600) otherwise.
272
+
273
+ `obtain_certificate` returns
274
+ `(cert_pem, fullchain_pem, chain_pem, domain_key_pem)` - the first three
275
+ as `str`, the key as `bytes`.
276
+
277
+ ### DNS-01
278
+
279
+ ```python
280
+ from pathlib import Path
281
+ from devnomads.api import Client
282
+ from devnomads.dns import Dns
283
+ from devnomads.acme import AcmeClient, DevNomadsDnsProvider, generate_key
284
+
285
+ acme = AcmeClient(
286
+ "/etc/devnomads/account.key",
287
+ contact_email="ops@example.com",
288
+ preferred_chain="ISRG Root X1",
289
+ )
290
+ domain_key = generate_key("ec256")
291
+
292
+ with Client.from_environment() as client:
293
+ provider = DevNomadsDnsProvider(Dns(client))
294
+ cert, fullchain, chain, key_pem = acme.obtain_certificate(
295
+ "example.com",
296
+ "dns-01",
297
+ domain_key,
298
+ sans=["www.example.com", "*.example.com"],
299
+ dns_provider=provider,
300
+ )
301
+
302
+ out = Path("/etc/pki/tls/private")
303
+ (out / "fullchain.pem").write_text(fullchain)
304
+ (out / "privkey.pem").write_bytes(key_pem)
305
+ ```
306
+
307
+ `dns_propagation_delay` (default 2s) sets how long to wait after writing
308
+ records before polling; `recursive_nameservers` (IPv6 first for graceful
309
+ failover) picks the resolvers used for that check.
310
+
311
+ ### HTTP-01
312
+
313
+ Answer HTTP-01 challenges from the built-in server, or by writing files
314
+ under an existing web root:
315
+
316
+ ```python
317
+ from devnomads.acme import StandaloneSolver, WebrootSolver
318
+
319
+ # Standalone: bind :80 and serve challenges directly.
320
+ with StandaloneSolver(port=80) as solver:
321
+ acme.obtain_certificate(
322
+ "example.com", "http-01", domain_key, http01_solver=solver
323
+ )
324
+
325
+ # Webroot: write files for an existing server to serve.
326
+ acme.obtain_certificate(
327
+ "example.com",
328
+ "http-01",
329
+ domain_key,
330
+ http01_solver=WebrootSolver("/var/www/html"),
331
+ )
332
+ ```
333
+
334
+ `StandaloneSolver` is a context manager that starts/stops the bundled
335
+ `ChallengeServer`; a bind failure is logged (not raised) so a co-located
336
+ instance already holding the port is tolerated.
337
+
338
+ ### Mixed challenges
339
+
340
+ Pass a `{identifier: challenge_type}` mapping to use different challenge
341
+ types per name in one order - e.g. HTTP-01 for the base domain and DNS-01
342
+ for its wildcard:
343
+
344
+ ```python
345
+ acme.obtain_certificate(
346
+ "example.com",
347
+ {"example.com": "http-01", "*.example.com": "dns-01"},
348
+ domain_key,
349
+ sans=["*.example.com"],
350
+ dns_provider=provider,
351
+ http01_solver=solver,
352
+ )
353
+ ```
354
+
355
+ ### Staging
356
+
357
+ ```python
358
+ acme = AcmeClient(
359
+ "/etc/devnomads/account.key",
360
+ directory_url=(
361
+ "https://acme-staging-v02.api.letsencrypt.org/directory"
362
+ ),
363
+ )
364
+ ```
365
+
366
+ ### Key helpers
367
+
368
+ ```python
369
+ from devnomads.acme import (
370
+ generate_key,
371
+ serialize_key,
372
+ build_csr,
373
+ load_or_create_account_key,
374
+ )
375
+
376
+ key = generate_key("rsa4096") # rsa2048/rsa4096/ec256/ec384
377
+ pem = serialize_key(key) # unencrypted PEM bytes
378
+ csr = build_csr(key, ["example.com", "www.example.com"])
379
+ jwk = load_or_create_account_key("/etc/devnomads/account.key", "ec256")
380
+ ```
381
+
382
+ ### Custom backends
383
+
384
+ `DevNomadsDnsProvider` is the bundled DNS-01 backend. Implement
385
+ `DnsProvider` (or `Http01Solver`) to drive your own:
386
+
387
+ ```python
388
+ from devnomads.acme import DnsProvider
389
+
390
+ class MyProvider(DnsProvider):
391
+ def create_challenge(self, fqdn, validation):
392
+ ...
393
+
394
+ def delete_challenge(self, fqdn, validation=None):
395
+ ...
396
+ ```
397
+
398
+ `verify_txt_record(fqdn, expected_value, *, timeout=120, interval=5,
399
+ nameservers=None)` is exposed if you want to run the propagation check
400
+ yourself.
401
+
402
+ ## Errors
403
+
404
+ The library never prints or exits; operations return values and failures
405
+ raise a `DevNomadsError` subclass, so the caller decides how to present
406
+ them.
407
+
408
+ ```text
409
+ DevNomadsError
410
+ ├── ConfigError # credentials / config could not be resolved
411
+ ├── ApiError # API returned an error (.status, .detail)
412
+ │ └── AuthError # 401 / 403
413
+ ├── DnsError # a DNS operation failed
414
+ │ └── ZoneNotFound # no accessible zone matches the name
415
+ └── AcmeError # an ACME issuance step failed
416
+ ```
417
+
418
+ ```python
419
+ from devnomads.api import DevNomadsError, AuthError
420
+
421
+ try:
422
+ dns.set_txt(name, value)
423
+ except AuthError:
424
+ ... # missing, invalid, or unauthorized key
425
+ except DevNomadsError as exc:
426
+ ... # everything else this library raises
427
+ ```
428
+
429
+ `ApiError` carries `.status` and `.detail`. Remember that `request`
430
+ returns `None` (rather than raising) for a `404`.
431
+
432
+ ## Logging
433
+
434
+ Progress and warnings go through the standard `logging` module under the
435
+ `devnomads` logger (`devnomads.api`, `devnomads.acme`). It is silent
436
+ unless you configure logging:
437
+
438
+ ```python
439
+ import logging
440
+
441
+ logging.getLogger("devnomads").setLevel(logging.INFO)
442
+ ```
443
+
444
+ ## Design
445
+
446
+ - **Library, not application.** It raises and returns; it never prints,
447
+ exits, or formats for humans. The calling CLI, hook, or daemon owns
448
+ presentation.
449
+ - **Layered by dependency weight.** DNS-only consumers never pull the
450
+ ACME stack; the heavy dependencies live behind the `acme` extra.
451
+ - **One credential scheme** shared with the DevNomads CLIs, so a host
452
+ configured for `dnctl` works unchanged.
453
+
454
+ ## Development
455
+
456
+ ```bash
457
+ uv sync --dev --extra acme
458
+ uv run pytest
459
+ uv run black . && uv run flake8 src tests
460
+ ```
461
+
462
+ ## License
463
+
464
+ MIT