182 lines
7.5 KiB
PHP
182 lines
7.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Server;
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|