Files
yakpanel-core/YakPanel-server/backend/app/api/ssl.py

295 lines
10 KiB
Python
Raw Normal View History

2026-04-07 02:04:22 +05:30
"""YakPanel - SSL/Domains API - Let's Encrypt via certbot"""
import os
2026-04-07 10:23:05 +05:30
import shutil
import subprocess
2026-04-07 10:29:29 +05:30
import sys
2026-04-07 02:04:22 +05:30
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
2026-04-07 10:47:27 +05:30
from typing import Optional
2026-04-07 02:04:22 +05:30
from app.core.database import get_db
from app.core.config import get_runtime_config
2026-04-07 10:23:05 +05:30
from app.core.utils import environment_with_system_path
2026-04-07 02:04:22 +05:30
from app.api.auth import get_current_user
from app.models.user import User
from app.models.site import Site, Domain
2026-04-07 10:35:44 +05:30
from app.core.utils import exec_shell_sync
2026-04-07 10:23:05 +05:30
from app.services.site_service import regenerate_site_vhost
2026-04-07 02:04:22 +05:30
router = APIRouter(prefix="/ssl", tags=["ssl"])
2026-04-07 10:29:29 +05:30
_CERTBOT_PATH_CANDIDATES = (
"/usr/bin/certbot",
"/usr/local/bin/certbot",
"/snap/bin/certbot",
)
2026-04-07 02:04:22 +05:30
2026-04-07 10:29:29 +05:30
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."
)
2026-04-07 10:23:05 +05:30
2026-04-07 10:47:27 +05:30
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 [])
2026-04-07 10:35:44 +05:30
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)
2026-04-07 02:04:22 +05:30
@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),
2026-04-07 10:23:05 +05:30
db: AsyncSession = Depends(get_db),
2026-04-07 02:04:22 +05:30
):
2026-04-07 10:23:05 +05:30
"""Request Let's Encrypt certificate via certbot (webroot challenge)."""
2026-04-07 02:04:22 +05:30
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")
2026-04-07 10:23:05 +05:30
dom = body.domain.split(":")[0].strip()
2026-04-07 10:35:44 +05:30
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
2026-04-07 10:29:29 +05:30
prefix = _certbot_command()
if not prefix:
raise HTTPException(status_code=500, detail=_certbot_missing_message())
2026-04-07 10:47:27 +05:30
hostnames = await _le_hostnames_for_domain_row(db, dom_row, dom)
base_flags = [
2026-04-07 10:23:05 +05:30
"--non-interactive",
"--agree-tos",
"--email",
body.email,
"--no-eff-email",
]
2026-04-07 10:47:27 +05:30
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"])
2026-04-07 10:23:05 +05:30
2026-04-07 10:47:27 +05:30
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"
2026-04-07 10:35:44 +05:30
hint = (
2026-04-07 10:47:27 +05:30
" 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."
2026-04-07 10:35:44 +05:30
)
raise HTTPException(status_code=500, detail=(msg + hint)[:8000])
2026-04-07 10:23:05 +05:30
2026-04-07 10:35:44 +05:30
row = dom_row
2026-04-07 10:23:05 +05:30
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:],
}
2026-04-07 02:04:22 +05:30
@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"])}