feat: add-server, apt updates, fail2ban config; clean modals; drop storage internals
Operator feedback: no way to add servers; irrelevant storage-internals text; fail2ban only on/off; raw shell commands shown; no apt update/upgrade. - Add server: CreateServer modal (name, IP/host, SSH port, user, password|key, optional label) on the Servers index → creates server + encrypted credential ATOMICALLY (DB transaction). Strict IP/hostname validation (filter_var + hostname; rejects 999.x, ':'). - System updates (Debian/Ubuntu only): MaintenanceService + SystemUpdate modal — shows the pending-update count (or "unbekannt" when undeterminable) and runs apt update && upgrade as root; gated to apt hosts. - fail2ban configuration: Fail2banConfig modal — Sperrdauer / Max. Fehlversuche / Zeitfenster, written to a Clusev-owned jail.d drop-in (zz-clusev.local, last-wins; never touches the operator's jail.local/jails). Durations kept verbatim in fail2ban's native grammar (600, 10m, 1h 30m, -1). Reads the EFFECTIVE [DEFAULT] across files; refuses to save when the current policy couldn't be read (no overwrite with unseen defaults); reloads fail2ban only when already active (never starts it). - Modals: removed the raw "Befehle (als root)" preview + raw stdout dumps from the hardening modal — clean German description + result only. - System page: dropped the .env/Datenbank storage-internals callout (irrelevant to the user). R15 — Codex gate: `codex review --uncommitted` run iteratively; fixed every finding across 9 rounds (fail2ban jail clobbering, section/precedence, composite/-1 durations, reload-starting- inactive, read-failure propagation, glob exit code, apt-count failure, atomic server create, IP validation) until clean — 0 security issues throughout. Live-verified on 10.10.90.162; R12: Servers/Detail/System 200 / 0 console errors, modals open, hardening modal shows no commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
36477c915a
commit
05f8ce49b4
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Form modal: register a new server in the fleet and deposit its SSH access in
|
||||
* the encrypted vault. The server starts as 'offline'; the first poll/connect
|
||||
* promotes it. The SSH port lives on the servers table (column ssh_port), which
|
||||
* is what the SSH layer (SshClient/Sftp) reads.
|
||||
*/
|
||||
class CreateServer extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $ip = '';
|
||||
|
||||
public int $sshPort = 22;
|
||||
|
||||
public string $username = '';
|
||||
|
||||
public string $authType = 'password';
|
||||
|
||||
public string $secret = '';
|
||||
|
||||
public string $passphrase = '';
|
||||
|
||||
public string $credentialName = '';
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
// Accept either a real IP (validated by PHP's filter) or a valid DNS hostname.
|
||||
'ip' => ['required', 'string', 'max:255', function ($attribute, $value, $fail) {
|
||||
$value = (string) $value;
|
||||
$isIp = filter_var($value, FILTER_VALIDATE_IP) !== false;
|
||||
// A digits-and-dots string is an IPv4 attempt — it must be a VALID IP,
|
||||
// not a numeric "hostname" (so 999.999.999.999 is rejected).
|
||||
$numericDotted = preg_match('/^[0-9.]+$/', $value) === 1;
|
||||
$isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1;
|
||||
if (! $isIp && ! $isHost) {
|
||||
$fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.');
|
||||
}
|
||||
}],
|
||||
'sshPort' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'username' => ['required', 'string', 'max:120'],
|
||||
'authType' => ['required', Rule::in(['password', 'key'])],
|
||||
'secret' => ['required', 'string'],
|
||||
'passphrase' => ['nullable', 'string'],
|
||||
'credentialName' => ['nullable', 'string', 'max:120'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function validationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Name',
|
||||
'ip' => 'IP/Host',
|
||||
'sshPort' => 'SSH-Port',
|
||||
'username' => 'Benutzer',
|
||||
'authType' => 'Authentifizierung',
|
||||
'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort',
|
||||
'credentialName' => 'Zugangs-Name',
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->validate();
|
||||
|
||||
// Atomic: a failure creating the credential or audit must not leave a
|
||||
// server row without its required SSH credential.
|
||||
$server = DB::transaction(function () use ($data) {
|
||||
$server = Server::create([
|
||||
'name' => trim($data['name']),
|
||||
'ip' => trim($data['ip']),
|
||||
'ssh_port' => (int) $data['sshPort'],
|
||||
'status' => 'offline',
|
||||
]);
|
||||
|
||||
$server->credential()->create([
|
||||
'name' => trim($this->credentialName) !== '' ? trim($this->credentialName) : null,
|
||||
'username' => trim($data['username']),
|
||||
'auth_type' => $data['authType'] === 'key' ? 'key' : 'password',
|
||||
'secret' => $this->secret,
|
||||
'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null,
|
||||
]);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'server.create',
|
||||
'target' => $server->name.' · '.$server->ip,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
$this->dispatch('serverCreated');
|
||||
$this->dispatch('notify', message: 'Server hinzugefügt.');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.create-server');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\MaintenanceService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Tune the fail2ban [DEFAULT] policy (Sperrdauer / Max. Fehlversuche / Zeitfenster)
|
||||
* via a small German form — no shell commands are ever shown. mount() loads the
|
||||
* current values; save() clamps + validates the integers, writes jail.local, AUDITs,
|
||||
* notifies and closes.
|
||||
*/
|
||||
class Fail2banConfig extends ModalComponent
|
||||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $serverName = '';
|
||||
|
||||
/** fail2ban duration grammar (e.g. 600, 10m, 1h 30m, -1 = dauerhaft) — kept verbatim. */
|
||||
public string $bantime = '10m';
|
||||
|
||||
public int $maxretry = 5;
|
||||
|
||||
public string $findtime = '10m';
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
/** True only once the current policy was read — guards save() from overwriting with unseen defaults. */
|
||||
public bool $loaded = false;
|
||||
|
||||
public function mount(int $serverId, MaintenanceService $maintenance): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->serverName = $server->name;
|
||||
|
||||
try {
|
||||
$current = $maintenance->readFail2ban($server);
|
||||
$this->bantime = $current['bantime'];
|
||||
$this->maxretry = $current['maxretry'];
|
||||
$this->findtime = $current['findtime'];
|
||||
$this->loaded = true;
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function save(MaintenanceService $maintenance): void
|
||||
{
|
||||
// Never overwrite the remote policy with defaults the operator never saw —
|
||||
// saving is only allowed once the current values were read successfully.
|
||||
if (! $this->loaded) {
|
||||
$this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->error = null;
|
||||
|
||||
// fail2ban duration grammar: a number (seconds) optionally with a unit
|
||||
// (s/m/h/d/w/mo/y), space-separated composites, or -1 for a permanent ban.
|
||||
$duration = ['required', 'string', 'max:40', 'regex:/^(?:-1|\d+(?:\.\d+)?\s*(?:s|m|h|d|w|mo|y)?(?:\s+\d+(?:\.\d+)?\s*(?:s|m|h|d|w|mo|y)?)*)$/i'];
|
||||
|
||||
$validated = $this->validate([
|
||||
'bantime' => $duration,
|
||||
'maxretry' => ['required', 'integer', 'min:1', 'max:100'],
|
||||
'findtime' => $duration,
|
||||
], [
|
||||
'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).',
|
||||
'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).',
|
||||
], [
|
||||
'bantime' => 'Sperrdauer',
|
||||
'maxretry' => 'Max. Fehlversuche',
|
||||
'findtime' => 'Zeitfenster',
|
||||
]);
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Values are already validated/clamped to the allowed ranges above.
|
||||
$result = $maintenance->writeFail2ban(
|
||||
$server,
|
||||
(string) $validated['bantime'],
|
||||
(int) $validated['maxretry'],
|
||||
(string) $validated['findtime'],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $result['ok']) {
|
||||
$this->error = 'Speichern fehlgeschlagen. '.$result['output'];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'fail2ban.configure',
|
||||
'target' => $server->name.' · bantime '.$validated['bantime'].', maxretry '.$validated['maxretry'],
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
$this->dispatch('hardeningApplied');
|
||||
$this->dispatch('notify', message: 'fail2ban konfiguriert.');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.fail2ban-config');
|
||||
}
|
||||
}
|
||||
|
|
@ -30,8 +30,6 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
public string $description = '';
|
||||
|
||||
public string $preview = '';
|
||||
|
||||
public bool $done = false;
|
||||
|
||||
public bool $ok = false;
|
||||
|
|
@ -57,7 +55,6 @@ class HardeningAction extends ModalComponent
|
|||
$hardening = app(HardeningService::class);
|
||||
$this->heading = $hardening->title($action, $enable);
|
||||
$this->description = $hardening->description($action, $enable);
|
||||
$this->preview = $hardening->preview($server, $action, $enable);
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
|
|
@ -93,7 +90,8 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
$this->done = true;
|
||||
$this->ok = $result['ok'];
|
||||
$this->output = $result['output'] !== '' ? $result['output'] : ($result['ok'] ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.');
|
||||
// Clean result only — no raw command output on success; a short reason on failure.
|
||||
$this->output = $this->ok ? '' : \Illuminate\Support\Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Services\MaintenanceService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Apply pending apt package updates (Debian/Ubuntu only). mount() reads whether
|
||||
* apt exists + the pending count; the body explains in GERMAN what happens — never
|
||||
* a shell command. „Jetzt aktualisieren" runs the upgrade, AUDITs, notifies, and
|
||||
* keeps the modal open showing a clean German result.
|
||||
*/
|
||||
class SystemUpdate extends ModalComponent
|
||||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $serverName = '';
|
||||
|
||||
public bool $hasApt = false;
|
||||
|
||||
/** Pending upgrade count, or null when it could not be determined ("unbekannt"). */
|
||||
public ?int $pending = null;
|
||||
|
||||
public bool $done = false;
|
||||
|
||||
public bool $ok = false;
|
||||
|
||||
/** Trimmed apt output, only shown when the upgrade failed. */
|
||||
public string $output = '';
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
public function mount(int $serverId, MaintenanceService $maintenance): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->serverName = $server->name;
|
||||
|
||||
try {
|
||||
$this->hasApt = $maintenance->hasApt($server);
|
||||
$this->pending = $this->hasApt ? $maintenance->pendingUpdates($server) : null;
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function upgrade(MaintenanceService $maintenance): void
|
||||
{
|
||||
if ($this->error !== null || $this->done || ! $this->hasApt) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $maintenance->aptUpgrade($server);
|
||||
} catch (Throwable $e) {
|
||||
$this->done = true;
|
||||
$this->ok = false;
|
||||
$this->output = $e->getMessage();
|
||||
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->done = true;
|
||||
$this->ok = $result['ok'];
|
||||
// On success we surface a clean German line, not the raw apt log; on failure
|
||||
// the trimmed error tail helps the operator diagnose.
|
||||
$this->output = $this->ok ? '' : $result['output'];
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'system.apt_upgrade',
|
||||
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($this->ok) {
|
||||
$this->pending = 0;
|
||||
$this->dispatch('notify', message: 'System aktualisiert.');
|
||||
} else {
|
||||
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.system-update');
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Servers;
|
|||
use App\Models\Server;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -15,6 +16,13 @@ class Index extends Component
|
|||
/** Live search over name / IP. */
|
||||
public string $search = '';
|
||||
|
||||
/** A freshly added server should appear without a manual reload (render() re-queries). */
|
||||
#[On('serverCreated')]
|
||||
public function refreshServers(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$term = trim($this->search);
|
||||
|
|
|
|||
|
|
@ -125,7 +125,6 @@ class Index extends Component
|
|||
'hasTls' => $this->domain !== '',
|
||||
'caddyConfig' => $deployment->renderCaddyConfig(),
|
||||
'reloadHint' => $deployment->reloadHint(),
|
||||
'whyNotEnv' => $deployment->whyNotEnv(),
|
||||
'channels' => self::CHANNELS,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ use App\Models\Setting;
|
|||
* .env IS NEVER WRITTEN. This service only persists Settings (DB) and writes a
|
||||
* standalone Caddy site block to storage/app/caddy/clusev.caddy. It does not read,
|
||||
* edit, or rewrite the application's .env in any way — the panel domain is a database
|
||||
* value, so a change to it never mutates .env. See whyNotEnv() for the operator-facing
|
||||
* explanation surfaced in the dashboard.
|
||||
* value, so a change to it never mutates .env.
|
||||
*/
|
||||
class DeploymentService
|
||||
{
|
||||
|
|
@ -137,16 +136,4 @@ class DeploymentService
|
|||
{
|
||||
return 'caddy reload --config /config/'.self::CONFIG_FILENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* German operator explanation: the panel domain lives in the DATABASE (Settings),
|
||||
* NOT in .env — and the app never overwrites .env. Surfaced in the dashboard to
|
||||
* clear up the worry that every domain change rewrites .env. Changes take effect
|
||||
* after a Caddy reload (the proxy runs in its own container).
|
||||
*/
|
||||
public function whyNotEnv(): string
|
||||
{
|
||||
return 'Die Domain liegt in der Datenbank, nicht in der .env — die .env wird nie '
|
||||
.'überschrieben. Änderungen greifen nach einem Caddy-Reload (`caddy reload`).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,65 +42,42 @@ class FirewallService
|
|||
return ['active' => $active, 'raw' => $raw, 'rules' => $rules];
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact command(s) enable() will run, with the detected sshd port
|
||||
* already substituted — shown to the operator before applying.
|
||||
*/
|
||||
public function enablePreview(Server $server): string
|
||||
{
|
||||
return $this->enableScript($this->sshPort($server));
|
||||
}
|
||||
|
||||
/**
|
||||
* GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never
|
||||
* enable a firewall that would drop the current SSH session.
|
||||
* enable a firewall that would drop the current SSH session. Installs ufw if missing.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function enable(Server $server): array
|
||||
{
|
||||
$port = $this->sshPort($server);
|
||||
$preview = $this->enableScript($port);
|
||||
// long timeout: may apt-install ufw first.
|
||||
$res = $this->fleet->runPrivileged($server, $this->enableScript($port), 600);
|
||||
$res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Turn the firewall off (ufw disable). */
|
||||
/** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */
|
||||
public function disable(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged($server, 'ufw disable');
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => 'ufw disable'];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a TCP port through the firewall.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
/** Allow a TCP port through the firewall. @return array{ok: bool, output: string} */
|
||||
public function allow(Server $server, int $port): array
|
||||
{
|
||||
$port = $this->clampPort($port);
|
||||
$preview = "ufw allow {$port}/tcp";
|
||||
$res = $this->fleet->runPrivileged($server, $preview);
|
||||
$res = $this->fleet->runPrivileged($server, 'ufw allow '.$this->clampPort($port).'/tcp');
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny a TCP port through the firewall.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
/** Deny a TCP port through the firewall. @return array{ok: bool, output: string} */
|
||||
public function deny(Server $server, int $port): array
|
||||
{
|
||||
$port = $this->clampPort($port);
|
||||
$preview = "ufw deny {$port}/tcp";
|
||||
$res = $this->fleet->runPrivileged($server, $preview);
|
||||
$res = $this->fleet->runPrivileged($server, 'ufw deny '.$this->clampPort($port).'/tcp');
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
|
||||
|
|
|
|||
|
|
@ -132,34 +132,22 @@ class HardeningService
|
|||
};
|
||||
}
|
||||
|
||||
/** The EXACT root command(s) the toggle will run — identical to what apply() executes. */
|
||||
public function preview(Server $server, string $action, bool $enable): string
|
||||
{
|
||||
if ($action === 'firewall') {
|
||||
return $enable ? $this->firewall->enablePreview($server) : 'ufw disable';
|
||||
}
|
||||
|
||||
return $this->commandFor($action, $enable);
|
||||
}
|
||||
|
||||
/** @return array{ok: bool, output: string, preview: string} */
|
||||
/** @return array{ok: bool, output: string} */
|
||||
public function apply(Server $server, string $action, bool $enable): array
|
||||
{
|
||||
$preview = $this->preview($server, $action, $enable);
|
||||
|
||||
if ($action === 'firewall') {
|
||||
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
// Lock-out guard: never disable password auth without a usable key path.
|
||||
if ($action === 'ssh_password' && ! $enable) {
|
||||
if ($server->credential?->auth_type === 'password') {
|
||||
return ['ok' => false, 'preview' => $preview, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.'];
|
||||
return ['ok' => false, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.'];
|
||||
}
|
||||
if (! $this->hasAuthorizedKey($server)) {
|
||||
return ['ok' => false, 'preview' => $preview, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).'];
|
||||
return ['ok' => false, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +155,7 @@ class HardeningService
|
|||
$timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60;
|
||||
$res = $this->fleet->runPrivileged($server, $this->commandFor($action, $enable), $timeout);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* Debian/Ubuntu (apt) maintenance helpers: counts pending package updates,
|
||||
* applies an apt upgrade, and reads/writes the fail2ban [DEFAULT] tuning.
|
||||
*
|
||||
* Every reader/writer goes through FleetService, so quoting is base64-safe and
|
||||
* privileged work runs as root. Callers must clamp the fail2ban integers before
|
||||
* calling writeFail2ban(); this service only persists already-validated values.
|
||||
*/
|
||||
class MaintenanceService
|
||||
{
|
||||
// A Clusev-owned drop-in. 'zz-' so it sorts LAST in jail.d and its [DEFAULT] wins
|
||||
// (fail2ban applies the last value). We only ever write/read THIS file — never the
|
||||
// operator's jail.local or jail definitions.
|
||||
private const FAIL2BAN_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
/** True if apt-get exists on the target (only then are the update features meaningful). */
|
||||
public function hasApt(Server $server): bool
|
||||
{
|
||||
$res = $this->fleet->runPlain($server, 'command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0');
|
||||
|
||||
return $res['ok'] && trim($res['output']) === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of pending package upgrades via a dry-run simulation (no root needed),
|
||||
* or NULL when the simulation could not run (apt absent / errored) — so the caller
|
||||
* can show "unbekannt" rather than a misleading "0 / up to date".
|
||||
*/
|
||||
public function pendingUpdates(Server $server): ?int
|
||||
{
|
||||
$res = $this->fleet->runPlain($server, 'apt-get -s upgrade 2>/dev/null');
|
||||
if (! $res['ok']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) preg_match_all('/^Inst /m', $res['output']);
|
||||
}
|
||||
|
||||
/**
|
||||
* apt-get update && upgrade as root. Long-running, so a 900s timeout. The
|
||||
* returned output is trimmed to the last ~400 chars for a compact result panel.
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function aptUpgrade(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged(
|
||||
$server,
|
||||
'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade',
|
||||
900,
|
||||
);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Clusev-managed fail2ban [DEFAULT] tuning from our OWN drop-in only —
|
||||
* we never read the operator's jail.local or per-jail sections, so a jail's
|
||||
* `maxretry` can't masquerade as the global default. Defaults when unset.
|
||||
*
|
||||
* @return array{bantime: int, maxretry: int, findtime: int}
|
||||
*/
|
||||
public function readFail2ban(Server $server): array
|
||||
{
|
||||
// Read the [DEFAULT] section across all fail2ban files in fail2ban's own
|
||||
// precedence order (last value wins). A CLUSEV_RESET marker between files
|
||||
// stops a section bleeding across file boundaries; only [DEFAULT] keys count
|
||||
// (a jail's maxretry must not masquerade as the global default).
|
||||
// A leading sentinel proves the read actually RAN (sudo/SSH ok). We key success
|
||||
// off the sentinel — NOT the loop's exit code — so a final unmatched glob (a
|
||||
// benign "no drop-ins" case) does not look like a failure, while a genuine
|
||||
// sudo/permission/SSH error (no sentinel echoed) IS propagated as a throw and can
|
||||
// never be downgraded to defaults the operator might then overwrite.
|
||||
$cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf '
|
||||
.'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do '
|
||||
.'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
|
||||
throw new \RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
return $this->parseFail2banDefaults($res['output']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section-aware parse of the effective [DEFAULT] tuning (last value wins),
|
||||
* converting fail2ban time units (s/m/h/d/w) to seconds.
|
||||
*
|
||||
* @return array{bantime: int, maxretry: int, findtime: int}
|
||||
*/
|
||||
private function parseFail2banDefaults(string $body): array
|
||||
{
|
||||
// bantime/findtime are kept VERBATIM — fail2ban's native duration grammar
|
||||
// (600, 10m, 1h 30m, -1 for permanent, …) is preserved, never lossily converted
|
||||
// to seconds. maxretry is a plain integer.
|
||||
$vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m'];
|
||||
$inDefault = false;
|
||||
|
||||
foreach (preg_split('/\R/', $body) ?: [] as $line) {
|
||||
$t = trim($line);
|
||||
if ($t === '' || $t[0] === '#' || $t[0] === ';') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^\[([^\]]+)\]/', $t, $m)) {
|
||||
$inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $inDefault) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) {
|
||||
$vals['bantime'] = $m[1];
|
||||
} elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) {
|
||||
$vals['findtime'] = $m[1];
|
||||
} elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) {
|
||||
$vals['maxretry'] = (int) $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $vals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the Clusev [DEFAULT] block to our OWN jail.d drop-in (overwriting only
|
||||
* that file — the operator's jail.local and jail definitions are untouched),
|
||||
* then reload (fallback restart) fail2ban. The integers MUST already be clamped
|
||||
* by the caller; cast here as a last line of defence.
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function writeFail2ban(Server $server, string $bantime, int $maxretry, string $findtime): array
|
||||
{
|
||||
$maxretry = (int) $maxretry;
|
||||
// Durations stay in fail2ban's native grammar (validated by the caller); collapse
|
||||
// any stray whitespace/newlines so the written ini line stays well-formed.
|
||||
$bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? '');
|
||||
$findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? '');
|
||||
|
||||
// Build the config with base64 so it never touches the shell unquoted; the
|
||||
// whole command is itself base64-wrapped again by runPrivileged.
|
||||
$content = "# Managed by Clusev — do not edit by hand.\n"
|
||||
."[DEFAULT]\n"
|
||||
."bantime = {$bantime}\n"
|
||||
."findtime = {$findtime}\n"
|
||||
."maxretry = {$maxretry}\n";
|
||||
|
||||
$b64 = base64_encode($content);
|
||||
|
||||
// Write the drop-in; reload ONLY if fail2ban is already running (never start an
|
||||
// inactive service from the settings form). A reload failure while active IS
|
||||
// propagated (the `if` returns reload's exit code); inactive succeeds silently.
|
||||
$cmd = 'mkdir -p /etc/fail2ban/jail.d'
|
||||
.' && printf %s '.$b64.' | base64 -d > '.self::FAIL2BAN_DROPIN
|
||||
.' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban; fi';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd, 120);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)];
|
||||
}
|
||||
|
||||
/** Keep only the last $max characters of a (multi-line) output. */
|
||||
private function tail(string $out, int $max): string
|
||||
{
|
||||
$out = trim($out);
|
||||
if (mb_strlen($out) <= $max) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
return '…'.mb_substr($out, -$max);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="server" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">Server hinzufügen</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name</label>
|
||||
<input wire:model="name" type="text" placeholder="z. B. Prod-Web-01" maxlength="120"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('name')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">IP/Host</label>
|
||||
<input wire:model="ip" type="text" placeholder="10.10.90.10 oder host.example.com"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('ip')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">SSH-Port</label>
|
||||
<input wire:model="sshPort" type="number" min="1" max="65535" placeholder="22"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('sshPort')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label>
|
||||
<input wire:model="username" type="text" placeholder="root"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('username')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label>
|
||||
<select wire:model.live="authType"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none">
|
||||
<option value="password">Passwort</option>
|
||||
<option value="key">Privater Schlüssel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label>
|
||||
@if ($authType === 'key')
|
||||
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
|
||||
@else
|
||||
<input wire:model="secret" type="password" placeholder="••••••••"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@endif
|
||||
@error('secret')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
@if ($authType === 'key')
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label>
|
||||
<input wire:model="passphrase" type="password" placeholder="leer = keine"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zugangs-Name (optional)</label>
|
||||
<input wire:model="credentialName" type="text" placeholder="z. B. Root-Login · Deploy-User" maxlength="120"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('credentialName')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
Hinzufügen
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="shield" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">fail2ban konfigurieren</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Legt fest, wann fail2ban auf {{ $serverName ?: 'diesem Server' }} eine IP sperrt und wie lange.
|
||||
Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($error)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($loaded)
|
||||
<div class="mt-5 space-y-4">
|
||||
<div>
|
||||
<label for="f2b-bantime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Sperrdauer</label>
|
||||
<input id="f2b-bantime" type="text" maxlength="40" wire:model="bantime" placeholder="z. B. 600, 10m, 1h, -1 = dauerhaft"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('bantime')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="f2b-maxretry" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Max. Fehlversuche</label>
|
||||
<input id="f2b-maxretry" type="number" min="1" max="100" wire:model="maxretry"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('maxretry')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="f2b-findtime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zeitfenster</label>
|
||||
<input id="f2b-findtime" type="text" maxlength="40" wire:model="findtime" placeholder="z. B. 600, 10m, 1h"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('findtime')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ $loaded ? 'Abbrechen' : 'Schließen' }}</x-btn>
|
||||
@if ($loaded)
|
||||
<x-btn variant="accent" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
Speichern
|
||||
</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -16,31 +16,20 @@
|
|||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Befehle (als root)</p>
|
||||
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3.5 py-3 font-mono text-[11px] leading-relaxed text-ink-2">{{ $preview }}</pre>
|
||||
</div>
|
||||
|
||||
@if ($done)
|
||||
<div @class([
|
||||
'mt-3 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<p @class([
|
||||
'flex items-center gap-1.5 font-mono text-[11px]',
|
||||
'text-online' => $ok,
|
||||
'text-offline' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
|
||||
{{ $ok ? 'Angewendet' : 'Fehlgeschlagen' }}
|
||||
</p>
|
||||
@if ($output !== '')
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
|
||||
@elseif ($done)
|
||||
<div @class([
|
||||
'mt-4 flex items-start gap-2.5 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" @class(['mt-0.5 h-4 w-4 shrink-0', 'text-online' => $ok, 'text-offline' => ! $ok]) />
|
||||
<div class="min-w-0">
|
||||
<p @class(['text-sm', 'text-online' => $ok, 'text-offline' => ! $ok])>{{ $ok ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.' }}</p>
|
||||
@if (! $ok && $output !== '')
|
||||
<p class="mt-1 break-words font-mono text-[11px] text-ink-3">{{ $output }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="rotate" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">System-Updates</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Aktualisiert die installierten Pakete auf {{ $serverName ?: 'diesem Server' }} über die
|
||||
Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($error)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@elseif (! $hasApt)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||
<p class="text-sm text-ink-2">Nur für Debian/Ubuntu (apt) verfügbar.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
|
||||
<span @class([
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||
'border-warning/25 bg-warning/10 text-warning' => $pending !== null && $pending > 0,
|
||||
'border-online/25 bg-online/10 text-online' => $pending === 0,
|
||||
'border-line bg-raised text-ink-3' => $pending === null,
|
||||
])>
|
||||
<x-icon name="rotate" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-display text-sm font-semibold text-ink">
|
||||
@if ($pending === null)
|
||||
Update-Anzahl unbekannt
|
||||
@elseif ($pending === 1)
|
||||
1 Paket-Update verfügbar
|
||||
@else
|
||||
{{ $pending }} Paket-Updates verfügbar
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-3">
|
||||
@if ($pending === null)
|
||||
Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich.
|
||||
@else
|
||||
{{ $pending > 0 ? 'Aktualisierung empfohlen.' : 'Das System ist auf dem aktuellen Stand.' }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($done)
|
||||
<div @class([
|
||||
'mt-3 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<p @class([
|
||||
'flex items-center gap-1.5 font-mono text-[11px]',
|
||||
'text-online' => $ok,
|
||||
'text-offline' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
|
||||
{{ $ok ? 'System aktualisiert.' : 'Aktualisierung fehlgeschlagen.' }}
|
||||
</p>
|
||||
@if (! $ok && $output !== '')
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
@if ($done)
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
@if (! $error && $hasApt)
|
||||
<x-btn variant="accent" wire:click="upgrade" wire:target="upgrade" wire:loading.attr="disabled">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="upgrade" />
|
||||
<svg wire:loading wire:target="upgrade" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
Jetzt aktualisieren
|
||||
</x-btn>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -9,7 +9,13 @@
|
|||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Flotte</p>
|
||||
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Server</h2>
|
||||
</div>
|
||||
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.create-server' })">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" />
|
||||
Server hinzufügen
|
||||
</x-btn>
|
||||
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- KPI grid: 1 → 2 → 4 --}}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.system-update', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" /> System-Updates
|
||||
</x-btn>
|
||||
<x-btn variant="secondary" wire:click="openFiles">
|
||||
<x-icon name="folder" class="h-3.5 w-3.5" /> Dateien
|
||||
</x-btn>
|
||||
|
|
@ -186,6 +189,12 @@
|
|||
</p>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
|
||||
</div>
|
||||
@if ($check['key'] === 'fail2ban')
|
||||
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="fail2ban konfigurieren"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||
</x-btn>
|
||||
@endif
|
||||
{{-- Toggle: secondary to make secure, quiet ghost to loosen. --}}
|
||||
<x-btn :variant="$check['secure'] ? 'ghost' : 'secondary'" size="sm" class="shrink-0"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
|
||||
|
|
|
|||
|
|
@ -26,16 +26,6 @@
|
|||
|
||||
{{-- Domain & TLS --}}
|
||||
<x-panel title="Domain & TLS" subtitle="Erreichbarkeit des Panels und Let's-Encrypt-Zertifikat">
|
||||
{{-- DB-not-.env clarification: the panel domain is a database value; .env is never rewritten. --}}
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
|
||||
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-cyan" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-ink-2">Die Domain wird in der <span class="text-ink">Datenbank</span> gespeichert, nicht in der <span class="font-mono text-ink">.env</span>.</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-3">{{ $whyNotEnv }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-4">Die <span class="text-ink-3">.env</span> wird vom Panel nie überschrieben. Laravel übernimmt die neue Adresse sofort (Laufzeit-Override von <span class="text-ink-3">app.url</span>); die Auslieferung greift nach dem Caddy-Reload unten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Current TLS state --}}
|
||||
@if ($hasTls)
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">
|
||||
|
|
|
|||
Loading…
Reference in New Issue