clusev/app/Livewire/Certs/Index.php

151 lines
4.8 KiB
PHP

<?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(CertService $certs): 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'],
]);
$ep = CertEndpoint::create($data);
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
// Probe the new endpoint right away so its validity shows immediately (not only after a
// manual refresh / the next scheduled scan).
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->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());
}
}