new changes

This commit is contained in:
Niranjan
2026-04-07 14:09:55 +05:30
parent 18777560d5
commit 8fe63c7cf4
4 changed files with 529 additions and 4 deletions

View File

@@ -9,8 +9,8 @@ import tempfile
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 pydantic import BaseModel, Field
from typing import Optional, Literal
from app.core.database import get_db
from app.core.config import get_runtime_config
@@ -173,6 +173,218 @@ class RequestCertRequest(BaseModel):
email: str
class SiteSslApplyRequest(BaseModel):
"""Apply Let's Encrypt for one site; domains must belong to that site."""
site_id: int
domains: list[str] = Field(..., min_length=1)
method: Literal["file", "dns_cloudflare"]
email: str
api_token: str = ""
def _normalize_site_ssl_domains(raw_list: list[str], dom_rows: list[Domain]) -> tuple[list[str], str | None]:
"""
Map requested names to DB hostnames for this site.
Returns (canonical_hostnames, error_message).
"""
if not dom_rows:
return [], "Site has no domains configured"
name_map: dict[str, str] = {}
for d in dom_rows:
n = (d.name or "").strip()
if n:
name_map[n.lower()] = n
seen: set[str] = set()
out: list[str] = []
for raw in raw_list:
key = (raw or "").split(":")[0].strip().lower()
if not key or ".." in key:
continue
canon = name_map.get(key)
if not canon:
continue
lk = canon.lower()
if lk not in seen:
seen.add(lk)
out.append(canon)
if not out:
return [], "Select at least one valid domain name for this site"
return out, None
@router.post("/site-apply")
async def ssl_site_apply(
body: SiteSslApplyRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Per-site SSL: choose subset of site domains and file (HTTP-01) or Cloudflare DNS-01 validation.
"""
site_result = await db.execute(select(Site).where(Site.id == body.site_id))
site = site_result.scalar_one_or_none()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
dom_result = await db.execute(select(Domain).where(Domain.pid == site.id).order_by(Domain.id))
dom_rows = list(dom_result.scalars().all())
hostnames, err = _normalize_site_ssl_domains(body.domains, dom_rows)
if err:
raise HTTPException(status_code=400, detail=err)
email = (body.email or "").strip()
if not email:
raise HTTPException(status_code=400, detail="Email is required")
dom_row = dom_rows[0]
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: " + 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 (fix config, then retry): " + err_ngx,
)
prefix = _certbot_command()
if not prefix:
raise HTTPException(status_code=500, detail=_certbot_missing_message())
if body.method == "file":
cfg = get_runtime_config()
allowed = [os.path.abspath(cfg["www_root"]), os.path.abspath(cfg["setup_path"])]
webroot_abs = os.path.abspath((site.path or "").strip() or ".")
if ".." in (site.path or ""):
raise HTTPException(status_code=400, detail="Invalid site path")
if not any(webroot_abs.startswith(a + os.sep) or webroot_abs == a for a in allowed):
raise HTTPException(status_code=400, detail="Site path must be under www_root or setup_path")
webroot_norm = webroot_abs.rstrip(os.sep)
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
base_flags = ["--non-interactive", "--agree-tos", "--email", 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 = (
" Check DNS A/AAAA for every selected name points here; port 80 must reach nginx for this site. "
"CDN or redirects block file validation — use DNS verification instead."
)
raise HTTPException(status_code=500, detail=(msg + hint)[:8000])
regen = await regenerate_site_vhost(db, site.id)
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:],
}
# dns_cloudflare
token = (body.api_token or "").strip()
if not token:
raise HTTPException(status_code=400, detail="Cloudflare API token required for DNS verification")
cred_lines = f"dns_cloudflare_api_token = {token}\n"
fd, cred_path = tempfile.mkstemp(suffix=".ini", prefix="yakpanel_cf_")
try:
os.write(fd, cred_lines.encode())
os.close(fd)
os.chmod(cred_path, 0o600)
except OSError as e:
try:
os.close(fd)
except OSError:
pass
raise HTTPException(status_code=500, detail=f"Cannot write credentials temp file: {e}") from e
base_flags = [
"--non-interactive",
"--agree-tos",
"--email",
email,
"--no-eff-email",
"--dns-cloudflare",
"--dns-cloudflare-credentials",
cred_path,
]
cmd = prefix + ["certonly"] + base_flags
for h in hostnames:
cmd.extend(["-d", h])
env = environment_with_system_path()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
try:
os.unlink(cred_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=str(e)) from e
finally:
try:
os.unlink(cred_path)
except OSError:
pass
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
raise HTTPException(
status_code=500,
detail="certbot DNS failed (install certbot-dns-cloudflare if missing). " + err[:6000],
)
regen = await regenerate_site_vhost(db, site.id)
if not regen.get("status"):
return {
"status": True,
"msg": "Certificate issued but vhost regen failed: " + str(regen.get("msg", "")),
"output": (proc.stdout or "")[-2000:],
}
return {
"status": True,
"msg": "Certificate issued via Cloudflare DNS-01",
"output": (proc.stdout or "")[-2000:],
}
@router.post("/request")
async def ssl_request_cert(
body: RequestCertRequest,