8.2 KiB
„Zertifikat anfordern"-Button (Dashboard, On-Demand-TLS auslösen) — Design
Date: 2026-06-17 · Branch: feat/v1-foundation · Status: approved
Add a System → Domain & TLS control that lets the operator explicitly request the Let's-Encrypt certificate from the dashboard (with a DNS pre-check and a clear status), for the common case where a domain was configured (at install or in the dashboard) before its DNS pointed at the server.
Why this is even needed (current behaviour)
Caddy already issues certificates on demand: on the first HTTPS handshake for the configured
(active) panel domain, Caddy asks app:80/_caddy/ask, the app approves ONLY the active domain
([routes/web.php:35], [docker/caddy/Caddyfile:19]), and Caddy obtains the cert automatically. So once
DNS points here, the cert is fetched on the first https://<domain> visit — no action required, and
it self-heals (Caddy retries on later handshakes until DNS is correct).
The gap is visibility + control, not capability: the auto-flow is invisible (the operator can't
tell whether the cert exists without opening https:// and inspecting), and nothing lets them trigger
it proactively. This feature surfaces a DNS check, a proactive trigger, and a status.
Trigger mechanism (the core decision — approved)
The app triggers Caddy's on-demand issuance by performing an internal HTTPS handshake to the caddy
service with SNI = the domain:
curl --connect-to <domain>:443:caddy:443 https://<domain>/up (cert-verifying)
--connect-to keeps SNI/Host = the real domain while dialing the internal caddy service (same Docker
network, [docker-compose.prod.yml] caddy + app share a network). Caddy sees SNI=domain → on-demand
path → asks /_caddy/ask (approves) → runs ACME. The ACME challenge itself still needs the PUBLIC
domain to resolve here with 80/443 reachable — the trigger starts issuance, public reachability
completes it. (Rejected alternatives: passive status-only — doesn't actively trigger; exposing Caddy's
admin API — extra attack surface.)
The app image has curl, openssl, and PHP dns_get_record (verified), so no new dependency.
Flow on click — DeploymentService::requestCertificate(string $domain): array
- Guard: if
externalTls()or the domain is empty → return anot_applicablestatus (the button is hidden in those cases anyway; this is belt-and-suspenders). - DNS pre-check (
domainResolvesHere): resolve the domain's A/AAAA viadns_get_recordand compare to the server's public IP (serverPublicIp()). On a clear mismatch → returnstatus: 'dns_mismatch'with the resolved-vs-server IPs and DO NOT trigger ACME (this is what protects the Let's-Encrypt failed-validation rate limit from being burned). If the public IP can't be determined, skip the comparison and proceed (the trigger result is the real source of truth). - Trigger: the
curl --connect-to <domain>:443:caddy:443 https://<domain>/handshake (short timeout, ~15s). The certificate is established during the TLS handshake itself, so the HTTP path is irrelevant —/(which redirects to login) is fine; no special endpoint is required. - Verify: confirm a trusted cert is now served for the domain (the cert-verifying curl returns
2xx/3xx, or an
openssl s_clientcheck shows a valid chain). →status: 'issued'. Otherwisestatus: 'failed'with a hint ("Ports 80/443 vom Internet erreichbar? ACME-Challenge fehlgeschlagen.").
Supporting DeploymentService methods
serverPublicIp(): ?string— the server's public IP (curl -fsS https://api.ipify.org, ~3s timeout, cached ~5 min). Mirrors the installer's approach. Best-effort; null on failure.domainResolvesHere(string $domain): array{ok: ?bool, resolved: string[], serverIp: ?string}— A/AAAA vs public IP.ok: true = matches, false = clear mismatch, null = unknown (public IP not determinable → caller treats unknown as "proceed", since the handshake result is authoritative).
Status display — persisted, NOT a live page-load probe. A naive "is a cert active?" check would
itself be an internal handshake for the domain, which (if no cert exists) makes Caddy attempt on-demand
issuance — so auto-probing on every page load could burn the ACME failed-validation rate limit when DNS
is wrong. Instead, requestCertificate() persists its outcome (a Setting tls_cert_status:
{status, checkedAt, detail}); the System page reads that persisted value for the status line ("zuletzt
geprüft … — ausgestellt ✓ / DNS zeigt woanders / ausstehend", or "noch nicht geprüft"). Only the button
performs a live check. No page-load side effects, no surprise ACME calls.
UI — System/Index (Domain & TLS panel)
- A TLS-Zertifikat status line + a „DNS prüfen & Zertifikat anfordern" button.
- Visible only when
tlsMode === 'caddy'AND an active domain exists AND not bare-IP. In external TLS mode show a short note instead ("Zertifikat über den externen Reverse-Proxy"); on a bare IP / no domain, omit the row. - The button is a direct server action with a
wire:loadingspinner (the trigger is slow: DNS + handshake + ACME). Result surfaced via the existing toaster (notify, withlevel: 'error'on failure) and a refreshed status line. - Pending-domain nuance: the button operates on the active domain (what Caddy serves). If a
domain change is saved but awaiting a restart (
configuredDomain() !== domain()), show the existing "restart to apply" hint and act on the active domain (or disable until restarted) — never imply a cert can be issued for a not-yet-active domain. - German copy, no emoji (R16); only
@themetoken utilities (R3); no inline styles (R4).
Abuse / rate-limiting (fits the just-shipped hardening)
requestCertificate is an authenticated admin action but it triggers outbound ACME, so throttle it
per user: RateLimiter key cert-request:<id>, e.g. 5 / 10 min, auto-expiring (never a
control-plane lockout). The DNS pre-check additionally avoids triggering ACME when DNS is obviously
wrong. Audit the request (AuditEvent, action tls.cert_request).
Constraints honoured
- Control plane never locked out: no new permanent state; the throttle auto-expires; bare-IP
recovery +
clusev:reset-adminuntouched. - No cert-issuance abuse:
/_caddy/askstill approves only the active domain; the per-user throttle- DNS pre-check bound ACME calls.
- External-TLS mode is respected (button hidden; Caddy issues nothing there).
Files
app/Services/DeploymentService.php—serverPublicIp,domainResolvesHere,requestCertificate(the latter persiststls_cert_status). No live page-load cert probe (see status note above).app/Livewire/System/Index.php—requestCertificate()action (throttled + audited; persists the outcome toSettingtls_cert_status), a status property read from that Setting, visibility gating.resources/views/livewire/system/index.blade.php— status line + button (loading spinner), mode gating.lang/{de,en}/system.php— status/button/result strings.- Tests:
domainResolvesHerematch/mismatch/unknown;requestCertificatestate machine (dns_mismatch / issued / failed) with the curl + IP lookups mocked; the throttle.
Testing
- Unit: mock DNS + public IP → assert
domainResolvesHereok/mismatch/unknown; assertrequestCertificatereturnsdns_mismatchWITHOUT triggering the handshake on a mismatch, and the throttle blocks after the cap. - R12 browser: the status line + button render in caddy mode with an active domain; hidden in external mode / bare IP; the spinner shows on click. (Real ACME issuance can only be confirmed on a host with correct public DNS — verify the UI states + the no-trigger-on-mismatch path.)
- Pint, full suite green, Codex clean (no errors / security issues — especially no SSRF via the domain
string: the handshake target is pinned to the
caddyservice via--connect-to, and the domain is the operator-validated panel domain, not arbitrary user input).
Out of scope
- Moving long SSH operations off the request path (separate UX/perf item).
- Cert renewal UI (Caddy renews automatically; on-demand re-issues as needed).
- Manual cert upload / custom CA.