Compare commits
4 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
0f57074e6f | |
|
|
05f8ce49b4 | |
|
|
36477c915a | |
|
|
3fc2edf4fc |
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -13,6 +13,28 @@ 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.
|
||||
|
|
@ -57,5 +79,6 @@ 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.0...HEAD
|
||||
[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
|
||||
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,7 +162,8 @@ class Index extends Component
|
|||
|
||||
/**
|
||||
* Delete a file (R5): opens the confirm modal, which writes the AuditEvent
|
||||
* and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later.
|
||||
* and re-dispatches `fileConfirmed` — handled by deleteEntry(), which performs
|
||||
* the SFTP unlink over the active server's connection.
|
||||
*/
|
||||
public function confirmDelete(int $index): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,8 @@ class EditCredential extends ModalComponent
|
|||
'username' => trim($this->username),
|
||||
'auth_type' => $this->authType === 'key' ? 'key' : 'password',
|
||||
'secret' => $this->secret,
|
||||
'passphrase' => $this->passphrase !== '' ? $this->passphrase : null,
|
||||
// A passphrase only applies to a private key; never store one for password auth.
|
||||
'passphrase' => ($this->authType === 'key' && $this->passphrase !== '') ? $this->passphrase : null,
|
||||
]);
|
||||
|
||||
AuditEvent::create([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,11 @@ 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);
|
||||
|
|
|
|||
|
|
@ -4,21 +4,19 @@ 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 a single server-hardening action (R5).
|
||||
* Preview + confirm modal for one server-hardening TOGGLE (R5).
|
||||
*
|
||||
* 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.
|
||||
* `$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.
|
||||
*/
|
||||
class HardeningAction extends ModalComponent
|
||||
{
|
||||
|
|
@ -26,30 +24,26 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
public string $action;
|
||||
|
||||
/** Optional TCP port (reserved for future allow/deny firewall actions). */
|
||||
public ?int $port = null;
|
||||
/** Desired feature state: true = aktivieren, false = deaktivieren. */
|
||||
public bool $enable;
|
||||
|
||||
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, ?int $port = null): void
|
||||
public function mount(int $serverId, string $action, bool $enable): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
$this->action = $action;
|
||||
$this->port = $port;
|
||||
$this->enable = $enable;
|
||||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
|
|
@ -59,16 +53,9 @@ class HardeningAction extends ModalComponent
|
|||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
$hardening = app(HardeningService::class);
|
||||
$this->heading = $hardening->title($action, $enable);
|
||||
$this->description = $hardening->description($action, $enable);
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
|
|
@ -93,9 +80,7 @@ class HardeningAction extends ModalComponent
|
|||
}
|
||||
|
||||
try {
|
||||
$result = $this->action === 'firewall'
|
||||
? app(FirewallService::class)->enable($server)
|
||||
: app(HardeningService::class)->apply($server, $this->action);
|
||||
$result = app(HardeningService::class)->apply($server, $this->action, $this->enable);
|
||||
} catch (Throwable $e) {
|
||||
$this->done = true;
|
||||
$this->ok = false;
|
||||
|
|
@ -106,13 +91,14 @@ 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 ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'harden.'.$this->action,
|
||||
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
|
||||
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,46 @@ class ServerSwitcher extends Component
|
|||
|
||||
session(['active_server_id' => $server->id]);
|
||||
|
||||
$this->redirect(request()->header('Referer') ?: route('dashboard'), navigate: true);
|
||||
$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;
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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;
|
||||
|
|
@ -73,8 +74,14 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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;
|
||||
|
|
@ -111,7 +112,7 @@ class Index extends Component
|
|||
|
||||
$this->dispatch('notify', message: $res['ok']
|
||||
? "{$name}: {$op} ausgeführt."
|
||||
: "{$name}: fehlgeschlagen — ".\Illuminate\Support\Str::limit($res['output'] ?: 'keine Rechte', 90));
|
||||
: "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90));
|
||||
|
||||
try {
|
||||
$this->services = $fleet->systemd($active)['services'];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -125,7 +125,6 @@ class Index extends Component
|
|||
'hasTls' => $this->domain !== '',
|
||||
'caddyConfig' => $deployment->renderCaddyConfig(),
|
||||
'reloadHint' => $deployment->reloadHint(),
|
||||
'whyNotEnv' => $deployment->whyNotEnv(),
|
||||
'channels' => self::CHANNELS,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
|
@ -31,9 +30,4 @@ class Server extends Model
|
|||
{
|
||||
return $this->hasOne(SshCredential::class);
|
||||
}
|
||||
|
||||
public function auditEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditEvent::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,9 +35,4 @@ class SshCredential extends Model
|
|||
{
|
||||
return $this->disabled_at !== null;
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNull('disabled_at');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -53,7 +52,9 @@ class DeploymentService
|
|||
/** True once a domain is configured — i.e. TLS via Let's Encrypt is in effect. */
|
||||
public function hasTls(): bool
|
||||
{
|
||||
return $this->domain() !== null && $this->domain() !== '';
|
||||
$domain = $this->domain();
|
||||
|
||||
return $domain !== null && $domain !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -137,16 +138,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`).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,93 +11,39 @@ 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.
|
||||
*
|
||||
* status()/preview() are read-only command builders; the mutating methods all
|
||||
* return array{ok: bool, output: string, preview: string}.
|
||||
* Both mutating methods return array{ok: bool, output: string}.
|
||||
*/
|
||||
class FirewallService
|
||||
{
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
/**
|
||||
* Parse `ufw status verbose` into a structured snapshot.
|
||||
*
|
||||
* @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.
|
||||
* 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);
|
||||
$res = $this->fleet->runPrivileged($server, $this->enableScript($port));
|
||||
// long timeout: may apt-install ufw first.
|
||||
$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']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a TCP port through the firewall.
|
||||
*
|
||||
* @return array{ok: bool, output: string, preview: string}
|
||||
*/
|
||||
public function allow(Server $server, int $port): array
|
||||
/** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */
|
||||
public function disable(Server $server): array
|
||||
{
|
||||
$port = $this->clampPort($port);
|
||||
$preview = "ufw allow {$port}/tcp";
|
||||
$res = $this->fleet->runPrivileged($server, $preview);
|
||||
$res = $this->fleet->runPrivileged($server, 'ufw disable');
|
||||
|
||||
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}
|
||||
*/
|
||||
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. */
|
||||
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
|
||||
private function enableScript(int $port): string
|
||||
{
|
||||
return "ufw allow {$port}/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw --force enable";
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -116,10 +62,4 @@ 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +241,7 @@ 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; '
|
||||
.'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; '
|
||||
// hardening state (sshd -T + pkg checks) is read privileged via HardeningService::state().
|
||||
.'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null';
|
||||
|
||||
try {
|
||||
|
|
@ -275,7 +274,6 @@ 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'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
|
@ -670,27 +668,6 @@ 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 = [];
|
||||
|
|
|
|||
|
|
@ -4,182 +4,200 @@ namespace App\Services;
|
|||
|
||||
use App\Models\Server;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Applies the dashboard server-hardening checklist items over SSH (as root).
|
||||
* 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.
|
||||
*
|
||||
* 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}.
|
||||
* 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.
|
||||
*/
|
||||
class HardeningService
|
||||
{
|
||||
/** Drop-in that holds every Clusev-managed sshd override. */
|
||||
private const SSHD_DROPIN = '/etc/ssh/sshd_config.d/99-clusev.conf';
|
||||
// 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';
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
// 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';
|
||||
|
||||
/** 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}"),
|
||||
};
|
||||
}
|
||||
public function __construct(private FleetService $fleet, private FirewallService $firewall) {}
|
||||
|
||||
/**
|
||||
* The exact shell command(s) an action will run — shown to the operator
|
||||
* before applying. Pure string-building, no remote calls.
|
||||
*/
|
||||
public function preview(Server $server, string $action): string
|
||||
{
|
||||
return match ($action) {
|
||||
'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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an action over SSH (as root). Dispatcher over the action keys.
|
||||
* 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{ok: bool, output: string, preview: string}
|
||||
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}>
|
||||
*/
|
||||
public function apply(Server $server, string $action): array
|
||||
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
|
||||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => $this->applySshRoot($server),
|
||||
'ssh_password' => $this->applySshPassword($server),
|
||||
'fail2ban' => $this->applyFail2ban($server),
|
||||
'unattended' => $this->applyUnattended($server),
|
||||
'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',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Disable direct SSH root login via a drop-in, then reload sshd. */
|
||||
private function applySshRoot(Server $server): array
|
||||
public function description(string $action, bool $enable): string
|
||||
{
|
||||
$preview = $this->preview($server, 'ssh_root');
|
||||
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PermitRootLogin no'));
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview];
|
||||
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.',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 array{ok: bool, output: string} */
|
||||
public function apply(Server $server, string $action, bool $enable): array
|
||||
{
|
||||
$preview = $this->preview($server, 'ssh_password');
|
||||
if ($action === 'firewall') {
|
||||
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
||||
|
||||
// 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,
|
||||
];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
// 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,
|
||||
];
|
||||
// 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).'];
|
||||
}
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $this->sshDropInScript('PasswordAuthentication no'));
|
||||
// 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'], 'preview' => $preview];
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Install + enable fail2ban. */
|
||||
private function applyFail2ban(Server $server): array
|
||||
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
||||
private function commandFor(string $action, bool $enable): string
|
||||
{
|
||||
$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];
|
||||
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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** True if the SSH user has at least one authorized key. */
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Install one sshd directive into the Clusev drop-in (replacing any prior value
|
||||
* of the same key), then reload whichever ssh unit exists.
|
||||
*/
|
||||
private function sshDropInScript(string $directive): string
|
||||
{
|
||||
|
|
@ -187,7 +205,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -13,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\SecurityHeaders::class,
|
||||
SecurityHeaders::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
|
|
@ -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.0',
|
||||
'version' => '0.1.1',
|
||||
|
||||
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
||||
'channel' => 'stable',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
'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"/>',
|
||||
|
|
@ -12,9 +14,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"/>',
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
'bg-accent/10 text-accent-text' => $isDir,
|
||||
'bg-raised text-ink-3' => ! $isDir,
|
||||
])>
|
||||
<x-icon :name="$isDir ? 'folder' : 'audit'" class="h-4 w-4" />
|
||||
<x-icon :name="$isDir ? 'folder' : 'file'" class="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
@if ($isDir)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="server" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">Server hinzufügen</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name</label>
|
||||
<input wire:model="name" type="text" placeholder="z. B. Prod-Web-01" maxlength="120"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('name')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">IP/Host</label>
|
||||
<input wire:model="ip" type="text" placeholder="10.10.90.10 oder host.example.com"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('ip')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">SSH-Port</label>
|
||||
<input wire:model="sshPort" type="number" min="1" max="65535" placeholder="22"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('sshPort')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label>
|
||||
<input wire:model="username" type="text" placeholder="root"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('username')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label>
|
||||
<select wire:model.live="authType"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none">
|
||||
<option value="password">Passwort</option>
|
||||
<option value="key">Privater Schlüssel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label>
|
||||
@if ($authType === 'key')
|
||||
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
|
||||
@else
|
||||
<input wire:model="secret" type="password" placeholder="••••••••"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@endif
|
||||
@error('secret')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
@if ($authType === 'key')
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label>
|
||||
<input wire:model="passphrase" type="password" placeholder="leer = keine"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zugangs-Name (optional)</label>
|
||||
<input wire:model="credentialName" type="text" placeholder="z. B. Root-Login · Deploy-User" maxlength="120"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('credentialName')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
Hinzufügen
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="shield" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">fail2ban konfigurieren</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Legt fest, wann fail2ban auf {{ $serverName ?: 'diesem Server' }} eine IP sperrt und wie lange.
|
||||
Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($error)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($loaded)
|
||||
<div class="mt-5 space-y-4">
|
||||
<div>
|
||||
<label for="f2b-bantime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Sperrdauer</label>
|
||||
<input id="f2b-bantime" type="text" maxlength="40" wire:model="bantime" placeholder="z. B. 600, 10m, 1h, -1 = dauerhaft"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('bantime')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="f2b-maxretry" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Max. Fehlversuche</label>
|
||||
<input id="f2b-maxretry" type="number" min="1" max="100" wire:model="maxretry"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('maxretry')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="f2b-findtime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zeitfenster</label>
|
||||
<input id="f2b-findtime" type="text" maxlength="40" wire:model="findtime" placeholder="z. B. 600, 10m, 1h"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('findtime')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ $loaded ? 'Abbrechen' : 'Schließen' }}</x-btn>
|
||||
@if ($loaded)
|
||||
<x-btn variant="accent" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
Speichern
|
||||
</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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="audit" class="h-5 w-5" />
|
||||
<x-icon name="file" 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>
|
||||
|
|
|
|||
|
|
@ -16,31 +16,20 @@
|
|||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Befehle (als root)</p>
|
||||
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3.5 py-3 font-mono text-[11px] leading-relaxed text-ink-2">{{ $preview }}</pre>
|
||||
</div>
|
||||
|
||||
@if ($done)
|
||||
<div @class([
|
||||
'mt-3 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<p @class([
|
||||
'flex items-center gap-1.5 font-mono text-[11px]',
|
||||
'text-online' => $ok,
|
||||
'text-offline' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
|
||||
{{ $ok ? 'Angewendet' : 'Fehlgeschlagen' }}
|
||||
</p>
|
||||
@if ($output !== '')
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
|
||||
@elseif ($done)
|
||||
<div @class([
|
||||
'mt-4 flex items-start gap-2.5 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" @class(['mt-0.5 h-4 w-4 shrink-0', 'text-online' => $ok, 'text-offline' => ! $ok]) />
|
||||
<div class="min-w-0">
|
||||
<p @class(['text-sm', 'text-online' => $ok, 'text-offline' => ! $ok])>{{ $ok ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.' }}</p>
|
||||
@if (! $ok && $output !== '')
|
||||
<p class="mt-1 break-words font-mono text-[11px] text-ink-3">{{ $output }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
|
||||
<x-icon name="rotate" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">System-Updates</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Aktualisiert die installierten Pakete auf {{ $serverName ?: 'diesem Server' }} über die
|
||||
Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($error)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@elseif (! $hasApt)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||
<p class="text-sm text-ink-2">Nur für Debian/Ubuntu (apt) verfügbar.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
|
||||
<span @class([
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||
'border-warning/25 bg-warning/10 text-warning' => $pending !== null && $pending > 0,
|
||||
'border-online/25 bg-online/10 text-online' => $pending === 0,
|
||||
'border-line bg-raised text-ink-3' => $pending === null,
|
||||
])>
|
||||
<x-icon name="rotate" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-display text-sm font-semibold text-ink">
|
||||
@if ($pending === null)
|
||||
Update-Anzahl unbekannt
|
||||
@elseif ($pending === 1)
|
||||
1 Paket-Update verfügbar
|
||||
@else
|
||||
{{ $pending }} Paket-Updates verfügbar
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-3">
|
||||
@if ($pending === null)
|
||||
Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich.
|
||||
@else
|
||||
{{ $pending > 0 ? 'Aktualisierung empfohlen.' : 'Das System ist auf dem aktuellen Stand.' }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($done)
|
||||
<div @class([
|
||||
'mt-3 rounded-md border px-3.5 py-3',
|
||||
'border-online/25 bg-online/10' => $ok,
|
||||
'border-offline/25 bg-offline/10' => ! $ok,
|
||||
])>
|
||||
<p @class([
|
||||
'flex items-center gap-1.5 font-mono text-[11px]',
|
||||
'text-online' => $ok,
|
||||
'text-offline' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
|
||||
{{ $ok ? 'System aktualisiert.' : 'Aktualisierung fehlgeschlagen.' }}
|
||||
</p>
|
||||
@if (! $ok && $output !== '')
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
@if ($done)
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
@if (! $error && $hasApt)
|
||||
<x-btn variant="accent" wire:click="upgrade" wire:target="upgrade" wire:loading.attr="disabled">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="upgrade" />
|
||||
<svg wire:loading wire:target="upgrade" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
Jetzt aktualisieren
|
||||
</x-btn>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -9,7 +9,13 @@
|
|||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Flotte</p>
|
||||
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Server</h2>
|
||||
</div>
|
||||
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.create-server' })">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" />
|
||||
Server hinzufügen
|
||||
</x-btn>
|
||||
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- KPI grid: 1 → 2 → 4 --}}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.system-update', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" /> System-Updates
|
||||
</x-btn>
|
||||
<x-btn variant="secondary" wire:click="openFiles">
|
||||
<x-icon name="folder" class="h-3.5 w-3.5" /> Dateien
|
||||
</x-btn>
|
||||
|
|
@ -165,41 +168,38 @@
|
|||
|
||||
<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)
|
||||
@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',
|
||||
])>
|
||||
{{-- 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>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-ink">{{ $check['label'] }}</p>
|
||||
<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 font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
|
||||
</div>
|
||||
@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
|
||||
@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>
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -26,16 +26,6 @@
|
|||
|
||||
{{-- Domain & TLS --}}
|
||||
<x-panel title="Domain & TLS" subtitle="Erreichbarkeit des Panels und Let's-Encrypt-Zertifikat">
|
||||
{{-- DB-not-.env clarification: the panel domain is a database value; .env is never rewritten. --}}
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
|
||||
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-cyan" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-ink-2">Die Domain wird in der <span class="text-ink">Datenbank</span> gespeichert, nicht in der <span class="font-mono text-ink">.env</span>.</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-3">{{ $whyNotEnv }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-4">Die <span class="text-ink-3">.env</span> wird vom Panel nie überschrieben. Laravel übernimmt die neue Adresse sofort (Laufzeit-Override von <span class="text-ink-3">app.url</span>); die Auslieferung greift nach dem Caddy-Reload unten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Current TLS state --}}
|
||||
@if ($hasTls)
|
||||
<div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">
|
||||
|
|
|
|||
Loading…
Reference in New Issue