feat(os): OS abstraction — manage updates/hardening/firewall across apt, dnf & zypper
Foundation so Clusev no longer assumes Debian/apt/ufw/systemd. A host's OS is resolved once (cached) and every package/firewall/service decision asks it. New app/Support/Os/: - OsProfile — family + package manager + firewall tool + service manager, plus supports(feature) returning NULL or a German reason (graceful degradation). - OsDetector — one unprivileged probe (/etc/os-release + `command -v` with sbin on PATH + which firewall is RUNNING); family from ID/ID_LIKE cross-checked against installed binaries; cached 1h (60s when nothing detected). Readability-guards the `.` source so a host without /etc/os-release still falls back by package manager. - PackageManager — apt/dnf/zypper command strings (pending count, apply, install, is-installed); zypper exit 102/103 normalized to success. - FirewallTool — ufw + firewalld enable/disable. firewalld opens ssh/80/443 in the PERMANENT config BEFORE the daemon starts filtering (handles running + stopped), preserving the no-lockout guard for custom SSH ports. Integration: - FirewallService enable/disable now OS-aware (ufw or firewalld) with a support gate. - MaintenanceService: hasApt -> updateSupport(); pendingUpdates() + applyUpgrades() across managers (dnf check-update exit 100 handled). - HardeningService: state()/commandFor() per-OS (dpkg vs rpm, ufw vs firewalld, apt periodic vs dnf-automatic; yum-only hosts gate auto-updates). Each row carries supported/reason; unsupported features render muted with a German note instead of a toggle. apply() refuses unsupported features gracefully. - Server-Details surfaces the detected System / package manager / firewall. - SystemUpdate modal + view: OS-neutral copy; audit action system.package_upgrade. Arch (pacman) & Alpine (apk/OpenRC) are detected and gracefully disabled where unsupported. Debian/ufw/systemd path verified behaviourally identical on the live fleet. Pint clean; Codex review clean (fixed firewalld lockout + os-release guard + yum/dual-firewall/zypper edge cases); R12 — 9 routes 200, 0 console errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
0f57074e6f
commit
4bfa367ccf
|
|
@ -10,10 +10,11 @@ use LivewireUI\Modal\ModalComponent;
|
|||
use Throwable;
|
||||
|
||||
/**
|
||||
* Apply pending apt package updates (Debian/Ubuntu only). mount() reads whether
|
||||
* apt exists + the pending count; the body explains in GERMAN what happens — never
|
||||
* a shell command. „Jetzt aktualisieren" runs the upgrade, AUDITs, notifies, and
|
||||
* keeps the modal open showing a clean German result.
|
||||
* Apply pending package updates across apt/dnf/zypper (resolved per OS). mount()
|
||||
* reads whether updates are supported on this host + the pending count; the body
|
||||
* explains in GERMAN what happens — never a shell command. „Jetzt aktualisieren"
|
||||
* runs the upgrade, AUDITs, notifies, and keeps the modal open showing a clean
|
||||
* German result. Unsupported hosts (e.g. Arch/Alpine) get a German reason instead.
|
||||
*/
|
||||
class SystemUpdate extends ModalComponent
|
||||
{
|
||||
|
|
@ -21,7 +22,11 @@ class SystemUpdate extends ModalComponent
|
|||
|
||||
public string $serverName = '';
|
||||
|
||||
public bool $hasApt = false;
|
||||
/** True when Clusev can manage package updates on this host. */
|
||||
public bool $supported = false;
|
||||
|
||||
/** German reason shown when updates are not supported on this OS. */
|
||||
public ?string $unsupportedReason = null;
|
||||
|
||||
/** Pending upgrade count, or null when it could not be determined ("unbekannt"). */
|
||||
public ?int $pending = null;
|
||||
|
|
@ -30,7 +35,7 @@ class SystemUpdate extends ModalComponent
|
|||
|
||||
public bool $ok = false;
|
||||
|
||||
/** Trimmed apt output, only shown when the upgrade failed. */
|
||||
/** Trimmed package-manager output, only shown when the upgrade failed. */
|
||||
public string $output = '';
|
||||
|
||||
public ?string $error = null;
|
||||
|
|
@ -49,8 +54,9 @@ class SystemUpdate extends ModalComponent
|
|||
$this->serverName = $server->name;
|
||||
|
||||
try {
|
||||
$this->hasApt = $maintenance->hasApt($server);
|
||||
$this->pending = $this->hasApt ? $maintenance->pendingUpdates($server) : null;
|
||||
$this->unsupportedReason = $maintenance->updateSupport($server);
|
||||
$this->supported = $this->unsupportedReason === null;
|
||||
$this->pending = $this->supported ? $maintenance->pendingUpdates($server) : null;
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
|
|
@ -63,7 +69,7 @@ class SystemUpdate extends ModalComponent
|
|||
|
||||
public function upgrade(MaintenanceService $maintenance): void
|
||||
{
|
||||
if ($this->error !== null || $this->done || ! $this->hasApt) {
|
||||
if ($this->error !== null || $this->done || ! $this->supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +81,7 @@ class SystemUpdate extends ModalComponent
|
|||
}
|
||||
|
||||
try {
|
||||
$result = $maintenance->aptUpgrade($server);
|
||||
$result = $maintenance->applyUpgrades($server);
|
||||
} catch (Throwable $e) {
|
||||
$this->done = true;
|
||||
$this->ok = false;
|
||||
|
|
@ -95,7 +101,7 @@ class SystemUpdate extends ModalComponent
|
|||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'system.apt_upgrade',
|
||||
'action' => 'system.package_upgrade',
|
||||
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Models\AuditEvent;
|
|||
use App\Models\Server;
|
||||
use App\Services\FleetService;
|
||||
use App\Services\HardeningService;
|
||||
use App\Support\Os\OsDetector;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
|
|
@ -32,6 +33,9 @@ class Show extends Component
|
|||
|
||||
public array $hardening = [];
|
||||
|
||||
/** Detected OS tooling (family/package manager/firewall) for the identity panel. */
|
||||
public array $os = [];
|
||||
|
||||
public array $sshKeys = [];
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +80,22 @@ class Show extends Component
|
|||
$this->interfaces = $snap['interfaces'];
|
||||
$this->sshKeys = $snap['sshKeys'];
|
||||
|
||||
// Detected OS profile (cached) — surfaces the package manager + firewall
|
||||
// tooling and drives which hardening features are offered.
|
||||
try {
|
||||
// fresh probe on each full load; primes the 1h cache the hardening,
|
||||
// firewall and update reads reuse within this request cycle.
|
||||
$profile = app(OsDetector::class)->detect($this->server, fresh: true);
|
||||
$this->os = [
|
||||
'familyLabel' => $profile->familyLabel(),
|
||||
'packageManager' => $profile->packageManager,
|
||||
'firewallTool' => $profile->firewallTool,
|
||||
'serviceManager' => $profile->serviceManager,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
$this->os = [];
|
||||
}
|
||||
|
||||
// Hardening state is a separate PRIVILEGED read (sshd -T + pkg checks).
|
||||
try {
|
||||
$this->hardening = app(HardeningService::class)->state($this->server);
|
||||
|
|
|
|||
|
|
@ -3,49 +3,53 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\Os\FirewallTool;
|
||||
use App\Support\Os\OsDetector;
|
||||
|
||||
/**
|
||||
* UFW firewall control over SSH (as root).
|
||||
* Host firewall control over SSH (as root), abstracted across ufw and firewalld
|
||||
* (resolved per OS via OsDetector/FirewallTool).
|
||||
*
|
||||
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd
|
||||
* Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the
|
||||
* firewall on — so enabling UFW can never sever the operator's own SSH session.
|
||||
* Safety model: "guards + confirmation". enable() ALWAYS opens the real sshd Port
|
||||
* (detected from sshd_config, default 22) plus 80/443 BEFORE turning the firewall
|
||||
* on — so enabling can never sever the operator's own SSH session.
|
||||
*
|
||||
* Both mutating methods return array{ok: bool, output: string}.
|
||||
*/
|
||||
class FirewallService
|
||||
{
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
||||
|
||||
/**
|
||||
* GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never
|
||||
* enable a firewall that would drop the current SSH session. Installs ufw if missing.
|
||||
* GUARD: allow the real sshd Port + 80/443 first, THEN enable the firewall.
|
||||
* Never enable a firewall that would drop the current SSH session. Installs the
|
||||
* firewall package first when supported and missing.
|
||||
*
|
||||
* @return array{ok: bool, output: string}
|
||||
*/
|
||||
public function enable(Server $server): array
|
||||
{
|
||||
// long timeout: may apt-install ufw first.
|
||||
$res = $this->fleet->runPrivileged($server, $this->enableScript($this->sshPort($server)), 600);
|
||||
$os = $this->detector->detect($server);
|
||||
if ($reason = $os->supports('firewall')) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
|
||||
// long timeout: may install the firewall package first.
|
||||
$script = FirewallTool::for($os)->enableScript($this->sshPort($server));
|
||||
$res = $this->fleet->runPrivileged($server, $script, 600);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Turn the firewall off (ufw disable). @return array{ok: bool, output: string} */
|
||||
/** Turn the firewall off (clean stop, never a panic/drop-all). @return array{ok: bool, output: string} */
|
||||
public function disable(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged($server, 'ufw disable');
|
||||
$os = $this->detector->detect($server);
|
||||
$res = $this->fleet->runPrivileged($server, FirewallTool::for($os)->disableScript());
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */
|
||||
private function enableScript(int $port): string
|
||||
{
|
||||
return '(command -v ufw >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y ufw) && '
|
||||
."ufw allow {$port}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the listening sshd Port from sshd_config (incl. drop-ins). Falls
|
||||
* back to 22 when nothing is set explicitly.
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\Os\FirewallTool;
|
||||
use App\Support\Os\OsDetector;
|
||||
use App\Support\Os\OsProfile;
|
||||
use App\Support\Os\PackageManager;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Applies the dashboard server-hardening toggles over SSH (as root). Every item is
|
||||
* BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying feature
|
||||
* on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand.
|
||||
* Applies the dashboard server-hardening toggles over SSH (as root), OS-aware
|
||||
* across apt/dnf/zypper + ufw/firewalld (resolved per host via OsDetector). Every
|
||||
* item is BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying
|
||||
* feature; package items (fail2ban, auto-updates, firewall) install on demand.
|
||||
*
|
||||
* apply() runs the exact command built by commandFor() (the single source of truth);
|
||||
* title()/description() supply the German confirm-modal copy for each direction.
|
||||
* Each state() row carries `supported`/`reason`, so a feature unavailable on the
|
||||
* host's OS degrades with a German explanation instead of failing opaquely.
|
||||
* Disabling SSH password auth is GUARDED so the operator can never lock themselves out.
|
||||
*/
|
||||
class HardeningService
|
||||
|
|
@ -24,43 +31,56 @@ class HardeningService
|
|||
// 99zz- so Clusev's apt periodic setting wins (apt uses the LAST value across apt.conf.d/*).
|
||||
private const APT_DROPIN = '/etc/apt/apt.conf.d/99zz-clusev';
|
||||
|
||||
public function __construct(private FleetService $fleet, private FirewallService $firewall) {}
|
||||
public function __construct(
|
||||
private FleetService $fleet,
|
||||
private FirewallService $firewall,
|
||||
private OsDetector $detector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Live state of every hardening item, read PRIVILEGED in one shot. SSH uses
|
||||
* `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks);
|
||||
* UFW uses `ufw status`; unattended uses the apt periodic config.
|
||||
* `sshd -T` (the EFFECTIVE config — honours Include order + Match blocks); the
|
||||
* package/firewall/auto-update probes are built per-OS (dpkg vs rpm, ufw vs
|
||||
* firewalld, apt periodic vs dnf-automatic).
|
||||
*
|
||||
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}>
|
||||
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string}>
|
||||
*/
|
||||
public function state(Server $server): array
|
||||
{
|
||||
$os = $this->detector->detect($server);
|
||||
$pm = $os->managesPackages() ? PackageManager::for($os) : null;
|
||||
$fw = FirewallTool::for($os);
|
||||
|
||||
// Shell CONDITIONS (exit 0 = true); `false` where the host lacks the tool.
|
||||
$f2bInst = $pm ? $pm->isInstalledTest('fail2ban') : 'false';
|
||||
$auInst = $pm ? $pm->isInstalledTest($os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades') : 'false';
|
||||
$auActive = match ($os->packageManager) {
|
||||
'apt' => 'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\'',
|
||||
'dnf' => 'systemctl is-enabled --quiet dnf-automatic.timer',
|
||||
default => 'false',
|
||||
};
|
||||
|
||||
$cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; '
|
||||
.'echo "fail2ban $(dpkg -l fail2ban 2>/dev/null | grep -c "^ii") $(systemctl is-active fail2ban 2>/dev/null)"; '
|
||||
.'ufw_inst=$(dpkg -l ufw 2>/dev/null | grep -c "^ii"); ufw_act=inactive; '
|
||||
.'[ "$ufw_inst" = "1" ] && ufw status 2>/dev/null | grep -qi "Status: active" && ufw_act=active; '
|
||||
.'echo "ufw $ufw_inst $ufw_act"; '
|
||||
.'ui=$(dpkg -l unattended-upgrades 2>/dev/null | grep -c "^ii"); ua=inactive; '
|
||||
// apt-config dump = the EFFECTIVE periodic value across all apt.conf.d files (last wins).
|
||||
.'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\' && ua=active; '
|
||||
.'echo "unattended-upgrades $ui $ua"';
|
||||
.'echo "fail2ban $('.$f2bInst.' && echo 1 || echo 0) $(systemctl is-active --quiet fail2ban && echo active || echo inactive)"; '
|
||||
.'echo "firewall $('.$fw->installedTest().' && echo 1 || echo 0) $('.$fw->activeTest().' && echo active || echo inactive)"; '
|
||||
.'echo "autoupdate $('.$auInst.' && echo 1 || echo 0) $('.$auActive.' && echo active || echo inactive)"';
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! $res['ok']) {
|
||||
throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
return $this->parseState($res['output']);
|
||||
return $this->parseState($res['output'], $os);
|
||||
}
|
||||
|
||||
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool}> */
|
||||
private function parseState(string $out): array
|
||||
/** @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string}> */
|
||||
private function parseState(string $out, OsProfile $os): array
|
||||
{
|
||||
$root = 'default';
|
||||
$pwauth = 'default';
|
||||
$pkg = [];
|
||||
|
||||
foreach (preg_split('/\R/', $out) as $line) {
|
||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^permitrootlogin\s+(\S+)/i', $line, $m)) {
|
||||
$root = strtolower($m[1]);
|
||||
|
|
@ -68,34 +88,38 @@ class HardeningService
|
|||
$pwauth = strtolower($m[1]);
|
||||
} else {
|
||||
$p = preg_split('/\s+/', $line);
|
||||
if (count($p) === 3 && in_array($p[0], ['fail2ban', 'ufw', 'unattended-upgrades'], true)) {
|
||||
if (count($p) === 3 && in_array($p[0], ['fail2ban', 'firewall', 'autoupdate'], true)) {
|
||||
$pkg[$p[0]] = ['installed' => $p[1] === '1', 'active' => $p[2] === 'active'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$f2b = $pkg['fail2ban'] ?? ['installed' => false, 'active' => false];
|
||||
$ufw = $pkg['ufw'] ?? ['installed' => false, 'active' => false];
|
||||
$upg = $pkg['unattended-upgrades'] ?? ['installed' => false, 'active' => false];
|
||||
$fw = $pkg['firewall'] ?? ['installed' => false, 'active' => false];
|
||||
$upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false];
|
||||
|
||||
$detail = fn (string $name, array $st): string => $name.' · '
|
||||
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
|
||||
// A package feature is only effectively ON when the package is installed AND active —
|
||||
// A package feature is only effectively ON when installed AND active —
|
||||
// stale config (e.g. apt periodic = 1 after removal) must not read as secure.
|
||||
$on = fn (array $st): bool => $st['installed'] && $st['active'];
|
||||
|
||||
$row = fn (string $key, string $label, string $detail, bool $featureOn, bool $secure, ?string $reason): array => [
|
||||
'key' => $key, 'label' => $label, 'detail' => $detail,
|
||||
'featureOn' => $featureOn, 'secure' => $secure,
|
||||
'supported' => $reason === null, 'reason' => $reason,
|
||||
];
|
||||
|
||||
$fwName = $os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw';
|
||||
$auName = $os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades';
|
||||
|
||||
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
|
||||
return [
|
||||
['key' => 'ssh_root', 'label' => 'SSH-Root-Login', 'detail' => 'PermitRootLogin '.$root,
|
||||
'featureOn' => $root !== 'no', 'secure' => $root === 'no'],
|
||||
['key' => 'ssh_password', 'label' => 'SSH-Passwort-Login', 'detail' => 'PasswordAuthentication '.$pwauth,
|
||||
'featureOn' => $pwauth !== 'no', 'secure' => $pwauth === 'no'],
|
||||
['key' => 'fail2ban', 'label' => 'fail2ban', 'detail' => $detail('fail2ban.service', $f2b),
|
||||
'featureOn' => $on($f2b), 'secure' => $on($f2b)],
|
||||
['key' => 'firewall', 'label' => 'Firewall (UFW)', 'detail' => $detail('ufw', $ufw),
|
||||
'featureOn' => $on($ufw), 'secure' => $on($ufw)],
|
||||
['key' => 'unattended', 'label' => 'Automatische Updates', 'detail' => $detail('unattended-upgrades', $upg),
|
||||
'featureOn' => $on($upg), 'secure' => $on($upg)],
|
||||
$row('ssh_root', 'SSH-Root-Login', 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
|
||||
$row('ssh_password', 'SSH-Passwort-Login', 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
|
||||
$row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')),
|
||||
$row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')),
|
||||
$row('unattended', 'Automatische Updates', $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +130,7 @@ class HardeningService
|
|||
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
|
||||
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
|
||||
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
|
||||
'firewall' => $enable ? 'Firewall (UFW) aktivieren' : 'Firewall (UFW) deaktivieren',
|
||||
'firewall' => $enable ? 'Firewall aktivieren' : 'Firewall deaktivieren',
|
||||
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
|
|
@ -125,11 +149,11 @@ class HardeningService
|
|||
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
|
||||
: 'Stoppt fail2ban und deaktiviert den Autostart.',
|
||||
'firewall' => $enable
|
||||
? 'Installiert UFW (falls nötig), gibt SSH + 80/443 frei und aktiviert die Firewall.'
|
||||
: 'Deaktiviert die UFW-Firewall (ufw disable).',
|
||||
? 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.'
|
||||
: 'Deaktiviert die Firewall des Servers.',
|
||||
'unattended' => $enable
|
||||
? 'Installiert unattended-upgrades (falls nötig) und aktiviert die automatische Update-Periodik + Timer.'
|
||||
: 'Schaltet die automatische Update-Periodik ab.',
|
||||
? 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.'
|
||||
: 'Schaltet die automatischen Updates ab.',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
|
@ -137,6 +161,20 @@ class HardeningService
|
|||
/** @return array{ok: bool, output: string} */
|
||||
public function apply(Server $server, string $action, bool $enable): array
|
||||
{
|
||||
$os = $this->detector->detect($server);
|
||||
|
||||
// Gracefully refuse a feature the host's OS does not support.
|
||||
$feature = match ($action) {
|
||||
'ssh_root', 'ssh_password' => 'ssh',
|
||||
'fail2ban' => 'fail2ban',
|
||||
'firewall' => 'firewall',
|
||||
'unattended' => 'auto_updates',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
if ($reason = $os->supports($feature)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
|
||||
if ($action === 'firewall') {
|
||||
$res = $enable ? $this->firewall->enable($server) : $this->firewall->disable($server);
|
||||
|
||||
|
|
@ -153,32 +191,49 @@ class HardeningService
|
|||
}
|
||||
}
|
||||
|
||||
// apt operations need a long timeout; the 12s default would abort the install.
|
||||
// package operations need a long timeout; the 12s default would abort the install.
|
||||
$timeout = $enable && in_array($action, ['fail2ban', 'unattended'], true) ? 600 : 60;
|
||||
$res = $this->fleet->runPrivileged($server, $this->commandFor($action, $enable), $timeout);
|
||||
$res = $this->fleet->runPrivileged($server, $this->commandFor($os, $action, $enable), $timeout);
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => $res['output']];
|
||||
}
|
||||
|
||||
/** Single source of truth for the shell command of a (non-firewall) toggle. */
|
||||
private function commandFor(string $action, bool $enable): string
|
||||
private function commandFor(OsProfile $os, string $action, bool $enable): string
|
||||
{
|
||||
$pm = PackageManager::for($os);
|
||||
|
||||
return match ($action) {
|
||||
'ssh_root' => $this->sshDropInScript('PermitRootLogin '.($enable ? 'yes' : 'no')),
|
||||
'ssh_password' => $this->sshDropInScript('PasswordAuthentication '.($enable ? 'yes' : 'no')),
|
||||
'fail2ban' => $enable
|
||||
? '(dpkg -l fail2ban 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban) && systemctl enable --now fail2ban'
|
||||
? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban'
|
||||
: 'systemctl disable --now fail2ban',
|
||||
'unattended' => $enable
|
||||
? '(dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii" || DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades)'
|
||||
.' && printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "1";\' \'APT::Periodic::Unattended-Upgrade "1";\' > '.self::APT_DROPIN
|
||||
.' && systemctl enable --now unattended-upgrades apt-daily-upgrade.timer apt-daily.timer'
|
||||
: 'printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "0";\' \'APT::Periodic::Unattended-Upgrade "0";\' > '.self::APT_DROPIN
|
||||
.' && (systemctl disable --now unattended-upgrades 2>/dev/null || true)',
|
||||
'unattended' => $this->autoUpdateScript($os, $pm, $enable),
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Auto-update mechanism per family: apt periodic + timers, or dnf-automatic.timer. */
|
||||
private function autoUpdateScript(OsProfile $os, PackageManager $pm, bool $enable): string
|
||||
{
|
||||
if ($os->packageManager === 'dnf') {
|
||||
return $enable
|
||||
? $pm->ensureInstalledScript('dnf-automatic')
|
||||
.' && sed -i \'s/^apply_updates.*/apply_updates = yes/\' /etc/dnf/automatic.conf'
|
||||
.' && systemctl enable --now dnf-automatic.timer'
|
||||
: 'systemctl disable --now dnf-automatic.timer';
|
||||
}
|
||||
|
||||
// apt (default): the periodic drop-in + service/timers.
|
||||
return $enable
|
||||
? $pm->ensureInstalledScript('unattended-upgrades')
|
||||
.' && printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "1";\' \'APT::Periodic::Unattended-Upgrade "1";\' > '.self::APT_DROPIN
|
||||
.' && systemctl enable --now unattended-upgrades apt-daily-upgrade.timer apt-daily.timer'
|
||||
: 'printf \'%s\n%s\n\' \'APT::Periodic::Update-Package-Lists "0";\' \'APT::Periodic::Unattended-Upgrade "0";\' > '.self::APT_DROPIN
|
||||
.' && (systemctl disable --now unattended-upgrades 2>/dev/null || true)';
|
||||
}
|
||||
|
||||
/** True if the SSH user has at least one authorized key. */
|
||||
private function hasAuthorizedKey(Server $server): bool
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Support\Os\OsDetector;
|
||||
use App\Support\Os\PackageManager;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Debian/Ubuntu (apt) maintenance helpers: counts pending package updates,
|
||||
* applies an apt upgrade, and reads/writes the fail2ban [DEFAULT] tuning.
|
||||
* 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
|
||||
|
|
@ -20,44 +23,53 @@ class MaintenanceService
|
|||
// operator's jail.local or jail definitions.
|
||||
private const FAIL2BAN_DROPIN = '/etc/fail2ban/jail.d/zz-clusev.local';
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
public function __construct(private FleetService $fleet, private OsDetector $detector) {}
|
||||
|
||||
/** True if apt-get exists on the target (only then are the update features meaningful). */
|
||||
public function hasApt(Server $server): bool
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$res = $this->fleet->runPlain($server, 'command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0');
|
||||
|
||||
return $res['ok'] && trim($res['output']) === '1';
|
||||
return $this->detector->detect($server)->supports('updates');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* 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
|
||||
{
|
||||
$res = $this->fleet->runPlain($server, 'apt-get -s upgrade 2>/dev/null');
|
||||
if (! $res['ok']) {
|
||||
$os = $this->detector->detect($server);
|
||||
if (! $os->managesPackages()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) preg_match_all('/^Inst /m', $res['output']);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 aptUpgrade(Server $server): array
|
||||
public function applyUpgrades(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged(
|
||||
$server,
|
||||
'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade',
|
||||
900,
|
||||
);
|
||||
$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)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Os;
|
||||
|
||||
/**
|
||||
* Shell command strings for the host firewall (ufw or firewalld), built per OS.
|
||||
*
|
||||
* Safety model is preserved across both tools: enableScript() ALWAYS opens the
|
||||
* real sshd port + 80/443 BEFORE turning the firewall on, so Clusev can never sever
|
||||
* its own SSH session. Disable is the clean stop — never `firewall-cmd --panic-on`
|
||||
* (which would drop ALL traffic, the exact lockout the guard avoids).
|
||||
*/
|
||||
final class FirewallTool
|
||||
{
|
||||
/** @param 'ufw'|'firewalld' $tool */
|
||||
public function __construct(
|
||||
private string $tool,
|
||||
private PackageManager $pm,
|
||||
private bool $canInstall,
|
||||
) {}
|
||||
|
||||
public static function for(OsProfile $os): self
|
||||
{
|
||||
return new self(
|
||||
$os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw',
|
||||
$os->managesPackages() ? PackageManager::for($os) : new PackageManager('none'),
|
||||
$os->managesPackages(),
|
||||
);
|
||||
}
|
||||
|
||||
public function isFirewalld(): bool
|
||||
{
|
||||
return $this->tool === 'firewalld';
|
||||
}
|
||||
|
||||
private function packageName(): string
|
||||
{
|
||||
return $this->tool === 'firewalld' ? 'firewalld' : 'ufw';
|
||||
}
|
||||
|
||||
/** Guarded enable: install if needed + missing, open ssh/80/443, then turn on. */
|
||||
public function enableScript(int $sshPort): string
|
||||
{
|
||||
$ensure = $this->canInstall ? $this->pm->ensureInstalledScript($this->packageName()).' && ' : '';
|
||||
|
||||
if ($this->isFirewalld()) {
|
||||
$ports = "--add-port={$sshPort}/tcp --add-port=80/tcp --add-port=443/tcp";
|
||||
|
||||
// GUARD: open ssh/80/443 in the PERMANENT config BEFORE firewalld starts
|
||||
// filtering, so starting it can never drop the operator's SSH session (a
|
||||
// custom SSH port is not in the default zone). Handle both states: if the
|
||||
// daemon is already running, add to permanent + reload; if it is stopped,
|
||||
// write the permanent rules offline first, THEN start it.
|
||||
return $ensure
|
||||
.'if systemctl is-active --quiet firewalld; then '
|
||||
."firewall-cmd --permanent {$ports} && systemctl enable --now firewalld && firewall-cmd --reload; "
|
||||
.'else '
|
||||
."firewall-offline-cmd {$ports} && systemctl enable --now firewalld; "
|
||||
.'fi';
|
||||
}
|
||||
|
||||
return $ensure
|
||||
."ufw allow {$sshPort}/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable";
|
||||
}
|
||||
|
||||
/** Clean disable (never a panic/drop-all). */
|
||||
public function disableScript(): string
|
||||
{
|
||||
return $this->isFirewalld() ? 'systemctl disable --now firewalld' : 'ufw disable';
|
||||
}
|
||||
|
||||
/** Shell CONDITION exiting 0 when the firewall binary is present. */
|
||||
public function installedTest(): string
|
||||
{
|
||||
return $this->isFirewalld()
|
||||
? 'command -v firewall-cmd >/dev/null 2>&1'
|
||||
: 'command -v ufw >/dev/null 2>&1';
|
||||
}
|
||||
|
||||
/** Shell CONDITION exiting 0 when the firewall is currently active. */
|
||||
public function activeTest(): string
|
||||
{
|
||||
return $this->isFirewalld()
|
||||
? 'systemctl is-active --quiet firewalld'
|
||||
: 'ufw status 2>/dev/null | grep -qi "Status: active"';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Os;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Resolves a Server's OsProfile from one unprivileged probe (/etc/os-release +
|
||||
* `command -v`), cached for an hour per server. The family is inferred from
|
||||
* ID/ID_LIKE, cross-checked against the binaries actually present so a derivative
|
||||
* (e.g. ID_LIKE="rhel fedora") still resolves and the installed tool wins.
|
||||
*/
|
||||
class OsDetector
|
||||
{
|
||||
private const PROBE_BINARIES = [
|
||||
'apt-get', 'dnf', 'yum', 'zypper', 'pacman', 'apk',
|
||||
'firewall-cmd', 'ufw', 'nft', 'systemctl', 'rc-service',
|
||||
];
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
/** Detect (and cache) the OS profile of a server. */
|
||||
public function detect(Server $server, bool $fresh = false): OsProfile
|
||||
{
|
||||
$key = 'os-profile:'.$server->id;
|
||||
|
||||
if (! $fresh) {
|
||||
$cached = Cache::get($key);
|
||||
if ($cached instanceof OsProfile) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Include the sbin dirs: ufw/firewall-cmd live in /usr/sbin, which is not on a
|
||||
// non-root user's PATH, so a bare `command -v ufw` would miss an installed tool.
|
||||
// Guard the source: `.` is a POSIX special built-in, so sourcing a MISSING
|
||||
// /etc/os-release would make a non-interactive sh exit before the binary
|
||||
// probe runs — defeating the package-manager fallback. Only source when readable.
|
||||
$probe = 'export PATH="/usr/local/sbin:/usr/sbin:/sbin:$PATH"; '
|
||||
.'[ -r /etc/os-release ] && . /etc/os-release 2>/dev/null; '
|
||||
.'echo "ID=${ID:-}"; echo "ID_LIKE=${ID_LIKE:-}"; echo "PRETTY=${PRETTY_NAME:-}"; '
|
||||
.'for p in '.implode(' ', self::PROBE_BINARIES).'; do command -v "$p" >/dev/null 2>&1 && echo "HAS=$p"; done; '
|
||||
// which firewall is actually RUNNING — decides the tool when both are installed.
|
||||
.'systemctl is-active --quiet firewalld 2>/dev/null && echo ACTIVE=firewalld; '
|
||||
.'systemctl is-active --quiet ufw 2>/dev/null && echo ACTIVE=ufw';
|
||||
|
||||
// Parse the output regardless of exit code: the probe's status reflects the
|
||||
// LAST `command -v` (often a binary that is legitimately absent, e.g.
|
||||
// rc-service on a systemd host), so a non-zero exit is normal and the
|
||||
// ID=/HAS= lines that printed are still valid.
|
||||
$res = $this->fleet->runPlain($server, $probe);
|
||||
$profile = $this->parse($res['output']);
|
||||
|
||||
// Don't lock in a "nothing detected" result (transient SSH failure / host
|
||||
// down) for a full hour — re-probe soon in that case.
|
||||
$detected = $profile->id !== '' || $profile->available !== [];
|
||||
Cache::put($key, $profile, $detected ? now()->addHour() : now()->addSeconds(60));
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
/** Drop the cached profile (call after a credential change or on demand). */
|
||||
public function forget(Server $server): void
|
||||
{
|
||||
Cache::forget('os-profile:'.$server->id);
|
||||
}
|
||||
|
||||
private function parse(string $out): OsProfile
|
||||
{
|
||||
$id = '';
|
||||
$idLike = '';
|
||||
$pretty = '';
|
||||
$available = [];
|
||||
$active = [];
|
||||
|
||||
foreach (preg_split('/\R/', $out) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if (str_starts_with($line, 'ID=')) {
|
||||
$id = strtolower(trim(substr($line, 3), " \"'"));
|
||||
} elseif (str_starts_with($line, 'ID_LIKE=')) {
|
||||
$idLike = strtolower(trim(substr($line, 8), " \"'"));
|
||||
} elseif (str_starts_with($line, 'PRETTY=')) {
|
||||
$pretty = trim(substr($line, 7), " \"'");
|
||||
} elseif (str_starts_with($line, 'HAS=')) {
|
||||
$available[] = substr($line, 4);
|
||||
} elseif (str_starts_with($line, 'ACTIVE=')) {
|
||||
$active[] = substr($line, 7);
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = array_values(array_filter(array_merge([$id], preg_split('/\s+/', $idLike) ?: [])));
|
||||
$family = $this->family($tokens, $available);
|
||||
|
||||
return new OsProfile(
|
||||
family: $family,
|
||||
id: $id,
|
||||
prettyName: $pretty !== '' ? $pretty : 'Unbekanntes System',
|
||||
packageManager: $this->packageManager($available),
|
||||
firewallTool: $this->firewallTool($family, $available, $active),
|
||||
serviceManager: in_array('systemctl', $available, true)
|
||||
? 'systemd'
|
||||
: (in_array('rc-service', $available, true) ? 'openrc' : 'none'),
|
||||
available: $available,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<string> $tokens */
|
||||
private function family(array $tokens, array $available): string
|
||||
{
|
||||
$hit = fn (array $names): bool => count(array_intersect($names, $tokens)) > 0;
|
||||
|
||||
if ($hit(['debian', 'ubuntu', 'raspbian', 'linuxmint', 'pop', 'devuan', 'kali'])) {
|
||||
return 'debian';
|
||||
}
|
||||
if ($hit(['rhel', 'fedora', 'centos', 'rocky', 'almalinux', 'ol', 'amzn'])) {
|
||||
return 'rhel';
|
||||
}
|
||||
if ($hit(['suse', 'opensuse', 'opensuse-leap', 'opensuse-tumbleweed', 'sles', 'sled'])) {
|
||||
return 'suse';
|
||||
}
|
||||
if ($hit(['arch', 'manjaro', 'endeavouros', 'arcolinux'])) {
|
||||
return 'arch';
|
||||
}
|
||||
if ($hit(['alpine'])) {
|
||||
return 'alpine';
|
||||
}
|
||||
|
||||
// No ID match — fall back to whichever package manager is actually present.
|
||||
return match (true) {
|
||||
in_array('apt-get', $available, true) => 'debian',
|
||||
in_array('dnf', $available, true), in_array('yum', $available, true) => 'rhel',
|
||||
in_array('zypper', $available, true) => 'suse',
|
||||
in_array('pacman', $available, true) => 'arch',
|
||||
in_array('apk', $available, true) => 'alpine',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/** @param list<string> $available */
|
||||
private function packageManager(array $available): string
|
||||
{
|
||||
return match (true) {
|
||||
in_array('apt-get', $available, true) => 'apt',
|
||||
in_array('dnf', $available, true), in_array('yum', $available, true) => 'dnf',
|
||||
in_array('zypper', $available, true) => 'zypper',
|
||||
in_array('pacman', $available, true) => 'pacman',
|
||||
in_array('apk', $available, true) => 'apk',
|
||||
default => 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $available
|
||||
* @param list<string> $active firewall tools reported running (firewalld/ufw)
|
||||
*/
|
||||
private function firewallTool(string $family, array $available, array $active): string
|
||||
{
|
||||
$hasFirewalld = in_array('firewall-cmd', $available, true);
|
||||
$hasUfw = in_array('ufw', $available, true);
|
||||
|
||||
// Both installed: the RUNNING one wins (managing the inactive tool would
|
||||
// report the wrong state and could start a second firewall manager).
|
||||
if ($hasFirewalld && $hasUfw) {
|
||||
if (in_array('firewalld', $active, true)) {
|
||||
return 'firewalld';
|
||||
}
|
||||
if (in_array('ufw', $active, true)) {
|
||||
return 'ufw';
|
||||
}
|
||||
} elseif ($hasFirewalld) {
|
||||
return 'firewalld';
|
||||
} elseif ($hasUfw) {
|
||||
return 'ufw';
|
||||
}
|
||||
|
||||
// Nothing installed (or both present but neither active) — default by family
|
||||
// so enable() installs/uses the conventional tool for the distro.
|
||||
return match ($family) {
|
||||
'rhel', 'suse' => 'firewalld',
|
||||
'debian', 'arch', 'alpine' => 'ufw',
|
||||
default => 'none',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Os;
|
||||
|
||||
/**
|
||||
* Immutable description of a fleet host's operating system, resolved once by
|
||||
* OsDetector from /etc/os-release + a `command -v` probe. Everything that used to
|
||||
* hard-code Debian/apt/ufw/systemd now asks an OsProfile instead.
|
||||
*
|
||||
* Phase-1 fully-supported package managers: apt, dnf (incl. yum), zypper — all on
|
||||
* systemd hosts with ufw or firewalld. Arch (pacman) and Alpine (apk/OpenRC) are
|
||||
* DETECTED and surfaced, but features that need an unsupported manager/init are
|
||||
* gracefully disabled with a German reason via supports().
|
||||
*/
|
||||
final class OsProfile
|
||||
{
|
||||
/** Package managers Clusev can drive for updates/installs in this release. */
|
||||
public const SUPPORTED_MANAGERS = ['apt', 'dnf', 'zypper'];
|
||||
|
||||
/**
|
||||
* @param list<string> $available detected binaries (apt-get, dnf, ufw, firewall-cmd, systemctl, …)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $family, // debian|rhel|suse|arch|alpine|unknown
|
||||
public readonly string $id, // raw /etc/os-release ID
|
||||
public readonly string $prettyName, // PRETTY_NAME for display
|
||||
public readonly string $packageManager, // apt|dnf|zypper|pacman|apk|none
|
||||
public readonly string $firewallTool, // ufw|firewalld|none
|
||||
public readonly string $serviceManager, // systemd|openrc|none
|
||||
public readonly array $available = [],
|
||||
) {}
|
||||
|
||||
public function has(string $binary): bool
|
||||
{
|
||||
return in_array($binary, $this->available, true);
|
||||
}
|
||||
|
||||
public function isSystemd(): bool
|
||||
{
|
||||
return $this->serviceManager === 'systemd';
|
||||
}
|
||||
|
||||
/** True when Clusev can drive package install/update on this host. */
|
||||
public function managesPackages(): bool
|
||||
{
|
||||
return in_array($this->packageManager, self::SUPPORTED_MANAGERS, true);
|
||||
}
|
||||
|
||||
/** German family label for the UI. */
|
||||
public function familyLabel(): string
|
||||
{
|
||||
return match ($this->family) {
|
||||
'debian' => 'Debian/Ubuntu',
|
||||
'rhel' => 'RHEL/Fedora',
|
||||
'suse' => 'openSUSE/SLES',
|
||||
'arch' => 'Arch Linux',
|
||||
'alpine' => 'Alpine Linux',
|
||||
default => 'Unbekanntes System',
|
||||
};
|
||||
}
|
||||
|
||||
/** German label for the host firewall in the hardening checklist. */
|
||||
public function firewallLabel(): string
|
||||
{
|
||||
return match ($this->firewallTool) {
|
||||
'ufw' => 'Firewall (UFW)',
|
||||
'firewalld' => 'Firewall (firewalld)',
|
||||
default => 'Firewall',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feature is supported on this host. Returns NULL when supported, or
|
||||
* a German reason to show the operator when not — so unsupported combinations
|
||||
* degrade with an explanation instead of failing opaquely.
|
||||
*/
|
||||
public function supports(string $feature): ?string
|
||||
{
|
||||
return match ($feature) {
|
||||
'ssh' => $this->isSystemd()
|
||||
? null
|
||||
: 'SSH-Härtung benötigt systemd, das auf '.$this->familyLabel().' nicht erkannt wurde.',
|
||||
'services' => $this->isSystemd()
|
||||
? null
|
||||
: 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.',
|
||||
'updates' => $this->managesPackages()
|
||||
? null
|
||||
: 'Paket-Updates über Clusev werden unter '.$this->familyLabel().' noch nicht unterstützt.',
|
||||
'fail2ban' => ($this->managesPackages() && $this->isSystemd())
|
||||
? null
|
||||
: 'fail2ban wird unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
|
||||
'firewall' => ($this->firewallTool !== 'none' && $this->isSystemd()
|
||||
&& ($this->firewallTool === 'firewalld' || $this->managesPackages() || $this->has('ufw')))
|
||||
? null
|
||||
: 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter '.$this->familyLabel().' nicht gefunden.',
|
||||
// apt -> unattended-upgrades; dnf -> dnf-automatic. A yum-ONLY host (e.g.
|
||||
// CentOS 7) maps to the dnf manager for upgrades but has no dnf-automatic
|
||||
// (it uses yum-cron), so require a real `dnf` binary for this feature.
|
||||
'auto_updates' => (($this->packageManager === 'apt'
|
||||
|| ($this->packageManager === 'dnf' && $this->has('dnf'))) && $this->isSystemd())
|
||||
? null
|
||||
: 'Automatische Updates werden unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Os;
|
||||
|
||||
/**
|
||||
* Builds SHELL COMMAND STRINGS for package operations, per manager. It never runs
|
||||
* anything — FleetService is the single executor (base64-wrapped, sudo + LC_ALL=C
|
||||
* handled there). Only apt/dnf/zypper are driven this release; package names passed
|
||||
* in are Clusev constants (fail2ban, ufw, …), never operator input.
|
||||
*
|
||||
* Note: the dnf/zypper command strings are constructed from documented syntax but
|
||||
* are exercised live only on the families a tester actually runs; the apt path is
|
||||
* the one verified against the live fleet today.
|
||||
*/
|
||||
final class PackageManager
|
||||
{
|
||||
/** @param 'apt'|'dnf'|'zypper'|'none' $manager */
|
||||
public function __construct(
|
||||
private string $manager,
|
||||
private bool $useYum = false, // dnf family lacking the dnf binary -> yum
|
||||
) {}
|
||||
|
||||
public static function for(OsProfile $os): self
|
||||
{
|
||||
$useYum = $os->packageManager === 'dnf' && ! $os->has('dnf') && $os->has('yum');
|
||||
|
||||
return new self($os->packageManager, $useYum);
|
||||
}
|
||||
|
||||
private function dnf(): string
|
||||
{
|
||||
return $this->useYum ? 'yum' : 'dnf';
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that prints `CLUSEV_OK` (only when the query actually ran) and
|
||||
* `CLUSEV_PENDING=<n>`. Runs unprivileged (simulation / metadata read only).
|
||||
*/
|
||||
public function pendingScript(): string
|
||||
{
|
||||
return match ($this->manager) {
|
||||
'apt' => 'out=$(apt-get -s upgrade 2>/dev/null) && echo CLUSEV_OK; '
|
||||
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -c '^Inst ')\"",
|
||||
'dnf' => 'out=$('.$this->dnf().' -q check-update 2>/dev/null); rc=$?; '
|
||||
.'if [ "$rc" = 0 ] || [ "$rc" = 100 ]; then echo CLUSEV_OK; fi; '
|
||||
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+')\"",
|
||||
'zypper' => 'out=$(zypper --non-interactive list-updates 2>/dev/null); rc=$?; '
|
||||
.'[ "$rc" = 0 ] && echo CLUSEV_OK; '
|
||||
."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^v \\|')\"",
|
||||
default => 'echo CLUSEV_PENDING=0',
|
||||
};
|
||||
}
|
||||
|
||||
/** Refresh index + apply all upgrades (privileged, long-running). */
|
||||
public function applyScript(): string
|
||||
{
|
||||
return match ($this->manager) {
|
||||
'apt' => 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade',
|
||||
'dnf' => $this->dnf().' -y upgrade',
|
||||
// zypper exits 102 (reboot required) / 103 (restart zypper) on SUCCESS —
|
||||
// normalize those to 0 so a successful upgrade is not reported as failed.
|
||||
'zypper' => 'zypper --non-interactive refresh && { zypper --non-interactive update; rc=$?; '
|
||||
.'[ "$rc" = 0 ] || [ "$rc" = 102 ] || [ "$rc" = 103 ]; }',
|
||||
default => 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/** Install one package (privileged). */
|
||||
public function installScript(string $pkg): string
|
||||
{
|
||||
return match ($this->manager) {
|
||||
'apt' => 'DEBIAN_FRONTEND=noninteractive apt-get install -y '.$pkg,
|
||||
'dnf' => $this->dnf().' install -y '.$pkg,
|
||||
'zypper' => 'zypper --non-interactive install '.$pkg,
|
||||
default => 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/** A shell CONDITION that exits 0 when $pkg is installed (for use with && / ||). */
|
||||
public function isInstalledTest(string $pkg): string
|
||||
{
|
||||
return match ($this->manager) {
|
||||
'apt' => "dpkg -l {$pkg} 2>/dev/null | grep -q '^ii'",
|
||||
'dnf', 'zypper' => "rpm -q {$pkg} >/dev/null 2>&1",
|
||||
default => 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/** Install $pkg only if missing (idempotent). */
|
||||
public function ensureInstalledScript(string $pkg): string
|
||||
{
|
||||
return '('.$this->isInstalledTest($pkg).' || '.$this->installScript($pkg).')';
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,10 @@
|
|||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@elseif (! $hasApt)
|
||||
@elseif (! $supported)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||
<p class="text-sm text-ink-2">Nur für Debian/Ubuntu (apt) verfügbar.</p>
|
||||
<p class="text-sm text-ink-2">{{ $unsupportedReason ?: 'Auf diesem System nicht verfügbar.' }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
@if (! $error && $hasApt)
|
||||
@if (! $error && $supported)
|
||||
<x-btn variant="accent" wire:click="upgrade" wire:target="upgrade" wire:loading.attr="disabled">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="upgrade" />
|
||||
<svg wire:loading wire:target="upgrade" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@
|
|||
['Disk', $diskGb !== null ? $diskGb.' GB' : '—'],
|
||||
];
|
||||
|
||||
// Detected OS tooling (populated once the live profile loads).
|
||||
if (! empty($os)) {
|
||||
$pm = ($os['packageManager'] ?? 'none') !== 'none' ? $os['packageManager'] : '—';
|
||||
$fw = ($os['firewallTool'] ?? 'none') !== 'none' ? $os['firewallTool'] : '—';
|
||||
$specRows[] = ['System', $os['familyLabel'] ?? '—'];
|
||||
$specRows[] = ['Paketverwaltung', $pm];
|
||||
$specRows[] = ['Firewall', $fw];
|
||||
}
|
||||
|
||||
$vitals = [
|
||||
['label' => 'CPU', 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? 'Last '.number_format($loadAvg, 2).' · ' : '').($cores ? $cores.' Kerne' : '—')],
|
||||
['label' => 'RAM', 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? $ramUsed.' / '.$ramGb.' GB' : '—'],
|
||||
|
|
@ -99,7 +108,7 @@
|
|||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="truncate font-display text-sm font-semibold text-ink">{{ $cred->name ?: '—' }}</p>
|
||||
<p class="truncate font-display text-sm font-semibold text-ink">{{ $cred->name ?: $cred->username }}</p>
|
||||
<x-status-pill :status="$credStatus" class="shrink-0">{{ $credStatusLabel }}</x-status-pill>
|
||||
<x-badge tone="neutral" class="shrink-0">{{ $credAuthLabel }}</x-badge>
|
||||
</div>
|
||||
|
|
@ -169,37 +178,46 @@
|
|||
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
|
||||
<div class="divide-y divide-line">
|
||||
@foreach ($hardening as $check)
|
||||
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen). --}}
|
||||
@php $supported = $check['supported'] ?? true; @endphp
|
||||
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen);
|
||||
unsupported items render muted with a German reason and no toggle. --}}
|
||||
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
|
||||
<span @class([
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||||
'border-online/25 bg-online/10 text-online' => $check['secure'],
|
||||
'border-warning/25 bg-warning/10 text-warning' => ! $check['secure'],
|
||||
'border-online/25 bg-online/10 text-online' => $supported && $check['secure'],
|
||||
'border-warning/25 bg-warning/10 text-warning' => $supported && ! $check['secure'],
|
||||
'border-line bg-raised text-ink-4' => ! $supported,
|
||||
])>
|
||||
<x-icon :name="$check['secure'] ? 'lock' : 'lock-open'" class="h-4 w-4" />
|
||||
<x-icon :name="$supported && ! $check['secure'] ? 'lock-open' : 'lock'" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="flex items-center gap-2 truncate text-sm text-ink">
|
||||
{{ $check['label'] }}
|
||||
<span @class([
|
||||
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
||||
'text-online' => $check['secure'],
|
||||
'text-warning' => ! $check['secure'],
|
||||
])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span>
|
||||
@if ($supported)
|
||||
<span @class([
|
||||
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
||||
'text-online' => $check['secure'],
|
||||
'text-warning' => ! $check['secure'],
|
||||
])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span>
|
||||
@else
|
||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">n. v.</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>
|
||||
</div>
|
||||
@if ($check['key'] === 'fail2ban')
|
||||
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="fail2ban konfigurieren"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||
@if ($supported)
|
||||
@if ($check['key'] === 'fail2ban')
|
||||
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="fail2ban konfigurieren"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||
</x-btn>
|
||||
@endif
|
||||
{{-- Toggle: secondary to make secure, quiet ghost to loosen. --}}
|
||||
<x-btn :variant="$check['secure'] ? 'ghost' : 'secondary'" size="sm" class="shrink-0"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
|
||||
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
|
||||
</x-btn>
|
||||
@endif
|
||||
{{-- Toggle: secondary to make secure, quiet ghost to loosen. --}}
|
||||
<x-btn :variant="$check['secure'] ? 'ghost' : 'secondary'" size="sm" class="shrink-0"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
|
||||
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue