diff --git a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc index 3b55a964..678fc7b3 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/ssl.py b/YakPanel-server/backend/app/api/ssl.py index e0f0f387..1cb82fd0 100644 --- a/YakPanel-server/backend/app/api/ssl.py +++ b/YakPanel-server/backend/app/api/ssl.py @@ -1,5 +1,7 @@ """YakPanel - SSL/Domains API - Let's Encrypt via certbot""" import os +import shutil +import subprocess from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -7,14 +9,25 @@ from pydantic import BaseModel from app.core.database import get_db from app.core.config import get_runtime_config -from app.core.utils import exec_shell_sync +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.services.site_service import regenerate_site_vhost router = APIRouter(prefix="/ssl", tags=["ssl"]) +def _certbot_executable() -> str: + w = shutil.which("certbot") + if w: + return w + for p in ("/usr/bin/certbot", "/usr/local/bin/certbot"): + if os.path.isfile(p): + return p + return "certbot" + + @router.get("/domains") async def ssl_domains( current_user: User = Depends(get_current_user), @@ -48,8 +61,9 @@ class RequestCertRequest(BaseModel): 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)""" + """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: @@ -59,14 +73,62 @@ async def ssl_request_cert( 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") - cmd = ( - f'certbot certonly --webroot -w "{body.webroot}" -d "{body.domain}" ' - f'--non-interactive --agree-tos --email "{body.email}"' - ) - out, err = exec_shell_sync(cmd, timeout=120) - if err and "error" in err.lower() and "successfully" not in err.lower(): - raise HTTPException(status_code=500, detail=err.strip() or out.strip()) - return {"status": True, "msg": "Certificate requested", "output": out} + + dom = body.domain.split(":")[0].strip() + certbot_bin = _certbot_executable() + cmd = [ + certbot_bin, + "certonly", + "--webroot", + "-w", + body.webroot, + "-d", + dom, + "--non-interactive", + "--agree-tos", + "--email", + body.email, + "--preferred-challenges", + "http", + "--no-eff-email", + ] + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=180, + env=environment_with_system_path(), + ) + except FileNotFoundError: + raise HTTPException( + status_code=500, + detail="certbot not found. Install it (e.g. apt install certbot) and ensure it is on PATH.", + ) 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}" + raise HTTPException(status_code=500, detail=msg[:8000]) + + result = await db.execute(select(Domain).where(Domain.name == dom).limit(1)) + row = result.scalar_one_or_none() + 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") diff --git a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc index dbeb6e8b..bb05cbcc 100644 Binary files a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc and b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/services/site_service.py b/YakPanel-server/backend/app/services/site_service.py index 2287690b..c5c53fc0 100644 --- a/YakPanel-server/backend/app/services/site_service.py +++ b/YakPanel-server/backend/app/services/site_service.py @@ -83,6 +83,73 @@ def _best_ssl_for_hostnames(hostnames: list[str]) -> dict: return none +def _letsencrypt_paths(hostname: str) -> tuple[str, str] | None: + """Return (fullchain, privkey) if Let's Encrypt files exist for this hostname.""" + h = (hostname or "").strip().lower().split(":")[0] + if not h or ".." in h: + return None + base = os.path.join(LETSENCRYPT_LIVE, h) + fc = os.path.join(base, "fullchain.pem") + pk = os.path.join(base, "privkey.pem") + if os.path.isfile(fc) and os.path.isfile(pk): + return fc, pk + return None + + +def _build_ssl_server_block( + server_names: str, + root_path: str, + logs_path: str, + site_name: str, + php_version: str, + fullchain: str, + privkey: str, + redirects: list[tuple[str, str, int]] | None, +) -> str: + """Second server {} for HTTPS when LE certs exist.""" + pv = php_version or "74" + redirect_lines: list[str] = [] + for src, tgt, code in (redirects or []): + if src and tgt: + redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") + redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else "" + q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"') + q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"') + return ( + f"server {{\n" + f" listen 443 ssl;\n" + f" server_name {server_names};\n" + f' ssl_certificate "{q_fc}";\n' + f' ssl_certificate_key "{q_pk}";\n' + f" index index.php index.html index.htm default.php default.htm default.html;\n" + f" root {root_path};\n" + f" error_page 404 /404.html;\n" + f" error_page 502 /502.html;\n" + f" location ^~ /.well-known/acme-challenge/ {{\n" + f' default_type "text/plain";\n' + f" allow all;\n" + f" try_files $uri =404;\n" + f" }}\n" + f"{redirect_block}\n" + r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n" + f" expires 30d;\n" + f" access_log off;\n" + f" }}\n" + r" location ~ .*\.(js|css)?$ {" + "\n" + f" expires 12h;\n" + f" access_log off;\n" + f" }}\n" + r" location ~ \.php$ {" + "\n" + f" fastcgi_pass unix:/tmp/php-cgi-{pv}.sock;\n" + f" fastcgi_index index.php;\n" + f" include fastcgi.conf;\n" + f" }}\n" + f" access_log {logs_path}/{site_name}.log;\n" + f" error_log {logs_path}/{site_name}.error.log;\n" + f"}}\n" + ) + + def _render_vhost( template: str, server_names: str, @@ -92,14 +159,32 @@ def _render_vhost( php_version: str, force_https: int, redirects: list[tuple[str, str, int]] | None = None, + le_hostnames: list[str] | None = None, ) -> str: """Render nginx vhost template. redirects: [(source, target, code), ...]""" - force_block = "return 301 https://$host$request_uri;" if force_https else "" + if force_https: + force_block = ( + ' if ($request_uri !~ "^/.well-known/acme-challenge/") {\n' + " return 301 https://$host$request_uri;\n" + " }" + ) + else: + force_block = "" redirect_lines = [] for src, tgt, code in (redirects or []): if src and tgt: redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") redirect_block = "\n".join(redirect_lines) if redirect_lines else "" + hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p] + ssl_block = "" + for h in hosts: + le = _letsencrypt_paths(h) + if le: + fc, pk = le + ssl_block = _build_ssl_server_block( + server_names, root_path, logs_path, site_name, php_version, fc, pk, redirects + ) + break content = template.replace("{SERVER_NAMES}", server_names) content = content.replace("{ROOT_PATH}", root_path) content = content.replace("{LOGS_PATH}", logs_path) @@ -107,6 +192,7 @@ def _render_vhost( content = content.replace("{PHP_VERSION}", php_version or "74") content = content.replace("{FORCE_HTTPS_BLOCK}", force_block) content = content.replace("{REDIRECTS_BLOCK}", redirect_block) + content = content.replace("{SSL_SERVER_BLOCK}", ssl_block) return content @@ -183,7 +269,10 @@ async def create_site( if os.path.exists(template_path): template = read_file(template_path) or "" server_names = " ".join(d.split(":")[0] for d in domains) - content = _render_vhost(template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, []) + le_hosts = [d.split(":")[0] for d in domains] + content = _render_vhost( + template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [], le_hosts + ) write_file(conf_path, content) # Reload Nginx if available @@ -337,7 +426,10 @@ async def update_site( fhttps = getattr(site, "force_https", 0) or 0 redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()] - content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects) + le_hosts = [d.name for d in domain_rows] + content = _render_vhost( + template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts + ) write_file(conf_path, content) nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx") if os.path.exists(nginx_bin): @@ -411,7 +503,10 @@ async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict: fhttps = getattr(site, "force_https", 0) or 0 redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()] - content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects) + le_hosts = [d.name for d in domain_rows] + content = _render_vhost( + template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts + ) write_file(conf_path, content) nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx") if os.path.exists(nginx_bin): diff --git a/YakPanel-server/webserver/templates/nginx_site.conf b/YakPanel-server/webserver/templates/nginx_site.conf index f03593f8..d5e87dc1 100644 --- a/YakPanel-server/webserver/templates/nginx_site.conf +++ b/YakPanel-server/webserver/templates/nginx_site.conf @@ -8,12 +8,14 @@ server { error_page 404 /404.html; error_page 502 /502.html; - # ACME challenge - location ~ \.well-known { + # ACME HTTP-01 (Let's Encrypt). Prefix match wins over regex locations. + location ^~ /.well-known/acme-challenge/ { + default_type "text/plain"; allow all; + try_files $uri =404; } - # Force HTTPS redirect (when enabled) + # Force HTTPS (skipped for ACME — see if block) {FORCE_HTTPS_BLOCK} # Custom redirects @@ -39,3 +41,4 @@ server { access_log {LOGS_PATH}/{SITE_NAME}.log; error_log {LOGS_PATH}/{SITE_NAME}.error.log; } +{SSL_SERVER_BLOCK}