devnomads 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.
- devnomads-0.1.0/.flake8 +4 -0
- devnomads-0.1.0/.gitignore +10 -0
- devnomads-0.1.0/.gitlab-ci.yml +42 -0
- devnomads-0.1.0/PKG-INFO +243 -0
- devnomads-0.1.0/README.md +225 -0
- devnomads-0.1.0/pyproject.toml +53 -0
- devnomads-0.1.0/src/devnomads/__init__.py +17 -0
- devnomads-0.1.0/src/devnomads/acme/__init__.py +49 -0
- devnomads-0.1.0/src/devnomads/acme/challenge_server.py +103 -0
- devnomads-0.1.0/src/devnomads/acme/client.py +364 -0
- devnomads-0.1.0/src/devnomads/acme/dns01.py +42 -0
- devnomads-0.1.0/src/devnomads/acme/errors.py +9 -0
- devnomads-0.1.0/src/devnomads/acme/http01.py +91 -0
- devnomads-0.1.0/src/devnomads/acme/keys.py +104 -0
- devnomads-0.1.0/src/devnomads/acme/verify.py +109 -0
- devnomads-0.1.0/src/devnomads/api/__init__.py +28 -0
- devnomads-0.1.0/src/devnomads/api/client.py +197 -0
- devnomads-0.1.0/src/devnomads/api/credentials.py +164 -0
- devnomads-0.1.0/src/devnomads/api/errors.py +32 -0
- devnomads-0.1.0/src/devnomads/dns/__init__.py +31 -0
- devnomads-0.1.0/src/devnomads/dns/errors.py +13 -0
- devnomads-0.1.0/src/devnomads/dns/names.py +75 -0
- devnomads-0.1.0/src/devnomads/dns/zones.py +211 -0
- devnomads-0.1.0/src/devnomads/py.typed +0 -0
- devnomads-0.1.0/tests/conftest.py +29 -0
- devnomads-0.1.0/tests/test_acme_challenges.py +85 -0
- devnomads-0.1.0/tests/test_acme_client.py +64 -0
- devnomads-0.1.0/tests/test_acme_keys.py +70 -0
- devnomads-0.1.0/tests/test_client.py +91 -0
- devnomads-0.1.0/tests/test_credentials.py +99 -0
- devnomads-0.1.0/tests/test_dns.py +187 -0
- devnomads-0.1.0/tests/test_names.py +70 -0
- devnomads-0.1.0/uv.lock +2234 -0
devnomads-0.1.0/.flake8
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
stages:
|
|
2
|
+
- check
|
|
3
|
+
- test
|
|
4
|
+
- publish
|
|
5
|
+
|
|
6
|
+
default:
|
|
7
|
+
image: ghcr.io/astral-sh/uv:python3.14-alpine
|
|
8
|
+
before_script:
|
|
9
|
+
# dev tools + the acme extra, so linting and tests cover devnomads.acme.
|
|
10
|
+
- uv sync --dev --extra acme
|
|
11
|
+
|
|
12
|
+
check:
|
|
13
|
+
stage: check
|
|
14
|
+
script:
|
|
15
|
+
- uv run black --check .
|
|
16
|
+
- uv run isort --check-only .
|
|
17
|
+
- uv run flake8 src tests
|
|
18
|
+
- uv run bandit -r src
|
|
19
|
+
- uv run semgrep scan --quiet --error --config p/python src
|
|
20
|
+
- uv run mypy src
|
|
21
|
+
|
|
22
|
+
test:
|
|
23
|
+
stage: test
|
|
24
|
+
parallel:
|
|
25
|
+
matrix:
|
|
26
|
+
- PYTHON: ["3.10", "3.14"]
|
|
27
|
+
image: ghcr.io/astral-sh/uv:python$PYTHON-alpine
|
|
28
|
+
script:
|
|
29
|
+
- uv run pytest -v
|
|
30
|
+
|
|
31
|
+
# Runs on version tags (v0.1.0). PYPI_TOKEN (masked) and
|
|
32
|
+
# PYPI_PUBLISH_URL are CI variables pointing at pypi.org; the
|
|
33
|
+
# PYPI_TEST_* variants hold the test.pypi.org equivalents - swap them
|
|
34
|
+
# in to rehearse a release without publishing for real.
|
|
35
|
+
publish:
|
|
36
|
+
stage: publish
|
|
37
|
+
rules:
|
|
38
|
+
- if: $CI_COMMIT_TAG =~ /^v/
|
|
39
|
+
before_script: []
|
|
40
|
+
script:
|
|
41
|
+
- uv build
|
|
42
|
+
- uv publish --publish-url "$PYPI_PUBLISH_URL" --token "$PYPI_TOKEN"
|
devnomads-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devnomads
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the DevNomads API (transport, DNS, ACME)
|
|
5
|
+
Project-URL: Homepage, https://devnomads.nl
|
|
6
|
+
Project-URL: Source, https://gitlab.infrapod.nl/infrapod/devnomads-libs
|
|
7
|
+
Author-email: Loek Geleijn <support@devnomads.nl>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: acme,api,devnomads,dns,powerdns
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: acme
|
|
13
|
+
Requires-Dist: acme>=2.11; extra == 'acme'
|
|
14
|
+
Requires-Dist: cryptography>=42; extra == 'acme'
|
|
15
|
+
Requires-Dist: dnspython>=2.6; extra == 'acme'
|
|
16
|
+
Requires-Dist: josepy>=1.14; extra == 'acme'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# devnomads
|
|
20
|
+
|
|
21
|
+
Python client library for the [DevNomads](https://devnomads.nl) API:
|
|
22
|
+
HTTP transport, DNS zone/record management, and ACME certificate
|
|
23
|
+
issuance.
|
|
24
|
+
|
|
25
|
+
It is the shared foundation of the DevNomads command-line tools - `dnctl`
|
|
26
|
+
and `dnsync` - exposed so you can build against the same primitives
|
|
27
|
+
directly instead of reimplementing them.
|
|
28
|
+
|
|
29
|
+
## Layers
|
|
30
|
+
|
|
31
|
+
The package is split by dependency weight, lightest first. `httpx` is the
|
|
32
|
+
only hard dependency; import what you need.
|
|
33
|
+
|
|
34
|
+
- **`devnomads.api`** - HTTP transport: authentication, retries with
|
|
35
|
+
`Retry-After`, Laravel envelope unwrapping, and a typed error hierarchy.
|
|
36
|
+
- **`devnomads.dns`** - DNS helpers on top of the transport: zone
|
|
37
|
+
discovery, rrset merging, A/AAAA records, and TXT/ACME-challenge
|
|
38
|
+
handling.
|
|
39
|
+
- **`devnomads.acme`** - ACME client for certificate issuance over DNS-01
|
|
40
|
+
and HTTP-01. Requires the `acme` extra (`acme`, `josepy`,
|
|
41
|
+
`cryptography`, `dnspython`).
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install devnomads # api + dns (httpx only)
|
|
47
|
+
pip install devnomads[acme] # + the ACME client and its dependencies
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`devnomads.acme` only imports cleanly with the `acme` extra installed;
|
|
51
|
+
without it, importing the module raises an `ImportError` that names the
|
|
52
|
+
extra. Requires Python 3.10 or newer.
|
|
53
|
+
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
Build a client from the environment, or pass credentials explicitly:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from devnomads.api import Client
|
|
60
|
+
|
|
61
|
+
# Resolved from the environment and credentials file.
|
|
62
|
+
client = Client.from_environment()
|
|
63
|
+
|
|
64
|
+
# Or explicit:
|
|
65
|
+
client = Client("https://api.devnomads.nl", "dn_xxx")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`Client` is a context manager and reuses one underlying connection pool:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
with Client.from_environment() as client:
|
|
72
|
+
...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The API key is resolved in this order:
|
|
76
|
+
|
|
77
|
+
1. an explicit value passed to `Client`/`resolve`;
|
|
78
|
+
2. `DN_API_KEY` (or the `DEVNOMADS_API_KEY` alias);
|
|
79
|
+
3. the `api_key` of the selected profile in an INI credentials file.
|
|
80
|
+
|
|
81
|
+
The credentials file is taken from `DN_CREDENTIALS_FILE` if set, otherwise
|
|
82
|
+
the first of `/etc/devnomads/credentials` and `~/.config/dnctl/credentials`
|
|
83
|
+
(the file `dnctl configure` writes). The profile is `DN_PROFILE`, else
|
|
84
|
+
`default`. This matches the resolution used by `dnctl` and `dnsync`, so a
|
|
85
|
+
host already configured for those works unchanged.
|
|
86
|
+
|
|
87
|
+
## DNS
|
|
88
|
+
|
|
89
|
+
`Dns` wraps a `Client` with zone-scoped operations. Zone discovery is
|
|
90
|
+
done by listing accessible zones and picking the longest matching suffix,
|
|
91
|
+
so a deep host resolves to its flat parent zone automatically.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from devnomads.api import Client
|
|
95
|
+
from devnomads.dns import Dns
|
|
96
|
+
|
|
97
|
+
with Client.from_environment() as client:
|
|
98
|
+
dns = Dns(client)
|
|
99
|
+
|
|
100
|
+
# Merge-aware, idempotent TXT updates (used for ACME challenges).
|
|
101
|
+
dns.set_txt("_acme-challenge.example.com", "token-value")
|
|
102
|
+
dns.unset_txt("_acme-challenge.example.com", "token-value")
|
|
103
|
+
|
|
104
|
+
# Generic records (content sent verbatim).
|
|
105
|
+
dns.replace_records("host.example.com", "A", ["192.0.2.1"], ttl=300)
|
|
106
|
+
dns.delete_records("host.example.com", "A")
|
|
107
|
+
|
|
108
|
+
zone = dns.find_zone("_acme-challenge.deep.host.example.com")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`set_txt`/`unset_txt` return a `TxtResult(zone, values, changed)`;
|
|
112
|
+
`set_txt` merges with any values already present (so a wildcard and its
|
|
113
|
+
base domain can validate the same name concurrently) and makes no request
|
|
114
|
+
when the value is already there. `unset_txt` deletes the rrset once its
|
|
115
|
+
last value is gone.
|
|
116
|
+
|
|
117
|
+
The `devnomads.dns` module also exports name helpers - `challenge_name`,
|
|
118
|
+
`fqdn`, `zone_id`, `quote_txt`, `unquote_txt` - and the `ZoneNotFound`
|
|
119
|
+
error.
|
|
120
|
+
|
|
121
|
+
## ACME
|
|
122
|
+
|
|
123
|
+
`AcmeClient` issues certificates from an ACME CA (Let's Encrypt by
|
|
124
|
+
default) over DNS-01, HTTP-01, or a mix of both within one order. It
|
|
125
|
+
manages the account key, builds the CSR, verifies DNS propagation against
|
|
126
|
+
the authoritative nameservers, and selects the preferred chain.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from devnomads.api import Client
|
|
130
|
+
from devnomads.dns import Dns
|
|
131
|
+
from devnomads.acme import AcmeClient, DevNomadsDnsProvider, generate_key
|
|
132
|
+
|
|
133
|
+
acme = AcmeClient(
|
|
134
|
+
"/etc/devnomads/account.key",
|
|
135
|
+
contact_email="ops@example.com",
|
|
136
|
+
)
|
|
137
|
+
domain_key = generate_key("ec256")
|
|
138
|
+
|
|
139
|
+
# DNS-01, including wildcards:
|
|
140
|
+
with Client.from_environment() as client:
|
|
141
|
+
provider = DevNomadsDnsProvider(Dns(client))
|
|
142
|
+
leaf, fullchain, chain, key_pem = acme.obtain_certificate(
|
|
143
|
+
"example.com",
|
|
144
|
+
"dns-01",
|
|
145
|
+
domain_key,
|
|
146
|
+
sans=["*.example.com"],
|
|
147
|
+
dns_provider=provider,
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`obtain_certificate` returns
|
|
152
|
+
`(cert_pem, fullchain_pem, chain_pem, domain_key_pem)` as strings (the key
|
|
153
|
+
as bytes).
|
|
154
|
+
|
|
155
|
+
### HTTP-01
|
|
156
|
+
|
|
157
|
+
Answer HTTP-01 challenges either from the bundled in-memory server or by
|
|
158
|
+
writing files under an existing web root:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from devnomads.acme import StandaloneSolver, WebrootSolver
|
|
162
|
+
|
|
163
|
+
# Standalone: bind :80 and serve challenges directly.
|
|
164
|
+
with StandaloneSolver(port=80) as solver:
|
|
165
|
+
acme.obtain_certificate(
|
|
166
|
+
"example.com", "http-01", domain_key, http01_solver=solver
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Webroot: write files for an existing server to serve.
|
|
170
|
+
acme.obtain_certificate(
|
|
171
|
+
"example.com",
|
|
172
|
+
"http-01",
|
|
173
|
+
domain_key,
|
|
174
|
+
http01_solver=WebrootSolver("/var/www/html"),
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Mixed challenges
|
|
179
|
+
|
|
180
|
+
Pass a `{identifier: challenge_type}` mapping to use different challenge
|
|
181
|
+
types per name in a single order - e.g. HTTP-01 for the base domain and
|
|
182
|
+
DNS-01 for its wildcard:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
acme.obtain_certificate(
|
|
186
|
+
"example.com",
|
|
187
|
+
{"example.com": "http-01", "*.example.com": "dns-01"},
|
|
188
|
+
domain_key,
|
|
189
|
+
sans=["*.example.com"],
|
|
190
|
+
dns_provider=provider,
|
|
191
|
+
http01_solver=solver,
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
To target staging, pass
|
|
196
|
+
`directory_url="https://acme-staging-v02.api.letsencrypt.org/directory"`.
|
|
197
|
+
Implement `devnomads.acme.DnsProvider` to drive a DNS backend other than
|
|
198
|
+
DevNomads.
|
|
199
|
+
|
|
200
|
+
## Errors
|
|
201
|
+
|
|
202
|
+
The library never prints or exits; operations return values and failures
|
|
203
|
+
raise `devnomads.api.DevNomadsError` subclasses, so the caller decides how
|
|
204
|
+
to present them.
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from devnomads.api import DevNomadsError, AuthError
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
dns.set_txt(name, value)
|
|
211
|
+
except AuthError:
|
|
212
|
+
... # 401/403: missing, invalid, or unauthorized key
|
|
213
|
+
except DevNomadsError as exc:
|
|
214
|
+
... # ApiError, ConfigError, DnsError, AcmeError, ...
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`ApiError` carries `status` and `detail`. Note that `Client.request`
|
|
218
|
+
returns `None` (rather than raising) for a 404, so a missing resource
|
|
219
|
+
reads as absence.
|
|
220
|
+
|
|
221
|
+
## Logging
|
|
222
|
+
|
|
223
|
+
Progress and warnings go through the standard `logging` module under the
|
|
224
|
+
`devnomads` logger (`devnomads.api`, `devnomads.acme`). It is silent
|
|
225
|
+
unless you configure logging:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
import logging
|
|
229
|
+
|
|
230
|
+
logging.getLogger("devnomads").setLevel(logging.INFO)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
uv sync --dev --extra acme
|
|
237
|
+
uv run pytest
|
|
238
|
+
uv run black . && uv run flake8 src tests
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# devnomads
|
|
2
|
+
|
|
3
|
+
Python client library for the [DevNomads](https://devnomads.nl) API:
|
|
4
|
+
HTTP transport, DNS zone/record management, and ACME certificate
|
|
5
|
+
issuance.
|
|
6
|
+
|
|
7
|
+
It is the shared foundation of the DevNomads command-line tools - `dnctl`
|
|
8
|
+
and `dnsync` - exposed so you can build against the same primitives
|
|
9
|
+
directly instead of reimplementing them.
|
|
10
|
+
|
|
11
|
+
## Layers
|
|
12
|
+
|
|
13
|
+
The package is split by dependency weight, lightest first. `httpx` is the
|
|
14
|
+
only hard dependency; import what you need.
|
|
15
|
+
|
|
16
|
+
- **`devnomads.api`** - HTTP transport: authentication, retries with
|
|
17
|
+
`Retry-After`, Laravel envelope unwrapping, and a typed error hierarchy.
|
|
18
|
+
- **`devnomads.dns`** - DNS helpers on top of the transport: zone
|
|
19
|
+
discovery, rrset merging, A/AAAA records, and TXT/ACME-challenge
|
|
20
|
+
handling.
|
|
21
|
+
- **`devnomads.acme`** - ACME client for certificate issuance over DNS-01
|
|
22
|
+
and HTTP-01. Requires the `acme` extra (`acme`, `josepy`,
|
|
23
|
+
`cryptography`, `dnspython`).
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install devnomads # api + dns (httpx only)
|
|
29
|
+
pip install devnomads[acme] # + the ACME client and its dependencies
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`devnomads.acme` only imports cleanly with the `acme` extra installed;
|
|
33
|
+
without it, importing the module raises an `ImportError` that names the
|
|
34
|
+
extra. Requires Python 3.10 or newer.
|
|
35
|
+
|
|
36
|
+
## Authentication
|
|
37
|
+
|
|
38
|
+
Build a client from the environment, or pass credentials explicitly:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from devnomads.api import Client
|
|
42
|
+
|
|
43
|
+
# Resolved from the environment and credentials file.
|
|
44
|
+
client = Client.from_environment()
|
|
45
|
+
|
|
46
|
+
# Or explicit:
|
|
47
|
+
client = Client("https://api.devnomads.nl", "dn_xxx")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`Client` is a context manager and reuses one underlying connection pool:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
with Client.from_environment() as client:
|
|
54
|
+
...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The API key is resolved in this order:
|
|
58
|
+
|
|
59
|
+
1. an explicit value passed to `Client`/`resolve`;
|
|
60
|
+
2. `DN_API_KEY` (or the `DEVNOMADS_API_KEY` alias);
|
|
61
|
+
3. the `api_key` of the selected profile in an INI credentials file.
|
|
62
|
+
|
|
63
|
+
The credentials file is taken from `DN_CREDENTIALS_FILE` if set, otherwise
|
|
64
|
+
the first of `/etc/devnomads/credentials` and `~/.config/dnctl/credentials`
|
|
65
|
+
(the file `dnctl configure` writes). The profile is `DN_PROFILE`, else
|
|
66
|
+
`default`. This matches the resolution used by `dnctl` and `dnsync`, so a
|
|
67
|
+
host already configured for those works unchanged.
|
|
68
|
+
|
|
69
|
+
## DNS
|
|
70
|
+
|
|
71
|
+
`Dns` wraps a `Client` with zone-scoped operations. Zone discovery is
|
|
72
|
+
done by listing accessible zones and picking the longest matching suffix,
|
|
73
|
+
so a deep host resolves to its flat parent zone automatically.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from devnomads.api import Client
|
|
77
|
+
from devnomads.dns import Dns
|
|
78
|
+
|
|
79
|
+
with Client.from_environment() as client:
|
|
80
|
+
dns = Dns(client)
|
|
81
|
+
|
|
82
|
+
# Merge-aware, idempotent TXT updates (used for ACME challenges).
|
|
83
|
+
dns.set_txt("_acme-challenge.example.com", "token-value")
|
|
84
|
+
dns.unset_txt("_acme-challenge.example.com", "token-value")
|
|
85
|
+
|
|
86
|
+
# Generic records (content sent verbatim).
|
|
87
|
+
dns.replace_records("host.example.com", "A", ["192.0.2.1"], ttl=300)
|
|
88
|
+
dns.delete_records("host.example.com", "A")
|
|
89
|
+
|
|
90
|
+
zone = dns.find_zone("_acme-challenge.deep.host.example.com")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`set_txt`/`unset_txt` return a `TxtResult(zone, values, changed)`;
|
|
94
|
+
`set_txt` merges with any values already present (so a wildcard and its
|
|
95
|
+
base domain can validate the same name concurrently) and makes no request
|
|
96
|
+
when the value is already there. `unset_txt` deletes the rrset once its
|
|
97
|
+
last value is gone.
|
|
98
|
+
|
|
99
|
+
The `devnomads.dns` module also exports name helpers - `challenge_name`,
|
|
100
|
+
`fqdn`, `zone_id`, `quote_txt`, `unquote_txt` - and the `ZoneNotFound`
|
|
101
|
+
error.
|
|
102
|
+
|
|
103
|
+
## ACME
|
|
104
|
+
|
|
105
|
+
`AcmeClient` issues certificates from an ACME CA (Let's Encrypt by
|
|
106
|
+
default) over DNS-01, HTTP-01, or a mix of both within one order. It
|
|
107
|
+
manages the account key, builds the CSR, verifies DNS propagation against
|
|
108
|
+
the authoritative nameservers, and selects the preferred chain.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from devnomads.api import Client
|
|
112
|
+
from devnomads.dns import Dns
|
|
113
|
+
from devnomads.acme import AcmeClient, DevNomadsDnsProvider, generate_key
|
|
114
|
+
|
|
115
|
+
acme = AcmeClient(
|
|
116
|
+
"/etc/devnomads/account.key",
|
|
117
|
+
contact_email="ops@example.com",
|
|
118
|
+
)
|
|
119
|
+
domain_key = generate_key("ec256")
|
|
120
|
+
|
|
121
|
+
# DNS-01, including wildcards:
|
|
122
|
+
with Client.from_environment() as client:
|
|
123
|
+
provider = DevNomadsDnsProvider(Dns(client))
|
|
124
|
+
leaf, fullchain, chain, key_pem = acme.obtain_certificate(
|
|
125
|
+
"example.com",
|
|
126
|
+
"dns-01",
|
|
127
|
+
domain_key,
|
|
128
|
+
sans=["*.example.com"],
|
|
129
|
+
dns_provider=provider,
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`obtain_certificate` returns
|
|
134
|
+
`(cert_pem, fullchain_pem, chain_pem, domain_key_pem)` as strings (the key
|
|
135
|
+
as bytes).
|
|
136
|
+
|
|
137
|
+
### HTTP-01
|
|
138
|
+
|
|
139
|
+
Answer HTTP-01 challenges either from the bundled in-memory server or by
|
|
140
|
+
writing files under an existing web root:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from devnomads.acme import StandaloneSolver, WebrootSolver
|
|
144
|
+
|
|
145
|
+
# Standalone: bind :80 and serve challenges directly.
|
|
146
|
+
with StandaloneSolver(port=80) as solver:
|
|
147
|
+
acme.obtain_certificate(
|
|
148
|
+
"example.com", "http-01", domain_key, http01_solver=solver
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Webroot: write files for an existing server to serve.
|
|
152
|
+
acme.obtain_certificate(
|
|
153
|
+
"example.com",
|
|
154
|
+
"http-01",
|
|
155
|
+
domain_key,
|
|
156
|
+
http01_solver=WebrootSolver("/var/www/html"),
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Mixed challenges
|
|
161
|
+
|
|
162
|
+
Pass a `{identifier: challenge_type}` mapping to use different challenge
|
|
163
|
+
types per name in a single order - e.g. HTTP-01 for the base domain and
|
|
164
|
+
DNS-01 for its wildcard:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
acme.obtain_certificate(
|
|
168
|
+
"example.com",
|
|
169
|
+
{"example.com": "http-01", "*.example.com": "dns-01"},
|
|
170
|
+
domain_key,
|
|
171
|
+
sans=["*.example.com"],
|
|
172
|
+
dns_provider=provider,
|
|
173
|
+
http01_solver=solver,
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
To target staging, pass
|
|
178
|
+
`directory_url="https://acme-staging-v02.api.letsencrypt.org/directory"`.
|
|
179
|
+
Implement `devnomads.acme.DnsProvider` to drive a DNS backend other than
|
|
180
|
+
DevNomads.
|
|
181
|
+
|
|
182
|
+
## Errors
|
|
183
|
+
|
|
184
|
+
The library never prints or exits; operations return values and failures
|
|
185
|
+
raise `devnomads.api.DevNomadsError` subclasses, so the caller decides how
|
|
186
|
+
to present them.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from devnomads.api import DevNomadsError, AuthError
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
dns.set_txt(name, value)
|
|
193
|
+
except AuthError:
|
|
194
|
+
... # 401/403: missing, invalid, or unauthorized key
|
|
195
|
+
except DevNomadsError as exc:
|
|
196
|
+
... # ApiError, ConfigError, DnsError, AcmeError, ...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`ApiError` carries `status` and `detail`. Note that `Client.request`
|
|
200
|
+
returns `None` (rather than raising) for a 404, so a missing resource
|
|
201
|
+
reads as absence.
|
|
202
|
+
|
|
203
|
+
## Logging
|
|
204
|
+
|
|
205
|
+
Progress and warnings go through the standard `logging` module under the
|
|
206
|
+
`devnomads` logger (`devnomads.api`, `devnomads.acme`). It is silent
|
|
207
|
+
unless you configure logging:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
import logging
|
|
211
|
+
|
|
212
|
+
logging.getLogger("devnomads").setLevel(logging.INFO)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
uv sync --dev --extra acme
|
|
219
|
+
uv run pytest
|
|
220
|
+
uv run black . && uv run flake8 src tests
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "devnomads"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the DevNomads API (transport, DNS, ACME)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Loek Geleijn", email = "support@devnomads.nl" }]
|
|
13
|
+
keywords = ["devnomads", "dns", "acme", "powerdns", "api"]
|
|
14
|
+
dependencies = ["httpx>=0.27"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
# The ACME client pulls in the only heavy dependencies in the project, so
|
|
18
|
+
# it lives behind an extra: `pip install devnomads[acme]`. Without it,
|
|
19
|
+
# `import devnomads.acme` raises a clear, actionable ImportError.
|
|
20
|
+
acme = ["acme>=2.11", "josepy>=1.14", "cryptography>=42", "dnspython>=2.6"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://devnomads.nl"
|
|
24
|
+
Source = "https://gitlab.infrapod.nl/infrapod/devnomads-libs"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8",
|
|
29
|
+
"respx>=0.21",
|
|
30
|
+
"black>=24",
|
|
31
|
+
"isort>=5",
|
|
32
|
+
"flake8>=7",
|
|
33
|
+
"mypy>=1.10",
|
|
34
|
+
"bandit>=1.7",
|
|
35
|
+
"semgrep>=1.50",
|
|
36
|
+
"pre-commit>=3",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/devnomads"]
|
|
41
|
+
|
|
42
|
+
[tool.black]
|
|
43
|
+
line-length = 88
|
|
44
|
+
target-version = ["py310"]
|
|
45
|
+
|
|
46
|
+
[tool.isort]
|
|
47
|
+
profile = "black"
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
python_version = "3.10"
|
|
51
|
+
warn_unused_ignores = true
|
|
52
|
+
warn_redundant_casts = true
|
|
53
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Python client for the DevNomads API.
|
|
2
|
+
|
|
3
|
+
Three layers, smallest dependency footprint first:
|
|
4
|
+
|
|
5
|
+
* :mod:`devnomads.api` - HTTP transport (auth, retries, error handling)
|
|
6
|
+
and, in time, the generated API SDK. Depends only on ``httpx``.
|
|
7
|
+
* :mod:`devnomads.dns` - DNS zone/record helpers (zone discovery, rrset
|
|
8
|
+
merging, TXT/challenge handling) on top of the transport.
|
|
9
|
+
* :mod:`devnomads.acme` - ACME client (DNS-01 and HTTP-01). Requires the
|
|
10
|
+
``acme`` extra: ``pip install devnomads[acme]``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""ACME client for the DevNomads tooling (DNS-01 and HTTP-01 issuance).
|
|
2
|
+
|
|
3
|
+
Requires the ``acme`` extra::
|
|
4
|
+
|
|
5
|
+
pip install devnomads[acme]
|
|
6
|
+
|
|
7
|
+
Without it, importing this package raises a clear ImportError pointing at
|
|
8
|
+
the extra rather than a bare "No module named 'acme'".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# The heavy third-party dependencies (acme, josepy, cryptography, dnspython)
|
|
14
|
+
# only ship with the [acme] extra. Catch their absence and re-raise with an
|
|
15
|
+
# actionable message; let any unrelated ImportError surface unchanged.
|
|
16
|
+
_EXTRA_MODULES = {"acme", "josepy", "cryptography", "dns"}
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from .challenge_server import ChallengeServer
|
|
20
|
+
from .client import DEFAULT_DIRECTORY_URL, AcmeClient
|
|
21
|
+
from .dns01 import DevNomadsDnsProvider, DnsProvider
|
|
22
|
+
from .errors import AcmeError
|
|
23
|
+
from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
|
|
24
|
+
from .keys import build_csr, generate_key, load_or_create_account_key, serialize_key
|
|
25
|
+
from .verify import verify_txt_record
|
|
26
|
+
except ImportError as exc: # pragma: no cover - exercised via packaging
|
|
27
|
+
if (exc.name or "").split(".")[0] in _EXTRA_MODULES:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"devnomads.acme requires the 'acme' extra; install it with: "
|
|
30
|
+
"pip install devnomads[acme]"
|
|
31
|
+
) from exc
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AcmeClient",
|
|
36
|
+
"DEFAULT_DIRECTORY_URL",
|
|
37
|
+
"AcmeError",
|
|
38
|
+
"DnsProvider",
|
|
39
|
+
"DevNomadsDnsProvider",
|
|
40
|
+
"Http01Solver",
|
|
41
|
+
"WebrootSolver",
|
|
42
|
+
"StandaloneSolver",
|
|
43
|
+
"ChallengeServer",
|
|
44
|
+
"build_csr",
|
|
45
|
+
"generate_key",
|
|
46
|
+
"serialize_key",
|
|
47
|
+
"load_or_create_account_key",
|
|
48
|
+
"verify_txt_record",
|
|
49
|
+
]
|