From 4bfa367ccfb67e841f7b9c2a53200db3e99e16fa Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 13 Jun 2026 18:30:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(os):=20OS=20abstraction=20=E2=80=94=20mana?= =?UTF-8?q?ge=20updates/hardening/firewall=20across=20apt,=20dnf=20&=20zyp?= =?UTF-8?q?per?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation so Clusev no longer assumes Debian/apt/ufw/systemd. A host's OS is resolved once (cached) and every package/firewall/service decision asks it. New app/Support/Os/: - OsProfile — family + package manager + firewall tool + service manager, plus supports(feature) returning NULL or a German reason (graceful degradation). - OsDetector — one unprivileged probe (/etc/os-release + `command -v` with sbin on PATH + which firewall is RUNNING); family from ID/ID_LIKE cross-checked against installed binaries; cached 1h (60s when nothing detected). Readability-guards the `.` source so a host without /etc/os-release still falls back by package manager. - PackageManager — apt/dnf/zypper command strings (pending count, apply, install, is-installed); zypper exit 102/103 normalized to success. - FirewallTool — ufw + firewalld enable/disable. firewalld opens ssh/80/443 in the PERMANENT config BEFORE the daemon starts filtering (handles running + stopped), preserving the no-lockout guard for custom SSH ports. Integration: - FirewallService enable/disable now OS-aware (ufw or firewalld) with a support gate. - MaintenanceService: hasApt -> updateSupport(); pendingUpdates() + applyUpgrades() across managers (dnf check-update exit 100 handled). - HardeningService: state()/commandFor() per-OS (dpkg vs rpm, ufw vs firewalld, apt periodic vs dnf-automatic; yum-only hosts gate auto-updates). Each row carries supported/reason; unsupported features render muted with a German note instead of a toggle. apply() refuses unsupported features gracefully. - Server-Details surfaces the detected System / package manager / firewall. - SystemUpdate modal + view: OS-neutral copy; audit action system.package_upgrade. Arch (pacman) & Alpine (apk/OpenRC) are detected and gracefully disabled where unsupported. Debian/ufw/systemd path verified behaviourally identical on the live fleet. Pint clean; Codex review clean (fixed firewalld lockout + os-release guard + yum/dual-firewall/zypper edge cases); R12 — 9 routes 200, 0 console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Modals/SystemUpdate.php | 28 +-- app/Livewire/Servers/Show.php | 20 ++ app/Services/FirewallService.php | 40 ++-- app/Services/HardeningService.php | 151 +++++++++----- app/Services/MaintenanceService.php | 56 +++--- app/Support/Os/FirewallTool.php | 87 ++++++++ app/Support/Os/OsDetector.php | 186 ++++++++++++++++++ app/Support/Os/OsProfile.php | 106 ++++++++++ app/Support/Os/PackageManager.php | 94 +++++++++ .../livewire/modals/system-update.blade.php | 6 +- .../views/livewire/servers/show.blade.php | 58 ++++-- 11 files changed, 710 insertions(+), 122 deletions(-) create mode 100644 app/Support/Os/FirewallTool.php create mode 100644 app/Support/Os/OsDetector.php create mode 100644 app/Support/Os/OsProfile.php create mode 100644 app/Support/Os/PackageManager.php diff --git a/app/Livewire/Modals/SystemUpdate.php b/app/Livewire/Modals/SystemUpdate.php index 1afdf25..dd58468 100644 --- a/app/Livewire/Modals/SystemUpdate.php +++ b/app/Livewire/Modals/SystemUpdate.php @@ -10,10 +10,11 @@ use LivewireUI\Modal\ModalComponent; use Throwable; /** - * Apply pending apt package updates (Debian/Ubuntu only). mount() reads whether - * apt exists + the pending count; the body explains in GERMAN what happens — never - * a shell command. „Jetzt aktualisieren" runs the upgrade, AUDITs, notifies, and - * keeps the modal open showing a clean German result. + * Apply pending package updates across apt/dnf/zypper (resolved per OS). mount() + * reads whether updates are supported on this host + the pending count; the body + * explains in GERMAN what happens — never a shell command. „Jetzt aktualisieren" + * runs the upgrade, AUDITs, notifies, and keeps the modal open showing a clean + * German result. Unsupported hosts (e.g. Arch/Alpine) get a German reason instead. */ class SystemUpdate extends ModalComponent { @@ -21,7 +22,11 @@ class SystemUpdate extends ModalComponent public string $serverName = ''; - public bool $hasApt = false; + /** True when Clusev can manage package updates on this host. */ + 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"). */ public ?int $pending = null; @@ -30,7 +35,7 @@ class SystemUpdate extends ModalComponent public bool $ok = false; - /** Trimmed apt output, only shown when the upgrade failed. */ + /** Trimmed package-manager output, only shown when the upgrade failed. */ public string $output = ''; public ?string $error = null; @@ -49,8 +54,9 @@ class SystemUpdate extends ModalComponent $this->serverName = $server->name; try { - $this->hasApt = $maintenance->hasApt($server); - $this->pending = $this->hasApt ? $maintenance->pendingUpdates($server) : null; + $this->unsupportedReason = $maintenance->updateSupport($server); + $this->supported = $this->unsupportedReason === null; + $this->pending = $this->supported ? $maintenance->pendingUpdates($server) : null; } catch (Throwable $e) { $this->error = $e->getMessage(); } @@ -63,7 +69,7 @@ class SystemUpdate extends ModalComponent public function upgrade(MaintenanceService $maintenance): void { - if ($this->error !== null || $this->done || ! $this->hasApt) { + if ($this->error !== null || $this->done || ! $this->supported) { return; } @@ -75,7 +81,7 @@ class SystemUpdate extends ModalComponent } try { - $result = $maintenance->aptUpgrade($server); + $result = $maintenance->applyUpgrades($server); } catch (Throwable $e) { $this->done = true; $this->ok = false; @@ -95,7 +101,7 @@ class SystemUpdate extends ModalComponent 'user_id' => Auth::id(), 'server_id' => $server->id, 'actor' => Auth::user()?->name ?? 'system', - 'action' => 'system.apt_upgrade', + 'action' => 'system.package_upgrade', 'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'), 'ip' => request()->ip(), ]); diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index 1d3e680..a70a480 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -6,6 +6,7 @@ use App\Models\AuditEvent; use App\Models\Server; use App\Services\FleetService; use App\Services\HardeningService; +use App\Support\Os\OsDetector; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Attributes\On; @@ -32,6 +33,9 @@ class Show extends Component public array $hardening = []; + /** Detected OS tooling (family/package manager/firewall) for the identity panel. */ + public array $os = []; + public array $sshKeys = []; /** @@ -76,6 +80,22 @@ class Show extends Component $this->interfaces = $snap['interfaces']; $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). try { $this->hardening = app(HardeningService::class)->state($this->server); diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php index 80a46aa..8e0bc15 100644 --- a/app/Services/FirewallService.php +++ b/app/Services/FirewallService.php @@ -3,49 +3,53 @@ namespace App\Services; use App\Models\Server; +use App\Support\Os\FirewallTool; +use App\Support\Os\OsDetector; /** - * UFW firewall control over SSH (as root). + * Host firewall control over SSH (as root), abstracted across ufw and firewalld + * (resolved per OS via OsDetector/FirewallTool). * - * Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd - * Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the - * firewall on — so enabling UFW can never sever the operator's own SSH session. + * Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd Port + * (detected from sshd_config, default 22) plus 80/443 BEFORE turning the firewall + * on — so enabling can never sever the operator's own SSH session. * * Both mutating methods return array{ok: bool, output: string}. */ class FirewallService { - public function __construct(private FleetService $fleet) {} + public function __construct(private FleetService $fleet, private OsDetector $detector) {} /** - * GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never - * enable a firewall that would drop the current SSH session. Installs ufw if missing. + * GUARD: allow the real sshd Port + 80/443 first, THEN enable the firewall. + * Never enable a firewall that would drop the current SSH session. Installs the + * firewall package first when supported and missing. * * @return array{ok: bool, output: string} */ public function enable(Server $server): array { - // long timeout: may apt-install ufw first. - $res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600); + $os = $this->detector->detect($server); + if ($reason = $os->supports('firewall')) { + 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']]; } - /** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */ + /** Turn the firewall off (clean stop, never a panic/drop-all). @return array{ok: bool, output: string} */ public function disable(Server $server): array { - $res = $this->fleet->runPrivileged($server, 'ufw disable'); + $os = $this->detector->detect($server); + $res = $this->fleet->runPrivileged($server, FirewallTool::for($os)->disableScript()); return ['ok' => $res['ok'], 'output' => $res['output']]; } - /** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */ - private function enableScript(int $port): string - { - return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && ' - ."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable"; - } - /** * Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls * back to 22 when nothing is set explicitly. diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index afd0d99..ef5c720 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -3,17 +3,24 @@ namespace App\Services; 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 RuntimeException; use Throwable; /** - * Applies the dashboard server-hardening toggles over SSH (as root). Every item is - * BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying feature - * on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand. + * Applies the dashboard server-hardening toggles over SSH (as root), OS-aware + * across apt/dnf/zypper + ufw/firewalld (resolved per host via OsDetector). Every + * item is BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying + * feature; package items (fail2ban, auto-updates, firewall) install on demand. * * apply() runs the exact command built by commandFor() (the single source of truth); * 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. */ class HardeningService @@ -24,43 +31,56 @@ class HardeningService // 99zz- so Clusev's apt periodic setting wins (apt uses the LAST value across apt.conf.d/*). private const APT_DROPIN = '/etc/apt/apt.conf.d/99zz-clusev'; - public function __construct(private FleetService $fleet, private FirewallService $firewall) {} + public function __construct( + private FleetService $fleet, + private FirewallService $firewall, + private OsDetector $detector, + ) {} /** * Live state of every hardening item, read PRIVILEGED in one shot. SSH uses - * `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks); - * UFW uses `ufw status`; unattended uses the apt periodic config. + * `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks); the + * package/firewall/auto-update probes are built per-OS (dpkg vs rpm, ufw vs + * firewalld, apt periodic vs dnf-automatic). * - * @return array + * @return 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) "; ' - .'echo "fail2ban $(dpkg -l fail2ban 2>/dev/null | grep -c "^ii") $(systemctl is-active fail2ban 2>/dev/null)"; ' - .'ufw_inst=$(dpkg -l ufw 2>/dev/null | grep -c "^ii"); ufw_act=inactive; ' - .'[ "$ufw_inst" = "1" ] && ufw status 2>/dev/null | grep -qi "Status: active" && ufw_act=active; ' - .'echo "ufw $ufw_inst $ufw_act"; ' - .'ui=$(dpkg -l unattended-upgrades 2>/dev/null | grep -c "^ii"); ua=inactive; ' - // apt-config dump = the EFFECTIVE periodic value across all apt.conf.d files (last wins). - .'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\' && ua=active; ' - .'echo "unattended-upgrades $ui $ua"'; + .'echo "fail2ban $('.$f2bInst.' && echo 1 || echo 0) $(systemctl is-active --quiet fail2ban && echo active || echo inactive)"; ' + .'echo "firewall $('.$fw->installedTest().' && echo 1 || echo 0) $('.$fw->activeTest().' && echo active || echo inactive)"; ' + .'echo "autoupdate $('.$auInst.' && echo 1 || echo 0) $('.$auActive.' && echo active || echo inactive)"'; $res = $this->fleet->runPrivileged($server, $cmd); if (! $res['ok']) { throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.'); } - return $this->parseState($res['output']); + return $this->parseState($res['output'], $os); } - /** @return array */ - private function parseState(string $out): array + /** @return array */ + private function parseState(string $out, OsProfile $os): array { $root = 'default'; $pwauth = 'default'; $pkg = []; - foreach (preg_split('/\R/', $out) as $line) { + foreach (preg_split('/\R/', $out) ?: [] as $line) { $line = trim($line); if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) { $root = strtolower($m[1]); @@ -68,34 +88,38 @@ class HardeningService $pwauth = strtolower($m[1]); } else { $p = preg_split('/\s+/', $line); - if (count($p) === 3 && in_array($p[0], ['fail2ban', 'ufw', 'unattended-upgrades'], true)) { + if (count($p) === 3 && in_array($p[0], ['fail2ban', 'firewall', 'autoupdate'], true)) { $pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active']; } } } $f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false]; - $ufw = $pkg['ufw'] ?? ['installed' => false, 'active' => false]; - $upg = $pkg['unattended-upgrades'] ?? ['installed' => false, 'active' => false]; + $fw = $pkg['firewall'] ?? ['installed' => false, 'active' => false]; + $upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false]; $detail = fn (string $name, array $st): string => $name.' · ' .(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv')); - // A package feature is only effectively ON when the package is installed AND active — + // A package feature is only effectively ON when installed AND active — // stale config (e.g. apt periodic = 1 after removal) must not read as secure. $on = fn (array $st): bool => $st['installed'] && $st['active']; + $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. return [ - ['key' => 'ssh_root', 'label' => 'SSH-Root-Login', 'detail' => 'PermitRootLogin '.$root, - 'featureOn' => $root !== 'no', 'secure' => $root === 'no'], - ['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication '.$pwauth, - 'featureOn' => $pwauth !== 'no', 'secure' => $pwauth === 'no'], - ['key' => 'fail2ban', 'label' => 'fail2ban', 'detail' => $detail('fail2ban.service', $f2b), - 'featureOn' => $on($f2b), 'secure' => $on($f2b)], - ['key' => 'firewall', 'label' => 'Firewall (UFW)', 'detail' => $detail('ufw', $ufw), - 'featureOn' => $on($ufw), 'secure' => $on($ufw)], - ['key' => 'unattended', 'label' => 'Automatische Updates', 'detail' => $detail('unattended-upgrades', $upg), - 'featureOn' => $on($upg), 'secure' => $on($upg)], + $row('ssh_root', 'SSH-Root-Login', 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')), + $row('ssh_password', 'SSH-Passwort-Login', 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')), + $row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')), + $row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')), + $row('unattended', 'Automatische Updates', $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')), ]; } @@ -106,7 +130,7 @@ class HardeningService 'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren', 'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren', 'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren', - 'firewall' => $enable ? 'Firewall (UFW) aktivieren' : 'Firewall (UFW) deaktivieren', + 'firewall' => $enable ? 'Firewall aktivieren' : 'Firewall deaktivieren', 'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren', default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), }; @@ -125,11 +149,11 @@ class HardeningService ? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.' : 'Stoppt fail2ban und deaktiviert den Autostart.', 'firewall' => $enable - ? 'Installiert UFW (falls nötig), gibt SSH + 80/443 frei und aktiviert die Firewall.' - : 'Deaktiviert die UFW-Firewall (ufw disable).', + ? 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.' + : 'Deaktiviert die Firewall des Servers.', 'unattended' => $enable - ? 'Installiert unattended-upgrades (falls nötig) und aktiviert die automatische Update-Periodik + Timer.' - : 'Schaltet die automatische Update-Periodik ab.', + ? 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.' + : 'Schaltet die automatischen Updates ab.', default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), }; } @@ -137,6 +161,20 @@ class HardeningService /** @return array{ok: bool, output: string} */ 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') { $res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server); @@ -153,32 +191,49 @@ class HardeningService } } - // apt operations need a long timeout; the 12s default would abort the install. + // package operations need a long timeout; the 12s default would abort the install. $timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60; - $res = $this->fleet->runPrivileged($server, $this->commandFor($action, $enable), $timeout); + $res = $this->fleet->runPrivileged($server, $this->commandFor($os, $action, $enable), $timeout); return ['ok' => $res['ok'], 'output' => $res['output']]; } /** Single source of truth for the shell command of a (non-firewall) toggle. */ - private function commandFor(string $action, bool $enable): string + private function commandFor(OsProfile $os, string $action, bool $enable): string { + $pm = PackageManager::for($os); + return match ($action) { 'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')), 'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')), 'fail2ban' => $enable - ? '(dpkg -l fail2ban 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban) && systemctl enable --now fail2ban' + ? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban' : 'systemctl disable --now fail2ban', - 'unattended' => $enable - ? '(dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades)' - .' && printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "1";\' \'APT::Periodic::Unattended-Upgrade "1";\' > '.self::APT_DROPIN - .' && systemctl enable --now unattended-upgrades apt-daily-upgrade.timer apt-daily.timer' - : 'printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "0";\' \'APT::Periodic::Unattended-Upgrade "0";\' > '.self::APT_DROPIN - .' && (systemctl disable --now unattended-upgrades 2>/dev/null || true)', + 'unattended' => $this->autoUpdateScript($os, $pm, $enable), 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. */ private function hasAuthorizedKey(Server $server): bool { diff --git a/app/Services/MaintenanceService.php b/app/Services/MaintenanceService.php index 3b8f9e0..85e40da 100644 --- a/app/Services/MaintenanceService.php +++ b/app/Services/MaintenanceService.php @@ -3,11 +3,14 @@ namespace App\Services; use App\Models\Server; +use App\Support\Os\OsDetector; +use App\Support\Os\PackageManager; use RuntimeException; /** - * Debian/Ubuntu (apt) maintenance helpers: counts pending package updates, - * applies an apt upgrade, and reads/writes the fail2ban [DEFAULT] tuning. + * Package-maintenance helpers across apt/dnf/zypper (resolved per OS via + * OsDetector): counts pending package updates, applies an upgrade, and reads/writes + * the fail2ban [DEFAULT] tuning. * * Every reader/writer goes through FleetService, so quoting is base64-safe and * privileged work runs as root. Callers must clamp the fail2ban integers before @@ -20,44 +23,53 @@ class MaintenanceService // operator's jail.local or jail definitions. private const FAIL2BAN_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local'; - public function __construct(private FleetService $fleet) {} + public function __construct(private FleetService $fleet, private OsDetector $detector) {} - /** True if apt-get exists on the target (only then are the update features meaningful). */ - public function hasApt(Server $server): bool + /** + * NULL when Clusev can manage package updates on this host, otherwise a German + * reason (e.g. unsupported package manager) for the operator. + */ + public function updateSupport(Server $server): ?string { - $res = $this->fleet->runPlain($server, 'command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0'); - - return $res['ok'] && trim($res['output']) === '1'; + return $this->detector->detect($server)->supports('updates'); } /** - * Number of pending package upgrades via a dry-run simulation (no root needed), - * or NULL when the simulation could not run (apt absent / errored) — so the caller - * can show "unbekannt" rather than a misleading "0 / up to date". + * Number of pending package upgrades via a dry-run/metadata read (no root + * needed), or NULL when it could not run (unsupported manager / errored) — so + * the caller shows "unbekannt" rather than a misleading "0 / up to date". The + * remote script emits a CLUSEV_OK sentinel only when the query actually ran. */ public function pendingUpdates(Server $server): ?int { - $res = $this->fleet->runPlain($server, 'apt-get -s upgrade 2>/dev/null'); - if (! $res['ok']) { + $os = $this->detector->detect($server); + if (! $os->managesPackages()) { return null; } - return (int) preg_match_all('/^Inst /m', $res['output']); + $res = $this->fleet->runPlain($server, PackageManager::for($os)->pendingScript()); + if (! str_contains($res['output'], 'CLUSEV_OK')) { + return null; + } + + return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null; } /** - * apt-get update && upgrade as root. Long-running, so a 900s timeout. The - * returned output is trimmed to the last ~400 chars for a compact result panel. + * Refresh the index and apply all upgrades as root, using the host's package + * manager. Long-running, so a 900s timeout. Output trimmed to the last ~400 + * chars for a compact result panel. * * @return array{ok: bool, output: string} */ - public function aptUpgrade(Server $server): array + public function applyUpgrades(Server $server): array { - $res = $this->fleet->runPrivileged( - $server, - 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade', - 900, - ); + $os = $this->detector->detect($server); + if ($reason = $os->supports('updates')) { + return ['ok' => false, 'output' => $reason]; + } + + $res = $this->fleet->runPrivileged($server, PackageManager::for($os)->applyScript(), 900); return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)]; } diff --git a/app/Support/Os/FirewallTool.php b/app/Support/Os/FirewallTool.php new file mode 100644 index 0000000..73cedcb --- /dev/null +++ b/app/Support/Os/FirewallTool.php @@ -0,0 +1,87 @@ +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"'; + } +} diff --git a/app/Support/Os/OsDetector.php b/app/Support/Os/OsDetector.php new file mode 100644 index 0000000..e790e23 --- /dev/null +++ b/app/Support/Os/OsDetector.php @@ -0,0 +1,186 @@ +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 $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 $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 $available + * @param list $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', + }; + } +} diff --git a/app/Support/Os/OsProfile.php b/app/Support/Os/OsProfile.php new file mode 100644 index 0000000..6e0b2bc --- /dev/null +++ b/app/Support/Os/OsProfile.php @@ -0,0 +1,106 @@ + $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, + }; + } +} diff --git a/app/Support/Os/PackageManager.php b/app/Support/Os/PackageManager.php new file mode 100644 index 0000000..554d6e5 --- /dev/null +++ b/app/Support/Os/PackageManager.php @@ -0,0 +1,94 @@ + 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=`. 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).')'; + } +} diff --git a/resources/views/livewire/modals/system-update.blade.php b/resources/views/livewire/modals/system-update.blade.php index 8678666..09c8ab5 100644 --- a/resources/views/livewire/modals/system-update.blade.php +++ b/resources/views/livewire/modals/system-update.blade.php @@ -17,10 +17,10 @@

{{ $error }}

- @elseif (! $hasApt) + @elseif (! $supported)
-

Nur für Debian/Ubuntu (apt) verfügbar.

+

{{ $unsupportedReason ?: 'Auf diesem System nicht verfügbar.' }}

@else
@@ -78,7 +78,7 @@ Schließen @else Abbrechen - @if (! $error && $hasApt) + @if (! $error && $supported) diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 8b16e63..4beec97 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -20,6 +20,15 @@ ['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 = [ ['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' : '—'], @@ -99,7 +108,7 @@
-

{{ $cred->name ?: '—' }}

+

{{ $cred->name ?: $cred->username }}

{{ $credStatusLabel }} {{ $credAuthLabel }}
@@ -169,37 +178,46 @@
@foreach ($hardening as $check) - {{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen). --}} + @php $supported = $check['supported'] ?? true; @endphp + {{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen); + unsupported items render muted with a German reason and no toggle. --}}
$check['secure'], - 'border-warning/25 bg-warning/10 text-warning' => ! $check['secure'], + 'border-online/25 bg-online/10 text-online' => $supported && $check['secure'], + 'border-warning/25 bg-warning/10 text-warning' => $supported && ! $check['secure'], + 'border-line bg-raised text-ink-4' => ! $supported, ])> - +

{{ $check['label'] }} - $check['secure'], - 'text-warning' => ! $check['secure'], - ])>{{ $check['secure'] ? 'sicher' : 'offen' }} + @if ($supported) + $check['secure'], + 'text-warning' => ! $check['secure'], + ])>{{ $check['secure'] ? 'sicher' : 'offen' }} + @else + n. v. + @endif

-

{{ $check['detail'] }}

+

{{ $supported ? $check['detail'] : $check['reason'] }}

- @if ($check['key'] === 'fail2ban') - - + @if ($supported) + @if ($check['key'] === 'fail2ban') + + + + @endif + {{-- Toggle: secondary to make secure, quiet ghost to loosen. --}} + + {{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }} @endif - {{-- Toggle: secondary to make secure, quiet ghost to loosen. --}} - - {{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }} -
@endforeach