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>
412 lines
15 KiB
Python
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
|