detector->detect($server)->supports('updates'); } /** * 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 { $os = $this->detector->detect($server); if (! $os->managesPackages()) { return null; } $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; } /** * 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 applyUpgrades(Server $server): array { $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)]; } /** * Read the Clusev-managed fail2ban [DEFAULT] tuning from our OWN drop-in only — * we never read the operator's jail.local or per-jail sections, so a jail's * `maxretry` can't masquerade as the global default. Defaults when unset. * * @return array{bantime: int, maxretry: int, findtime: int} */ public function readFail2ban(Server $server): array { // Read the [DEFAULT] section across all fail2ban files in fail2ban's own // precedence order (last value wins). A CLUSEV_RESET marker between files // stops a section bleeding across file boundaries; only [DEFAULT] keys count // (a jail's maxretry must not masquerade as the global default). // A leading sentinel proves the read actually RAN (sudo/SSH ok). We key success // off the sentinel — NOT the loop's exit code — so a final unmatched glob (a // benign "no drop-ins" case) does not look like a failure, while a genuine // sudo/permission/SSH error (no sentinel echoed) IS propagated as a throw and can // never be downgraded to defaults the operator might then overwrite. $cmd = 'echo CLUSEV_READ_OK; for f in /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/*.conf ' .'/etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.local; do ' .'[ -f "$f" ] && { echo "[CLUSEV_RESET]"; cat "$f"; }; done 2>/dev/null'; $res = $this->fleet->runPrivileged($server, $cmd); if (! str_contains($res['output'], 'CLUSEV_READ_OK')) { throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.'); } return $this->parseFail2banDefaults($res['output']); } /** * Section-aware parse of the effective [DEFAULT] tuning (last value wins), * converting fail2ban time units (s/m/h/d/w) to seconds. * * @return array{bantime: int, maxretry: int, findtime: int} */ private function parseFail2banDefaults(string $body): array { // bantime/findtime are kept VERBATIM — fail2ban's native duration grammar // (600, 10m, 1h 30m, -1 for permanent, …) is preserved, never lossily converted // to seconds. maxretry is a plain integer. $vals = ['bantime' => '10m', 'maxretry' => 5, 'findtime' => '10m']; $inDefault = false; foreach (preg_split('/\R/', $body) ?: [] as $line) { $t = trim($line); if ($t === '' || $t[0] === '#' || $t[0] === ';') { continue; } if (preg_match('/^\[([^\]]+)\]/', $t, $m)) { $inDefault = strcasecmp(trim($m[1]), 'DEFAULT') === 0; continue; } if (! $inDefault) { continue; } if (preg_match('/^bantime\s*=\s*(.+?)\s*$/i', $t, $m)) { $vals['bantime'] = $m[1]; } elseif (preg_match('/^findtime\s*=\s*(.+?)\s*$/i', $t, $m)) { $vals['findtime'] = $m[1]; } elseif (preg_match('/^maxretry\s*=\s*(\d+)/i', $t, $m)) { $vals['maxretry'] = (int) $m[1]; } } return $vals; } /** * Write the Clusev [DEFAULT] block to our OWN jail.d drop-in (overwriting only * that file — the operator's jail.local and jail definitions are untouched), * then reload (fallback restart) fail2ban. The integers MUST already be clamped * by the caller; cast here as a last line of defence. * * @return array{ok: bool, output: string} */ public function writeFail2ban(Server $server, string $bantime, int $maxretry, string $findtime): array { $maxretry = (int) $maxretry; // Durations stay in fail2ban's native grammar (validated by the caller); collapse // any stray whitespace/newlines so the written ini line stays well-formed. $bantime = trim(preg_replace('/\s+/', ' ', $bantime) ?? ''); $findtime = trim(preg_replace('/\s+/', ' ', $findtime) ?? ''); // Build the config with base64 so it never touches the shell unquoted; the // whole command is itself base64-wrapped again by runPrivileged. $content = "# Managed by Clusev — do not edit by hand.\n" ."[DEFAULT]\n" ."bantime = {$bantime}\n" ."findtime = {$findtime}\n" ."maxretry = {$maxretry}\n"; $b64 = base64_encode($content); // Write the drop-in; reload ONLY if fail2ban is already running (never start an // inactive service from the settings form). A reload failure while active IS // propagated (the `if` returns reload's exit code); inactive succeeds silently. $cmd = 'mkdir -p /etc/fail2ban/jail.d' .' && printf %s '.$b64.' | base64 -d > '.self::FAIL2BAN_DROPIN .' && if systemctl is-active --quiet fail2ban; then systemctl reload fail2ban; fi'; $res = $this->fleet->runPrivileged($server, $cmd, 120); return ['ok' => $res['ok'], 'output' => $this->tail($res['output'], 400)]; } /** Keep only the last $max characters of a (multi-line) output. */ private function tail(string $out, int $max): string { $out = trim($out); if (mb_strlen($out) <= $max) { return $out; } return '…'.mb_substr($out, -$max); } }