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 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)
@@ -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:]
@@ -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)
@@ -0,0 +1,9 @@
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."""
@@ -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)