From 8fe63c7cf40582afc536a0801ebc42c36d14f13e Mon Sep 17 00:00:00 2001 From: Niranjan Date: Tue, 7 Apr 2026 14:09:55 +0530 Subject: [PATCH] new changes --- .../app/api/__pycache__/ssl.cpython-314.pyc | Bin 34484 -> 46278 bytes YakPanel-server/backend/app/api/ssl.py | 216 ++++++++++++- YakPanel-server/frontend/src/api/client.ts | 13 + .../frontend/src/pages/SitePage.tsx | 304 +++++++++++++++++- 4 files changed, 529 insertions(+), 4 deletions(-) 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 db4f8b34accd95ddcca751156b065fffefd8c06d..2ec0b89e13ada8ffb4e2e51019ab4f30134551b3 100644 GIT binary patch delta 13371 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 delta 4921 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 K;UBIL4E`G^O`6#N 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