clusev/app/Services/MaintenanceService.php

195 lines
8.0 KiB
PHP

<?php
namespace App\Services;
use App\Models\Server;
use App\Support\Os\OsDetector;
use App\Support\Os\PackageManager;
use RuntimeException;
/**
* 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
* calling writeFail2ban(); this service only persists already-validated values.
*/
class MaintenanceService
{
// A Clusev-owned drop-in. 'zz-' so it sorts LAST in jail.d and its [DEFAULT] wins
// (fail2ban applies the last value). We only ever write/read THIS file — never the
// operator's jail.local or jail definitions.
private const FAIL2BAN_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
/**
* 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
{
return $this->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);
}
}