Skip to content

Commit 0476785

Browse files
wouter-bonclaude
andcommitted
feat: add ACME/Let's Encrypt certificate management with Cloudflare DNS
Add automated certificate issuance from Let's Encrypt using Cloudflare DNS-01 challenges, with direct import to FortiGate devices. New tools: - request_certificate: Request Let's Encrypt cert via Cloudflare DNS - request_and_import_certificate: Request and import cert to FortiGate - import_certificate: Import existing certificate to FortiGate - import_ca_certificate: Import CA certificate to FortiGate - list_cloudflare_zones: List available DNS zones - verify_cloudflare_token: Verify Cloudflare API token New modules: - core/acme_client.py: ACME protocol client for Let's Encrypt - core/cloudflare_dns.py: Cloudflare DNS API for DNS-01 challenges - tools/acme.py: MCP tool implementations Also adds ACMEConfig model and certificate import methods to FortiGate API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2a03b21 commit 0476785

10 files changed

Lines changed: 1412 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ dependencies = [
3333
"fastapi>=0.104.0",
3434
"uvicorn[standard]>=0.24.0",
3535
"asgiref>=3.7.0",
36+
"acme>=2.0.0",
37+
"josepy>=1.13.0",
38+
"cryptography>=41.0.0",
39+
"cloudflare>=2.19.0",
3640
]
3741

3842
[project.optional-dependencies]

src/fortigate_mcp/config/models.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,31 @@ class ServerConfig(BaseModel):
7979

8080
class RateLimitConfig(BaseModel):
8181
"""Model for rate limiting configuration.
82-
82+
8383
Defines rate limiting parameters to prevent
8484
API abuse and ensure stable performance.
8585
"""
8686
enabled: bool = Field(default=True, description="Enable rate limiting")
8787
max_requests_per_minute: int = Field(default=60, description="Maximum requests per minute")
8888
burst_size: int = Field(default=10, description="Burst request allowance")
8989

90+
class ACMEConfig(BaseModel):
91+
"""Model for ACME/Let's Encrypt configuration.
92+
93+
Defines configuration for automated certificate issuance
94+
using Let's Encrypt with Cloudflare DNS validation.
95+
"""
96+
enabled: bool = Field(default=False, description="Enable ACME certificate management")
97+
email: Optional[str] = Field(default=None, description="Contact email for Let's Encrypt account")
98+
staging: bool = Field(default=False, description="Use Let's Encrypt staging environment")
99+
account_key_path: Optional[str] = Field(default=None, description="Path to store ACME account key")
100+
cloudflare_api_token: Optional[str] = Field(default=None, description="Cloudflare API token for DNS validation")
101+
default_key_type: str = Field(default="rsa", description="Default key type (rsa or ec)")
102+
default_key_size: int = Field(default=2048, description="Default key size for RSA keys")
103+
90104
class Config(BaseModel):
91105
"""Root configuration model.
92-
106+
93107
Combines all configuration models into a single validated
94108
configuration object. Provides the complete server configuration.
95109
"""
@@ -98,6 +112,7 @@ class Config(BaseModel):
98112
auth: AuthConfig = Field(default_factory=AuthConfig)
99113
logging: LoggingConfig = Field(default_factory=LoggingConfig)
100114
rate_limiting: RateLimitConfig = Field(default_factory=RateLimitConfig)
115+
acme: ACMEConfig = Field(default_factory=ACMEConfig)
101116

102117
# Parameter models for tool validation
103118
class DeviceCommandParams(BaseModel):
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""ACME client for Let's Encrypt certificate management."""
2+
import os
3+
import time
4+
from typing import Optional, Tuple, Callable
5+
from datetime import datetime, timedelta
6+
from cryptography import x509
7+
from cryptography.x509.oid import NameOID
8+
from cryptography.hazmat.primitives import hashes, serialization
9+
from cryptography.hazmat.primitives.asymmetric import rsa, ec
10+
from cryptography.hazmat.backends import default_backend
11+
12+
import josepy as jose
13+
from acme import challenges, client, messages, crypto_util
14+
15+
from .logging import get_logger
16+
17+
18+
class ACMEClient:
19+
"""ACME client for requesting Let's Encrypt certificates."""
20+
21+
# Let's Encrypt directories
22+
LETSENCRYPT_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
23+
LETSENCRYPT_PRODUCTION = "https://acme-v02.api.letsencrypt.org/directory"
24+
25+
def __init__(
26+
self,
27+
email: str,
28+
staging: bool = False,
29+
account_key_path: Optional[str] = None
30+
):
31+
"""Initialize ACME client.
32+
33+
Args:
34+
email: Contact email for Let's Encrypt account
35+
staging: Use staging environment (for testing)
36+
account_key_path: Path to store/load account key
37+
"""
38+
self.email = email
39+
self.staging = staging
40+
self.directory_url = self.LETSENCRYPT_STAGING if staging else self.LETSENCRYPT_PRODUCTION
41+
self.account_key_path = account_key_path
42+
self.logger = get_logger("acme_client")
43+
44+
self._account_key = None
45+
self._client = None
46+
self._registration = None
47+
48+
def _get_or_create_account_key(self) -> jose.JWKRSA:
49+
"""Get existing account key or create new one."""
50+
if self._account_key:
51+
return self._account_key
52+
53+
if self.account_key_path and os.path.exists(self.account_key_path):
54+
self.logger.info(f"Loading account key from {self.account_key_path}")
55+
with open(self.account_key_path, "rb") as f:
56+
key_pem = f.read()
57+
private_key = serialization.load_pem_private_key(
58+
key_pem, password=None, backend=default_backend()
59+
)
60+
self._account_key = jose.JWKRSA(key=private_key)
61+
else:
62+
self.logger.info("Generating new account key")
63+
private_key = rsa.generate_private_key(
64+
public_exponent=65537,
65+
key_size=2048,
66+
backend=default_backend()
67+
)
68+
self._account_key = jose.JWKRSA(key=private_key)
69+
70+
if self.account_key_path:
71+
key_pem = private_key.private_bytes(
72+
encoding=serialization.Encoding.PEM,
73+
format=serialization.PrivateFormat.PKCS8,
74+
encryption_algorithm=serialization.NoEncryption()
75+
)
76+
os.makedirs(os.path.dirname(self.account_key_path), exist_ok=True)
77+
with open(self.account_key_path, "wb") as f:
78+
f.write(key_pem)
79+
self.logger.info(f"Saved account key to {self.account_key_path}")
80+
81+
return self._account_key
82+
83+
def _get_client(self) -> client.ClientV2:
84+
"""Get or create ACME client."""
85+
if self._client:
86+
return self._client
87+
88+
account_key = self._get_or_create_account_key()
89+
90+
# Create network client
91+
net = client.ClientNetwork(account_key, user_agent="fortigate-mcp-server/1.0")
92+
directory = messages.Directory.from_json(net.get(self.directory_url).json())
93+
self._client = client.ClientV2(directory, net=net)
94+
95+
return self._client
96+
97+
def register_account(self) -> messages.RegistrationResource:
98+
"""Register or retrieve existing ACME account."""
99+
if self._registration:
100+
return self._registration
101+
102+
acme_client = self._get_client()
103+
104+
self.logger.info(f"Registering ACME account for {self.email}")
105+
106+
try:
107+
# Try to create new registration
108+
registration = acme_client.new_account(
109+
messages.NewRegistration.from_data(
110+
email=self.email,
111+
terms_of_service_agreed=True
112+
)
113+
)
114+
self.logger.info("Created new ACME account")
115+
except Exception as e:
116+
# Account might already exist
117+
self.logger.info(f"Account may already exist: {e}")
118+
registration = acme_client.new_account(
119+
messages.NewRegistration.from_data(
120+
email=self.email,
121+
terms_of_service_agreed=True,
122+
only_return_existing=True
123+
)
124+
)
125+
self.logger.info("Retrieved existing ACME account")
126+
127+
self._registration = registration
128+
return registration
129+
130+
def generate_csr(
131+
self,
132+
domains: list[str],
133+
key_type: str = "rsa",
134+
key_size: int = 2048
135+
) -> Tuple[bytes, bytes]:
136+
"""Generate a Certificate Signing Request.
137+
138+
Args:
139+
domains: List of domain names (first is CN, rest are SANs)
140+
key_type: Key type ('rsa' or 'ec')
141+
key_size: Key size for RSA (2048, 4096) or curve for EC
142+
143+
Returns:
144+
Tuple of (private_key_pem, csr_pem)
145+
"""
146+
self.logger.info(f"Generating CSR for domains: {domains}")
147+
148+
# Generate private key
149+
if key_type.lower() == "rsa":
150+
private_key = rsa.generate_private_key(
151+
public_exponent=65537,
152+
key_size=key_size,
153+
backend=default_backend()
154+
)
155+
elif key_type.lower() == "ec":
156+
private_key = ec.generate_private_key(
157+
ec.SECP256R1(),
158+
backend=default_backend()
159+
)
160+
else:
161+
raise ValueError(f"Unsupported key type: {key_type}")
162+
163+
# Build CSR
164+
csr_builder = x509.CertificateSigningRequestBuilder()
165+
csr_builder = csr_builder.subject_name(x509.Name([
166+
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
167+
]))
168+
169+
# Add all domains as SANs
170+
san_list = [x509.DNSName(domain) for domain in domains]
171+
csr_builder = csr_builder.add_extension(
172+
x509.SubjectAlternativeName(san_list),
173+
critical=False
174+
)
175+
176+
# Sign CSR
177+
csr = csr_builder.sign(private_key, hashes.SHA256(), default_backend())
178+
179+
# Export to PEM
180+
private_key_pem = private_key.private_bytes(
181+
encoding=serialization.Encoding.PEM,
182+
format=serialization.PrivateFormat.PKCS8,
183+
encryption_algorithm=serialization.NoEncryption()
184+
)
185+
186+
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
187+
188+
return private_key_pem, csr_pem
189+
190+
def request_certificate(
191+
self,
192+
domains: list[str],
193+
dns_challenge_handler: Callable[[str, str, str], None],
194+
dns_cleanup_handler: Callable[[str, str], None],
195+
key_type: str = "rsa",
196+
key_size: int = 2048,
197+
timeout: int = 300
198+
) -> Tuple[bytes, bytes, bytes]:
199+
"""Request a certificate using DNS-01 challenge.
200+
201+
Args:
202+
domains: List of domain names
203+
dns_challenge_handler: Callback to create DNS TXT record
204+
(domain, record_name, record_value) -> None
205+
dns_cleanup_handler: Callback to remove DNS TXT record
206+
(domain, record_name) -> None
207+
key_type: Key type ('rsa' or 'ec')
208+
key_size: Key size
209+
timeout: Timeout for challenge validation in seconds
210+
211+
Returns:
212+
Tuple of (private_key_pem, certificate_pem, chain_pem)
213+
"""
214+
self.logger.info(f"Requesting certificate for: {domains}")
215+
216+
# Ensure account is registered
217+
self.register_account()
218+
219+
acme_client = self._get_client()
220+
221+
# Generate CSR
222+
private_key_pem, csr_pem = self.generate_csr(domains, key_type, key_size)
223+
224+
# Request new order
225+
csr = crypto_util.load_pem_private_key(private_key_pem)
226+
order = acme_client.new_order(csr_pem)
227+
228+
self.logger.info(f"Created order with {len(order.authorizations)} authorizations")
229+
230+
# Process each authorization
231+
for authz in order.authorizations:
232+
domain = authz.body.identifier.value
233+
self.logger.info(f"Processing authorization for {domain}")
234+
235+
# Find DNS-01 challenge
236+
dns_challenge = None
237+
for challenge in authz.body.challenges:
238+
if isinstance(challenge.chall, challenges.DNS01):
239+
dns_challenge = challenge
240+
break
241+
242+
if not dns_challenge:
243+
raise ValueError(f"No DNS-01 challenge available for {domain}")
244+
245+
# Get challenge response
246+
response, validation = dns_challenge.response_and_validation(
247+
self._get_or_create_account_key()
248+
)
249+
250+
# Create DNS record
251+
record_name = f"_acme-challenge.{domain}"
252+
self.logger.info(f"Creating DNS TXT record: {record_name} = {validation}")
253+
254+
try:
255+
dns_challenge_handler(domain, record_name, validation)
256+
257+
# Wait for DNS propagation
258+
self.logger.info("Waiting for DNS propagation (30s)...")
259+
time.sleep(30)
260+
261+
# Answer challenge
262+
self.logger.info("Answering challenge...")
263+
acme_client.answer_challenge(dns_challenge, response)
264+
265+
# Wait for validation
266+
start_time = time.time()
267+
while time.time() - start_time < timeout:
268+
authz_resource = acme_client.poll(authz)
269+
if authz_resource.body.status == messages.STATUS_VALID:
270+
self.logger.info(f"Authorization valid for {domain}")
271+
break
272+
elif authz_resource.body.status == messages.STATUS_INVALID:
273+
raise ValueError(f"Authorization failed for {domain}")
274+
time.sleep(5)
275+
else:
276+
raise TimeoutError(f"Authorization timeout for {domain}")
277+
278+
finally:
279+
# Cleanup DNS record
280+
self.logger.info(f"Cleaning up DNS record: {record_name}")
281+
dns_cleanup_handler(domain, record_name)
282+
283+
# Finalize order
284+
self.logger.info("Finalizing certificate order...")
285+
order = acme_client.poll_and_finalize(order)
286+
287+
# Extract certificate
288+
cert_pem = order.fullchain_pem.encode()
289+
290+
# Split certificate and chain
291+
certs = cert_pem.split(b"-----END CERTIFICATE-----")
292+
certificate_pem = certs[0] + b"-----END CERTIFICATE-----\n"
293+
chain_pem = b"-----END CERTIFICATE-----".join(certs[1:]).strip()
294+
if chain_pem:
295+
chain_pem = chain_pem + b"\n"
296+
297+
self.logger.info("Certificate obtained successfully!")
298+
299+
return private_key_pem, certificate_pem, chain_pem
300+
301+
def get_certificate_info(self, cert_pem: bytes) -> dict:
302+
"""Parse certificate and return info."""
303+
cert = x509.load_pem_x509_certificate(cert_pem, default_backend())
304+
305+
# Get SANs
306+
try:
307+
san_ext = cert.extensions.get_extension_for_oid(
308+
x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
309+
)
310+
sans = [name.value for name in san_ext.value]
311+
except x509.ExtensionNotFound:
312+
sans = []
313+
314+
return {
315+
"subject": cert.subject.rfc4514_string(),
316+
"issuer": cert.issuer.rfc4514_string(),
317+
"serial_number": str(cert.serial_number),
318+
"not_valid_before": cert.not_valid_before_utc.isoformat(),
319+
"not_valid_after": cert.not_valid_after_utc.isoformat(),
320+
"domains": sans,
321+
"days_remaining": (cert.not_valid_after_utc - datetime.utcnow()).days
322+
}

0 commit comments

Comments
 (0)