new changes
This commit is contained in:
Binary file not shown.
@@ -9,8 +9,8 @@ import tempfile
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional, Literal
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.config import get_runtime_config
|
from app.core.config import get_runtime_config
|
||||||
@@ -173,6 +173,218 @@ class RequestCertRequest(BaseModel):
|
|||||||
email: str
|
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")
|
@router.post("/request")
|
||||||
async def ssl_request_cert(
|
async def ssl_request_cert(
|
||||||
body: RequestCertRequest,
|
body: RequestCertRequest,
|
||||||
|
|||||||
@@ -546,6 +546,19 @@ export async function getDashboardStats() {
|
|||||||
}>('/dashboard/stats')
|
}>('/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() {
|
export async function getFirewallBackendStatus() {
|
||||||
return apiRequest<{
|
return apiRequest<{
|
||||||
ufw: { detected: boolean; active: boolean | null; summary_line: string }
|
ufw: { detected: boolean; active: boolean | null; summary_line: string }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
siteGitClone,
|
siteGitClone,
|
||||||
siteGitPull,
|
siteGitPull,
|
||||||
listServices,
|
listServices,
|
||||||
|
applySiteSsl,
|
||||||
type SiteListItem,
|
type SiteListItem,
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
|
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
|
||||||
@@ -29,6 +30,25 @@ function formatPhpVersion(code: string): string {
|
|||||||
return m[code] || code
|
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() {
|
export function SitePage() {
|
||||||
const [sites, setSites] = useState<SiteListItem[]>([])
|
const [sites, setSites] = useState<SiteListItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -73,6 +93,63 @@ export function SitePage() {
|
|||||||
const [nginxStatus, setNginxStatus] = useState<string | null>(null)
|
const [nginxStatus, setNginxStatus] = useState<string | null>(null)
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
const [rowBackupId, setRowBackupId] = useState<number | null>(null)
|
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 = () => {
|
const loadSites = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -671,9 +748,13 @@ export function SitePage() {
|
|||||||
<i className="ti ti-file-text me-2" />
|
<i className="ti ti-file-text me-2" />
|
||||||
Logs
|
Logs
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Item as={Link} to="/ssl_domain">
|
<Dropdown.Item onClick={() => openSslModal(s)}>
|
||||||
<i className="ti ti-shield-lock me-2" />
|
<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 & diagnostics
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
<Dropdown.Divider />
|
<Dropdown.Divider />
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
@@ -1015,6 +1096,225 @@ export function SitePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</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's Encrypt
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{sslTab === 'current' ? (
|
||||||
|
<div>
|
||||||
|
{(sslSite.ssl?.status ?? 'none') === 'none' ? (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
) : sslSite.ssl?.status === 'expired' ? (
|
||||||
|
<div className="alert alert-danger">
|
||||||
|
Certificate appears <strong>expired</strong> for monitored hostnames. Renew from the Let'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 & 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'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'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 show={backupSiteId != null} onHide={() => setBackupSiteId(null)} size="lg" centered>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>Site Backup</Modal.Title>
|
<Modal.Title>Site Backup</Modal.Title>
|
||||||
|
|||||||
Reference in New Issue
Block a user