new changes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user