396 lines
14 KiB
Python
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"])}
|