feat(tls): dashboard "request certificate" button (trigger Caddy on-demand TLS)

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 <domain>: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:<id>, 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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-17 18:38:21 +02:00
parent 4f1ea8ef0f
commit 1fb6b34fd1
7 changed files with 375 additions and 1 deletions

View File

@ -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

View File

@ -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'));
}
}

View File

@ -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;
}
}

View File

@ -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',
];

View File

@ -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',
];

View File

@ -190,6 +190,41 @@
</div>
@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
<div class="rounded-md border border-line bg-inset/60 p-3">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.cert_title') }}</p>
<p class="mt-1 font-mono text-[11px] leading-relaxed">
@switch($certState)
@case('issued')
<span class="text-online">{{ __('system.cert_status_issued') }}</span>
@break
@case('dns_mismatch')
<span class="text-warning">{{ __('system.cert_status_dns_mismatch') }}</span>
@break
@case('failed')
<span class="text-offline">{{ __('system.cert_status_failed') }}</span>
@break
@default
<span class="text-ink-3">{{ __('system.cert_status_unknown') }}</span>
@endswitch
</p>
<x-btn variant="secondary" size="sm" class="mt-2.5"
wire:click="requestCertificate" wire:target="requestCertificate" wire:loading.attr="disabled">
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestCertificate" />
<svg wire:loading wire:target="requestCertificate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('system.cert_button') }}
</x-btn>
</div>
@elseif ($tlsMode === 'external')
<div class="rounded-md border border-line bg-inset/60 p-3">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.cert_title') }}</p>
<p class="mt-1 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.cert_external_note') }}</p>
</div>
@endif
{{-- Last-resort recovery: SSH into the host and reset admin access. --}}
<div class="flex items-start gap-2.5 border-t border-line pt-4 text-ink-4">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0" />

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use App\Livewire\System\Index as SystemIndex;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Models\User;
use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* The dashboard "request certificate" flow: a DNS pre-check that must NOT trigger ACME on a
* mismatch (Let's-Encrypt rate-limit protection), and a per-user throttle on the action.
*/
class TlsCertificateRequestTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_dns_mismatch_does_not_trigger_a_handshake(): void
{
$svc = Mockery::mock(DeploymentService::class)->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());
}
}