devnomads 0.2.4__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. devnomads-0.2.4/README.md → devnomads-0.3.0/PKG-INFO +53 -0
  2. devnomads-0.2.4/PKG-INFO → devnomads-0.3.0/README.md +35 -17
  3. {devnomads-0.2.4 → devnomads-0.3.0}/pyproject.toml +8 -2
  4. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/__init__.py +14 -2
  5. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/client.py +135 -22
  6. devnomads-0.3.0/src/devnomads/acme/errors.py +27 -0
  7. devnomads-0.3.0/tests/test_acme_client.py +359 -0
  8. {devnomads-0.2.4 → devnomads-0.3.0}/uv.lock +3 -1
  9. devnomads-0.2.4/src/devnomads/acme/errors.py +0 -9
  10. devnomads-0.2.4/tests/test_acme_client.py +0 -125
  11. {devnomads-0.2.4 → devnomads-0.3.0}/.flake8 +0 -0
  12. {devnomads-0.2.4 → devnomads-0.3.0}/.gitignore +0 -0
  13. {devnomads-0.2.4 → devnomads-0.3.0}/.gitlab-ci.yml +0 -0
  14. {devnomads-0.2.4 → devnomads-0.3.0}/Makefile +0 -0
  15. {devnomads-0.2.4 → devnomads-0.3.0}/openapi.json +0 -0
  16. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/__init__.py +0 -0
  17. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/challenge_server.py +0 -0
  18. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/dns01.py +0 -0
  19. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/http01.py +0 -0
  20. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/keys.py +0 -0
  21. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/verify.py +0 -0
  22. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/__init__.py +0 -0
  23. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/_services.py +0 -0
  24. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/client.py +0 -0
  25. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/credentials.py +0 -0
  26. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/errors.py +0 -0
  27. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/__init__.py +0 -0
  28. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/errors.py +0 -0
  29. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/names.py +0 -0
  30. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/zones.py +0 -0
  31. {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/py.typed +0 -0
  32. {devnomads-0.2.4 → devnomads-0.3.0}/tests/conftest.py +0 -0
  33. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_acme_challenges.py +0 -0
  34. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_acme_keys.py +0 -0
  35. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_client.py +0 -0
  36. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_credentials.py +0 -0
  37. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_dns.py +0 -0
  38. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_names.py +0 -0
  39. {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_services.py +0 -0
  40. {devnomads-0.2.4 → devnomads-0.3.0}/tools/bump_version.py +0 -0
  41. {devnomads-0.2.4 → devnomads-0.3.0}/tools/generate.py +0 -0
@@ -1,3 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: devnomads
3
+ Version: 0.3.0
4
+ Summary: Python client library for the DevNomads API (transport, DNS, ACME)
5
+ Project-URL: Homepage, https://devnomads.nl
6
+ Author-email: DevNomads <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
+ Requires-Dist: requests>=2.31; extra == 'acme'
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # devnomads
2
20
 
3
21
  Python client library for the [DevNomads](https://devnomads.nl) API:
@@ -303,9 +321,16 @@ AcmeClient(
303
321
  preferred_chain=None, # match alternate chain by CN
304
322
  recursive_nameservers=None, # resolvers for propagation
305
323
  user_agent="devnomads",
324
+ eab_kid=None, # external account binding key id
325
+ eab_hmac_key=None, # external account binding HMAC key
326
+ eab_hmac_alg="HS256", # HS256/HS384/HS512
306
327
  )
307
328
  ```
308
329
 
330
+ Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
331
+ `directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
332
+ also need `eab_kid` and `eab_hmac_key`.
333
+
309
334
  The account key at `account_key_path` is loaded if present and created
310
335
  (mode 0600) otherwise.
311
336
 
@@ -402,6 +427,34 @@ acme = AcmeClient(
402
427
  )
403
428
  ```
404
429
 
430
+ ### Providers (Let's Encrypt, ZeroSSL)
431
+
432
+ `for_provider` resolves a named CA from `CA_DIRECTORIES`
433
+ (`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
434
+ remember directory URLs. Let's Encrypt stays the default everywhere else.
435
+
436
+ ```python
437
+ from devnomads.acme import AcmeClient
438
+
439
+ # Let's Encrypt (default CA) - no EAB needed.
440
+ acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
441
+
442
+ # ZeroSSL - requires External Account Binding credentials from your
443
+ # ZeroSSL account (Developer -> EAB Credentials, or the REST API).
444
+ acme = AcmeClient.for_provider(
445
+ "zerossl",
446
+ "/etc/devnomads/zerossl-account.key",
447
+ contact_email="ops@example.com",
448
+ eab_kid="...", # EAB key id
449
+ eab_hmac_key="...", # base64url EAB HMAC key
450
+ )
451
+ ```
452
+
453
+ Issuance is otherwise identical - the same `obtain_certificate` calls,
454
+ DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
455
+ directory URL and EAB fields can also be passed straight to the
456
+ `AcmeClient(...)` constructor if you prefer not to use the alias.
457
+
405
458
  ### Key helpers
406
459
 
407
460
  ```python
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: devnomads
3
- Version: 0.2.4
4
- Summary: Python client library for the DevNomads API (transport, DNS, ACME)
5
- Project-URL: Homepage, https://devnomads.nl
6
- Author-email: DevNomads <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
1
  # devnomads
19
2
 
20
3
  Python client library for the [DevNomads](https://devnomads.nl) API:
@@ -320,9 +303,16 @@ AcmeClient(
320
303
  preferred_chain=None, # match alternate chain by CN
321
304
  recursive_nameservers=None, # resolvers for propagation
322
305
  user_agent="devnomads",
306
+ eab_kid=None, # external account binding key id
307
+ eab_hmac_key=None, # external account binding HMAC key
308
+ eab_hmac_alg="HS256", # HS256/HS384/HS512
323
309
  )
324
310
  ```
325
311
 
312
+ Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
313
+ `directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
314
+ also need `eab_kid` and `eab_hmac_key`.
315
+
326
316
  The account key at `account_key_path` is loaded if present and created
327
317
  (mode 0600) otherwise.
328
318
 
@@ -419,6 +409,34 @@ acme = AcmeClient(
419
409
  )
420
410
  ```
421
411
 
412
+ ### Providers (Let's Encrypt, ZeroSSL)
413
+
414
+ `for_provider` resolves a named CA from `CA_DIRECTORIES`
415
+ (`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
416
+ remember directory URLs. Let's Encrypt stays the default everywhere else.
417
+
418
+ ```python
419
+ from devnomads.acme import AcmeClient
420
+
421
+ # Let's Encrypt (default CA) - no EAB needed.
422
+ acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
423
+
424
+ # ZeroSSL - requires External Account Binding credentials from your
425
+ # ZeroSSL account (Developer -> EAB Credentials, or the REST API).
426
+ acme = AcmeClient.for_provider(
427
+ "zerossl",
428
+ "/etc/devnomads/zerossl-account.key",
429
+ contact_email="ops@example.com",
430
+ eab_kid="...", # EAB key id
431
+ eab_hmac_key="...", # base64url EAB HMAC key
432
+ )
433
+ ```
434
+
435
+ Issuance is otherwise identical - the same `obtain_certificate` calls,
436
+ DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
437
+ directory URL and EAB fields can also be passed straight to the
438
+ `AcmeClient(...)` constructor if you prefer not to use the alias.
439
+
422
440
  ### Key helpers
423
441
 
424
442
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devnomads"
7
- version = "0.2.4"
7
+ version = "0.3.0"
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 = ["acme>=2.11", "josepy>=1.14", "cryptography>=42", "dnspython>=2.6"]
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"
@@ -17,9 +17,16 @@ _EXTRA_MODULES = {"acme", "josepy", "cryptography", "dns"}
17
17
 
18
18
  try:
19
19
  from .challenge_server import ChallengeServer
20
- from .client import DEFAULT_DIRECTORY_URL, AcmeClient
20
+ from .client import (
21
+ CA_DIRECTORIES,
22
+ DEFAULT_DIRECTORY_URL,
23
+ LETSENCRYPT_DIRECTORY_URL,
24
+ LETSENCRYPT_STAGING_DIRECTORY_URL,
25
+ ZEROSSL_DIRECTORY_URL,
26
+ AcmeClient,
27
+ )
21
28
  from .dns01 import DevNomadsDnsProvider, DnsProvider
22
- from .errors import AcmeError
29
+ from .errors import AcmeError, TransientAcmeError
23
30
  from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
24
31
  from .keys import (
25
32
  build_csr,
@@ -40,7 +47,12 @@ except ImportError as exc: # pragma: no cover - exercised via packaging
40
47
  __all__ = [
41
48
  "AcmeClient",
42
49
  "DEFAULT_DIRECTORY_URL",
50
+ "LETSENCRYPT_DIRECTORY_URL",
51
+ "LETSENCRYPT_STAGING_DIRECTORY_URL",
52
+ "ZEROSSL_DIRECTORY_URL",
53
+ "CA_DIRECTORIES",
43
54
  "AcmeError",
55
+ "TransientAcmeError",
44
56
  "DnsProvider",
45
57
  "DevNomadsDnsProvider",
46
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,
@@ -25,7 +27,54 @@ from .verify import verify_txt_record
25
27
 
26
28
  log = logging.getLogger("devnomads.acme")
27
29
 
28
- DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
30
+ LETSENCRYPT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
31
+ LETSENCRYPT_STAGING_DIRECTORY_URL = (
32
+ "https://acme-staging-v02.api.letsencrypt.org/directory"
33
+ )
34
+ ZEROSSL_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90"
35
+
36
+ # Let's Encrypt remains the default CA everywhere it is not specified.
37
+ DEFAULT_DIRECTORY_URL = LETSENCRYPT_DIRECTORY_URL
38
+
39
+ # Named ACME providers, resolvable via :meth:`AcmeClient.for_provider`.
40
+ CA_DIRECTORIES = {
41
+ "letsencrypt": LETSENCRYPT_DIRECTORY_URL,
42
+ "letsencrypt-staging": LETSENCRYPT_STAGING_DIRECTORY_URL,
43
+ "zerossl": ZEROSSL_DIRECTORY_URL,
44
+ }
45
+
46
+ # Providers that mandate External Account Binding (RFC 8555 §7.3.4) for new
47
+ # account registration.
48
+ _EAB_REQUIRED_PROVIDERS = frozenset({"zerossl"})
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
29
78
 
30
79
 
31
80
  def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str | None:
@@ -61,7 +110,14 @@ class AcmeClient:
61
110
  preferred_chain: str | None = None,
62
111
  recursive_nameservers: list[str] | None = None,
63
112
  user_agent: str = "devnomads",
113
+ eab_kid: str | None = None,
114
+ eab_hmac_key: str | None = None,
115
+ eab_hmac_alg: str = "HS256",
64
116
  ) -> None:
117
+ if bool(eab_kid) != bool(eab_hmac_key):
118
+ raise AcmeError(
119
+ "external account binding requires both eab_kid and eab_hmac_key"
120
+ )
65
121
  self.account_key_path = account_key_path
66
122
  self.directory_url = directory_url
67
123
  self.account_key_algorithm = account_key_algorithm
@@ -69,6 +125,39 @@ class AcmeClient:
69
125
  self.preferred_chain = preferred_chain
70
126
  self.recursive_nameservers = recursive_nameservers
71
127
  self.user_agent = user_agent
128
+ self.eab_kid = eab_kid
129
+ self.eab_hmac_key = eab_hmac_key
130
+ self.eab_hmac_alg = eab_hmac_alg
131
+
132
+ @classmethod
133
+ def for_provider(
134
+ cls, provider: str, account_key_path: str, **kwargs
135
+ ) -> "AcmeClient":
136
+ """Build a client for a named CA from :data:`CA_DIRECTORIES`.
137
+
138
+ ``provider`` is one of ``"letsencrypt"`` (the default CA),
139
+ ``"letsencrypt-staging"`` or ``"zerossl"``. Providers that mandate
140
+ External Account Binding (e.g. ZeroSSL) require ``eab_kid`` and
141
+ ``eab_hmac_key`` keyword arguments. Any other keyword is forwarded to
142
+ :class:`AcmeClient`.
143
+ """
144
+
145
+ try:
146
+ directory_url = CA_DIRECTORIES[provider]
147
+ except KeyError:
148
+ raise AcmeError(
149
+ f"unknown ACME provider {provider!r}; "
150
+ f"known providers: {', '.join(sorted(CA_DIRECTORIES))}"
151
+ ) from None
152
+ if provider in _EAB_REQUIRED_PROVIDERS and not (
153
+ kwargs.get("eab_kid") and kwargs.get("eab_hmac_key")
154
+ ):
155
+ raise AcmeError(
156
+ f"provider {provider!r} requires external account binding; "
157
+ "pass eab_kid and eab_hmac_key"
158
+ )
159
+ kwargs.pop("directory_url", None)
160
+ return cls(account_key_path, directory_url=directory_url, **kwargs)
72
161
 
73
162
  # -- account / client setup -------------------------------------------
74
163
 
@@ -81,24 +170,38 @@ class AcmeClient:
81
170
  net = acme_client.ClientNetwork(
82
171
  account_key, alg=jws_alg(account_key), user_agent=self.user_agent
83
172
  )
84
- directory = messages.Directory.from_json(net.get(self.directory_url).json())
173
+ with _reraise_transient("directory fetch"):
174
+ directory = messages.Directory.from_json(net.get(self.directory_url).json())
85
175
  client = acme_client.ClientV2(directory, net)
86
176
 
87
- new_account = messages.NewRegistration(
177
+ registration_kwargs = dict(
88
178
  contact=(f"mailto:{self.contact_email}",) if self.contact_email else (),
89
179
  terms_of_service_agreed=True,
90
180
  only_return_existing=False,
91
181
  )
182
+ if self.eab_kid and self.eab_hmac_key:
183
+ log.debug("attaching external account binding (kid=%s)", self.eab_kid)
184
+ registration_kwargs["external_account_binding"] = (
185
+ messages.ExternalAccountBinding.from_data(
186
+ account_key.public_key(),
187
+ self.eab_kid,
188
+ self.eab_hmac_key,
189
+ directory,
190
+ hmac_alg=self.eab_hmac_alg,
191
+ )
192
+ )
193
+ new_account = messages.NewRegistration(**registration_kwargs)
92
194
 
93
195
  account = None
94
- try:
95
- account = client.new_account(new_account)
96
- except acme_errors.ConflictError as exc:
97
- uri = getattr(exc, "location", None) or getattr(
98
- getattr(exc, "response", None), "headers", {}
99
- ).get("Location")
100
- if uri:
101
- account = messages.RegistrationResource(body=None, uri=uri)
196
+ with _reraise_transient("account registration"):
197
+ try:
198
+ account = client.new_account(new_account)
199
+ except acme_errors.ConflictError as exc:
200
+ uri = getattr(exc, "location", None) or getattr(
201
+ getattr(exc, "response", None), "headers", {}
202
+ ).get("Location")
203
+ if uri:
204
+ account = messages.RegistrationResource(body=None, uri=uri)
102
205
 
103
206
  if account is None:
104
207
  account = getattr(client.net, "account", None)
@@ -113,13 +216,14 @@ class AcmeClient:
113
216
  def _wait_for_authz(self, client, authz, timeout: int = 300):
114
217
  deadline = time() + timeout
115
218
  while time() < deadline:
116
- authz, _ = client.poll(authz)
219
+ with _reraise_transient("authorization poll"):
220
+ authz, _ = client.poll(authz)
117
221
  if authz.body.status == messages.STATUS_VALID:
118
222
  return authz
119
223
  if authz.body.status == messages.STATUS_INVALID:
120
224
  raise AcmeError(_authz_failure(authz.body))
121
225
  sleep(2)
122
- raise AcmeError("authorization polling timed out")
226
+ raise TransientAcmeError("authorization polling timed out")
123
227
 
124
228
  def _fetch_order_authorizations(self, client, order):
125
229
  raw = getattr(order, "authorizations", None) or []
@@ -165,7 +269,8 @@ class AcmeClient:
165
269
  csr_pem = build_csr(domain_key, identifiers)
166
270
  log.info("requesting certificate for: %s", ", ".join(identifiers))
167
271
 
168
- order = client.new_order(csr_pem)
272
+ with _reraise_transient("new order"):
273
+ order = client.new_order(csr_pem)
169
274
  authzs = self._fetch_order_authorizations(client, order)
170
275
 
171
276
  http_tokens: list[str] = []
@@ -188,9 +293,10 @@ class AcmeClient:
188
293
  client, dns_challenges, dns_propagation_delay
189
294
  )
190
295
 
191
- order = client.poll_and_finalize(
192
- order, deadline=datetime.now() + timedelta(seconds=300)
193
- )
296
+ with _reraise_transient("finalize order"):
297
+ order = client.poll_and_finalize(
298
+ order, deadline=datetime.now() + timedelta(seconds=300)
299
+ )
194
300
  finally:
195
301
  self._cleanup(http01_solver, http_tokens, dns_provider, dns_challenges)
196
302
 
@@ -238,7 +344,12 @@ class AcmeClient:
238
344
  if dns_challenges:
239
345
  sleep(1) # space out API calls to avoid rate limiting
240
346
  log.info("planting dns-01 challenge for %s", fqdn)
241
- dns_provider.create_challenge(fqdn, validation)
347
+ try:
348
+ dns_provider.create_challenge(fqdn, validation)
349
+ except Exception as exc: # noqa: BLE001
350
+ raise TransientAcmeError(
351
+ f"dns provider failed to create challenge for {fqdn}: {exc}"
352
+ ) from exc
242
353
  dns_challenges.append((fqdn, challb, response, authz, validation))
243
354
  else:
244
355
  if not http01_solver:
@@ -247,7 +358,8 @@ class AcmeClient:
247
358
  log.info("serving http-01 challenge for %s", ident)
248
359
  http01_solver.set(token, validation)
249
360
  http_tokens.append(token)
250
- client.answer_challenge(challb, response)
361
+ with _reraise_transient("answer challenge"):
362
+ client.answer_challenge(challb, response)
251
363
  self._wait_for_authz(client, authz)
252
364
 
253
365
  def _answer_dns_challenges(self, client, dns_challenges, delay: int) -> None:
@@ -259,13 +371,14 @@ class AcmeClient:
259
371
  if not verify_txt_record(
260
372
  fqdn, validation, nameservers=self.recursive_nameservers
261
373
  ):
262
- raise AcmeError(
374
+ raise TransientAcmeError(
263
375
  f"DNS challenge verification failed for {fqdn}: "
264
376
  "TXT record not found at authoritative nameservers"
265
377
  )
266
378
  for fqdn, challb, response, authz, _ in dns_challenges:
267
379
  log.info("answering dns-01 challenge for %s", fqdn)
268
- client.answer_challenge(challb, response)
380
+ with _reraise_transient("answer challenge"):
381
+ client.answer_challenge(challb, response)
269
382
  self._wait_for_authz(client, authz)
270
383
 
271
384
  def _cleanup(
@@ -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
@@ -0,0 +1,359 @@
1
+ """Tests for the pure helpers of the ACME client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ from devnomads.acme import AcmeError, TransientAcmeError
10
+ from devnomads.acme.client import (
11
+ _challenge_for_authz,
12
+ _normalize_challenge_types,
13
+ _normalize_identifiers,
14
+ _reraise_transient,
15
+ _split_fullchain,
16
+ )
17
+
18
+
19
+ def test_normalize_identifiers_dedup_preserves_order():
20
+ assert _normalize_identifiers(["a.com", "b.com", "a.com", ""]) == ["a.com", "b.com"]
21
+
22
+
23
+ def test_normalize_challenge_types_string_applies_to_all():
24
+ assert _normalize_challenge_types("dns-01", ["a.com", "b.com"]) == {
25
+ "a.com": "dns-01",
26
+ "b.com": "dns-01",
27
+ }
28
+
29
+
30
+ def test_normalize_challenge_types_dict_per_identifier():
31
+ mapping = {"a.com": "dns-01", "b.com": "http-01"}
32
+ assert _normalize_challenge_types(mapping, ["a.com", "b.com"]) == mapping
33
+
34
+
35
+ def test_normalize_challenge_types_missing_raises():
36
+ with pytest.raises(AcmeError):
37
+ _normalize_challenge_types({"a.com": "dns-01"}, ["a.com", "b.com"])
38
+
39
+
40
+ def _authz(value, wildcard):
41
+ return SimpleNamespace(identifier=SimpleNamespace(value=value), wildcard=wildcard)
42
+
43
+
44
+ def test_challenge_for_authz_plain():
45
+ body = _authz("example.com", False)
46
+ assert _challenge_for_authz({"example.com": "dns-01"}, body) == "dns-01"
47
+
48
+
49
+ def test_challenge_for_authz_wildcard_reprefixes():
50
+ body = _authz("example.com", True)
51
+ assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
52
+
53
+
54
+ def test_split_fullchain():
55
+ leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
56
+ rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"
57
+ leaf_pem, chain_pem = _split_fullchain(leaf + rest)
58
+ assert leaf_pem == leaf
59
+ assert chain_pem == rest
60
+
61
+
62
+ def test_split_fullchain_no_marker():
63
+ leaf_pem, chain_pem = _split_fullchain("garbage")
64
+ assert leaf_pem == "garbage"
65
+ assert chain_pem == ""
66
+
67
+
68
+ def test_init_client_registers_with_new_registration(monkeypatch, tmp_path):
69
+ """_init_client must build a real acme.messages.NewRegistration and hand
70
+ it to new_account; this exercises the message construction that a wrong
71
+ class name (e.g. the non-existent NewAccount) would break."""
72
+
73
+ import josepy
74
+ from acme import messages
75
+ from cryptography.hazmat.backends import default_backend
76
+ from cryptography.hazmat.primitives.asymmetric import ec
77
+ from josepy import jwk
78
+
79
+ from devnomads.acme import client as client_mod
80
+
81
+ captured = {}
82
+ account_key = jwk.JWKEC(
83
+ key=ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
84
+ )
85
+
86
+ class FakeResponse:
87
+ def json(self):
88
+ return {}
89
+
90
+ class FakeNetwork:
91
+ def __init__(self, *args, **kwargs):
92
+ captured["alg"] = kwargs.get("alg")
93
+ self.account = None
94
+
95
+ def get(self, url):
96
+ return FakeResponse()
97
+
98
+ class FakeClientV2:
99
+ def __init__(self, directory, net):
100
+ self.net = net
101
+
102
+ def new_account(self, registration):
103
+ captured["registration"] = registration
104
+ return SimpleNamespace(uri="https://acme.example/acct/1")
105
+
106
+ monkeypatch.setattr(
107
+ client_mod, "load_or_create_account_key", lambda *a, **k: account_key
108
+ )
109
+ monkeypatch.setattr(client_mod.acme_client, "ClientNetwork", FakeNetwork)
110
+ monkeypatch.setattr(client_mod.acme_client, "ClientV2", FakeClientV2)
111
+ monkeypatch.setattr(
112
+ client_mod.messages.Directory, "from_json", lambda data: object()
113
+ )
114
+
115
+ ac = client_mod.AcmeClient(
116
+ str(tmp_path / "account.pem"), contact_email="ops@example.com"
117
+ )
118
+ client, _account_key = ac._init_client()
119
+
120
+ # an EC account key must be signed with ES256, not the ClientNetwork
121
+ # default of RS256, or josepy rejects the JWS.
122
+ assert captured["alg"] == josepy.ES256
123
+ registration = captured["registration"]
124
+ assert isinstance(registration, messages.NewRegistration)
125
+ assert registration.contact == ("mailto:ops@example.com",)
126
+ # without EAB credentials, no external account binding is attached
127
+ assert not registration.external_account_binding
128
+ assert client.net.account is not None
129
+
130
+
131
+ def _patch_fake_network(monkeypatch, client_mod, account_key, captured, directory):
132
+ from types import SimpleNamespace
133
+
134
+ class FakeResponse:
135
+ def json(self):
136
+ return {}
137
+
138
+ class FakeNetwork:
139
+ def __init__(self, *args, **kwargs):
140
+ self.account = None
141
+
142
+ def get(self, url):
143
+ return FakeResponse()
144
+
145
+ class FakeClientV2:
146
+ def __init__(self, directory, net):
147
+ self.net = net
148
+
149
+ def new_account(self, registration):
150
+ captured["registration"] = registration
151
+ return SimpleNamespace(uri="https://acme.example/acct/1")
152
+
153
+ monkeypatch.setattr(
154
+ client_mod, "load_or_create_account_key", lambda *a, **k: account_key
155
+ )
156
+ monkeypatch.setattr(client_mod.acme_client, "ClientNetwork", FakeNetwork)
157
+ monkeypatch.setattr(client_mod.acme_client, "ClientV2", FakeClientV2)
158
+ monkeypatch.setattr(
159
+ client_mod.messages.Directory, "from_json", lambda data: directory
160
+ )
161
+
162
+
163
+ def test_init_client_attaches_external_account_binding(monkeypatch, tmp_path):
164
+ """When EAB credentials are supplied, _init_client must attach an
165
+ externalAccountBinding to the NewRegistration (required by ZeroSSL)."""
166
+
167
+ from acme import messages
168
+ from cryptography.hazmat.backends import default_backend
169
+ from cryptography.hazmat.primitives.asymmetric import ec
170
+ from josepy import jwk
171
+
172
+ from devnomads.acme import client as client_mod
173
+
174
+ captured = {}
175
+ account_key = jwk.JWKEC(
176
+ key=ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
177
+ )
178
+ directory = messages.Directory.from_json(
179
+ {"newAccount": "https://acme.example/new-acct"}
180
+ )
181
+ _patch_fake_network(monkeypatch, client_mod, account_key, captured, directory)
182
+
183
+ ac = client_mod.AcmeClient(
184
+ str(tmp_path / "account.pem"),
185
+ directory_url=client_mod.ZEROSSL_DIRECTORY_URL,
186
+ eab_kid="kid-123",
187
+ # HMAC key must be valid base64; this is "hmac-secret" encoded.
188
+ eab_hmac_key="aG1hYy1zZWNyZXQ",
189
+ )
190
+ ac._init_client()
191
+
192
+ registration = captured["registration"]
193
+ assert registration.external_account_binding # signed JWS dict
194
+
195
+
196
+ def test_for_provider_resolves_directory_urls(tmp_path):
197
+ from devnomads.acme import client as client_mod
198
+
199
+ le = client_mod.AcmeClient.for_provider("letsencrypt", str(tmp_path / "a.pem"))
200
+ assert le.directory_url == client_mod.LETSENCRYPT_DIRECTORY_URL
201
+
202
+ zs = client_mod.AcmeClient.for_provider(
203
+ "zerossl",
204
+ str(tmp_path / "b.pem"),
205
+ eab_kid="kid-123",
206
+ eab_hmac_key="aG1hYy1zZWNyZXQ",
207
+ )
208
+ assert zs.directory_url == client_mod.ZEROSSL_DIRECTORY_URL
209
+ assert zs.eab_kid == "kid-123"
210
+
211
+
212
+ def test_for_provider_unknown_raises(tmp_path):
213
+ from devnomads.acme import client as client_mod
214
+
215
+ with pytest.raises(AcmeError):
216
+ client_mod.AcmeClient.for_provider("notaca", str(tmp_path / "a.pem"))
217
+
218
+
219
+ def test_for_provider_zerossl_requires_eab(tmp_path):
220
+ from devnomads.acme import client as client_mod
221
+
222
+ with pytest.raises(AcmeError):
223
+ client_mod.AcmeClient.for_provider("zerossl", str(tmp_path / "a.pem"))
224
+
225
+
226
+ def test_partial_eab_credentials_raise(tmp_path):
227
+ from devnomads.acme import client as client_mod
228
+
229
+ with pytest.raises(AcmeError):
230
+ client_mod.AcmeClient(str(tmp_path / "a.pem"), eab_kid="kid-only")
231
+
232
+
233
+ # -- WI-1: transient/structural ACME failure classification ---------------
234
+
235
+
236
+ def test_transient_error_is_acme_error_subclass():
237
+ """Callers that catch AcmeError must still catch the transient variant,
238
+ so the outer 'blacklist everything' net keeps working if a caller has
239
+ not yet special-cased transients."""
240
+
241
+ assert issubclass(TransientAcmeError, AcmeError)
242
+ exc = TransientAcmeError("boom", code="rateLimited")
243
+ assert exc.code == "rateLimited"
244
+ assert TransientAcmeError("boom").code is None
245
+
246
+
247
+ def test_reraise_transient_wraps_request_exception():
248
+ import requests
249
+
250
+ with pytest.raises(TransientAcmeError) as ei:
251
+ with _reraise_transient("directory fetch"):
252
+ raise requests.exceptions.ConnectTimeout("no route to CA")
253
+ assert ei.value.code is None
254
+ assert "directory fetch" in str(ei.value)
255
+
256
+
257
+ @pytest.mark.parametrize("code", ["serverInternal", "rateLimited", "badNonce"])
258
+ def test_reraise_transient_wraps_transient_acme_codes(code):
259
+ from acme import messages
260
+
261
+ with pytest.raises(TransientAcmeError) as ei:
262
+ with _reraise_transient("new order"):
263
+ raise messages.Error.with_code(code)
264
+ assert ei.value.code == code
265
+
266
+
267
+ @pytest.mark.parametrize("code", ["unauthorized", "caa", "rejectedIdentifier"])
268
+ def test_reraise_transient_passes_structural_acme_codes(code):
269
+ """Structural ACME problem documents must escape as plain messages.Error
270
+ (not TransientAcmeError) so the caller still blacklists the domain."""
271
+
272
+ from acme import messages
273
+
274
+ err = messages.Error.with_code(code)
275
+ with pytest.raises(messages.Error) as ei:
276
+ with _reraise_transient("new order"):
277
+ raise err
278
+ assert not isinstance(ei.value, TransientAcmeError)
279
+ assert ei.value is err
280
+
281
+
282
+ @pytest.mark.parametrize("exc_name", ["TimeoutError", "PollError", "NonceError"])
283
+ def test_reraise_transient_wraps_acme_poll_errors(exc_name):
284
+ from acme import errors as acme_errors
285
+
286
+ exc_cls = getattr(acme_errors, exc_name)
287
+ try:
288
+ raised = exc_cls("boom")
289
+ except TypeError:
290
+ # PollError's constructor signature differs across acme versions.
291
+ raised = exc_cls(set(), {})
292
+
293
+ with pytest.raises(TransientAcmeError):
294
+ with _reraise_transient("authorization poll"):
295
+ raise raised
296
+
297
+
298
+ def test_reraise_transient_passes_unrelated_exceptions():
299
+ with pytest.raises(ValueError):
300
+ with _reraise_transient("new order"):
301
+ raise ValueError("not an acme failure")
302
+
303
+
304
+ def _dns01_authz(ident):
305
+ from acme import challenges
306
+
307
+ challb = SimpleNamespace(
308
+ chall=challenges.DNS01(token=b"\x00" * 32),
309
+ response_and_validation=lambda key: (SimpleNamespace(), "validation-string"),
310
+ )
311
+ body = SimpleNamespace(
312
+ identifier=SimpleNamespace(value=ident),
313
+ status=SimpleNamespace(name="pending"),
314
+ challenges=[challb],
315
+ wildcard=False,
316
+ )
317
+ return SimpleNamespace(body=body)
318
+
319
+
320
+ def test_setup_authz_dns_provider_failure_is_transient(tmp_path):
321
+ """A DNS provider outage (API down, bad credentials, network) is an
322
+ infrastructure problem, never a statement about the domain."""
323
+
324
+ from devnomads.acme import client as client_mod
325
+
326
+ class BadProvider:
327
+ def create_challenge(self, fqdn, validation):
328
+ raise RuntimeError("dns api down")
329
+
330
+ ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
331
+ authz = _dns01_authz("example.com")
332
+
333
+ with pytest.raises(TransientAcmeError) as ei:
334
+ ac._setup_authz(
335
+ client=None,
336
+ account_key=None,
337
+ authz=authz,
338
+ types_map={"example.com": "dns-01"},
339
+ dns_provider=BadProvider(),
340
+ http01_solver=None,
341
+ http_tokens=[],
342
+ dns_challenges=[],
343
+ )
344
+ assert "dns provider failed" in str(ei.value)
345
+
346
+
347
+ def test_answer_dns_challenges_txt_timeout_is_transient(monkeypatch, tmp_path):
348
+ """Slow TXT propagation is retry-safe, not a permanent failure."""
349
+
350
+ from devnomads.acme import client as client_mod
351
+
352
+ monkeypatch.setattr(client_mod, "verify_txt_record", lambda *a, **k: False)
353
+
354
+ ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
355
+ dns_challenges = [("example.com", object(), object(), object(), "validation")]
356
+
357
+ with pytest.raises(TransientAcmeError) as ei:
358
+ ac._answer_dns_challenges(client=None, dns_challenges=dns_challenges, delay=0)
359
+ 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.2.4"
471
+ version = "0.2.5"
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]
@@ -1,9 +0,0 @@
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."""
@@ -1,125 +0,0 @@
1
- """Tests for the pure helpers of the ACME client."""
2
-
3
- from __future__ import annotations
4
-
5
- from types import SimpleNamespace
6
-
7
- import pytest
8
-
9
- from devnomads.acme import AcmeError
10
- from devnomads.acme.client import (
11
- _challenge_for_authz,
12
- _normalize_challenge_types,
13
- _normalize_identifiers,
14
- _split_fullchain,
15
- )
16
-
17
-
18
- def test_normalize_identifiers_dedup_preserves_order():
19
- assert _normalize_identifiers(["a.com", "b.com", "a.com", ""]) == ["a.com", "b.com"]
20
-
21
-
22
- def test_normalize_challenge_types_string_applies_to_all():
23
- assert _normalize_challenge_types("dns-01", ["a.com", "b.com"]) == {
24
- "a.com": "dns-01",
25
- "b.com": "dns-01",
26
- }
27
-
28
-
29
- def test_normalize_challenge_types_dict_per_identifier():
30
- mapping = {"a.com": "dns-01", "b.com": "http-01"}
31
- assert _normalize_challenge_types(mapping, ["a.com", "b.com"]) == mapping
32
-
33
-
34
- def test_normalize_challenge_types_missing_raises():
35
- with pytest.raises(AcmeError):
36
- _normalize_challenge_types({"a.com": "dns-01"}, ["a.com", "b.com"])
37
-
38
-
39
- def _authz(value, wildcard):
40
- return SimpleNamespace(identifier=SimpleNamespace(value=value), wildcard=wildcard)
41
-
42
-
43
- def test_challenge_for_authz_plain():
44
- body = _authz("example.com", False)
45
- assert _challenge_for_authz({"example.com": "dns-01"}, body) == "dns-01"
46
-
47
-
48
- def test_challenge_for_authz_wildcard_reprefixes():
49
- body = _authz("example.com", True)
50
- assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
51
-
52
-
53
- def test_split_fullchain():
54
- leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
55
- rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"
56
- leaf_pem, chain_pem = _split_fullchain(leaf + rest)
57
- assert leaf_pem == leaf
58
- assert chain_pem == rest
59
-
60
-
61
- def test_split_fullchain_no_marker():
62
- leaf_pem, chain_pem = _split_fullchain("garbage")
63
- assert leaf_pem == "garbage"
64
- assert chain_pem == ""
65
-
66
-
67
- def test_init_client_registers_with_new_registration(monkeypatch, tmp_path):
68
- """_init_client must build a real acme.messages.NewRegistration and hand
69
- it to new_account; this exercises the message construction that a wrong
70
- class name (e.g. the non-existent NewAccount) would break."""
71
-
72
- import josepy
73
- from acme import messages
74
- from cryptography.hazmat.backends import default_backend
75
- from cryptography.hazmat.primitives.asymmetric import ec
76
- from josepy import jwk
77
-
78
- from devnomads.acme import client as client_mod
79
-
80
- captured = {}
81
- account_key = jwk.JWKEC(
82
- key=ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
83
- )
84
-
85
- class FakeResponse:
86
- def json(self):
87
- return {}
88
-
89
- class FakeNetwork:
90
- def __init__(self, *args, **kwargs):
91
- captured["alg"] = kwargs.get("alg")
92
- self.account = None
93
-
94
- def get(self, url):
95
- return FakeResponse()
96
-
97
- class FakeClientV2:
98
- def __init__(self, directory, net):
99
- self.net = net
100
-
101
- def new_account(self, registration):
102
- captured["registration"] = registration
103
- return SimpleNamespace(uri="https://acme.example/acct/1")
104
-
105
- monkeypatch.setattr(
106
- client_mod, "load_or_create_account_key", lambda *a, **k: account_key
107
- )
108
- monkeypatch.setattr(client_mod.acme_client, "ClientNetwork", FakeNetwork)
109
- monkeypatch.setattr(client_mod.acme_client, "ClientV2", FakeClientV2)
110
- monkeypatch.setattr(
111
- client_mod.messages.Directory, "from_json", lambda data: object()
112
- )
113
-
114
- ac = client_mod.AcmeClient(
115
- str(tmp_path / "account.pem"), contact_email="ops@example.com"
116
- )
117
- client, _account_key = ac._init_client()
118
-
119
- # an EC account key must be signed with ES256, not the ClientNetwork
120
- # default of RS256, or josepy rejects the JWS.
121
- assert captured["alg"] == josepy.ES256
122
- registration = captured["registration"]
123
- assert isinstance(registration, messages.NewRegistration)
124
- assert registration.contact == ("mailto:ops@example.com",)
125
- assert client.net.account is not None
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