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')
{{ __('certs.eyebrow') }}
+{{ __('certs.subtitle') }}
+{{ __('certs.no_endpoints_title') }}
+{{ __('certs.no_endpoints') }}
+{{ $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 +
+