""" 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