Files
amnezia/managers/cloudflare_ssl.py
test2andCursor 7ef408afe7 Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:41:50 +03:00

412 lines
15 KiB
Python

"""
Cloudflare DNS-01 ACME client for issuing the panel's own Let's Encrypt
certificate WITHOUT binding port 80/443.
Why not certbot: the panel ships as a single PyInstaller binary, so we implement
a minimal ACME v2 (RFC 8555) flow using `cryptography` + `httpx` only, both of
which are already dependencies. Validation is done via a Cloudflare DNS TXT
record (dns-01), which never touches the HTTP port — so the panel can keep
running on any port while the reverse proxy owns 443.
Public entry points:
issue_certificate(domain, cf_token, email, ...) -> {cert_pem, key_pem, ...}
get_cert_expiry(cert_pem) -> datetime | None
"""
import base64
import hashlib
import json
import logging
import time
from datetime import datetime, timezone
import httpx
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.x509.oid import NameOID
logger = logging.getLogger(__name__)
# Let's Encrypt production directory. Staging is available for testing but its
# certs are not browser-trusted, so we default to production.
ACME_DIRECTORY_PROD = "https://acme-v02.api.letsencrypt.org/directory"
ACME_DIRECTORY_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
CF_API_BASE = "https://api.cloudflare.com/client/v4"
USER_AGENT = "amnezia-panel-acme/1.0"
def _b64(data: bytes) -> str:
"""base64url without padding, per JWS spec."""
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
class CloudflareError(Exception):
pass
class ACMEError(Exception):
pass
# ============================================================
# Cloudflare DNS API
# ============================================================
class CloudflareDNS:
def __init__(self, token: str, timeout: float = 30.0):
self.token = token.strip()
self._client = httpx.Client(
base_url=CF_API_BASE,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
timeout=timeout,
)
def close(self):
self._client.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _check(self, resp: httpx.Response) -> dict:
try:
body = resp.json()
except Exception:
raise CloudflareError(f"Cloudflare returned non-JSON ({resp.status_code}): {resp.text[:200]}")
if not body.get("success", False):
errors = body.get("errors", [])
msg = "; ".join(e.get("message", str(e)) for e in errors) or resp.text[:200]
raise CloudflareError(f"Cloudflare API error: {msg}")
return body
def verify_token(self) -> bool:
resp = self._client.get("/user/tokens/verify")
self._check(resp)
return True
def find_zone_id(self, domain: str) -> str:
"""Resolve the zone that owns `domain`. Tries the domain and each parent
(sub.example.com -> example.com) so subdomains resolve to the apex zone."""
parts = domain.split(".")
candidates = [".".join(parts[i:]) for i in range(len(parts) - 1)]
for zone_name in candidates:
resp = self._client.get("/zones", params={"name": zone_name})
body = self._check(resp)
results = body.get("result", [])
if results:
return results[0]["id"]
raise CloudflareError(
f"No Cloudflare zone found for '{domain}'. Ensure the domain is added to this Cloudflare account and the token has Zone:Read."
)
def create_txt_record(self, zone_id: str, name: str, content: str) -> str:
resp = self._client.post(
f"/zones/{zone_id}/dns_records",
json={"type": "TXT", "name": name, "content": content, "ttl": 60},
)
body = self._check(resp)
return body["result"]["id"]
def delete_record(self, zone_id: str, record_id: str):
try:
self._client.delete(f"/zones/{zone_id}/dns_records/{record_id}")
except Exception as e:
logger.warning("Failed to delete Cloudflare TXT record %s: %s", record_id, e)
# ============================================================
# Minimal ACME v2 client (RFC 8555) — dns-01 only
# ============================================================
class ACMEClient:
def __init__(self, directory_url: str = ACME_DIRECTORY_PROD, timeout: float = 60.0):
self.directory_url = directory_url
self._client = httpx.Client(
headers={"User-Agent": USER_AGENT, "Content-Type": "application/jose+json"},
timeout=timeout,
)
self._directory = None
self._nonce = None
self._account_key = None
self._kid = None
def close(self):
self._client.close()
# ---- account key (RSA 2048) ----
def _gen_account_key(self):
self._account_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _jwk(self) -> dict:
pub = self._account_key.public_key().public_numbers()
e = pub.e.to_bytes((pub.e.bit_length() + 7) // 8, "big")
n = pub.n.to_bytes((pub.n.bit_length() + 7) // 8, "big")
return {"kty": "RSA", "e": _b64(e), "n": _b64(n)}
def _thumbprint(self) -> str:
jwk = self._jwk()
canonical = json.dumps({"e": jwk["e"], "kty": jwk["kty"], "n": jwk["n"]}, separators=(",", ":"), sort_keys=True)
return _b64(hashlib.sha256(canonical.encode()).digest())
# ---- low-level JWS transport ----
def _load_directory(self):
resp = self._client.get(self.directory_url)
resp.raise_for_status()
self._directory = resp.json()
def _get_nonce(self) -> str:
if self._nonce:
n, self._nonce = self._nonce, None
return n
resp = self._client.head(self._directory["newNonce"])
return resp.headers["Replay-Nonce"]
def _signed_request(self, url: str, payload, use_jwk: bool = False):
protected = {"alg": "RS256", "nonce": self._get_nonce(), "url": url}
if use_jwk:
protected["jwk"] = self._jwk()
else:
protected["kid"] = self._kid
protected_b64 = _b64(json.dumps(protected, separators=(",", ":")).encode())
if payload == "":
payload_b64 = "" # POST-as-GET
else:
payload_b64 = _b64(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{protected_b64}.{payload_b64}".encode()
signature = self._account_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
jose = {"protected": protected_b64, "payload": payload_b64, "signature": _b64(signature)}
resp = self._client.post(url, content=json.dumps(jose))
if "Replay-Nonce" in resp.headers:
self._nonce = resp.headers["Replay-Nonce"]
if resp.status_code >= 400:
try:
err = resp.json()
raise ACMEError(f"ACME error {resp.status_code}: {err.get('detail', resp.text)}")
except (ValueError, KeyError):
raise ACMEError(f"ACME error {resp.status_code}: {resp.text[:300]}")
return resp
# ---- ACME flow ----
def register_account(self, email: str):
payload = {"termsOfServiceAgreed": True}
if email:
payload["contact"] = [f"mailto:{email}"]
resp = self._signed_request(self._directory["newAccount"], payload, use_jwk=True)
self._kid = resp.headers["Location"]
def new_order(self, domains: list) -> dict:
payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
resp = self._signed_request(self._directory["newOrder"], payload)
order = resp.json()
order["_url"] = resp.headers["Location"]
return order
def get_authorization(self, auth_url: str) -> dict:
resp = self._signed_request(auth_url, "")
return resp.json()
def dns_challenge_value(self, token: str) -> str:
key_auth = f"{token}.{self._thumbprint()}"
return _b64(hashlib.sha256(key_auth.encode()).digest())
def trigger_challenge(self, challenge_url: str):
self._signed_request(challenge_url, {})
def poll_authorization(self, auth_url: str, attempts: int = 30, delay: float = 5.0) -> str:
for _ in range(attempts):
auth = self.get_authorization(auth_url)
status = auth.get("status")
if status == "valid":
return "valid"
if status == "invalid":
challenges = auth.get("challenges", [])
detail = ""
for c in challenges:
if c.get("error"):
detail = c["error"].get("detail", "")
raise ACMEError(f"DNS validation failed: {detail or 'authorization invalid'}")
time.sleep(delay)
raise ACMEError("Timed out waiting for DNS validation")
def finalize_order(self, finalize_url: str, csr_der: bytes) -> dict:
resp = self._signed_request(finalize_url, {"csr": _b64(csr_der)})
return resp.json()
def poll_order(self, order_url: str, attempts: int = 20, delay: float = 3.0) -> dict:
for _ in range(attempts):
resp = self._signed_request(order_url, "")
order = resp.json()
if order.get("status") == "valid":
return order
if order.get("status") == "invalid":
raise ACMEError("Order became invalid during finalization")
time.sleep(delay)
raise ACMEError("Timed out waiting for certificate issuance")
def download_certificate(self, cert_url: str) -> str:
resp = self._signed_request(cert_url, "")
return resp.text
def _build_csr(domains: list):
"""Generate an EC P-256 leaf key + CSR for the given domains."""
key = ec.generate_private_key(ec.SECP256R1())
builder = x509.CertificateSigningRequestBuilder().subject_name(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domains[0])])
)
san = x509.SubjectAlternativeName([x509.DNSName(d) for d in domains])
builder = builder.add_extension(san, critical=False)
csr = builder.sign(key, hashes.SHA256())
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode()
return csr.public_bytes(serialization.Encoding.DER), key_pem
def issue_certificate(
domain: str,
cf_token: str,
email: str = "",
staging: bool = False,
dns_propagation_wait: int = 20,
progress=None,
) -> dict:
"""
Issue a Let's Encrypt certificate for `domain` using Cloudflare dns-01.
Returns dict: {cert_pem, key_pem, domain, expires_at (iso), issued_at (iso)}
Raises CloudflareError / ACMEError on failure.
"""
def log(msg):
logger.info(msg)
if progress:
try:
progress(msg)
except Exception:
pass
domain = domain.strip().lower().rstrip(".")
if not domain:
raise ACMEError("Domain is required")
directory = ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD
cf = CloudflareDNS(cf_token)
acme = ACMEClient(directory)
txt_record_id = None
zone_id = None
try:
log("Verifying Cloudflare API token...")
cf.verify_token()
zone_id = cf.find_zone_id(domain)
log(f"Cloudflare zone resolved for {domain}")
log("Setting up ACME account...")
acme._gen_account_key()
acme._load_directory()
acme.register_account(email)
log("Creating certificate order...")
order = acme.new_order([domain])
for auth_url in order["authorizations"]:
auth = acme.get_authorization(auth_url)
identifier = auth["identifier"]["value"]
dns_challenge = next(
(c for c in auth["challenges"] if c["type"] == "dns-01"), None
)
if not dns_challenge:
raise ACMEError("No dns-01 challenge offered by ACME server")
txt_value = acme.dns_challenge_value(dns_challenge["token"])
record_name = f"_acme-challenge.{identifier}"
log(f"Creating DNS TXT record {record_name} ...")
txt_record_id = cf.create_txt_record(zone_id, record_name, txt_value)
log(f"Waiting {dns_propagation_wait}s for DNS propagation...")
time.sleep(dns_propagation_wait)
log("Asking Let's Encrypt to validate...")
acme.trigger_challenge(dns_challenge["url"])
acme.poll_authorization(auth_url)
log("Domain validated ✓")
if txt_record_id:
cf.delete_record(zone_id, txt_record_id)
txt_record_id = None
log("Generating key and CSR...")
csr_der, key_pem = _build_csr([domain])
log("Finalizing order...")
acme.finalize_order(order["finalize"], csr_der)
final_order = acme.poll_order(order["_url"])
log("Downloading certificate...")
cert_pem = acme.download_certificate(final_order["certificate"])
expires_at = get_cert_expiry(cert_pem)
result = {
"cert_pem": cert_pem,
"key_pem": key_pem,
"domain": domain,
"issued_at": datetime.now(timezone.utc).isoformat(),
"expires_at": expires_at.isoformat() if expires_at else None,
"staging": staging,
}
log("Certificate issued successfully ✓")
return result
finally:
if txt_record_id and zone_id:
cf.delete_record(zone_id, txt_record_id)
cf.close()
acme.close()
def get_cert_expiry(cert_pem: str):
"""Return the notAfter datetime (UTC) of the first cert in the PEM chain."""
try:
cert = x509.load_pem_x509_certificate(cert_pem.encode())
try:
return cert.not_valid_after_utc
except AttributeError:
return cert.not_valid_after.replace(tzinfo=timezone.utc)
except Exception as e:
logger.warning("Could not parse certificate expiry: %s", e)
return None
def needs_renewal(expires_at_iso: str, days_before: int = 30) -> bool:
"""True if the cert expires within `days_before` days (or is unparseable)."""
if not expires_at_iso:
return True
try:
expires = datetime.fromisoformat(expires_at_iso)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
remaining = (expires - datetime.now(timezone.utc)).days
return remaining <= days_before
except Exception:
return True