Files
yakpanel-core/YakPanel-server/backend/app/api/ssl.py
2026-04-07 11:42:19 +05:30

396 lines
14 KiB
Python

"""YakPanel - SSL/Domains API - Let's Encrypt via certbot"""
import os
import re
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, 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)
@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", "")),
)
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/)."
)
if effective_listen_443:
hints.append(
"Loaded nginx configuration includes a 443 listener. If HTTPS still fails, open TCP port 443 on the OS firewall and cloud/VPS security group."
)
return {
"vhost_dir": vhost_dir,
"include_snippet": include_snippet,
"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,
"hints": hints,
}
@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"])}