*/ 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. // "Installed" is read CONSISTENTLY with Fail2banService/FirewallTool: the package // manager (dpkg ^ii / rpm), OR the client binary on PATH, OR the service already // running — ANY one proves the component is present. So a fail2ban/ufw installed // from source or left in dpkg `rc` (config-only) state no longer reads as "nicht // installiert" while its own live panel reports it active. A component can never be // active without being installed, so this only removes FALSE negatives. $anyOf = fn (array $tests): string => '('.implode(' || ', array_filter($tests)).')'; $f2bActive = 'systemctl is-active --quiet fail2ban'; $f2bInst = $anyOf([ $pm?->isInstalledTest('fail2ban'), 'command -v fail2ban-client >/dev/null 2>&1', $f2bActive, ]); $fwInst = $anyOf([ $pm?->isInstalledTest($os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw'), $fw->installedTest(), $fw->activeTest(), ]); $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', }; // NOTE the space after each `$(` that precedes a `(`: `$( (` is a command // substitution of a subshell, whereas `$((` would be ARITHMETIC expansion. $cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; ' .'echo "fail2ban $( '.$f2bInst.' && echo 1 || echo 0) $('.$f2bActive.' && echo active || echo inactive)"; ' .'echo "firewall $( '.$fwInst.' && 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(__('backend.hardening_status_read_failed')); } return $this->parseState($res['output'], $os); } /** @return array */ private function parseState(string $out, OsProfile $os): array { $root = 'default'; $pwauth = 'default'; $pkg = []; foreach (preg_split('/\R/', $out) ?: [] as $line) { $line = trim($line); if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) { $root = strtolower($m[1]); } elseif (preg_match('/^passwordauthentication\s+(\S+)/i', $line, $m)) { $pwauth = strtolower($m[1]); } else { $p = preg_split('/\s+/', $line); if (count($p) === 3 && in_array($p[0], ['fail2ban', 'firewall', 'autoupdate'], true)) { $pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active']; } } } $f2b = $pkg['fail2ban'] ?? ['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'] ? __('backend.item_not_installed') : ($st['active'] ? __('common.active') : __('common.inactive'))); // 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, bool $neutral = false): array => [ 'key' => $key, 'label' => $label, 'detail' => $detail, 'featureOn' => $featureOn, 'secure' => $secure, 'supported' => $reason === null, 'reason' => $reason, 'neutral' => $neutral, ]; $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 [ $row('ssh_root', __('backend.label_ssh_root'), 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')), $row('ssh_password', __('backend.label_ssh_password'), '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')), // Auto-updates are an operator PREFERENCE, not a security gate: never an // insecure verdict (secure = true), and flagged `neutral` so the UI shows a // muted aktiv/inaktiv state instead of SICHER/OFFEN. featureOn still drives // the Aktivieren/Deaktivieren toggle from the real host state. $row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), true, $os->supports('auto_updates'), true), ]; } /** Modal title, reflecting the toggle direction. */ public function title(string $action, bool $enable): string { return match ($action) { 'ssh_root' => $enable ? __('backend.title_ssh_root_enable') : __('backend.title_ssh_root_disable'), 'ssh_password' => $enable ? __('backend.title_ssh_password_enable') : __('backend.title_ssh_password_disable'), 'fail2ban' => $enable ? __('backend.title_fail2ban_enable') : __('backend.title_fail2ban_disable'), 'firewall' => $enable ? __('backend.title_firewall_enable') : __('backend.title_firewall_disable'), 'unattended' => $enable ? __('backend.title_unattended_enable') : __('backend.title_unattended_disable'), default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), }; } public function description(string $action, bool $enable): string { return match ($action) { 'ssh_root' => $enable ? __('backend.desc_ssh_root_enable') : __('backend.desc_ssh_root_disable'), 'ssh_password' => $enable ? __('backend.desc_ssh_password_enable') : __('backend.desc_ssh_password_disable'), 'fail2ban' => $enable ? __('backend.desc_fail2ban_enable') : __('backend.desc_fail2ban_disable'), 'firewall' => $enable ? __('backend.desc_firewall_enable') : __('backend.desc_firewall_disable'), 'unattended' => $enable ? __('backend.desc_unattended_enable') : __('backend.desc_unattended_disable'), default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), }; } /** @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(__('backend.unknown_hardening', ['action' => $action])), }; if ($reason = $os->supports($feature)) { return ['ok' => false, 'output' => $reason]; } if ($action === 'firewall') { $res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server); return ['ok' => $res['ok'], 'output' => $res['output']]; } // Lock-out guard: disabling root login (PermitRootLogin no) severs Clusev's OWN access when // it connects AS root — no key or password can reach root afterwards. Refuse until Clusev is // switched to a non-root credential (a sudo user with a key), then root login can be disabled. if ($action === 'ssh_root' && ! $enable && $server->credential?->username === 'root') { return ['ok' => false, 'output' => __('backend.ssh_root_self_lockout')]; } // Lock-out guard: never disable password auth without a usable key path. if ($action === 'ssh_password' && ! $enable) { if ($server->credential?->auth_type === 'password') { return ['ok' => false, 'output' => __('backend.ssh_password_self_lockout')]; } if (! $this->hasAuthorizedKey($server)) { return ['ok' => false, 'output' => __('backend.ssh_password_no_key')]; } } // 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($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(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 ? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban' : 'systemctl disable --now fail2ban', 'unattended' => $this->autoUpdateScript($os, $pm, $enable), default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $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 { try { if (count($this->fleet->sshKeys($server)) > 0) { return true; } } catch (Throwable) { // fall through to the raw check } $res = $this->fleet->runPlain($server, 'grep -cE "^(ssh-|ecdsa-|sk-)" ~/.ssh/authorized_keys 2>/dev/null || echo 0'); return $res['ok'] && (int) trim($res['output']) > 0; } /** * Install one sshd directive into the Clusev drop-in (replacing any prior value * of the same key), then reload whichever ssh unit exists. */ private function sshDropInScript(string $directive): string { $key = strtok($directive, ' '); return 'mkdir -p /etc/ssh/sshd_config.d' .' && touch '.self::SSHD_DROPIN .' && grep -viE "^[[:space:]]*'.$key.'[[:space:]]" '.self::SSHD_DROPIN.' > '.self::SSHD_DROPIN.'.tmp 2>/dev/null; ' .'mv '.self::SSHD_DROPIN.'.tmp '.self::SSHD_DROPIN.' 2>/dev/null; ' .'echo "'.$directive.'" >> '.self::SSHD_DROPIN .' && (systemctl reload ssh || systemctl reload sshd)'; } }