From 65b92bc65c7caab9c78c50978766aa8bcf68ab6c Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 21:04:14 +0200 Subject: [PATCH] feat(uptime): HTTP/TCP health checks + status board (feature 8/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uptime monitoring for the services the fleet hosts: HTTP and TCP probes with a live up/down status board. Completes the monitoring trio (alerts, certs, uptime). - HealthService::probe(check): native — Laravel HTTP client (withoutRedirecting; reports only the status code + latency, never the response body → no internal-content exfil) for http, a PHP TCP stream for tcp. No SSH, no shell, so the target is never interpolated into a command. 2xx/3xx = up; a 4xx/5xx means "answered but unhealthy"; a connect error = down. - Health\Index page (route /health, `operate`-gated: route + mount + per-method). Add/remove checks (http target validated as a real http(s) URL; tcp target as a hostname/IP + a required port). Lazy per-check probe, each guarded. Up/total summary. Delete via signed ConfirmToken + R5 modal. Add/delete audited. - lang/{de,en}/health.php + audit.php health.check_* + shell.nav_health (de/en parity). No emoji. 11 new tests: service (http 2xx up / 5xx down / connection-error down, tcp refused-port down), component (route gating, add http + audit, reject non-url http target, tcp requires host+port, reject missing port, scan probes each check, delete via confirmed token). 742 tests green, Pint, lang parity, self-reviewed. A PUBLIC (unauthenticated) status page is a documented future. Co-Authored-By: Claude Fable 5 --- app/Livewire/Health/Index.php | 158 ++++++++++++++++++ app/Models/HealthCheck.php | 26 +++ app/Services/HealthService.php | 66 ++++++++ app/Support/Confirm/ConfirmToken.php | 1 + ...7_05_100006_create_health_checks_table.php | 26 +++ lang/de/audit.php | 2 + lang/de/health.php | 38 +++++ lang/de/shell.php | 1 + lang/en/audit.php | 2 + lang/en/health.php | 38 +++++ lang/en/shell.php | 1 + resources/views/components/sidebar.blade.php | 1 + .../views/livewire/health/index.blade.php | 82 +++++++++ routes/web.php | 2 + tests/Feature/HealthComponentTest.php | 126 ++++++++++++++ tests/Feature/HealthServiceTest.php | 62 +++++++ 16 files changed, 632 insertions(+) create mode 100644 app/Livewire/Health/Index.php create mode 100644 app/Models/HealthCheck.php create mode 100644 app/Services/HealthService.php create mode 100644 database/migrations/2026_07_05_100006_create_health_checks_table.php create mode 100644 lang/de/health.php create mode 100644 lang/en/health.php create mode 100644 resources/views/livewire/health/index.blade.php create mode 100644 tests/Feature/HealthComponentTest.php create mode 100644 tests/Feature/HealthServiceTest.php diff --git a/app/Livewire/Health/Index.php b/app/Livewire/Health/Index.php new file mode 100644 index 0000000..7673956 --- /dev/null +++ b/app/Livewire/Health/Index.php @@ -0,0 +1,158 @@ + check uuid → probe result */ + public array $results = []; + + public bool $ready = false; + + public function mount(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + public function title(): string + { + return __('health.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(HealthService $health): void + { + $this->gate(); + $this->results = []; + + foreach (HealthCheck::orderBy('label')->orderBy('target')->get() as $check) { + try { + $this->results[$check->uuid] = $health->probe($check); + } catch (Throwable $e) { + $this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()]; + } + } + + $this->ready = true; + } + + public function addCheck(): void + { + $this->gate(); + + // HTTP → a real http(s) URL; TCP → a hostname/IP + a port. Neither is ever shelled. + $rules = [ + 'label' => ['nullable', 'string', 'max:60'], + 'type' => ['required', Rule::in(HealthCheck::TYPES)], + ]; + if ($this->type === 'tcp') { + $rules['target'] = ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/']; + $rules['port'] = ['required', 'integer', 'min:1', 'max:65535']; + } else { + $rules['target'] = ['required', 'url:http,https', 'max:2048']; + $rules['port'] = ['nullable']; + } + + $data = $this->validate($rules); + + HealthCheck::create([ + 'label' => $data['label'] ?? null, + 'type' => $data['type'], + 'target' => $data['target'], + 'port' => $this->type === 'tcp' ? $data['port'] : null, + ]); + + $this->audit('health.check_add', $data['target']); + $this->reset('label', 'target', 'port'); + $this->type = 'http'; + $this->dispatch('notify', message: __('health.added')); + } + + public function confirmDeleteCheck(string $uuid): void + { + $this->gate(); + $check = HealthCheck::where('uuid', $uuid)->firstOrFail(); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('health.delete_heading'), + 'body' => __('health.delete_body', ['target' => $check->target]), + 'confirmLabel' => __('common.delete'), + 'danger' => true, + 'icon' => 'trash', + 'notify' => __('health.deleted'), + 'token' => ConfirmToken::issue('healthCheckDeleted', ['uuid' => $check->uuid], 'health.check_delete', $check->target, null), + ], + ); + } + + #[On('healthCheckDeleted')] + public function deleteCheck(string $confirmToken): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'healthCheckDeleted'); + } catch (InvalidConfirmToken) { + return; + } + + HealthCheck::where('uuid', $payload['params']['uuid'])->first()?->delete(); + } + + public function render(): View + { + $checks = HealthCheck::orderBy('label')->orderBy('target')->get(); + $up = count(array_filter($this->results, fn ($r) => $r['ok'] ?? false)); + + return view('livewire.health.index', [ + 'checks' => $checks, + 'up' => $up, + 'total' => count($this->results), + ])->title($this->title()); + } +} diff --git a/app/Models/HealthCheck.php b/app/Models/HealthCheck.php new file mode 100644 index 0000000..42916d8 --- /dev/null +++ b/app/Models/HealthCheck.php @@ -0,0 +1,26 @@ + 'integer']; + + public const TYPES = ['http', 'tcp']; + + protected static function booted(): void + { + static::creating(fn (self $c) => $c->uuid ??= (string) Str::uuid()); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/app/Services/HealthService.php b/app/Services/HealthService.php new file mode 100644 index 0000000..1645329 --- /dev/null +++ b/app/Services/HealthService.php @@ -0,0 +1,66 @@ +type === 'tcp' + ? $this->tcp((string) $check->target, (int) $check->port, $timeout) + : $this->http((string) $check->target, $timeout); + } + + private function http(string $url, int $timeout): array + { + $start = microtime(true); + try { + $res = Http::timeout($timeout)->withoutRedirecting()->get($url); + $latency = (int) round((microtime(true) - $start) * 1000); + $status = $res->status(); + + // 2xx/3xx = up; a 4xx/5xx means the endpoint answered but is unhealthy. + return ['ok' => $status >= 200 && $status < 400, 'latency' => $latency, 'detail' => 'HTTP '.$status]; + } catch (Throwable $e) { + return ['ok' => false, 'latency' => null, 'detail' => $this->reason($e->getMessage())]; + } + } + + private function tcp(string $host, int $port, int $timeout): array + { + $start = microtime(true); + $errno = 0; + $errstr = ''; + $sock = @stream_socket_client('tcp://'.$host.':'.$port, $errno, $errstr, $timeout); + + if (! $sock) { + return ['ok' => false, 'latency' => null, 'detail' => $errstr !== '' ? $errstr : 'connection failed']; + } + + fclose($sock); + $latency = (int) round((microtime(true) - $start) * 1000); + + return ['ok' => true, 'latency' => $latency, 'detail' => 'TCP '.$port.' open']; + } + + /** Keep the failure reason short + on one line for the compact status row. */ + private function reason(string $msg): string + { + $msg = trim(preg_split('/\R/', $msg)[0] ?? $msg); + + return mb_strlen($msg) > 80 ? mb_substr($msg, 0, 80).'…' : $msg; + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 44e0688..579b969 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -72,6 +72,7 @@ class ConfirmToken 'runbookDeleted', 'patchApply', 'certEndpointDeleted', + 'healthCheckDeleted', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/database/migrations/2026_07_05_100006_create_health_checks_table.php b/database/migrations/2026_07_05_100006_create_health_checks_table.php new file mode 100644 index 0000000..146f753 --- /dev/null +++ b/database/migrations/2026_07_05_100006_create_health_checks_table.php @@ -0,0 +1,26 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('label')->nullable(); + $table->string('type')->default('http'); // http | tcp + $table->string('target'); // http: a URL; tcp: a host + $table->unsignedSmallInteger('port')->nullable(); // tcp only + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('health_checks'); + } +}; diff --git a/lang/de/audit.php b/lang/de/audit.php index daf7651..ed43b19 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -67,6 +67,8 @@ return [ 'firewall.rule_add' => 'Firewall-Regel hinzugefügt', 'firewall.rule_delete' => 'Firewall-Regel gelöscht', 'group.create' => 'Server-Gruppe angelegt', + 'health.check_add' => 'Verfügbarkeits-Prüfung hinzugefügt', + 'health.check_delete' => 'Verfügbarkeits-Prüfung entfernt', 'group.update' => 'Server-Gruppe geändert', 'group.delete' => 'Server-Gruppe gelöscht', 'group.members' => 'Gruppen-Zuordnung geändert', diff --git a/lang/de/health.php b/lang/de/health.php new file mode 100644 index 0000000..a641c4e --- /dev/null +++ b/lang/de/health.php @@ -0,0 +1,38 @@ + 'Betrieb', + 'title' => 'Verfügbarkeit', + 'subtitle' => 'HTTP-/TCP-Erreichbarkeit überwachter Dienste', + + 'scan_hint' => 'Prüfe Dienste …', + 'up_of' => ':up/:total erreichbar', + + // Add + 'new_check' => 'Neue Prüfung', + 'label_label' => 'Bezeichnung', + 'type_label' => 'Typ', + 'type_http' => 'HTTP', + 'type_tcp' => 'TCP', + 'target_label' => 'Ziel', + 'target_http_placeholder' => 'https://dienst.example.com', + 'target_tcp_placeholder' => 'host.example.com', + 'port_label' => 'Port', + 'add' => 'Hinzufügen', + + // List + 'no_checks_title' => 'Keine Prüfungen', + 'no_checks' => 'Noch keine überwachten Dienste. Füge oben den ersten hinzu.', + 'status_up' => 'Erreichbar', + 'status_down' => 'Ausfall', + 'latency' => ':ms ms', + 'delete' => 'Entfernen', + + // Toasts + 'added' => 'Prüfung hinzugefügt.', + 'deleted' => 'Prüfung entfernt.', + + // Delete confirm + 'delete_heading' => 'Prüfung entfernen', + 'delete_body' => ':target wird nicht mehr überwacht.', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index f53f9a8..69de19c 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -28,6 +28,7 @@ return [ 'nav_posture' => 'Sicherheitslage', 'nav_patch' => 'Updates', 'nav_certs' => 'Zertifikate', + 'nav_health' => 'Verfügbarkeit', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', 'nav_help' => 'Hilfe', diff --git a/lang/en/audit.php b/lang/en/audit.php index 1b8e78a..863399e 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -67,6 +67,8 @@ return [ 'firewall.rule_add' => 'Firewall rule added', 'firewall.rule_delete' => 'Firewall rule deleted', 'group.create' => 'Server group created', + 'health.check_add' => 'Uptime check added', + 'health.check_delete' => 'Uptime check removed', 'group.update' => 'Server group changed', 'group.delete' => 'Server group deleted', 'group.members' => 'Group membership changed', diff --git a/lang/en/health.php b/lang/en/health.php new file mode 100644 index 0000000..0806f63 --- /dev/null +++ b/lang/en/health.php @@ -0,0 +1,38 @@ + 'Operations', + 'title' => 'Uptime', + 'subtitle' => 'HTTP/TCP reachability of monitored services', + + 'scan_hint' => 'Checking services …', + 'up_of' => ':up/:total up', + + // Add + 'new_check' => 'New check', + 'label_label' => 'Label', + 'type_label' => 'Type', + 'type_http' => 'HTTP', + 'type_tcp' => 'TCP', + 'target_label' => 'Target', + 'target_http_placeholder' => 'https://service.example.com', + 'target_tcp_placeholder' => 'host.example.com', + 'port_label' => 'Port', + 'add' => 'Add', + + // List + 'no_checks_title' => 'No checks', + 'no_checks' => 'No monitored services yet. Add the first one above.', + 'status_up' => 'Up', + 'status_down' => 'Down', + 'latency' => ':ms ms', + 'delete' => 'Remove', + + // Toasts + 'added' => 'Check added.', + 'deleted' => 'Check removed.', + + // Delete confirm + 'delete_heading' => 'Remove check', + 'delete_body' => ':target will no longer be monitored.', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index fd1ac91..42d7588 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -28,6 +28,7 @@ return [ 'nav_posture' => 'Security posture', 'nav_patch' => 'Updates', 'nav_certs' => 'Certificates', + 'nav_health' => 'Uptime', '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 4f20579..fdb4258 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -64,6 +64,7 @@ {{ __('shell.nav_posture') }} {{ __('shell.nav_patch') }} {{ __('shell.nav_certs') }} + {{ __('shell.nav_health') }} @endcan @can('manage-panel') {{ __('shell.nav_alerts') }} diff --git a/resources/views/livewire/health/index.blade.php b/resources/views/livewire/health/index.blade.php new file mode 100644 index 0000000..ffac549 --- /dev/null +++ b/resources/views/livewire/health/index.blade.php @@ -0,0 +1,82 @@ +@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'; +@endphp + +
+ {{-- Header --}} +
+
+

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

+

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

+

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

+
+ @if ($ready && $total > 0) + {{ __('health.up_of', ['up' => $up, 'total' => $total]) }} + @endif +
+ + {{-- Add check --}} + +
+
+ + +
+
+ + +
+
+ + + @error('target')

{{ $message }}

@enderror +
+
$type !== 'tcp'])> + + + @error('port')

{{ $message }}

@enderror +
+ {{ __('health.add') }} +
+
+ + {{-- Checks --}} + @if ($checks->isEmpty()) + +
+

{{ __('health.no_checks_title') }}

+

{{ __('health.no_checks') }}

+
+
+ @else + +
+ @foreach ($checks as $check) + @php($r = $results[$check->uuid] ?? null) +
+ +
+

{{ $check->label ?: $check->target }}

+

+ {{ $check->type }} · {{ $check->target }}@if ($check->type === 'tcp'):{{ $check->port }}@endif + @if ($r) · {{ $r['detail'] }} @endif +

+
+ @if ($r && ! is_null($r['latency'])) + {{ __('health.latency', ['ms' => $r['latency']]) }} + @endif + @if ($r) + {{ $r['ok'] ? __('health.status_up') : __('health.status_down') }} + @endif + {{ __('health.delete') }} +
+ @endforeach +
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 16ce17a..37a31cf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Livewire\Commands; use App\Livewire\Dashboard; use App\Livewire\Docker; use App\Livewire\Files; +use App\Livewire\Health; use App\Livewire\Help; use App\Livewire\Patch; use App\Livewire\Posture; @@ -243,6 +244,7 @@ Route::middleware('auth')->group(function () { 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('/health', Health\Index::class)->middleware('can:operate')->name('health'); 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/HealthComponentTest.php b/tests/Feature/HealthComponentTest.php new file mode 100644 index 0000000..312bf05 --- /dev/null +++ b/tests/Feature/HealthComponentTest.php @@ -0,0 +1,126 @@ +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_health(): void + { + $this->actingAs($this->viewer())->get('/health')->assertForbidden(); + } + + public function test_operator_can_open_health(): void + { + $this->actingAs($this->operator())->get('/health')->assertOk(); + } + + public function test_operator_adds_an_http_check_and_it_is_audited(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('type', 'http') + ->set('target', 'https://svc.example.com/health') + ->call('addCheck') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('health_checks', ['type' => 'http', 'target' => 'https://svc.example.com/health', 'port' => null]); + $this->assertDatabaseHas('audit_events', ['action' => 'health.check_add']); + } + + public function test_http_check_rejects_a_non_url(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('type', 'http') + ->set('target', 'not a url; rm -rf') + ->call('addCheck') + ->assertHasErrors('target'); + + $this->assertDatabaseCount('health_checks', 0); + } + + public function test_tcp_check_requires_a_host_and_port(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('type', 'tcp') + ->set('target', 'db.example.com') + ->set('port', 5432) + ->call('addCheck') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('health_checks', ['type' => 'tcp', 'target' => 'db.example.com', 'port' => 5432]); + } + + public function test_tcp_check_rejects_a_missing_port(): void + { + $this->actingAs($this->operator()); + + Livewire::test(Index::class) + ->set('type', 'tcp') + ->set('target', 'db.example.com') + ->set('port', null) + ->call('addCheck') + ->assertHasErrors('port'); + } + + public function test_scan_probes_each_check(): void + { + $this->actingAs($this->operator()); + HealthCheck::create(['type' => 'http', 'target' => 'https://svc.example.com']); + + $health = Mockery::mock(HealthService::class); + $health->shouldReceive('probe')->once()->andReturn(['ok' => true, 'latency' => 42, 'detail' => 'HTTP 200']); + app()->instance(HealthService::class, $health); + + Livewire::test(Index::class) + ->call('scan') + ->assertOk() + ->assertSet('ready', true) + ->assertSee(__('health.status_up')) + ->assertViewHas('up', 1); + } + + public function test_operator_deletes_a_check_via_a_confirmed_token(): void + { + $this->actingAs($this->operator()); + $check = HealthCheck::create(['type' => 'http', 'target' => 'https://gone.example.com']); + $token = ConfirmToken::issue('healthCheckDeleted', ['uuid' => $check->uuid], 'health.check_delete', $check->target, null); + ConfirmToken::confirm($token); + + Livewire::test(Index::class)->call('deleteCheck', $token)->assertOk(); + + $this->assertDatabaseMissing('health_checks', ['id' => $check->id]); + } +} diff --git a/tests/Feature/HealthServiceTest.php b/tests/Feature/HealthServiceTest.php new file mode 100644 index 0000000..227f57b --- /dev/null +++ b/tests/Feature/HealthServiceTest.php @@ -0,0 +1,62 @@ + 'http', 'target' => $url]); + } + + public function test_http_2xx_is_up(): void + { + Http::fake(['*' => Http::response('ok', 200)]); + + $r = (new HealthService)->probe($this->httpCheck()); + + $this->assertTrue($r['ok']); + $this->assertSame('HTTP 200', $r['detail']); + $this->assertNotNull($r['latency']); + } + + public function test_http_5xx_is_down_but_answered(): void + { + Http::fake(['*' => Http::response('boom', 500)]); + + $r = (new HealthService)->probe($this->httpCheck()); + + $this->assertFalse($r['ok']); + $this->assertSame('HTTP 500', $r['detail']); + } + + public function test_http_connection_error_is_down(): void + { + Http::fake(fn () => throw new ConnectionException('Could not resolve host')); + + $r = (new HealthService)->probe($this->httpCheck()); + + $this->assertFalse($r['ok']); + $this->assertNull($r['latency']); + $this->assertStringContainsString('resolve host', $r['detail']); + } + + public function test_tcp_to_a_refused_port_is_down(): void + { + $check = HealthCheck::create(['type' => 'tcp', 'target' => '127.0.0.1', 'port' => 1]); + + $r = (new HealthService)->probe($check, 1); + + $this->assertFalse($r['ok']); + $this->assertArrayHasKey('detail', $r); + } +}