diff --git a/docs/superpowers/specs/2026-06-17-request-certificate-button-design.md b/docs/superpowers/specs/2026-06-17-request-certificate-button-design.md new file mode 100644 index 0000000..940d896 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-request-certificate-button-design.md @@ -0,0 +1,116 @@ +# „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://` 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 :443:caddy:443 https:///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 :443:caddy:443 https:///` 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:`, 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.php` — `serverPublicIp`, `domainResolvesHere`, `requestCertificate` + (the latter persists `tls_cert_status`). No live page-load cert probe (see status note above). +- `app/Livewire/System/Index.php` — `requestCertificate()` 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.