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 $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 $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 $available * @param list $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', }; } }