Files
yakpanel-core/YakPanel-server/backend/app/api/ssl.py
2026-04-07 10:35:44 +05:30

261 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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."
)
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())
cmd = prefix + [
"certonly",
"--webroot",
"-w",
webroot_norm,
"-d",
dom,
"--non-interactive",
"--agree-tos",
"--email",
body.email,
"--preferred-challenges",
"http",
"--no-eff-email",
]
env = environment_with_system_path()
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=180,
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 (180s)") from None
if proc.returncode != 0:
msg = (proc.stderr or proc.stdout or "").strip() or f"certbot exited with code {proc.returncode}"
hint = (
" Check: DNS A/AAAA for this domain points to this server; port 80 is reachable; "
"the website is enabled in YakPanel; nginx on port 80 loads this sites vhost (same server as panel nginx if used)."
)
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"])}