new changes

This commit is contained in:
Niranjan
2026-04-07 10:23:05 +05:30
parent 8a3e3ce04b
commit 09d0e2e033
5 changed files with 177 additions and 17 deletions

View File

@@ -1,5 +1,7 @@
"""YakPanel - SSL/Domains API - Let's Encrypt via certbot""" """YakPanel - SSL/Domains API - Let's Encrypt via certbot"""
import os import os
import shutil
import subprocess
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -7,14 +9,25 @@ from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.core.config import get_runtime_config 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.api.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.models.site import Site, Domain from app.models.site import Site, Domain
from app.services.site_service import regenerate_site_vhost
router = APIRouter(prefix="/ssl", tags=["ssl"]) 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") @router.get("/domains")
async def ssl_domains( async def ssl_domains(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -48,8 +61,9 @@ class RequestCertRequest(BaseModel):
async def ssl_request_cert( async def ssl_request_cert(
body: RequestCertRequest, body: RequestCertRequest,
current_user: User = Depends(get_current_user), 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: if not body.domain or not body.webroot or not body.email:
raise HTTPException(status_code=400, detail="domain, webroot and email required") raise HTTPException(status_code=400, detail="domain, webroot and email required")
if ".." in body.domain or ".." in body.webroot: if ".." in body.domain or ".." in body.webroot:
@@ -59,14 +73,62 @@ async def ssl_request_cert(
webroot_abs = os.path.abspath(body.webroot) webroot_abs = os.path.abspath(body.webroot)
if not any(webroot_abs.startswith(a + os.sep) or webroot_abs == a for a in allowed): 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") 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}" ' dom = body.domain.split(":")[0].strip()
f'--non-interactive --agree-tos --email "{body.email}"' certbot_bin = _certbot_executable()
) cmd = [
out, err = exec_shell_sync(cmd, timeout=120) certbot_bin,
if err and "error" in err.lower() and "successfully" not in err.lower(): "certonly",
raise HTTPException(status_code=500, detail=err.strip() or out.strip()) "--webroot",
return {"status": True, "msg": "Certificate requested", "output": out} "-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") @router.get("/certificates")

View File

@@ -83,6 +83,73 @@ def _best_ssl_for_hostnames(hostnames: list[str]) -> dict:
return none 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( def _render_vhost(
template: str, template: str,
server_names: str, server_names: str,
@@ -92,14 +159,32 @@ def _render_vhost(
php_version: str, php_version: str,
force_https: int, force_https: int,
redirects: list[tuple[str, str, int]] | None = None, redirects: list[tuple[str, str, int]] | None = None,
le_hostnames: list[str] | None = None,
) -> str: ) -> str:
"""Render nginx vhost template. redirects: [(source, target, code), ...]""" """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 = [] redirect_lines = []
for src, tgt, code in (redirects or []): for src, tgt, code in (redirects or []):
if src and tgt: if src and tgt:
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}") redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
redirect_block = "\n".join(redirect_lines) if redirect_lines else "" 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 = template.replace("{SERVER_NAMES}", server_names)
content = content.replace("{ROOT_PATH}", root_path) content = content.replace("{ROOT_PATH}", root_path)
content = content.replace("{LOGS_PATH}", logs_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("{PHP_VERSION}", php_version or "74")
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block) content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
content = content.replace("{REDIRECTS_BLOCK}", redirect_block) content = content.replace("{REDIRECTS_BLOCK}", redirect_block)
content = content.replace("{SSL_SERVER_BLOCK}", ssl_block)
return content return content
@@ -183,7 +269,10 @@ async def create_site(
if os.path.exists(template_path): if os.path.exists(template_path):
template = read_file(template_path) or "" template = read_file(template_path) or ""
server_names = " ".join(d.split(":")[0] for d in domains) 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) write_file(conf_path, content)
# Reload Nginx if available # Reload Nginx if available
@@ -337,7 +426,10 @@ async def update_site(
fhttps = getattr(site, "force_https", 0) or 0 fhttps = getattr(site, "force_https", 0) or 0
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) 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()] 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) write_file(conf_path, content)
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx") nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
if os.path.exists(nginx_bin): 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 fhttps = getattr(site, "force_https", 0) or 0
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id)) 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()] 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) write_file(conf_path, content)
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx") nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
if os.path.exists(nginx_bin): if os.path.exists(nginx_bin):

View File

@@ -8,12 +8,14 @@ server {
error_page 404 /404.html; error_page 404 /404.html;
error_page 502 /502.html; error_page 502 /502.html;
# ACME challenge # ACME HTTP-01 (Let's Encrypt). Prefix match wins over regex locations.
location ~ \.well-known { location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
allow all; allow all;
try_files $uri =404;
} }
# Force HTTPS redirect (when enabled) # Force HTTPS (skipped for ACME — see if block)
{FORCE_HTTPS_BLOCK} {FORCE_HTTPS_BLOCK}
# Custom redirects # Custom redirects
@@ -39,3 +41,4 @@ server {
access_log {LOGS_PATH}/{SITE_NAME}.log; access_log {LOGS_PATH}/{SITE_NAME}.log;
error_log {LOGS_PATH}/{SITE_NAME}.error.log; error_log {LOGS_PATH}/{SITE_NAME}.error.log;
} }
{SSL_SERVER_BLOCK}