"""YakPanel - SSL/Domains API - Let's Encrypt via certbot""" import os import shutil import subprocess import sys from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from typing import Optional from app.core.database import get_db from app.core.config import get_runtime_config from app.core.utils import environment_with_system_path from app.api.auth import get_current_user from app.models.user import User from app.models.site import Site, Domain from app.core.utils import exec_shell_sync 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() -> None: """Reload nginx so new vhost (ACME path) is live before certbot HTTP-01.""" cfg = get_runtime_config() seen: set[str] = set() binaries: list[str] = [] panel_ngx = os.path.join(cfg.get("setup_path") or "", "nginx", "sbin", "nginx") if os.path.isfile(panel_ngx): binaries.append(panel_ngx) seen.add(os.path.realpath(panel_ngx)) for alt in ("/usr/sbin/nginx", "/usr/bin/nginx", "/usr/local/nginx/sbin/nginx"): if not os.path.isfile(alt): continue rp = os.path.realpath(alt) if rp in seen: continue binaries.append(alt) seen.add(rp) for ngx in binaries: exec_shell_sync(f'"{ngx}" -t && "{ngx}" -s reload', timeout=60) @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 @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", "")), ) _reload_panel_and_common_nginx() 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("/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"])}