···
1
-
"""Automated SSL certificate management using ACME protocol."""
9
-
from datetime import datetime, timezone
10
-
from pathlib import Path
11
-
from typing import Optional, Tuple
14
-
from acme import challenges, client, errors, messages
15
-
from cryptography import x509
16
-
from cryptography.hazmat.backends import default_backend
17
-
from cryptography.hazmat.primitives import hashes, serialization
18
-
from cryptography.hazmat.primitives.asymmetric import rsa
19
-
from cryptography.x509.oid import NameOID
20
-
from fastapi import FastAPI
21
-
from fastapi.responses import PlainTextResponse
23
-
import josepy as jose
25
-
logger = structlog.get_logger()
27
-
LETSENCRYPT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
28
-
LETSENCRYPT_STAGING_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
31
-
class CertificateManager:
32
-
"""Manages SSL certificates using ACME protocol."""
39
-
staging: bool = False,
42
-
"""Initialize certificate manager.
45
-
domain: Domain name for the certificate
46
-
email: Email for Let's Encrypt account
47
-
cert_dir: Directory to store certificates
48
-
staging: Use Let's Encrypt staging server
49
-
port: Port for HTTP-01 challenge server
51
-
self.domain = domain
53
-
self.cert_dir = Path(cert_dir)
54
-
self.cert_dir.mkdir(parents=True, exist_ok=True)
55
-
self.staging = staging
56
-
self.challenge_port = port
58
-
self.directory_url = LETSENCRYPT_STAGING_URL if staging else LETSENCRYPT_DIRECTORY_URL
59
-
self.account_key_path = self.cert_dir / "account_key.pem"
60
-
self.cert_path = self.cert_dir / f"{domain}_cert.pem"
61
-
self.key_path = self.cert_dir / f"{domain}_key.pem"
62
-
self.fullchain_path = self.cert_dir / f"{domain}_fullchain.pem"
64
-
# For HTTP-01 challenge
65
-
self.challenge_tokens = {}
66
-
self.challenge_server = None
67
-
self.challenge_thread = None
69
-
def _generate_private_key(self) -> rsa.RSAPrivateKey:
70
-
"""Generate a new RSA private key."""
71
-
return rsa.generate_private_key(
72
-
public_exponent=65537,
74
-
backend=default_backend()
77
-
def _get_or_create_account_key(self) -> jose.JWK:
78
-
"""Get existing account key or create a new one."""
79
-
if self.account_key_path.exists():
80
-
with open(self.account_key_path, 'rb') as f:
82
-
private_key = serialization.load_pem_private_key(
83
-
key_data, password=None, backend=default_backend()
86
-
private_key = self._generate_private_key()
87
-
key_pem = private_key.private_bytes(
88
-
encoding=serialization.Encoding.PEM,
89
-
format=serialization.PrivateFormat.TraditionalOpenSSL,
90
-
encryption_algorithm=serialization.NoEncryption()
92
-
with open(self.account_key_path, 'wb') as f:
94
-
logger.info("Created new account key", path=str(self.account_key_path))
96
-
return jose.JWK.load(private_key.private_bytes(
97
-
encoding=serialization.Encoding.PEM,
98
-
format=serialization.PrivateFormat.TraditionalOpenSSL,
99
-
encryption_algorithm=serialization.NoEncryption()
102
-
def _create_csr(self, private_key: rsa.RSAPrivateKey) -> bytes:
103
-
"""Create a Certificate Signing Request."""
104
-
csr = x509.CertificateSigningRequestBuilder().subject_name(
106
-
x509.NameAttribute(NameOID.COMMON_NAME, self.domain),
108
-
).sign(private_key, hashes.SHA256(), backend=default_backend())
110
-
return csr.public_bytes(serialization.Encoding.DER)
112
-
def _start_challenge_server(self):
113
-
"""Start HTTP server for ACME challenges."""
116
-
@app.get("/.well-known/acme-challenge/{token}")
117
-
async def acme_challenge(token: str):
118
-
"""Serve ACME challenge responses."""
119
-
if token in self.challenge_tokens:
120
-
logger.info("Serving ACME challenge", token=token)
121
-
return PlainTextResponse(self.challenge_tokens[token])
122
-
logger.warning("Unknown ACME challenge token", token=token)
123
-
return PlainTextResponse("Not found", status_code=404)
126
-
"""Run the challenge server in a thread."""
131
-
port=self.challenge_port,
134
-
except Exception as e:
135
-
logger.error("Challenge server error", error=str(e))
137
-
self.challenge_thread = threading.Thread(target=run_server, daemon=True)
138
-
self.challenge_thread.start()
140
-
# Give the server time to start
142
-
logger.info("Started ACME challenge server", port=self.challenge_port)
144
-
def _stop_challenge_server(self):
145
-
"""Stop the challenge server."""
146
-
if self.challenge_thread and self.challenge_thread.is_alive():
147
-
# The thread is daemon, so it will stop when the main process exits
148
-
logger.info("Challenge server will stop with main process")
150
-
def _perform_http01_challenge(
152
-
acme_client: client.ClientV2,
153
-
authz: messages.Authorization
155
-
"""Perform HTTP-01 challenge."""
156
-
# Find HTTP-01 challenge
157
-
http_challenge = None
158
-
for challenge in authz.body.challenges:
159
-
if isinstance(challenge.chall, challenges.HTTP01):
160
-
http_challenge = challenge
163
-
if not http_challenge:
164
-
logger.error("No HTTP-01 challenge found")
167
-
# Prepare challenge response
168
-
response, validation = http_challenge.chall.response_and_validation(
169
-
acme_client.net.key
172
-
# Store challenge token and response
173
-
self.challenge_tokens[http_challenge.chall.token.decode('utf-8')] = validation
176
-
"Prepared HTTP-01 challenge",
177
-
token=http_challenge.chall.token.decode('utf-8'),
181
-
# Notify ACME server that we're ready
182
-
acme_client.answer_challenge(http_challenge, response)
184
-
# Wait for challenge validation
186
-
for attempt in range(max_attempts):
189
-
authz, _ = acme_client.poll(authz)
190
-
if authz.body.status == messages.STATUS_VALID:
191
-
logger.info("Challenge validated successfully")
193
-
elif authz.body.status == messages.STATUS_INVALID:
194
-
logger.error("Challenge validation failed")
196
-
except errors.TimeoutError:
197
-
if attempt == max_attempts - 1:
198
-
logger.error("Challenge validation timeout")
204
-
def needs_renewal(self) -> bool:
205
-
"""Check if certificate needs renewal."""
206
-
if not self.cert_path.exists():
210
-
with open(self.cert_path, 'rb') as f:
211
-
cert_data = f.read()
212
-
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
214
-
# Renew if less than 30 days remaining
215
-
days_remaining = (cert.not_valid_after_utc -
216
-
datetime.now(timezone.utc)).days
218
-
if days_remaining < 30:
219
-
logger.info("Certificate needs renewal", days_remaining=days_remaining)
222
-
logger.info("Certificate still valid", days_remaining=days_remaining)
225
-
except Exception as e:
226
-
logger.error("Error checking certificate", error=str(e))
229
-
def obtain_certificate(self) -> Tuple[Path, Path, Path]:
230
-
"""Obtain or renew SSL certificate.
233
-
Tuple of (cert_path, key_path, fullchain_path)
235
-
if not self.needs_renewal():
236
-
logger.info("Certificate is still valid, skipping renewal")
237
-
return self.cert_path, self.key_path, self.fullchain_path
240
-
"Obtaining SSL certificate",
241
-
domain=self.domain,
242
-
staging=self.staging
246
-
# Start challenge server
247
-
self._start_challenge_server()
249
-
# Get or create account key
250
-
account_key = self._get_or_create_account_key()
252
-
# Create ACME client
253
-
net = client.ClientNetwork(account_key)
254
-
directory = messages.Directory.from_json(
255
-
net.get(self.directory_url).json()
257
-
acme_client = client.ClientV2(directory, net=net)
259
-
# Register or get existing account
261
-
account = acme_client.new_account(
262
-
messages.NewRegistration.from_data(
264
-
terms_of_service_agreed=True
267
-
logger.info("Created new ACME account")
268
-
except errors.ConflictError:
269
-
# Account already exists
270
-
account = acme_client.query_registration(
271
-
messages.Registration(key=account_key.public_key())
273
-
logger.info("Using existing ACME account")
275
-
# Generate certificate private key
276
-
cert_key = self._generate_private_key()
279
-
csr = self._create_csr(cert_key)
281
-
# Request certificate
282
-
order = acme_client.new_order(csr)
284
-
# Complete challenges
285
-
for authz in order.authorizations:
286
-
if not self._perform_http01_challenge(acme_client, authz):
287
-
raise Exception(f"Failed to complete challenge for {authz.body.identifier.value}")
290
-
order = acme_client.poll_and_finalize(order)
292
-
if order.fullchain_pem:
293
-
# Save certificate and key
294
-
with open(self.cert_path, 'w') as f:
295
-
f.write(order.fullchain_pem.split('\n\n')[0] + '\n')
297
-
with open(self.fullchain_path, 'w') as f:
298
-
f.write(order.fullchain_pem)
300
-
key_pem = cert_key.private_bytes(
301
-
encoding=serialization.Encoding.PEM,
302
-
format=serialization.PrivateFormat.TraditionalOpenSSL,
303
-
encryption_algorithm=serialization.NoEncryption()
306
-
with open(self.key_path, 'w') as f:
309
-
# Set proper permissions
310
-
os.chmod(self.key_path, 0o600)
311
-
os.chmod(self.cert_path, 0o644)
312
-
os.chmod(self.fullchain_path, 0o644)
315
-
"Certificate obtained successfully",
316
-
cert_path=str(self.cert_path),
317
-
key_path=str(self.key_path),
318
-
fullchain_path=str(self.fullchain_path)
321
-
return self.cert_path, self.key_path, self.fullchain_path
323
-
raise Exception("Failed to obtain certificate")
325
-
except Exception as e:
326
-
logger.error("Failed to obtain certificate", error=str(e))
329
-
# Clean up challenge tokens and stop server
330
-
self.challenge_tokens.clear()
331
-
self._stop_challenge_server()
333
-
def setup_auto_renewal(self, check_interval: int = 86400):
334
-
"""Setup automatic certificate renewal.
337
-
check_interval: Interval in seconds to check for renewal (default: 24 hours)
339
-
def renewal_loop():
340
-
"""Background renewal loop."""
343
-
if self.needs_renewal():
344
-
logger.info("Certificate renewal needed")
345
-
self.obtain_certificate()
347
-
logger.debug("Certificate renewal not needed")
348
-
except Exception as e:
349
-
logger.error("Certificate renewal check failed", error=str(e))
351
-
time.sleep(check_interval)
353
-
renewal_thread = threading.Thread(target=renewal_loop, daemon=True)
354
-
renewal_thread.start()
355
-
logger.info("Started automatic certificate renewal", interval_hours=check_interval/3600)