docs(spec): dashboard "request certificate" button (trigger Caddy on-demand TLS)

DNS pre-check + internal SNI handshake to caddy to trigger on-demand issuance, with a
persisted status (no page-load ACME side effects), per-user throttle, external-TLS-mode
gating, and the control-plane-never-locked constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-17 18:24:25 +02:00
parent d6d37583d0
commit 4f1ea8ef0f
1 changed files with 116 additions and 0 deletions

View File

@ -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://<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.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.