diff --git a/app/Livewire/Certs/Index.php b/app/Livewire/Certs/Index.php new file mode 100644 index 0000000..5527778 --- /dev/null +++ b/app/Livewire/Certs/Index.php @@ -0,0 +1,139 @@ +> endpoint uuid → check result (+ 'status') */ + public array $checks = []; + + public bool $ready = false; + + public function mount(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + public function title(): string + { + return __('certs.title'); + } + + private function gate(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + private function audit(string $action, string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function scan(CertService $certs): void + { + $this->gate(); + $this->checks = []; + + foreach (CertEndpoint::orderBy('host')->get() as $ep) { + try { + $res = $certs->check($ep->host, $ep->port); + $res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error'; + $this->checks[$ep->uuid] = $res; + } catch (Throwable $e) { + $this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()]; + } + } + + $this->ready = true; + } + + public function addEndpoint(): void + { + $this->gate(); + + $data = $this->validate([ + 'label' => ['nullable', 'string', 'max:60'], + // A hostname or an IP — never a scheme/path/shell char (it's used in ssl://host:port). + 'host' => ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'], + 'port' => ['required', 'integer', 'min:1', 'max:65535'], + ]); + + CertEndpoint::create($data); + $this->audit('cert.endpoint_add', $data['host'].':'.$data['port']); + $this->reset('label', 'host'); + $this->port = 443; + $this->dispatch('notify', message: __('certs.added', ['host' => $data['host']])); + } + + public function confirmDeleteEndpoint(string $uuid): void + { + $this->gate(); + $ep = CertEndpoint::where('uuid', $uuid)->firstOrFail(); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('certs.delete_heading'), + 'body' => __('certs.delete_body', ['host' => $ep->host]), + 'confirmLabel' => __('common.delete'), + 'danger' => true, + 'icon' => 'trash', + 'notify' => __('certs.deleted', ['host' => $ep->host]), + 'token' => ConfirmToken::issue('certEndpointDeleted', ['uuid' => $ep->uuid], 'cert.endpoint_delete', $ep->host.':'.$ep->port, null), + ], + ); + } + + #[On('certEndpointDeleted')] + public function deleteEndpoint(string $confirmToken): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'certEndpointDeleted'); + } catch (InvalidConfirmToken) { + return; + } + + CertEndpoint::where('uuid', $payload['params']['uuid'])->first()?->delete(); + } + + public function render(): View + { + return view('livewire.certs.index', [ + 'endpoints' => CertEndpoint::orderBy('host')->get(), + ])->title($this->title()); + } +} diff --git a/app/Models/CertEndpoint.php b/app/Models/CertEndpoint.php new file mode 100644 index 0000000..d2930e8 --- /dev/null +++ b/app/Models/CertEndpoint.php @@ -0,0 +1,24 @@ + 'integer']; + + protected static function booted(): void + { + static::creating(fn (self $e) => $e->uuid ??= (string) Str::uuid()); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/app/Services/CertService.php b/app/Services/CertService.php new file mode 100644 index 0000000..fc1d6f1 --- /dev/null +++ b/app/Services/CertService.php @@ -0,0 +1,87 @@ + [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'SNI_enabled' => true, + 'peer_name' => $host, + ]]); + + $errno = 0; + $errstr = ''; + // @ — a refused/timed-out/unresolved endpoint is a normal result here, not a PHP warning. + $client = @stream_socket_client( + 'ssl://'.$host.':'.$port, + $errno, + $errstr, + $timeout, + STREAM_CLIENT_CONNECT, + $ctx, + ); + + if (! $client) { + return ['ok' => false, 'error' => $errstr !== '' ? $errstr : 'connection failed']; + } + + $params = stream_context_get_params($client); + fclose($client); + + $cert = $params['options']['ssl']['peer_certificate'] ?? null; + if ($cert === null) { + return ['ok' => false, 'error' => 'no certificate presented']; + } + + return ['ok' => true] + $this->certInfo($cert); + } + + /** + * Extract subject/issuer/expiry from an openssl cert (resource or PEM). Split out so it is unit- + * testable against a generated cert without a live network connection. + * + * @param \OpenSSLCertificate|string $cert + * @return array{subject:string, issuer:string, expiresAt:?int, daysLeft:?int} + */ + public function certInfo($cert): array + { + $parsed = openssl_x509_parse($cert) ?: []; + $expiresAt = isset($parsed['validTo_time_t']) ? (int) $parsed['validTo_time_t'] : null; + + return [ + 'subject' => (string) ($parsed['subject']['CN'] ?? ($parsed['subject']['O'] ?? '—')), + 'issuer' => (string) ($parsed['issuer']['CN'] ?? ($parsed['issuer']['O'] ?? '—')), + 'expiresAt' => $expiresAt, + 'daysLeft' => $expiresAt !== null ? (int) floor(($expiresAt - time()) / 86400) : null, + ]; + } + + /** expired · critical (<7d) · warning (<30d) · ok — mapped to the status triad in the UI. */ + public function status(?int $daysLeft): string + { + if ($daysLeft === null) { + return 'unknown'; + } + + return match (true) { + $daysLeft < 0 => 'expired', + $daysLeft < 7 => 'critical', + $daysLeft < 30 => 'warning', + default => 'ok', + }; + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index f6bbf89..44e0688 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -71,6 +71,7 @@ class ConfirmToken 'commandRun', 'runbookDeleted', 'patchApply', + 'certEndpointDeleted', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/database/migrations/2026_07_05_100005_create_cert_endpoints_table.php b/database/migrations/2026_07_05_100005_create_cert_endpoints_table.php new file mode 100644 index 0000000..7caa6b0 --- /dev/null +++ b/database/migrations/2026_07_05_100005_create_cert_endpoints_table.php @@ -0,0 +1,27 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('label')->nullable(); + $table->string('host'); + $table->unsignedSmallInteger('port')->default(443); + $table->timestamps(); + + $table->unique(['host', 'port']); + }); + } + + public function down(): void + { + Schema::dropIfExists('cert_endpoints'); + } +}; diff --git a/lang/de/audit.php b/lang/de/audit.php index 9470093..daf7651 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -57,6 +57,8 @@ return [ 'fail2ban.configure' => 'fail2ban konfiguriert', 'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt', 'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt', + 'cert.endpoint_add' => 'Zertifikat-Endpunkt hinzugefügt', + 'cert.endpoint_delete' => 'Zertifikat-Endpunkt entfernt', 'command.run' => 'Fleet-Befehl ausgeführt', 'docker.action' => 'Container-Aktion', 'runbook.create' => 'Runbook angelegt', diff --git a/lang/de/certs.php b/lang/de/certs.php new file mode 100644 index 0000000..3e4375c --- /dev/null +++ b/lang/de/certs.php @@ -0,0 +1,42 @@ + 'Sicherheit', + 'title' => 'Zertifikate', + 'subtitle' => 'TLS-Ablauf überwachter Endpunkte', + + 'scan_hint' => 'Prüfe Zertifikate …', + + // Add + 'new_endpoint' => 'Neuer Endpunkt', + 'label_label' => 'Bezeichnung', + 'host_label' => 'Host', + 'port_label' => 'Port', + 'add' => 'Hinzufügen', + + // List + 'no_endpoints_title' => 'Keine Endpunkte', + 'no_endpoints' => 'Noch keine überwachten Endpunkte. Füge oben den ersten hinzu.', + 'expires_in' => 'läuft in :days Tagen ab', + 'expired_since' => 'abgelaufen vor :days Tagen', + 'expires_today' => 'läuft heute ab', + 'issuer' => 'Aussteller: :issuer', + 'check_error' => 'nicht erreichbar', + 'delete' => 'Entfernen', + + // Status + 'status_ok' => 'Gültig', + 'status_warning' => 'Bald fällig', + 'status_critical' => 'Kritisch', + 'status_expired' => 'Abgelaufen', + 'status_error' => 'Fehler', + 'status_unknown' => 'Unbekannt', + + // Toasts + 'added' => 'Endpunkt :host hinzugefügt.', + 'deleted' => 'Endpunkt :host entfernt.', + + // Delete confirm + 'delete_heading' => 'Endpunkt entfernen', + 'delete_body' => 'Der Endpunkt :host wird nicht mehr überwacht.', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index a95603d..f53f9a8 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -27,6 +27,7 @@ return [ 'nav_alerts' => 'Alarme', 'nav_posture' => 'Sicherheitslage', 'nav_patch' => 'Updates', + 'nav_certs' => 'Zertifikate', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', 'nav_help' => 'Hilfe', diff --git a/lang/en/audit.php b/lang/en/audit.php index f24b874..1b8e78a 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -57,6 +57,8 @@ return [ 'fail2ban.configure' => 'fail2ban configured', 'fail2ban.ignoreip_add' => 'fail2ban: exception added', 'fail2ban.ignoreip_remove' => 'fail2ban: exception removed', + 'cert.endpoint_add' => 'Certificate endpoint added', + 'cert.endpoint_delete' => 'Certificate endpoint removed', 'command.run' => 'Fleet command run', 'docker.action' => 'Container action', 'runbook.create' => 'Runbook created', diff --git a/lang/en/certs.php b/lang/en/certs.php new file mode 100644 index 0000000..b64c46a --- /dev/null +++ b/lang/en/certs.php @@ -0,0 +1,42 @@ + 'Security', + 'title' => 'Certificates', + 'subtitle' => 'TLS expiry of monitored endpoints', + + 'scan_hint' => 'Checking certificates …', + + // Add + 'new_endpoint' => 'New endpoint', + 'label_label' => 'Label', + 'host_label' => 'Host', + 'port_label' => 'Port', + 'add' => 'Add', + + // List + 'no_endpoints_title' => 'No endpoints', + 'no_endpoints' => 'No monitored endpoints yet. Add the first one above.', + 'expires_in' => 'expires in :days days', + 'expired_since' => 'expired :days days ago', + 'expires_today' => 'expires today', + 'issuer' => 'Issuer: :issuer', + 'check_error' => 'unreachable', + 'delete' => 'Remove', + + // Status + 'status_ok' => 'Valid', + 'status_warning' => 'Due soon', + 'status_critical' => 'Critical', + 'status_expired' => 'Expired', + 'status_error' => 'Error', + 'status_unknown' => 'Unknown', + + // Toasts + 'added' => 'Endpoint :host added.', + 'deleted' => 'Endpoint :host removed.', + + // Delete confirm + 'delete_heading' => 'Remove endpoint', + 'delete_body' => 'The endpoint :host will no longer be monitored.', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 6b18bb4..fd1ac91 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -27,6 +27,7 @@ return [ 'nav_alerts' => 'Alerts', 'nav_posture' => 'Security posture', 'nav_patch' => 'Updates', + 'nav_certs' => 'Certificates', 'nav_threats' => 'Threats', 'nav_versions' => 'Version', 'nav_help' => 'Help', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index b381e4f..4f20579 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -63,6 +63,7 @@ @can('operate') {{ __('shell.nav_posture') }} {{ __('shell.nav_patch') }} + {{ __('shell.nav_certs') }} @endcan @can('manage-panel') {{ __('shell.nav_alerts') }} diff --git a/resources/views/livewire/certs/index.blade.php b/resources/views/livewire/certs/index.blade.php new file mode 100644 index 0000000..f307ed2 --- /dev/null +++ b/resources/views/livewire/certs/index.blade.php @@ -0,0 +1,78 @@ +@php + $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $statusPill = ['ok' => 'online', 'warning' => 'warning', 'critical' => 'offline', 'expired' => 'offline', 'error' => 'offline', 'unknown' => 'pending']; +@endphp + +
+ {{-- Header --}} +
+

{{ __('certs.eyebrow') }}

+

{{ __('certs.title') }}

+

{{ __('certs.subtitle') }}

+
+ + {{-- Add endpoint --}} + +
+
+ + +
+
+ + + @error('host')

{{ $message }}

@enderror +
+
+ + + @error('port')

{{ $message }}

@enderror +
+ {{ __('certs.add') }} +
+
+ + {{-- Endpoints --}} + @if ($endpoints->isEmpty()) + +
+

{{ __('certs.no_endpoints_title') }}

+

{{ __('certs.no_endpoints') }}

+
+
+ @else + +
+ @foreach ($endpoints as $ep) + @php($c = $checks[$ep->uuid] ?? null) +
+ +
+

{{ $ep->label ?: $ep->host }}

+

+ {{ $ep->host }}:{{ $ep->port }} + @if ($c && ($c['ok'] ?? false) && ! is_null($c['daysLeft'] ?? null)) + · + @if ($c['daysLeft'] < 0) + {{ __('certs.expired_since', ['days' => abs($c['daysLeft'])]) }} + @elseif ($c['daysLeft'] === 0) + {{ __('certs.expires_today') }} + @else + {{ __('certs.expires_in', ['days' => $c['daysLeft']]) }} + @endif + @elseif ($c && ! ($c['ok'] ?? false)) + · {{ __('certs.check_error') }} + @endif +

+
+ @if ($c) + {{ __('certs.status_'.$c['status']) }} + @endif + {{ __('certs.delete') }} +
+ @endforeach +
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 4b58d99..16ce17a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Middleware\VerifyTerminalSidecarSecret; use App\Livewire\Alerts; use App\Livewire\Audit; use App\Livewire\Auth; +use App\Livewire\Certs; use App\Livewire\Commands; use App\Livewire\Dashboard; use App\Livewire\Docker; @@ -241,6 +242,7 @@ Route::middleware('auth')->group(function () { Route::get('/system', System\Index::class)->name('system'); Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture'); Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch'); + Route::get('/certs', Certs\Index::class)->middleware('can:operate')->name('certs'); Route::get('/help', Help\Index::class)->name('help'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); Route::get('/terminal', Terminal\Index::class)->name('terminal'); diff --git a/tests/Feature/CertServiceTest.php b/tests/Feature/CertServiceTest.php new file mode 100644 index 0000000..36fa0f4 --- /dev/null +++ b/tests/Feature/CertServiceTest.php @@ -0,0 +1,48 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + $csr = openssl_csr_new(['commonName' => $cn, 'organizationName' => 'Clusev Test'], $pk); + + return openssl_csr_sign($csr, null, $pk, $days); + } + + public function test_cert_info_extracts_subject_and_expiry(): void + { + $info = (new CertService)->certInfo($this->makeCert('svc.example.com', 30)); + + $this->assertSame('svc.example.com', $info['subject']); + $this->assertNotNull($info['expiresAt']); + $this->assertGreaterThanOrEqual(28, $info['daysLeft']); // ~30 days out + $this->assertLessThanOrEqual(30, $info['daysLeft']); + } + + public function test_status_maps_days_left_to_the_triad(): void + { + $svc = new CertService; + + $this->assertSame('expired', $svc->status(-1)); + $this->assertSame('critical', $svc->status(3)); + $this->assertSame('warning', $svc->status(20)); + $this->assertSame('ok', $svc->status(60)); + $this->assertSame('unknown', $svc->status(null)); + } + + public function test_check_returns_an_error_for_an_unreachable_endpoint(): void + { + // 127.0.0.1:1 is refused immediately → a fast, network-free failure. + $res = (new CertService)->check('127.0.0.1', 1, 1); + + $this->assertFalse($res['ok']); + $this->assertArrayHasKey('error', $res); + } +} diff --git a/tests/Feature/CertsComponentTest.php b/tests/Feature/CertsComponentTest.php new file mode 100644 index 0000000..b3c0937 --- /dev/null +++ b/tests/Feature/CertsComponentTest.php @@ -0,0 +1,112 @@ +operator()->create(['must_change_password' => false]); + } + + private function viewer(): User + { + return User::factory()->viewer()->create(['must_change_password' => false]); + } + + public function test_viewer_cannot_open_certs(): void + { + $this->actingAs($this->viewer())->get('/certs')->assertForbidden(); + } + + public function test_operator_can_open_certs(): void + { + $this->actingAs($this->operator())->get('/certs')->assertOk(); + } + + public function test_operator_adds_an_endpoint_and_it_is_audited(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('label', 'Panel') + ->set('host', 'panel.example.com') + ->set('port', 443) + ->call('addEndpoint') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('cert_endpoints', ['host' => 'panel.example.com', 'port' => 443]); + $this->assertDatabaseHas('audit_events', ['action' => 'cert.endpoint_add']); + } + + public function test_add_rejects_a_host_with_a_scheme_or_metacharacters(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('host', 'https://evil.com/;rm -rf') + ->set('port', 443) + ->call('addEndpoint') + ->assertHasErrors('host'); + + $this->assertDatabaseCount('cert_endpoints', 0); + } + + public function test_add_rejects_an_out_of_range_port(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('host', 'ok.example.com') + ->set('port', 99999) + ->call('addEndpoint') + ->assertHasErrors('port'); + } + + public function test_scan_checks_each_endpoint(): void + { + $this->actingAs($this->operator()); + CertEndpoint::create(['host' => 'svc.example.com', 'port' => 443]); + + $certs = Mockery::mock(CertService::class); + $certs->shouldReceive('check')->once()->andReturn(['ok' => true, 'subject' => 'svc.example.com', 'issuer' => 'LE', 'expiresAt' => time() + 5 * 86400, 'daysLeft' => 5]); + $certs->shouldReceive('status')->once()->with(5)->andReturn('critical'); + app()->instance(CertService::class, $certs); + + Livewire::test(Index::class) + ->call('scan') + ->assertOk() + ->assertSet('ready', true) + ->assertSee(__('certs.status_critical')); + } + + public function test_operator_deletes_an_endpoint_via_a_confirmed_token(): void + { + $this->actingAs($this->operator()); + $ep = CertEndpoint::create(['host' => 'gone.example.com', 'port' => 443]); + $token = ConfirmToken::issue('certEndpointDeleted', ['uuid' => $ep->uuid], 'cert.endpoint_delete', $ep->host, null); + ConfirmToken::confirm($token); + + Livewire::test(Index::class)->call('deleteEndpoint', $token)->assertOk(); + + $this->assertDatabaseMissing('cert_endpoints', ['id' => $ep->id]); + } +}