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>
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user