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 db4f8b34..2ec0b89e 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 0f26050f..f1ed3788 100644 --- a/YakPanel-server/backend/app/api/ssl.py +++ b/YakPanel-server/backend/app/api/ssl.py @@ -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, diff --git a/YakPanel-server/frontend/src/api/client.ts b/YakPanel-server/frontend/src/api/client.ts index 2f43f5b3..6b500b08 100644 --- a/YakPanel-server/frontend/src/api/client.ts +++ b/YakPanel-server/frontend/src/api/client.ts @@ -546,6 +546,19 @@ export async function getDashboardStats() { }>('/dashboard/stats') } +export async function applySiteSsl(data: { + site_id: number + domains: string[] + method: 'file' | 'dns_cloudflare' + email: string + api_token?: string +}) { + return apiRequest<{ status: boolean; msg: string; output?: string }>('/ssl/site-apply', { + method: 'POST', + body: JSON.stringify(data), + }) +} + export async function getFirewallBackendStatus() { return apiRequest<{ ufw: { detected: boolean; active: boolean | null; summary_line: string } diff --git a/YakPanel-server/frontend/src/pages/SitePage.tsx b/YakPanel-server/frontend/src/pages/SitePage.tsx index d88088ed..98351f55 100644 --- a/YakPanel-server/frontend/src/pages/SitePage.tsx +++ b/YakPanel-server/frontend/src/pages/SitePage.tsx @@ -20,6 +20,7 @@ import { siteGitClone, siteGitPull, listServices, + applySiteSsl, type SiteListItem, } from '../api/client' import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin' @@ -29,6 +30,25 @@ function formatPhpVersion(code: string): string { return m[code] || code } +/** Domain column may be `host` or `host:port` (match API list). */ +function hostFromDomainEntry(entry: string): string { + return (entry || '').split(':')[0].trim() +} + +function uniqueHostsFromSiteDomains(domains: string[] | undefined): string[] { + const seen = new Set() + const out: string[] = [] + for (const entry of domains || []) { + const h = hostFromDomainEntry(entry) + const k = h.toLowerCase() + if (k && !seen.has(k)) { + seen.add(k) + out.push(h) + } + } + return out +} + export function SitePage() { const [sites, setSites] = useState([]) const [loading, setLoading] = useState(true) @@ -73,6 +93,63 @@ export function SitePage() { const [nginxStatus, setNginxStatus] = useState(null) const [batchLoading, setBatchLoading] = useState(false) const [rowBackupId, setRowBackupId] = useState(null) + const [sslSite, setSslSite] = useState(null) + const [sslTab, setSslTab] = useState<'current' | 'letsencrypt'>('letsencrypt') + const [sslMethod, setSslMethod] = useState<'file' | 'dns_cloudflare'>('file') + const [sslEmail, setSslEmail] = useState('') + const [sslCfToken, setSslCfToken] = useState('') + const [sslSelectedHosts, setSslSelectedHosts] = useState>(() => new Set()) + const [sslBusy, setSslBusy] = useState(false) + const [sslError, setSslError] = useState('') + const [sslSuccess, setSslSuccess] = useState('') + + const sslHostOptions = useMemo(() => (sslSite ? uniqueHostsFromSiteDomains(sslSite.domains) : []), [sslSite]) + + const openSslModal = (s: SiteListItem) => { + setSslSite(s) + setSslTab('letsencrypt') + setSslMethod('file') + setSslEmail('') + setSslCfToken('') + setSslSelectedHosts(new Set(uniqueHostsFromSiteDomains(s.domains))) + setSslError('') + setSslSuccess('') + setError('') + } + + const toggleSslHost = (host: string, checked: boolean) => { + setSslSelectedHosts((prev) => { + const n = new Set(prev) + if (checked) n.add(host) + else n.delete(host) + return n + }) + } + + const sslSelectAll = (on: boolean) => { + setSslSelectedHosts(on ? new Set(sslHostOptions) : new Set()) + } + + const handleSiteSslApply = (e: React.FormEvent) => { + e.preventDefault() + if (!sslSite || sslSelectedHosts.size === 0 || !sslEmail.trim()) return + setSslBusy(true) + setSslError('') + setSslSuccess('') + applySiteSsl({ + site_id: sslSite.id, + domains: [...sslSelectedHosts], + method: sslMethod, + email: sslEmail.trim(), + api_token: sslMethod === 'dns_cloudflare' ? sslCfToken.trim() : undefined, + }) + .then((r) => { + setSslSuccess(r.msg || 'Certificate request completed.') + loadSites() + }) + .catch((err) => setSslError(err.message)) + .finally(() => setSslBusy(false)) + } const loadSites = () => { setLoading(true) @@ -671,9 +748,13 @@ export function SitePage() { Logs - + openSslModal(s)}> - SSL / Domains + SSL (this site) + + + + Domains & diagnostics + { + setSslSite(null) + setSslError('') + setSslSuccess('') + }} + size="lg" + centered + scrollable + > + + + + SSL — {sslSite?.name} + + + + {sslSite ? ( + <> +
    +
  • + +
  • +
  • + +
  • +
+ + {sslTab === 'current' ? ( +
+ {(sslSite.ssl?.status ?? 'none') === 'none' ? ( +
+ This site has no matching Let's Encrypt certificate detected on the server. Use the Let's + Encrypt tab to issue one, or open Domains for global checks. +
+ ) : sslSite.ssl?.status === 'expired' ? ( +
+ Certificate appears expired for monitored hostnames. Renew from the Let's + Encrypt tab. +
+ ) : sslSite.ssl?.status === 'expiring' ? ( +
+ Certificate expires in {sslSite.ssl?.days_left ?? 0} days — consider renewing soon. +
+ ) : ( +
+ Certificate looks active (~{sslSite.ssl?.days_left ?? '—'} days left for matched hostname + {sslSite.ssl?.cert_name ? ( + <> + : {sslSite.ssl.cert_name} + + ) : null} + ). +
+ )} +

+ Open Domains & SSL for nginx include wizard, DNS-01 helpers, and all + certificates on the server. +

+
+ ) : ( +
+ {(sslSite.ssl?.status ?? 'none') !== 'active' ? ( +
+ + Tip: HTTPS is not fully active for this site's domains — visitors may see + browser warnings until a certificate is deployed. + + Use the form below to apply +
+ ) : ( +
+ A certificate is already detected as active; you can still re-issue to add names or renew early. +
+ )} + +
+
Validation
+
+ setSslMethod('file')} + /> + +
+
+ setSslMethod('dns_cloudflare')} + /> + +
+
+ +
+
+ + {sslHostOptions.length > 1 ? ( + + ) : null} +
+ {sslHostOptions.length === 0 ? ( +

No domains on this site — add domains in site settings first.

+ ) : ( +
+ {sslHostOptions.map((h) => ( +
+ toggleSslHost(h, ev.target.checked)} + /> + +
+ ))} +
+ )} +
+ +
+ + setSslEmail(e.target.value)} + placeholder="admin@example.com" + required + autoComplete="email" + /> +
+ + {sslMethod === 'dns_cloudflare' ? ( +
+ + setSslCfToken(e.target.value)} + placeholder="API token" + autoComplete="off" + required + /> +
+ ) : null} + +
    +
  • + Confirm DNS A/AAAA records for every selected name point to this server before using file + verification. +
  • +
  • If you use a CDN or redirect, file validation may fail — use Cloudflare DNS verification.
  • +
  • + Requires certbot on the server; DNS method also needs certbot-dns-cloudflare. +
  • +
+ + {sslError ? {sslError} : null} + {sslSuccess ? {sslSuccess} : null} + +
+ + +
+
+ )} + + ) : null} +
+
+ setBackupSiteId(null)} size="lg" centered> Site Backup