certpost 1.0.0b1__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.
certpost/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # ----------------------------------------------------------------------------------------
2
+ # certpost
3
+ # --------
4
+ #
5
+ # Let's Encrypt certificate manager with DNS-01 via Namecheap and API access.
6
+ #
7
+ # (c) 2026 WaterJuice — Released under the Unlicense; see LICENSE.
8
+ #
9
+ # Authors
10
+ # -------
11
+ # bena (via Claude)
12
+ #
13
+ # Version History
14
+ # ---------------
15
+ # Mar 2026 - Created
16
+ # ----------------------------------------------------------------------------------------
17
+
18
+ # ----------------------------------------------------------------------------------------
19
+ # Version
20
+ # ----------------------------------------------------------------------------------------
21
+
22
+ from .version import VERSION_STR
23
+
24
+ __version__ = VERSION_STR
25
+ __all__ = ["__version__"]
certpost/__main__.py ADDED
@@ -0,0 +1,32 @@
1
+ # ----------------------------------------------------------------------------------------
2
+ # __main__.py
3
+ # -----------
4
+ #
5
+ # Entry point for python -m certpost.
6
+ #
7
+ # (c) 2026 WaterJuice — Released under the Unlicense; see LICENSE.
8
+ #
9
+ # Authors
10
+ # -------
11
+ # bena (via Claude)
12
+ #
13
+ # Version History
14
+ # ---------------
15
+ # Mar 2026 - Created
16
+ # ----------------------------------------------------------------------------------------
17
+
18
+ import sys
19
+
20
+ MIN_PYTHON = (3, 12)
21
+ if sys.version_info < MIN_PYTHON:
22
+ print(
23
+ f"Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ is required. "
24
+ f"You are using Python {sys.version_info.major}.{sys.version_info.minor}.",
25
+ file=sys.stderr,
26
+ )
27
+ sys.exit(1)
28
+
29
+ from certpost.client_cli import main # noqa: E402
30
+
31
+ if __name__ == "__main__":
32
+ raise SystemExit(main())
certpost/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0b1"
certpost/acme.py ADDED
@@ -0,0 +1,326 @@
1
+ # ----------------------------------------------------------------------------------------
2
+ # acme.py
3
+ # -------
4
+ #
5
+ # ACME v2 client for Let's Encrypt certificate issuance using DNS-01 challenges.
6
+ # Uses urllib.request for HTTP and shells out to openssl for all crypto operations.
7
+ #
8
+ # (c) 2026 WaterJuice — Released under the Unlicense; see LICENSE.
9
+ #
10
+ # Authors
11
+ # -------
12
+ # bena (via Claude)
13
+ #
14
+ # Version History
15
+ # ---------------
16
+ # Mar 2026 - Created
17
+ # ----------------------------------------------------------------------------------------
18
+
19
+ # ----------------------------------------------------------------------------------------
20
+ # Imports
21
+ # ----------------------------------------------------------------------------------------
22
+
23
+ import json
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ from typing import Any
28
+ from .crypto import build_jws
29
+ from .crypto import create_csr
30
+ from .crypto import dns_challenge_value
31
+ from .crypto import generate_rsa_key
32
+ from .crypto import parse_cert_expiry
33
+ from .dns import DnsProvider
34
+ from .storage import Storage
35
+
36
+ # ----------------------------------------------------------------------------------------
37
+ # Types
38
+ # ----------------------------------------------------------------------------------------
39
+
40
+ type JsonDict = dict[str, Any]
41
+
42
+ # ----------------------------------------------------------------------------------------
43
+ # Constants
44
+ # ----------------------------------------------------------------------------------------
45
+
46
+ _DNS_PROPAGATION_WAIT = 30
47
+ _CHALLENGE_POLL_INTERVAL = 2
48
+ _CHALLENGE_POLL_TIMEOUT = 120
49
+ _ORDER_POLL_INTERVAL = 2
50
+ _ORDER_POLL_TIMEOUT = 120
51
+ _ACME_DIRECTORY = "https://acme-v02.api.letsencrypt.org/directory"
52
+
53
+ # ----------------------------------------------------------------------------------------
54
+ # ACME Client
55
+ # ----------------------------------------------------------------------------------------
56
+
57
+
58
+ # ----------------------------------------------------------------------------------------
59
+ class AcmeClient:
60
+ """ACME v2 client for Let's Encrypt."""
61
+
62
+ # ------------------------------------------------------------------------------------
63
+ # Construction
64
+ # ------------------------------------------------------------------------------------
65
+
66
+ def __init__(self, storage: Storage, dns: DnsProvider) -> None:
67
+ """Initialise the ACME client."""
68
+ self._storage = storage
69
+ self._dns = dns
70
+ self._directory: JsonDict = {}
71
+ self._account_key_pem: str = ""
72
+ self._account_kid: str = ""
73
+
74
+ # ------------------------------------------------------------------------------------
75
+ # Private methods
76
+ # ------------------------------------------------------------------------------------
77
+
78
+ # ------------------------------------------------------------------------------------
79
+ def _log(self, message: str) -> None:
80
+ """Log a message."""
81
+ from .log import log
82
+
83
+ log("acme", message)
84
+
85
+ # ------------------------------------------------------------------------------------
86
+ def _fetch_directory(self) -> None:
87
+ """Fetch the ACME directory endpoints."""
88
+ with urllib.request.urlopen(_ACME_DIRECTORY, timeout=30) as response:
89
+ self._directory = json.loads(response.read())
90
+
91
+ # ------------------------------------------------------------------------------------
92
+ def _get_nonce(self) -> str:
93
+ """Get a fresh nonce from the ACME server."""
94
+ nonce_url = self._directory["newNonce"]
95
+ req = urllib.request.Request(str(nonce_url), method="HEAD")
96
+ with urllib.request.urlopen(req, timeout=30) as response:
97
+ nonce = response.headers.get("Replay-Nonce", "")
98
+ return str(nonce)
99
+
100
+ # ------------------------------------------------------------------------------------
101
+ def _acme_request(
102
+ self, url: str, payload: JsonDict | str
103
+ ) -> tuple[JsonDict, dict[str, str]]:
104
+ """Make a signed ACME request. Returns (response_body, response_headers)."""
105
+ nonce = self._get_nonce()
106
+
107
+ kid = self._account_kid if self._account_kid else None
108
+ body = build_jws(url, payload, nonce, self._account_key_pem, kid=kid)
109
+
110
+ req = urllib.request.Request(
111
+ url,
112
+ data=body.encode(),
113
+ headers={"Content-Type": "application/jose+json"},
114
+ method="POST",
115
+ )
116
+
117
+ try:
118
+ with urllib.request.urlopen(req, timeout=30) as response:
119
+ resp_body = response.read()
120
+ headers = {k.lower(): v for k, v in response.headers.items()}
121
+ if resp_body:
122
+ return json.loads(resp_body), headers
123
+ return {}, headers
124
+ except urllib.error.HTTPError as e:
125
+ error_body = e.read().decode()
126
+ raise RuntimeError(f"ACME request failed ({e.code}): {error_body}") from e
127
+
128
+ # ------------------------------------------------------------------------------------
129
+ def _ensure_account(self) -> None:
130
+ """Ensure we have an ACME account registered."""
131
+ account = self._storage.get_acme_account()
132
+
133
+ if account and account.get("key_pem") and account.get("kid"):
134
+ self._account_key_pem = str(account["key_pem"])
135
+ self._account_kid = str(account["kid"])
136
+ return
137
+
138
+ # Generate account key if needed
139
+ if account and account.get("key_pem"):
140
+ self._account_key_pem = str(account["key_pem"])
141
+ else:
142
+ self._log("Generating ACME account key...")
143
+ self._account_key_pem = generate_rsa_key(4096)
144
+
145
+ # Register account
146
+ self._log("Registering ACME account...")
147
+ payload: JsonDict = {
148
+ "termsOfServiceAgreed": True,
149
+ }
150
+
151
+ new_account_url = self._directory["newAccount"]
152
+ _, headers = self._acme_request(str(new_account_url), payload)
153
+
154
+ self._account_kid = headers.get("location", "")
155
+ if not self._account_kid:
156
+ raise RuntimeError("ACME registration did not return account URL")
157
+
158
+ self._storage.save_acme_account(
159
+ {
160
+ "key_pem": self._account_key_pem,
161
+ "kid": self._account_kid,
162
+ }
163
+ )
164
+ self._log(f"Account registered: {self._account_kid}")
165
+
166
+ # ------------------------------------------------------------------------------------
167
+ # Public methods
168
+ # ------------------------------------------------------------------------------------
169
+
170
+ # ------------------------------------------------------------------------------------
171
+ def initialise(self) -> None:
172
+ """Initialise the ACME client — fetch directory and ensure account exists."""
173
+ self._fetch_directory()
174
+ self._ensure_account()
175
+
176
+ # ------------------------------------------------------------------------------------
177
+ def issue_certificate(self, fqdn: str) -> JsonDict:
178
+ """Issue a certificate for the given FQDN. Returns cert data dict."""
179
+ self._log(f"Ordering certificate for {fqdn}...")
180
+
181
+ # Create order
182
+ new_order_url = self._directory["newOrder"]
183
+ order_payload: JsonDict = {
184
+ "identifiers": [{"type": "dns", "value": fqdn}],
185
+ }
186
+ order, order_headers = self._acme_request(str(new_order_url), order_payload)
187
+ order_url = order_headers.get("location", "")
188
+
189
+ # Process authorisations
190
+ authorisations: list[str] = order.get("authorizations", [])
191
+ for auth_url in authorisations:
192
+ auth_body, _ = self._acme_request(auth_url, "")
193
+ challenges: list[JsonDict] = auth_body.get("challenges", [])
194
+
195
+ # Find DNS-01 challenge
196
+ dns_challenge: JsonDict | None = None
197
+ for c in challenges:
198
+ if c.get("type") == "dns-01":
199
+ dns_challenge = c
200
+ break
201
+
202
+ if dns_challenge is None:
203
+ raise RuntimeError(f"No DNS-01 challenge found for {fqdn}")
204
+
205
+ token = str(dns_challenge["token"])
206
+ challenge_url = str(dns_challenge["url"])
207
+ challenge_value = dns_challenge_value(token, self._account_key_pem)
208
+
209
+ # Set DNS TXT record
210
+ acme_record_name = f"_acme-challenge.{fqdn}"
211
+ self._log(f"Setting TXT record: {acme_record_name} = {challenge_value}")
212
+ self._dns.set_txt_record(acme_record_name, challenge_value)
213
+
214
+ # Wait for DNS propagation
215
+ self._log(f"Waiting {_DNS_PROPAGATION_WAIT}s for DNS propagation...")
216
+ time.sleep(_DNS_PROPAGATION_WAIT)
217
+
218
+ # Tell ACME server to validate
219
+ self._log("Requesting challenge validation...")
220
+ self._acme_request(challenge_url, {})
221
+
222
+ # Poll for challenge completion
223
+ start = time.time()
224
+ while time.time() - start < _CHALLENGE_POLL_TIMEOUT:
225
+ auth_body, _ = self._acme_request(auth_url, "")
226
+ status = auth_body.get("status", "")
227
+ if status == "valid":
228
+ self._log("Challenge validated!")
229
+ break
230
+ if status == "invalid":
231
+ raise RuntimeError(f"Challenge failed for {fqdn}: {auth_body}")
232
+ time.sleep(_CHALLENGE_POLL_INTERVAL)
233
+ else:
234
+ raise RuntimeError(f"Challenge timed out for {fqdn}")
235
+
236
+ # Clean up DNS record
237
+ self._log("Cleaning up TXT record...")
238
+ self._dns.remove_txt_record(acme_record_name)
239
+
240
+ # Generate cert key and CSR
241
+ self._log("Generating certificate key and CSR...")
242
+ cert_key_pem = generate_rsa_key(2048)
243
+ csr_pem = create_csr(cert_key_pem, [fqdn])
244
+
245
+ # Finalise order
246
+ import base64
247
+
248
+ # Convert PEM CSR to DER for ACME
249
+ csr_lines = [
250
+ line
251
+ for line in csr_pem.split("\n")
252
+ if not line.startswith("-----") and line.strip()
253
+ ]
254
+ csr_der = base64.b64decode("".join(csr_lines))
255
+ csr_b64 = base64.urlsafe_b64encode(csr_der).rstrip(b"=").decode("ascii")
256
+
257
+ finalise_url = str(order.get("finalize", ""))
258
+ self._log("Finalising order...")
259
+ self._acme_request(finalise_url, {"csr": csr_b64})
260
+
261
+ # Poll for certificate
262
+ start = time.time()
263
+ cert_url = ""
264
+ while time.time() - start < _ORDER_POLL_TIMEOUT:
265
+ order_body, _ = self._acme_request(order_url, "")
266
+ status = order_body.get("status", "")
267
+ if status == "valid":
268
+ cert_url = str(order_body.get("certificate", ""))
269
+ break
270
+ if status == "invalid":
271
+ raise RuntimeError(f"Order failed for {fqdn}: {order_body}")
272
+ time.sleep(_ORDER_POLL_INTERVAL)
273
+ else:
274
+ raise RuntimeError(f"Order timed out for {fqdn}")
275
+
276
+ # Download certificate
277
+ self._log("Downloading certificate...")
278
+ nonce = self._get_nonce()
279
+ body = build_jws(
280
+ cert_url, "", nonce, self._account_key_pem, kid=self._account_kid
281
+ )
282
+ req = urllib.request.Request(
283
+ cert_url,
284
+ data=body.encode(),
285
+ headers={
286
+ "Content-Type": "application/jose+json",
287
+ "Accept": "application/pem-certificate-chain",
288
+ },
289
+ method="POST",
290
+ )
291
+ with urllib.request.urlopen(req, timeout=30) as response:
292
+ full_chain = response.read().decode()
293
+
294
+ # Split into cert and chain
295
+ certs = full_chain.split("-----END CERTIFICATE-----")
296
+ cert_pem = certs[0] + "-----END CERTIFICATE-----\n" if certs else ""
297
+ chain_pem = (
298
+ "-----END CERTIFICATE-----".join(certs[1:]).strip()
299
+ if len(certs) > 1
300
+ else ""
301
+ )
302
+ if chain_pem and not chain_pem.endswith("\n"):
303
+ chain_pem += "\n"
304
+
305
+ # Parse expiry
306
+ expires_at = parse_cert_expiry(cert_pem)
307
+
308
+ # Save
309
+ self._storage.save_cert(fqdn, cert_pem, chain_pem, cert_key_pem, expires_at)
310
+ self._storage.update_domain(
311
+ fqdn,
312
+ {
313
+ "status": "issued",
314
+ "cert_expires_at": expires_at,
315
+ "last_error": None,
316
+ },
317
+ )
318
+
319
+ self._log(f"Certificate issued for {fqdn}")
320
+
321
+ return {
322
+ "cert_pem": cert_pem,
323
+ "chain_pem": chain_pem,
324
+ "key_pem": cert_key_pem,
325
+ "expires_at": expires_at,
326
+ }