Compare commits
No commits in common. "v0.2.0" and "v0.1.1" have entirely different histories.
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -13,41 +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._
|
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||||
|
|
||||||
## [0.2.0] - 2026-06-13
|
|
||||||
|
|
||||||
Mehr-Betriebssystem-Verwaltung, echte Firewall- und fail2ban-Steuerung aus dem Dashboard sowie eine Tastatur-/Befehls-Bedienung.
|
|
||||||
|
|
||||||
### Hinzugefügt
|
|
||||||
- **OS-Abstraktion**: Updates, Härtung und Firewall laufen jetzt über **apt, dnf und
|
|
||||||
zypper** (Debian/Ubuntu, RHEL/Fedora/Rocky/Alma, openSUSE). Das Betriebssystem wird
|
|
||||||
erkannt und auf der Server-Detailseite angezeigt (Paketverwaltung/Firewall). Arch
|
|
||||||
und Alpine werden erkannt; nicht unterstützte Funktionen werden mit deutschem
|
|
||||||
Hinweis sauber deaktiviert statt fehlzuschlagen.
|
|
||||||
- **Firewall-Regeln (UFW)**: Regeln ansehen, hinzufügen und löschen samt
|
|
||||||
Standard-Anzeige — mit Aussperr-Schutz (SSH-Regeln/-Port, deny ohne Port). firewalld
|
|
||||||
wird nur angezeigt (Laufzeit aller aktiven Zonen inkl. Rich Rules); Regelverwaltung
|
|
||||||
bleibt vorerst UFW.
|
|
||||||
- **fail2ban-Status**: Jails mit Zählern, **gebannte IPs sehen und entsperren**,
|
|
||||||
IP manuell sperren (R5-Dialog, Aussperr-Schutz für Loopback + eigenen Zugang) und
|
|
||||||
**Whitelist (ignoreip)** verwalten.
|
|
||||||
- **Befehls-Palette (Strg/⌘-K)** + Tastenkürzel: Schnell-Navigation (Leader „g“),
|
|
||||||
Seitensuche („/“), Hilfe („?“).
|
|
||||||
|
|
||||||
### Geändert
|
|
||||||
- System-Updates verwenden je Distribution den passenden Paketmanager
|
|
||||||
(Audit-Aktion `system.package_upgrade`).
|
|
||||||
- fail2ban-Tuning und -Whitelist liegen in getrennten Drop-ins, damit Änderungen sich
|
|
||||||
nicht gegenseitig überschreiben; das frühere Einzel-Drop-in wird migriert.
|
|
||||||
- Server-Detailseite zeigt erkanntes System, Paketverwaltung und Firewall-Werkzeug.
|
|
||||||
- System-Seite erklärt jetzt, warum TLS über Caddy terminiert wird.
|
|
||||||
|
|
||||||
### Sicherheit
|
|
||||||
- Firewall: race-freies Löschen per Regel-Spezifikation (keine Regelnummern),
|
|
||||||
Whitelisting gegen Befehls-Injection, firewalld-Lockout-Schutz (Ports vor dem Start
|
|
||||||
in der permanenten Konfiguration).
|
|
||||||
- fail2ban: kanonischer (inet_pton) Schutz gegen das Sperren des eigenen Zugangs,
|
|
||||||
ignoreip wird verbatim erhalten (inkl. Hostnames/Fortsetzungszeilen).
|
|
||||||
|
|
||||||
## [0.1.1] - 2026-06-13
|
## [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.
|
Wartungs-Release: vollständige Code-Auditierung, Entfernung von totem Code und ein behobenes Open-Redirect. Keine funktionalen Änderungen für Nutzer.
|
||||||
|
|
@ -114,7 +79,6 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane
|
||||||
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
|
- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog
|
||||||
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
|
pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`).
|
||||||
|
|
||||||
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.0...HEAD
|
[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.1.1...HEAD
|
||||||
[0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0
|
|
||||||
[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1
|
[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
|
[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@ class ConfirmAction extends ModalComponent
|
||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
public array $params = [];
|
public array $params = [];
|
||||||
|
|
||||||
/** Toast shown on confirm. Pass '' to defer the notification to the handler
|
|
||||||
* (e.g. when the real outcome is only known after a remote command runs). */
|
|
||||||
public string $notify = 'Aktion ausgeführt.';
|
public string $notify = 'Aktion ausgeführt.';
|
||||||
|
|
||||||
public static function modalMaxWidth(): string
|
public static function modalMaxWidth(): string
|
||||||
|
|
@ -65,10 +63,7 @@ class ConfirmAction extends ModalComponent
|
||||||
$this->dispatch($this->event, ...$this->params);
|
$this->dispatch($this->event, ...$this->params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty notify = the handler will report the real outcome itself.
|
$this->dispatch('notify', message: $this->notify);
|
||||||
if ($this->notify !== '') {
|
|
||||||
$this->dispatch('notify', message: $this->notify);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->closeModal();
|
$this->closeModal();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Modals;
|
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
|
||||||
use App\Models\Server;
|
|
||||||
use App\Services\Fail2banService;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use LivewireUI\Modal\ModalComponent;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually ban an IP in a fail2ban jail (R5: a deliberate confirm dialog, not a
|
|
||||||
* native popup). Fail2banService refuses to ban loopback or Clusev's own connection,
|
|
||||||
* so this can never lock the operator out.
|
|
||||||
*/
|
|
||||||
class Fail2banBan extends ModalComponent
|
|
||||||
{
|
|
||||||
public int $serverId;
|
|
||||||
|
|
||||||
/** @var array<int, string> */
|
|
||||||
public array $jails = [];
|
|
||||||
|
|
||||||
public string $jail = '';
|
|
||||||
|
|
||||||
public string $ip = '';
|
|
||||||
|
|
||||||
public ?string $error = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $jails
|
|
||||||
*/
|
|
||||||
public function mount(int $serverId, array $jails = []): void
|
|
||||||
{
|
|
||||||
$this->serverId = $serverId;
|
|
||||||
$this->jails = array_values($jails);
|
|
||||||
$this->jail = $this->jails[0] ?? 'sshd';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function modalMaxWidth(): string
|
|
||||||
{
|
|
||||||
return 'lg';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(Fail2banService $fail2ban): void
|
|
||||||
{
|
|
||||||
$this->error = null;
|
|
||||||
|
|
||||||
$server = Server::find($this->serverId);
|
|
||||||
if (! $server) {
|
|
||||||
$this->error = 'Server nicht gefunden.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$res = $fail2ban->ban($server, $this->jail, trim($this->ip));
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->error = $e->getMessage();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $res['ok']) {
|
|
||||||
$this->error = $res['output'] !== '' ? $res['output'] : 'Sperren fehlgeschlagen.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditEvent::create([
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'server_id' => $server->id,
|
|
||||||
'actor' => Auth::user()?->name ?? 'system',
|
|
||||||
'action' => 'fail2ban.ban',
|
|
||||||
'target' => trim($this->ip).' · '.$this->jail.' · '.$server->name,
|
|
||||||
'ip' => request()->ip(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->dispatch('fail2banChanged');
|
|
||||||
$this->dispatch('notify', message: 'IP '.trim($this->ip).' gesperrt.');
|
|
||||||
$this->closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.modals.fail2ban-ban');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Livewire\Modals;
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
use App\Models\AuditEvent;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\Fail2banService;
|
use App\Services\MaintenanceService;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use LivewireUI\Modal\ModalComponent;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
@ -33,7 +33,7 @@ class Fail2banConfig extends ModalComponent
|
||||||
/** True only once the current policy was read — guards save() from overwriting with unseen defaults. */
|
/** True only once the current policy was read — guards save() from overwriting with unseen defaults. */
|
||||||
public bool $loaded = false;
|
public bool $loaded = false;
|
||||||
|
|
||||||
public function mount(int $serverId, Fail2banService $fail2ban): void
|
public function mount(int $serverId, MaintenanceService $maintenance): void
|
||||||
{
|
{
|
||||||
$this->serverId = $serverId;
|
$this->serverId = $serverId;
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ class Fail2banConfig extends ModalComponent
|
||||||
$this->serverName = $server->name;
|
$this->serverName = $server->name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$current = $fail2ban->readConfig($server);
|
$current = $maintenance->readFail2ban($server);
|
||||||
$this->bantime = $current['bantime'];
|
$this->bantime = $current['bantime'];
|
||||||
$this->maxretry = $current['maxretry'];
|
$this->maxretry = $current['maxretry'];
|
||||||
$this->findtime = $current['findtime'];
|
$this->findtime = $current['findtime'];
|
||||||
|
|
@ -62,7 +62,7 @@ class Fail2banConfig extends ModalComponent
|
||||||
return 'lg';
|
return 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(Fail2banService $fail2ban): void
|
public function save(MaintenanceService $maintenance): void
|
||||||
{
|
{
|
||||||
// Never overwrite the remote policy with defaults the operator never saw —
|
// Never overwrite the remote policy with defaults the operator never saw —
|
||||||
// saving is only allowed once the current values were read successfully.
|
// saving is only allowed once the current values were read successfully.
|
||||||
|
|
@ -99,9 +99,8 @@ class Fail2banConfig extends ModalComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Tuning lives in its OWN drop-in (separate from the whitelist), so this can
|
// Values are already validated/clamped to the allowed ranges above.
|
||||||
// never touch ignoreip. Values were validated/clamped above.
|
$result = $maintenance->writeFail2ban(
|
||||||
$result = $fail2ban->writeTuning(
|
|
||||||
$server,
|
$server,
|
||||||
(string) $validated['bantime'],
|
(string) $validated['bantime'],
|
||||||
(int) $validated['maxretry'],
|
(int) $validated['maxretry'],
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Modals;
|
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
|
||||||
use App\Models\Server;
|
|
||||||
use App\Services\FirewallService;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use LivewireUI\Modal\ModalComponent;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a firewall rule. For ufw the operator picks action/protocol/port/source; for
|
|
||||||
* firewalld (an allow-list default zone) it opens a port. All validation happens in
|
|
||||||
* FirewallService — this modal only collects and forwards. Adding is non-destructive,
|
|
||||||
* so no R5 confirm (deleting a rule is the guarded path).
|
|
||||||
*/
|
|
||||||
class FirewallRule extends ModalComponent
|
|
||||||
{
|
|
||||||
public int $serverId;
|
|
||||||
|
|
||||||
public string $tool = 'ufw';
|
|
||||||
|
|
||||||
public string $action = 'allow';
|
|
||||||
|
|
||||||
public string $proto = 'tcp';
|
|
||||||
|
|
||||||
public string $port = '';
|
|
||||||
|
|
||||||
public string $from = '';
|
|
||||||
|
|
||||||
public ?string $error = null;
|
|
||||||
|
|
||||||
public function mount(int $serverId, string $tool = 'ufw'): void
|
|
||||||
{
|
|
||||||
$this->serverId = $serverId;
|
|
||||||
$this->tool = $tool === 'firewalld' ? 'firewalld' : 'ufw';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function modalMaxWidth(): string
|
|
||||||
{
|
|
||||||
return 'lg';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(FirewallService $firewall): void
|
|
||||||
{
|
|
||||||
$this->error = null;
|
|
||||||
|
|
||||||
$server = Server::find($this->serverId);
|
|
||||||
if (! $server) {
|
|
||||||
$this->error = 'Server nicht gefunden.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$port = trim($this->port) !== '' ? (int) $this->port : null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$res = $firewall->addRule($server, $this->action, $this->proto, $port, $this->from);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->error = $e->getMessage();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $res['ok']) {
|
|
||||||
$this->error = $res['output'] !== '' ? $res['output'] : 'Regel konnte nicht hinzugefügt werden.';
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditEvent::create([
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'server_id' => $server->id,
|
|
||||||
'actor' => Auth::user()?->name ?? 'system',
|
|
||||||
'action' => 'firewall.rule_add',
|
|
||||||
'target' => $server->name.' · '.$this->summary($port),
|
|
||||||
'ip' => request()->ip(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->dispatch('firewallChanged');
|
|
||||||
$this->dispatch('notify', message: 'Firewall-Regel hinzugefügt.');
|
|
||||||
$this->closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Short human summary of the rule for the audit log. */
|
|
||||||
private function summary(?int $port): string
|
|
||||||
{
|
|
||||||
if ($this->tool === 'firewalld') {
|
|
||||||
return 'Port '.$port.'/'.($this->proto === 'udp' ? 'udp' : 'tcp').' geöffnet';
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [$this->action];
|
|
||||||
if ($port !== null) {
|
|
||||||
$parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : '');
|
|
||||||
}
|
|
||||||
if (trim($this->from) !== '') {
|
|
||||||
$parts[] = 'von '.trim($this->from);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.modals.firewall-rule');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,11 +10,10 @@ use LivewireUI\Modal\ModalComponent;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply pending package updates across apt/dnf/zypper (resolved per OS). mount()
|
* Apply pending apt package updates (Debian/Ubuntu only). mount() reads whether
|
||||||
* reads whether updates are supported on this host + the pending count; the body
|
* apt exists + the pending count; the body explains in GERMAN what happens — never
|
||||||
* explains in GERMAN what happens — never a shell command. „Jetzt aktualisieren"
|
* a shell command. „Jetzt aktualisieren" runs the upgrade, AUDITs, notifies, and
|
||||||
* runs the upgrade, AUDITs, notifies, and keeps the modal open showing a clean
|
* keeps the modal open showing a clean German result.
|
||||||
* German result. Unsupported hosts (e.g. Arch/Alpine) get a German reason instead.
|
|
||||||
*/
|
*/
|
||||||
class SystemUpdate extends ModalComponent
|
class SystemUpdate extends ModalComponent
|
||||||
{
|
{
|
||||||
|
|
@ -22,11 +21,7 @@ class SystemUpdate extends ModalComponent
|
||||||
|
|
||||||
public string $serverName = '';
|
public string $serverName = '';
|
||||||
|
|
||||||
/** True when Clusev can manage package updates on this host. */
|
public bool $hasApt = false;
|
||||||
public bool $supported = false;
|
|
||||||
|
|
||||||
/** German reason shown when updates are not supported on this OS. */
|
|
||||||
public ?string $unsupportedReason = null;
|
|
||||||
|
|
||||||
/** Pending upgrade count, or null when it could not be determined ("unbekannt"). */
|
/** Pending upgrade count, or null when it could not be determined ("unbekannt"). */
|
||||||
public ?int $pending = null;
|
public ?int $pending = null;
|
||||||
|
|
@ -35,7 +30,7 @@ class SystemUpdate extends ModalComponent
|
||||||
|
|
||||||
public bool $ok = false;
|
public bool $ok = false;
|
||||||
|
|
||||||
/** Trimmed package-manager output, only shown when the upgrade failed. */
|
/** Trimmed apt output, only shown when the upgrade failed. */
|
||||||
public string $output = '';
|
public string $output = '';
|
||||||
|
|
||||||
public ?string $error = null;
|
public ?string $error = null;
|
||||||
|
|
@ -54,9 +49,8 @@ class SystemUpdate extends ModalComponent
|
||||||
$this->serverName = $server->name;
|
$this->serverName = $server->name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->unsupportedReason = $maintenance->updateSupport($server);
|
$this->hasApt = $maintenance->hasApt($server);
|
||||||
$this->supported = $this->unsupportedReason === null;
|
$this->pending = $this->hasApt ? $maintenance->pendingUpdates($server) : null;
|
||||||
$this->pending = $this->supported ? $maintenance->pendingUpdates($server) : null;
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->error = $e->getMessage();
|
$this->error = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +63,7 @@ class SystemUpdate extends ModalComponent
|
||||||
|
|
||||||
public function upgrade(MaintenanceService $maintenance): void
|
public function upgrade(MaintenanceService $maintenance): void
|
||||||
{
|
{
|
||||||
if ($this->error !== null || $this->done || ! $this->supported) {
|
if ($this->error !== null || $this->done || ! $this->hasApt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +75,7 @@ class SystemUpdate extends ModalComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $maintenance->applyUpgrades($server);
|
$result = $maintenance->aptUpgrade($server);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->done = true;
|
$this->done = true;
|
||||||
$this->ok = false;
|
$this->ok = false;
|
||||||
|
|
@ -101,7 +95,7 @@ class SystemUpdate extends ModalComponent
|
||||||
'user_id' => Auth::id(),
|
'user_id' => Auth::id(),
|
||||||
'server_id' => $server->id,
|
'server_id' => $server->id,
|
||||||
'actor' => Auth::user()?->name ?? 'system',
|
'actor' => Auth::user()?->name ?? 'system',
|
||||||
'action' => 'system.package_upgrade',
|
'action' => 'system.apt_upgrade',
|
||||||
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
|
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||||
'ip' => request()->ip(),
|
'ip' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,9 @@ namespace App\Livewire\Servers;
|
||||||
|
|
||||||
use App\Models\AuditEvent;
|
use App\Models\AuditEvent;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\Fail2banService;
|
|
||||||
use App\Services\FirewallService;
|
|
||||||
use App\Services\FleetService;
|
use App\Services\FleetService;
|
||||||
use App\Services\HardeningService;
|
use App\Services\HardeningService;
|
||||||
use App\Support\Os\OsDetector;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
|
|
@ -36,18 +32,6 @@ class Show extends Component
|
||||||
|
|
||||||
public array $hardening = [];
|
public array $hardening = [];
|
||||||
|
|
||||||
/** Firewall state (installed/active/defaults/rules) for the Firewall-Regeln panel. */
|
|
||||||
public array $firewall = [];
|
|
||||||
|
|
||||||
/** fail2ban state (jails/banned IPs/whitelist) for the fail2ban-Status panel. */
|
|
||||||
public array $fail2ban = [];
|
|
||||||
|
|
||||||
/** Input for adding an IP/CIDR to the fail2ban whitelist. */
|
|
||||||
public string $newIgnoreIp = '';
|
|
||||||
|
|
||||||
/** Detected OS tooling (family/package manager/firewall) for the identity panel. */
|
|
||||||
public array $os = [];
|
|
||||||
|
|
||||||
public array $sshKeys = [];
|
public array $sshKeys = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,42 +76,12 @@ class Show extends Component
|
||||||
$this->interfaces = $snap['interfaces'];
|
$this->interfaces = $snap['interfaces'];
|
||||||
$this->sshKeys = $snap['sshKeys'];
|
$this->sshKeys = $snap['sshKeys'];
|
||||||
|
|
||||||
// Detected OS profile (cached) — surfaces the package manager + firewall
|
|
||||||
// tooling and drives which hardening features are offered.
|
|
||||||
try {
|
|
||||||
// fresh probe on each full load; primes the 1h cache the hardening,
|
|
||||||
// firewall and update reads reuse within this request cycle.
|
|
||||||
$profile = app(OsDetector::class)->detect($this->server, fresh: true);
|
|
||||||
$this->os = [
|
|
||||||
'familyLabel' => $profile->familyLabel(),
|
|
||||||
'packageManager' => $profile->packageManager,
|
|
||||||
'firewallTool' => $profile->firewallTool,
|
|
||||||
'serviceManager' => $profile->serviceManager,
|
|
||||||
];
|
|
||||||
} catch (Throwable) {
|
|
||||||
$this->os = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks).
|
// Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks).
|
||||||
try {
|
try {
|
||||||
$this->hardening = app(HardeningService::class)->state($this->server);
|
$this->hardening = app(HardeningService::class)->state($this->server);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$this->hardening = [];
|
$this->hardening = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Firewall rules/defaults (separate PRIVILEGED read; empty on failure).
|
|
||||||
try {
|
|
||||||
$this->firewall = app(FirewallService::class)->status($this->server);
|
|
||||||
} catch (Throwable) {
|
|
||||||
$this->firewall = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// fail2ban jails/banned IPs/whitelist (separate PRIVILEGED read).
|
|
||||||
try {
|
|
||||||
$this->fail2ban = app(Fail2banService::class)->status($this->server);
|
|
||||||
} catch (Throwable) {
|
|
||||||
$this->fail2ban = [];
|
|
||||||
}
|
|
||||||
$this->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
|
$this->loadAvg = (float) ($snap['metrics']['load'] ?? 0);
|
||||||
$this->connected = true;
|
$this->connected = true;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
|
|
@ -298,190 +252,6 @@ class Show extends Component
|
||||||
$this->load($fleet);
|
$this->load($fleet);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a firewall rule (R5): opens the confirm modal, which writes the
|
|
||||||
* AuditEvent and re-dispatches `firewallRuleDelete` (handled below).
|
|
||||||
*/
|
|
||||||
public function confirmDeleteRule(int $index): void
|
|
||||||
{
|
|
||||||
$rule = $this->firewall['rules'][$index] ?? null;
|
|
||||||
if ($rule === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$spec = $rule['spec'] ?? '';
|
|
||||||
$label = $rule['label'] ?? ($rule['raw'] ?? $spec);
|
|
||||||
|
|
||||||
$this->dispatch('openModal',
|
|
||||||
component: 'modals.confirm-action',
|
|
||||||
arguments: [
|
|
||||||
'heading' => 'Firewall-Regel entfernen',
|
|
||||||
'body' => "Die Regel „{$label}“ wird aus der Firewall von {$this->server->name} entfernt.",
|
|
||||||
'confirmLabel' => 'Entfernen',
|
|
||||||
'danger' => true,
|
|
||||||
'icon' => 'trash',
|
|
||||||
// No premature audit/notify — the handler records the real outcome
|
|
||||||
// only after the remote command returns (audit reflects success).
|
|
||||||
'event' => 'firewallRuleDelete',
|
|
||||||
'params' => ['spec' => $spec, 'label' => $label],
|
|
||||||
'notify' => '',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies the confirmed rule deletion over SSH, then audits + reloads on success. */
|
|
||||||
#[On('firewallRuleDelete')]
|
|
||||||
public function deleteFirewallRule(string $spec, string $label, FirewallService $firewall): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$res = $firewall->deleteRule($this->server, $spec);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The rule was already gone — inform, refresh, but do NOT audit a non-event.
|
|
||||||
if (! empty($res['notFound'])) {
|
|
||||||
$this->dispatch('notify', message: 'Regel existiert nicht mehr.');
|
|
||||||
$this->reloadFirewall($firewall);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $res['ok']) {
|
|
||||||
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditEvent::create([
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'server_id' => $this->server->id,
|
|
||||||
'actor' => Auth::user()?->name ?? 'system',
|
|
||||||
'action' => 'firewall.rule_delete',
|
|
||||||
'target' => $label.' · '.$this->server->name,
|
|
||||||
'ip' => request()->ip(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: 'Firewall-Regel entfernt.');
|
|
||||||
$this->reloadFirewall($firewall);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-read the firewall state after an add/delete/default change. */
|
|
||||||
#[On('firewallChanged')]
|
|
||||||
public function reloadFirewall(FirewallService $firewall): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->firewall = $firewall->status($this->server);
|
|
||||||
} catch (Throwable) {
|
|
||||||
// keep the current state on failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unban a fail2ban IP — recovery action, so a direct (audited) click, no R5 modal. */
|
|
||||||
public function unbanIp(string $jail, string $ip, Fail2banService $fail2ban): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$res = $fail2ban->unban($this->server, $jail, $ip);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! $res['ok']) {
|
|
||||||
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name);
|
|
||||||
$this->dispatch('notify', message: "IP {$ip} entsperrt.");
|
|
||||||
$this->reloadFail2ban($fail2ban);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add an IP/CIDR to the fail2ban whitelist (ignoreip) — non-destructive, direct + audited. */
|
|
||||||
public function addIgnore(Fail2banService $fail2ban): void
|
|
||||||
{
|
|
||||||
$ip = trim($this->newIgnoreIp);
|
|
||||||
if ($ip === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$res = $fail2ban->addIgnoreIp($this->server, $ip);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! $res['ok']) {
|
|
||||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! empty($res['noop'])) {
|
|
||||||
$this->newIgnoreIp = '';
|
|
||||||
$this->dispatch('notify', message: $res['output']);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name);
|
|
||||||
$this->newIgnoreIp = '';
|
|
||||||
$this->dispatch('notify', message: "{$ip} zur Whitelist hinzugefügt.");
|
|
||||||
$this->reloadFail2ban($fail2ban);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove an IP/CIDR from the fail2ban whitelist — direct + audited (loopback is refused by the service). */
|
|
||||||
public function removeIgnore(string $ip, Fail2banService $fail2ban): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! $res['ok']) {
|
|
||||||
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! empty($res['noop'])) {
|
|
||||||
$this->dispatch('notify', message: $res['output']);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name);
|
|
||||||
$this->dispatch('notify', message: "{$ip} von der Whitelist entfernt.");
|
|
||||||
$this->reloadFail2ban($fail2ban);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-read fail2ban state after an unban/ban/whitelist change. */
|
|
||||||
#[On('fail2banChanged')]
|
|
||||||
public function reloadFail2ban(Fail2banService $fail2ban): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->fail2ban = $fail2ban->status($this->server);
|
|
||||||
} catch (Throwable) {
|
|
||||||
// keep the current state on failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write one AuditEvent for a fail2ban action (success path only). */
|
|
||||||
private function audit(string $action, string $target): void
|
|
||||||
{
|
|
||||||
AuditEvent::create([
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'server_id' => $this->server->id,
|
|
||||||
'actor' => Auth::user()?->name ?? 'system',
|
|
||||||
'action' => $action,
|
|
||||||
'target' => $target,
|
|
||||||
'ip' => request()->ip(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.servers.show');
|
return view('livewire.servers.show');
|
||||||
|
|
|
||||||
|
|
@ -1,427 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Server;
|
|
||||||
use App\Support\Os\OsDetector;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fail2ban management over SSH (as root): jail status + banned-IP list, unban,
|
|
||||||
* manual ban (lockout-guarded), and the Clusev-managed [DEFAULT] tuning + whitelist.
|
|
||||||
*
|
|
||||||
* Clusev owns EXACTLY ONE file — the zz-clusev.local drop-in — and rewrites the whole
|
|
||||||
* [DEFAULT] block on every change, so the tuning (bantime/findtime/maxretry) and the
|
|
||||||
* whitelist (ignoreip) can never clobber each other. Loopback is always whitelisted.
|
|
||||||
*/
|
|
||||||
class Fail2banService
|
|
||||||
{
|
|
||||||
// TWO 'zz-' drop-ins (sort LAST in jail.d so their [DEFAULT] wins). Tuning and the
|
|
||||||
// whitelist live in SEPARATE files and each writes only its OWN keys, so editing the
|
|
||||||
// whitelist can never rewrite (and clobber) the ban policy, and vice-versa.
|
|
||||||
private const DROPIN_TUNING = '/etc/fail2ban/jail.d/zz-clusev-tuning.local';
|
|
||||||
|
|
||||||
private const DROPIN_IGNOREIP = '/etc/fail2ban/jail.d/zz-clusev-ignoreip.local';
|
|
||||||
|
|
||||||
// The previous single-file drop-in (tuning only). It sorts AFTER the new split files,
|
|
||||||
// so it must be removed on migration or its stale [DEFAULT] would override new tuning.
|
|
||||||
private const LEGACY_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
|
|
||||||
|
|
||||||
// Always whitelisted so the operator can never lock fail2ban against localhost.
|
|
||||||
private const LOOPBACK = ['127.0.0.1/8', '::1'];
|
|
||||||
|
|
||||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
|
||||||
|
|
||||||
/** NULL when fail2ban is supported on this host, else a German reason. */
|
|
||||||
public function support(Server $server): ?string
|
|
||||||
{
|
|
||||||
return $this->detector->detect($server)->supports('fail2ban');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jail status + banned IPs + whitelist in as few round-trips as possible.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function status(Server $server): array
|
|
||||||
{
|
|
||||||
$reason = $this->support($server);
|
|
||||||
$base = ['supported' => $reason === null, 'reason' => $reason, 'installed' => false, 'active' => false, 'jails' => [], 'ignoreip' => []];
|
|
||||||
if ($reason !== null) {
|
|
||||||
return $base;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cmd = 'echo CLUSEV_F2B_OK; '
|
|
||||||
.'command -v fail2ban-client >/dev/null 2>&1 && echo INST=yes || echo INST=no; '
|
|
||||||
.'systemctl is-active --quiet fail2ban && echo ACT=active || echo ACT=inactive; '
|
|
||||||
// CLIENT_OK only when `fail2ban-client status` itself SUCCEEDS — so an active
|
|
||||||
// service whose client call fails is not misreported as "0 jails".
|
|
||||||
.'cs=$(fail2ban-client status 2>/dev/null) && echo CLIENT_OK; '
|
|
||||||
.'jails=$(printf "%s" "$cs" | sed -n "s/.*Jail list:[[:space:]]*//p" | tr "," " "); '
|
|
||||||
.'for j in $jails; do echo ===CLUSEV:jail:$j===; fail2ban-client status "$j" 2>/dev/null; done';
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
|
||||||
if (! str_contains($res['output'], 'CLUSEV_F2B_OK')) {
|
|
||||||
return $base + ['readError' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = $res['output'];
|
|
||||||
$installed = (bool) preg_match('/^INST=yes$/m', $out);
|
|
||||||
$active = (bool) preg_match('/^ACT=active$/m', $out);
|
|
||||||
|
|
||||||
// Service active but the client query failed -> a real read error, not "no jails".
|
|
||||||
if ($installed && $active && ! preg_match('/^CLIENT_OK$/m', $out)) {
|
|
||||||
return $base + ['readError' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
$jails = [];
|
|
||||||
foreach ($this->jailSections($out) as $name => $body) {
|
|
||||||
$jails[] = [
|
|
||||||
'name' => $name,
|
|
||||||
'currentlyFailed' => $this->intField($body, 'Currently failed'),
|
|
||||||
'totalFailed' => $this->intField($body, 'Total failed'),
|
|
||||||
'currentlyBanned' => $this->intField($body, 'Currently banned'),
|
|
||||||
'totalBanned' => $this->intField($body, 'Total banned'),
|
|
||||||
'bannedIps' => $this->bannedIps($body),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignoreip is managed separately (our drop-in); read it best-effort.
|
|
||||||
$ignoreip = [];
|
|
||||||
try {
|
|
||||||
$ignoreip = $this->readConfig($server)['ignoreip'];
|
|
||||||
} catch (RuntimeException) {
|
|
||||||
// leave empty on read failure
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['supported' => true, 'reason' => null, 'installed' => $installed, 'active' => $active, 'jails' => $jails, 'ignoreip' => $ignoreip];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the effective [DEFAULT] tuning + whitelist (last value wins across files).
|
|
||||||
*
|
|
||||||
* @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
|
|
||||||
*/
|
|
||||||
public function readConfig(Server $server): array
|
|
||||||
{
|
|
||||||
// A leading sentinel proves the read RAN (sudo/SSH ok); a CLUSEV_RESET marker
|
|
||||||
// stops a section bleeding across file boundaries. Only [DEFAULT] keys count.
|
|
||||||
$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->parseDefaults($res['output']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write ONLY the [DEFAULT] tuning keys to the tuning drop-in (never ignoreip), so a
|
|
||||||
* tuning change can't touch the whitelist. Values must already be validated/clamped.
|
|
||||||
*
|
|
||||||
* @return array{ok: bool, output: string}
|
|
||||||
*/
|
|
||||||
public function writeTuning(Server $server, string $bantime, int $maxretry, string $findtime): array
|
|
||||||
{
|
|
||||||
$bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? '');
|
|
||||||
$findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? '');
|
|
||||||
|
|
||||||
$content = "# Managed by Clusev — fail2ban tuning. Do not edit by hand.\n"
|
|
||||||
."[DEFAULT]\n"
|
|
||||||
."bantime = {$bantime}\n"
|
|
||||||
."findtime = {$findtime}\n"
|
|
||||||
."maxretry = {$maxretry}\n";
|
|
||||||
|
|
||||||
// Drop the legacy single-file drop-in (tuning only) so it can't override this.
|
|
||||||
return $this->writeDropin($server, self::DROPIN_TUNING, $content, [self::LEGACY_DROPIN]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write ONLY the [DEFAULT] ignoreip to the whitelist drop-in (never tuning), so a
|
|
||||||
* whitelist change can't touch the ban policy. Loopback is always re-seeded.
|
|
||||||
*
|
|
||||||
* @param array<int, string> $ignoreip
|
|
||||||
* @return array{ok: bool, output: string}
|
|
||||||
*/
|
|
||||||
public function writeIgnoreip(Server $server, array $ignoreip): array
|
|
||||||
{
|
|
||||||
$ignoreip = $this->normalizeIgnoreip($ignoreip);
|
|
||||||
|
|
||||||
$content = "# Managed by Clusev — fail2ban whitelist. Do not edit by hand.\n"
|
|
||||||
."[DEFAULT]\n"
|
|
||||||
.'ignoreip = '.implode(' ', $ignoreip)."\n";
|
|
||||||
|
|
||||||
return $this->writeDropin($server, self::DROPIN_IGNOREIP, $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically write one drop-in (base64-safe), optionally removing stale files first,
|
|
||||||
* then reload fail2ban if it is running.
|
|
||||||
*
|
|
||||||
* @param array<int, string> $removeFirst
|
|
||||||
*/
|
|
||||||
private function writeDropin(Server $server, string $path, string $content, array $removeFirst = []): array
|
|
||||||
{
|
|
||||||
$rm = '';
|
|
||||||
foreach ($removeFirst as $stale) {
|
|
||||||
$rm .= 'rm -f '.$stale.'; ';
|
|
||||||
}
|
|
||||||
|
|
||||||
$b64 = base64_encode($content);
|
|
||||||
$cmd = 'mkdir -p /etc/fail2ban/jail.d; '.$rm
|
|
||||||
.'printf %s '.$b64.' | base64 -d > '.$path
|
|
||||||
.' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban || fail2ban-client reload; fi';
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, $cmd, 120);
|
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unban one IP from a jail. "is not banned" counts as success (idempotent). */
|
|
||||||
public function unban(Server $server, string $jail, string $ip): array
|
|
||||||
{
|
|
||||||
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
||||||
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
|
|
||||||
}
|
|
||||||
if ($reason = $this->support($server)) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' unbanip '.$ip);
|
|
||||||
$ok = $res['ok'] || stripos($res['output'], 'not banned') !== false;
|
|
||||||
|
|
||||||
return ['ok' => $ok, 'output' => $this->tail($res['output'])];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Manually ban an IP in a jail. GUARDED against banning Clusev's own connection. */
|
|
||||||
public function ban(Server $server, string $jail, string $ip): array
|
|
||||||
{
|
|
||||||
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
||||||
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
|
|
||||||
}
|
|
||||||
if ($reason = $this->support($server)) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
// LOCKOUT GUARD: never ban loopback (the WHOLE 127.0.0.0/8 range + ::1) or the
|
|
||||||
// address Clusev connects FROM. Compare CANONICALLY (inet_pton) so an alternate
|
|
||||||
// IPv6 spelling cannot slip past.
|
|
||||||
$cp = $this->controlPlaneIp($server);
|
|
||||||
$loopback = $this->sameIp($ip, '::1')
|
|
||||||
|| (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.'));
|
|
||||||
if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) {
|
|
||||||
return ['ok' => false, 'output' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip);
|
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'])];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add an IP/CIDR to the whitelist (ignoreip), preserving the tuning values. */
|
|
||||||
public function addIgnoreIp(Server $server, string $ip): array
|
|
||||||
{
|
|
||||||
$ip = trim($ip);
|
|
||||||
if (! $this->validIpOrCidr($ip)) {
|
|
||||||
return ['ok' => false, 'output' => 'Ungültige IP-Adresse oder CIDR.'];
|
|
||||||
}
|
|
||||||
if ($reason = $this->support($server)) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cfg = $this->readConfig($server);
|
|
||||||
if (in_array($ip, $cfg['ignoreip'], true)) {
|
|
||||||
return ['ok' => true, 'noop' => true, 'output' => 'Bereits auf der Whitelist.'];
|
|
||||||
}
|
|
||||||
$list = $cfg['ignoreip'];
|
|
||||||
$list[] = $ip;
|
|
||||||
|
|
||||||
return $this->writeIgnoreip($server, $list);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove an IP/CIDR from the whitelist (loopback can never be removed). */
|
|
||||||
public function removeIgnoreIp(Server $server, string $ip): array
|
|
||||||
{
|
|
||||||
$ip = trim($ip);
|
|
||||||
if ($reason = $this->support($server)) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
if (in_array($ip, self::LOOPBACK, true)) {
|
|
||||||
return ['ok' => false, 'output' => 'Loopback bleibt immer auf der Whitelist.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cfg = $this->readConfig($server);
|
|
||||||
if (! in_array($ip, $cfg['ignoreip'], true)) {
|
|
||||||
return ['ok' => true, 'noop' => true, 'output' => 'Nicht auf der Whitelist.'];
|
|
||||||
}
|
|
||||||
$list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip));
|
|
||||||
|
|
||||||
return $this->writeIgnoreip($server, $list);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Canonical IP equality (normalizes IPv6 spellings via inet_pton). */
|
|
||||||
private function sameIp(string $a, string $b): bool
|
|
||||||
{
|
|
||||||
$pa = @inet_pton($a);
|
|
||||||
$pb = @inet_pton($b);
|
|
||||||
|
|
||||||
return $pa !== false && $pb !== false && $pa === $pb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clusev's source IP as the server sees it (from $SSH_CONNECTION), or null. */
|
|
||||||
private function controlPlaneIp(Server $server): ?string
|
|
||||||
{
|
|
||||||
$res = $this->fleet->runPlain($server, 'printf %s "$SSH_CONNECTION"');
|
|
||||||
$ip = $res['ok'] ? (preg_split('/\s+/', trim($res['output']))[0] ?? '') : '';
|
|
||||||
|
|
||||||
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{bantime: string, maxretry: int, findtime: string, ignoreip: array<int, string>}
|
|
||||||
*/
|
|
||||||
private function parseDefaults(string $body): array
|
|
||||||
{
|
|
||||||
$vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m', 'ignoreip' => self::LOOPBACK];
|
|
||||||
$inDefault = false;
|
|
||||||
$cont = false; // currently inside an ignoreip continuation
|
|
||||||
$ignoreRaw = null; // raw ignoreip value (across continuation lines), last file wins
|
|
||||||
|
|
||||||
foreach (preg_split('/\R/', $body) ?: [] as $raw) {
|
|
||||||
$t = trim($raw);
|
|
||||||
if ($t === '' || $t[0] === '#' || $t[0] === ';') {
|
|
||||||
$cont = false;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Section header (incl. the [CLUSEV_RESET] between files) resets state.
|
|
||||||
if (preg_match('/^\[([^\]]+)\]/', $t, $m)) {
|
|
||||||
$inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0;
|
|
||||||
$cont = false;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! $inDefault) {
|
|
||||||
$cont = false;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// INI continuation: an indented line right after ignoreip extends its value.
|
|
||||||
if ($cont && preg_match('/^\s/', $raw)) {
|
|
||||||
$ignoreRaw .= ' '.$t;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$cont = false;
|
|
||||||
|
|
||||||
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];
|
|
||||||
} elseif (preg_match('/^ignoreip\s*=\s*(.*)$/i', $t, $m)) {
|
|
||||||
$ignoreRaw = $m[1]; // last assignment (highest-precedence file) wins
|
|
||||||
$cont = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ignoreRaw !== null) {
|
|
||||||
$vals['ignoreip'] = $this->normalizeIgnoreip(preg_split('/\s+/', trim($ignoreRaw)) ?: []);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $vals;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loopback first, then every existing entry VERBATIM (deduped) — hostnames, CIDRs
|
|
||||||
* and other fail2ban-valid tokens are preserved, never dropped (new entries from
|
|
||||||
* the UI are validated separately before they reach this list).
|
|
||||||
*/
|
|
||||||
private function normalizeIgnoreip(array $list): array
|
|
||||||
{
|
|
||||||
$out = self::LOOPBACK;
|
|
||||||
foreach ($list as $entry) {
|
|
||||||
$entry = trim((string) $entry);
|
|
||||||
if ($entry !== '' && ! in_array($entry, $out, true)) {
|
|
||||||
$out[] = $entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, string> jail name => status body */
|
|
||||||
private function jailSections(string $out): array
|
|
||||||
{
|
|
||||||
$sections = [];
|
|
||||||
$current = null;
|
|
||||||
$buffer = [];
|
|
||||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
|
||||||
if (str_starts_with($line, '===CLUSEV:jail:')) {
|
|
||||||
if ($current !== null) {
|
|
||||||
$sections[$current] = implode("\n", $buffer);
|
|
||||||
}
|
|
||||||
$current = rtrim(substr($line, strlen('===CLUSEV:jail:')), '=');
|
|
||||||
$buffer = [];
|
|
||||||
} elseif ($current !== null) {
|
|
||||||
$buffer[] = $line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($current !== null) {
|
|
||||||
$sections[$current] = implode("\n", $buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function intField(string $body, string $label): int
|
|
||||||
{
|
|
||||||
return preg_match('/'.preg_quote($label, '/').':\s*(\d+)/i', $body, $m) ? (int) $m[1] : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, string> */
|
|
||||||
private function bannedIps(string $body): array
|
|
||||||
{
|
|
||||||
if (! preg_match('/Banned IP list:\s*(.*)$/im', $body, $m)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter(
|
|
||||||
preg_split('/\s+/', trim($m[1])) ?: [],
|
|
||||||
fn (string $ip): bool => (bool) filter_var($ip, FILTER_VALIDATE_IP)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validJail(string $jail): bool
|
|
||||||
{
|
|
||||||
return (bool) preg_match('/^[A-Za-z0-9._-]+$/', $jail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validIpOrCidr(string $v): bool
|
|
||||||
{
|
|
||||||
if (filter_var($v, FILTER_VALIDATE_IP)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (! str_contains($v, '/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
[$ip, $mask] = explode('/', $v, 2);
|
|
||||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$max = str_contains($ip, ':') ? 128 : 32;
|
|
||||||
|
|
||||||
return (int) $mask >= 0 && (int) $mask <= $max;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tail(string $out, int $max = 300): string
|
|
||||||
{
|
|
||||||
$out = trim($out);
|
|
||||||
|
|
||||||
return mb_strlen($out) <= $max ? $out : '…'.mb_substr($out, -$max);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,258 +3,49 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Support\Os\FirewallTool;
|
|
||||||
use App\Support\Os\OsDetector;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host firewall control over SSH (as root), abstracted across ufw and firewalld
|
* UFW firewall control over SSH (as root).
|
||||||
* (resolved per OS via OsDetector/FirewallTool).
|
|
||||||
*
|
*
|
||||||
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd Port
|
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd
|
||||||
* (detected from sshd_config, default 22) plus 80/443 BEFORE turning the firewall
|
* Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the
|
||||||
* on — so enabling can never sever the operator's own SSH session.
|
* firewall on — so enabling UFW can never sever the operator's own SSH session.
|
||||||
*
|
*
|
||||||
* Both mutating methods return array{ok: bool, output: string}.
|
* Both mutating methods return array{ok: bool, output: string}.
|
||||||
*/
|
*/
|
||||||
class FirewallService
|
class FirewallService
|
||||||
{
|
{
|
||||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
public function __construct(private FleetService $fleet) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GUARD: allow the real sshd Port + 80/443 first, THEN enable the firewall.
|
* GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never
|
||||||
* Never enable a firewall that would drop the current SSH session. Installs the
|
* enable a firewall that would drop the current SSH session. Installs ufw if missing.
|
||||||
* firewall package first when supported and missing.
|
|
||||||
*
|
*
|
||||||
* @return array{ok: bool, output: string}
|
* @return array{ok: bool, output: string}
|
||||||
*/
|
*/
|
||||||
public function enable(Server $server): array
|
public function enable(Server $server): array
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
// long timeout: may apt-install ufw first.
|
||||||
if ($reason = $os->supports('firewall')) {
|
$res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600);
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
// long timeout: may install the firewall package first.
|
|
||||||
$script = FirewallTool::for($os)->enableScript($this->sshPort($server));
|
|
||||||
$res = $this->fleet->runPrivileged($server, $script, 600);
|
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Turn the firewall off (clean stop, never a panic/drop-all). @return array{ok: bool, output: string} */
|
/** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */
|
||||||
public function disable(Server $server): array
|
public function disable(Server $server): array
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
$res = $this->fleet->runPrivileged($server, 'ufw disable');
|
||||||
$res = $this->fleet->runPrivileged($server, FirewallTool::for($os)->disableScript());
|
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
|
||||||
* Read the full firewall state in one privileged round-trip: installed/active,
|
private function enableScript(int $port): string
|
||||||
* default policies (ufw) or zone+target (firewalld), and the rule list. Returns
|
|
||||||
* `supported=false` + a German reason on an OS without a supported firewall.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function status(Server $server): array
|
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && '
|
||||||
$reason = $os->supports('firewall');
|
."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
|
||||||
$tool = FirewallTool::for($os);
|
|
||||||
|
|
||||||
$base = [
|
|
||||||
'supported' => $reason === null,
|
|
||||||
'reason' => $reason,
|
|
||||||
'tool' => $tool->isFirewalld() ? 'firewalld' : 'ufw',
|
|
||||||
'installed' => false,
|
|
||||||
'active' => false,
|
|
||||||
'defaults' => [],
|
|
||||||
'rules' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($reason !== null) {
|
|
||||||
return $base;
|
|
||||||
}
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, $tool->statusScript());
|
|
||||||
// The sentinel proves the privileged read actually RAN (sudo/SSH ok). A sudo
|
|
||||||
// failure emits error text (non-empty) but no sentinel — so we must not parse
|
|
||||||
// that as an empty firewall (which would make a delete falsely "succeed").
|
|
||||||
if (! str_contains($res['output'], 'CLUSEV_FWREAD_OK')) {
|
|
||||||
return $base + ['readError' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
$s = $this->sections($res['output']);
|
|
||||||
|
|
||||||
// ufw: a successful read ALWAYS prints a "Status:" line. If the tool is installed
|
|
||||||
// but that line is missing, the privileged ufw call itself failed (malformed
|
|
||||||
// config, etc.) — treat as a read error rather than an empty (no-rules) firewall,
|
|
||||||
// so a later delete can't falsely report "already gone".
|
|
||||||
if (! $tool->isFirewalld()
|
|
||||||
&& trim($s['inst'] ?? '') === 'yes'
|
|
||||||
&& ! preg_match('/Status:/i', $s['verbose'] ?? '')) {
|
|
||||||
return $base + ['readError' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tool->isFirewalld() ? $this->parseFirewalld($s, $base) : $this->parseUfw($s, $base);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a firewall rule from validated parts. ufw: action/proto/port/from;
|
|
||||||
* firewalld: opens a port in the default zone.
|
|
||||||
*
|
|
||||||
* @return array{ok: bool, output: string}
|
|
||||||
*/
|
|
||||||
public function addRule(Server $server, string $action, string $proto, ?int $port, ?string $from): array
|
|
||||||
{
|
|
||||||
$os = $this->detector->detect($server);
|
|
||||||
if ($reason = $os->supports('firewall')) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule mutation is ufw-only this release; firewalld is read-only.
|
|
||||||
if ($os->firewallTool === 'firewalld') {
|
|
||||||
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow';
|
|
||||||
$proto = in_array($proto, ['tcp', 'udp', 'any'], true) ? $proto : 'tcp';
|
|
||||||
$from = ($from !== null && trim($from) !== '') ? trim($from) : null;
|
|
||||||
|
|
||||||
if ($port !== null && ($port < 1 || $port > 65535)) {
|
|
||||||
return ['ok' => false, 'output' => 'Ungültiger Port (1–65535).'];
|
|
||||||
}
|
|
||||||
if ($from !== null && ! $this->validIpOrCidr($from)) {
|
|
||||||
return ['ok' => false, 'output' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.'];
|
|
||||||
}
|
|
||||||
if ($port === null && $from === null) {
|
|
||||||
return ['ok' => false, 'output' => 'Port oder Quelle ist erforderlich.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever
|
|
||||||
// every future management session. Over TCP/any, refuse it when it targets the
|
|
||||||
// SSH port OR has NO port (a portless deny blocks ALL inbound, incl. SSH) —
|
|
||||||
// regardless of source, since a source may include Clusev's own address. Use
|
|
||||||
// fail2ban to block individual IPs from SSH instead.
|
|
||||||
$sshPort = $this->sshPort($server);
|
|
||||||
if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true)
|
|
||||||
&& ($port === null || $port === $sshPort)) {
|
|
||||||
return ['ok' => false, 'output' => ($port === null
|
|
||||||
? 'Eine '.$action.'-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. '
|
|
||||||
: 'Eine '.$action.'-Regel auf den SSH-Port ('.$sshPort.') ist nicht erlaubt (Aussperrgefahr). ')
|
|
||||||
.'Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tool = FirewallTool::for($os);
|
|
||||||
if ($tool->isFirewalld() && $port === null) {
|
|
||||||
return ['ok' => false, 'output' => 'firewalld: Bitte einen Port angeben.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60);
|
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a rule. ufw: $spec is the rule's raw line content — we re-read NOW and
|
|
||||||
* delete by the CURRENT number that matches, side-stepping the renumber race.
|
|
||||||
* firewalld: $spec is "port:80/tcp" or "service:ssh".
|
|
||||||
*
|
|
||||||
* @return array{ok: bool, output: string}
|
|
||||||
*/
|
|
||||||
public function deleteRule(Server $server, string $spec): array
|
|
||||||
{
|
|
||||||
$os = $this->detector->detect($server);
|
|
||||||
if ($reason = $os->supports('firewall')) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule mutation is ufw-only this release; firewalld is read-only.
|
|
||||||
if ($os->firewallTool === 'firewalld') {
|
|
||||||
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tool = FirewallTool::for($os);
|
|
||||||
$sshPort = $this->sshPort($server);
|
|
||||||
|
|
||||||
// ufw: $spec is a public-method argument, so it is UNTRUSTED. Whitelist it
|
|
||||||
// against the specs ufw itself reported (which contain no shell metacharacters)
|
|
||||||
// — a forged spec like "allow 80/tcp; rm -rf /" can never match, so the raw
|
|
||||||
// concatenation into `ufw delete <spec>` can never inject. Delete-by-spec is
|
|
||||||
// also race-free (no rule number to shift).
|
|
||||||
$status = $this->status($server);
|
|
||||||
if (! empty($status['readError'])) {
|
|
||||||
return ['ok' => false, 'output' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.'];
|
|
||||||
}
|
|
||||||
$known = array_column($status['rules'], 'spec');
|
|
||||||
if (! in_array($spec, $known, true)) {
|
|
||||||
// The rule vanished between confirm and apply — report not_found so the
|
|
||||||
// caller does NOT audit a deletion that never happened.
|
|
||||||
return ['ok' => true, 'notFound' => true, 'output' => 'Regel existiert nicht mehr.'];
|
|
||||||
}
|
|
||||||
// LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open.
|
|
||||||
if ($this->ufwSpecOpensSsh($spec, $sshPort)) {
|
|
||||||
return ['ok' => false, 'output' => $this->sshGuardMessage($sshPort)];
|
|
||||||
}
|
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, $tool->deleteUfwScript($spec));
|
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when a ufw allow/limit spec keeps the SSH port (or an SSH app profile)
|
|
||||||
* open. The port is matched ONLY in the destination position — the simple form
|
|
||||||
* ("allow 22/tcp") or after "to any port" — never inside a source IP like
|
|
||||||
* 192.0.2.22, which must stay deletable.
|
|
||||||
*/
|
|
||||||
private function ufwSpecOpensSsh(string $spec, int $sshPort): bool
|
|
||||||
{
|
|
||||||
$spec = trim($spec);
|
|
||||||
if (! preg_match('/^(allow|limit)\b/i', $spec)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (stripos($spec, 'OpenSSH') !== false || preg_match('/(^|\s)SSH(\s|$)/i', $spec)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// SSH is TCP — ignore explicitly UDP-only rules (simple "<port>/udp" or "proto udp").
|
|
||||||
if (preg_match('/^(allow|limit)\s+\d+(?::\d+)?\/udp\b/i', $spec) || preg_match('/proto\s+udp/i', $spec)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// simple form: "allow 22", "allow 22/tcp", or a range "allow 20:30/tcp".
|
|
||||||
if (preg_match('/^(allow|limit)\s+(\d+)(?::(\d+))?(\/tcp)?(\s|$)/i', $spec, $m)
|
|
||||||
&& $this->portInRange($sshPort, (int) $m[2], ($m[3] ?? '') !== '' ? (int) $m[3] : null)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// destination-port form: "... port 22 ..." or a range "... port 20:30 ...".
|
|
||||||
if (preg_match('/\bport\s+(\d+)(?::(\d+))?/i', $spec, $m)
|
|
||||||
&& $this->portInRange($sshPort, (int) $m[1], ($m[2] ?? '') !== '' ? (int) $m[2] : null)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when $needle equals the single port, or falls inside the [low, high] range. */
|
|
||||||
private function portInRange(int $needle, int $low, ?int $high): bool
|
|
||||||
{
|
|
||||||
return $high === null ? $needle === $low : ($needle >= $low && $needle <= $high);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** German refusal shown when a delete would sever Clusev's own SSH access. */
|
|
||||||
private function sshGuardMessage(int $port): string
|
|
||||||
{
|
|
||||||
return "Diese Regel hält den SSH-Zugang (Port {$port}) offen und kann nicht entfernt werden — "
|
|
||||||
.'sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT
|
|
||||||
// exposed — it is the highest-lockout-risk firewall operation (a restrictive
|
|
||||||
// default with a missing/source-mismatched SSH rule severs Clusev). Defaults are
|
|
||||||
// shown READ-ONLY in the panel; activating the firewall via the hardening toggle
|
|
||||||
// applies safe defaults while guaranteeing ssh/80/443 stay open.
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
|
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
|
||||||
* back to 22 when nothing is set explicitly.
|
* back to 22 when nothing is set explicitly.
|
||||||
|
|
@ -271,164 +62,4 @@ class FirewallService
|
||||||
|
|
||||||
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
|
return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $s
|
|
||||||
* @param array<string, mixed> $base
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function parseUfw(array $s, array $base): array
|
|
||||||
{
|
|
||||||
// Defaults from /etc/default/ufw — correct whether ufw is active or inactive
|
|
||||||
// (verbose only prints the Default: line while active).
|
|
||||||
$map = ['DROP' => 'deny', 'REJECT' => 'reject', 'ACCEPT' => 'allow'];
|
|
||||||
$defaults = ['incoming' => null, 'outgoing' => null, 'routed' => null];
|
|
||||||
if (preg_match('/DEFAULT_INPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mi)) {
|
|
||||||
$defaults['incoming'] = $map[strtoupper($mi[1])] ?? strtolower($mi[1]);
|
|
||||||
}
|
|
||||||
if (preg_match('/DEFAULT_OUTPUT_POLICY="?(\w+)"?/', $s['defaults'] ?? '', $mo)) {
|
|
||||||
$defaults['outgoing'] = $map[strtoupper($mo[1])] ?? strtolower($mo[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rules from `ufw show added` (add-syntax). The spec IS the delete argument.
|
|
||||||
$rules = [];
|
|
||||||
foreach (preg_split('/\R/', $s['added'] ?? '') ?: [] as $line) {
|
|
||||||
if (! preg_match('/^ufw\s+(.+\S)\s*$/', trim($line), $m)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$spec = trim(preg_replace('/\s+/', ' ', $m[1]) ?? '');
|
|
||||||
// Routed/forwarding rules look like "route allow in on eth0 ..." — the action
|
|
||||||
// is the SECOND token; keep the whole spec (delete needs "route allow …").
|
|
||||||
$first = (string) strtok($spec, ' ');
|
|
||||||
$routed = strcasecmp($first, 'route') === 0;
|
|
||||||
$afterFirst = trim((string) substr($spec, strlen($first)));
|
|
||||||
$action = strtoupper($routed ? (string) strtok($afterFirst, ' ') : $first);
|
|
||||||
if (! in_array($action, ['ALLOW', 'DENY', 'REJECT', 'LIMIT'], true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$label = $routed ? $spec : (trim((string) substr($spec, strlen($first))) ?: $spec);
|
|
||||||
$rules[] = ['raw' => $spec, 'label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $spec];
|
|
||||||
}
|
|
||||||
|
|
||||||
$installed = trim($s['inst'] ?? '') === 'yes';
|
|
||||||
|
|
||||||
return array_merge($base, [
|
|
||||||
'installed' => $installed,
|
|
||||||
'active' => (bool) preg_match('/Status:\s*active/i', $s['verbose'] ?? ''),
|
|
||||||
// ufw stores rules in files, so they are manageable even while inactive.
|
|
||||||
'manageable' => $installed,
|
|
||||||
'readOnly' => false,
|
|
||||||
'defaults' => $defaults,
|
|
||||||
'rules' => $rules,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $s
|
|
||||||
* @param array<string, mixed> $base
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function parseFirewalld(array $s, array $base): array
|
|
||||||
{
|
|
||||||
// Each line is "zone|value"; the zone is shown when it is not the default zone,
|
|
||||||
// so a port enforced in a non-default zone is attributed correctly and identical
|
|
||||||
// rules in different zones stay distinct. firewalld is read-only, so `spec` is
|
|
||||||
// cosmetic here (never used for deletion).
|
|
||||||
$default = trim($s['zone'] ?? '');
|
|
||||||
$rules = [];
|
|
||||||
$seen = [];
|
|
||||||
|
|
||||||
$add = function (string $line, string $kind) use (&$rules, &$seen, $default): void {
|
|
||||||
$line = trim($line);
|
|
||||||
if ($line === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
[$zone, $value] = array_pad(explode('|', $line, 2), 2, '');
|
|
||||||
if ($value === '') {
|
|
||||||
[$zone, $value] = [$default, $zone];
|
|
||||||
}
|
|
||||||
$key = $kind.'|'.$zone.'|'.$value;
|
|
||||||
if (isset($seen[$key])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$seen[$key] = true;
|
|
||||||
|
|
||||||
$suffix = ($zone !== '' && $zone !== $default) ? ' · '.$zone : '';
|
|
||||||
$action = 'ALLOW';
|
|
||||||
$label = $value.$suffix;
|
|
||||||
if ($kind === 'service') {
|
|
||||||
$label = $value.' (Dienst)'.$suffix;
|
|
||||||
} elseif ($kind === 'rich') {
|
|
||||||
$action = stripos($value, 'accept') !== false ? 'ALLOW'
|
|
||||||
: (stripos($value, 'reject') !== false ? 'REJECT' : (stripos($value, 'drop') !== false ? 'DENY' : 'ALLOW'));
|
|
||||||
}
|
|
||||||
$rules[] = ['label' => $label, 'action' => $action, 'from' => '', 'v6' => false, 'spec' => $kind.':'.$value];
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (preg_split('/\R/', $s['ports'] ?? '') ?: [] as $l) {
|
|
||||||
$add($l, 'port');
|
|
||||||
}
|
|
||||||
foreach (preg_split('/\R/', $s['services'] ?? '') ?: [] as $l) {
|
|
||||||
$add($l, 'service');
|
|
||||||
}
|
|
||||||
foreach (preg_split('/\R/', $s['rich'] ?? '') ?: [] as $l) {
|
|
||||||
$add($l, 'rich');
|
|
||||||
}
|
|
||||||
|
|
||||||
$installed = trim($s['inst'] ?? '') === 'yes';
|
|
||||||
$active = trim($s['active'] ?? '') === 'active';
|
|
||||||
|
|
||||||
return array_merge($base, [
|
|
||||||
'installed' => $installed,
|
|
||||||
'active' => $active,
|
|
||||||
// firewalld is READ-ONLY this release: rules are displayed (runtime state)
|
|
||||||
// but not mutated from Clusev, so it is never "manageable" for add/delete.
|
|
||||||
'manageable' => false,
|
|
||||||
'readOnly' => true,
|
|
||||||
'defaults' => ['zone' => trim($s['zone'] ?? '')],
|
|
||||||
'rules' => $rules,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a marker-sectioned (===CLUSEV:name===) output into [name => body]. */
|
|
||||||
private function sections(string $out): array
|
|
||||||
{
|
|
||||||
$sections = [];
|
|
||||||
$current = null;
|
|
||||||
$buffer = [];
|
|
||||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
|
||||||
if (str_starts_with($line, '===CLUSEV:')) {
|
|
||||||
if ($current !== null) {
|
|
||||||
$sections[$current] = implode("\n", $buffer);
|
|
||||||
}
|
|
||||||
$current = rtrim(substr($line, strlen('===CLUSEV:')), '=');
|
|
||||||
$buffer = [];
|
|
||||||
} elseif ($current !== null) {
|
|
||||||
$buffer[] = $line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($current !== null) {
|
|
||||||
$sections[$current] = implode("\n", $buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Accept a bare IP (v4/v6) or an IP/CIDR with a valid prefix length. */
|
|
||||||
private function validIpOrCidr(string $v): bool
|
|
||||||
{
|
|
||||||
if (filter_var($v, FILTER_VALIDATE_IP)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (! str_contains($v, '/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
[$ip, $mask] = explode('/', $v, 2);
|
|
||||||
if (! filter_var($ip, FILTER_VALIDATE_IP) || ! ctype_digit($mask)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$max = str_contains($ip, ':') ? 128 : 32;
|
|
||||||
|
|
||||||
return (int) $mask >= 0 && (int) $mask <= $max;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,17 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Support\Os\FirewallTool;
|
|
||||||
use App\Support\Os\OsDetector;
|
|
||||||
use App\Support\Os\OsProfile;
|
|
||||||
use App\Support\Os\PackageManager;
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the dashboard server-hardening toggles over SSH (as root), OS-aware
|
* Applies the dashboard server-hardening toggles over SSH (as root). Every item is
|
||||||
* across apt/dnf/zypper + ufw/firewalld (resolved per host via OsDetector). Every
|
* BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying feature
|
||||||
* item is BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying
|
* on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand.
|
||||||
* feature; package items (fail2ban, auto-updates, firewall) install on demand.
|
|
||||||
*
|
*
|
||||||
* apply() runs the exact command built by commandFor() (the single source of truth);
|
* apply() runs the exact command built by commandFor() (the single source of truth);
|
||||||
* title()/description() supply the German confirm-modal copy for each direction.
|
* title()/description() supply the German confirm-modal copy for each direction.
|
||||||
* Each state() row carries `supported`/`reason`, so a feature unavailable on the
|
|
||||||
* host's OS degrades with a German explanation instead of failing opaquely.
|
|
||||||
* Disabling SSH password auth is GUARDED so the operator can never lock themselves out.
|
* Disabling SSH password auth is GUARDED so the operator can never lock themselves out.
|
||||||
*/
|
*/
|
||||||
class HardeningService
|
class HardeningService
|
||||||
|
|
@ -31,56 +24,43 @@ class HardeningService
|
||||||
// 99zz- so Clusev's apt periodic setting wins (apt uses the LAST value across apt.conf.d/*).
|
// 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';
|
private const APT_DROPIN = '/etc/apt/apt.conf.d/99zz-clusev';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(private FleetService $fleet, private FirewallService $firewall) {}
|
||||||
private FleetService $fleet,
|
|
||||||
private FirewallService $firewall,
|
|
||||||
private OsDetector $detector,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live state of every hardening item, read PRIVILEGED in one shot. SSH uses
|
* Live state of every hardening item, read PRIVILEGED in one shot. SSH uses
|
||||||
* `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks); the
|
* `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks);
|
||||||
* package/firewall/auto-update probes are built per-OS (dpkg vs rpm, ufw vs
|
* UFW uses `ufw status`; unattended uses the apt periodic config.
|
||||||
* firewalld, apt periodic vs dnf-automatic).
|
|
||||||
*
|
*
|
||||||
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string}>
|
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}>
|
||||||
*/
|
*/
|
||||||
public function state(Server $server): array
|
public function state(Server $server): array
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
|
||||||
$pm = $os->managesPackages() ? PackageManager::for($os) : null;
|
|
||||||
$fw = FirewallTool::for($os);
|
|
||||||
|
|
||||||
// Shell CONDITIONS (exit 0 = true); `false` where the host lacks the tool.
|
|
||||||
$f2bInst = $pm ? $pm->isInstalledTest('fail2ban') : 'false';
|
|
||||||
$auInst = $pm ? $pm->isInstalledTest($os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades') : 'false';
|
|
||||||
$auActive = match ($os->packageManager) {
|
|
||||||
'apt' => 'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\'',
|
|
||||||
'dnf' => 'systemctl is-enabled --quiet dnf-automatic.timer',
|
|
||||||
default => 'false',
|
|
||||||
};
|
|
||||||
|
|
||||||
$cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; '
|
$cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; '
|
||||||
.'echo "fail2ban $('.$f2bInst.' && echo 1 || echo 0) $(systemctl is-active --quiet fail2ban && echo active || echo inactive)"; '
|
.'echo "fail2ban $(dpkg -l fail2ban 2>/dev/null | grep -c "^ii") $(systemctl is-active fail2ban 2>/dev/null)"; '
|
||||||
.'echo "firewall $('.$fw->installedTest().' && echo 1 || echo 0) $('.$fw->activeTest().' && echo active || echo inactive)"; '
|
.'ufw_inst=$(dpkg -l ufw 2>/dev/null | grep -c "^ii"); ufw_act=inactive; '
|
||||||
.'echo "autoupdate $('.$auInst.' && echo 1 || echo 0) $('.$auActive.' && echo active || echo 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);
|
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||||
if (! $res['ok']) {
|
if (! $res['ok']) {
|
||||||
throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
|
throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->parseState($res['output'], $os);
|
return $this->parseState($res['output']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string}> */
|
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}> */
|
||||||
private function parseState(string $out, OsProfile $os): array
|
private function parseState(string $out): array
|
||||||
{
|
{
|
||||||
$root = 'default';
|
$root = 'default';
|
||||||
$pwauth = 'default';
|
$pwauth = 'default';
|
||||||
$pkg = [];
|
$pkg = [];
|
||||||
|
|
||||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
foreach (preg_split('/\R/', $out) as $line) {
|
||||||
$line = trim($line);
|
$line = trim($line);
|
||||||
if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) {
|
if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) {
|
||||||
$root = strtolower($m[1]);
|
$root = strtolower($m[1]);
|
||||||
|
|
@ -88,38 +68,34 @@ class HardeningService
|
||||||
$pwauth = strtolower($m[1]);
|
$pwauth = strtolower($m[1]);
|
||||||
} else {
|
} else {
|
||||||
$p = preg_split('/\s+/', $line);
|
$p = preg_split('/\s+/', $line);
|
||||||
if (count($p) === 3 && in_array($p[0], ['fail2ban', 'firewall', 'autoupdate'], true)) {
|
if (count($p) === 3 && in_array($p[0], ['fail2ban', 'ufw', 'unattended-upgrades'], true)) {
|
||||||
$pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active'];
|
$pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false];
|
$f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false];
|
||||||
$fw = $pkg['firewall'] ?? ['installed' => false, 'active' => false];
|
$ufw = $pkg['ufw'] ?? ['installed' => false, 'active' => false];
|
||||||
$upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false];
|
$upg = $pkg['unattended-upgrades'] ?? ['installed' => false, 'active' => false];
|
||||||
|
|
||||||
$detail = fn (string $name, array $st): string => $name.' · '
|
$detail = fn (string $name, array $st): string => $name.' · '
|
||||||
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
|
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
|
||||||
// A package feature is only effectively ON when installed AND active —
|
// 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.
|
// stale config (e.g. apt periodic = 1 after removal) must not read as secure.
|
||||||
$on = fn (array $st): bool => $st['installed'] && $st['active'];
|
$on = fn (array $st): bool => $st['installed'] && $st['active'];
|
||||||
|
|
||||||
$row = fn (string $key, string $label, string $detail, bool $featureOn, bool $secure, ?string $reason): array => [
|
|
||||||
'key' => $key, 'label' => $label, 'detail' => $detail,
|
|
||||||
'featureOn' => $featureOn, 'secure' => $secure,
|
|
||||||
'supported' => $reason === null, 'reason' => $reason,
|
|
||||||
];
|
|
||||||
|
|
||||||
$fwName = $os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw';
|
|
||||||
$auName = $os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades';
|
|
||||||
|
|
||||||
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
|
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
|
||||||
return [
|
return [
|
||||||
$row('ssh_root', 'SSH-Root-Login', 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
|
['key' => 'ssh_root', 'label' => 'SSH-Root-Login', 'detail' => 'PermitRootLogin '.$root,
|
||||||
$row('ssh_password', 'SSH-Passwort-Login', 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
|
'featureOn' => $root !== 'no', 'secure' => $root === 'no'],
|
||||||
$row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')),
|
['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication '.$pwauth,
|
||||||
$row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')),
|
'featureOn' => $pwauth !== 'no', 'secure' => $pwauth === 'no'],
|
||||||
$row('unattended', 'Automatische Updates', $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')),
|
['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)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +106,7 @@ class HardeningService
|
||||||
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
|
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
|
||||||
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
|
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
|
||||||
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
|
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
|
||||||
'firewall' => $enable ? 'Firewall aktivieren' : 'Firewall deaktivieren',
|
'firewall' => $enable ? 'Firewall (UFW) aktivieren' : 'Firewall (UFW) deaktivieren',
|
||||||
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
|
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
|
||||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||||
};
|
};
|
||||||
|
|
@ -149,11 +125,11 @@ class HardeningService
|
||||||
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
|
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
|
||||||
: 'Stoppt fail2ban und deaktiviert den Autostart.',
|
: 'Stoppt fail2ban und deaktiviert den Autostart.',
|
||||||
'firewall' => $enable
|
'firewall' => $enable
|
||||||
? 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.'
|
? 'Installiert UFW (falls nötig), gibt SSH + 80/443 frei und aktiviert die Firewall.'
|
||||||
: 'Deaktiviert die Firewall des Servers.',
|
: 'Deaktiviert die UFW-Firewall (ufw disable).',
|
||||||
'unattended' => $enable
|
'unattended' => $enable
|
||||||
? 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.'
|
? 'Installiert unattended-upgrades (falls nötig) und aktiviert die automatische Update-Periodik + Timer.'
|
||||||
: 'Schaltet die automatischen Updates ab.',
|
: 'Schaltet die automatische Update-Periodik ab.',
|
||||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -161,20 +137,6 @@ class HardeningService
|
||||||
/** @return array{ok: bool, output: string} */
|
/** @return array{ok: bool, output: string} */
|
||||||
public function apply(Server $server, string $action, bool $enable): array
|
public function apply(Server $server, string $action, bool $enable): array
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
|
||||||
|
|
||||||
// Gracefully refuse a feature the host's OS does not support.
|
|
||||||
$feature = match ($action) {
|
|
||||||
'ssh_root', 'ssh_password' => 'ssh',
|
|
||||||
'fail2ban' => 'fail2ban',
|
|
||||||
'firewall' => 'firewall',
|
|
||||||
'unattended' => 'auto_updates',
|
|
||||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
|
||||||
};
|
|
||||||
if ($reason = $os->supports($feature)) {
|
|
||||||
return ['ok' => false, 'output' => $reason];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($action === 'firewall') {
|
if ($action === 'firewall') {
|
||||||
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
||||||
|
|
||||||
|
|
@ -191,49 +153,32 @@ class HardeningService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// package operations need a long timeout; the 12s default would abort the install.
|
// apt operations need a long timeout; the 12s default would abort the install.
|
||||||
$timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60;
|
$timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60;
|
||||||
$res = $this->fleet->runPrivileged($server, $this->commandFor($os, $action, $enable), $timeout);
|
$res = $this->fleet->runPrivileged($server, $this->commandFor($action, $enable), $timeout);
|
||||||
|
|
||||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
||||||
private function commandFor(OsProfile $os, string $action, bool $enable): string
|
private function commandFor(string $action, bool $enable): string
|
||||||
{
|
{
|
||||||
$pm = PackageManager::for($os);
|
|
||||||
|
|
||||||
return match ($action) {
|
return match ($action) {
|
||||||
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
|
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
|
||||||
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
|
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
|
||||||
'fail2ban' => $enable
|
'fail2ban' => $enable
|
||||||
? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban'
|
? '(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',
|
: 'systemctl disable --now fail2ban',
|
||||||
'unattended' => $this->autoUpdateScript($os, $pm, $enable),
|
'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}"),
|
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-update mechanism per family: apt periodic + timers, or dnf-automatic.timer. */
|
|
||||||
private function autoUpdateScript(OsProfile $os, PackageManager $pm, bool $enable): string
|
|
||||||
{
|
|
||||||
if ($os->packageManager === 'dnf') {
|
|
||||||
return $enable
|
|
||||||
? $pm->ensureInstalledScript('dnf-automatic')
|
|
||||||
.' && sed -i \'s/^apply_updates.*/apply_updates = yes/\' /etc/dnf/automatic.conf'
|
|
||||||
.' && systemctl enable --now dnf-automatic.timer'
|
|
||||||
: 'systemctl disable --now dnf-automatic.timer';
|
|
||||||
}
|
|
||||||
|
|
||||||
// apt (default): the periodic drop-in + service/timers.
|
|
||||||
return $enable
|
|
||||||
? $pm->ensureInstalledScript('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)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if the SSH user has at least one authorized key. */
|
/** True if the SSH user has at least one authorized key. */
|
||||||
private function hasAuthorizedKey(Server $server): bool
|
private function hasAuthorizedKey(Server $server): bool
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,65 +3,168 @@
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Support\Os\OsDetector;
|
use RuntimeException;
|
||||||
use App\Support\Os\PackageManager;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Package-maintenance helpers across apt/dnf/zypper (resolved per OS via
|
* Debian/Ubuntu (apt) maintenance helpers: counts pending package updates,
|
||||||
* OsDetector): counts pending package updates and applies an upgrade.
|
* applies an apt upgrade, and reads/writes the fail2ban [DEFAULT] tuning.
|
||||||
*
|
*
|
||||||
* Every reader/writer goes through FleetService, so quoting is base64-safe and
|
* Every reader/writer goes through FleetService, so quoting is base64-safe and
|
||||||
* privileged work runs as root. (fail2ban tuning + management lives in Fail2banService.)
|
* privileged work runs as root. Callers must clamp the fail2ban integers before
|
||||||
|
* calling writeFail2ban(); this service only persists already-validated values.
|
||||||
*/
|
*/
|
||||||
class MaintenanceService
|
class MaintenanceService
|
||||||
{
|
{
|
||||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
// 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) {}
|
||||||
* NULL when Clusev can manage package updates on this host, otherwise a German
|
|
||||||
* reason (e.g. unsupported package manager) for the operator.
|
/** True if apt-get exists on the target (only then are the update features meaningful). */
|
||||||
*/
|
public function hasApt(Server $server): bool
|
||||||
public function updateSupport(Server $server): ?string
|
|
||||||
{
|
{
|
||||||
return $this->detector->detect($server)->supports('updates');
|
$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/metadata read (no root
|
* Number of pending package upgrades via a dry-run simulation (no root needed),
|
||||||
* needed), or NULL when it could not run (unsupported manager / errored) — so
|
* or NULL when the simulation could not run (apt absent / errored) — so the caller
|
||||||
* the caller shows "unbekannt" rather than a misleading "0 / up to date". The
|
* can show "unbekannt" rather than a misleading "0 / up to date".
|
||||||
* remote script emits a CLUSEV_OK sentinel only when the query actually ran.
|
|
||||||
*/
|
*/
|
||||||
public function pendingUpdates(Server $server): ?int
|
public function pendingUpdates(Server $server): ?int
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
$res = $this->fleet->runPlain($server, 'apt-get -s upgrade 2>/dev/null');
|
||||||
if (! $os->managesPackages()) {
|
if (! $res['ok']) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$res = $this->fleet->runPlain($server, PackageManager::for($os)->pendingScript());
|
return (int) preg_match_all('/^Inst /m', $res['output']);
|
||||||
if (! str_contains($res['output'], 'CLUSEV_OK')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the index and apply all upgrades as root, using the host's package
|
* apt-get update && upgrade as root. Long-running, so a 900s timeout. The
|
||||||
* manager. Long-running, so a 900s timeout. Output trimmed to the last ~400
|
* returned output is trimmed to the last ~400 chars for a compact result panel.
|
||||||
* chars for a compact result panel.
|
|
||||||
*
|
*
|
||||||
* @return array{ok: bool, output: string}
|
* @return array{ok: bool, output: string}
|
||||||
*/
|
*/
|
||||||
public function applyUpgrades(Server $server): array
|
public function aptUpgrade(Server $server): array
|
||||||
{
|
{
|
||||||
$os = $this->detector->detect($server);
|
$res = $this->fleet->runPrivileged(
|
||||||
if ($reason = $os->supports('updates')) {
|
$server,
|
||||||
return ['ok' => false, 'output' => $reason];
|
'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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, PackageManager::for($os)->applyScript(), 900);
|
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)];
|
return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Os;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shell command strings for the host firewall (ufw or firewalld), built per OS.
|
|
||||||
*
|
|
||||||
* Safety model is preserved across both tools: enableScript() ALWAYS opens the
|
|
||||||
* real sshd port + 80/443 BEFORE turning the firewall on, so Clusev can never sever
|
|
||||||
* its own SSH session. Disable is the clean stop — never `firewall-cmd --panic-on`
|
|
||||||
* (which would drop ALL traffic, the exact lockout the guard avoids).
|
|
||||||
*/
|
|
||||||
final class FirewallTool
|
|
||||||
{
|
|
||||||
private const MARK = '===CLUSEV:';
|
|
||||||
|
|
||||||
/** @param 'ufw'|'firewalld' $tool */
|
|
||||||
public function __construct(
|
|
||||||
private string $tool,
|
|
||||||
private PackageManager $pm,
|
|
||||||
private bool $canInstall,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function for(OsProfile $os): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
$os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw',
|
|
||||||
$os->managesPackages() ? PackageManager::for($os) : new PackageManager('none'),
|
|
||||||
$os->managesPackages(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isFirewalld(): bool
|
|
||||||
{
|
|
||||||
return $this->tool === 'firewalld';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function packageName(): string
|
|
||||||
{
|
|
||||||
return $this->tool === 'firewalld' ? 'firewalld' : 'ufw';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Guarded enable: install if needed + missing, open ssh/80/443, then turn on. */
|
|
||||||
public function enableScript(int $sshPort): string
|
|
||||||
{
|
|
||||||
$ensure = $this->canInstall ? $this->pm->ensureInstalledScript($this->packageName()).' && ' : '';
|
|
||||||
|
|
||||||
if ($this->isFirewalld()) {
|
|
||||||
$ports = "--add-port={$sshPort}/tcp --add-port=80/tcp --add-port=443/tcp";
|
|
||||||
|
|
||||||
// GUARD: open ssh/80/443 in the PERMANENT config BEFORE firewalld starts
|
|
||||||
// filtering, so starting it can never drop the operator's SSH session (a
|
|
||||||
// custom SSH port is not in the default zone). Handle both states: if the
|
|
||||||
// daemon is already running, add to permanent + reload; if it is stopped,
|
|
||||||
// write the permanent rules offline first, THEN start it.
|
|
||||||
return $ensure
|
|
||||||
.'if systemctl is-active --quiet firewalld; then '
|
|
||||||
."firewall-cmd --permanent {$ports} && systemctl enable --now firewalld && firewall-cmd --reload; "
|
|
||||||
.'else '
|
|
||||||
."firewall-offline-cmd {$ports} && systemctl enable --now firewalld; "
|
|
||||||
.'fi';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ensure
|
|
||||||
."ufw allow {$sshPort}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clean disable (never a panic/drop-all). */
|
|
||||||
public function disableScript(): string
|
|
||||||
{
|
|
||||||
return $this->isFirewalld() ? 'systemctl disable --now firewalld' : 'ufw disable';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shell CONDITION exiting 0 when the firewall binary is present. */
|
|
||||||
public function installedTest(): string
|
|
||||||
{
|
|
||||||
return $this->isFirewalld()
|
|
||||||
? 'command -v firewall-cmd >/dev/null 2>&1'
|
|
||||||
: 'command -v ufw >/dev/null 2>&1';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shell CONDITION exiting 0 when the firewall is currently active. */
|
|
||||||
public function activeTest(): string
|
|
||||||
{
|
|
||||||
return $this->isFirewalld()
|
|
||||||
? 'systemctl is-active --quiet firewalld'
|
|
||||||
: 'ufw status 2>/dev/null | grep -qi "Status: active"';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One privileged read of the firewall state, marker-sectioned for parsing. */
|
|
||||||
public function statusScript(): string
|
|
||||||
{
|
|
||||||
if ($this->isFirewalld()) {
|
|
||||||
// firewalld is READ-ONLY in this release (rule mutation is ufw-only), so we
|
|
||||||
// read the RUNTIME state of the active default zone — exactly what is being
|
|
||||||
// enforced right now — which also sidesteps any runtime/permanent zone drift.
|
|
||||||
// Enumerate EVERY active zone (an interface/source may be bound to a
|
|
||||||
// non-default zone), so the read-only view reflects all enforced rules.
|
|
||||||
$zones = 'firewall-cmd --get-active-zones 2>/dev/null | grep -v "^[[:space:]]"';
|
|
||||||
|
|
||||||
return 'echo CLUSEV_FWREAD_OK; '
|
|
||||||
.'echo '.self::MARK.'inst===; command -v firewall-cmd >/dev/null 2>&1 && echo yes || echo no; '
|
|
||||||
.'echo '.self::MARK.'active===; systemctl is-active --quiet firewalld && echo active || echo inactive; '
|
|
||||||
.'echo '.self::MARK.'zone===; firewall-cmd --get-default-zone 2>/dev/null; '
|
|
||||||
// emit "zone|value" per line so each rule keeps its zone attribution.
|
|
||||||
.'echo '.self::MARK.'ports===; for z in $('.$zones.'); do for p in $(firewall-cmd --zone="$z" --list-ports 2>/dev/null); do echo "$z|$p"; done; done; '
|
|
||||||
.'echo '.self::MARK.'services===; for z in $('.$zones.'); do for s in $(firewall-cmd --zone="$z" --list-services 2>/dev/null); do echo "$z|$s"; done; done; '
|
|
||||||
.'echo '.self::MARK.'rich===; for z in $('.$zones.'); do firewall-cmd --zone="$z" --list-rich-rules 2>/dev/null | while IFS= read -r r; do [ -n "$r" ] && echo "$z|$r"; done; done';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'echo CLUSEV_FWREAD_OK; '
|
|
||||||
.'echo '.self::MARK.'inst===; command -v ufw >/dev/null 2>&1 && echo yes || echo no; '
|
|
||||||
.'echo '.self::MARK.'verbose===; ufw status verbose 2>/dev/null; '
|
|
||||||
// `ufw show added` lists user rules in add-syntax and works whether ufw is
|
|
||||||
// active OR inactive (status only shows rules while active); the spec
|
|
||||||
// doubles as the delete argument, so deletes need no rule NUMBER (race-free).
|
|
||||||
.'echo '.self::MARK.'added===; ufw show added 2>/dev/null; '
|
|
||||||
// defaults from the config file so they are correct even when inactive.
|
|
||||||
.'echo '.self::MARK.'defaults===; grep -E "^DEFAULT_(INPUT|OUTPUT)_POLICY" /etc/default/ufw 2>/dev/null';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the ufw add command from already-validated parts (action/proto/port/from).
|
|
||||||
* Rule mutation is ufw-only in this release; firewalld is read-only.
|
|
||||||
*/
|
|
||||||
public function addRuleScript(string $action, string $proto, ?int $port, ?string $from): string
|
|
||||||
{
|
|
||||||
$spec = $action;
|
|
||||||
if ($from !== null && $from !== '') {
|
|
||||||
// ufw extended syntax wants `proto <p>` BEFORE the `from` clause; the
|
|
||||||
// trailing-proto form is rejected. proto is kept even without a port so a
|
|
||||||
// source-only rule constrains the protocol instead of allowing all.
|
|
||||||
if ($proto !== 'any') {
|
|
||||||
$spec .= ' proto '.$proto;
|
|
||||||
}
|
|
||||||
$spec .= ' from '.$from;
|
|
||||||
if ($port !== null) {
|
|
||||||
$spec .= ' to any port '.$port;
|
|
||||||
}
|
|
||||||
} elseif ($port !== null) {
|
|
||||||
$spec .= ' '.$port.($proto !== 'any' ? '/'.$proto : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ufw '.$spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a ufw rule by its add-syntax spec (e.g. "allow 80/tcp"). Race-free:
|
|
||||||
* no rule number is involved, so a concurrent change cannot shift the target.
|
|
||||||
* Deleting by rule does not prompt (unlike delete-by-number).
|
|
||||||
*/
|
|
||||||
public function deleteUfwScript(string $spec): string
|
|
||||||
{
|
|
||||||
// Routed rules use the route grammar: "ufw route delete allow …" (NOT "ufw
|
|
||||||
// delete route …", which ufw rejects).
|
|
||||||
if (preg_match('/^route\s+(.+)$/i', $spec, $m)) {
|
|
||||||
return 'ufw route delete '.$m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ufw delete '.$spec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Os;
|
|
||||||
|
|
||||||
use App\Models\Server;
|
|
||||||
use App\Services\FleetService;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a Server's OsProfile from one unprivileged probe (/etc/os-release +
|
|
||||||
* `command -v`), cached for an hour per server. The family is inferred from
|
|
||||||
* ID/ID_LIKE, cross-checked against the binaries actually present so a derivative
|
|
||||||
* (e.g. ID_LIKE="rhel fedora") still resolves and the installed tool wins.
|
|
||||||
*/
|
|
||||||
class OsDetector
|
|
||||||
{
|
|
||||||
private const PROBE_BINARIES = [
|
|
||||||
'apt-get', 'dnf', 'yum', 'zypper', 'pacman', 'apk',
|
|
||||||
'firewall-cmd', 'ufw', 'nft', 'systemctl', 'rc-service',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(private FleetService $fleet) {}
|
|
||||||
|
|
||||||
/** Detect (and cache) the OS profile of a server. */
|
|
||||||
public function detect(Server $server, bool $fresh = false): OsProfile
|
|
||||||
{
|
|
||||||
$key = 'os-profile:'.$server->id;
|
|
||||||
|
|
||||||
if (! $fresh) {
|
|
||||||
$cached = Cache::get($key);
|
|
||||||
if ($cached instanceof OsProfile) {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include the sbin dirs: ufw/firewall-cmd live in /usr/sbin, which is not on a
|
|
||||||
// non-root user's PATH, so a bare `command -v ufw` would miss an installed tool.
|
|
||||||
// Guard the source: `.` is a POSIX special built-in, so sourcing a MISSING
|
|
||||||
// /etc/os-release would make a non-interactive sh exit before the binary
|
|
||||||
// probe runs — defeating the package-manager fallback. Only source when readable.
|
|
||||||
$probe = 'export PATH="/usr/local/sbin:/usr/sbin:/sbin:$PATH"; '
|
|
||||||
.'[ -r /etc/os-release ] && . /etc/os-release 2>/dev/null; '
|
|
||||||
.'echo "ID=${ID:-}"; echo "ID_LIKE=${ID_LIKE:-}"; echo "PRETTY=${PRETTY_NAME:-}"; '
|
|
||||||
.'for p in '.implode(' ', self::PROBE_BINARIES).'; do command -v "$p" >/dev/null 2>&1 && echo "HAS=$p"; done; '
|
|
||||||
// which firewall is actually RUNNING — decides the tool when both are installed.
|
|
||||||
.'systemctl is-active --quiet firewalld 2>/dev/null && echo ACTIVE=firewalld; '
|
|
||||||
.'systemctl is-active --quiet ufw 2>/dev/null && echo ACTIVE=ufw';
|
|
||||||
|
|
||||||
// Parse the output regardless of exit code: the probe's status reflects the
|
|
||||||
// LAST `command -v` (often a binary that is legitimately absent, e.g.
|
|
||||||
// rc-service on a systemd host), so a non-zero exit is normal and the
|
|
||||||
// ID=/HAS= lines that printed are still valid.
|
|
||||||
$res = $this->fleet->runPlain($server, $probe);
|
|
||||||
$profile = $this->parse($res['output']);
|
|
||||||
|
|
||||||
// Don't lock in a "nothing detected" result (transient SSH failure / host
|
|
||||||
// down) for a full hour — re-probe soon in that case.
|
|
||||||
$detected = $profile->id !== '' || $profile->available !== [];
|
|
||||||
Cache::put($key, $profile, $detected ? now()->addHour() : now()->addSeconds(60));
|
|
||||||
|
|
||||||
return $profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the cached profile (call after a credential change or on demand). */
|
|
||||||
public function forget(Server $server): void
|
|
||||||
{
|
|
||||||
Cache::forget('os-profile:'.$server->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parse(string $out): OsProfile
|
|
||||||
{
|
|
||||||
$id = '';
|
|
||||||
$idLike = '';
|
|
||||||
$pretty = '';
|
|
||||||
$available = [];
|
|
||||||
$active = [];
|
|
||||||
|
|
||||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
if (str_starts_with($line, 'ID=')) {
|
|
||||||
$id = strtolower(trim(substr($line, 3), " \"'"));
|
|
||||||
} elseif (str_starts_with($line, 'ID_LIKE=')) {
|
|
||||||
$idLike = strtolower(trim(substr($line, 8), " \"'"));
|
|
||||||
} elseif (str_starts_with($line, 'PRETTY=')) {
|
|
||||||
$pretty = trim(substr($line, 7), " \"'");
|
|
||||||
} elseif (str_starts_with($line, 'HAS=')) {
|
|
||||||
$available[] = substr($line, 4);
|
|
||||||
} elseif (str_starts_with($line, 'ACTIVE=')) {
|
|
||||||
$active[] = substr($line, 7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokens = array_values(array_filter(array_merge([$id], preg_split('/\s+/', $idLike) ?: [])));
|
|
||||||
$family = $this->family($tokens, $available);
|
|
||||||
|
|
||||||
return new OsProfile(
|
|
||||||
family: $family,
|
|
||||||
id: $id,
|
|
||||||
prettyName: $pretty !== '' ? $pretty : 'Unbekanntes System',
|
|
||||||
packageManager: $this->packageManager($available),
|
|
||||||
firewallTool: $this->firewallTool($family, $available, $active),
|
|
||||||
serviceManager: in_array('systemctl', $available, true)
|
|
||||||
? 'systemd'
|
|
||||||
: (in_array('rc-service', $available, true) ? 'openrc' : 'none'),
|
|
||||||
available: $available,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param list<string> $tokens */
|
|
||||||
private function family(array $tokens, array $available): string
|
|
||||||
{
|
|
||||||
$hit = fn (array $names): bool => count(array_intersect($names, $tokens)) > 0;
|
|
||||||
|
|
||||||
if ($hit(['debian', 'ubuntu', 'raspbian', 'linuxmint', 'pop', 'devuan', 'kali'])) {
|
|
||||||
return 'debian';
|
|
||||||
}
|
|
||||||
if ($hit(['rhel', 'fedora', 'centos', 'rocky', 'almalinux', 'ol', 'amzn'])) {
|
|
||||||
return 'rhel';
|
|
||||||
}
|
|
||||||
if ($hit(['suse', 'opensuse', 'opensuse-leap', 'opensuse-tumbleweed', 'sles', 'sled'])) {
|
|
||||||
return 'suse';
|
|
||||||
}
|
|
||||||
if ($hit(['arch', 'manjaro', 'endeavouros', 'arcolinux'])) {
|
|
||||||
return 'arch';
|
|
||||||
}
|
|
||||||
if ($hit(['alpine'])) {
|
|
||||||
return 'alpine';
|
|
||||||
}
|
|
||||||
|
|
||||||
// No ID match — fall back to whichever package manager is actually present.
|
|
||||||
return match (true) {
|
|
||||||
in_array('apt-get', $available, true) => 'debian',
|
|
||||||
in_array('dnf', $available, true), in_array('yum', $available, true) => 'rhel',
|
|
||||||
in_array('zypper', $available, true) => 'suse',
|
|
||||||
in_array('pacman', $available, true) => 'arch',
|
|
||||||
in_array('apk', $available, true) => 'alpine',
|
|
||||||
default => 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param list<string> $available */
|
|
||||||
private function packageManager(array $available): string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
in_array('apt-get', $available, true) => 'apt',
|
|
||||||
in_array('dnf', $available, true), in_array('yum', $available, true) => 'dnf',
|
|
||||||
in_array('zypper', $available, true) => 'zypper',
|
|
||||||
in_array('pacman', $available, true) => 'pacman',
|
|
||||||
in_array('apk', $available, true) => 'apk',
|
|
||||||
default => 'none',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $available
|
|
||||||
* @param list<string> $active firewall tools reported running (firewalld/ufw)
|
|
||||||
*/
|
|
||||||
private function firewallTool(string $family, array $available, array $active): string
|
|
||||||
{
|
|
||||||
$hasFirewalld = in_array('firewall-cmd', $available, true);
|
|
||||||
$hasUfw = in_array('ufw', $available, true);
|
|
||||||
|
|
||||||
// Both installed: the RUNNING one wins (managing the inactive tool would
|
|
||||||
// report the wrong state and could start a second firewall manager).
|
|
||||||
if ($hasFirewalld && $hasUfw) {
|
|
||||||
if (in_array('firewalld', $active, true)) {
|
|
||||||
return 'firewalld';
|
|
||||||
}
|
|
||||||
if (in_array('ufw', $active, true)) {
|
|
||||||
return 'ufw';
|
|
||||||
}
|
|
||||||
} elseif ($hasFirewalld) {
|
|
||||||
return 'firewalld';
|
|
||||||
} elseif ($hasUfw) {
|
|
||||||
return 'ufw';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing installed (or both present but neither active) — default by family
|
|
||||||
// so enable() installs/uses the conventional tool for the distro.
|
|
||||||
return match ($family) {
|
|
||||||
'rhel', 'suse' => 'firewalld',
|
|
||||||
'debian', 'arch', 'alpine' => 'ufw',
|
|
||||||
default => 'none',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Os;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable description of a fleet host's operating system, resolved once by
|
|
||||||
* OsDetector from /etc/os-release + a `command -v` probe. Everything that used to
|
|
||||||
* hard-code Debian/apt/ufw/systemd now asks an OsProfile instead.
|
|
||||||
*
|
|
||||||
* Phase-1 fully-supported package managers: apt, dnf (incl. yum), zypper — all on
|
|
||||||
* systemd hosts with ufw or firewalld. Arch (pacman) and Alpine (apk/OpenRC) are
|
|
||||||
* DETECTED and surfaced, but features that need an unsupported manager/init are
|
|
||||||
* gracefully disabled with a German reason via supports().
|
|
||||||
*/
|
|
||||||
final class OsProfile
|
|
||||||
{
|
|
||||||
/** Package managers Clusev can drive for updates/installs in this release. */
|
|
||||||
public const SUPPORTED_MANAGERS = ['apt', 'dnf', 'zypper'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $available detected binaries (apt-get, dnf, ufw, firewall-cmd, systemctl, …)
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $family, // debian|rhel|suse|arch|alpine|unknown
|
|
||||||
public readonly string $id, // raw /etc/os-release ID
|
|
||||||
public readonly string $prettyName, // PRETTY_NAME for display
|
|
||||||
public readonly string $packageManager, // apt|dnf|zypper|pacman|apk|none
|
|
||||||
public readonly string $firewallTool, // ufw|firewalld|none
|
|
||||||
public readonly string $serviceManager, // systemd|openrc|none
|
|
||||||
public readonly array $available = [],
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function has(string $binary): bool
|
|
||||||
{
|
|
||||||
return in_array($binary, $this->available, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isSystemd(): bool
|
|
||||||
{
|
|
||||||
return $this->serviceManager === 'systemd';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when Clusev can drive package install/update on this host. */
|
|
||||||
public function managesPackages(): bool
|
|
||||||
{
|
|
||||||
return in_array($this->packageManager, self::SUPPORTED_MANAGERS, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** German family label for the UI. */
|
|
||||||
public function familyLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this->family) {
|
|
||||||
'debian' => 'Debian/Ubuntu',
|
|
||||||
'rhel' => 'RHEL/Fedora',
|
|
||||||
'suse' => 'openSUSE/SLES',
|
|
||||||
'arch' => 'Arch Linux',
|
|
||||||
'alpine' => 'Alpine Linux',
|
|
||||||
default => 'Unbekanntes System',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** German label for the host firewall in the hardening checklist. */
|
|
||||||
public function firewallLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this->firewallTool) {
|
|
||||||
'ufw' => 'Firewall (UFW)',
|
|
||||||
'firewalld' => 'Firewall (firewalld)',
|
|
||||||
default => 'Firewall',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a feature is supported on this host. Returns NULL when supported, or
|
|
||||||
* a German reason to show the operator when not — so unsupported combinations
|
|
||||||
* degrade with an explanation instead of failing opaquely.
|
|
||||||
*/
|
|
||||||
public function supports(string $feature): ?string
|
|
||||||
{
|
|
||||||
return match ($feature) {
|
|
||||||
'ssh' => $this->isSystemd()
|
|
||||||
? null
|
|
||||||
: 'SSH-Härtung benötigt systemd, das auf '.$this->familyLabel().' nicht erkannt wurde.',
|
|
||||||
'services' => $this->isSystemd()
|
|
||||||
? null
|
|
||||||
: 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.',
|
|
||||||
'updates' => $this->managesPackages()
|
|
||||||
? null
|
|
||||||
: 'Paket-Updates über Clusev werden unter '.$this->familyLabel().' noch nicht unterstützt.',
|
|
||||||
'fail2ban' => ($this->managesPackages() && $this->isSystemd())
|
|
||||||
? null
|
|
||||||
: 'fail2ban wird unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
|
|
||||||
'firewall' => ($this->firewallTool !== 'none' && $this->isSystemd()
|
|
||||||
&& ($this->firewallTool === 'firewalld' || $this->managesPackages() || $this->has('ufw')))
|
|
||||||
? null
|
|
||||||
: 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter '.$this->familyLabel().' nicht gefunden.',
|
|
||||||
// apt -> unattended-upgrades; dnf -> dnf-automatic. A yum-ONLY host (e.g.
|
|
||||||
// CentOS 7) maps to the dnf manager for upgrades but has no dnf-automatic
|
|
||||||
// (it uses yum-cron), so require a real `dnf` binary for this feature.
|
|
||||||
'auto_updates' => (($this->packageManager === 'apt'
|
|
||||||
|| ($this->packageManager === 'dnf' && $this->has('dnf'))) && $this->isSystemd())
|
|
||||||
? null
|
|
||||||
: 'Automatische Updates werden unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Os;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds SHELL COMMAND STRINGS for package operations, per manager. It never runs
|
|
||||||
* anything — FleetService is the single executor (base64-wrapped, sudo + LC_ALL=C
|
|
||||||
* handled there). Only apt/dnf/zypper are driven this release; package names passed
|
|
||||||
* in are Clusev constants (fail2ban, ufw, …), never operator input.
|
|
||||||
*
|
|
||||||
* Note: the dnf/zypper command strings are constructed from documented syntax but
|
|
||||||
* are exercised live only on the families a tester actually runs; the apt path is
|
|
||||||
* the one verified against the live fleet today.
|
|
||||||
*/
|
|
||||||
final class PackageManager
|
|
||||||
{
|
|
||||||
/** @param 'apt'|'dnf'|'zypper'|'none' $manager */
|
|
||||||
public function __construct(
|
|
||||||
private string $manager,
|
|
||||||
private bool $useYum = false, // dnf family lacking the dnf binary -> yum
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function for(OsProfile $os): self
|
|
||||||
{
|
|
||||||
$useYum = $os->packageManager === 'dnf' && ! $os->has('dnf') && $os->has('yum');
|
|
||||||
|
|
||||||
return new self($os->packageManager, $useYum);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dnf(): string
|
|
||||||
{
|
|
||||||
return $this->useYum ? 'yum' : 'dnf';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command that prints `CLUSEV_OK` (only when the query actually ran) and
|
|
||||||
* `CLUSEV_PENDING=<n>`. Runs unprivileged (simulation / metadata read only).
|
|
||||||
*/
|
|
||||||
public function pendingScript(): string
|
|
||||||
{
|
|
||||||
return match ($this->manager) {
|
|
||||||
'apt' => 'out=$(apt-get -s upgrade 2>/dev/null) && echo CLUSEV_OK; '
|
|
||||||
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -c '^Inst ')\"",
|
|
||||||
'dnf' => 'out=$('.$this->dnf().' -q check-update 2>/dev/null); rc=$?; '
|
|
||||||
.'if [ "$rc" = 0 ] || [ "$rc" = 100 ]; then echo CLUSEV_OK; fi; '
|
|
||||||
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+')\"",
|
|
||||||
'zypper' => 'out=$(zypper --non-interactive list-updates 2>/dev/null); rc=$?; '
|
|
||||||
.'[ "$rc" = 0 ] && echo CLUSEV_OK; '
|
|
||||||
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^v \\|')\"",
|
|
||||||
default => 'echo CLUSEV_PENDING=0',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh index + apply all upgrades (privileged, long-running). */
|
|
||||||
public function applyScript(): string
|
|
||||||
{
|
|
||||||
return match ($this->manager) {
|
|
||||||
'apt' => 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade',
|
|
||||||
'dnf' => $this->dnf().' -y upgrade',
|
|
||||||
// zypper exits 102 (reboot required) / 103 (restart zypper) on SUCCESS —
|
|
||||||
// normalize those to 0 so a successful upgrade is not reported as failed.
|
|
||||||
'zypper' => 'zypper --non-interactive refresh && { zypper --non-interactive update; rc=$?; '
|
|
||||||
.'[ "$rc" = 0 ] || [ "$rc" = 102 ] || [ "$rc" = 103 ]; }',
|
|
||||||
default => 'false',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Install one package (privileged). */
|
|
||||||
public function installScript(string $pkg): string
|
|
||||||
{
|
|
||||||
return match ($this->manager) {
|
|
||||||
'apt' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y '.$pkg,
|
|
||||||
'dnf' => $this->dnf().' install -y '.$pkg,
|
|
||||||
'zypper' => 'zypper --non-interactive install '.$pkg,
|
|
||||||
default => 'false',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A shell CONDITION that exits 0 when $pkg is installed (for use with && / ||). */
|
|
||||||
public function isInstalledTest(string $pkg): string
|
|
||||||
{
|
|
||||||
return match ($this->manager) {
|
|
||||||
'apt' => "dpkg -l {$pkg} 2>/dev/null | grep -q '^ii'",
|
|
||||||
'dnf', 'zypper' => "rpm -q {$pkg} >/dev/null 2>&1",
|
|
||||||
default => 'false',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Install $pkg only if missing (idempotent). */
|
|
||||||
public function ensureInstalledScript(string $pkg): string
|
|
||||||
{
|
|
||||||
return '('.$this->isInstalledTest($pkg).' || '.$this->installScript($pkg).')';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
|
// 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).
|
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
|
||||||
'version' => '0.2.0',
|
'version' => '0.1.1',
|
||||||
|
|
||||||
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
||||||
'channel' => 'stable',
|
'channel' => 'stable',
|
||||||
|
|
|
||||||
|
|
@ -66,132 +66,4 @@ document.addEventListener('alpine:init', () => {
|
||||||
get memLine() { return this._coords(this.mem); },
|
get memLine() { return this._coords(this.mem); },
|
||||||
get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
|
get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Command palette + keyboard shortcuts ────────────────────────────
|
|
||||||
// German nav targets (mirrors the sidebar) with a leader-key hint. Leader
|
|
||||||
// mnemonics: i=dIenste, l=Log, e=Einstellungen, y=sYstem (d/s already taken).
|
|
||||||
const CMDK_NAV = [
|
|
||||||
{ label: 'Dashboard', href: '/', hint: 'g d' },
|
|
||||||
{ label: 'Server', href: '/servers', hint: 'g s' },
|
|
||||||
{ label: 'Dienste', href: '/services', hint: 'g i' },
|
|
||||||
{ label: 'Dateien', href: '/files', hint: 'g f' },
|
|
||||||
{ label: 'Audit-Log', href: '/audit', hint: 'g l' },
|
|
||||||
{ label: 'Einstellungen', href: '/settings', hint: 'g e' },
|
|
||||||
{ label: 'System', href: '/system', hint: 'g y' },
|
|
||||||
{ label: 'Version', href: '/versions', hint: 'g v' },
|
|
||||||
];
|
|
||||||
const CMDK_ACTIONS = [
|
|
||||||
{ label: 'Server hinzufügen', action: 'create-server', hint: '' },
|
|
||||||
];
|
|
||||||
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
|
|
||||||
|
|
||||||
window.Alpine.data('cmdk', () => ({
|
|
||||||
open: false,
|
|
||||||
help: false,
|
|
||||||
query: '',
|
|
||||||
active: 0,
|
|
||||||
leader: false,
|
|
||||||
leaderTimer: null,
|
|
||||||
nav: CMDK_NAV,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Reset overlays after an SPA navigation. The component is recreated on each
|
|
||||||
// wire:navigate, so we MUST remove this document listener in destroy() — else
|
|
||||||
// every navigation would accumulate another permanent callback.
|
|
||||||
this._onNav = () => {
|
|
||||||
this.open = false;
|
|
||||||
this.help = false;
|
|
||||||
this.leader = false;
|
|
||||||
};
|
|
||||||
document.addEventListener('livewire:navigated', this._onNav);
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
document.removeEventListener('livewire:navigated', this._onNav);
|
|
||||||
},
|
|
||||||
|
|
||||||
get items() {
|
|
||||||
const q = this.query.trim().toLowerCase();
|
|
||||||
const all = [...CMDK_NAV, ...CMDK_ACTIONS];
|
|
||||||
return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q));
|
|
||||||
},
|
|
||||||
|
|
||||||
toggle(force) {
|
|
||||||
this.open = force === true ? true : ! this.open;
|
|
||||||
if (this.open) {
|
|
||||||
this.help = false;
|
|
||||||
this.query = '';
|
|
||||||
this.active = 0;
|
|
||||||
this.$nextTick(() => this.$refs.input?.focus());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.open = false;
|
|
||||||
this.help = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
move(d) {
|
|
||||||
const n = this.items.length;
|
|
||||||
if (n) this.active = (this.active + d + n) % n;
|
|
||||||
},
|
|
||||||
|
|
||||||
run(item) {
|
|
||||||
item = item || this.items[this.active] || null;
|
|
||||||
if (! item) return;
|
|
||||||
if (item.href) {
|
|
||||||
this.navigate(item.href);
|
|
||||||
} else if (item.action === 'create-server') {
|
|
||||||
this.close();
|
|
||||||
window.Livewire?.dispatch('openModal', { component: 'modals.create-server' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
navigate(href) {
|
|
||||||
this.close();
|
|
||||||
if (window.Livewire?.navigate) window.Livewire.navigate(href);
|
|
||||||
else window.location.href = href;
|
|
||||||
},
|
|
||||||
|
|
||||||
focusSearch() {
|
|
||||||
const el = document.querySelector('[data-page-search]');
|
|
||||||
if (el) {
|
|
||||||
el.focus();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
|
|
||||||
go(key) {
|
|
||||||
if (CMDK_GO[key]) this.navigate(CMDK_GO[key]);
|
|
||||||
},
|
|
||||||
|
|
||||||
onKey(e) {
|
|
||||||
// Cmd/Ctrl-K toggles the palette even from within an input.
|
|
||||||
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.toggle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.open || this.help) return; // overlay keys handled in the markup
|
|
||||||
const t = e.target;
|
|
||||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
|
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
||||||
// Only consume "/" when a page search exists — otherwise leave the browser's quick-find.
|
|
||||||
if (e.key === '/') { if (this.focusSearch()) e.preventDefault(); return; }
|
|
||||||
if (e.key === '?') { e.preventDefault(); this.help = true; return; }
|
|
||||||
if (this.leader) {
|
|
||||||
this.leader = false;
|
|
||||||
clearTimeout(this.leaderTimer);
|
|
||||||
this.go(e.key.toLowerCase());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'g' || e.key === 'G') {
|
|
||||||
this.leader = true;
|
|
||||||
this.leaderTimer = setTimeout(() => { this.leader = false; }, 1200);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
{{--
|
|
||||||
Command palette + keyboard shortcuts. Mounted ONCE in the persistent layout so it
|
|
||||||
survives wire:navigate and its global keydown listener is registered only once.
|
|
||||||
Strg/⌘-K opens it; "g" + a key navigates; "/" focuses the page search; "?" shows help.
|
|
||||||
--}}
|
|
||||||
@php
|
|
||||||
$chords = [
|
|
||||||
['Strg / ⌘ K', 'Befehle öffnen'],
|
|
||||||
['/', 'Suche auf der Seite fokussieren'],
|
|
||||||
['?', 'Diese Hilfe anzeigen'],
|
|
||||||
['g d', 'Dashboard'],
|
|
||||||
['g s', 'Server'],
|
|
||||||
['g i', 'Dienste'],
|
|
||||||
['g f', 'Dateien'],
|
|
||||||
['g l', 'Audit-Log'],
|
|
||||||
['g e', 'Einstellungen'],
|
|
||||||
['g y', 'System'],
|
|
||||||
['g v', 'Version'],
|
|
||||||
];
|
|
||||||
$kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2';
|
|
||||||
@endphp
|
|
||||||
<div x-data="cmdk"
|
|
||||||
@keydown.window="onKey($event)"
|
|
||||||
@keydown.escape.window="close()"
|
|
||||||
@cmdk-open.window="toggle(true)">
|
|
||||||
|
|
||||||
{{-- Command palette --}}
|
|
||||||
<div x-show="open" x-cloak class="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[12vh]" role="dialog" aria-modal="true">
|
|
||||||
<div class="fixed inset-0 bg-void/70 backdrop-blur-sm" @click="close()" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div x-show="open" x-transition
|
|
||||||
class="relative w-full max-w-xl overflow-hidden rounded-lg border border-line bg-surface shadow-pop">
|
|
||||||
<div class="flex items-center gap-2.5 border-b border-line px-4">
|
|
||||||
<x-icon name="search" class="h-4 w-4 shrink-0 text-ink-3" />
|
|
||||||
<input x-ref="input" x-model="query" type="text" autocomplete="off" spellcheck="false"
|
|
||||||
placeholder="Befehl oder Seite…"
|
|
||||||
@input="active = 0"
|
|
||||||
@keydown.down.prevent="move(1)"
|
|
||||||
@keydown.up.prevent="move(-1)"
|
|
||||||
@keydown.enter.prevent="run()"
|
|
||||||
class="h-12 w-full bg-transparent font-mono text-sm text-ink placeholder:text-ink-4 focus:outline-none" />
|
|
||||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">Esc</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="max-h-80 overflow-y-auto py-1.5">
|
|
||||||
<template x-for="(item, idx) in items" :key="item.label">
|
|
||||||
<li>
|
|
||||||
<button type="button"
|
|
||||||
@click="run(item)"
|
|
||||||
@mouseenter="active = idx"
|
|
||||||
:class="active === idx ? 'bg-accent/10 text-accent-text' : 'text-ink-2'"
|
|
||||||
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left">
|
|
||||||
<span class="truncate font-mono text-sm" x-text="item.label"></span>
|
|
||||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4" x-text="item.hint"></span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
<li x-show="items.length === 0" class="px-4 py-3 font-mono text-[11px] text-ink-4">Kein Treffer.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t border-line px-4 py-2 font-mono text-[10px] text-ink-4">
|
|
||||||
<span class="flex items-center gap-1.5"><x-icon name="corner-down-left" class="h-3 w-3" /> öffnen</span>
|
|
||||||
<button type="button" @click="open = false; help = true" class="hover:text-ink-2">? Tastenkürzel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Shortcut help --}}
|
|
||||||
<div x-show="help" x-cloak class="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[12vh]" role="dialog" aria-modal="true">
|
|
||||||
<div class="fixed inset-0 bg-void/70 backdrop-blur-sm" @click="help = false" aria-hidden="true"></div>
|
|
||||||
<div x-show="help" x-transition class="relative w-full max-w-md overflow-hidden rounded-lg border border-line bg-surface shadow-pop">
|
|
||||||
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
|
||||||
<x-icon name="command" class="h-4 w-4 shrink-0 text-accent-text" />
|
|
||||||
<h2 class="font-display text-sm font-semibold text-ink">Tastenkürzel</h2>
|
|
||||||
<button type="button" @click="help = false" class="ml-auto text-ink-4 hover:text-ink" aria-label="Schließen">
|
|
||||||
<x-icon name="x" class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<dl class="divide-y divide-line">
|
|
||||||
@foreach ($chords as [$chord, $desc])
|
|
||||||
<div class="flex items-center justify-between gap-3 px-4 py-2">
|
|
||||||
<dt class="text-sm text-ink-2">{{ $desc }}</dt>
|
|
||||||
<dd class="shrink-0"><span class="{{ $kbd }}">{{ $chord }}</span></dd>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -15,8 +15,6 @@
|
||||||
'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"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
||||||
'command' => '<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
|
|
||||||
'corner-down-left' => '<path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/>',
|
|
||||||
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
'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"/>',
|
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||||||
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,6 @@
|
||||||
<h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1>
|
<h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1>
|
||||||
|
|
||||||
<div class="ml-auto flex min-w-0 items-center gap-2">
|
<div class="ml-auto flex min-w-0 items-center gap-2">
|
||||||
<button type="button" @click="$dispatch('cmdk-open')"
|
|
||||||
class="hidden items-center gap-2 rounded-md border border-line bg-inset px-2.5 py-1.5 text-ink-3 transition-colors hover:border-line-strong hover:text-ink sm:flex"
|
|
||||||
aria-label="Befehle öffnen (Strg K)" title="Befehle (Strg K)">
|
|
||||||
<x-icon name="command" class="h-3.5 w-3.5" />
|
|
||||||
<span class="font-mono text-[10px] uppercase tracking-wider">Strg K</span>
|
|
||||||
</button>
|
|
||||||
@php
|
@php
|
||||||
$fleetTotal = \App\Models\Server::count();
|
$fleetTotal = \App\Models\Server::count();
|
||||||
$fleetOnline = \App\Models\Server::where('status', 'online')->count();
|
$fleetOnline = \App\Models\Server::where('status', 'online')->count();
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,6 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}}
|
|
||||||
<x-command-palette />
|
|
||||||
|
|
||||||
<livewire:wire-elements-modal />
|
<livewire:wire-elements-modal />
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
data-page-search
|
|
||||||
wire:model.live.debounce.300ms="q"
|
wire:model.live.debounce.300ms="q"
|
||||||
placeholder="Akteur, Aktion, Ziel …"
|
placeholder="Akteur, Aktion, Ziel …"
|
||||||
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"
|
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
@php
|
|
||||||
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
|
||||||
$field = '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';
|
|
||||||
$select = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none';
|
|
||||||
@endphp
|
|
||||||
<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-offline/30 bg-offline/10 text-offline">
|
|
||||||
<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">IP manuell sperren</h2>
|
|
||||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
|
||||||
Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang
|
|
||||||
können nicht gesperrt werden.
|
|
||||||
</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
|
|
||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label class="{{ $label }}">Jail</label>
|
|
||||||
<select wire:model="jail" class="{{ $select }}">
|
|
||||||
@forelse ($jails as $j)
|
|
||||||
<option value="{{ $j }}">{{ $j }}</option>
|
|
||||||
@empty
|
|
||||||
<option value="sshd">sshd</option>
|
|
||||||
@endforelse
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="{{ $label }}">IP-Adresse</label>
|
|
||||||
<input wire:model="ip" type="text" placeholder="z. B. 203.0.113.10" class="{{ $field }}" />
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
IP sperren
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
@php
|
|
||||||
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
|
||||||
$field = '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';
|
|
||||||
$select = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none';
|
|
||||||
@endphp
|
|
||||||
<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">Firewall-Regel hinzufügen</h2>
|
|
||||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
|
||||||
@if ($tool === 'firewalld')
|
|
||||||
Öffnet einen Port in der Standard-Zone von firewalld.
|
|
||||||
@else
|
|
||||||
Neue UFW-Regel. Quelle leer lassen = von überall.
|
|
||||||
@endif
|
|
||||||
</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
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
@if ($tool !== 'firewalld')
|
|
||||||
<div>
|
|
||||||
<label class="{{ $label }}">Aktion</label>
|
|
||||||
<select wire:model="action" class="{{ $select }}">
|
|
||||||
<option value="allow">Erlauben (allow)</option>
|
|
||||||
<option value="deny">Verwerfen (deny)</option>
|
|
||||||
<option value="reject">Ablehnen (reject)</option>
|
|
||||||
<option value="limit">Begrenzen (limit)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label class="{{ $label }}">Port {{ $tool === 'firewalld' ? '' : '(optional)' }}</label>
|
|
||||||
<input wire:model="port" type="number" min="1" max="65535" placeholder="z. B. 8080" class="{{ $field }}" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="{{ $label }}">Protokoll</label>
|
|
||||||
<select wire:model="proto" class="{{ $select }}">
|
|
||||||
<option value="tcp">TCP</option>
|
|
||||||
<option value="udp">UDP</option>
|
|
||||||
@if ($tool !== 'firewalld')
|
|
||||||
<option value="any">Beide</option>
|
|
||||||
@endif
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($tool !== 'firewalld')
|
|
||||||
<div>
|
|
||||||
<label class="{{ $label }}">Quelle (optional)</label>
|
|
||||||
<input wire:model="from" type="text" placeholder="IP oder CIDR, z. B. 10.0.0.0/8" class="{{ $field }}" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</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>
|
|
||||||
|
|
@ -17,10 +17,10 @@
|
||||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||||
</div>
|
</div>
|
||||||
@elseif (! $supported)
|
@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">
|
<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" />
|
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||||
<p class="text-sm text-ink-2">{{ $unsupportedReason ?: 'Auf diesem System nicht verfügbar.' }}</p>
|
<p class="text-sm text-ink-2">Nur für Debian/Ubuntu (apt) verfügbar.</p>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
|
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||||
@else
|
@else
|
||||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||||
@if (! $error && $supported)
|
@if (! $error && $hasApt)
|
||||||
<x-btn variant="accent" wire:click="upgrade" wire:target="upgrade" wire:loading.attr="disabled">
|
<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" />
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
|
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
data-page-search
|
|
||||||
wire:model.live.debounce.300ms="search"
|
wire:model.live.debounce.300ms="search"
|
||||||
placeholder="Name oder IP…"
|
placeholder="Name oder IP…"
|
||||||
class="min-h-11 w-full rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
|
class="min-h-11 w-full rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,6 @@
|
||||||
['Disk', $diskGb !== null ? $diskGb.' GB' : '—'],
|
['Disk', $diskGb !== null ? $diskGb.' GB' : '—'],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Detected OS tooling (populated once the live profile loads).
|
|
||||||
if (! empty($os)) {
|
|
||||||
$pm = ($os['packageManager'] ?? 'none') !== 'none' ? $os['packageManager'] : '—';
|
|
||||||
$fw = ($os['firewallTool'] ?? 'none') !== 'none' ? $os['firewallTool'] : '—';
|
|
||||||
$specRows[] = ['System', $os['familyLabel'] ?? '—'];
|
|
||||||
$specRows[] = ['Paketverwaltung', $pm];
|
|
||||||
$specRows[] = ['Firewall', $fw];
|
|
||||||
}
|
|
||||||
|
|
||||||
$vitals = [
|
$vitals = [
|
||||||
['label' => 'CPU', 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? 'Last '.number_format($loadAvg, 2).' · ' : '').($cores ? $cores.' Kerne' : '—')],
|
['label' => 'CPU', 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? 'Last '.number_format($loadAvg, 2).' · ' : '').($cores ? $cores.' Kerne' : '—')],
|
||||||
['label' => 'RAM', 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? $ramUsed.' / '.$ramGb.' GB' : '—'],
|
['label' => 'RAM', 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? $ramUsed.' / '.$ramGb.' GB' : '—'],
|
||||||
|
|
@ -108,7 +99,7 @@
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="truncate font-display text-sm font-semibold text-ink">{{ $cred->name ?: $cred->username }}</p>
|
<p class="truncate font-display text-sm font-semibold text-ink">{{ $cred->name ?: '—' }}</p>
|
||||||
<x-status-pill :status="$credStatus" class="shrink-0">{{ $credStatusLabel }}</x-status-pill>
|
<x-status-pill :status="$credStatus" class="shrink-0">{{ $credStatusLabel }}</x-status-pill>
|
||||||
<x-badge tone="neutral" class="shrink-0">{{ $credAuthLabel }}</x-badge>
|
<x-badge tone="neutral" class="shrink-0">{{ $credAuthLabel }}</x-badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -178,258 +169,43 @@
|
||||||
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
|
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
|
||||||
<div class="divide-y divide-line">
|
<div class="divide-y divide-line">
|
||||||
@foreach ($hardening as $check)
|
@foreach ($hardening as $check)
|
||||||
@php $supported = $check['supported'] ?? true; @endphp
|
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen). --}}
|
||||||
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen);
|
|
||||||
unsupported items render muted with a German reason and no toggle. --}}
|
|
||||||
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
|
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
|
||||||
<span @class([
|
<span @class([
|
||||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||||
'border-online/25 bg-online/10 text-online' => $supported && $check['secure'],
|
'border-online/25 bg-online/10 text-online' => $check['secure'],
|
||||||
'border-warning/25 bg-warning/10 text-warning' => $supported && ! $check['secure'],
|
'border-warning/25 bg-warning/10 text-warning' => ! $check['secure'],
|
||||||
'border-line bg-raised text-ink-4' => ! $supported,
|
|
||||||
])>
|
])>
|
||||||
<x-icon :name="$supported && ! $check['secure'] ? 'lock-open' : 'lock'" class="h-4 w-4" />
|
<x-icon :name="$check['secure'] ? 'lock' : 'lock-open'" class="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="flex items-center gap-2 truncate text-sm text-ink">
|
<p class="flex items-center gap-2 truncate text-sm text-ink">
|
||||||
{{ $check['label'] }}
|
{{ $check['label'] }}
|
||||||
@if ($supported)
|
<span @class([
|
||||||
<span @class([
|
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
||||||
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
'text-online' => $check['secure'],
|
||||||
'text-online' => $check['secure'],
|
'text-warning' => ! $check['secure'],
|
||||||
'text-warning' => ! $check['secure'],
|
])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span>
|
||||||
])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span>
|
|
||||||
@else
|
|
||||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">n. v.</span>
|
|
||||||
@endif
|
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>
|
<p class="truncate font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
|
||||||
</div>
|
</div>
|
||||||
@if ($supported)
|
@if ($check['key'] === 'fail2ban')
|
||||||
@if ($check['key'] === 'fail2ban')
|
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="fail2ban konfigurieren"
|
||||||
<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 }} } })">
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
|
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
|
||||||
</x-btn>
|
|
||||||
@endif
|
|
||||||
{{-- Toggle: secondary to make secure, quiet ghost to loosen. --}}
|
|
||||||
<x-btn :variant="$check['secure'] ? 'ghost' : 'secondary'" size="sm" class="shrink-0"
|
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
|
|
||||||
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
|
|
||||||
</x-btn>
|
</x-btn>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Firewall-Regeln --}}
|
|
||||||
@php
|
|
||||||
$fw = $firewall ?? [];
|
|
||||||
$fwTool = $fw['tool'] ?? 'ufw';
|
|
||||||
$fwToolLabel = $fwTool === 'firewalld' ? 'firewalld' : 'UFW';
|
|
||||||
$fwSupported = $fw['supported'] ?? false;
|
|
||||||
$fwInstalled = $fw['installed'] ?? false;
|
|
||||||
$fwActive = $fw['active'] ?? false;
|
|
||||||
$fwManageable = $fw['manageable'] ?? false;
|
|
||||||
$fwReadOnly = $fw['readOnly'] ?? false;
|
|
||||||
$fwReadError = $fw['readError'] ?? false;
|
|
||||||
$fwRules = $fw['rules'] ?? [];
|
|
||||||
$fwDefaults = $fw['defaults'] ?? [];
|
|
||||||
$actTone = ['ALLOW' => 'online', 'LIMIT' => 'warning', 'DENY' => 'offline', 'REJECT' => 'offline'];
|
|
||||||
@endphp
|
|
||||||
<x-panel title="Firewall-Regeln"
|
|
||||||
:subtitle="$fwSupported ? ($fwToolLabel . ($fwInstalled ? ($fwActive ? ' · aktiv' : ' · inaktiv') : ' · nicht installiert')) : 'Nicht verfügbar'"
|
|
||||||
:padded="false">
|
|
||||||
@if ($fwSupported && $fwManageable)
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-btn variant="accent" size="sm"
|
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.firewall-rule', arguments: { serverId: {{ $server->id }}, tool: '{{ $fwTool }}' } })">
|
|
||||||
<x-icon name="plus" class="h-3.5 w-3.5" /> Regel
|
|
||||||
</x-btn>
|
|
||||||
</x-slot:actions>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! $fwSupported)
|
|
||||||
<div class="px-4 py-4 sm:px-5">
|
|
||||||
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $fw['reason'] ?? 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.' }}</p>
|
|
||||||
</div>
|
|
||||||
@elseif ($fwReadError)
|
|
||||||
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
|
|
||||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.</p>
|
|
||||||
</div>
|
|
||||||
@elseif (! $fwInstalled)
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">{{ $fwToolLabel }} ist nicht installiert.</p>
|
|
||||||
<x-btn variant="secondary" size="sm"
|
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
|
|
||||||
Aktivieren
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
|
||||||
@elseif ($fwTool === 'firewalld' && ! $fwActive)
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">firewalld ist installiert, aber inaktiv.</p>
|
|
||||||
<x-btn variant="secondary" size="sm"
|
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
|
|
||||||
Aktivieren
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5 sm:px-5">
|
|
||||||
@if ($fwTool === 'ufw')
|
|
||||||
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Standard</span>
|
|
||||||
<x-badge tone="neutral">eingehend: {{ $fwDefaults['incoming'] ?? '—' }}</x-badge>
|
|
||||||
<x-badge tone="neutral">ausgehend: {{ $fwDefaults['outgoing'] ?? '—' }}</x-badge>
|
|
||||||
@else
|
|
||||||
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Zone</span>
|
|
||||||
<x-badge tone="neutral">{{ $fwDefaults['zone'] ?? '—' }}</x-badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($fwReadOnly)
|
|
||||||
<div class="flex items-start gap-2.5 border-b border-line bg-inset px-4 py-2.5 sm:px-5">
|
|
||||||
<x-icon name="lock" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" />
|
|
||||||
<p class="font-mono text-[11px] leading-relaxed text-ink-3">firewalld: nur Anzeige — Regeln bitte direkt am Server verwalten.</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="divide-y divide-line">
|
|
||||||
@forelse ($fwRules as $rule)
|
|
||||||
{{-- $ruleTone, NOT $tone: the page-level $tone is a closure used by the
|
|
||||||
gauges/Volumes panel; reusing the name here would clobber it. --}}
|
|
||||||
@php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp
|
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
|
|
||||||
<span @class([
|
|
||||||
'h-2 w-2 shrink-0 rounded-full',
|
|
||||||
'bg-online' => $ruleTone === 'online',
|
|
||||||
'bg-warning' => $ruleTone === 'warning',
|
|
||||||
'bg-offline' => $ruleTone === 'offline',
|
|
||||||
])></span>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="truncate font-mono text-sm text-ink">{{ $rule['label'] ?? ($rule['to'] ?? $rule['raw']) }}</p>
|
|
||||||
@if ($fwTool === 'ufw' && ($rule['from'] ?? '') !== '' && ! \Illuminate\Support\Str::contains($rule['from'], 'Anywhere'))
|
|
||||||
<p class="truncate font-mono text-[11px] text-ink-3">von {{ $rule['from'] }}</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<span @class([
|
|
||||||
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
|
||||||
'text-online' => $ruleTone === 'online',
|
|
||||||
'text-warning' => $ruleTone === 'warning',
|
|
||||||
'text-offline' => $ruleTone === 'offline',
|
|
||||||
])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
|
|
||||||
@unless ($fwReadOnly)
|
|
||||||
<x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="Regel entfernen"
|
|
||||||
wire:click="confirmDeleteRule({{ $loop->index }})">
|
|
||||||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
|
||||||
</x-btn>
|
|
||||||
@endunless
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<div class="px-4 py-4 sm:px-5">
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">Keine Regeln definiert.</p>
|
|
||||||
</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-panel>
|
|
||||||
|
|
||||||
{{-- fail2ban-Status --}}
|
|
||||||
@php
|
|
||||||
$f2b = $fail2ban ?? [];
|
|
||||||
$f2bSupported = $f2b['supported'] ?? false;
|
|
||||||
$f2bReadError = $f2b['readError'] ?? false;
|
|
||||||
$f2bInstalled = $f2b['installed'] ?? false;
|
|
||||||
$f2bActive = $f2b['active'] ?? false;
|
|
||||||
$f2bJails = $f2b['jails'] ?? [];
|
|
||||||
$f2bIgnore = $f2b['ignoreip'] ?? [];
|
|
||||||
$f2bBanned = array_sum(array_map(fn ($j) => $j['currentlyBanned'] ?? 0, $f2bJails));
|
|
||||||
@endphp
|
|
||||||
<x-panel title="fail2ban-Status"
|
|
||||||
:subtitle="$f2bSupported ? ($f2bInstalled ? ($f2bActive ? ('aktiv · ' . $f2bBanned . ' gesperrt') : 'installiert · inaktiv') : 'nicht installiert') : 'Nicht verfügbar'"
|
|
||||||
:padded="false">
|
|
||||||
@if ($f2bSupported && $f2bInstalled && $f2bActive)
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-btn variant="ghost" size="sm" icon 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>
|
|
||||||
<x-btn variant="accent" size="sm"
|
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-ban', arguments: { serverId: {{ $server->id }}, jails: {{ \Illuminate\Support\Js::from(array_map(fn ($j) => $j['name'], $f2bJails)) }} } })">
|
|
||||||
<x-icon name="lock" class="h-3.5 w-3.5" /> IP sperren
|
|
||||||
</x-btn>
|
|
||||||
</x-slot:actions>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! $f2bSupported)
|
|
||||||
<div class="px-4 py-4 sm:px-5">
|
|
||||||
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $f2b['reason'] ?? 'fail2ban ist auf diesem System nicht verfügbar.' }}</p>
|
|
||||||
</div>
|
|
||||||
@elseif ($f2bReadError)
|
|
||||||
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
|
|
||||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.</p>
|
|
||||||
</div>
|
|
||||||
@elseif (! $f2bInstalled || ! $f2bActive)
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">fail2ban ist {{ $f2bInstalled ? 'installiert, aber inaktiv' : 'nicht installiert' }}.</p>
|
|
||||||
<x-btn variant="secondary" size="sm"
|
|
||||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'fail2ban', enable: true } })">
|
|
||||||
Aktivieren
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="divide-y divide-line">
|
|
||||||
@foreach ($f2bJails as $jail)
|
|
||||||
<div class="px-4 py-3 sm:px-5">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p class="font-mono text-sm text-ink">{{ $jail['name'] }}</p>
|
|
||||||
<p class="font-mono text-[11px] text-ink-3">
|
|
||||||
<span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>gebannt: {{ $jail['currentlyBanned'] ?? 0 }}</span>
|
|
||||||
· Fehlversuche: {{ $jail['currentlyFailed'] ?? 0 }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@if (count($jail['bannedIps'] ?? []))
|
|
||||||
<div class="mt-2 space-y-1">
|
|
||||||
@foreach ($jail['bannedIps'] as $ip)
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-mono text-[11px] text-ink-2">{{ $ip }}</span>
|
|
||||||
<x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">Entsperren</x-btn>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<p class="mt-1 font-mono text-[11px] text-ink-4">Keine gebannten IPs.</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Whitelist (ignoreip) --}}
|
|
||||||
<div class="border-t border-line px-4 py-3 sm:px-5">
|
|
||||||
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">Whitelist (ignoreip)</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
@foreach ($f2bIgnore as $ip)
|
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
|
|
||||||
{{ $ip }}
|
|
||||||
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
|
|
||||||
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="Entfernen">
|
|
||||||
<x-icon name="x" class="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
@endunless
|
|
||||||
</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
<div class="mt-2.5 flex items-center gap-2">
|
|
||||||
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="IP oder CIDR"
|
|
||||||
class="h-8 w-full max-w-xs rounded-md border border-line bg-inset px-2.5 font-mono text-[11px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
||||||
<x-btn variant="secondary" size="sm" wire:click="addIgnore">Hinzufügen</x-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-panel>
|
|
||||||
|
|
||||||
{{-- Volumes + Netzwerk-Interfaces --}}
|
{{-- Volumes + Netzwerk-Interfaces --}}
|
||||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
|
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@
|
||||||
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
|
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
data-page-search
|
|
||||||
wire:model.live.debounce.250ms="search"
|
wire:model.live.debounce.250ms="search"
|
||||||
placeholder="Dienst suchen…"
|
placeholder="Dienst suchen…"
|
||||||
class="h-9 w-40 rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
|
class="h-9 w-40 rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
|
||||||
|
|
|
||||||
|
|
@ -45,20 +45,6 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Architecture rationale: why TLS is terminated by Caddy (collapsible) --}}
|
|
||||||
<div class="mb-4 rounded-md border border-line bg-inset p-3" x-data="{ open: false }">
|
|
||||||
<button type="button" @click="open = ! open" class="flex w-full items-center gap-2 text-left">
|
|
||||||
<x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-ink-3" />
|
|
||||||
<span class="text-sm text-ink-2">Warum läuft TLS über Caddy?</span>
|
|
||||||
<span class="ml-auto shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4" x-text="open ? 'schließen' : 'mehr'"></span>
|
|
||||||
</button>
|
|
||||||
<div x-show="open" x-cloak x-transition class="mt-2.5 space-y-1.5 font-mono text-[11px] leading-relaxed text-ink-3">
|
|
||||||
<p>Clusev nutzt <span class="text-ink-2">genau einen</span> nach außen offenen Dienst: Caddy. Es terminiert TLS, holt und erneuert das Let's-Encrypt-Zertifikat automatisch und leitet die Reverb-WebSockets (wss) über dieselbe Adresse — ohne Skript, Cronjob oder Reload-Hook.</p>
|
|
||||||
<p>App und Reverb bleiben intern erreichbar (kein offener Port nach außen). Caddy ist ein eigener Container mit eigener Version, daher kann ein App-Update die TLS-Terminierung nie unterbrechen.</p>
|
|
||||||
<p>Ein zusätzliches certbot + nginx wären <span class="text-ink-2">mehr</span> bewegliche Teile (eigene 443-Konfiguration, WebSocket-Upgrade, Renewal-Timer, Reload-Hook) — bei nur einer Domain kein Gewinn.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form wire:submit="saveDomain" class="space-y-4">
|
<form wire:submit="saveDomain" class="space-y-4">
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue