151 lines
4.8 KiB
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());
|
|
}
|
|
}
|