"""YakPanel - SSL/Domains API - Let's Encrypt via certbot""" import os import re import shutil import socket import subprocess import sys import tempfile from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel, Field from typing import Optional, Literal from app.core.database import get_db from app.core.config import get_runtime_config from app.core.utils import environment_with_system_path, exec_shell_sync, read_file, nginx_reload_all_known, nginx_binary_candidates from app.api.auth import get_current_user from app.models.user import User from app.models.site import Site, Domain from app.services.site_service import regenerate_site_vhost router = APIRouter(prefix="/ssl", tags=["ssl"]) _CERTBOT_PATH_CANDIDATES = ( "/usr/bin/certbot", "/usr/local/bin/certbot", "/snap/bin/certbot", ) def _certbot_command() -> list[str] | None: """Resolve argv prefix to run certbot: [binary] or [python, -m, certbot].""" env = environment_with_system_path() path_var = env.get("PATH", "") exe = getattr(sys, "executable", None) or "" if exe and os.path.isfile(exe): try: r = subprocess.run( [exe, "-m", "certbot", "--version"], capture_output=True, text=True, timeout=20, env=env, ) if r.returncode == 0: return [exe, "-m", "certbot"] except (FileNotFoundError, OSError, subprocess.TimeoutExpired): pass tried: list[str] = [] w = shutil.which("certbot", path=path_var) if w and os.path.isfile(w): tried.append(w) for p in _CERTBOT_PATH_CANDIDATES: if p not in tried and os.path.isfile(p): tried.append(p) for exe in tried: try: r = subprocess.run( [exe, "--version"], capture_output=True, text=True, timeout=15, env=env, ) if r.returncode == 0: return [exe] except (FileNotFoundError, OSError, subprocess.TimeoutExpired): continue for py_name in ("python3", "python"): py = shutil.which(py_name, path=path_var) if not py or not os.path.isfile(py): continue try: r = subprocess.run( [py, "-m", "certbot", "--version"], capture_output=True, text=True, timeout=20, env=env, ) if r.returncode == 0: return [py, "-m", "certbot"] except (FileNotFoundError, OSError, subprocess.TimeoutExpired): continue return None def _certbot_missing_message() -> str: return ( "certbot is not installed or not reachable from the panel process. " "On the server, run one of: apt install certbot | dnf install certbot | yum install certbot | snap install certbot. " "Alternatively: pip install certbot (panel can use python3 -m certbot). " "If certbot is already installed, ensure /usr/bin is on PATH for the YakPanel service." ) async def _le_hostnames_for_domain_row(db: AsyncSession, dom_row: Optional[Domain], primary: str) -> list[str]: """All distinct hostnames for the site (for -d flags). Falls back to primary.""" if not dom_row: return [primary] if primary else [] result = await db.execute(select(Domain).where(Domain.pid == dom_row.pid).order_by(Domain.id)) rows = result.scalars().all() seen: set[str] = set() out: list[str] = [] for d in rows: n = (d.name or "").strip() if not n: continue key = n.lower() if key not in seen: seen.add(key) out.append(n) if primary and primary.lower() not in seen: out.insert(0, primary) return out if out else ([primary] if primary else []) def _reload_panel_and_common_nginx() -> tuple[bool, str]: """Reload nginx so new vhost (ACME path) is live before certbot HTTP-01.""" return nginx_reload_all_known(timeout=60) def _localhost_accepts_tcp(port: int, timeout: float = 2.0) -> bool: """True if something accepts a TCP connection on this machine (checks IPv4 loopback).""" try: with socket.create_connection(("127.0.0.1", port), timeout=timeout): return True except OSError: return False def _ss_reports_listen_443() -> bool | None: """Parse ss/netstat output; None if the probe could not run.""" out, _ = exec_shell_sync("ss -tln 2>/dev/null || netstat -tln 2>/dev/null", timeout=5) if not out or not out.strip(): return None return bool(re.search(r":443\b", out)) @router.get("/domains") async def ssl_domains( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List all domains from sites with site path for certbot webroot""" result = await db.execute( select(Domain, Site).join(Site, Domain.pid == Site.id).order_by(Domain.name) ) rows = result.all() return [ { "id": d.id, "name": d.name, "port": d.port, "site_id": s.id, "site_name": s.name, "site_path": s.path, } for d, s in rows ] class RequestCertRequest(BaseModel): domain: str webroot: str email: str class SiteSslApplyRequest(BaseModel): """Apply Let's Encrypt for one site; domains must belong to that site.""" site_id: int domains: list[str] = Field(..., min_length=1) method: Literal["file", "dns_cloudflare"] email: str api_token: str = "" def _normalize_site_ssl_domains(raw_list: list[str], dom_rows: list[Domain]) -> tuple[list[str], str | None]: """ Map requested names to DB hostnames for this site. Returns (canonical_hostnames, error_message). """ if not dom_rows: return [], "Site has no domains configured" name_map: dict[str, str] = {} for d in dom_rows: n = (d.name or "").strip() if n: name_map[n.lower()] = n seen: set[str] = set() out: list[str] = [] for raw in raw_list: key = (raw or "").split(":")[0].strip().lower() if not key or ".." in key: continue canon = name_map.get(key) if not canon: continue lk = canon.lower() if lk not in seen: seen.add(lk) out.append(canon) if not out: return [], "Select at least one valid domain name for this site" return out, None @router.post("/site-apply") async def ssl_site_apply( body: SiteSslApplyRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Per-site SSL: choose subset of site domains and file (HTTP-01) or Cloudflare DNS-01 validation. """ site_result = await db.execute(select(Site).where(Site.id == body.site_id)) site = site_result.scalar_one_or_none() if not site: raise HTTPException(status_code=404, detail="Site not found") dom_result = await db.execute(select(Domain).where(Domain.pid == site.id).order_by(Domain.id)) dom_rows = list(dom_result.scalars().all()) hostnames, err = _normalize_site_ssl_domains(body.domains, dom_rows) if err: raise HTTPException(status_code=400, detail=err) email = (body.email or "").strip() if not email: raise HTTPException(status_code=400, detail="Email is required") dom_row = dom_rows[0] regen_pre = await regenerate_site_vhost(db, dom_row.pid) if not regen_pre.get("status"): raise HTTPException( status_code=500, detail="Cannot refresh nginx vhost before certificate: " + str(regen_pre.get("msg", "")), ) ok_ngx, err_ngx = _reload_panel_and_common_nginx() if not ok_ngx: raise HTTPException( status_code=500, detail="Nginx test/reload failed (fix config, then retry): " + err_ngx, ) prefix = _certbot_command() if not prefix: raise HTTPException(status_code=500, detail=_certbot_missing_message()) if body.method == "file": cfg = get_runtime_config() allowed = [os.path.abspath(cfg["www_root"]), os.path.abspath(cfg["setup_path"])] webroot_abs = os.path.abspath((site.path or "").strip() or ".") if ".." in (site.path or ""): raise HTTPException(status_code=400, detail="Invalid site path") if not any(webroot_abs.startswith(a + os.sep) or webroot_abs == a for a in allowed): raise HTTPException(status_code=400, detail="Site path must be under www_root or setup_path") webroot_norm = webroot_abs.rstrip(os.sep) challenge_dir = os.path.join(webroot_norm, ".well-known", "acme-challenge") try: os.makedirs(challenge_dir, mode=0o755, exist_ok=True) except OSError as e: raise HTTPException(status_code=500, detail=f"Cannot create ACME webroot directory: {e}") from e base_flags = ["--non-interactive", "--agree-tos", "--email", email, "--no-eff-email"] cmd_webroot = prefix + ["certonly", "--webroot", "-w", webroot_norm, *base_flags] for h in hostnames: cmd_webroot.extend(["-d", h]) cmd_webroot.extend(["--preferred-challenges", "http"]) cmd_nginx = prefix + ["certonly", "--nginx", *base_flags] for h in hostnames: cmd_nginx.extend(["-d", h]) env = environment_with_system_path() proc: subprocess.CompletedProcess[str] | None = None last_err = "" for cmd, label in ((cmd_webroot, "webroot"), (cmd_nginx, "nginx")): try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) except FileNotFoundError: raise HTTPException(status_code=500, detail=_certbot_missing_message()) from None except subprocess.TimeoutExpired: raise HTTPException(status_code=500, detail="certbot timed out (300s)") from None if proc.returncode == 0: break chunk = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}" last_err = f"[{label}] {chunk}" if proc is None or proc.returncode != 0: msg = last_err or "certbot failed" hint = ( " Check DNS A/AAAA for every selected name points here; port 80 must reach nginx for this site. " "CDN or redirects block file validation — use DNS verification instead." ) raise HTTPException(status_code=500, detail=(msg + hint)[:8000]) regen = await regenerate_site_vhost(db, site.id) if not regen.get("status"): return { "status": True, "msg": "Certificate issued but nginx vhost update failed: " + str(regen.get("msg", "")), "output": (proc.stdout or "")[-2000:], } return { "status": True, "msg": "Certificate issued and nginx updated", "output": (proc.stdout or "")[-2000:], } # dns_cloudflare token = (body.api_token or "").strip() if not token: raise HTTPException(status_code=400, detail="Cloudflare API token required for DNS verification") cred_lines = f"dns_cloudflare_api_token = {token}\n" fd, cred_path = tempfile.mkstemp(suffix=".ini", prefix="yakpanel_cf_") try: os.write(fd, cred_lines.encode()) os.close(fd) os.chmod(cred_path, 0o600) except OSError as e: try: os.close(fd) except OSError: pass raise HTTPException(status_code=500, detail=f"Cannot write credentials temp file: {e}") from e base_flags = [ "--non-interactive", "--agree-tos", "--email", email, "--no-eff-email", "--dns-cloudflare", "--dns-cloudflare-credentials", cred_path, ] cmd = prefix + ["certonly"] + base_flags for h in hostnames: cmd.extend(["-d", h]) env = environment_with_system_path() try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env) except (FileNotFoundError, subprocess.TimeoutExpired) as e: try: os.unlink(cred_path) except OSError: pass raise HTTPException(status_code=500, detail=str(e)) from e finally: try: os.unlink(cred_path) except OSError: pass if proc.returncode != 0: err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}" raise HTTPException( status_code=500, detail="certbot DNS failed (install certbot-dns-cloudflare if missing). " + err[:6000], ) regen = await regenerate_site_vhost(db, site.id) if not regen.get("status"): return { "status": True, "msg": "Certificate issued but vhost regen failed: " + str(regen.get("msg", "")), "output": (proc.stdout or "")[-2000:], } return { "status": True, "msg": "Certificate issued via Cloudflare DNS-01", "output": (proc.stdout or "")[-2000:], } @router.post("/request") async def ssl_request_cert( body: RequestCertRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Request Let's Encrypt certificate via certbot (webroot challenge).""" if not body.domain or not body.webroot or not body.email: raise HTTPException(status_code=400, detail="domain, webroot and email required") if ".." in body.domain or ".." in body.webroot: raise HTTPException(status_code=400, detail="Invalid path") cfg = get_runtime_config() allowed = [os.path.abspath(cfg["www_root"]), os.path.abspath(cfg["setup_path"])] webroot_abs = os.path.abspath(body.webroot) if not any(webroot_abs.startswith(a + os.sep) or webroot_abs == a for a in allowed): raise HTTPException(status_code=400, detail="Webroot must be under www_root or setup_path") dom = body.domain.split(":")[0].strip() webroot_norm = webroot_abs.rstrip(os.sep) result_dom = await db.execute(select(Domain).where(Domain.name == dom).limit(1)) dom_row = result_dom.scalar_one_or_none() if dom_row: regen_pre = await regenerate_site_vhost(db, dom_row.pid) if not regen_pre.get("status"): raise HTTPException( status_code=500, detail="Cannot refresh nginx vhost before certificate request: " + str(regen_pre.get("msg", "")), ) ok_ngx, err_ngx = _reload_panel_and_common_nginx() if not ok_ngx: raise HTTPException( status_code=500, detail="Nginx test/reload failed before certificate request (fix config, then retry): " + err_ngx, ) challenge_dir = os.path.join(webroot_norm, ".well-known", "acme-challenge") try: os.makedirs(challenge_dir, mode=0o755, exist_ok=True) except OSError as e: raise HTTPException(status_code=500, detail=f"Cannot create ACME webroot directory: {e}") from e prefix = _certbot_command() if not prefix: raise HTTPException(status_code=500, detail=_certbot_missing_message()) hostnames = await _le_hostnames_for_domain_row(db, dom_row, dom) base_flags = [ "--non-interactive", "--agree-tos", "--email", body.email, "--no-eff-email", ] cmd_webroot = prefix + ["certonly", "--webroot", "-w", webroot_norm, *base_flags] for h in hostnames: cmd_webroot.extend(["-d", h]) cmd_webroot.extend(["--preferred-challenges", "http"]) cmd_nginx = prefix + ["certonly", "--nginx", *base_flags] for h in hostnames: cmd_nginx.extend(["-d", h]) env = environment_with_system_path() proc: subprocess.CompletedProcess[str] | None = None last_err = "" for cmd, label in ((cmd_webroot, "webroot"), (cmd_nginx, "nginx")): try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=300, env=env, ) except FileNotFoundError: raise HTTPException(status_code=500, detail=_certbot_missing_message()) from None except subprocess.TimeoutExpired: raise HTTPException(status_code=500, detail="certbot timed out (300s)") from None if proc.returncode == 0: break chunk = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}" last_err = f"[{label}] {chunk}" if proc is None or proc.returncode != 0: msg = last_err or "certbot failed" hint = ( " Webroot and nginx plugins both failed. Check: " "DNS A/AAAA for every -d name points to this server; port 80 reaches the nginx that serves these hosts; " "site is enabled; install python3-certbot-nginx if the nginx method reports a missing plugin. " "If you use a CDN proxy, pause it or use DNS validation instead." ) raise HTTPException(status_code=500, detail=(msg + hint)[:8000]) row = dom_row if row: regen = await regenerate_site_vhost(db, row.pid) if not regen.get("status"): return { "status": True, "msg": "Certificate issued but nginx vhost update failed: " + str(regen.get("msg", "")), "output": (proc.stdout or "")[-2000:], } return { "status": True, "msg": "Certificate issued and nginx updated", "output": (proc.stdout or "")[-2000:], } @router.get("/diagnostics") async def ssl_diagnostics(current_user: User = Depends(get_current_user)): """ Help debug HTTP vs HTTPS: compares panel-written vhosts with what nginx -T actually loads. ERR_CONNECTION_REFUSED on 443 usually means no listen 443 in the active nginx, or a firewall. """ cfg = get_runtime_config() setup_abs = os.path.abspath((cfg.get("setup_path") or "").strip() or ".") vhost_dir = os.path.join(setup_abs, "panel", "vhost", "nginx") include_snippet = "include " + vhost_dir.replace(os.sep, "/") + "/*.conf;" vhost_summaries: list[dict] = [] if os.path.isdir(vhost_dir): try: names = sorted(os.listdir(vhost_dir)) except OSError: names = [] for fn in names: if not fn.endswith(".conf") or fn.startswith("."): continue fp = os.path.join(vhost_dir, fn) if not os.path.isfile(fp): continue body = read_file(fp) or "" vhost_summaries.append({ "file": fn, "has_listen_80": bool(re.search(r"\blisten\s+80\b", body)), "has_listen_443": bool(re.search(r"\blisten\s+.*443", body)), "has_ssl_directives": "ssl_certificate" in body, }) any_vhost_443 = any( v.get("has_listen_443") and v.get("has_ssl_directives") for v in vhost_summaries ) effective_listen_443 = False panel_include_in_effective_config = False nginx_t_errors: list[str] = [] norm_vhost = vhost_dir.replace(os.sep, "/") env = environment_with_system_path() for ngx in nginx_binary_candidates(): try: r = subprocess.run( [ngx, "-T"], capture_output=True, text=True, timeout=25, env=env, ) except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as e: nginx_t_errors.append(f"{ngx}: {e}") continue dump = (r.stdout or "") + (r.stderr or "") if r.returncode != 0: nginx_t_errors.append(f"{ngx}: " + (dump.strip()[:800] or f"-T exit {r.returncode}")) continue if re.search(r"\blisten\s+.*443", dump): effective_listen_443 = True if norm_vhost in dump or "panel/vhost/nginx" in dump: panel_include_in_effective_config = True hints: list[str] = [] if not os.path.isdir(vhost_dir): hints.append(f"The panel vhost directory is missing ({vhost_dir}). Create a website in YakPanel first.") elif not vhost_summaries: hints.append("There are no .conf files under the panel nginx vhost directory.") le_live = "/etc/letsencrypt/live" le_present = False if os.path.isdir(le_live): try: le_present = any( n and not n.startswith(".") for n in os.listdir(le_live) ) except OSError: le_present = False if le_present and vhost_summaries and not any_vhost_443: hints.append( "Let's Encrypt certs exist on this server but panel vhosts do not include an HTTPS (listen 443 ssl) block. " "Regenerate the vhost: edit the site and save, or use Request SSL again." ) if any_vhost_443 and not effective_listen_443: hints.append( "Your panel .conf files define HTTPS, but nginx -T does not show any listen 443 — the daemon that handles traffic is not loading YakPanel vhosts. " "Add the include line below inside http { } for that nginx (e.g. /etc/nginx/nginx.conf), then nginx -t && reload." ) elif vhost_summaries and not panel_include_in_effective_config: hints.append( "If http://domain shows the default 'Welcome to nginx' page, stock nginx is answering and likely does not include YakPanel vhosts. " "Add the include below (or symlink this directory into /etc/nginx/conf.d/)." ) localhost_443_open = _localhost_accepts_tcp(443) ss_443 = _ss_reports_listen_443() if not localhost_443_open and not effective_listen_443: hints.append( "This server is not accepting TCP on 127.0.0.1:443 — nothing is listening on 443 yet. " "Fix nginx (listen 443 ssl + include panel vhosts) first; opening only the cloud firewall will not fix ERR_CONNECTION_REFUSED until nginx binds 443." ) elif effective_listen_443 and localhost_443_open: hints.append( "Nginx loads HTTPS and 127.0.0.1:443 accepts connections on this host. " "If browsers off this machine still see connection refused, allow inbound TCP 443: " "sudo ufw allow 443/tcp && sudo ufw reload (or firewalld), and your VPS Security Group / provider firewall." ) elif effective_listen_443 and not localhost_443_open: hints.append( "nginx -T reports listen 443, but connecting to 127.0.0.1:443 failed — check nginx error.log; nginx may have failed to bind (permission or address already in use)." ) elif localhost_443_open and not effective_listen_443: hints.append( "127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80." ) debian_sites = os.path.isdir("/etc/nginx/sites-available") rhel_conf = os.path.isdir("/etc/nginx/conf.d") layout = "unknown" if debian_sites: layout = "debian_sites_available" elif rhel_conf: layout = "rhel_conf_d" drop_deb = "/etc/nginx/sites-available/yakpanel-vhosts.conf" drop_rhel = "/etc/nginx/conf.d/yakpanel-vhosts.conf" nginx_wizard = { "detected_layout": layout, "include_snippet": include_snippet, "dropin_file_suggested": drop_deb if debian_sites else drop_rhel, "debian": { "sites_available_file": drop_deb, "sites_enabled_symlink": "/etc/nginx/sites-enabled/yakpanel-vhosts.conf", "steps": [ f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_deb}", f"sudo ln -sf {drop_deb} /etc/nginx/sites-enabled/yakpanel-vhosts.conf", "sudo nginx -t && sudo systemctl reload nginx", ], }, "rhel": { "conf_d_file": drop_rhel, "steps": [ f"printf '%s\\n' '{include_snippet}' | sudo tee {drop_rhel}", "sudo nginx -t && sudo systemctl reload nginx", ], }, "note": "Run the steps for your distro as root. The include line must appear inside the main http { } context (conf.d files do automatically).", } return { "vhost_dir": vhost_dir, "include_snippet": include_snippet, "nginx_wizard": nginx_wizard, "vhosts": vhost_summaries, "any_vhost_listen_ssl": any_vhost_443, "nginx_effective_listen_443": effective_listen_443, "panel_vhost_path_in_nginx_t": panel_include_in_effective_config, "nginx_t_probe_errors": nginx_t_errors, "localhost_443_accepts_tcp": localhost_443_open, "ss_reports_443_listen": ss_443, "hints": hints, } class DnsCertCloudflareRequest(BaseModel): domain: str email: str api_token: str class DnsManualInstructionsRequest(BaseModel): domain: str @router.post("/dns-request/cloudflare") async def ssl_dns_cloudflare_cert( body: DnsCertCloudflareRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Request Let's Encrypt certificate using DNS-01 via Cloudflare (requires certbot-dns-cloudflare).""" dom = (body.domain or "").split(":")[0].strip() if not dom or ".." in dom or not body.email or not body.api_token: raise HTTPException(status_code=400, detail="domain, email, and api_token required") result_dom = await db.execute(select(Domain).where(Domain.name == dom).limit(1)) dom_row = result_dom.scalar_one_or_none() if dom_row: regen_pre = await regenerate_site_vhost(db, dom_row.pid) if not regen_pre.get("status"): raise HTTPException( status_code=500, detail="Cannot refresh nginx vhost: " + str(regen_pre.get("msg", "")), ) ok_ngx, err_ngx = _reload_panel_and_common_nginx() if not ok_ngx: raise HTTPException( status_code=500, detail="Nginx reload failed: " + err_ngx, ) prefix = _certbot_command() if not prefix: raise HTTPException(status_code=500, detail=_certbot_missing_message()) hostnames = await _le_hostnames_for_domain_row(db, dom_row, dom) if not hostnames: hostnames = [dom] cred_lines = f'dns_cloudflare_api_token = {body.api_token.strip()}\n' fd, cred_path = tempfile.mkstemp(suffix=".ini", prefix="yakpanel_cf_") try: os.write(fd, cred_lines.encode()) os.close(fd) os.chmod(cred_path, 0o600) except OSError as e: try: os.close(fd) except OSError: pass raise HTTPException(status_code=500, detail=f"Cannot write credentials temp file: {e}") from e base_flags = [ "--non-interactive", "--agree-tos", "--email", body.email.strip(), "--no-eff-email", "--dns-cloudflare", "--dns-cloudflare-credentials", cred_path, ] cmd = prefix + ["certonly"] + base_flags for h in hostnames: cmd.extend(["-d", h]) env = environment_with_system_path() try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env) except (FileNotFoundError, subprocess.TimeoutExpired) as e: try: os.unlink(cred_path) except OSError: pass raise HTTPException(status_code=500, detail=str(e)) from e finally: try: os.unlink(cred_path) except OSError: pass if proc.returncode != 0: err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}" raise HTTPException( status_code=500, detail="certbot DNS failed. Install certbot-dns-cloudflare (pip or OS package) if missing. " + err[:6000], ) if dom_row: regen = await regenerate_site_vhost(db, dom_row.pid) if not regen.get("status"): return { "status": True, "msg": "Certificate issued but vhost regen failed: " + str(regen.get("msg", "")), "output": (proc.stdout or "")[-2000:], } return { "status": True, "msg": "Certificate issued via Cloudflare DNS-01", "output": (proc.stdout or "")[-2000:], } @router.post("/dns-request/manual-instructions") async def ssl_dns_manual_instructions( body: DnsManualInstructionsRequest, current_user: User = Depends(get_current_user), ): """Return TXT record host for ACME DNS-01 (user creates record then runs certbot --manual).""" d = (body.domain or "").split(":")[0].strip() if not d or ".." in d: raise HTTPException(status_code=400, detail="Invalid domain") return { "txt_record_name": f"_acme-challenge.{d}", "certbot_example": ( f"sudo certbot certonly --manual --preferred-challenges dns --email you@example.com " f"--agree-tos -d {d}" ), "note": "Certbot will display the exact TXT value to create. After DNS propagates, continue in the terminal.", } @router.get("/certificates") async def ssl_list_certificates(current_user: User = Depends(get_current_user)): """List existing Let's Encrypt certificates""" live_dir = "/etc/letsencrypt/live" if not os.path.isdir(live_dir): return {"certificates": []} certs = [] for name in os.listdir(live_dir): if name.startswith("."): continue path = os.path.join(live_dir, name) if os.path.isdir(path) and os.path.isfile(os.path.join(path, "fullchain.pem")): certs.append({"name": name, "path": path}) return {"certificates": sorted(certs, key=lambda x: x["name"])}