From 1fb6b34fd1889cb4f7b85df1510930d0286971f1 Mon Sep 17 00:00:00 2001 From: boban Date: Wed, 17 Jun 2026 18:38:21 +0200 Subject: [PATCH] feat(tls): dashboard "request certificate" button (trigger Caddy on-demand TLS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the common case where a domain was configured before its DNS pointed here, the operator can now request the Let's Encrypt cert from System -> Domain & TLS instead of waiting for the first HTTPS visitor: - DeploymentService::requestCertificate() does a DNS pre-check (domainResolvesHere via dns_get_record vs serverPublicIp) and, only when DNS is NOT a clear mismatch, triggers Caddy's on-demand issuance with an internal handshake (probeCertificate: `curl --connect-to :443:caddy:443` — the dial target is pinned to the caddy service, the domain only sets SNI/Host, so no SSRF). A clear mismatch returns early and never calls ACME (Let's Encrypt rate-limit protection). The outcome is persisted to the `tls_cert_status` Setting so the page shows a status without an ACME-triggering probe on every load. - System\Index::requestCertificate() is per-user throttled (cert-request:, 5/10min, auto-expiring -> never a control-plane lockout) and audited (tls.cert_request). The status line + button render only in caddy mode with an active domain; external mode shows a note; bare IP hides it. Automatic on-demand still works on the first HTTPS handshake regardless — this only adds explicit control + a status. Tests cover the no-handshake-on-mismatch guard, the issued/failed/not-applicable states, and the per-user throttle. Full suite 192 green; Codex clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 +- app/Livewire/System/Index.php | 50 ++++++ app/Services/DeploymentService.php | 157 ++++++++++++++++++ lang/de/system.php | 14 ++ lang/en/system.php | 14 ++ .../views/livewire/system/index.blade.php | 35 ++++ tests/Feature/TlsCertificateRequestTest.php | 94 +++++++++++ 7 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/TlsCertificateRequestTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b0051..d17debc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui ## [Unreleased] -_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +### Hinzugefügt +- **„DNS prüfen & Zertifikat anfordern" im Dashboard (System → Domain & TLS).** Wurde eine Domain + konfiguriert, deren DNS noch nicht auf den Server zeigte, lässt sich das Let's-Encrypt-Zertifikat + jetzt per Knopf anstoßen, sobald das DNS stimmt: eine DNS-Vorabprüfung (zeigt die Domain hierher?) + und — nur ohne klare Fehlanpassung — ein interner Handshake, der Caddys On-Demand-Ausstellung + auslöst, plus ein persistierter Status (aktiv / DNS zeigt woanders / ausstehend). Sichtbar nur im + eingebauten-TLS-Modus mit aktiver Domain (im Externer-Proxy-Modus ein Hinweis, bei Bare-IP nichts). + Per-Nutzer gedrosselt (5/10 Min, auto-ablaufend — sperrt die Control-Plane nie aus) + auditiert; der + Handshake ist fest auf den `caddy`-Dienst gepinnt (kein SSRF). Hinweis: das automatische On-Demand + greift weiterhin von selbst beim ersten HTTPS-Aufruf — der Knopf gibt nur explizite Kontrolle + + Statusrückmeldung. ## [0.9.3] - 2026-06-17 diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index 91e9a98..35b8bd2 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -8,6 +8,7 @@ use App\Services\DeploymentService; use App\Support\Confirm\ConfirmToken; use App\Support\Confirm\InvalidConfirmToken; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Component; @@ -149,6 +150,52 @@ class Index extends Component $this->dispatch('notify', message: __('system.restart_running')); } + /** + * Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS + * pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user + * throttled (5 / 10 min, auto-expiring — never a control-plane lockout) so it can't hammer + * ACME, and audited. Only meaningful in caddy mode with an active domain. + */ + public function requestCertificate(DeploymentService $deployment): void + { + $domain = $deployment->domain(); + + if ($domain === null || $deployment->externalTls()) { + $this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error'); + + return; + } + + $key = 'cert-request:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); + + return; + } + RateLimiter::hit($key, 600); + + $result = $deployment->requestCertificate($domain); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'tls.cert_request', + 'target' => $domain.' → '.$result['status'], + 'ip' => request()->ip(), + ]); + + [$message, $level] = match ($result['status']) { + 'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'], + 'dns_mismatch' => [__('system.cert_dns_mismatch', [ + 'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—', + 'server' => $result['serverIp'] ?? '?', + ]), 'error'], + default => [__('system.cert_failed'), 'error'], + }; + + $this->dispatch('notify', message: $message, level: $level); + } + /** Release channel changes are audited (confirmation via the shared modal). */ public function confirmChannel(string $channel): void { @@ -255,6 +302,9 @@ class Index extends Component 'panelUrl' => $deployment->panelUrl(), 'isOverridden' => $deployment->domainIsOverridden(), 'channels' => $this->channelDescriptions(), + // Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain. + 'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '', + 'certStatus' => $deployment->certStatus(), ])->title(__('system.title')); } } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index 929fe10..e8050cc 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Models\Setting; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Process; use Throwable; /** @@ -276,4 +278,159 @@ class DeploymentService 'scheme' => $opt['scheme'] ?? 'http', ]; } + + /** + * The server's own public IP, used only to pre-check that a domain's DNS points here + * before triggering ACME. Best-effort (cached ~5 min on success); null when it can't be + * determined — the caller then treats the DNS comparison as "unknown" and proceeds, since + * the actual issuance handshake is the real source of truth. + */ + public function serverPublicIp(): ?string + { + $cached = Cache::get('clusev:public-ip'); + if (is_string($cached) && $cached !== '') { + return $cached; + } + + try { + $res = Process::timeout(5)->run(['curl', '-fsS', '--max-time', '3', 'https://api.ipify.org']); + $ip = trim($res->output()); + } catch (Throwable) { + $ip = ''; + } + + if (filter_var($ip, FILTER_VALIDATE_IP)) { + Cache::put('clusev:public-ip', $ip, now()->addMinutes(5)); + + return $ip; + } + + return null; + } + + /** + * Whether the domain's DNS (A/AAAA) currently resolves to THIS server. + * + * @return array{ok: ?bool, resolved: string[], serverIp: ?string} + * ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable) + */ + public function domainResolvesHere(string $domain): array + { + $domain = strtolower(trim($domain)); + $serverIp = $this->serverPublicIp(); + + $resolved = []; + try { + foreach ([DNS_A, DNS_AAAA] as $type) { + foreach (@dns_get_record($domain, $type) ?: [] as $rec) { + $ip = $rec['ip'] ?? $rec['ipv6'] ?? null; + if (is_string($ip) && $ip !== '') { + $resolved[] = $ip; + } + } + } + } catch (Throwable) { + // resolution failed — treat as no records + } + $resolved = array_values(array_unique($resolved)); + + $ok = $serverIp === null ? null : in_array($serverIp, $resolved, true); + + return ['ok' => $ok, 'resolved' => $resolved, 'serverIp' => $serverIp]; + } + + /** + * Trigger Caddy's on-demand TLS for the domain by doing an internal HTTPS handshake to the + * `caddy` service with SNI = the domain (`--connect-to` pins the dial to caddy regardless of + * the domain, so there is no SSRF — the connection target is fixed, the domain only sets + * SNI/Host). Returns true only when curl completes WITH certificate verification (exit 0), + * i.e. Caddy is now serving a trusted (Let's Encrypt) cert for the domain. Never throws. + */ + public function probeCertificate(string $domain): bool + { + $domain = strtolower(trim($domain)); + if ($domain === '') { + return false; + } + + try { + $res = Process::timeout(25)->run([ + 'curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}', + '--connect-to', "{$domain}:443:caddy:443", + '--max-time', '20', + "https://{$domain}/", + ]); + + // Exit 0 means the TLS handshake AND certificate verification succeeded (curl exits + // 60 on an untrusted/self-signed cert) — so a trusted cert is being served. + return $res->successful(); + } catch (Throwable) { + return false; + } + } + + /** + * Operator-triggered certificate request: DNS pre-check, then (only if DNS is not a clear + * mismatch) trigger on-demand issuance and verify. Persists the outcome to a Setting so the + * dashboard can show a status without a live (ACME-triggering) probe on every page load. + * + * @return array{status: string, checkedAt: string, resolved?: string[], serverIp?: ?string} + * status: issued | dns_mismatch | failed | not_applicable + */ + public function requestCertificate(string $domain): array + { + $domain = strtolower(trim($domain)); + + if ($domain === '' || $this->externalTls()) { + return $this->persistCertStatus('not_applicable'); + } + + $dns = $this->domainResolvesHere($domain); + if ($dns['ok'] === false) { + // Don't trigger ACME against a wrong host — protects the Let's Encrypt failed- + // validation rate limit. + return $this->persistCertStatus('dns_mismatch', [ + 'resolved' => $dns['resolved'], + 'serverIp' => $dns['serverIp'], + ]); + } + + $issued = $this->probeCertificate($domain); + + return $this->persistCertStatus($issued ? 'issued' : 'failed'); + } + + /** Last persisted certificate-request outcome, or null when never checked. */ + public function certStatus(): ?array + { + try { + $raw = Setting::get('tls_cert_status'); + } catch (Throwable) { + return null; + } + + if (! is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : null; + } + + /** @return array{status: string, checkedAt: string} */ + private function persistCertStatus(string $status, array $extra = []): array + { + $payload = array_merge(['status' => $status, 'checkedAt' => now()->toIso8601String()], $extra); + + try { + Setting::put('tls_cert_status', json_encode($payload)); + } catch (Throwable $e) { + // Status display is best-effort (the notify still reports the live result), but + // surface the failure to logs so a broken settings store doesn't fail silently. + report($e); + } + + return $payload; + } } diff --git a/lang/de/system.php b/lang/de/system.php index 392d388..1073bd6 100644 --- a/lang/de/system.php +++ b/lang/de/system.php @@ -75,6 +75,20 @@ return [ 'change_tls_confirm' => 'Umstellen', 'tls_saved_notify' => 'TLS-Modus gespeichert — Stack neu starten, um zu übernehmen.', + // TLS-Zertifikat (On-Demand-Ausstellung manuell anstoßen) + 'cert_title' => 'TLS-Zertifikat', + 'cert_button' => 'DNS prüfen & Zertifikat anfordern', + 'cert_status_issued' => 'Zertifikat aktiv — gültig und vertrauenswürdig.', + 'cert_status_dns_mismatch' => 'DNS zeigt (noch) nicht auf diesen Server — Zertifikat ausstehend.', + 'cert_status_failed' => 'Ausstellung fehlgeschlagen — sind Port 80 und 443 aus dem Internet erreichbar?', + 'cert_status_unknown' => 'Noch nicht geprüft. Sobald die Domain per DNS hierher zeigt, „anfordern" klicken.', + 'cert_external_note' => 'TLS läuft über den externen Reverse-Proxy — das Panel stellt kein Zertifikat aus.', + 'cert_issued' => 'Zertifikat für :domain ausgestellt.', + 'cert_dns_mismatch' => 'DNS zeigt auf :resolved statt auf diesen Server (:server) — kein Zertifikat angefordert.', + 'cert_failed' => 'Zertifikat konnte nicht ausgestellt werden — Ports 80/443 aus dem Internet erreichbar? Bitte später erneut.', + 'cert_not_applicable' => 'Zertifikatsanforderung hier nicht möglich (Bare-IP oder externer Proxy).', + 'cert_throttled' => 'Zu viele Anforderungen — bitte in :seconds Sekunden erneut.', + // Page title 'title' => 'System — Clusev', ]; diff --git a/lang/en/system.php b/lang/en/system.php index 19d8f34..80a7ff4 100644 --- a/lang/en/system.php +++ b/lang/en/system.php @@ -75,6 +75,20 @@ return [ 'change_tls_confirm' => 'Switch', 'tls_saved_notify' => 'TLS mode saved — restart the stack to apply.', + // TLS certificate (manually trigger on-demand issuance) + 'cert_title' => 'TLS certificate', + 'cert_button' => 'Check DNS & request certificate', + 'cert_status_issued' => 'Certificate active — valid and trusted.', + 'cert_status_dns_mismatch' => 'DNS does not point here (yet) — certificate pending.', + 'cert_status_failed' => 'Issuance failed — are ports 80 and 443 reachable from the internet?', + 'cert_status_unknown' => 'Not checked yet. Once the domain points here via DNS, click “request”.', + 'cert_external_note' => 'TLS is handled by the external reverse proxy — the panel issues no certificate.', + 'cert_issued' => 'Certificate issued for :domain.', + 'cert_dns_mismatch' => 'DNS points to :resolved instead of this server (:server) — no certificate requested.', + 'cert_failed' => 'Could not issue the certificate — are ports 80/443 reachable from the internet? Try again later.', + 'cert_not_applicable' => 'Certificate request not available here (bare IP or external proxy).', + 'cert_throttled' => 'Too many requests — try again in :seconds seconds.', + // Page title 'title' => 'System — Clusev', ]; diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index a11c059..80b02e8 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -190,6 +190,41 @@ @endif + {{-- TLS certificate: explicit DNS check + on-demand issuance trigger + status. + Only in Caddy mode with an active domain; the external proxy owns TLS otherwise. --}} + @if ($showCert) + @php $certState = $certStatus['status'] ?? null; @endphp +
+

{{ __('system.cert_title') }}

+

+ @switch($certState) + @case('issued') + {{ __('system.cert_status_issued') }} + @break + @case('dns_mismatch') + {{ __('system.cert_status_dns_mismatch') }} + @break + @case('failed') + {{ __('system.cert_status_failed') }} + @break + @default + {{ __('system.cert_status_unknown') }} + @endswitch +

+ + + + {{ __('system.cert_button') }} + +
+ @elseif ($tlsMode === 'external') +
+

{{ __('system.cert_title') }}

+

{{ __('system.cert_external_note') }}

+
+ @endif + {{-- Last-resort recovery: SSH into the host and reset admin access. --}}
diff --git a/tests/Feature/TlsCertificateRequestTest.php b/tests/Feature/TlsCertificateRequestTest.php new file mode 100644 index 0000000..395b620 --- /dev/null +++ b/tests/Feature/TlsCertificateRequestTest.php @@ -0,0 +1,94 @@ +makePartial(); + $svc->shouldReceive('domainResolvesHere')->once()->with('example.com') + ->andReturn(['ok' => false, 'resolved' => ['203.0.113.9'], 'serverIp' => '198.51.100.1']); + $svc->shouldReceive('probeCertificate')->never(); // never trigger ACME against a wrong host + + $result = $svc->requestCertificate('example.com'); + + $this->assertSame('dns_mismatch', $result['status']); + $this->assertSame('dns_mismatch', $svc->certStatus()['status']); + } + + public function test_dns_ok_and_handshake_success_is_issued(): void + { + $svc = Mockery::mock(DeploymentService::class)->makePartial(); + $svc->shouldReceive('domainResolvesHere')->once() + ->andReturn(['ok' => true, 'resolved' => ['198.51.100.1'], 'serverIp' => '198.51.100.1']); + $svc->shouldReceive('probeCertificate')->once()->with('example.com')->andReturnTrue(); + + $this->assertSame('issued', $svc->requestCertificate('example.com')['status']); + } + + public function test_dns_ok_but_handshake_failure_is_failed(): void + { + $svc = Mockery::mock(DeploymentService::class)->makePartial(); + $svc->shouldReceive('domainResolvesHere')->once()->andReturn(['ok' => true, 'resolved' => [], 'serverIp' => null]); + $svc->shouldReceive('probeCertificate')->once()->andReturnFalse(); + + $this->assertSame('failed', $svc->requestCertificate('example.com')['status']); + } + + public function test_external_tls_mode_is_not_applicable_and_never_probes(): void + { + Setting::put('tls_mode', 'external'); + + $svc = Mockery::mock(DeploymentService::class)->makePartial(); + $svc->shouldReceive('domainResolvesHere')->never(); + $svc->shouldReceive('probeCertificate')->never(); + + $this->assertSame('not_applicable', $svc->requestCertificate('example.com')['status']); + } + + public function test_request_action_is_throttled_per_user(): void + { + $this->mock(DeploymentService::class, function ($m) { + $m->makePartial(); + // Stub the serving state directly (domain() otherwise reads a shared snapshot file). + $m->shouldReceive('domain')->andReturn('example.com'); + $m->shouldReceive('externalTls')->andReturn(false); + // The network-touching request is stubbed; the throttle must cap it at 5 calls. + $m->shouldReceive('requestCertificate')->times(5) + ->andReturn(['status' => 'issued', 'checkedAt' => now()->toIso8601String()]); + }); + + $this->actingAs(User::factory()->create(['must_change_password' => false])); + + for ($i = 0; $i < 6; $i++) { + Livewire::test(SystemIndex::class)->call('requestCertificate'); + } + + // 6th call was throttled before reaching the service; 5 audited requests. + $this->assertSame(5, AuditEvent::where('action', 'tls.cert_request')->count()); + } +}