; localized at runtime (attributes can't call __()). */ public function title(): string { return __('servers.show_title'); } public bool $connected = false; public bool $ready = false; public float $loadAvg = 0; public array $volumes = []; public array $interfaces = []; public array $hardening = []; /** Firewall state (installed/active/defaults/rules) for the Firewall-Regeln panel. */ public array $firewall = []; /** fail2ban state (jails/banned IPs/whitelist) for the fail2ban-Status panel. */ public array $fail2ban = []; /** Input for adding an IP/CIDR to the fail2ban whitelist. */ public string $newIgnoreIp = ''; /** Detected OS tooling (family/package manager/firewall) for the identity panel. */ public array $os = []; public array $sshKeys = []; /** * Pull a live snapshot over SSH and persist identity/metrics onto the row. * On any failure (no credential, unreachable) the page renders an offline * state from the last persisted values instead of crashing. */ public function mount(): void { // Viewing a server's details makes it the ACTIVE server, so the sidebar switcher // (and the scoped sections) reflect the server on screen. Set ONLY at mount — i.e. // once per deliberate navigation — so a slow in-flight load() or a parallel tab // can never write a stale value over a newer selection (the single-session active // server is last-writer-wins; we keep writes to deliberate navigations only). session(['active_server_id' => $this->server->id]); // identity + gauges render from the persisted row immediately; // the live snapshot loads lazily via wire:init -> load(). } /** Lazy: pull the full SSH snapshot after the shell renders, behind skeletons. */ public function load(FleetService $fleet): void { try { $snap = $fleet->snapshot($this->server); $id = $snap['identity']; $rootUsed = collect($snap['volumes'])->firstWhere('mount', '/')['used'] ?? $this->server->disk; $this->server->forceFill([ 'cpu' => $snap['metrics']['cpu'], 'mem' => $snap['metrics']['mem'], 'disk' => $rootUsed, 'os' => $id['os'], 'hostname' => $id['hostname'], 'uptime' => $id['uptime'], 'status' => 'online', 'last_seen_at' => now(), 'specs' => [ 'cores' => $id['cores'], 'ram_gb' => $id['ram_gb'], 'disk_gb' => $id['disk_gb'], 'arch' => $id['arch'], 'kernel' => $id['kernel'], 'virt' => $id['virt'], ], ])->save(); $this->volumes = $snap['volumes']; $this->interfaces = $snap['interfaces']; $this->sshKeys = $snap['sshKeys']; // Detected OS profile (cached) — surfaces the package manager + firewall // tooling and drives which hardening features are offered. try { // fresh probe on each full load; primes the 1h cache the hardening, // firewall and update reads reuse within this request cycle. $profile = app(OsDetector::class)->detect($this->server, fresh: true); $this->os = [ 'familyLabel' => $profile->familyLabel(), 'packageManager' => $profile->packageManager, 'firewallTool' => $profile->firewallTool, 'serviceManager' => $profile->serviceManager, ]; } catch (Throwable) { $this->os = []; } // Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks). try { $this->hardening = app(HardeningService::class)->state($this->server); } catch (Throwable) { $this->hardening = []; } // Firewall rules/defaults (separate PRIVILEGED read; empty on failure). try { $this->firewall = app(FirewallService::class)->status($this->server); } catch (Throwable) { $this->firewall = []; } // fail2ban jails/banned IPs/whitelist (separate PRIVILEGED read). try { $this->fail2ban = app(Fail2banService::class)->status($this->server); } catch (Throwable) { $this->fail2ban = []; } $this->loadAvg = (float) ($snap['metrics']['load'] ?? 0); $this->connected = true; } catch (Throwable) { $this->connected = false; if ($this->server->status !== 'offline') { $this->server->forceFill(['status' => 'offline'])->save(); } } $this->ready = true; } /** Refresh the live gauges from the poller-updated row (wire:poll). */ public function pollMetrics(): void { // Only the Übersicht tab shows live metrics; skip the refresh/re-render on Docker/Terminal. if ($this->tab !== 'overview') { return; } $this->server->refresh(); } /** * Revoke an SSH key (R5): opens the confirm modal, which writes the * AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later. */ public function confirmKeyRemoval(int $index): void { abort_unless(auth()->user()?->can('manage-fleet'), 403); $key = $this->sshKeys[$index] ?? null; if ($key === null) { return; } $fingerprint = $key['fingerprint']; $comment = $key['comment']; $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('servers.confirm_remove_key_heading'), 'body' => __('servers.confirm_remove_key_body', ['name' => $comment, 'server' => $this->server->name]), 'confirmLabel' => __('common.remove'), 'danger' => true, 'icon' => 'trash', 'notify' => __('servers.notify_key_removed', ['name' => $comment]), 'token' => ConfirmToken::issue( 'keyRemoved', ['fingerprint' => $fingerprint], 'ssh_key.remove', "{$comment} · {$this->server->name}", $this->server->id, ), ], ); } /** Applies the confirmed key removal over SSH, then reloads the list. */ #[On('keyRemoved')] public function removeKey(string $confirmToken, FleetService $fleet): void { // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued // while the user could manage the fleet must still be refused after a demotion. abort_unless(auth()->user()?->can('manage-fleet'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'keyRemoved'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } if (($payload['serverId'] ?? null) !== $this->server->id) { return; // token sealed for a different server — refuse to retarget } $fingerprint = $payload['params']['fingerprint']; try { $fleet->removeAuthorizedKey($this->server, $fingerprint); } catch (Throwable $e) { $this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => $e->getMessage()])); return; } $this->reloadKeys($fleet); } /** Re-read the authorized keys after an add/remove. */ #[On('keyChanged')] public function reloadKeys(FleetService $fleet): void { try { $this->sshKeys = $fleet->sshKeys($this->server); } catch (Throwable) { // keep the current list on failure } } /** Credential changed -> reconnect with the new login and re-pull the snapshot. */ #[On('credentialChanged')] public function reloadAfterCredential(FleetService $fleet): void { $this->server->refresh(); $this->ready = false; $this->load($fleet); } /** Open the file manager scoped to this server. */ public function openFiles() { session(['active_server_id' => $this->server->id]); return $this->redirect(route('files.index'), navigate: true); } /** * Toggle the credential between aktiv/gesperrt (sets/clears disabled_at). * The CredentialVault already refuses a disabled credential, so this is the * operator's kill-switch for a deposited login without deleting it. */ public function toggleCredential(): void { abort_unless(auth()->user()?->can('manage-fleet'), 403); $cred = $this->server->credential; if (! $cred) { return; } $disable = ! $cred->disabled; $cred->forceFill(['disabled_at' => $disable ? now() : null])->save(); AuditEvent::create([ 'user_id' => Auth::id(), 'server_id' => $this->server->id, 'actor' => Auth::user()?->name ?? 'system', 'action' => $disable ? 'credential.disable' : 'credential.enable', 'target' => $this->server->name.' · '.$cred->username, 'ip' => request()->ip(), ]); $this->server->refresh(); $this->dispatch('notify', message: $disable ? __('servers.notify_access_locked') : __('servers.notify_access_unlocked')); } /** * Delete the credential (R5): opens the confirm modal, which writes the * AuditEvent and re-dispatches `credentialDeleted`. */ public function confirmDeleteCredential(): void { abort_unless(auth()->user()?->can('manage-fleet'), 403); $cred = $this->server->credential; if (! $cred) { return; } $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('servers.confirm_delete_credential_heading'), 'body' => __('servers.confirm_delete_credential_body', ['server' => $this->server->name]), 'confirmLabel' => __('common.delete'), 'danger' => true, 'icon' => 'trash', 'notify' => __('servers.notify_access_deleted'), 'token' => ConfirmToken::issue( 'credentialDeleted', [], 'credential.delete', $this->server->name.' · '.$cred->username, $this->server->id, ), ], ); } /** Applies the confirmed credential deletion, then refreshes the row. */ #[On('credentialDeleted')] public function deleteCredential(string $confirmToken): void { // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued // while the user could manage the fleet must still be refused after a demotion. abort_unless(auth()->user()?->can('manage-fleet'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'credentialDeleted'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } if (($payload['serverId'] ?? null) !== $this->server->id) { return; // token sealed for a different server — refuse to retarget } $this->server->credential()->delete(); $this->server->refresh(); } /** * A hardening/firewall action was applied -> re-pull the snapshot so the * checklist + firewall row reflect the new state. */ #[On('hardeningApplied')] public function reloadSnapshot(): void { // A hardening/fail2ban toggle changes only the SECURITY reads. Re-probe the OS profile // (a firewall "Aktivieren" may have just INSTALLED the tool the panels key off) and // re-read the three security states — but keep the page rendered (no skeleton flip) // and skip the full metrics/volumes/keys snapshot: that halves the SSH round-trips // and the operator sees the page update in place instead of a long blank reload. // The Sicherheit/Firewall panels show a wire:loading cue targeting this method. $this->server->refresh(); try { $profile = app(OsDetector::class)->detect($this->server, fresh: true); $this->os = [ 'familyLabel' => $profile->familyLabel(), 'packageManager' => $profile->packageManager, 'firewallTool' => $profile->firewallTool, 'serviceManager' => $profile->serviceManager, ]; } catch (Throwable) { // keep the current profile on failure } try { $this->hardening = app(HardeningService::class)->state($this->server); } catch (Throwable) { $this->hardening = []; } try { $this->firewall = app(FirewallService::class)->status($this->server); } catch (Throwable) { $this->firewall = []; } try { $this->fail2ban = app(Fail2banService::class)->status($this->server); } catch (Throwable) { $this->fail2ban = []; } } /** * Delete a firewall rule (R5): opens the confirm modal, which writes the * AuditEvent and re-dispatches `firewallRuleDelete` (handled below). */ public function confirmDeleteRule(int $index): void { abort_unless(auth()->user()?->can('manage-network'), 403); $rule = $this->firewall['rules'][$index] ?? null; if ($rule === null) { return; } $spec = $rule['spec'] ?? ''; $label = $rule['label'] ?? ($rule['raw'] ?? $spec); $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('servers.confirm_remove_rule_heading'), 'body' => __('servers.confirm_remove_rule_body', ['label' => $label, 'server' => $this->server->name]), 'confirmLabel' => __('common.remove'), 'danger' => true, 'icon' => 'trash', // No premature audit/notify — the handler records the real outcome // only after the remote command returns (audit reflects success). 'notify' => '', 'token' => ConfirmToken::issue( 'firewallRuleDelete', ['spec' => $spec, 'label' => $label], serverId: $this->server->id, ), ], ); } /** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */ #[On('firewallRuleDelete')] public function deleteFirewallRule(string $confirmToken, FirewallService $firewall): void { // Re-gate on consume: ConfirmToken is uid-bound, not role-bound, so a token issued // while the user could manage the network must still be refused after a demotion. abort_unless(auth()->user()?->can('manage-network'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'firewallRuleDelete'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } if (($payload['serverId'] ?? null) !== $this->server->id) { return; // token sealed for a different server — refuse to retarget } $spec = $payload['params']['spec']; $label = $payload['params']['label']; try { $res = $firewall->deleteRule($this->server, $spec); } catch (Throwable $e) { $this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($e->getMessage(), 90)])); return; } // The rule was already gone — inform, refresh, but do NOT audit a non-event. if (! empty($res['notFound'])) { $this->dispatch('notify', message: __('servers.notify_rule_not_found')); $this->reloadFirewall($firewall); return; } if (! $res['ok']) { $this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)])); return; } AuditEvent::create([ 'user_id' => Auth::id(), 'server_id' => $this->server->id, 'actor' => Auth::user()?->name ?? 'system', 'action' => 'firewall.rule_delete', 'target' => $label.' · '.$this->server->name, 'ip' => request()->ip(), ]); $this->dispatch('notify', message: __('servers.notify_firewall_rule_removed')); $this->reloadFirewall($firewall); } /** Re-read the firewall state after an add/delete/default change. */ #[On('firewallChanged')] public function reloadFirewall(FirewallService $firewall): void { try { $this->firewall = $firewall->status($this->server); } catch (Throwable) { // keep the current state on failure } } /** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */ public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void { abort_unless(auth()->user()?->can('manage-network'), 403); try { $res = $fail2ban->unban($this->server, $jail, $ip); } catch (Throwable $e) { $this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)])); return; } if (! $res['ok']) { $this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)])); return; } $this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name); $this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip])); $this->reloadFail2ban($fail2ban); } /** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */ public function addIgnore(Fail2banService $fail2ban): void { abort_unless(auth()->user()?->can('manage-network'), 403); $ip = trim($this->newIgnoreIp); if ($ip === '') { return; } try { $res = $fail2ban->addIgnoreIp($this->server, $ip); } catch (Throwable $e) { $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)])); return; } if (! $res['ok']) { $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)])); return; } if (! empty($res['noop'])) { $this->newIgnoreIp = ''; $this->dispatch('notify', message: $res['output']); return; } $this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name); $this->newIgnoreIp = ''; $this->dispatch('notify', message: __('servers.notify_ip_whitelisted', ['ip' => $ip])); $this->reloadFail2ban($fail2ban); } /** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */ public function removeIgnore(string $ip, Fail2banService $fail2ban): void { abort_unless(auth()->user()?->can('manage-network'), 403); try { $res = $fail2ban->removeIgnoreIp($this->server, $ip); } catch (Throwable $e) { $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)])); return; } if (! $res['ok']) { $this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)])); return; } if (! empty($res['noop'])) { $this->dispatch('notify', message: $res['output']); return; } $this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name); $this->dispatch('notify', message: __('servers.notify_ip_unwhitelisted', ['ip' => $ip])); $this->reloadFail2ban($fail2ban); } /** Re-read fail2ban state after an unban/ban/whitelist change. */ #[On('fail2banChanged')] public function reloadFail2ban(Fail2banService $fail2ban): void { try { $this->fail2ban = $fail2ban->status($this->server); } catch (Throwable) { // keep the current state on failure } } /** Write one AuditEvent for a fail2ban action (success path only). */ private function audit(string $action, string $target): void { AuditEvent::create([ 'user_id' => Auth::id(), 'server_id' => $this->server->id, 'actor' => Auth::user()?->name ?? 'system', 'action' => $action, 'target' => $target, 'ip' => request()->ip(), ]); } public function render() { return view('livewire.servers.show')->title($this->title()); } }