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
boban 2026-06-13 12:19:55 +02:00
parent 36477c915a
commit 05f8ce49b4
17 changed files with 853 additions and 105 deletions

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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(),

View File

@ -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');
}
}

View File

@ -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);

View File

@ -125,7 +125,6 @@ class Index extends Component
'hasTls' => $this->domain !== '',
'caddyConfig' => $deployment->renderCaddyConfig(),
'reloadHint' => $deployment->reloadHint(),
'whyNotEnv' => $deployment->whyNotEnv(),
'channels' => self::CHANNELS,
]);
}

View File

@ -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`).';
}
}

View File

@ -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. */

View File

@ -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. */

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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 --}}

View File

@ -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' }} } })">

View File

@ -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">