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
parent
d6d37583d0
commit
4f1ea8ef0f
|
|
@ -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.
|
||||
Loading…
Reference in New Issue