clusev/docs/superpowers/specs/2026-06-17-request-certific...

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

  1. Guard: if externalTls() or the domain is empty → return a not_applicable status (the button is hidden in those cases anyway; this is belt-and-suspenders).
  2. DNS pre-check (domainResolvesHere): resolve the domain's A/AAAA via dns_get_record and compare to the server's public IP (serverPublicIp()). On a clear mismatch → return status: '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).
  3. 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.
  4. Verify: confirm a trusted cert is now served for the domain (the cert-verifying curl returns 2xx/3xx, or an openssl s_client check shows a valid chain). → status: 'issued'. Otherwise status: '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:loading spinner (the trigger is slow: DNS + handshake + ACME). Result surfaced via the existing toaster (notify, with level: '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 @theme token 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-admin untouched.
  • No cert-issuance abuse: /_caddy/ask still 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.phpserverPublicIp, domainResolvesHere, requestCertificate (the latter persists tls_cert_status). No live page-load cert probe (see status note above).
  • app/Livewire/System/Index.phprequestCertificate() action (throttled + audited; persists the outcome to Setting tls_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: domainResolvesHere match/mismatch/unknown; requestCertificate state machine (dns_mismatch / issued / failed) with the curl + IP lookups mocked; the throttle.

Testing

  • Unit: mock DNS + public IP → assert domainResolvesHere ok/mismatch/unknown; assert requestCertificate returns dns_mismatch WITHOUT 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 caddy service 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.