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,

View File

@@ -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 }

View File

@@ -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<string>()
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<SiteListItem[]>([])
const [loading, setLoading] = useState(true)
@@ -73,6 +93,63 @@ export function SitePage() {
const [nginxStatus, setNginxStatus] = useState<string | null>(null)
const [batchLoading, setBatchLoading] = useState(false)
const [rowBackupId, setRowBackupId] = useState<number | null>(null)
const [sslSite, setSslSite] = useState<SiteListItem | null>(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<Set<string>>(() => 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() {
<i className="ti ti-file-text me-2" />
Logs
</Dropdown.Item>
<Dropdown.Item as={Link} to="/ssl_domain">
<Dropdown.Item onClick={() => openSslModal(s)}>
<i className="ti ti-shield-lock me-2" />
SSL / Domains
SSL (this site)
</Dropdown.Item>
<Dropdown.Item as={Link} to="/ssl_domain">
<i className="ti ti-world-www me-2" />
Domains &amp; diagnostics
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item
@@ -1015,6 +1096,225 @@ export function SitePage() {
) : null}
</Modal>
<Modal
show={sslSite != null}
onHide={() => {
setSslSite(null)
setSslError('')
setSslSuccess('')
}}
size="lg"
centered
scrollable
>
<Modal.Header closeButton>
<Modal.Title className="d-flex align-items-center gap-2">
<i className="ti ti-shield-lock" aria-hidden />
SSL {sslSite?.name}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{sslSite ? (
<>
<ul className="nav nav-tabs mb-3">
<li className="nav-item">
<button
type="button"
className={`nav-link ${sslTab === 'current' ? 'active' : ''}`}
onClick={() => setSslTab('current')}
>
Current certificate
</button>
</li>
<li className="nav-item">
<button
type="button"
className={`nav-link ${sslTab === 'letsencrypt' ? 'active' : ''}`}
onClick={() => setSslTab('letsencrypt')}
>
Let&apos;s Encrypt
</button>
</li>
</ul>
{sslTab === 'current' ? (
<div>
{(sslSite.ssl?.status ?? 'none') === 'none' ? (
<div className="alert alert-warning">
This site has no matching Let&apos;s Encrypt certificate detected on the server. Use the Let&apos;s
Encrypt tab to issue one, or open Domains for global checks.
</div>
) : sslSite.ssl?.status === 'expired' ? (
<div className="alert alert-danger">
Certificate appears <strong>expired</strong> for monitored hostnames. Renew from the Let&apos;s
Encrypt tab.
</div>
) : sslSite.ssl?.status === 'expiring' ? (
<div className="alert alert-warning">
Certificate expires in <strong>{sslSite.ssl?.days_left ?? 0}</strong> days consider renewing soon.
</div>
) : (
<div className="alert alert-success mb-0">
Certificate looks active (~{sslSite.ssl?.days_left ?? '—'} days left for matched hostname
{sslSite.ssl?.cert_name ? (
<>
: <code>{sslSite.ssl.cert_name}</code>
</>
) : null}
).
</div>
)}
<p className="small text-muted mt-3 mb-0">
<Link to="/ssl_domain">Open Domains &amp; SSL</Link> for nginx include wizard, DNS-01 helpers, and all
certificates on the server.
</p>
</div>
) : (
<form onSubmit={handleSiteSslApply}>
{(sslSite.ssl?.status ?? 'none') !== 'active' ? (
<div className="alert alert-danger d-flex flex-wrap align-items-center justify-content-between gap-2">
<span>
<strong>Tip:</strong> HTTPS is not fully active for this site&apos;s domains visitors may see
browser warnings until a certificate is deployed.
</span>
<span className="badge bg-success text-wrap">Use the form below to apply</span>
</div>
) : (
<div className="alert alert-secondary small mb-3">
A certificate is already detected as active; you can still re-issue to add names or renew early.
</div>
)}
<div className="mb-3">
<div className="fw-semibold mb-2">Validation</div>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="sslVal"
id="ssl-file"
checked={sslMethod === 'file'}
onChange={() => setSslMethod('file')}
/>
<label className="form-check-label" htmlFor="ssl-file">
File verification (HTTP-01, webroot on this site)
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="sslVal"
id="ssl-dns"
checked={sslMethod === 'dns_cloudflare'}
onChange={() => setSslMethod('dns_cloudflare')}
/>
<label className="form-check-label" htmlFor="ssl-dns">
DNS verification Cloudflare (works behind CDN / orange-cloud)
</label>
</div>
</div>
<div className="mb-3">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
<label className="fw-semibold mb-0">Domains on this site</label>
{sslHostOptions.length > 1 ? (
<button
type="button"
className="btn btn-link btn-sm py-0"
onClick={() => sslSelectAll(sslSelectedHosts.size < sslHostOptions.length)}
>
{sslSelectedHosts.size < sslHostOptions.length ? 'Select all' : 'Clear all'}
</button>
) : null}
</div>
{sslHostOptions.length === 0 ? (
<p className="text-danger small mb-0">No domains on this site add domains in site settings first.</p>
) : (
<div className="border rounded p-3 bg-body-secondary bg-opacity-25">
{sslHostOptions.map((h) => (
<div className="form-check" key={h}>
<input
className="form-check-input"
type="checkbox"
id={`ssl-host-${h}`}
checked={sslSelectedHosts.has(h)}
onChange={(ev) => toggleSslHost(h, ev.target.checked)}
/>
<label className="form-check-label" htmlFor={`ssl-host-${h}`}>
{h}
</label>
</div>
))}
</div>
)}
</div>
<div className="mb-3">
<label className="form-label">Let&apos;s Encrypt email</label>
<input
type="email"
className="form-control"
value={sslEmail}
onChange={(e) => setSslEmail(e.target.value)}
placeholder="admin@example.com"
required
autoComplete="email"
/>
</div>
{sslMethod === 'dns_cloudflare' ? (
<div className="mb-3">
<label className="form-label">Cloudflare API token (DNS:Edit)</label>
<input
type="password"
className="form-control"
value={sslCfToken}
onChange={(e) => setSslCfToken(e.target.value)}
placeholder="API token"
autoComplete="off"
required
/>
</div>
) : null}
<ul className="small text-muted mb-3">
<li>
Confirm DNS A/AAAA records for every selected name point to this server before using file
verification.
</li>
<li>If you use a CDN or redirect, file validation may fail use Cloudflare DNS verification.</li>
<li>
Requires <code>certbot</code> on the server; DNS method also needs <code>certbot-dns-cloudflare</code>.
</li>
</ul>
{sslError ? <AdminAlert variant="danger">{sslError}</AdminAlert> : null}
{sslSuccess ? <AdminAlert variant="success">{sslSuccess}</AdminAlert> : null}
<div className="d-flex justify-content-end gap-2">
<button
type="button"
className="btn btn-light"
onClick={() => {
setSslSite(null)
setSslError('')
setSslSuccess('')
}}
>
Close
</button>
<button type="submit" className="btn btn-success" disabled={sslBusy || sslHostOptions.length === 0}>
{sslBusy ? 'Running certbot…' : 'Apply certificate'}
</button>
</div>
</form>
)}
</>
) : null}
</Modal.Body>
</Modal>
<Modal show={backupSiteId != null} onHide={() => setBackupSiteId(null)} size="lg" centered>
<Modal.Header closeButton>
<Modal.Title>Site Backup</Modal.Title>