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 +25 -0
- certpost/__main__.py +32 -0
- certpost/_version.py +1 -0
- certpost/acme.py +326 -0
- certpost/argbuilder.py +1432 -0
- certpost/cli.py +268 -0
- certpost/client_cli.py +518 -0
- certpost/client_fetch.py +76 -0
- certpost/cloudflare.py +198 -0
- certpost/crypto.py +283 -0
- certpost/dns.py +52 -0
- certpost/log.py +62 -0
- certpost/proxy.py +361 -0
- certpost/renewal.py +143 -0
- certpost/server.py +704 -0
- certpost/storage.py +277 -0
- certpost/version.py +37 -0
- certpost/web/index.html +621 -0
- certpost-1.0.0b1.dist-info/METADATA +192 -0
- certpost-1.0.0b1.dist-info/RECORD +23 -0
- certpost-1.0.0b1.dist-info/WHEEL +4 -0
- certpost-1.0.0b1.dist-info/entry_points.txt +3 -0
- certpost-1.0.0b1.dist-info/licenses/LICENSE +24 -0
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
|
+
}
|