devnomads 0.1.0__py3-none-any.whl
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/__init__.py +17 -0
- devnomads/acme/__init__.py +49 -0
- devnomads/acme/challenge_server.py +103 -0
- devnomads/acme/client.py +364 -0
- devnomads/acme/dns01.py +42 -0
- devnomads/acme/errors.py +9 -0
- devnomads/acme/http01.py +91 -0
- devnomads/acme/keys.py +104 -0
- devnomads/acme/verify.py +109 -0
- devnomads/api/__init__.py +28 -0
- devnomads/api/client.py +197 -0
- devnomads/api/credentials.py +164 -0
- devnomads/api/errors.py +32 -0
- devnomads/dns/__init__.py +31 -0
- devnomads/dns/errors.py +13 -0
- devnomads/dns/names.py +75 -0
- devnomads/dns/zones.py +211 -0
- devnomads/py.typed +0 -0
- devnomads-0.1.0.dist-info/METADATA +243 -0
- devnomads-0.1.0.dist-info/RECORD +21 -0
- devnomads-0.1.0.dist-info/WHEEL +4 -0
devnomads/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Python client for the DevNomads API.
|
|
2
|
+
|
|
3
|
+
Three layers, smallest dependency footprint first:
|
|
4
|
+
|
|
5
|
+
* :mod:`devnomads.api` - HTTP transport (auth, retries, error handling)
|
|
6
|
+
and, in time, the generated API SDK. Depends only on ``httpx``.
|
|
7
|
+
* :mod:`devnomads.dns` - DNS zone/record helpers (zone discovery, rrset
|
|
8
|
+
merging, TXT/challenge handling) on top of the transport.
|
|
9
|
+
* :mod:`devnomads.acme` - ACME client (DNS-01 and HTTP-01). Requires the
|
|
10
|
+
``acme`` extra: ``pip install devnomads[acme]``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""ACME client for the DevNomads tooling (DNS-01 and HTTP-01 issuance).
|
|
2
|
+
|
|
3
|
+
Requires the ``acme`` extra::
|
|
4
|
+
|
|
5
|
+
pip install devnomads[acme]
|
|
6
|
+
|
|
7
|
+
Without it, importing this package raises a clear ImportError pointing at
|
|
8
|
+
the extra rather than a bare "No module named 'acme'".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# The heavy third-party dependencies (acme, josepy, cryptography, dnspython)
|
|
14
|
+
# only ship with the [acme] extra. Catch their absence and re-raise with an
|
|
15
|
+
# actionable message; let any unrelated ImportError surface unchanged.
|
|
16
|
+
_EXTRA_MODULES = {"acme", "josepy", "cryptography", "dns"}
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from .challenge_server import ChallengeServer
|
|
20
|
+
from .client import DEFAULT_DIRECTORY_URL, AcmeClient
|
|
21
|
+
from .dns01 import DevNomadsDnsProvider, DnsProvider
|
|
22
|
+
from .errors import AcmeError
|
|
23
|
+
from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
|
|
24
|
+
from .keys import build_csr, generate_key, load_or_create_account_key, serialize_key
|
|
25
|
+
from .verify import verify_txt_record
|
|
26
|
+
except ImportError as exc: # pragma: no cover - exercised via packaging
|
|
27
|
+
if (exc.name or "").split(".")[0] in _EXTRA_MODULES:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"devnomads.acme requires the 'acme' extra; install it with: "
|
|
30
|
+
"pip install devnomads[acme]"
|
|
31
|
+
) from exc
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AcmeClient",
|
|
36
|
+
"DEFAULT_DIRECTORY_URL",
|
|
37
|
+
"AcmeError",
|
|
38
|
+
"DnsProvider",
|
|
39
|
+
"DevNomadsDnsProvider",
|
|
40
|
+
"Http01Solver",
|
|
41
|
+
"WebrootSolver",
|
|
42
|
+
"StandaloneSolver",
|
|
43
|
+
"ChallengeServer",
|
|
44
|
+
"build_csr",
|
|
45
|
+
"generate_key",
|
|
46
|
+
"serialize_key",
|
|
47
|
+
"load_or_create_account_key",
|
|
48
|
+
"verify_txt_record",
|
|
49
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""In-memory HTTP-01 challenge server.
|
|
2
|
+
|
|
3
|
+
Serves ACME HTTP-01 responses from memory, so no shared filesystem with a
|
|
4
|
+
web server is required. Intended to listen on port 80 (directly or behind
|
|
5
|
+
a reverse proxy). A permanent token at
|
|
6
|
+
``/.well-known/acme-challenge/devnomads-test`` aids manual reachability
|
|
7
|
+
checks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("devnomads.acme")
|
|
17
|
+
|
|
18
|
+
CHALLENGE_PREFIX = "/.well-known/acme-challenge/"
|
|
19
|
+
TEST_TOKEN = "devnomads-test" # nosec B105 - a public health-check token, not a secret
|
|
20
|
+
TEST_RESPONSE = "devnomads-ok"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _ChallengeHandler(BaseHTTPRequestHandler):
|
|
24
|
+
"""Serves challenge tokens from the server's in-memory map."""
|
|
25
|
+
|
|
26
|
+
def do_GET(self): # noqa: N802
|
|
27
|
+
if not self.path.startswith(CHALLENGE_PREFIX):
|
|
28
|
+
self.send_error(404)
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
token = self.path[len(CHALLENGE_PREFIX) :]
|
|
32
|
+
# Tokens are URL-safe base64 without slashes; a slash means the
|
|
33
|
+
# path is being probed, not a real challenge.
|
|
34
|
+
if "/" in token:
|
|
35
|
+
self.send_error(404)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
# dict.get() is atomic under the GIL, so a concurrent remove()
|
|
39
|
+
# cannot turn this into a KeyError.
|
|
40
|
+
validation = self.server.challenges.get(token)
|
|
41
|
+
if validation is None:
|
|
42
|
+
self.send_error(404)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
body = validation.encode()
|
|
46
|
+
self.send_response(200)
|
|
47
|
+
self.send_header("Content-Type", "text/plain")
|
|
48
|
+
self.send_header("Cache-Control", "no-store")
|
|
49
|
+
self.send_header("Content-Length", str(len(body)))
|
|
50
|
+
self.end_headers()
|
|
51
|
+
self.wfile.write(body)
|
|
52
|
+
|
|
53
|
+
def log_message(self, format, *args): # noqa: A002
|
|
54
|
+
log.debug("challenge server: %s", args[0] if args else "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ChallengeServer:
|
|
58
|
+
"""In-memory HTTP-01 challenge server running in a daemon thread."""
|
|
59
|
+
|
|
60
|
+
# B104: bind all interfaces so the ACME CA can reach the HTTP-01 server.
|
|
61
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 80) -> None: # nosec B104
|
|
62
|
+
self.host = host
|
|
63
|
+
self.port = port
|
|
64
|
+
self._challenges: dict[str, str] = {TEST_TOKEN: TEST_RESPONSE}
|
|
65
|
+
self._server: ThreadingHTTPServer | None = None
|
|
66
|
+
self._thread: threading.Thread | None = None
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
def start(self) -> None:
|
|
70
|
+
"""Start serving. A bind failure is logged, not raised, so a server
|
|
71
|
+
already owning the port (a co-located instance) is tolerated."""
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
self._server = ThreadingHTTPServer(
|
|
75
|
+
(self.host, self.port), _ChallengeHandler
|
|
76
|
+
)
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
log.warning(
|
|
79
|
+
"challenge server not started (%s); assuming another instance "
|
|
80
|
+
"owns the port",
|
|
81
|
+
exc,
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
self._server.challenges = self._challenges # type: ignore[attr-defined]
|
|
85
|
+
self._thread = threading.Thread(
|
|
86
|
+
target=self._server.serve_forever, name="challenge-server", daemon=True
|
|
87
|
+
)
|
|
88
|
+
self._thread.start()
|
|
89
|
+
log.info("challenge server listening on %s:%s", self.host, self.port)
|
|
90
|
+
|
|
91
|
+
def stop(self) -> None:
|
|
92
|
+
if self._server:
|
|
93
|
+
self._server.shutdown()
|
|
94
|
+
self._server = None
|
|
95
|
+
self._thread = None
|
|
96
|
+
|
|
97
|
+
def set(self, token: str, validation: str) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._challenges[token] = validation
|
|
100
|
+
|
|
101
|
+
def remove(self, token: str) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._challenges.pop(token, None)
|
devnomads/acme/client.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""ACME v2 client supporting DNS-01 and HTTP-01 challenges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from time import sleep, time
|
|
8
|
+
|
|
9
|
+
from acme import challenges
|
|
10
|
+
from acme import client as acme_client
|
|
11
|
+
from acme import errors as acme_errors
|
|
12
|
+
from acme import messages
|
|
13
|
+
|
|
14
|
+
from .dns01 import DnsProvider
|
|
15
|
+
from .errors import AcmeError
|
|
16
|
+
from .http01 import Http01Solver
|
|
17
|
+
from .keys import PrivateKey, build_csr, load_or_create_account_key, serialize_key
|
|
18
|
+
from .verify import verify_txt_record
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("devnomads.acme")
|
|
21
|
+
|
|
22
|
+
DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str | None:
|
|
26
|
+
"""Resolve the configured challenge type for one authorization.
|
|
27
|
+
|
|
28
|
+
RFC 8555 §7.5: a wildcard authz reports ``identifier.value`` as the base
|
|
29
|
+
domain with ``wildcard=true``, so the lookup key is re-prefixed with
|
|
30
|
+
``*.`` to match what the caller keyed.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
ident = getattr(getattr(authz_body, "identifier", None), "value", None)
|
|
34
|
+
if ident is None:
|
|
35
|
+
return None
|
|
36
|
+
lookup = f"*.{ident}" if getattr(authz_body, "wildcard", False) else ident
|
|
37
|
+
return challenge_types_map.get(lookup)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AcmeClient:
|
|
41
|
+
"""Issue certificates from an ACME CA (Let's Encrypt by default).
|
|
42
|
+
|
|
43
|
+
Supports DNS-01 (via a :class:`~devnomads.acme.DnsProvider`) and HTTP-01
|
|
44
|
+
(via an :class:`~devnomads.acme.Http01Solver`), and mixing the two
|
|
45
|
+
across the identifiers of a single order.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
account_key_path: str,
|
|
51
|
+
*,
|
|
52
|
+
directory_url: str = DEFAULT_DIRECTORY_URL,
|
|
53
|
+
account_key_algorithm: str = "ec256",
|
|
54
|
+
contact_email: str | None = None,
|
|
55
|
+
preferred_chain: str | None = None,
|
|
56
|
+
recursive_nameservers: list[str] | None = None,
|
|
57
|
+
user_agent: str = "devnomads",
|
|
58
|
+
) -> None:
|
|
59
|
+
self.account_key_path = account_key_path
|
|
60
|
+
self.directory_url = directory_url
|
|
61
|
+
self.account_key_algorithm = account_key_algorithm
|
|
62
|
+
self.contact_email = contact_email
|
|
63
|
+
self.preferred_chain = preferred_chain
|
|
64
|
+
self.recursive_nameservers = recursive_nameservers
|
|
65
|
+
self.user_agent = user_agent
|
|
66
|
+
|
|
67
|
+
# -- account / client setup -------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _init_client(self):
|
|
70
|
+
"""Build an ACME v2 client with an account KID attached."""
|
|
71
|
+
|
|
72
|
+
account_key = load_or_create_account_key(
|
|
73
|
+
self.account_key_path, self.account_key_algorithm
|
|
74
|
+
)
|
|
75
|
+
net = acme_client.ClientNetwork(account_key, user_agent=self.user_agent)
|
|
76
|
+
directory = messages.Directory.from_json(net.get(self.directory_url).json())
|
|
77
|
+
client = acme_client.ClientV2(directory, net)
|
|
78
|
+
|
|
79
|
+
new_account = messages.NewAccount(
|
|
80
|
+
contact=(f"mailto:{self.contact_email}",) if self.contact_email else (),
|
|
81
|
+
terms_of_service_agreed=True,
|
|
82
|
+
only_return_existing=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
account = None
|
|
86
|
+
try:
|
|
87
|
+
account = client.new_account(new_account)
|
|
88
|
+
except acme_errors.ConflictError as exc:
|
|
89
|
+
uri = getattr(exc, "location", None) or getattr(
|
|
90
|
+
getattr(exc, "response", None), "headers", {}
|
|
91
|
+
).get("Location")
|
|
92
|
+
if uri:
|
|
93
|
+
account = messages.RegistrationResource(body=None, uri=uri)
|
|
94
|
+
|
|
95
|
+
if account is None:
|
|
96
|
+
account = getattr(client.net, "account", None)
|
|
97
|
+
if account is None:
|
|
98
|
+
raise AcmeError("unable to establish or load ACME account")
|
|
99
|
+
|
|
100
|
+
client.net.account = account
|
|
101
|
+
return client, account_key
|
|
102
|
+
|
|
103
|
+
# -- polling helpers ---------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _wait_for_authz(self, client, authz, timeout: int = 300):
|
|
106
|
+
deadline = time() + timeout
|
|
107
|
+
while time() < deadline:
|
|
108
|
+
authz, _ = client.poll(authz)
|
|
109
|
+
if authz.body.status == messages.STATUS_VALID:
|
|
110
|
+
return authz
|
|
111
|
+
if authz.body.status == messages.STATUS_INVALID:
|
|
112
|
+
raise AcmeError(_authz_failure(authz.body))
|
|
113
|
+
sleep(2)
|
|
114
|
+
raise AcmeError("authorization polling timed out")
|
|
115
|
+
|
|
116
|
+
def _fetch_order_authorizations(self, client, order):
|
|
117
|
+
raw = getattr(order, "authorizations", None) or []
|
|
118
|
+
if raw and hasattr(raw[0], "body"):
|
|
119
|
+
return raw
|
|
120
|
+
authzs = []
|
|
121
|
+
for url in raw:
|
|
122
|
+
try:
|
|
123
|
+
body = messages.Authorization.from_json(client.net.get(url).json())
|
|
124
|
+
authzs.append(messages.AuthorizationResource(uri=url, body=body))
|
|
125
|
+
except Exception as exc: # noqa: BLE001
|
|
126
|
+
log.warning("failed to fetch authorization %s: %s", url, exc)
|
|
127
|
+
return authzs or raw
|
|
128
|
+
|
|
129
|
+
# -- issuance ----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def obtain_certificate(
|
|
132
|
+
self,
|
|
133
|
+
domain: str,
|
|
134
|
+
challenge_types: dict[str, str] | str,
|
|
135
|
+
domain_key: PrivateKey,
|
|
136
|
+
*,
|
|
137
|
+
sans: list[str] | None = None,
|
|
138
|
+
dns_provider: DnsProvider | None = None,
|
|
139
|
+
http01_solver: Http01Solver | None = None,
|
|
140
|
+
dns_propagation_delay: int = 2,
|
|
141
|
+
) -> tuple[str, str, str, bytes]:
|
|
142
|
+
"""Obtain a certificate for ``domain`` (plus ``sans``).
|
|
143
|
+
|
|
144
|
+
``challenge_types`` is either a single ``"dns-01"``/``"http-01"``
|
|
145
|
+
applied to every identifier, or a ``{identifier: type}`` mapping for
|
|
146
|
+
per-identifier selection within one order. Returns
|
|
147
|
+
``(cert_pem, fullchain_pem, chain_pem, domain_key_pem)``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
client, account_key = self._init_client()
|
|
151
|
+
identifiers = _normalize_identifiers([domain] + (sans or []))
|
|
152
|
+
if not identifiers:
|
|
153
|
+
raise AcmeError("no valid identifiers for CSR")
|
|
154
|
+
types_map = _normalize_challenge_types(challenge_types, identifiers)
|
|
155
|
+
|
|
156
|
+
domain_key_pem = serialize_key(domain_key)
|
|
157
|
+
csr_pem = build_csr(domain_key, identifiers)
|
|
158
|
+
log.info("requesting certificate for: %s", ", ".join(identifiers))
|
|
159
|
+
|
|
160
|
+
order = client.new_order(csr_pem)
|
|
161
|
+
authzs = self._fetch_order_authorizations(client, order)
|
|
162
|
+
|
|
163
|
+
http_tokens: list[str] = []
|
|
164
|
+
dns_challenges: list[tuple[str, object, object, object, str]] = []
|
|
165
|
+
try:
|
|
166
|
+
for authz in authzs:
|
|
167
|
+
self._setup_authz(
|
|
168
|
+
client,
|
|
169
|
+
account_key,
|
|
170
|
+
authz,
|
|
171
|
+
types_map,
|
|
172
|
+
dns_provider,
|
|
173
|
+
http01_solver,
|
|
174
|
+
http_tokens,
|
|
175
|
+
dns_challenges,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if dns_challenges:
|
|
179
|
+
self._answer_dns_challenges(
|
|
180
|
+
client, dns_challenges, dns_propagation_delay
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
order = client.poll_and_finalize(
|
|
184
|
+
order, deadline=datetime.now() + timedelta(seconds=300)
|
|
185
|
+
)
|
|
186
|
+
finally:
|
|
187
|
+
self._cleanup(http01_solver, http_tokens, dns_provider, dns_challenges)
|
|
188
|
+
|
|
189
|
+
fullchain_pem = self._select_chain(client, order)
|
|
190
|
+
leaf_pem, chain_pem = _split_fullchain(fullchain_pem)
|
|
191
|
+
return leaf_pem, fullchain_pem, chain_pem, domain_key_pem
|
|
192
|
+
|
|
193
|
+
def _setup_authz(
|
|
194
|
+
self,
|
|
195
|
+
client,
|
|
196
|
+
account_key,
|
|
197
|
+
authz,
|
|
198
|
+
types_map,
|
|
199
|
+
dns_provider,
|
|
200
|
+
http01_solver,
|
|
201
|
+
http_tokens,
|
|
202
|
+
dns_challenges,
|
|
203
|
+
) -> None:
|
|
204
|
+
ident = getattr(getattr(authz.body, "identifier", None), "value", None)
|
|
205
|
+
status = getattr(authz.body, "status", None)
|
|
206
|
+
if status and status.name == "valid":
|
|
207
|
+
log.debug("authorization for %s already valid, skipping", ident)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
this = _challenge_for_authz(types_map, authz.body)
|
|
211
|
+
if this is None:
|
|
212
|
+
raise AcmeError(f"no challenge type configured for identifier {ident!r}")
|
|
213
|
+
|
|
214
|
+
want = challenges.DNS01 if this == "dns-01" else challenges.HTTP01
|
|
215
|
+
challb = next(
|
|
216
|
+
(c for c in authz.body.challenges if isinstance(c.chall, want)), None
|
|
217
|
+
)
|
|
218
|
+
if not challb:
|
|
219
|
+
offered = [c.chall.typ for c in authz.body.challenges if c.chall]
|
|
220
|
+
raise AcmeError(
|
|
221
|
+
f"{this} challenge not offered for {ident} (offered: {offered})"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
response, validation = challb.response_and_validation(account_key)
|
|
225
|
+
|
|
226
|
+
if this == "dns-01":
|
|
227
|
+
if not dns_provider:
|
|
228
|
+
raise AcmeError("dns-01 selected but no DNS provider configured")
|
|
229
|
+
fqdn = authz.body.identifier.value
|
|
230
|
+
if dns_challenges:
|
|
231
|
+
sleep(1) # space out API calls to avoid rate limiting
|
|
232
|
+
log.info("planting dns-01 challenge for %s", fqdn)
|
|
233
|
+
dns_provider.create_challenge(fqdn, validation)
|
|
234
|
+
dns_challenges.append((fqdn, challb, response, authz, validation))
|
|
235
|
+
else:
|
|
236
|
+
if not http01_solver:
|
|
237
|
+
raise AcmeError("http-01 selected but no HTTP-01 solver configured")
|
|
238
|
+
token = challb.chall.encode("token")
|
|
239
|
+
log.info("serving http-01 challenge for %s", ident)
|
|
240
|
+
http01_solver.set(token, validation)
|
|
241
|
+
http_tokens.append(token)
|
|
242
|
+
client.answer_challenge(challb, response)
|
|
243
|
+
self._wait_for_authz(client, authz)
|
|
244
|
+
|
|
245
|
+
def _answer_dns_challenges(self, client, dns_challenges, delay: int) -> None:
|
|
246
|
+
if delay > 0:
|
|
247
|
+
log.info("waiting %ds for DNS propagation", delay)
|
|
248
|
+
sleep(delay)
|
|
249
|
+
log.info("verifying %d DNS challenge(s)", len(dns_challenges))
|
|
250
|
+
for fqdn, _, _, _, validation in dns_challenges:
|
|
251
|
+
if not verify_txt_record(
|
|
252
|
+
fqdn, validation, nameservers=self.recursive_nameservers
|
|
253
|
+
):
|
|
254
|
+
raise AcmeError(
|
|
255
|
+
f"DNS challenge verification failed for {fqdn}: "
|
|
256
|
+
"TXT record not found at authoritative nameservers"
|
|
257
|
+
)
|
|
258
|
+
for fqdn, challb, response, authz, _ in dns_challenges:
|
|
259
|
+
log.info("answering dns-01 challenge for %s", fqdn)
|
|
260
|
+
client.answer_challenge(challb, response)
|
|
261
|
+
self._wait_for_authz(client, authz)
|
|
262
|
+
|
|
263
|
+
def _cleanup(
|
|
264
|
+
self, http01_solver, http_tokens, dns_provider, dns_challenges
|
|
265
|
+
) -> None:
|
|
266
|
+
if http01_solver:
|
|
267
|
+
for token in http_tokens:
|
|
268
|
+
http01_solver.remove(token)
|
|
269
|
+
if dns_provider:
|
|
270
|
+
for fqdn, _, _, _, validation in dns_challenges:
|
|
271
|
+
try:
|
|
272
|
+
dns_provider.delete_challenge(fqdn, validation)
|
|
273
|
+
except Exception as exc: # noqa: BLE001
|
|
274
|
+
log.warning("failed to clean up DNS challenge %s: %s", fqdn, exc)
|
|
275
|
+
|
|
276
|
+
def _select_chain(self, client, order) -> str:
|
|
277
|
+
fullchain_pem = order.fullchain_pem
|
|
278
|
+
if not (self.preferred_chain and getattr(order, "fullchain_uri", None)):
|
|
279
|
+
return fullchain_pem
|
|
280
|
+
try:
|
|
281
|
+
resp = client._post_as_get(order.fullchain_uri)
|
|
282
|
+
candidates = [resp.text] if hasattr(resp, "text") else []
|
|
283
|
+
for rel, link in getattr(resp, "links", {}).items():
|
|
284
|
+
if rel == "alternate" and isinstance(link, dict) and link.get("url"):
|
|
285
|
+
alt = client._post_as_get(link["url"])
|
|
286
|
+
if hasattr(alt, "text"):
|
|
287
|
+
candidates.append(alt.text)
|
|
288
|
+
for candidate in candidates:
|
|
289
|
+
if self.preferred_chain in candidate:
|
|
290
|
+
return candidate
|
|
291
|
+
except Exception as exc: # noqa: BLE001
|
|
292
|
+
log.warning("preferred chain selection failed (%s), using default", exc)
|
|
293
|
+
return fullchain_pem
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _normalize_identifiers(names: list[str]) -> list[str]:
|
|
297
|
+
"""IDNA-encode and de-duplicate identifiers, preserving order."""
|
|
298
|
+
|
|
299
|
+
identifiers: list[str] = []
|
|
300
|
+
seen: set[str] = set()
|
|
301
|
+
for name in names:
|
|
302
|
+
if not name:
|
|
303
|
+
continue
|
|
304
|
+
try:
|
|
305
|
+
ascii_name = name.encode("idna").decode("ascii")
|
|
306
|
+
except Exception: # noqa: BLE001 # nosec B112
|
|
307
|
+
continue
|
|
308
|
+
if ascii_name not in seen:
|
|
309
|
+
seen.add(ascii_name)
|
|
310
|
+
identifiers.append(ascii_name)
|
|
311
|
+
return identifiers
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _normalize_challenge_types(
|
|
315
|
+
challenge_types: dict[str, str] | str, identifiers: list[str]
|
|
316
|
+
) -> dict[str, str]:
|
|
317
|
+
"""Return a ``{identifier: challenge_type}`` map for every identifier."""
|
|
318
|
+
|
|
319
|
+
if isinstance(challenge_types, str):
|
|
320
|
+
return {ident: challenge_types for ident in identifiers}
|
|
321
|
+
|
|
322
|
+
result: dict[str, str] = {}
|
|
323
|
+
for ident in identifiers:
|
|
324
|
+
ct = challenge_types.get(ident)
|
|
325
|
+
if ct is None:
|
|
326
|
+
for key, value in challenge_types.items():
|
|
327
|
+
try:
|
|
328
|
+
if key.encode("idna").decode("ascii") == ident:
|
|
329
|
+
ct = value
|
|
330
|
+
break
|
|
331
|
+
except Exception: # noqa: BLE001 # nosec B112
|
|
332
|
+
continue
|
|
333
|
+
if ct is None:
|
|
334
|
+
raise AcmeError(
|
|
335
|
+
f"no challenge type specified for identifier {ident!r} "
|
|
336
|
+
f"(provided: {sorted(challenge_types)})"
|
|
337
|
+
)
|
|
338
|
+
result[ident] = ct
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _authz_failure(authz_body) -> str:
|
|
343
|
+
err = getattr(authz_body, "error", None)
|
|
344
|
+
if err:
|
|
345
|
+
detail = getattr(err, "detail", None) or "unknown error"
|
|
346
|
+
return (
|
|
347
|
+
f"authorization failed: {detail} (type: {getattr(err, 'typ', 'unknown')})"
|
|
348
|
+
)
|
|
349
|
+
parts = []
|
|
350
|
+
for challb in getattr(authz_body, "challenges", []):
|
|
351
|
+
ch_err = getattr(challb, "error", None)
|
|
352
|
+
if ch_err:
|
|
353
|
+
detail = getattr(ch_err, "detail", None) or "unknown error"
|
|
354
|
+
parts.append(f"{getattr(challb.chall, 'typ', 'unknown')}: {detail}")
|
|
355
|
+
return "authorization failed: " + ("; ".join(parts) if parts else "unknown")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _split_fullchain(fullchain_pem: str) -> tuple[str, str]:
|
|
359
|
+
marker = "-----END CERTIFICATE-----"
|
|
360
|
+
end = fullchain_pem.find(marker)
|
|
361
|
+
if end == -1:
|
|
362
|
+
return fullchain_pem, ""
|
|
363
|
+
cut = end + len(marker) + 1
|
|
364
|
+
return fullchain_pem[:cut], fullchain_pem[cut:]
|
devnomads/acme/dns01.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""DNS-01 challenge solvers.
|
|
2
|
+
|
|
3
|
+
A solver plants and removes the ``_acme-challenge`` TXT record that proves
|
|
4
|
+
control of a domain. :class:`DnsProvider` is the interface; the bundled
|
|
5
|
+
:class:`DevNomadsDnsProvider` drives it through :mod:`devnomads.dns`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
|
|
12
|
+
from ..dns import Dns, challenge_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DnsProvider(ABC):
|
|
16
|
+
"""Plants/removes DNS-01 challenge records for a domain."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def create_challenge(self, fqdn: str, validation: str) -> None:
|
|
20
|
+
"""Create the ``_acme-challenge.<fqdn>`` TXT record."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def delete_challenge(self, fqdn: str, validation: str | None = None) -> None:
|
|
24
|
+
"""Remove the challenge record.
|
|
25
|
+
|
|
26
|
+
``validation`` lets the provider remove just that one value, leaving
|
|
27
|
+
any other values (e.g. a concurrent wildcard order) intact; with
|
|
28
|
+
``None`` the whole record is removed.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DevNomadsDnsProvider(DnsProvider):
|
|
33
|
+
"""DNS-01 provider backed by the DevNomads DNS API."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, dns: Dns) -> None:
|
|
36
|
+
self.dns = dns
|
|
37
|
+
|
|
38
|
+
def create_challenge(self, fqdn: str, validation: str) -> None:
|
|
39
|
+
self.dns.set_txt(challenge_name(fqdn), validation)
|
|
40
|
+
|
|
41
|
+
def delete_challenge(self, fqdn: str, validation: str | None = None) -> None:
|
|
42
|
+
self.dns.unset_txt(challenge_name(fqdn), validation)
|
devnomads/acme/errors.py
ADDED
devnomads/acme/http01.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HTTP-01 challenge solvers.
|
|
2
|
+
|
|
3
|
+
Two ways to answer an HTTP-01 challenge, both behind one interface:
|
|
4
|
+
|
|
5
|
+
* :class:`WebrootSolver` writes the validation file under an existing web
|
|
6
|
+
server's document root (``<webroot>/.well-known/acme-challenge/<token>``).
|
|
7
|
+
* :class:`StandaloneSolver` runs the bundled :class:`ChallengeServer` and
|
|
8
|
+
answers requests itself, for hosts with no web server.
|
|
9
|
+
|
|
10
|
+
Use a solver as a context manager so the server (if any) is started and
|
|
11
|
+
stopped around issuance::
|
|
12
|
+
|
|
13
|
+
with StandaloneSolver(port=80) as solver:
|
|
14
|
+
client.obtain_certificate(..., http01_solver=solver)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .challenge_server import CHALLENGE_PREFIX, ChallengeServer
|
|
24
|
+
from .errors import AcmeError
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("devnomads.acme")
|
|
27
|
+
|
|
28
|
+
# CHALLENGE_PREFIX is an absolute URL path ("/.well-known/..."); strip the
|
|
29
|
+
# leading slash to use it as a relative filesystem path under a webroot.
|
|
30
|
+
_RELATIVE_CHALLENGE_DIR = CHALLENGE_PREFIX.strip("/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Http01Solver(ABC):
|
|
34
|
+
"""Serves and removes HTTP-01 challenge validations."""
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> "Http01Solver":
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, *exc: object) -> None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def set(self, token: str, validation: str) -> None:
|
|
44
|
+
"""Make ``validation`` reachable at ``/.well-known/acme-challenge/<token>``."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def remove(self, token: str) -> None:
|
|
48
|
+
"""Stop serving ``token``."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WebrootSolver(Http01Solver):
|
|
52
|
+
"""Writes challenge files under an existing web server's document root."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, webroot: str | Path) -> None:
|
|
55
|
+
self.challenge_dir = Path(webroot) / _RELATIVE_CHALLENGE_DIR
|
|
56
|
+
|
|
57
|
+
def set(self, token: str, validation: str) -> None:
|
|
58
|
+
try:
|
|
59
|
+
self.challenge_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
(self.challenge_dir / token).write_text(validation)
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise AcmeError(f"failed to write challenge file: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
def remove(self, token: str) -> None:
|
|
65
|
+
try:
|
|
66
|
+
(self.challenge_dir / token).unlink()
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
pass
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
log.warning("failed to remove challenge file %s: %s", token, exc)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StandaloneSolver(Http01Solver):
|
|
74
|
+
"""Answers HTTP-01 challenges from the bundled in-memory server."""
|
|
75
|
+
|
|
76
|
+
# B104: bind all interfaces so the ACME CA can reach the HTTP-01 server.
|
|
77
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 80) -> None: # nosec B104
|
|
78
|
+
self.server = ChallengeServer(host=host, port=port)
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> "StandaloneSolver":
|
|
81
|
+
self.server.start()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc: object) -> None:
|
|
85
|
+
self.server.stop()
|
|
86
|
+
|
|
87
|
+
def set(self, token: str, validation: str) -> None:
|
|
88
|
+
self.server.set(token, validation)
|
|
89
|
+
|
|
90
|
+
def remove(self, token: str) -> None:
|
|
91
|
+
self.server.remove(token)
|