devnomads 0.2.5__tar.gz → 0.3.1__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.2.5 → devnomads-0.3.1}/PKG-INFO +2 -1
- {devnomads-0.2.5 → devnomads-0.3.1}/pyproject.toml +8 -2
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/__init__.py +2 -1
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/client.py +82 -23
- devnomads-0.3.1/src/devnomads/acme/errors.py +27 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_acme_client.py +154 -1
- {devnomads-0.2.5 → devnomads-0.3.1}/uv.lock +3 -1
- devnomads-0.2.5/src/devnomads/acme/errors.py +0 -9
- {devnomads-0.2.5 → devnomads-0.3.1}/.flake8 +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/.gitignore +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/.gitlab-ci.yml +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/Makefile +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/README.md +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/openapi.json +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/__init__.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/client.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/py.typed +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/conftest.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_acme_keys.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_client.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_credentials.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_dns.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_names.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_services.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tools/bump_version.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.1}/tools/generate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python client library for the DevNomads API (transport, DNS, ACME)
|
|
5
5
|
Project-URL: Homepage, https://devnomads.nl
|
|
6
6
|
Author-email: DevNomads <support@devnomads.nl>
|
|
@@ -13,6 +13,7 @@ Requires-Dist: acme>=2.11; extra == 'acme'
|
|
|
13
13
|
Requires-Dist: cryptography>=42; extra == 'acme'
|
|
14
14
|
Requires-Dist: dnspython>=2.6; extra == 'acme'
|
|
15
15
|
Requires-Dist: josepy>=1.14; extra == 'acme'
|
|
16
|
+
Requires-Dist: requests>=2.31; extra == 'acme'
|
|
16
17
|
Description-Content-Type: text/markdown
|
|
17
18
|
|
|
18
19
|
# devnomads
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devnomads"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Python client library for the DevNomads API (transport, DNS, ACME)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -17,7 +17,13 @@ dependencies = ["httpx>=0.27"]
|
|
|
17
17
|
# The ACME client pulls in the only heavy dependencies in the project, so
|
|
18
18
|
# it lives behind an extra: `pip install devnomads[acme]`. Without it,
|
|
19
19
|
# `import devnomads.acme` raises a clear, actionable ImportError.
|
|
20
|
-
acme = [
|
|
20
|
+
acme = [
|
|
21
|
+
"acme>=2.11",
|
|
22
|
+
"josepy>=1.14",
|
|
23
|
+
"cryptography>=42",
|
|
24
|
+
"dnspython>=2.6",
|
|
25
|
+
"requests>=2.31",
|
|
26
|
+
]
|
|
21
27
|
|
|
22
28
|
[project.urls]
|
|
23
29
|
Homepage = "https://devnomads.nl"
|
|
@@ -26,7 +26,7 @@ try:
|
|
|
26
26
|
AcmeClient,
|
|
27
27
|
)
|
|
28
28
|
from .dns01 import DevNomadsDnsProvider, DnsProvider
|
|
29
|
-
from .errors import AcmeError
|
|
29
|
+
from .errors import AcmeError, TransientAcmeError
|
|
30
30
|
from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
|
|
31
31
|
from .keys import (
|
|
32
32
|
build_csr,
|
|
@@ -52,6 +52,7 @@ __all__ = [
|
|
|
52
52
|
"ZEROSSL_DIRECTORY_URL",
|
|
53
53
|
"CA_DIRECTORIES",
|
|
54
54
|
"AcmeError",
|
|
55
|
+
"TransientAcmeError",
|
|
55
56
|
"DnsProvider",
|
|
56
57
|
"DevNomadsDnsProvider",
|
|
57
58
|
"Http01Solver",
|
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from contextlib import contextmanager
|
|
6
7
|
from datetime import datetime, timedelta
|
|
7
8
|
from time import sleep, time
|
|
8
9
|
|
|
10
|
+
import requests
|
|
9
11
|
from acme import challenges
|
|
10
12
|
from acme import client as acme_client
|
|
11
13
|
from acme import errors as acme_errors
|
|
12
14
|
from acme import messages
|
|
13
15
|
|
|
14
16
|
from .dns01 import DnsProvider
|
|
15
|
-
from .errors import AcmeError
|
|
17
|
+
from .errors import AcmeError, TransientAcmeError
|
|
16
18
|
from .http01 import Http01Solver
|
|
17
19
|
from .keys import (
|
|
18
20
|
PrivateKey,
|
|
@@ -45,6 +47,35 @@ CA_DIRECTORIES = {
|
|
|
45
47
|
# account registration.
|
|
46
48
|
_EAB_REQUIRED_PROVIDERS = frozenset({"zerossl"})
|
|
47
49
|
|
|
50
|
+
# ACME problem-document codes that mean "the CA had a problem", not "the
|
|
51
|
+
# subject is broken". These are retry-safe: back off and try again later.
|
|
52
|
+
_TRANSIENT_ACME_CODES = {"serverInternal", "rateLimited", "badNonce"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def _reraise_transient(step: str):
|
|
57
|
+
"""Re-raise infrastructure-level failures as :class:`TransientAcmeError`.
|
|
58
|
+
|
|
59
|
+
Wraps a network-facing ACME step. Structural ACME errors (validation
|
|
60
|
+
failures, unauthorized, CAA, ...) pass through untouched so callers can
|
|
61
|
+
still treat them as permanent.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
except requests.exceptions.RequestException as exc:
|
|
67
|
+
raise TransientAcmeError(f"{step}: CA unreachable: {exc}") from exc
|
|
68
|
+
except messages.Error as exc:
|
|
69
|
+
if exc.code in _TRANSIENT_ACME_CODES:
|
|
70
|
+
raise TransientAcmeError(f"{step}: {exc}", code=exc.code) from exc
|
|
71
|
+
raise
|
|
72
|
+
except (
|
|
73
|
+
acme_errors.TimeoutError,
|
|
74
|
+
acme_errors.PollError,
|
|
75
|
+
acme_errors.NonceError,
|
|
76
|
+
) as exc:
|
|
77
|
+
raise TransientAcmeError(f"{step}: {exc}") from exc
|
|
78
|
+
|
|
48
79
|
|
|
49
80
|
def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str | None:
|
|
50
81
|
"""Resolve the configured challenge type for one authorization.
|
|
@@ -57,6 +88,9 @@ def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str
|
|
|
57
88
|
ident = getattr(getattr(authz_body, "identifier", None), "value", None)
|
|
58
89
|
if ident is None:
|
|
59
90
|
return None
|
|
91
|
+
# The map is keyed by lowercased identifiers; lowercase the authz value
|
|
92
|
+
# too (belt and braces — CAs already lowercase it) so the lookup matches.
|
|
93
|
+
ident = ident.lower()
|
|
60
94
|
lookup = f"*.{ident}" if getattr(authz_body, "wildcard", False) else ident
|
|
61
95
|
return challenge_types_map.get(lookup)
|
|
62
96
|
|
|
@@ -139,7 +173,8 @@ class AcmeClient:
|
|
|
139
173
|
net = acme_client.ClientNetwork(
|
|
140
174
|
account_key, alg=jws_alg(account_key), user_agent=self.user_agent
|
|
141
175
|
)
|
|
142
|
-
directory
|
|
176
|
+
with _reraise_transient("directory fetch"):
|
|
177
|
+
directory = messages.Directory.from_json(net.get(self.directory_url).json())
|
|
143
178
|
client = acme_client.ClientV2(directory, net)
|
|
144
179
|
|
|
145
180
|
registration_kwargs = dict(
|
|
@@ -161,14 +196,15 @@ class AcmeClient:
|
|
|
161
196
|
new_account = messages.NewRegistration(**registration_kwargs)
|
|
162
197
|
|
|
163
198
|
account = None
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
getattr(exc, "
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
199
|
+
with _reraise_transient("account registration"):
|
|
200
|
+
try:
|
|
201
|
+
account = client.new_account(new_account)
|
|
202
|
+
except acme_errors.ConflictError as exc:
|
|
203
|
+
uri = getattr(exc, "location", None) or getattr(
|
|
204
|
+
getattr(exc, "response", None), "headers", {}
|
|
205
|
+
).get("Location")
|
|
206
|
+
if uri:
|
|
207
|
+
account = messages.RegistrationResource(body=None, uri=uri)
|
|
172
208
|
|
|
173
209
|
if account is None:
|
|
174
210
|
account = getattr(client.net, "account", None)
|
|
@@ -183,13 +219,14 @@ class AcmeClient:
|
|
|
183
219
|
def _wait_for_authz(self, client, authz, timeout: int = 300):
|
|
184
220
|
deadline = time() + timeout
|
|
185
221
|
while time() < deadline:
|
|
186
|
-
|
|
222
|
+
with _reraise_transient("authorization poll"):
|
|
223
|
+
authz, _ = client.poll(authz)
|
|
187
224
|
if authz.body.status == messages.STATUS_VALID:
|
|
188
225
|
return authz
|
|
189
226
|
if authz.body.status == messages.STATUS_INVALID:
|
|
190
227
|
raise AcmeError(_authz_failure(authz.body))
|
|
191
228
|
sleep(2)
|
|
192
|
-
raise
|
|
229
|
+
raise TransientAcmeError("authorization polling timed out")
|
|
193
230
|
|
|
194
231
|
def _fetch_order_authorizations(self, client, order):
|
|
195
232
|
raw = getattr(order, "authorizations", None) or []
|
|
@@ -235,7 +272,8 @@ class AcmeClient:
|
|
|
235
272
|
csr_pem = build_csr(domain_key, identifiers)
|
|
236
273
|
log.info("requesting certificate for: %s", ", ".join(identifiers))
|
|
237
274
|
|
|
238
|
-
|
|
275
|
+
with _reraise_transient("new order"):
|
|
276
|
+
order = client.new_order(csr_pem)
|
|
239
277
|
authzs = self._fetch_order_authorizations(client, order)
|
|
240
278
|
|
|
241
279
|
http_tokens: list[str] = []
|
|
@@ -258,9 +296,10 @@ class AcmeClient:
|
|
|
258
296
|
client, dns_challenges, dns_propagation_delay
|
|
259
297
|
)
|
|
260
298
|
|
|
261
|
-
|
|
262
|
-
order
|
|
263
|
-
|
|
299
|
+
with _reraise_transient("finalize order"):
|
|
300
|
+
order = client.poll_and_finalize(
|
|
301
|
+
order, deadline=datetime.now() + timedelta(seconds=300)
|
|
302
|
+
)
|
|
264
303
|
finally:
|
|
265
304
|
self._cleanup(http01_solver, http_tokens, dns_provider, dns_challenges)
|
|
266
305
|
|
|
@@ -308,7 +347,12 @@ class AcmeClient:
|
|
|
308
347
|
if dns_challenges:
|
|
309
348
|
sleep(1) # space out API calls to avoid rate limiting
|
|
310
349
|
log.info("planting dns-01 challenge for %s", fqdn)
|
|
311
|
-
|
|
350
|
+
try:
|
|
351
|
+
dns_provider.create_challenge(fqdn, validation)
|
|
352
|
+
except Exception as exc: # noqa: BLE001
|
|
353
|
+
raise TransientAcmeError(
|
|
354
|
+
f"dns provider failed to create challenge for {fqdn}: {exc}"
|
|
355
|
+
) from exc
|
|
312
356
|
dns_challenges.append((fqdn, challb, response, authz, validation))
|
|
313
357
|
else:
|
|
314
358
|
if not http01_solver:
|
|
@@ -317,7 +361,8 @@ class AcmeClient:
|
|
|
317
361
|
log.info("serving http-01 challenge for %s", ident)
|
|
318
362
|
http01_solver.set(token, validation)
|
|
319
363
|
http_tokens.append(token)
|
|
320
|
-
|
|
364
|
+
with _reraise_transient("answer challenge"):
|
|
365
|
+
client.answer_challenge(challb, response)
|
|
321
366
|
self._wait_for_authz(client, authz)
|
|
322
367
|
|
|
323
368
|
def _answer_dns_challenges(self, client, dns_challenges, delay: int) -> None:
|
|
@@ -329,13 +374,14 @@ class AcmeClient:
|
|
|
329
374
|
if not verify_txt_record(
|
|
330
375
|
fqdn, validation, nameservers=self.recursive_nameservers
|
|
331
376
|
):
|
|
332
|
-
raise
|
|
377
|
+
raise TransientAcmeError(
|
|
333
378
|
f"DNS challenge verification failed for {fqdn}: "
|
|
334
379
|
"TXT record not found at authoritative nameservers"
|
|
335
380
|
)
|
|
336
381
|
for fqdn, challb, response, authz, _ in dns_challenges:
|
|
337
382
|
log.info("answering dns-01 challenge for %s", fqdn)
|
|
338
|
-
|
|
383
|
+
with _reraise_transient("answer challenge"):
|
|
384
|
+
client.answer_challenge(challb, response)
|
|
339
385
|
self._wait_for_authz(client, authz)
|
|
340
386
|
|
|
341
387
|
def _cleanup(
|
|
@@ -372,7 +418,17 @@ class AcmeClient:
|
|
|
372
418
|
|
|
373
419
|
|
|
374
420
|
def _normalize_identifiers(names: list[str]) -> list[str]:
|
|
375
|
-
"""IDNA-encode and de-duplicate identifiers, preserving order.
|
|
421
|
+
"""IDNA-encode, lowercase and de-duplicate identifiers, preserving order.
|
|
422
|
+
|
|
423
|
+
DNS names are case-insensitive and ACME servers (Let's Encrypt) echo
|
|
424
|
+
authorization identifiers back lowercased. Python's IDNA codec, however,
|
|
425
|
+
preserves the case of plain-ASCII labels, so a mixed-case config entry
|
|
426
|
+
like ``www.Example.com`` would otherwise key the challenge-type map in a
|
|
427
|
+
case the CA never uses — the challenge lookup at answer time then misses
|
|
428
|
+
and issuance fails with "no challenge type configured". Lowercasing here
|
|
429
|
+
keeps every identifier canonical (punycode output is already lowercase,
|
|
430
|
+
so this is a no-op for internationalised names).
|
|
431
|
+
"""
|
|
376
432
|
|
|
377
433
|
identifiers: list[str] = []
|
|
378
434
|
seen: set[str] = set()
|
|
@@ -380,7 +436,7 @@ def _normalize_identifiers(names: list[str]) -> list[str]:
|
|
|
380
436
|
if not name:
|
|
381
437
|
continue
|
|
382
438
|
try:
|
|
383
|
-
ascii_name = name.encode("idna").decode("ascii")
|
|
439
|
+
ascii_name = name.encode("idna").decode("ascii").lower()
|
|
384
440
|
except Exception: # noqa: BLE001 # nosec B112
|
|
385
441
|
continue
|
|
386
442
|
if ascii_name not in seen:
|
|
@@ -399,11 +455,14 @@ def _normalize_challenge_types(
|
|
|
399
455
|
|
|
400
456
|
result: dict[str, str] = {}
|
|
401
457
|
for ident in identifiers:
|
|
458
|
+
# ``identifiers`` are already IDNA-normalised and lowercased; resolve
|
|
459
|
+
# each against the caller's map case-insensitively so a mixed-case
|
|
460
|
+
# key (``www.Example.com``) still matches its lowercased identifier.
|
|
402
461
|
ct = challenge_types.get(ident)
|
|
403
462
|
if ct is None:
|
|
404
463
|
for key, value in challenge_types.items():
|
|
405
464
|
try:
|
|
406
|
-
if key.encode("idna").decode("ascii") == ident:
|
|
465
|
+
if key.encode("idna").decode("ascii").lower() == ident:
|
|
407
466
|
ct = value
|
|
408
467
|
break
|
|
409
468
|
except Exception: # noqa: BLE001 # nosec B112
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ACME-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..api.errors import DevNomadsError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AcmeError(DevNomadsError):
|
|
9
|
+
"""An ACME issuance step failed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransientAcmeError(AcmeError):
|
|
13
|
+
"""A retry-safe ACME failure (CA unreachable, rate limit,
|
|
14
|
+
propagation timeout, DNS provider outage). The operation may
|
|
15
|
+
succeed if simply retried later; callers should back off and
|
|
16
|
+
retry rather than treat the subject as permanently broken.
|
|
17
|
+
|
|
18
|
+
``code`` carries the bare ACME error code when one is known
|
|
19
|
+
(e.g. ``"rateLimited"``, ``"serverInternal"``) so callers can
|
|
20
|
+
apply per-code retry policy without string-matching the
|
|
21
|
+
message. None when the failure was not an ACME problem
|
|
22
|
+
document (network error, propagation timeout, ...).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.code = code
|
|
@@ -6,11 +6,12 @@ from types import SimpleNamespace
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from devnomads.acme import AcmeError
|
|
9
|
+
from devnomads.acme import AcmeError, TransientAcmeError
|
|
10
10
|
from devnomads.acme.client import (
|
|
11
11
|
_challenge_for_authz,
|
|
12
12
|
_normalize_challenge_types,
|
|
13
13
|
_normalize_identifiers,
|
|
14
|
+
_reraise_transient,
|
|
14
15
|
_split_fullchain,
|
|
15
16
|
)
|
|
16
17
|
|
|
@@ -50,6 +51,29 @@ def test_challenge_for_authz_wildcard_reprefixes():
|
|
|
50
51
|
assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def test_normalize_identifiers_lowercases():
|
|
55
|
+
# DNS is case-insensitive and CAs lowercase identifiers; a mixed-case
|
|
56
|
+
# ASCII label must not survive with its original case (Python's IDNA
|
|
57
|
+
# codec would otherwise preserve it).
|
|
58
|
+
assert _normalize_identifiers(["www.Ahlersbuilding.BE"]) == [
|
|
59
|
+
"www.ahlersbuilding.be"
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_mixed_case_challenge_map_resolves_against_lowercased_authz():
|
|
64
|
+
"""Regression: a mixed-case config entry keyed the challenge-type map in
|
|
65
|
+
a case the CA never uses, so the challenge lookup missed and issuance
|
|
66
|
+
failed with "no challenge type configured". The map must resolve against
|
|
67
|
+
the lowercased authorization identifier the CA echoes back."""
|
|
68
|
+
# certmonger builds the map keyed by the original (mixed) case:
|
|
69
|
+
challenge_types = {"www.Ahlersbuilding.be": "dns-01"}
|
|
70
|
+
identifiers = _normalize_identifiers(["www.Ahlersbuilding.be"])
|
|
71
|
+
types_map = _normalize_challenge_types(challenge_types, identifiers)
|
|
72
|
+
# LE hands back the identifier lowercased:
|
|
73
|
+
body = _authz("www.ahlersbuilding.be", False)
|
|
74
|
+
assert _challenge_for_authz(types_map, body) == "dns-01"
|
|
75
|
+
|
|
76
|
+
|
|
53
77
|
def test_split_fullchain():
|
|
54
78
|
leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
|
|
55
79
|
rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"
|
|
@@ -227,3 +251,132 @@ def test_partial_eab_credentials_raise(tmp_path):
|
|
|
227
251
|
|
|
228
252
|
with pytest.raises(AcmeError):
|
|
229
253
|
client_mod.AcmeClient(str(tmp_path / "a.pem"), eab_kid="kid-only")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# -- WI-1: transient/structural ACME failure classification ---------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_transient_error_is_acme_error_subclass():
|
|
260
|
+
"""Callers that catch AcmeError must still catch the transient variant,
|
|
261
|
+
so the outer 'blacklist everything' net keeps working if a caller has
|
|
262
|
+
not yet special-cased transients."""
|
|
263
|
+
|
|
264
|
+
assert issubclass(TransientAcmeError, AcmeError)
|
|
265
|
+
exc = TransientAcmeError("boom", code="rateLimited")
|
|
266
|
+
assert exc.code == "rateLimited"
|
|
267
|
+
assert TransientAcmeError("boom").code is None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_reraise_transient_wraps_request_exception():
|
|
271
|
+
import requests
|
|
272
|
+
|
|
273
|
+
with pytest.raises(TransientAcmeError) as ei:
|
|
274
|
+
with _reraise_transient("directory fetch"):
|
|
275
|
+
raise requests.exceptions.ConnectTimeout("no route to CA")
|
|
276
|
+
assert ei.value.code is None
|
|
277
|
+
assert "directory fetch" in str(ei.value)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pytest.mark.parametrize("code", ["serverInternal", "rateLimited", "badNonce"])
|
|
281
|
+
def test_reraise_transient_wraps_transient_acme_codes(code):
|
|
282
|
+
from acme import messages
|
|
283
|
+
|
|
284
|
+
with pytest.raises(TransientAcmeError) as ei:
|
|
285
|
+
with _reraise_transient("new order"):
|
|
286
|
+
raise messages.Error.with_code(code)
|
|
287
|
+
assert ei.value.code == code
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.parametrize("code", ["unauthorized", "caa", "rejectedIdentifier"])
|
|
291
|
+
def test_reraise_transient_passes_structural_acme_codes(code):
|
|
292
|
+
"""Structural ACME problem documents must escape as plain messages.Error
|
|
293
|
+
(not TransientAcmeError) so the caller still blacklists the domain."""
|
|
294
|
+
|
|
295
|
+
from acme import messages
|
|
296
|
+
|
|
297
|
+
err = messages.Error.with_code(code)
|
|
298
|
+
with pytest.raises(messages.Error) as ei:
|
|
299
|
+
with _reraise_transient("new order"):
|
|
300
|
+
raise err
|
|
301
|
+
assert not isinstance(ei.value, TransientAcmeError)
|
|
302
|
+
assert ei.value is err
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize("exc_name", ["TimeoutError", "PollError", "NonceError"])
|
|
306
|
+
def test_reraise_transient_wraps_acme_poll_errors(exc_name):
|
|
307
|
+
from acme import errors as acme_errors
|
|
308
|
+
|
|
309
|
+
exc_cls = getattr(acme_errors, exc_name)
|
|
310
|
+
try:
|
|
311
|
+
raised = exc_cls("boom")
|
|
312
|
+
except TypeError:
|
|
313
|
+
# PollError's constructor signature differs across acme versions.
|
|
314
|
+
raised = exc_cls(set(), {})
|
|
315
|
+
|
|
316
|
+
with pytest.raises(TransientAcmeError):
|
|
317
|
+
with _reraise_transient("authorization poll"):
|
|
318
|
+
raise raised
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_reraise_transient_passes_unrelated_exceptions():
|
|
322
|
+
with pytest.raises(ValueError):
|
|
323
|
+
with _reraise_transient("new order"):
|
|
324
|
+
raise ValueError("not an acme failure")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _dns01_authz(ident):
|
|
328
|
+
from acme import challenges
|
|
329
|
+
|
|
330
|
+
challb = SimpleNamespace(
|
|
331
|
+
chall=challenges.DNS01(token=b"\x00" * 32),
|
|
332
|
+
response_and_validation=lambda key: (SimpleNamespace(), "validation-string"),
|
|
333
|
+
)
|
|
334
|
+
body = SimpleNamespace(
|
|
335
|
+
identifier=SimpleNamespace(value=ident),
|
|
336
|
+
status=SimpleNamespace(name="pending"),
|
|
337
|
+
challenges=[challb],
|
|
338
|
+
wildcard=False,
|
|
339
|
+
)
|
|
340
|
+
return SimpleNamespace(body=body)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_setup_authz_dns_provider_failure_is_transient(tmp_path):
|
|
344
|
+
"""A DNS provider outage (API down, bad credentials, network) is an
|
|
345
|
+
infrastructure problem, never a statement about the domain."""
|
|
346
|
+
|
|
347
|
+
from devnomads.acme import client as client_mod
|
|
348
|
+
|
|
349
|
+
class BadProvider:
|
|
350
|
+
def create_challenge(self, fqdn, validation):
|
|
351
|
+
raise RuntimeError("dns api down")
|
|
352
|
+
|
|
353
|
+
ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
|
|
354
|
+
authz = _dns01_authz("example.com")
|
|
355
|
+
|
|
356
|
+
with pytest.raises(TransientAcmeError) as ei:
|
|
357
|
+
ac._setup_authz(
|
|
358
|
+
client=None,
|
|
359
|
+
account_key=None,
|
|
360
|
+
authz=authz,
|
|
361
|
+
types_map={"example.com": "dns-01"},
|
|
362
|
+
dns_provider=BadProvider(),
|
|
363
|
+
http01_solver=None,
|
|
364
|
+
http_tokens=[],
|
|
365
|
+
dns_challenges=[],
|
|
366
|
+
)
|
|
367
|
+
assert "dns provider failed" in str(ei.value)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_answer_dns_challenges_txt_timeout_is_transient(monkeypatch, tmp_path):
|
|
371
|
+
"""Slow TXT propagation is retry-safe, not a permanent failure."""
|
|
372
|
+
|
|
373
|
+
from devnomads.acme import client as client_mod
|
|
374
|
+
|
|
375
|
+
monkeypatch.setattr(client_mod, "verify_txt_record", lambda *a, **k: False)
|
|
376
|
+
|
|
377
|
+
ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
|
|
378
|
+
dns_challenges = [("example.com", object(), object(), object(), "validation")]
|
|
379
|
+
|
|
380
|
+
with pytest.raises(TransientAcmeError) as ei:
|
|
381
|
+
ac._answer_dns_challenges(client=None, dns_challenges=dns_challenges, delay=0)
|
|
382
|
+
assert "TXT record not found" in str(ei.value)
|
|
@@ -468,7 +468,7 @@ wheels = [
|
|
|
468
468
|
|
|
469
469
|
[[package]]
|
|
470
470
|
name = "devnomads"
|
|
471
|
-
version = "0.
|
|
471
|
+
version = "0.3.0"
|
|
472
472
|
source = { editable = "." }
|
|
473
473
|
dependencies = [
|
|
474
474
|
{ name = "httpx" },
|
|
@@ -480,6 +480,7 @@ acme = [
|
|
|
480
480
|
{ name = "cryptography" },
|
|
481
481
|
{ name = "dnspython" },
|
|
482
482
|
{ name = "josepy" },
|
|
483
|
+
{ name = "requests" },
|
|
483
484
|
]
|
|
484
485
|
|
|
485
486
|
[package.dev-dependencies]
|
|
@@ -502,6 +503,7 @@ requires-dist = [
|
|
|
502
503
|
{ name = "dnspython", marker = "extra == 'acme'", specifier = ">=2.6" },
|
|
503
504
|
{ name = "httpx", specifier = ">=0.27" },
|
|
504
505
|
{ name = "josepy", marker = "extra == 'acme'", specifier = ">=1.14" },
|
|
506
|
+
{ name = "requests", marker = "extra == 'acme'", specifier = ">=2.31" },
|
|
505
507
|
]
|
|
506
508
|
|
|
507
509
|
[package.metadata.requires-dev]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|