devnomads 0.1.0__tar.gz → 0.1.2__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.
- devnomads-0.1.2/PKG-INFO +464 -0
- devnomads-0.1.2/README.md +447 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/pyproject.toml +1 -2
- {devnomads-0.1.0 → devnomads-0.1.2}/uv.lock +1 -1
- devnomads-0.1.0/PKG-INFO +0 -243
- devnomads-0.1.0/README.md +0 -225
- {devnomads-0.1.0 → devnomads-0.1.2}/.flake8 +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/.gitignore +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/.gitlab-ci.yml +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/__init__.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/__init__.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/client.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/errors.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/api/client.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/src/devnomads/py.typed +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/conftest.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_acme_client.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_acme_keys.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_client.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_credentials.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_dns.py +0 -0
- {devnomads-0.1.0 → devnomads-0.1.2}/tests/test_names.py +0 -0
devnomads-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devnomads
|
|
3
|
+
Version: 0.1.2
|
|
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
|