501 lines
18 KiB
PHP
501 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Servers;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Server;
|
|
use App\Services\Fail2banService;
|
|
use App\Services\FirewallService;
|
|
use App\Services\FleetService;
|
|
use App\Services\HardeningService;
|
|
use App\Support\Os\OsDetector;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
use Throwable;
|
|
|
|
#[Layout('layouts.app')]
|
|
class Show extends Component
|
|
{
|
|
/** Route-model-bound by uuid (R11). */
|
|
public Server $server;
|
|
|
|
/** Page <title>; 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
|
|
{
|
|
$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
|
|
{
|
|
$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',
|
|
'auditAction' => 'ssh_key.remove',
|
|
'auditTarget' => "{$comment} · {$this->server->name}",
|
|
'serverId' => $this->server->id,
|
|
'event' => 'keyRemoved',
|
|
'params' => ['fingerprint' => $fingerprint],
|
|
'notify' => __('servers.notify_key_removed', ['name' => $comment]),
|
|
],
|
|
);
|
|
}
|
|
|
|
/** Applies the confirmed key removal over SSH, then reloads the list. */
|
|
#[On('keyRemoved')]
|
|
public function removeKey(string $fingerprint, FleetService $fleet): void
|
|
{
|
|
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
|
|
{
|
|
$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
|
|
{
|
|
$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',
|
|
'auditAction' => 'credential.delete',
|
|
'auditTarget' => $this->server->name.' · '.$cred->username,
|
|
'serverId' => $this->server->id,
|
|
'event' => 'credentialDeleted',
|
|
'params' => [],
|
|
'notify' => __('servers.notify_access_deleted'),
|
|
],
|
|
);
|
|
}
|
|
|
|
/** Applies the confirmed credential deletion, then refreshes the row. */
|
|
#[On('credentialDeleted')]
|
|
public function deleteCredential(): void
|
|
{
|
|
$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(FleetService $fleet): void
|
|
{
|
|
$this->server->refresh();
|
|
$this->ready = false;
|
|
$this->load($fleet);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$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).
|
|
'event' => 'firewallRuleDelete',
|
|
'params' => ['spec' => $spec, 'label' => $label],
|
|
'notify' => '',
|
|
],
|
|
);
|
|
}
|
|
|
|
/** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */
|
|
#[On('firewallRuleDelete')]
|
|
public function deleteFirewallRule(string $spec, string $label, FirewallService $firewall): void
|
|
{
|
|
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
|
|
{
|
|
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
|
|
{
|
|
$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
|
|
{
|
|
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());
|
|
}
|
|
}
|