clusev/app/Services/MaintenanceService.php

183 lines
7.5 KiB
PHP

<?php
namespace App\Services;
use App\Models\Server;
use RuntimeException;
/**
* Debian/Ubuntu (apt) maintenance helpers: counts pending package updates,
* applies an apt 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) {}
/** True if apt-get exists on the target (only then are the update features meaningful). */
public function hasApt(Server $server): bool
{
$res = $this->fleet->runPlain($server, 'command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0');
return $res['ok'] && trim($res['output']) === '1';
}
/**
* 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".
*/
public function pendingUpdates(Server $server): ?int
{
$res = $this->fleet->runPlain($server, 'apt-get -s upgrade 2>/dev/null');
if (! $res['ok']) {
return null;
}
return (int) preg_match_all('/^Inst /m', $res['output']);
}
/**
* 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.
*
* @return array{ok: bool, output: string}
*/
public function aptUpgrade(Server $server): array
{
$res = $this->fleet->runPrivileged(
$server,
'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade',
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);
}
}