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.
- devnomads-0.2.4/README.md → devnomads-0.3.0/PKG-INFO +53 -0
- devnomads-0.2.4/PKG-INFO → devnomads-0.3.0/README.md +35 -17
- {devnomads-0.2.4 → devnomads-0.3.0}/pyproject.toml +8 -2
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/__init__.py +14 -2
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/client.py +135 -22
- devnomads-0.3.0/src/devnomads/acme/errors.py +27 -0
- devnomads-0.3.0/tests/test_acme_client.py +359 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/uv.lock +3 -1
- devnomads-0.2.4/src/devnomads/acme/errors.py +0 -9
- devnomads-0.2.4/tests/test_acme_client.py +0 -125
- {devnomads-0.2.4 → devnomads-0.3.0}/.flake8 +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/.gitignore +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/.gitlab-ci.yml +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/Makefile +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/openapi.json +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/__init__.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/client.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/src/devnomads/py.typed +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/conftest.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_acme_keys.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_client.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_credentials.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_dns.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_names.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tests/test_services.py +0 -0
- {devnomads-0.2.4 → devnomads-0.3.0}/tools/bump_version.py +0 -0
- {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.
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
getattr(exc, "
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
order
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,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
|
|
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
|