195 lines
8.0 KiB
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);
|
|
}
|
|
}
|