Compare commits

..

No commits in common. "v0.1.1" and "v0.1.0" have entirely different histories.

35 changed files with 372 additions and 1140 deletions

View File

@ -13,28 +13,6 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.1.1] - 2026-06-13
Wartungs-Release: vollständige Code-Auditierung, Entfernung von totem Code und ein behobenes Open-Redirect. Keine funktionalen Änderungen für Nutzer.
### Behoben
- Open-Redirect im Server-Switcher: ein manipulierter `Referer` konnte die
Weiterleitung nach dem Server-Wechsel auf eine fremde Adresse lenken. Die
Weiterleitung wird jetzt auf einen **gleich-origin** relativen Pfad reduziert
(fremde Schemata wie `javascript:`/`data:`, andere Hosts/Ports sowie
protokoll-relative Tricks `//host` / `/\host` werden abgewiesen).
- SSH-Zugang: eine Passphrase wurde unabhängig vom Auth-Typ gespeichert; sie wird
jetzt nur noch bei Schlüssel-Authentifizierung hinterlegt.
### Geändert
- Datei-Manager + Editor verwenden ein eigenes `file`-Symbol statt des
Audit-Symbols (semantisch korrekt).
- Code-Hygiene: toten Code entfernt (ungenutzte `FirewallService`-Methoden
`status/allow/deny`, `Server::auditEvents()`, `SshCredential::scopeActive()`,
ungenutztes `bell`-Icon), veraltete Doc-Blöcke/Kommentare korrigiert,
Exceptions sauber importiert, base64-Argument im Sudo-Pfad defensiv gequotet,
Null-sichere Nutzerabfrage in den Einstellungen. Pint + Codex-Review sauber.
## [0.1.0] - 2026-06-13
Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Panel-Härtung als ein Release.
@ -79,6 +57,5 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.1.1...HEAD
[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.1.0...HEAD
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0

View File

@ -45,7 +45,7 @@ class TwoFactorChallenge extends Component
throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']);
}
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
$valid = (new Google2FA())->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
if (! $valid) {
RateLimiter::hit($key, 60);

View File

@ -25,14 +25,14 @@ class TwoFactorSetup extends Component
return $this->redirect(route('dashboard'), navigate: true);
}
$this->secret = (new Google2FA)->generateSecretKey();
$this->secret = (new Google2FA())->generateSecretKey();
}
public function confirm()
{
$this->validate();
if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
if (! (new Google2FA())->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']);
}
@ -46,7 +46,7 @@ class TwoFactorSetup extends Component
public function qrCode(): string
{
return (new Google2FA)->getQRCodeInline('Clusev', Auth::user()->email, $this->secret);
return (new Google2FA())->getQRCodeInline('Clusev', Auth::user()->email, $this->secret);
}
public function render()

View File

@ -114,7 +114,7 @@ class Index extends Component
return null;
}
return response()->streamDownload(fn () => print ($content), basename($name));
return response()->streamDownload(fn () => print($content), basename($name));
}
/** Open the view/edit modal for a file. */
@ -162,8 +162,7 @@ class Index extends Component
/**
* Delete a file (R5): opens the confirm modal, which writes the AuditEvent
* and re-dispatches `fileConfirmed` handled by deleteEntry(), which performs
* the SFTP unlink over the active server's connection.
* and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later.
*/
public function confirmDelete(int $index): void
{

View File

@ -1,128 +0,0 @@
<?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

@ -70,8 +70,7 @@ class EditCredential extends ModalComponent
'username' => trim($this->username),
'auth_type' => $this->authType === 'key' ? 'key' : 'password',
'secret' => $this->secret,
// A passphrase only applies to a private key; never store one for password auth.
'passphrase' => ($this->authType === 'key' && $this->passphrase !== '') ? $this->passphrase : null,
'passphrase' => $this->passphrase !== '' ? $this->passphrase : null,
]);
AuditEvent::create([

View File

@ -1,139 +0,0 @@
<?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

@ -38,11 +38,6 @@ class FileEditor extends ModalComponent
$this->name = $name;
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function load(FleetService $fleet): void
{
$server = Server::find($this->serverId);

View File

@ -4,19 +4,21 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FirewallService;
use App\Services\HardeningService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* Preview + confirm modal for one server-hardening TOGGLE (R5).
* Preview + confirm modal for a single server-hardening action (R5).
*
* `$enable` is the desired feature state true turns the feature on, false off.
* mount() loads a direction-aware title + the exact root commands (preview) so the
* operator sees what will run BEFORE applying. apply() runs it, AUDITs, notifies,
* asks the page to reload its hardening state, and shows the result.
* mount() loads a human title + the EXACT shell commands (from the service's
* preview) so the operator sees what will run BEFORE applying. `apply()` runs
* the action over SSH, AUDITs (`harden.<key>`), notifies, asks the page to
* reload the snapshot, and on success closes.
*
* Action keys: ssh_root, ssh_password, fail2ban, unattended, firewall.
*/
class HardeningAction extends ModalComponent
{
@ -24,26 +26,30 @@ class HardeningAction extends ModalComponent
public string $action;
/** Desired feature state: true = aktivieren, false = deaktivieren. */
public bool $enable;
/** Optional TCP port (reserved for future allow/deny firewall actions). */
public ?int $port = null;
public string $heading = '';
public string $description = '';
public string $preview = '';
/** Result of the apply run, set after the operator confirms. */
public bool $done = false;
public bool $ok = false;
public string $output = '';
/** Set when the action key is unknown / server vanished — blocks apply. */
public ?string $error = null;
public function mount(int $serverId, string $action, bool $enable): void
public function mount(int $serverId, string $action, ?int $port = null): void
{
$this->serverId = $serverId;
$this->action = $action;
$this->enable = $enable;
$this->port = $port;
$server = Server::find($serverId);
if (! $server) {
@ -53,9 +59,16 @@ class HardeningAction extends ModalComponent
}
try {
$hardening = app(HardeningService::class);
$this->heading = $hardening->title($action, $enable);
$this->description = $hardening->description($action, $enable);
if ($action === 'firewall') {
$this->heading = 'Firewall (UFW) aktivieren';
$this->description = 'Öffnet zuerst den SSH-Port und 80/443, dann wird UFW aktiviert — die laufende SSH-Sitzung bleibt erreichbar.';
$this->preview = app(FirewallService::class)->enablePreview($server);
} else {
$hardening = app(HardeningService::class);
$this->heading = $hardening->title($action);
$this->description = $hardening->description($action);
$this->preview = $hardening->preview($server, $action);
}
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@ -80,7 +93,9 @@ class HardeningAction extends ModalComponent
}
try {
$result = app(HardeningService::class)->apply($server, $this->action, $this->enable);
$result = $this->action === 'firewall'
? app(FirewallService::class)->enable($server)
: app(HardeningService::class)->apply($server, $this->action);
} catch (Throwable $e) {
$this->done = true;
$this->ok = false;
@ -91,14 +106,13 @@ class HardeningAction extends ModalComponent
$this->done = true;
$this->ok = $result['ok'];
// Clean result only — no raw command output on success; a short reason on failure.
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200);
$this->output = $result['output'] !== '' ? $result['output'] : ($result['ok'] ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.');
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
'action' => 'harden.'.$this->action,
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
'ip' => request()->ip(),
]);

View File

@ -1,115 +0,0 @@
<?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

@ -18,46 +18,7 @@ class ServerSwitcher extends Component
session(['active_server_id' => $server->id]);
$this->redirect($this->safeReturnUrl(), navigate: true);
}
/**
* Return a SAME-ORIGIN relative target derived from the Referer, or the
* dashboard. An attacker-supplied Referer must never turn a server switch
* into an open redirect, so we:
* 1. reject any Referer whose host differs or whose scheme is not http(s)
* (blocks javascript:/data: and off-site hosts), then
* 2. keep ONLY the path + query and redirect to that rooted relative URL
* so the result can never carry a foreign scheme, host, or port (e.g.
* `https://host:444/x` collapses to `/x` on the current origin), and
* 3. reject protocol-relative (`//host`, `/\host`) and non-rooted values.
*/
private function safeReturnUrl(): string
{
$referer = request()->header('Referer');
$fallback = route('dashboard');
if (! is_string($referer) || $referer === '') {
return $fallback;
}
$parts = parse_url($referer);
if ($parts === false
|| ! isset($parts['host'])
|| strtolower($parts['host']) !== strtolower(request()->getHost())
|| (isset($parts['scheme']) && ! in_array(strtolower($parts['scheme']), ['http', 'https'], true))) {
return $fallback;
}
$target = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
// Must be a rooted, host-relative path; reject protocol-relative tricks.
if ($target === '' || $target[0] !== '/'
|| (isset($target[1]) && ($target[1] === '/' || $target[1] === '\\'))) {
return $fallback;
}
return $target;
$this->redirect(request()->header('Referer') ?: route('dashboard'), navigate: true);
}
public function render()

View File

@ -5,7 +5,6 @@ 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;
@ -16,13 +15,6 @@ 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

@ -5,7 +5,6 @@ namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use App\Services\HardeningService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
@ -74,14 +73,8 @@ class Show extends Component
$this->volumes = $snap['volumes'];
$this->interfaces = $snap['interfaces'];
$this->hardening = $snap['hardening'];
$this->sshKeys = $snap['sshKeys'];
// Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks).
try {
$this->hardening = app(HardeningService::class)->state($this->server);
} catch (Throwable) {
$this->hardening = [];
}
$this->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
$this->connected = true;
} catch (Throwable) {

View File

@ -4,7 +4,6 @@ namespace App\Livewire\Services;
use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
@ -112,7 +111,7 @@ class Index extends Component
$this->dispatch('notify', message: $res['ok']
? "{$name}: {$op} ausgeführt."
: "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90));
: "{$name}: fehlgeschlagen — ".\Illuminate\Support\Str::limit($res['output'] ?: 'keine Rechte', 90));
try {
$this->services = $fleet->systemd($active)['services'];

View File

@ -69,7 +69,7 @@ class Index extends Component
'danger' => true,
'icon' => 'shield',
'auditAction' => 'two_factor.disable',
'auditTarget' => Auth::user()?->email,
'auditTarget' => Auth::user()->email,
'event' => 'twoFactorDisabled',
'notify' => '2FA deaktiviert.',
],

View File

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

View File

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
@ -30,4 +31,9 @@ class Server extends Model
{
return $this->hasOne(SshCredential::class);
}
public function auditEvents(): HasMany
{
return $this->hasMany(AuditEvent::class);
}
}

View File

@ -35,4 +35,9 @@ class SshCredential extends Model
{
return $this->disabled_at !== null;
}
public function scopeActive($query)
{
return $query->whereNull('disabled_at');
}
}

View File

@ -22,7 +22,8 @@ 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.
* value, so a change to it never mutates .env. See whyNotEnv() for the operator-facing
* explanation surfaced in the dashboard.
*/
class DeploymentService
{
@ -52,9 +53,7 @@ class DeploymentService
/** True once a domain is configured — i.e. TLS via Let's Encrypt is in effect. */
public function hasTls(): bool
{
$domain = $this->domain();
return $domain !== null && $domain !== '';
return $this->domain() !== null && $this->domain() !== '';
}
/**
@ -138,4 +137,16 @@ 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

@ -11,39 +11,93 @@ use App\Models\Server;
* Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the
* firewall on so enabling UFW can never sever the operator's own SSH session.
*
* Both mutating methods return array{ok: bool, output: string}.
* status()/preview() are read-only command builders; the mutating methods all
* return array{ok: bool, output: string, preview: string}.
*/
class FirewallService
{
public function __construct(private FleetService $fleet) {}
/**
* GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never
* enable a firewall that would drop the current SSH session. Installs ufw if missing.
* Parse `ufw status verbose` into a structured snapshot.
*
* @return array{ok: bool, output: string}
* @return array{active: bool, raw: string, rules: array<int, string>}
*/
public function status(Server $server): array
{
$res = $this->fleet->runPrivileged($server, 'ufw status verbose 2>&1');
$raw = $res['output'];
$active = (bool) preg_match('/Status:\s*active/i', $raw);
$rules = [];
foreach (preg_split('/\R/', $raw) as $line) {
$line = trim($line);
// rule lines carry an action token (ALLOW/DENY/REJECT/LIMIT)
if ($line !== '' && preg_match('/\b(ALLOW|DENY|REJECT|LIMIT)\b/i', $line)) {
$rules[] = $line;
}
}
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.
*
* @return array{ok: bool, output: string, preview: string}
*/
public function enable(Server $server): array
{
// long timeout: may apt-install ufw first.
$res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600);
$port = $this->sshPort($server);
$preview = $this->enableScript($port);
$res = $this->fleet->runPrivileged($server, $this->enableScript($port));
return ['ok' => $res['ok'], 'output' => $res['output']];
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */
public function disable(Server $server): array
/**
* Allow a TCP port through the firewall.
*
* @return array{ok: bool, output: string, preview: string}
*/
public function allow(Server $server, int $port): array
{
$res = $this->fleet->runPrivileged($server, 'ufw disable');
$port = $this->clampPort($port);
$preview = "ufw allow {$port}/tcp";
$res = $this->fleet->runPrivileged($server, $preview);
return ['ok' => $res['ok'], 'output' => $res['output']];
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
/**
* Deny a TCP port through the firewall.
*
* @return array{ok: bool, output: string, preview: string}
*/
public function deny(Server $server, int $port): array
{
$port = $this->clampPort($port);
$preview = "ufw deny {$port}/tcp";
$res = $this->fleet->runPrivileged($server, $preview);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Build the guarded enable script for a given sshd port. */
private function enableScript(int $port): string
{
return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && '
."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
return "ufw allow {$port}/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable";
}
/**
@ -62,4 +116,10 @@ class FirewallService
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
}
/** Keep a port in the valid TCP range. */
private function clampPort(int $port): int
{
return max(1, min(65535, $port));
}
}

View File

@ -47,7 +47,7 @@ class FleetService
if ($password !== '') {
$b64 = base64_encode($password."\n");
$branch = 'echo \''.$b64.'\' | base64 -d | sudo -S -p \'\' "$@"';
$branch = 'echo '.$b64.' | base64 -d | sudo -S -p \'\' "$@"';
} else {
$branch = 'sudo -n "$@"';
}
@ -70,7 +70,7 @@ class FleetService
try {
[$out, $code] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server).
'priv sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1'
'priv sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1'
);
return ['ok' => $code === 0, 'output' => trim($out)];
@ -90,7 +90,7 @@ class FleetService
$ssh = $this->client($server, $timeout);
try {
[$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1');
[$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1');
return ['ok' => $code === 0, 'output' => trim($out)];
} finally {
@ -241,7 +241,8 @@ class FleetService
.'echo '.self::MARK.'addr===; ip -o -4 addr show; '
.'echo '.self::MARK.'link===; ip -o link show; '
.'echo '.self::MARK.'netdev===; cat /proc/net/dev; '
// hardening state (sshd -T + pkg checks) is read privileged via HardeningService::state().
.'echo '.self::MARK.'sshd===; grep -Ei "^[[:space:]]*(permitrootlogin|passwordauthentication)" /etc/ssh/sshd_config 2>/dev/null; '
.'echo '.self::MARK.'active===; for u in fail2ban ufw nftables unattended-upgrades; do echo "$u $(systemctl is-active $u 2>/dev/null)"; done; '
.'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null';
try {
@ -274,6 +275,7 @@ class FleetService
],
'volumes' => $volumes,
'interfaces' => $this->parseInterfaces($s['addr'] ?? '', $s['link'] ?? '', $s['netdev'] ?? ''),
'hardening' => $this->parseHardening($s['sshd'] ?? '', $s['active'] ?? ''),
'sshKeys' => $this->parseKeys($s['keys'] ?? ''),
];
}
@ -668,6 +670,27 @@ class FleetService
return $out;
}
private function parseHardening(string $sshd, string $active): string|array
{
$root = preg_match('/permitrootlogin\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes';
$pwauth = preg_match('/passwordauthentication\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes';
$svc = [];
foreach ($this->lines($active) as $line) {
[$name, $state] = array_pad(preg_split('/\s+/', trim($line)), 2, '');
$svc[$name] = $state;
}
$isActive = fn (string $u) => ($svc[$u] ?? '') === 'active';
$firewall = $isActive('ufw') || $isActive('nftables');
return [
['label' => 'SSH-Root-Login deaktiviert', 'detail' => "PermitRootLogin {$root}", 'status' => $root === 'no' ? 'online' : 'offline'],
['label' => 'SSH-Passwort-Login deaktiviert', 'detail' => "PasswordAuthentication {$pwauth}", 'status' => $pwauth === 'no' ? 'online' : 'warning'],
['label' => 'fail2ban aktiv', 'detail' => 'fail2ban.service · '.($svc['fail2ban'] ?? 'unbekannt'), 'status' => $isActive('fail2ban') ? 'online' : 'offline'],
['label' => 'Firewall aktiv', 'detail' => $firewall ? 'ufw/nftables · aktiv' : 'ufw/nftables · inaktiv', 'status' => $firewall ? 'online' : 'offline'],
['label' => 'Automatische Updates', 'detail' => 'unattended-upgrades · '.($svc['unattended-upgrades'] ?? 'unbekannt'), 'status' => $isActive('unattended-upgrades') ? 'online' : 'warning'],
];
}
private function parseKeys(string $body): array
{
$out = [];

View File

@ -4,200 +4,182 @@ namespace App\Services;
use App\Models\Server;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* Applies the dashboard server-hardening toggles over SSH (as root). Every item is
* BIDIRECTIONAL apply($server, $action, $enable) toggles the underlying feature
* on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand.
* Applies the dashboard server-hardening checklist items over SSH (as root).
*
* apply() runs the exact command built by commandFor() (the single source of truth);
* title()/description() supply the German confirm-modal copy for each direction.
* Disabling SSH password auth is GUARDED so the operator can never lock themselves out.
* Safety model: "guards + confirmation". Every action exposes a `preview()`
* of the exact shell commands the operator sees BEFORE applying, and
* `ssh_password` is GUARDED so the operator can never lock themselves out by
* disabling password auth without an authorized SSH key in place.
*
* Each apply returns array{ok: bool, output: string, preview: string}.
*/
class HardeningService
{
// 00- so it sorts + wins before distro drop-ins like 50-cloud-init.conf (sshd uses the first value).
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/00-clusev.conf';
/** Drop-in that holds every Clusev-managed sshd override. */
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/99-clusev.conf';
// 99zz- so Clusev's apt periodic setting wins (apt uses the LAST value across apt.conf.d/*).
private const APT_DROPIN = '/etc/apt/apt.conf.d/99zz-clusev';
public function __construct(private FleetService $fleet) {}
public function __construct(private FleetService $fleet, private FirewallService $firewall) {}
/** Human title for an action key (shown in the modal heading). */
public function title(string $action): string
{
return match ($action) {
'ssh_root' => 'SSH-Root-Login deaktivieren',
'ssh_password' => 'SSH-Passwort-Login deaktivieren',
'fail2ban' => 'fail2ban installieren',
'unattended' => 'Automatische Updates aktivieren',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/** Short description of what an action does (shown above the command preview). */
public function description(string $action): string
{
return match ($action) {
'ssh_root' => 'Schreibt PermitRootLogin no als Drop-in und lädt den SSH-Dienst neu. Direkter Root-Login über SSH wird unterbunden.',
'ssh_password' => 'Schreibt PasswordAuthentication no als Drop-in und lädt den SSH-Dienst neu. Anmeldung ist danach nur noch per SSH-Key möglich.',
'fail2ban' => 'Installiert fail2ban und startet den Dienst. Wiederholte fehlgeschlagene Logins werden automatisch gesperrt.',
'unattended' => 'Installiert unattended-upgrades und aktiviert automatische Sicherheitsupdates.',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/**
* Live state of every hardening item, read PRIVILEGED in one shot. SSH uses
* `sshd -T` (the EFFECTIVE config honours Include order + Match blocks);
* UFW uses `ufw status`; unattended uses the apt periodic config.
*
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}>
* The exact shell command(s) an action will run shown to the operator
* before applying. Pure string-building, no remote calls.
*/
public function state(Server $server): array
{
$cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; '
.'echo "fail2ban $(dpkg -l fail2ban 2>/dev/null | grep -c "^ii") $(systemctl is-active fail2ban 2>/dev/null)"; '
.'ufw_inst=$(dpkg -l ufw 2>/dev/null | grep -c "^ii"); ufw_act=inactive; '
.'[ "$ufw_inst" = "1" ] && ufw status 2>/dev/null | grep -qi "Status: active" && ufw_act=active; '
.'echo "ufw $ufw_inst $ufw_act"; '
.'ui=$(dpkg -l unattended-upgrades 2>/dev/null | grep -c "^ii"); ua=inactive; '
// apt-config dump = the EFFECTIVE periodic value across all apt.conf.d files (last wins).
.'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\' && ua=active; '
.'echo "unattended-upgrades $ui $ua"';
$res = $this->fleet->runPrivileged($server, $cmd);
if (! $res['ok']) {
throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
}
return $this->parseState($res['output']);
}
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}> */
private function parseState(string $out): array
{
$root = 'default';
$pwauth = 'default';
$pkg = [];
foreach (preg_split('/\R/', $out) as $line) {
$line = trim($line);
if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) {
$root = strtolower($m[1]);
} elseif (preg_match('/^passwordauthentication\s+(\S+)/i', $line, $m)) {
$pwauth = strtolower($m[1]);
} else {
$p = preg_split('/\s+/', $line);
if (count($p) === 3 && in_array($p[0], ['fail2ban', 'ufw', 'unattended-upgrades'], true)) {
$pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active'];
}
}
}
$f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false];
$ufw = $pkg['ufw'] ?? ['installed' => false, 'active' => false];
$upg = $pkg['unattended-upgrades'] ?? ['installed' => false, 'active' => false];
$detail = fn (string $name, array $st): string => $name.' · '
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
// A package feature is only effectively ON when the package is installed AND active —
// stale config (e.g. apt periodic = 1 after removal) must not read as secure.
$on = fn (array $st): bool => $st['installed'] && $st['active'];
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
return [
['key' => 'ssh_root', 'label' => 'SSH-Root-Login', 'detail' => 'PermitRootLogin '.$root,
'featureOn' => $root !== 'no', 'secure' => $root === 'no'],
['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication '.$pwauth,
'featureOn' => $pwauth !== 'no', 'secure' => $pwauth === 'no'],
['key' => 'fail2ban', 'label' => 'fail2ban', 'detail' => $detail('fail2ban.service', $f2b),
'featureOn' => $on($f2b), 'secure' => $on($f2b)],
['key' => 'firewall', 'label' => 'Firewall (UFW)', 'detail' => $detail('ufw', $ufw),
'featureOn' => $on($ufw), 'secure' => $on($ufw)],
['key' => 'unattended', 'label' => 'Automatische Updates', 'detail' => $detail('unattended-upgrades', $upg),
'featureOn' => $on($upg), 'secure' => $on($upg)],
];
}
/** Modal title, reflecting the toggle direction. */
public function title(string $action, bool $enable): string
public function preview(Server $server, string $action): string
{
return match ($action) {
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
'firewall' => $enable ? 'Firewall (UFW) aktivieren' : 'Firewall (UFW) deaktivieren',
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
'ssh_root' => $this->sshDropInPreview('PermitRootLogin no'),
'ssh_password' => $this->sshDropInPreview('PasswordAuthentication no'),
'fail2ban' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban'."\n"
.'systemctl enable --now fail2ban',
'unattended' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'."\n"
.'systemctl enable --now unattended-upgrades'."\n"
.'dpkg-reconfigure -f noninteractive unattended-upgrades',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
public function description(string $action, bool $enable): string
/**
* Apply an action over SSH (as root). Dispatcher over the action keys.
*
* @return array{ok: bool, output: string, preview: string}
*/
public function apply(Server $server, string $action): array
{
return match ($action) {
'ssh_root' => $enable
? 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.'
: 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.',
'ssh_password' => $enable
? 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).'
: 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
'fail2ban' => $enable
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
: 'Stoppt fail2ban und deaktiviert den Autostart.',
'firewall' => $enable
? 'Installiert UFW (falls nötig), gibt SSH + 80/443 frei und aktiviert die Firewall.'
: 'Deaktiviert die UFW-Firewall (ufw disable).',
'unattended' => $enable
? 'Installiert unattended-upgrades (falls nötig) und aktiviert die automatische Update-Periodik + Timer.'
: 'Schaltet die automatische Update-Periodik ab.',
'ssh_root' => $this->applySshRoot($server),
'ssh_password' => $this->applySshPassword($server),
'fail2ban' => $this->applyFail2ban($server),
'unattended' => $this->applyUnattended($server),
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
}
/** @return array{ok: bool, output: string} */
public function apply(Server $server, string $action, bool $enable): array
/** Disable direct SSH root login via a drop-in, then reload sshd. */
private function applySshRoot(Server $server): array
{
if ($action === 'firewall') {
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
$preview = $this->preview($server, 'ssh_root');
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PermitRootLogin no'));
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, '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, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).'];
}
}
// apt operations need a long timeout; the 12s default would abort the install.
$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']];
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Single source of truth for the shell command of a (non-firewall) toggle. */
private function commandFor(string $action, bool $enable): string
/**
* GUARD: refuse to disable password auth unless >=1 authorized SSH key is
* present otherwise the operator could be locked out (Aussperrgefahr).
*/
private function applySshPassword(Server $server): array
{
return match ($action) {
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
'fail2ban' => $enable
? '(dpkg -l fail2ban 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban) && systemctl enable --now fail2ban'
: 'systemctl disable --now fail2ban',
'unattended' => $enable
? '(dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades)'
.' && printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "1";\' \'APT::Periodic::Unattended-Upgrade "1";\' > '.self::APT_DROPIN
.' && systemctl enable --now unattended-upgrades apt-daily-upgrade.timer apt-daily.timer'
: 'printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "0";\' \'APT::Periodic::Unattended-Upgrade "0";\' > '.self::APT_DROPIN
.' && (systemctl disable --now unattended-upgrades 2>/dev/null || true)',
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
};
$preview = $this->preview($server, 'ssh_password');
// GUARD 1: if Clusev itself logs in with a PASSWORD, disabling password
// auth would cut Clusev's own access (its credential is not a key) — refuse.
if ($server->credential?->auth_type === 'password') {
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.',
'preview' => $preview,
];
}
// GUARD 2: there must be at least one authorized key on the box.
if (! $this->hasAuthorizedKey($server)) {
return [
'ok' => false,
'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',
'preview' => $preview,
];
}
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PasswordAuthentication no'));
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** True if the SSH user has at least one authorized key. */
/** Install + enable fail2ban. */
private function applyFail2ban(Server $server): array
{
$preview = $this->preview($server, 'fail2ban');
$cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban && systemctl enable --now fail2ban';
// apt download+install can take a minute — long timeout (12s default kills it).
$res = $this->fleet->runPrivileged($server, $cmd, 600);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/** Install + enable unattended-upgrades. */
private function applyUnattended(Server $server): array
{
$preview = $this->preview($server, 'unattended');
$cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades'
.' && systemctl enable --now unattended-upgrades'
.' && dpkg-reconfigure -f noninteractive unattended-upgrades';
// apt download+install can take a minute — long timeout (12s default kills it).
$res = $this->fleet->runPrivileged($server, $cmd, 600);
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
}
/**
* True if the SSH user has at least one authorized key. Tries the structured
* FleetService::sshKeys() first, then falls back to a raw grep so the guard
* never wrongly reports "no key" on a parsing hiccup.
*/
private function hasAuthorizedKey(Server $server): bool
{
try {
if (count($this->fleet->sshKeys($server)) > 0) {
return true;
}
} catch (Throwable) {
} catch (\Throwable) {
// fall through to the raw check
}
$res = $this->fleet->runPlain($server, 'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0');
$res = $this->fleet->runPlain(
$server,
'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0'
);
return $res['ok'] && (int) trim($res['output']) > 0;
}
/** Human-readable preview of writing a directive into the Clusev drop-in. */
private function sshDropInPreview(string $directive): string
{
return 'mkdir -p /etc/ssh/sshd_config.d'."\n"
."echo '{$directive}' > ".self::SSHD_DROPIN."\n"
.'systemctl reload ssh || systemctl reload sshd';
}
/**
* Install one sshd directive into the Clusev drop-in (replacing any prior value
* of the same key), then reload whichever ssh unit exists.
* The shell run to install one sshd directive: ensure the drop-in dir, write
* the directive, then reload whichever ssh unit exists. Appends if the
* drop-in already holds other Clusev directives (keeps both lines).
*/
private function sshDropInScript(string $directive): string
{
@ -205,6 +187,7 @@ class HardeningService
return 'mkdir -p /etc/ssh/sshd_config.d'
.' && touch '.self::SSHD_DROPIN
// drop any prior copy of this directive, then append the new value
.' && grep -viE "^[[:space:]]*'.$key.'[[:space:]]" '.self::SSHD_DROPIN.' > '.self::SSHD_DROPIN.'.tmp 2>/dev/null; '
.'mv '.self::SSHD_DROPIN.'.tmp '.self::SSHD_DROPIN.' 2>/dev/null; '
.'echo "'.$directive.'" >> '.self::SSHD_DROPIN

View File

@ -1,182 +0,0 @@
<?php
namespace App\Services;
use App\Models\Server;
use RuntimeException;
/**
* 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

@ -1,6 +1,5 @@
<?php
use App\Http\Middleware\SecurityHeaders;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -14,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
SecurityHeaders::class,
\App\Http\Middleware\SecurityHeaders::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -3,7 +3,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.1.1',
'version' => '0.1.0',
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable',

View File

@ -5,8 +5,6 @@
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'lock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'lock-open' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',
'git-branch' => '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
@ -14,9 +12,9 @@
'cpu' => '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
'file' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
'bell' => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',

View File

@ -86,7 +86,7 @@
'bg-accent/10 text-accent-text' => $isDir,
'bg-raised text-ink-3' => ! $isDir,
])>
<x-icon :name="$isDir ? 'folder' : 'file'" class="h-4 w-4" />
<x-icon :name="$isDir ? 'folder' : 'audit'" class="h-4 w-4" />
</span>
@if ($isDir)

View File

@ -1,87 +0,0 @@
<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

@ -1,57 +0,0 @@
<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

@ -1,7 +1,7 @@
<div class="p-5 sm:p-6" wire:init="load">
<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="file" class="h-5 w-5" />
<x-icon name="audit" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="truncate font-display text-base font-semibold text-ink">{{ $name }}</h2>

View File

@ -16,20 +16,31 @@
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
<p class="text-sm text-ink-2">{{ $error }}</p>
</div>
@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>
@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>
@endif
</div>
</div>
@endif
@endif
<div class="mt-6 flex items-center justify-end gap-2">

View File

@ -1,90 +0,0 @@
<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,13 +9,7 @@
<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>
<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>
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
</div>
{{-- KPI grid: 1 2 4 --}}

View File

@ -64,9 +64,6 @@
</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>
@ -168,38 +165,41 @@
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
<div class="divide-y divide-line">
@php
// Map each checklist label to its hardening-action key.
$hardenKey = function (string $label): ?string {
return match (true) {
str_contains($label, 'Root-Login') => 'ssh_root',
str_contains($label, 'Passwort-Login') => 'ssh_password',
str_contains($label, 'fail2ban') => 'fail2ban',
str_contains($label, 'Firewall') => 'firewall',
str_contains($label, 'Updates') => 'unattended',
default => null,
};
};
@endphp
@foreach ($hardening as $check)
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen). --}}
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
<span @class([
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
'border-online/25 bg-online/10 text-online' => $check['secure'],
'border-warning/25 bg-warning/10 text-warning' => ! $check['secure'],
])>
<x-icon :name="$check['secure'] ? 'lock' : 'lock-open'" class="h-4 w-4" />
</span>
@php $action = $hardenKey($check['label']); @endphp
<div @class([
'flex items-center gap-3 border-l-2 px-4 py-3 sm:px-5',
'border-online' => $check['status'] === 'online',
'border-warning' => $check['status'] === 'warning',
'border-offline' => $check['status'] === 'offline',
])>
<div class="min-w-0 flex-1">
<p class="flex items-center gap-2 truncate text-sm text-ink">
{{ $check['label'] }}
<span @class([
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
'text-online' => $check['secure'],
'text-warning' => ! $check['secure'],
])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span>
</p>
<p class="truncate text-sm text-ink">{{ $check['label'] }}</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" />
@if ($check['status'] !== 'online' && $action)
<x-btn variant="accent" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $action }}' } })">
Anwenden
</x-btn>
@else
<x-status-pill :status="$check['status']" class="shrink-0">
{{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }}
</x-status-pill>
@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' }} } })">
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
</x-btn>
</div>
@endforeach
</div>

View File

@ -26,6 +26,16 @@
{{-- 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">