new changes
This commit is contained in:
Binary file not shown.
@@ -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 = [
|
||||||
|
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(),
|
||||||
)
|
)
|
||||||
out, err = exec_shell_sync(cmd, timeout=120)
|
except FileNotFoundError:
|
||||||
if err and "error" in err.lower() and "successfully" not in err.lower():
|
raise HTTPException(
|
||||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
status_code=500,
|
||||||
return {"status": True, "msg": "Certificate requested", "output": out}
|
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")
|
||||||
|
|||||||
Binary file not shown.
@@ -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):
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user