clusev/app/Support/Os/OsDetector.php

187 lines
7.5 KiB
PHP

<?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',
};
}
}