feat(security): TLS certificate expiry monitoring (feature 7/8)
Monitor the certificate expiry of configured TLS endpoints (the panel's own domain, fleet
services, anything reachable) and flag the ones due soon — before they lapse.
- CertService::check(host, port) reads the peer certificate NATIVELY via a PHP SSL stream +
openssl_x509_parse — no SSH, no shell, so `host` is never interpolated into a command. Trust is
intentionally NOT verified (verify_peer off): the goal is to report expiry even for a self-signed
or already-expired cert. certInfo() (subject/issuer/expiry/daysLeft) is split out for unit testing
against a generated cert; status() maps daysLeft → ok / warning (<30) / critical (<7) / expired.
- Certs\Index page (route /certs, `operate`-gated: route + mount + per-method). Add/remove endpoints
(host validated as a hostname/IP — rejects scheme/path/shell chars; port 1-65535). Lazy per-endpoint
scan, each guarded. Delete via signed ConfirmToken + R5 modal. Add/delete audited. Internal
endpoints are allowed on purpose (monitoring internal service certs is legitimate; the check only
reads a cert, it POSTs nothing).
- lang/{de,en}/certs.php + audit.php cert.endpoint_* + shell.nav_certs (de/en parity). No emoji.
10 new tests: service (certInfo extracts subject/expiry from a generated cert, status thresholds,
check errors on an unreachable endpoint), component (route gating, add + audit, reject scheme/
metachar host + out-of-range port, scan checks each endpoint, delete via confirmed token). 730 tests
green, Pint, lang parity, self-reviewed. LE issuance is a documented future (needs ACME on the host).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
parent
79862ab014
commit
3cab72bf46
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Certs;
|
||||||
|
|
||||||
|
use App\Models\AuditEvent;
|
||||||
|
use App\Models\CertEndpoint;
|
||||||
|
use App\Services\CertService;
|
||||||
|
use App\Support\Confirm\ConfirmToken;
|
||||||
|
use App\Support\Confirm\InvalidConfirmToken;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS certificate-expiry monitoring for configured endpoints. `operate`-gated (route + mount +
|
||||||
|
* per-method). The expiry check is native (a PHP SSL stream in CertService — no SSH/shell, so the
|
||||||
|
* host is never interpolated into a command); the lazy scan is per-endpoint guarded. Deleting an
|
||||||
|
* endpoint goes through a signed ConfirmToken + R5 modal.
|
||||||
|
*/
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
// Add-endpoint form
|
||||||
|
public string $label = '';
|
||||||
|
|
||||||
|
public string $host = '';
|
||||||
|
|
||||||
|
public int $port = 443;
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/** A TLS endpoint (host:port) whose certificate expiry Clusev monitors. */
|
||||||
|
class CertEndpoint extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = ['port' => 'integer'];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(fn (self $e) => $e->uuid ??= (string) Str::uuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a TLS endpoint's certificate expiry natively (a PHP SSL stream + openssl_x509_parse) — no
|
||||||
|
* SSH, no shell, so `host` is never interpolated into a command. Trust is intentionally NOT verified
|
||||||
|
* (verify_peer off): the point is to report expiry even for a self-signed or already-expired cert.
|
||||||
|
* Monitoring internal endpoints is a legitimate use, so private/internal hosts are allowed.
|
||||||
|
*/
|
||||||
|
class CertService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool, subject?:string, issuer?:string, expiresAt?:int, daysLeft?:?int, error?:string}
|
||||||
|
*/
|
||||||
|
public function check(string $host, int $port, int $timeout = 5): array
|
||||||
|
{
|
||||||
|
$ctx = stream_context_create(['ssl' => [
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,7 @@ class ConfirmToken
|
||||||
'commandRun',
|
'commandRun',
|
||||||
'runbookDeleted',
|
'runbookDeleted',
|
||||||
'patchApply',
|
'patchApply',
|
||||||
|
'certEndpointDeleted',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cert_endpoints', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -57,6 +57,8 @@ return [
|
||||||
'fail2ban.configure' => 'fail2ban konfiguriert',
|
'fail2ban.configure' => 'fail2ban konfiguriert',
|
||||||
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
|
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
|
||||||
'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt',
|
'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',
|
'command.run' => 'Fleet-Befehl ausgeführt',
|
||||||
'docker.action' => 'Container-Aktion',
|
'docker.action' => 'Container-Aktion',
|
||||||
'runbook.create' => 'Runbook angelegt',
|
'runbook.create' => 'Runbook angelegt',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'eyebrow' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -27,6 +27,7 @@ return [
|
||||||
'nav_alerts' => 'Alarme',
|
'nav_alerts' => 'Alarme',
|
||||||
'nav_posture' => 'Sicherheitslage',
|
'nav_posture' => 'Sicherheitslage',
|
||||||
'nav_patch' => 'Updates',
|
'nav_patch' => 'Updates',
|
||||||
|
'nav_certs' => 'Zertifikate',
|
||||||
'nav_threats' => 'Bedrohungen',
|
'nav_threats' => 'Bedrohungen',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Hilfe',
|
'nav_help' => 'Hilfe',
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ return [
|
||||||
'fail2ban.configure' => 'fail2ban configured',
|
'fail2ban.configure' => 'fail2ban configured',
|
||||||
'fail2ban.ignoreip_add' => 'fail2ban: exception added',
|
'fail2ban.ignoreip_add' => 'fail2ban: exception added',
|
||||||
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
|
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
|
||||||
|
'cert.endpoint_add' => 'Certificate endpoint added',
|
||||||
|
'cert.endpoint_delete' => 'Certificate endpoint removed',
|
||||||
'command.run' => 'Fleet command run',
|
'command.run' => 'Fleet command run',
|
||||||
'docker.action' => 'Container action',
|
'docker.action' => 'Container action',
|
||||||
'runbook.create' => 'Runbook created',
|
'runbook.create' => 'Runbook created',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'eyebrow' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -27,6 +27,7 @@ return [
|
||||||
'nav_alerts' => 'Alerts',
|
'nav_alerts' => 'Alerts',
|
||||||
'nav_posture' => 'Security posture',
|
'nav_posture' => 'Security posture',
|
||||||
'nav_patch' => 'Updates',
|
'nav_patch' => 'Updates',
|
||||||
|
'nav_certs' => 'Certificates',
|
||||||
'nav_threats' => 'Threats',
|
'nav_threats' => 'Threats',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Help',
|
'nav_help' => 'Help',
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@
|
||||||
@can('operate')
|
@can('operate')
|
||||||
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
|
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
|
||||||
<x-nav-item icon="rotate" href="/patch" :active="request()->is('patch*')">{{ __('shell.nav_patch') }}</x-nav-item>
|
<x-nav-item icon="rotate" href="/patch" :active="request()->is('patch*')">{{ __('shell.nav_patch') }}</x-nav-item>
|
||||||
|
<x-nav-item icon="lock" href="/certs" :active="request()->is('certs*')">{{ __('shell.nav_certs') }}</x-nav-item>
|
||||||
@endcan
|
@endcan
|
||||||
@can('manage-panel')
|
@can('manage-panel')
|
||||||
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
|
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
<div class="space-y-5" @if (! $ready) wire:init="scan" @endif>
|
||||||
|
{{-- Header --}}
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('certs.eyebrow') }}</p>
|
||||||
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('certs.title') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-ink-3">{{ __('certs.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Add endpoint --}}
|
||||||
|
<x-panel :title="__('certs.new_endpoint')">
|
||||||
|
<form wire:submit="addEndpoint" class="grid gap-3 sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)_6rem_auto] sm:items-end">
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">{{ __('certs.label_label') }}</label>
|
||||||
|
<input wire:model="label" type="text" maxlength="60" class="{{ $field }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">{{ __('certs.host_label') }}</label>
|
||||||
|
<input wire:model="host" type="text" maxlength="253" placeholder="example.com" class="{{ $field }} font-mono" />
|
||||||
|
@error('host') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="{{ $label }}">{{ __('certs.port_label') }}</label>
|
||||||
|
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
|
||||||
|
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<x-btn variant="primary" size="lg" type="submit">{{ __('certs.add') }}</x-btn>
|
||||||
|
</form>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
{{-- Endpoints --}}
|
||||||
|
@if ($endpoints->isEmpty())
|
||||||
|
<x-panel>
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<p class="font-display text-sm font-semibold text-ink">{{ __('certs.no_endpoints_title') }}</p>
|
||||||
|
<p class="mt-1 max-w-sm text-xs text-ink-3">{{ __('certs.no_endpoints') }}</p>
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
@else
|
||||||
|
<x-panel :padded="false">
|
||||||
|
<div class="divide-y divide-line">
|
||||||
|
@foreach ($endpoints as $ep)
|
||||||
|
@php($c = $checks[$ep->uuid] ?? null)
|
||||||
|
<div wire:key="ce-{{ $ep->uuid }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
|
||||||
|
<x-status-dot :status="$c ? ($statusPill[$c['status']] ?? 'pending') : 'pending'" class="h-2.5 w-2.5" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm text-ink">{{ $ep->label ?: $ep->host }}</p>
|
||||||
|
<p class="truncate font-mono text-[11px] text-ink-3">
|
||||||
|
{{ $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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@if ($c)
|
||||||
|
<x-status-pill :status="$statusPill[$c['status']] ?? 'pending'">{{ __('certs.status_'.$c['status']) }}</x-status-pill>
|
||||||
|
@endif
|
||||||
|
<x-modal-trigger variant="danger-soft" action="confirmDeleteEndpoint('{{ $ep->uuid }}')">{{ __('certs.delete') }}</x-modal-trigger>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Http\Middleware\VerifyTerminalSidecarSecret;
|
||||||
use App\Livewire\Alerts;
|
use App\Livewire\Alerts;
|
||||||
use App\Livewire\Audit;
|
use App\Livewire\Audit;
|
||||||
use App\Livewire\Auth;
|
use App\Livewire\Auth;
|
||||||
|
use App\Livewire\Certs;
|
||||||
use App\Livewire\Commands;
|
use App\Livewire\Commands;
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
use App\Livewire\Docker;
|
use App\Livewire\Docker;
|
||||||
|
|
@ -241,6 +242,7 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture');
|
Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture');
|
||||||
Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch');
|
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('/help', Help\Index::class)->name('help');
|
||||||
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
|
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
|
||||||
Route::get('/terminal', Terminal\Index::class)->name('terminal');
|
Route::get('/terminal', Terminal\Index::class)->name('terminal');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Services\CertService;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CertServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
/** A self-signed cert valid for $days days, so certInfo can be tested without a live network. */
|
||||||
|
private function makeCert(string $cn, int $days): \OpenSSLCertificate
|
||||||
|
{
|
||||||
|
$pk = openssl_pkey_new(['private_key_bits' => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Certs\Index;
|
||||||
|
use App\Models\CertEndpoint;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\CertService;
|
||||||
|
use App\Support\Confirm\ConfirmToken;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CertsComponentTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operator(): User
|
||||||
|
{
|
||||||
|
return User::factory()->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue