261 lines
8.6 KiB
Python
261 lines
8.6 KiB
Python
"""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 site’s 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"])}
|