diff --git a/.nextcloudignore b/.nextcloudignore index d776fa6c..a131ffef 100644 --- a/.nextcloudignore +++ b/.nextcloudignore @@ -6,6 +6,7 @@ .nextcloudignore .php-cs-fixer.dist.php .tx/ +build/ eslint.config.mjs l10n/.gitkeep psalm.xml diff --git a/README.md b/README.md index ff2c14f4..dbd5166d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,53 @@ provided end-points. This app is part of the standard Nextcloud package and can be found in the directory **nextcloud/apps/serverinfo** +## Operating system + +The app keeps track of what keeps the Linux distribution under Nextcloud +secure: + +- pending updates, security fixes first, with the packages Nextcloud itself + runs on (PHP, the web server, the database, Redis …) marked +- whether a reboot is needed, and why (a newer kernel, updated core libraries, + the distribution asking for one), with live kernel patches taken into account +- how long the release still receives security updates, and whether a paid + extension (Ubuntu Pro or Debian ELTS) is active +- automatic updates, package list age, held packages, failed system services, + time synchronisation, SELinux and AppArmor + +Debian and Ubuntu are supported. Inside Docker, Podman, Kubernetes or a snap the status says +what can and cannot be known from inside; LXC and systemd-nspawn guests are +treated like machines without a kernel of their own. + +Nothing is installed or changed. To find out, the app reads `/etc/os-release`, +`/proc` (uptime, `/proc/1/cgroup`), `/sys/class/dmi/id`, +`/sys/kernel/livepatch`, the SELinux and AppArmor state under `/sys`, the +package manager's configuration, state and logs, `/run/reboot-required` and the +installed kernel modules. It runs the package manager's query commands (updates +only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and +`systemd-detect-virt`. Anything it may not read or run is reported as unknown. + +The status is collected by a background job once an hour, when background +jobs run from system cron; under AJAX or webcron the commands would hold a web +request for minutes. A status older than three job intervals (at least six +hours), or background jobs that do not run from system cron, count as a +warning, so an old all-clear is never shown as current. Admins can also check +it right away with: + +``` +occ serverinfo:os-status [--refresh] [--output=json] +``` + +The command shows how old the status is and exits with 0, 1, 2 or 3 for ok, +warning, error and unknown, so it can be used as a monitoring check. Unknown means the status has not been +collected yet, or nothing is wrong but the pending updates could not be +determined. When something counts as a warning or an error is set by +the `os_*_days` app config values, see `lib/Config/ConfigLexicon.php`. + +Release support dates are bundled with the app (see +`build/update-os-support.php`, data from [endoflife.date](https://endoflife.date), +MIT License); the app never looks them up online. + ## API The API provides a lot of information about a running Nextcloud diff --git a/REUSE.toml b/REUSE.toml index 858aeb84..db68c96c 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -28,3 +28,9 @@ path = "css/**.css" precedence = "aggregate" SPDX-FileCopyrightText = "Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" + +[[annotations]] +path = "resources/os-support.json" +precedence = "aggregate" +SPDX-FileCopyrightText = "2020 endoflife.date contributors" +SPDX-License-Identifier = "MIT" diff --git a/appinfo/info.xml b/appinfo/info.xml index 48e4d34b..2e71af34 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -8,8 +8,8 @@ serverinfo Monitoring Monitoring app with useful server information - Provides useful server information, such as CPU load, RAM usage, disk usage, number of users, etc. - 8.0.0-dev.0 + Provides useful server information, such as CPU load, RAM usage, disk usage, number of users, etc. Shows pending operating system updates, required reboots and service restarts, and how long the Linux distribution is still supported. + 8.0.0-dev.1 AGPL-3.0-or-later Bjoern Schiessle Ivan Sein Santiago @@ -24,10 +24,12 @@ OCA\ServerInfo\Jobs\UpdateStorageStats OCA\ServerInfo\Jobs\UpdateJobStats + OCA\ServerInfo\Jobs\UpdateOsStatus OCA\ServerInfo\Commands\UpdateStorageStats OCA\ServerInfo\Commands\UpdateJobStats + OCA\ServerInfo\Commands\ShowOsStatus OCA\ServerInfo\Settings\AdminSettings diff --git a/build/update-os-support.php b/build/update-os-support.php new file mode 100644 index 00000000..b16439f2 --- /dev/null +++ b/build/update-os-support.php @@ -0,0 +1,109 @@ + endoflife.date product, how VERSION_ID maps to a release + * cycle, and which paid programme (if any) extends security support. + */ +const PRODUCTS = [ + 'ubuntu' => ['product' => 'ubuntu', 'cycle' => 'minor', 'extended' => 'ubuntu-pro'], + 'debian' => ['product' => 'debian', 'cycle' => 'major', 'extended' => 'debian-elts', 'lts' => true], + 'rhel' => ['product' => 'rhel', 'cycle' => 'major', 'extended' => 'rhel-els'], + 'almalinux' => ['product' => 'almalinux', 'cycle' => 'major', 'extended' => null], + 'rocky' => ['product' => 'rocky-linux', 'cycle' => 'major', 'extended' => null], + 'centos-stream' => ['product' => 'centos-stream', 'cycle' => 'major', 'extended' => null], + 'centos' => ['product' => 'centos', 'cycle' => 'major', 'extended' => null], + 'ol' => ['product' => 'oracle-linux', 'cycle' => 'major', 'extended' => 'oracle-extended'], + 'fedora' => ['product' => 'fedora', 'cycle' => 'major', 'extended' => null], + 'opensuse-leap' => ['product' => 'opensuse', 'cycle' => 'minor', 'extended' => null], + 'sles' => ['product' => 'sles', 'cycle' => 'minor', 'extended' => 'suse-ltss'], + 'alpine' => ['product' => 'alpine-linux', 'cycle' => 'minor', 'extended' => null], + 'amzn' => ['product' => 'amazon-linux', 'cycle' => 'major', 'extended' => null], +]; + +/** os-release IDs of rolling releases, which have no end of support. */ +const ROLLING = [ + 'arch', 'archarm', 'manjaro', 'endeavouros', 'cachyos', 'garuda', + 'opensuse-tumbleweed', 'opensuse-microos', 'opensuse-slowroll', + 'gentoo', 'void', +]; + +/** Releases whose support ended before this are left out; nothing that old runs a current Nextcloud. */ +const OLDEST = '2018-01-01'; + +function fetchProduct(string $product): array { + $context = stream_context_create(['http' => ['timeout' => 120, 'user_agent' => 'nextcloud-serverinfo-build']]); + $json = file_get_contents(sprintf(API, $product), false, $context); + if ($json === false) { + fwrite(STDERR, "Could not download $product\n"); + exit(1); + } + + $data = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (!isset($data['result']['releases']) || !is_array($data['result']['releases'])) { + fwrite(STDERR, "Unexpected response for $product\n"); + exit(1); + } + + return $data['result']['releases']; +} + +function date_or_null(mixed $value): ?string { + return is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : null; +} + +$releases = []; +foreach (PRODUCTS as $id => $config) { + foreach (fetchProduct($config['product']) as $release) { + $cycle = (string)($release['name'] ?? ''); + $securityEnds = date_or_null($release['eolFrom'] ?? null); + $extendedEnds = $config['extended'] !== null ? date_or_null($release['eoesFrom'] ?? null) : null; + if ($cycle === '' || $securityEnds === null) { + continue; + } + if (max($securityEnds, $extendedEnds ?? '') < OLDEST) { + continue; + } + + $entry = ['securityEnds' => $securityEnds]; + if (($config['lts'] ?? false) && ($ltsFrom = date_or_null($release['eoasFrom'] ?? null)) !== null) { + $entry['ltsFrom'] = $ltsFrom; + } + if ($extendedEnds !== null && $extendedEnds > $securityEnds) { + $entry['extendedEnds'] = $extendedEnds; + $entry['extended'] = $config['extended']; + } + + $releases[$id . ':' . $cycle] = $entry; + } +} + +ksort($releases, SORT_NATURAL); + +$table = [ + 'source' => 'endoflife.date, MIT License, https://endoflife.date', + 'generatedAt' => gmdate('Y-m-d'), + 'cycles' => array_map(static fn (array $c): string => $c['cycle'], PRODUCTS), + 'rolling' => ROLLING, + 'releases' => $releases, +]; + +$target = __DIR__ . '/../resources/os-support.json'; +file_put_contents($target, json_encode($table, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); +echo 'Wrote ' . count($releases) . " releases to resources/os-support.json\n"; diff --git a/js/serverinfo-main.mjs.license b/js/serverinfo-main.mjs.license index a758fd8f..1037a3dd 100644 --- a/js/serverinfo-main.mjs.license +++ b/js/serverinfo-main.mjs.license @@ -108,7 +108,7 @@ This file is generated from multiple sources. Included packages: - version: 7.8.5 - license: ISC - serverinfo - - version: 8.0.0-dev.0 + - version: 8.0.0-dev.1 - license: AGPL-3.0-or-later - tabbable - version: 6.5.0 diff --git a/js/serverinfo-main.mjs.map.license b/js/serverinfo-main.mjs.map.license index a758fd8f..1037a3dd 100644 --- a/js/serverinfo-main.mjs.map.license +++ b/js/serverinfo-main.mjs.map.license @@ -108,7 +108,7 @@ This file is generated from multiple sources. Included packages: - version: 7.8.5 - license: ISC - serverinfo - - version: 8.0.0-dev.0 + - version: 8.0.0-dev.1 - license: AGPL-3.0-or-later - tabbable - version: 6.5.0 diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bf748796..b0507a46 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -10,6 +10,8 @@ namespace OCA\ServerInfo\AppInfo; use OCA\ServerInfo\Config\ConfigLexicon; +use OCA\ServerInfo\OsStatus\Host\IHost; +use OCA\ServerInfo\OsStatus\Host\LocalHost; use OCA\ServerInfo\SetupChecks\DatabaseChecks; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -30,6 +32,7 @@ public function __construct() { public function register(IRegistrationContext $context): void { $context->registerConfigLexicon(ConfigLexicon::class); $context->registerSetupCheck(DatabaseChecks::class); + $context->registerServiceAlias(IHost::class, LocalHost::class); } #[\Override] diff --git a/lib/Commands/ShowOsStatus.php b/lib/Commands/ShowOsStatus.php new file mode 100644 index 00000000..6f37d154 --- /dev/null +++ b/lib/Commands/ShowOsStatus.php @@ -0,0 +1,116 @@ +collector->collectAndStore() === null) { + $output->writeln('The operating system status could not be collected; see the Nextcloud log.'); + + return self::EXIT_UNKNOWN; + } + } + + $status = $this->service->getStatus(); + if ($status === null) { + $output->writeln('The operating system has not been checked yet. Run with --refresh to check it now.'); + + return self::EXIT_UNKNOWN; + } + + $verdict = $this->service->evaluate($status); + $concerns = []; + foreach ($verdict?->concerns ?? [] as $concern) { + $concerns[$concern->id] = ['level' => $concern->level] + array_filter( + $concern->facts, + static fn (mixed $fact): bool => $fact !== null && $fact !== [], + ); + } + $pendingDays = $verdict?->get(Concern::UPDATES)?->facts['days'] ?? null; + $age = $verdict?->get(Concern::FRESHNESS)?->facts['ageSeconds'] ?? null; + $securityUpdates = $status->securityUpdates(); + + $output->writeArrayInOutputFormat([ + 'level' => $verdict?->level() ?? Concern::LEVEL_UNKNOWN, + 'collectedAt' => date(DATE_ATOM, $status->collectedAt), + 'statusAge' => is_int($age) ? self::formatAge($age) : 'unknown', + 'release' => $status->release?->prettyName ?? 'unknown', + 'kernel' => $status->kernel, + 'environment' => $status->environment->kind, + 'packageManager' => $status->packageManager ?? 'none', + 'securityUpdates' => $securityUpdates === null ? 'unknown' : count($securityUpdates), + 'updates' => $status->updates === null ? 'unknown' : count($status->updates) + $status->updatesOmitted, + 'updatesPendingDays' => is_int($pendingDays) ? $pendingDays : 'none', + 'rebootRequired' => $status->rebootReasons === null ? 'unknown' : ($status->rebootReasons !== [] ? 'yes' : 'no'), + 'concerns' => $concerns, + ]); + + return self::exitCode($verdict?->level()); + } + + /** + * A duration in its two largest units, e.g. "3 d 4 h" or "12 min". + */ + public static function formatAge(int $seconds): string { + return match (true) { + $seconds >= 86400 => sprintf('%d d %d h', intdiv($seconds, 86400), intdiv($seconds % 86400, 3600)), + $seconds >= 3600 => sprintf('%d h %d min', intdiv($seconds, 3600), intdiv($seconds % 3600, 60)), + default => sprintf('%d min', intdiv(max(0, $seconds), 60)), + }; + } + + /** + * @param Concern::LEVEL_*|null $level + * @return self::EXIT_* + */ + public static function exitCode(?string $level): int { + return match ($level) { + Concern::LEVEL_OK, Concern::LEVEL_INFO => self::EXIT_OK, + Concern::LEVEL_WARNING => self::EXIT_WARNING, + Concern::LEVEL_ERROR => self::EXIT_CRITICAL, + default => self::EXIT_UNKNOWN, + }; + } +} diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 394fd77c..d13de962 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -28,6 +28,19 @@ class ConfigLexicon implements ILexicon { public const CACHED_DB_CHECK = 'cached_db_check'; + public const CACHED_OS_FIRST_SEEN = 'cached_os_first_seen'; + public const CACHED_OS_PHP_PROCESS = 'cached_os_php_process'; + public const CACHED_OS_PHP_OUTDATED_SINCE = 'cached_os_php_outdated_since'; + public const JOB_INTERVAL_OS_STATUS = 'job_interval_os_status'; + public const OS_SECURITY_WARNING_DAYS = 'os_security_warning_days'; + public const OS_SECURITY_ERROR_DAYS = 'os_security_error_days'; + public const OS_REBOOT_WARNING_DAYS = 'os_reboot_warning_days'; + public const OS_REBOOT_ERROR_DAYS = 'os_reboot_error_days'; + public const OS_RESTART_WARNING_DAYS = 'os_restart_warning_days'; + public const OS_EOL_WARNING_DAYS = 'os_eol_warning_days'; + public const OS_LISTS_WARNING_DAYS = 'os_lists_warning_days'; + public const OS_LISTS_ERROR_DAYS = 'os_lists_error_days'; + #[\Override] public function getStrictness(): Strictness { // The app predates the lexicon and still writes keys that are not listed here @@ -71,6 +84,41 @@ public function getAppConfigs(): array { 'Outcome of the last database check run, read by the setup check on Settings > Overview', lazy: true, ), + new Entry( + self::CACHED_OS_FIRST_SEEN, + ValueType::ARRAY, + [], + 'When each pending security update and reboot reason was first seen, for the alert thresholds', + lazy: true, + ), + new Entry( + self::CACHED_OS_PHP_PROCESS, + ValueType::ARRAY, + [], + 'What the last web request saw of its own PHP process: libraries replaced on disk since it started', + lazy: true, + ), + new Entry( + self::CACHED_OS_PHP_OUTDATED_SINCE, + ValueType::INT, + 0, + 'When web requests first saw their PHP process running replaced libraries, 0 when they do not', + lazy: true, + ), + new Entry( + self::JOB_INTERVAL_OS_STATUS, + ValueType::INT, + 60 * 60, + 'How often the operating system status is collected, in seconds', + ), + new Entry(self::OS_SECURITY_WARNING_DAYS, ValueType::INT, 3, 'Days a security update may be pending before a warning', lazy: true), + new Entry(self::OS_SECURITY_ERROR_DAYS, ValueType::INT, 14, 'Days a security update may be pending before an error', lazy: true), + new Entry(self::OS_REBOOT_WARNING_DAYS, ValueType::INT, 7, 'Days a reboot may be pending before a warning', lazy: true), + new Entry(self::OS_REBOOT_ERROR_DAYS, ValueType::INT, 30, 'Days a reboot may be pending before an error', lazy: true), + new Entry(self::OS_RESTART_WARNING_DAYS, ValueType::INT, 3, 'Days a service restart may be pending before a warning', lazy: true), + new Entry(self::OS_EOL_WARNING_DAYS, ValueType::INT, 90, 'Days before the end of security support that a warning starts', lazy: true), + new Entry(self::OS_LISTS_WARNING_DAYS, ValueType::INT, 7, 'Days without a package list refresh before a warning', lazy: true), + new Entry(self::OS_LISTS_ERROR_DAYS, ValueType::INT, 30, 'Days without a package list refresh before an error', lazy: true), ]; } diff --git a/lib/Jobs/UpdateOsStatus.php b/lib/Jobs/UpdateOsStatus.php new file mode 100644 index 00000000..57ba6349 --- /dev/null +++ b/lib/Jobs/UpdateOsStatus.php @@ -0,0 +1,61 @@ +setInterval(self::interval($appConfig)); + } + + #[\Override] + protected function run($argument): void { + if (!self::runsFromSystemCron($this->appConfig)) { + return; + } + + $this->collector->collectAndStore(); + } + + /** + * Seconds between two collections. + */ + public static function interval(IAppConfig $appConfig): int { + return max(self::MIN_INTERVAL, $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::JOB_INTERVAL_OS_STATUS)); + } + + public static function runsFromSystemCron(IAppConfig $appConfig): bool { + return $appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax') === 'cron'; + } +} diff --git a/lib/OsStatus/Environment.php b/lib/OsStatus/Environment.php new file mode 100644 index 00000000..b02f4ebe --- /dev/null +++ b/lib/OsStatus/Environment.php @@ -0,0 +1,77 @@ +kind === self::KIND_HOST || $this->kind === self::KIND_SYSTEM_CONTAINER; + } + + /** + * Whether a kernel upgrade here is booted by rebooting this system. + */ + public function ownsKernel(): bool { + return $this->kind === self::KIND_HOST && $this->virtualization !== 'wsl'; + } + + /** + * @return array{kind: string, container: ?string, virtualization: ?string} + */ + public function toArray(): array { + return [ + 'kind' => $this->kind, + 'container' => $this->container, + 'virtualization' => $this->virtualization, + ]; + } + + public static function fromArray(mixed $data): self { + $kind = is_array($data) ? ($data['kind'] ?? null) : null; + if (!in_array($kind, [self::KIND_HOST, self::KIND_APPLICATION_CONTAINER, self::KIND_SYSTEM_CONTAINER, self::KIND_SNAP], true)) { + return new self(self::KIND_HOST); + } + + /** @var array $data */ + return new self( + $kind, + Values::string($data['container'] ?? null), + Values::string($data['virtualization'] ?? null), + ); + } +} diff --git a/lib/OsStatus/EnvironmentDetector.php b/lib/OsStatus/EnvironmentDetector.php new file mode 100644 index 00000000..858eddeb --- /dev/null +++ b/lib/OsStatus/EnvironmentDetector.php @@ -0,0 +1,143 @@ + 'kvm', + 'KVM' => 'kvm', + 'VMware' => 'vmware', + 'VirtualBox' => 'oracle', + 'innotek' => 'oracle', + 'Xen' => 'xen', + 'Amazon EC2' => 'amazon', + 'Google Compute Engine' => 'google', + 'Virtual Machine' => 'microsoft', + 'Parallels' => 'parallels', + 'Bochs' => 'bochs', + ]; + + public function __construct( + private IHost $host, + ) { + } + + public function detect(): Environment { + $virtualization = $this->detectVirtualization(); + + if ($this->host->environment('SNAP') !== null && $this->host->environment('SNAP_NAME') !== null) { + return new Environment(Environment::KIND_SNAP, 'snap', $virtualization); + } + + $container = $this->detectContainer(); + if ($container === 'wsl') { + return new Environment(Environment::KIND_HOST, null, 'wsl'); + } + if ($container !== null) { + $kind = in_array($container, self::SYSTEM_CONTAINERS, true) + ? Environment::KIND_SYSTEM_CONTAINER + : Environment::KIND_APPLICATION_CONTAINER; + + return new Environment($kind, $container, $virtualization); + } + + return new Environment(Environment::KIND_HOST, null, $virtualization); + } + + private function detectContainer(): ?string { + // systemd writes the container manager it was started under here. + $marker = $this->host->readFile('/run/systemd/container', 256); + if ($marker !== null && ($name = $this->normalizeContainer(trim($marker))) !== null) { + return $name; + } + + if ($this->host->fileExists('/.dockerenv')) { + return 'docker'; + } + if ($this->host->fileExists('/run/.containerenv')) { + return 'podman'; + } + if ($this->host->environment('KUBERNETES_SERVICE_HOST') !== null) { + return 'kubernetes'; + } + + $result = $this->host->run('systemd-detect-virt', ['--container'], 5); + if ($result !== null && $result->succeeded() && ($name = $this->normalizeContainer(trim($result->stdout))) !== null) { + return $name; + } + + $cgroup = $this->host->readFile('/proc/1/cgroup', 65536); + if ($cgroup !== null) { + foreach (['kubepods' => 'kubernetes', 'docker' => 'docker', 'libpod' => 'podman', '/lxc' => 'lxc'] as $needle => $name) { + if (str_contains($cgroup, $needle)) { + return $name; + } + } + } + + if ($this->host->fileExists('/proc/vz') && !$this->host->fileExists('/proc/bc')) { + return 'openvz'; + } + + $release = strtolower($this->host->uname('r')); + if (str_contains($release, 'microsoft') || str_contains($release, 'wsl')) { + return 'wsl'; + } + + return null; + } + + private function normalizeContainer(string $name): ?string { + $name = strtolower($name); + if ($name === '' || $name === 'none') { + return null; + } + if ($name === 'wsl' || in_array($name, self::SYSTEM_CONTAINERS, true) || in_array($name, self::APPLICATION_CONTAINERS, true)) { + return $name; + } + + return preg_match('/^[a-z0-9._-]{1,32}$/', $name) === 1 ? $name : 'container-other'; + } + + private function detectVirtualization(): ?string { + $result = $this->host->run('systemd-detect-virt', ['--vm'], 5); + if ($result !== null && $result->succeeded()) { + $name = strtolower(trim($result->stdout)); + if ($name !== '' && $name !== 'none' && preg_match('/^[a-z0-9._-]{1,32}$/', $name) === 1) { + return $name; + } + } + if ($result !== null && trim($result->stdout) === 'none') { + return null; + } + + $dmi = trim(($this->host->readFile('/sys/class/dmi/id/sys_vendor', 256) ?? '') . ' ' + . ($this->host->readFile('/sys/class/dmi/id/product_name', 256) ?? '')); + foreach (self::DMI_HYPERVISORS as $needle => $name) { + if (str_contains($dmi, $needle)) { + return $name; + } + } + + return null; + } +} diff --git a/lib/OsStatus/Health/SecurityProbe.php b/lib/OsStatus/Health/SecurityProbe.php new file mode 100644 index 00000000..770ae102 --- /dev/null +++ b/lib/OsStatus/Health/SecurityProbe.php @@ -0,0 +1,74 @@ +host->readFile('/sys/fs/selinux/enforce', 16); + if ($enforce !== null) { + return trim($enforce) === '1' ? 'enforcing' : 'permissive'; + } + + return $this->host->fileExists('/sys/fs/selinux') ? null : 'disabled'; + } + + /** + * @return 'enabled'|'disabled'|null + */ + public function getApparmor(): ?string { + $enabled = $this->host->readFile('/sys/module/apparmor/parameters/enabled', 16); + if ($enabled === null) { + return $this->host->fileExists('/sys/module') ? 'disabled' : null; + } + + return strtoupper(trim($enabled)) === 'Y' ? 'enabled' : 'disabled'; + } + + /** + * Live patches loaded into the running kernel, whatever tool put them + * there: Canonical Livepatch, kpatch and SUSE's klp all use the kernel's + * livepatch interface. + * + * @return list|null patch names, null when /sys cannot be read + */ + public function getLivePatches(): ?array { + if (!$this->host->fileExists('/sys/kernel')) { + return null; + } + + $patches = []; + foreach ($this->host->glob('/sys/kernel/livepatch/*/enabled') as $file) { + if (trim((string)$this->host->readFile($file, 16)) === '1') { + $name = basename(dirname($file)); + if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) === 1) { + $patches[] = $name; + } + } + } + + return $patches; + } +} diff --git a/lib/OsStatus/Health/SystemdProbe.php b/lib/OsStatus/Health/SystemdProbe.php new file mode 100644 index 00000000..4dea254c --- /dev/null +++ b/lib/OsStatus/Health/SystemdProbe.php @@ -0,0 +1,74 @@ +|null failed unit names, null when systemd cannot be asked + */ + public function getFailedUnits(): ?array { + $result = $this->host->run('systemctl', ['list-units', '--failed', '--all', '--no-legend', '--plain', '--no-pager'], 10); + if ($result === null || !$result->succeeded()) { + return null; + } + + return self::parseUnitList($result->stdout); + } + + /** + * @return list + */ + public static function parseUnitList(string $output): array { + $units = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + // Older systemd puts a status bullet in front of each unit even with --plain. + $line = trim((string)preg_replace('/^[\x{25CF}\x{00D7}*]\s*/u', '', trim($line))); + if (preg_match('/^([A-Za-z0-9@._:\\\\-]+\.(service|socket|timer|mount|path|target|scope|slice|automount|swap|device))\s/', $line . ' ', $matches) === 1) { + $units[] = $matches[1]; + } + } + + return array_values(array_unique($units)); + } + + /** + * @return string|null "enabled", "disabled", "static", "masked"… or null when unknown + */ + public function getUnitFileState(string $unit): ?string { + if (preg_match('/^[A-Za-z0-9@._-]+$/', $unit) !== 1) { + return null; + } + + $result = $this->host->run('systemctl', ['is-enabled', $unit], 5); + if ($result === null) { + return null; + } + + $state = trim($result->stdout); + + return preg_match('/^[a-z-]{2,20}$/', $state) === 1 ? $state : null; + } + + public function isRunning(): bool { + return $this->host->fileExists('/run/systemd/system'); + } +} diff --git a/lib/OsStatus/Health/TimeSyncProbe.php b/lib/OsStatus/Health/TimeSyncProbe.php new file mode 100644 index 00000000..6b614dc5 --- /dev/null +++ b/lib/OsStatus/Health/TimeSyncProbe.php @@ -0,0 +1,50 @@ +host->run('timedatectl', ['show', '--property=NTPSynchronized', '--value'], 5); + if ($result !== null && $result->succeeded()) { + $value = trim($result->stdout); + if ($value === 'yes' || $value === 'no') { + return ['synchronized' => $value === 'yes', 'source' => 'timedatectl']; + } + } + + // systemd-timesyncd touches this file once it has synchronised. + if ($this->host->fileExists('/run/systemd/timesync/synchronized')) { + return ['synchronized' => true, 'source' => 'systemd-timesyncd']; + } + + $result = $this->host->run('chronyc', ['-n', 'tracking'], 5); + if ($result !== null && $result->succeeded() && preg_match('/^Leap status\s*:\s*(.+)$/m', $result->stdout, $matches) === 1) { + return ['synchronized' => trim($matches[1]) === 'Normal', 'source' => 'chrony']; + } + + return ['synchronized' => null, 'source' => null]; + } +} diff --git a/lib/OsStatus/Host/CommandResult.php b/lib/OsStatus/Host/CommandResult.php new file mode 100644 index 00000000..77ec1dac --- /dev/null +++ b/lib/OsStatus/Host/CommandResult.php @@ -0,0 +1,58 @@ +exitCode === 0 && !$this->timedOut && !$this->truncated && !$this->readFailed; + } + + /** + * Why the command did not succeed, for a diagnostic: the runner's own + * reason when it stopped the command, otherwise the first line it printed. + * + * @param int $timeout the time limit the command was run with, in seconds + */ + public function failure(int $timeout): string { + if ($this->timedOut) { + return sprintf('timed out after %d s', $timeout); + } + if ($this->truncated) { + return 'output too large'; + } + if ($this->readFailed) { + return 'output could not be read'; + } + + $line = mb_substr(trim((string)strtok(trim($this->stderr ?: $this->stdout), "\n")), 0, 200); + + return $line !== '' ? $line : sprintf('exit code %d', $this->exitCode); + } +} diff --git a/lib/OsStatus/Host/CommandRunner.php b/lib/OsStatus/Host/CommandRunner.php new file mode 100644 index 00000000..8cc43072 --- /dev/null +++ b/lib/OsStatus/Host/CommandRunner.php @@ -0,0 +1,306 @@ + binary => the path it runs from, or false when it is missing */ + private array $found = []; + + public function isAvailable(): bool { + return function_exists('proc_open') + && function_exists('proc_get_status') + && function_exists('proc_terminate') + && function_exists('proc_close'); + } + + public function resolve(string $binary): ?string { + $found = $this->found[$binary] ?? $this->lookUp($binary); + + return is_string($found) ? $found : null; + } + + /** + * Whether a binary is installed: null when open_basedir hides the system + * directories and it has not been run yet. + */ + public function state(string $binary): ?bool { + $found = $this->found[$binary] ?? $this->lookUp($binary); + + return $found === null ? null : is_string($found); + } + + /** + * @param list $arguments + */ + public function run(string $binary, array $arguments, int $timeout): ?CommandResult { + if (!$this->isAvailable()) { + return null; + } + + $found = $this->found[$binary] ?? null; + if ($found === false) { + return null; + } + + $candidates = is_string($found) ? [[$found, true]] : $this->candidates($binary); + $guessesOnly = true; + foreach ($candidates as [$path, $verified]) { + $guessesOnly = $guessesOnly && !$verified; + $result = $this->execute($path, $arguments, max(1, $timeout)); + if ($result === null) { + continue; + } + + // An unverified guess that does not exist fails exec in the child, + // which exits with 127 before printing anything. + if (!$verified && $result->exitCode === 127 && $result->stdout === '') { + continue; + } + + $this->found[$binary] = $path; + + return $result; + } + + // Every guess failed to start, so the binary is not there. + if ($guessesOnly) { + $this->found[$binary] = false; + } + + return null; + } + + /** + * Looks a binary up on disk and remembers a definite answer. + * + * @return string|false|null the path, false when it is missing, null when only running it can tell + */ + private function lookUp(string $binary): string|false|null { + $candidates = $this->candidates($binary); + if ($candidates === []) { + return $this->found[$binary] = false; + } + + [$path, $verified] = $candidates[0]; + + return $verified ? $this->found[$binary] = $path : null; + } + + /** + * Paths to try for a binary, each with whether it was seen to exist. + * + * Under open_basedir the system directories cannot be inspected, though + * processes can still be started from them, so the candidates are then + * tried blind. + * + * @return list + */ + private function candidates(string $binary): array { + if (str_starts_with($binary, '/')) { + if (preg_match('#^(/[A-Za-z0-9._+-]+)+$#', $binary) !== 1 || str_contains($binary, '/..')) { + return []; + } + + return $this->isExecutable($binary) + ? [[$binary, true]] + : ($this->hasOpenBasedir() ? [[$binary, false]] : []); + } + + if (preg_match('/^[A-Za-z0-9._+-]+$/', $binary) !== 1) { + return []; + } + + foreach (self::SEARCH_PATH as $directory) { + $path = $directory . '/' . $binary; + if ($this->isExecutable($path)) { + return [[$path, true]]; + } + } + + if (!$this->hasOpenBasedir()) { + return []; + } + + return array_map(static fn (string $directory): array => [$directory . '/' . $binary, false], self::SEARCH_PATH); + } + + private function isExecutable(string $path): bool { + return @is_file($path) && @is_executable($path); + } + + protected function hasOpenBasedir(): bool { + return trim((string)ini_get('open_basedir')) !== ''; + } + + /** + * @param list $arguments + */ + protected function execute(string $path, array $arguments, int $timeout): ?CommandResult { + $descriptors = [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $pipes = []; + $process = @proc_open([$path, ...$arguments], $descriptors, $pipes, null, $this->environment()); + if (!is_resource($process)) { + return null; + } + + $open = [1 => $pipes[1], 2 => $pipes[2]]; + foreach ($open as $stream) { + stream_set_blocking($stream, false); + } + + $output = [1 => '', 2 => '']; + $deadline = microtime(true) + (float)$timeout; + $timedOut = false; + $truncated = false; + $readFailed = false; + $selectFailures = 0; + + while ($open !== []) { + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + $timedOut = true; + break; + } + + $read = array_values($open); + if ($this->select($read, $remaining) === false) { + if (++$selectFailures < self::MAX_SELECT_FAILURES) { + continue; + } + $readFailed = true; + break; + } + $selectFailures = 0; + + foreach ($read as $stream) { + $fd = array_search($stream, $open, true); + if ($fd === false) { + continue; + } + + $chunk = fread($stream, 65536); + if ($chunk === false || ($chunk === '' && feof($stream))) { + fclose($stream); + unset($open[$fd]); + continue; + } + + if (strlen($output[$fd]) + strlen($chunk) > self::MAX_OUTPUT) { + $output[$fd] .= substr($chunk, 0, self::MAX_OUTPUT - strlen($output[$fd])); + $truncated = true; + break 2; + } + + $output[$fd] .= $chunk; + } + } + + foreach ($open as $stream) { + fclose($stream); + } + + $exitCode = $this->finish($process, $timedOut || $truncated || $readFailed); + + return new CommandResult($exitCode, $output[1], $output[2], $timedOut, $truncated, $readFailed); + } + + /** + * Waits until one of the streams can be read, at most $timeout seconds. + * + * @param list $read narrowed to the readable streams + * @return int|false false when waiting failed, e.g. interrupted by a signal + */ + protected function select(array &$read, float $timeout): int|false { + $write = null; + $except = null; + $seconds = (int)$timeout; + + return @stream_select($read, $write, $except, $seconds, (int)(($timeout - (float)$seconds) * 1000000.0)); + } + + /** + * Waits for the process to exit, killing it when asked to or when it + * outstays the grace period, and returns its exit code. + * + * The exit code has to be taken from proc_get_status(): once that has seen + * the process exit, proc_close() can only answer -1. + * + * @param resource $process + */ + private function finish($process, bool $kill): int { + if ($kill) { + proc_terminate($process, 9); + } + + $deadline = microtime(true) + self::EXIT_GRACE_SECONDS; + $exitCode = null; + do { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['signaled'] ? 128 + $status['termsig'] : $status['exitcode']; + break; + } + usleep(10000); + } while (microtime(true) < $deadline); + + if ($exitCode === null) { + proc_terminate($process, 9); + } + + $closed = proc_close($process); + + return $exitCode ?? ($closed >= 0 ? $closed : 137); + } + + /** + * @return array + */ + private function environment(): array { + $temp = sys_get_temp_dir(); + $home = getenv('HOME'); + + return [ + 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'LC_ALL' => 'C', + 'LANG' => 'C', + 'HOME' => is_string($home) && $home !== '' ? $home : $temp, + 'TMPDIR' => $temp, + // Wide enough that table output is never cut to a terminal width. + 'COLUMNS' => '512', + 'TERM' => 'dumb', + 'NO_COLOR' => '1', + ]; + } +} diff --git a/lib/OsStatus/Host/IHost.php b/lib/OsStatus/Host/IHost.php new file mode 100644 index 00000000..a8bf9cfd --- /dev/null +++ b/lib/OsStatus/Host/IHost.php @@ -0,0 +1,102 @@ + + */ + public function glob(string $pattern): array; + + /** + * Run a binary without a shell. + * + * @param string $binary a bare name looked up in the system directories, or an absolute path + * @param list $arguments + * @param int $timeout seconds before the process is killed + * @return CommandResult|null null when the binary is missing or processes cannot be started + */ + public function run(string $binary, array $arguments = [], int $timeout = 10): ?CommandResult; + + /** + * Whether a binary is known to be installed. + */ + public function hasBinary(string $binary): bool; + + /** + * Whether a binary is installed: true or false when that is known, null + * when only running it can tell, because open_basedir hides the system + * directories. + */ + public function binaryState(string $binary): ?bool; + + public function canRunCommands(): bool; + + public function environment(string $name): ?string; + + /** + * @param 'r'|'m'|'n' $mode as for php_uname() + */ + public function uname(string $mode): string; + + /** + * As PHP_OS_FAMILY, e.g. "Linux" or "BSD". + */ + public function osFamily(): string; + + /** + * Directories open_basedir confines PHP to, empty when it is not set. + * + * @return list + */ + public function openBasedir(): array; + + /** + * The PHP SAPI serving the current request, e.g. "fpm-fcgi", "apache2handler" or "cli". + */ + public function sapi(): string; +} diff --git a/lib/OsStatus/Host/LocalHost.php b/lib/OsStatus/Host/LocalHost.php new file mode 100644 index 00000000..f6a95675 --- /dev/null +++ b/lib/OsStatus/Host/LocalHost.php @@ -0,0 +1,151 @@ + 0 && ($newline = strpos($content, "\n")) !== false) { + $content = substr($content, $newline + 1); + } + + return $content; + } + + #[\Override] + public function fileExists(string $path): bool { + clearstatcache(true, $path); + + return @file_exists($path); + } + + #[\Override] + public function modifiedAt(string $path): ?int { + clearstatcache(true, $path); + $time = @filemtime($path); + + return $time === false ? null : $time; + } + + #[\Override] + public function changedAt(string $path): ?int { + clearstatcache(true, $path); + $time = @filectime($path); + + return $time === false ? null : $time; + } + + #[\Override] + public function readLink(string $path): ?string { + $target = @readlink($path); + + return $target === false ? null : $target; + } + + #[\Override] + public function glob(string $pattern): array { + $matches = @glob($pattern); + + return is_array($matches) ? array_values($matches) : []; + } + + #[\Override] + public function run(string $binary, array $arguments = [], int $timeout = 10): ?CommandResult { + return $this->runner->run($binary, $arguments, $timeout); + } + + #[\Override] + public function hasBinary(string $binary): bool { + return $this->runner->isAvailable() && $this->runner->resolve($binary) !== null; + } + + #[\Override] + public function binaryState(string $binary): ?bool { + return $this->runner->isAvailable() ? $this->runner->state($binary) : false; + } + + #[\Override] + public function canRunCommands(): bool { + return $this->runner->isAvailable(); + } + + #[\Override] + public function environment(string $name): ?string { + $value = getenv($name); + + return is_string($value) && $value !== '' ? $value : null; + } + + #[\Override] + public function uname(string $mode): string { + return php_uname($mode); + } + + #[\Override] + public function osFamily(): string { + return PHP_OS_FAMILY; + } + + #[\Override] + public function openBasedir(): array { + $setting = trim((string)ini_get('open_basedir')); + if ($setting === '') { + return []; + } + + return array_values(array_filter(explode(PATH_SEPARATOR, $setting), static fn (string $dir): bool => $dir !== '')); + } + + #[\Override] + public function sapi(): string { + return PHP_SAPI; + } +} diff --git a/lib/OsStatus/Host/SystemTimeZone.php b/lib/OsStatus/Host/SystemTimeZone.php new file mode 100644 index 00000000..b4fb412f --- /dev/null +++ b/lib/OsStatus/Host/SystemTimeZone.php @@ -0,0 +1,54 @@ +readLink('/etc/localtime'); + if ($link !== null && preg_match('#/zoneinfo/(.+)$#', $link, $matches) === 1) { + $zone = self::create($matches[1]); + if ($zone !== null) { + return $zone; + } + } + + $configured = $host->readFile('/etc/timezone', 128); + if ($configured !== null && ($zone = self::create(trim($configured))) !== null) { + return $zone; + } + + return new \DateTimeZone('UTC'); + } + + /** + * Parses a local wall-clock time such as "2026-10-19 06:36:20". + */ + public static function parse(string $time, \DateTimeZone $zone): ?int { + $parsed = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $time, $zone); + + return $parsed === false ? null : $parsed->getTimestamp(); + } + + private static function create(string $name): ?\DateTimeZone { + if ($name === '' || preg_match('#^[A-Za-z0-9/_+-]+$#', $name) !== 1) { + return null; + } + + try { + return new \DateTimeZone($name); + } catch (\Exception) { + return null; + } + } +} diff --git a/lib/OsStatus/NextcloudStack.php b/lib/OsStatus/NextcloudStack.php new file mode 100644 index 00000000..ac29748b --- /dev/null +++ b/lib/OsStatus/NextcloudStack.php @@ -0,0 +1,72 @@ + $idLike + */ + public function __construct( + public readonly string $id, + public readonly array $idLike, + public readonly string $name, + public readonly string $prettyName, + public readonly ?string $versionId, + public readonly ?string $codename, + public readonly ?string $variantId = null, + ) { + } + + public static function parse(string $content): ?self { + $values = []; + foreach (preg_split('/\R/', $content) ?: [] as $line) { + if (preg_match('/^([A-Z0-9_]+)=(.*)$/', trim($line), $matches) !== 1) { + continue; + } + $values[$matches[1]] = self::unquote($matches[2]); + } + + $id = strtolower($values['ID'] ?? ''); + if (preg_match('/^[a-z0-9._-]+$/', $id) !== 1) { + return null; + } + + $idLike = array_values(array_filter( + preg_split('/\s+/', strtolower($values['ID_LIKE'] ?? '')) ?: [], + static fn (string $like): bool => preg_match('/^[a-z0-9._-]+$/', $like) === 1, + )); + + $name = $values['NAME'] ?? $id; + $versionId = ($values['VERSION_ID'] ?? '') !== '' ? $values['VERSION_ID'] : null; + $prettyName = $values['PRETTY_NAME'] ?? trim($name . ' ' . ($values['VERSION'] ?? '')); + $codename = ($values['VERSION_CODENAME'] ?? '') !== '' ? $values['VERSION_CODENAME'] : null; + $variantId = ($values['VARIANT_ID'] ?? '') !== '' ? strtolower($values['VARIANT_ID']) : null; + + return new self($id, $idLike, $name, $prettyName, $versionId, $codename, $variantId); + } + + /** + * Whether this is the given distribution or derived from it. + */ + public function isLike(string $id): bool { + return $this->id === $id || in_array($id, $this->idLike, true); + } + + /** + * @return array{id: string, idLike: list, name: string, prettyName: string, versionId: ?string, codename: ?string, variantId: ?string} + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'idLike' => $this->idLike, + 'name' => $this->name, + 'prettyName' => $this->prettyName, + 'versionId' => $this->versionId, + 'codename' => $this->codename, + 'variantId' => $this->variantId, + ]; + } + + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || !is_string($data['id'] ?? null)) { + return null; + } + + return new self( + $data['id'], + array_values(array_filter((array)($data['idLike'] ?? []), 'is_string')), + (string)($data['name'] ?? $data['id']), + (string)($data['prettyName'] ?? $data['id']), + Values::string($data['versionId'] ?? null), + Values::string($data['codename'] ?? null), + Values::string($data['variantId'] ?? null), + ); + } + + /** + * Values may be double- or single-quoted with shell-style escapes. + */ + private static function unquote(string $value): string { + $value = trim($value); + if (strlen($value) >= 2 && ($value[0] === '"' || $value[0] === "'") && $value[-1] === $value[0]) { + $inner = substr($value, 1, -1); + + return $value[0] === '"' ? (string)preg_replace('/\\\\([\\\\"$`])/', '$1', $inner) : $inner; + } + + return $value; + } +} diff --git a/lib/OsStatus/OsStatus.php b/lib/OsStatus/OsStatus.php new file mode 100644 index 00000000..3e2c2bfb --- /dev/null +++ b/lib/OsStatus/OsStatus.php @@ -0,0 +1,207 @@ +|null $updates + * @param self::SOURCE_*|null $updatesSource + * @param list|null $held + * @param list|null $rebootReasons + * @param list|null $livePatches + * @param list|null $failedUnits + * @param array{all: ?string, security: ?string} $upgradeCommands + * @param list $diagnostics + */ + public function __construct( + public readonly string $state, + public readonly int $collectedAt, + public readonly ?OsRelease $release = null, + public readonly string $kernel = '', + public readonly string $architecture = '', + public readonly Environment $environment = new Environment(Environment::KIND_HOST), + public readonly SupportStatus $support = new SupportStatus(false, null), + public readonly ?string $packageManager = null, + public readonly ?array $updates = null, + public readonly ?string $updatesSource = null, + /** Whether the package manager tells security updates from others */ + public readonly bool $securityClassified = false, + /** Updates left out of $updates to keep the stored status small */ + public readonly int $updatesOmitted = 0, + public readonly ?array $held = null, + public readonly ?int $listsRefreshedAt = null, + public readonly ?int $lastUpgradeAt = null, + public readonly AutomaticUpdates $automaticUpdates = new AutomaticUpdates(AutomaticUpdates::STATE_UNKNOWN), + public readonly ?array $rebootReasons = null, + public readonly ?array $livePatches = null, + public readonly ?array $failedUnits = null, + public readonly ?bool $timeSynchronized = null, + public readonly ?string $timeSource = null, + public readonly ?string $selinux = null, + public readonly ?string $apparmor = null, + public readonly array $upgradeCommands = ['all' => null, 'security' => null], + public readonly ?string $packageDatabase = null, + public readonly ?int $bootTime = null, + public readonly array $diagnostics = [], + ) { + } + + public static function restricted(int $now): self { + return new self(self::STATE_RESTRICTED, $now); + } + + public static function unsupported(int $now, string $kernel, string $architecture): self { + return new self(self::STATE_UNSUPPORTED, $now, kernel: $kernel, architecture: $architecture); + } + + /** + * @return list|null + */ + public function securityUpdates(): ?array { + if ($this->updates === null) { + return null; + } + + return array_values(array_filter($this->updates, static fn (PendingUpdate $update): bool => $update->security)); + } + + public function toArray(): array { + return [ + 'schema' => self::SCHEMA, + 'state' => $this->state, + 'collectedAt' => $this->collectedAt, + 'release' => $this->release?->toArray(), + 'kernel' => $this->kernel, + 'architecture' => $this->architecture, + 'environment' => $this->environment->toArray(), + 'support' => $this->support->toArray(), + 'packageManager' => $this->packageManager, + 'updates' => $this->updates === null ? null : array_map(static fn (PendingUpdate $u): array => $u->toArray(), $this->updates), + 'updatesSource' => $this->updatesSource, + 'securityClassified' => $this->securityClassified, + 'updatesOmitted' => $this->updatesOmitted, + 'held' => $this->held === null ? null : array_map(static fn (HeldPackage $h): array => $h->toArray(), $this->held), + 'listsRefreshedAt' => $this->listsRefreshedAt, + 'lastUpgradeAt' => $this->lastUpgradeAt, + 'automaticUpdates' => $this->automaticUpdates->toArray(), + 'rebootReasons' => $this->rebootReasons === null ? null : array_map(static fn (RebootReason $r): array => $r->toArray(), $this->rebootReasons), + 'livePatches' => $this->livePatches, + 'failedUnits' => $this->failedUnits, + 'timeSynchronized' => $this->timeSynchronized, + 'timeSource' => $this->timeSource, + 'selinux' => $this->selinux, + 'apparmor' => $this->apparmor, + 'upgradeCommands' => $this->upgradeCommands, + 'packageDatabase' => $this->packageDatabase, + 'bootTime' => $this->bootTime, + 'diagnostics' => $this->diagnostics, + ]; + } + + /** + * Rebuilds a stored status. Anything malformed reads as unknown rather + * than failing, so a damaged cache entry cannot break the page. + */ + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || ($data['schema'] ?? null) !== self::SCHEMA || !is_int($data['collectedAt'] ?? null)) { + return null; + } + + $state = in_array($data['state'] ?? null, [self::STATE_OK, self::STATE_RESTRICTED, self::STATE_UNSUPPORTED], true) + ? $data['state'] : self::STATE_OK; + $source = ($data['updatesSource'] ?? null) === self::SOURCE_SYSTEM ? self::SOURCE_SYSTEM : null; + $commands = is_array($data['upgradeCommands'] ?? null) ? $data['upgradeCommands'] : []; + + return new self( + $state, + $data['collectedAt'], + OsRelease::fromArray($data['release'] ?? null), + is_string($data['kernel'] ?? null) ? $data['kernel'] : '', + is_string($data['architecture'] ?? null) ? $data['architecture'] : '', + Environment::fromArray($data['environment'] ?? null), + SupportStatus::fromArray($data['support'] ?? null), + Values::string($data['packageManager'] ?? null), + self::listOf($data['updates'] ?? null, static fn (mixed $item): ?PendingUpdate => PendingUpdate::fromArray($item)), + $source, + ($data['securityClassified'] ?? false) === true, + is_int($data['updatesOmitted'] ?? null) ? $data['updatesOmitted'] : 0, + self::listOf($data['held'] ?? null, static fn (mixed $item): ?HeldPackage => HeldPackage::fromArray($item)), + Values::int($data['listsRefreshedAt'] ?? null), + Values::int($data['lastUpgradeAt'] ?? null), + AutomaticUpdates::fromArray($data['automaticUpdates'] ?? null), + self::listOf($data['rebootReasons'] ?? null, static fn (mixed $item): ?RebootReason => RebootReason::fromArray($item)), + self::stringList($data['livePatches'] ?? null), + self::stringList($data['failedUnits'] ?? null), + is_bool($data['timeSynchronized'] ?? null) ? $data['timeSynchronized'] : null, + Values::string($data['timeSource'] ?? null), + Values::string($data['selinux'] ?? null), + Values::string($data['apparmor'] ?? null), + [ + 'all' => is_string($commands['all'] ?? null) ? $commands['all'] : null, + 'security' => is_string($commands['security'] ?? null) ? $commands['security'] : null, + ], + Values::string($data['packageDatabase'] ?? null), + Values::int($data['bootTime'] ?? null), + self::stringList($data['diagnostics'] ?? null) ?? [], + ); + } + + /** + * @template T + * @param callable(mixed): (T|null) $map + * @return list|null + */ + private static function listOf(mixed $items, callable $map): ?array { + if (!is_array($items)) { + return null; + } + + $list = []; + foreach ($items as $item) { + $value = $map($item); + if ($value !== null) { + $list[] = $value; + } + } + + return $list; + } + + /** + * @return list|null + */ + private static function stringList(mixed $items): ?array { + return is_array($items) ? array_values(array_filter($items, 'is_string')) : null; + } +} diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php new file mode 100644 index 00000000..d4733300 --- /dev/null +++ b/lib/OsStatus/OsStatusCollector.php @@ -0,0 +1,315 @@ +timeFactory->getTime(); + + if ($this->appConfig->getAppValueString('restricted_mode', 'no') === 'yes') { + return OsStatus::restricted($now); + } + + $kernel = trim($this->host->uname('r')); + $architecture = trim($this->host->uname('m')); + if ($this->host->osFamily() !== 'Linux') { + return OsStatus::unsupported($now, $kernel, $architecture); + } + + $diagnostics = []; + if (!$this->host->canRunCommands()) { + $diagnostics[] = 'Commands cannot be run because proc_open() is disabled in the PHP configuration; only files are read.'; + } + if ($this->host->openBasedir() !== []) { + $diagnostics[] = 'open_basedir is set, so system files outside the allowed directories cannot be read.'; + } + + $release = OsRelease::parse($this->host->readFile('/etc/os-release', 65536) ?? $this->host->readFile('/usr/lib/os-release', 65536) ?? ''); + if ($release === null) { + $diagnostics[] = '/etc/os-release could not be read, so the distribution is unknown.'; + } + + $environment = $this->probe('The environment', fn (): Environment => $this->environmentDetector->detect(), new Environment(Environment::KIND_HOST), $diagnostics); + $uptime = $this->probe('The boot time', fn (): int => $this->os->getUptime(), -1, $diagnostics); + $bootTime = $uptime >= 0 ? $now - $uptime : null; + + $support = $release !== null + ? $this->probe('The support period', fn (): SupportStatus => $this->supportTable->lookup($release), SupportStatus::unknown(), $diagnostics) + : SupportStatus::unknown(); + if ($support->extended !== null) { + $extended = $support->extended; + $support = $support->withExtendedActive($this->probe('The extended support subscription', fn (): ?bool => $this->subscriptions->isActive($extended), null, $diagnostics)); + } + + $packageManager = null; + if ($release !== null && $environment->managesPackages()) { + $packageManager = $this->probe('The package manager', fn (): ?IPackageManager => $this->packageManagers->forRelease($release), null, $diagnostics); + if ($packageManager === null) { + $diagnostics[] = sprintf('No supported package manager was found for %s.', $release->prettyName); + } + } + + $updates = null; + $updatesSource = null; + if ($packageManager !== null) { + try { + $updates = $packageManager->getPendingUpdates(); + $updatesSource = OsStatus::SOURCE_SYSTEM; + } catch (ProbeException $e) { + $diagnostics[] = $e->getMessage(); + } catch (\Throwable $e) { + $this->logger->warning('Pending updates could not be determined', ['exception' => $e]); + $diagnostics[] = 'Pending updates could not be determined; see the Nextcloud log.'; + } + } + + $listsRefreshedAt = $packageManager !== null + ? $this->probe('The package list age', fn (): ?int => $packageManager->getListsRefreshedAt(), null, $diagnostics) + : null; + // Without package lists the package manager knows of no newer versions at all. + if ($updates === [] && $listsRefreshedAt === null && in_array($packageManager?->getId(), OsVerdict::LISTS_AGE_MATTERS, true)) { + $updates = null; + $updatesSource = null; + $diagnostics[] = 'The package lists could not be found, so pending updates are unknown until the lists are refreshed.'; + } + + $rebootReasons = $environment->managesPackages() + ? $this->probe('Whether a reboot is needed', fn (): array => $this->rebootReasons($packageManager, $environment, $bootTime), null, $diagnostics) + : null; + + $classified = $packageManager?->classifiesSecurity() ?? false; + [$updates, $rebootReasons] = $this->probe( + 'How long the findings have been pending', + fn (): array => $this->applyFirstSeen($updates, $classified, $rebootReasons, $now), + [$updates, $rebootReasons], + $diagnostics, + ); + + $omitted = 0; + if ($updates !== null) { + $updates = self::sortUpdates($updates); + $omitted = max(0, count($updates) - self::MAX_LISTED_UPDATES); + $updates = array_slice($updates, 0, self::MAX_LISTED_UPDATES); + } + + $timeSync = $this->probe('The time synchronisation', fn (): array => $this->timeSync->probe(), ['synchronized' => null, 'source' => null], $diagnostics); + + return new OsStatus( + OsStatus::STATE_OK, + $now, + $release, + $kernel, + $architecture, + $environment, + $support, + $packageManager?->getId(), + $updates, + $updatesSource, + $classified, + $omitted, + $packageManager !== null ? $this->probe('The held packages', fn (): ?array => $packageManager->getHeldPackages(), null, $diagnostics) : null, + $listsRefreshedAt, + $packageManager !== null ? $this->probe('The last upgrade', fn (): ?int => $packageManager->getLastUpgradeAt(), null, $diagnostics) : null, + $packageManager !== null + ? $this->probe('The automatic updates', fn (): AutomaticUpdates => $packageManager->getAutomaticUpdates(), AutomaticUpdates::unknown(), $diagnostics) + : AutomaticUpdates::unknown(), + $rebootReasons, + $this->probe('The live kernel patches', fn (): ?array => $this->security->getLivePatches(), null, $diagnostics), + $this->probe('The failed system services', fn (): ?array => $this->systemd->getFailedUnits(), null, $diagnostics), + $timeSync['synchronized'], + $timeSync['source'], + $this->probe('The SELinux state', fn (): ?string => $this->security->getSelinux(), null, $diagnostics), + $this->probe('The AppArmor state', fn (): ?string => $this->security->getApparmor(), null, $diagnostics), + $packageManager?->getUpgradeCommands() ?? ['all' => null, 'security' => null], + $packageManager?->getPackageDatabasePath(), + $bootTime, + $diagnostics, + ); + } + + /** + * Runs one probe, so that a probe that throws costs its own finding and + * not the whole run. + * + * @template T + * @param string $finding what the probe finds out, to start a sentence with + * @param callable(): T $probe + * @param T $fallback the unknown answer + * @param list $diagnostics + * @param-out list $diagnostics + * @return T + */ + private function probe(string $finding, callable $probe, mixed $fallback, array &$diagnostics): mixed { + try { + return $probe(); + } catch (\Throwable $e) { + $this->logger->warning($finding . ' could not be determined', ['exception' => $e]); + $diagnostics[] = sprintf('%s could not be determined; see the Nextcloud log.', $finding); + + return $fallback; + } + } + + /** + * Collects and stores the result, logging rather than throwing so a + * failure does not take the background job queue down with it. + */ + public function collectAndStore(): ?OsStatus { + try { + $status = $this->collect(); + $this->store->save($status); + + return $status; + } catch (\Throwable $e) { + $this->logger->error('The operating system status could not be collected', ['exception' => $e]); + + return null; + } + } + + /** + * @return list + */ + private function rebootReasons(?IPackageManager $packageManager, Environment $environment, ?int $bootTime): array { + $reasons = []; + $add = static function (RebootReason $reason) use (&$reasons, $environment): void { + if (!$environment->ownsKernel() && in_array($reason->kind, [RebootReason::KIND_KERNEL, RebootReason::KIND_KERNEL_REMOVED, RebootReason::KIND_MICROCODE], true)) { + return; + } + $reasons[$reason->key()] ??= $reason; + }; + + foreach ($packageManager?->getRebootReasons() ?? [] as $reason) { + $add($reason); + } + foreach ($this->kernelProbe->getRebootReasons($bootTime) as $reason) { + $add($reason); + } + + $hasDistributionAnswer = ($packageManager?->hasOwnRebootCheck() ?? false) + || array_filter($reasons, static fn (RebootReason $r): bool => $r->kind === RebootReason::KIND_DISTRIBUTION) !== []; + // A system container may see the host's uptime, which says nothing about when its own processes started. + if (!$hasDistributionAnswer && $bootTime !== null && $environment->kind !== Environment::KIND_SYSTEM_CONTAINER) { + foreach ($this->coreLibraryProbe->getRebootReasons($bootTime, $packageManager?->getPackageDatabasePath()) as $reason) { + $add($reason); + } + } + + return array_values($reasons); + } + + /** + * Stamps every finding with when it was first seen, which the thresholds + * count from. + * + * @param list|null $updates + * @param list|null $reasons + * @return array{0: list|null, 1: list|null} + */ + private function applyFirstSeen(?array $updates, bool $classified, ?array $reasons, int $now): array { + $current = []; + $scope = []; + + if ($updates !== null) { + $scope[] = 'update'; + foreach ($updates as $update) { + if ($update->security || !$classified) { + $current['update:' . $update->key()] = null; + } + } + } + if ($reasons !== null) { + $scope[] = 'reboot'; + foreach ($reasons as $reason) { + $current['reboot:' . $reason->key()] = $reason->since; + } + } + if ($scope === []) { + return [$updates, $reasons]; + } + + $seen = $this->store->firstSeen($current, $now, $scope); + + return [ + $updates === null ? null : array_map( + static fn (PendingUpdate $u): PendingUpdate => $u->withSince($seen['update:' . $u->key()] ?? null), + $updates, + ), + $reasons === null ? null : array_map( + static fn (RebootReason $r): RebootReason => $r->withSince($seen['reboot:' . $r->key()] ?? $r->since), + $reasons, + ), + ]; + } + + /** + * Security updates first, then the packages Nextcloud depends on, then by name. + * + * @param list $updates + * @return list + */ + private static function sortUpdates(array $updates): array { + usort($updates, static fn (PendingUpdate $a, PendingUpdate $b): int => [!$a->security, !$a->isStack(), $a->name] + <=> [!$b->security, !$b->isStack(), $b->name]); + + return $updates; + } +} diff --git a/lib/OsStatus/OsStatusService.php b/lib/OsStatus/OsStatusService.php new file mode 100644 index 00000000..a5c7120d --- /dev/null +++ b/lib/OsStatus/OsStatusService.php @@ -0,0 +1,98 @@ +store->load(); + } + + /** + * Looks at the PHP process serving this request and remembers what it + * found. Cheap: one read of /proc/self/maps. + */ + public function samplePhpProcess(): void { + if (!$this->phpProcessProbe->canSample()) { + return; + } + + $sample = $this->phpProcessProbe->sample($this->store->load()?->release, $this->timeFactory->getTime()); + if ($sample !== null) { + $this->store->savePhpSample($sample); + } + } + + public function getPhpSample(): ?PhpProcessSample { + return $this->store->loadPhpSample(); + } + + public function getThresholds(): Thresholds { + return Thresholds::load($this->appConfig); + } + + public function evaluate(?OsStatus $status = null): ?Verdict { + $status ??= $this->store->load(); + if ($status === null) { + return null; + } + + return $this->verdict->evaluate( + $status, + $this->store->loadPhpSample(), + $this->store->phpOutdatedSince(), + $this->getThresholds(), + $this->timeFactory->getTime(), + UpdateOsStatus::runsFromSystemCron($this->serverConfig), + UpdateOsStatus::interval($this->serverConfig), + ); + } + + /** + * Whether packages were installed or removed after the status was + * collected, so its pending updates may already be installed. + */ + public function isOutdated(OsStatus $status): bool { + if ($status->packageDatabase === null) { + return false; + } + + $changedAt = $this->host->modifiedAt($status->packageDatabase); + + return $changedAt !== null && $changedAt > $status->collectedAt; + } +} diff --git a/lib/OsStatus/OsStatusStore.php b/lib/OsStatus/OsStatusStore.php new file mode 100644 index 00000000..873036cf --- /dev/null +++ b/lib/OsStatus/OsStatusStore.php @@ -0,0 +1,232 @@ +cache = $cacheFactory->createDistributed('serverinfo-os'); + } + + /** + * @throws NotPermittedException|NotFoundException|\JsonException when the status cannot be written + */ + public function save(OsStatus $status): void { + $json = json_encode($status->toArray(), JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES); + + $folder = $this->folder(); + if ($folder->fileExists(self::FILE)) { + $folder->getFile(self::FILE)->putContent($json); + } else { + $folder->newFile(self::FILE, $json); + } + + $this->cache->set(self::CACHE_KEY, $json, self::CACHE_TTL); + $this->status = $status; + $this->loaded = true; + + if ($this->appConfig->hasAppKey(self::LEGACY_STATUS_KEY, null)) { + $this->appConfig->deleteAppValue(self::LEGACY_STATUS_KEY); + } + } + + /** + * The stored status, read once per request. + */ + public function load(): ?OsStatus { + if (!$this->loaded) { + $this->status = $this->read(); + $this->loaded = true; + } + + return $this->status; + } + + private function read(): ?OsStatus { + $json = $this->cache->get(self::CACHE_KEY); + if (!is_string($json)) { + try { + $json = $this->appData->getFolder(self::FOLDER)->getFile(self::FILE)->getContent(); + } catch (NotFoundException) { + $json = ''; + } catch (NotPermittedException|GenericFileException) { + return null; + } + // An empty entry remembers that nothing has been collected yet. + $this->cache->set(self::CACHE_KEY, $json, self::CACHE_TTL); + } + + if ($json === '') { + return null; + } + + try { + return OsStatus::fromArray(json_decode($json, true, 64, JSON_THROW_ON_ERROR)); + } catch (\JsonException) { + return null; + } + } + + /** + * @throws NotPermittedException + */ + private function folder(): ISimpleFolder { + try { + return $this->appData->getFolder(self::FOLDER); + } catch (NotFoundException) { + return $this->appData->newFolder(self::FOLDER); + } + } + + /** + * Remembers what a web request saw of its own PHP process. The same + * findings are written at most every few minutes, because this runs on + * page loads. + */ + public function savePhpSample(PhpProcessSample $sample): void { + $previous = $this->loadPhpSample(); + if ($previous !== null + && $previous->outdatedLibraries === $sample->outdatedLibraries + && $previous->service === $sample->service + && $previous->confinement === $sample->confinement + && $sample->sampledAt - $previous->sampledAt < self::PHP_SAMPLE_INTERVAL) { + return; + } + + $this->appConfig->setAppValueArray(ConfigLexicon::CACHED_OS_PHP_PROCESS, $sample->toArray()); + + $since = $this->phpOutdatedSince(); + if ($sample->isOutdated() && $since === null) { + $this->appConfig->setAppValueInt(ConfigLexicon::CACHED_OS_PHP_OUTDATED_SINCE, $sample->sampledAt); + } elseif (!$sample->isOutdated() && $since !== null) { + $this->appConfig->setAppValueInt(ConfigLexicon::CACHED_OS_PHP_OUTDATED_SINCE, 0); + } + } + + public function loadPhpSample(): ?PhpProcessSample { + return PhpProcessSample::fromArray($this->appConfig->getAppValueArray(ConfigLexicon::CACHED_OS_PHP_PROCESS)); + } + + /** + * When the PHP process was first seen running outdated libraries. + * + * Web requests keep this under a key of its own, because the collector + * rewrites the first-seen times of everything else at the same time. + */ + public function phpOutdatedSince(): ?int { + $since = $this->appConfig->getAppValueInt(ConfigLexicon::CACHED_OS_PHP_OUTDATED_SINCE); + + return $since > 0 ? $since : null; + } + + /** + * Tracks when each finding was first seen, which is what the alert + * thresholds count from. Distributions do not record how long an update + * has been waiting, so the app has to. + * + * Findings of one scope that are no longer current are forgotten, so a + * finding that comes back starts counting again. + * + * @param array $current key => when the system says it began, null when it does not say + * @param list|null $scope prefixes this call is authoritative for; all keys when null + * @return array key => first seen + */ + public function firstSeen(array $current, int $now, ?array $scope = null): array { + $stored = $this->appConfig->getAppValueArray(ConfigLexicon::CACHED_OS_FIRST_SEEN); + + $result = []; + foreach ($stored as $key => $since) { + if (!is_string($key) || !is_int($since)) { + continue; + } + if (!array_key_exists($key, $current) && !$this->inScope($key, $scope)) { + $result[$key] = $since; + } + } + + $found = []; + foreach ($current as $key => $known) { + if ($scope !== null && !$this->inScope($key, $scope)) { + continue; + } + + $previous = $stored[$key] ?? null; + $candidates = []; + if (is_int($previous)) { + $candidates[] = $previous; + } + if ($known !== null) { + $candidates[] = $known; + } + $found[$key] = $result[$key] = $candidates === [] ? $now : min($candidates); + } + + if ($result != $stored) { + $this->appConfig->setAppValueArray(ConfigLexicon::CACHED_OS_FIRST_SEEN, $result); + } + + return $found; + } + + /** + * @param list|null $scope + */ + private function inScope(string $key, ?array $scope): bool { + if ($scope === null) { + return true; + } + + foreach ($scope as $prefix) { + if ($key === $prefix || str_starts_with($key, $prefix . ':')) { + return true; + } + } + + return false; + } +} diff --git a/lib/OsStatus/Packages/Apt.php b/lib/OsStatus/Packages/Apt.php new file mode 100644 index 00000000..ed2c5063 --- /dev/null +++ b/lib/OsStatus/Packages/Apt.php @@ -0,0 +1,333 @@ +host->binaryState('apt-get') !== false; + } + + /** + * The same package manager, for the given distribution. + */ + public function withRelease(OsRelease $release): self { + $clone = clone $this; + $clone->release = $release; + + return $clone; + } + + /** + * Security updates are told apart by the distribution's own archives, + * so they need to know which distribution this is. + */ + #[\Override] + public function classifiesSecurity(): bool { + return $this->release !== null; + } + + #[\Override] + public function getPendingUpdates(): array { + $result = $this->host->run('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], self::SIMULATE_TIMEOUT); + if ($result === null) { + throw new ProbeException('apt-get could not be run.'); + } + if (!$result->succeeded()) { + throw new ProbeException('apt-get -s dist-upgrade failed: ' . $result->failure(self::SIMULATE_TIMEOUT)); + } + + return self::parseSimulation($result->stdout, $this->release); + } + + /** + * Parses the "Inst" lines of an `apt-get -s` run, e.g. + * Inst libssl3 [3.0.11-1~deb12u2] (3.0.15-1~deb12u1 Debian:12.8/stable, Debian-Security:12/stable-security [amd64]) + * A line whose name, versions or archives do not look like apt's is left out. + * Without the release, no update is counted as a security update. + * + * @return list + */ + public static function parseSimulation(string $output, ?OsRelease $release = null): array { + $updates = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^Inst (\S+)(?: \[([^\]]*)\])? \((\S+) ([^()\[]*?)(?: \[[^\]]*\])?\)/', $line, $matches) !== 1) { + continue; + } + + $origins = array_values(array_filter(array_map('trim', explode(',', $matches[4])))); + if (preg_match(self::PACKAGE_NAME, $matches[1]) !== 1 + || ($matches[2] !== '' && preg_match(self::VERSION, $matches[2]) !== 1) + || preg_match(self::VERSION, $matches[3]) !== 1 + || array_filter($origins, static fn (string $origin): bool => preg_match(self::ORIGIN, $origin) !== 1) !== []) { + continue; + } + + $security = false; + foreach ($origins as $origin) { + if ($release !== null && self::isSecurityArchive($origin, $release)) { + $security = true; + } + } + + $updates[$matches[1]] = new PendingUpdate( + $matches[1], + $matches[2] !== '' ? $matches[2] : null, + $matches[3], + $security, + $origins !== [] ? implode(', ', $origins) : null, + ); + } + + return array_values($updates); + } + + /** + * Whether an archive ("Label:Version/Suite") is a "-security" suite of the + * distribution or one it derives from: the label must be the os-release + * ID or an ID_LIKE entry, optionally followed by "-Security", "ESM" or + * "ESMApps". A third-party suite named "*-security" does not count; one + * that copies the distribution's label still would. + */ + private static function isSecurityArchive(string $archive, OsRelease $release): bool { + $colon = strpos($archive, ':'); + if ($colon === false) { + return false; + } + + $slash = strrpos($archive, '/'); + $suite = substr($archive, $slash === false ? $colon + 1 : $slash + 1); + $label = strtolower(substr($archive, 0, $colon)); + $distribution = (string)preg_replace('/(?:-security|esm|esmapps)$/', '', $label); + + return str_ends_with($suite, '-security') && $release->isLike($distribution); + } + + #[\Override] + public function getHeldPackages(): ?array { + $result = $this->host->run('apt-mark', ['showhold'], 30); + if ($result === null || !$result->succeeded()) { + return null; + } + + $held = []; + foreach (preg_split('/\R/', trim($result->stdout)) ?: [] as $name) { + $name = trim($name); + if (preg_match(self::PACKAGE_NAME, $name) === 1) { + $held[] = new HeldPackage($name, 'apt-mark hold'); + } + } + + return $held; + } + + /** + * update-success-stamp is touched after every `apt-get update` that + * succeeded, where a hook for it is installed. Otherwise the newest + * release file tells: apt only moves one into place after verifying it, + * and keeps the mirror's modification time, so it is when the lists were + * refreshed at the earliest. lists/partial is not used: it is written on + * every run, also on one that fails. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + $stamp = $this->host->modifiedAt(self::UPDATE_SUCCESS_STAMP); + if ($stamp !== null) { + return $stamp; + } + + $times = []; + foreach (['*InRelease', '*_Release'] as $pattern) { + foreach ($this->host->glob(self::LISTS . '/' . $pattern) as $file) { + $times[] = $this->host->modifiedAt($file); + } + } + $times = array_filter($times, 'is_int'); + + return $times === [] ? null : max($times); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + $log = $this->host->readFileTail(self::DPKG_LOG, 512 * 1024); + if ($log !== null) { + $last = self::lastUpgradeFromLog($log, SystemTimeZone::of($this->host)); + if ($last !== null) { + return $last; + } + } + + return $this->host->modifiedAt(self::DPKG_STATUS); + } + + /** + * The time of the last install or upgrade in a dpkg.log, which is + * written in the system's local time. + */ + public static function lastUpgradeFromLog(string $log, \DateTimeZone $zone): ?int { + if (preg_match_all('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (?:upgrade|install) /m', $log, $matches) < 1) { + return null; + } + + return SystemTimeZone::parse((string)end($matches[1]), $zone); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + $installed = $this->host->fileExists('/usr/bin/unattended-upgrade'); + + $result = $this->host->run('apt-config', ['dump'], 30); + if ($result === null || !$result->succeeded()) { + return $installed ? AutomaticUpdates::unknown() : new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED); + } + $config = $result->stdout; + + $periodic = self::configValue($config, 'APT::Periodic::Unattended-Upgrade'); + if (!$installed || $periodic === null || $periodic === '0' || $periodic === '') { + $download = self::configValue($config, 'APT::Periodic::Download-Upgradeable-Packages'); + if ($download !== null && $download !== '0' && $download !== '') { + return new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED, 'apt-daily', AutomaticUpdates::SCOPE_DOWNLOAD); + } + + return new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED); + } + + // The schedule comes from the systemd timer; a disabled or masked timer + // means the configuration above never runs. + $timer = $this->systemd->getUnitFileState('apt-daily-upgrade.timer'); + if ($timer === 'disabled' || $timer === 'masked') { + return new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED, 'unattended-upgrades'); + } + + return new AutomaticUpdates(AutomaticUpdates::STATE_ENABLED, 'unattended-upgrades', self::unattendedScope($config)); + } + + /** + * Whether unattended-upgrades is limited to security origins. + * + * The bare release pocket ("Ubuntu:noble") only changes at release time, + * so allowing it does not widen the scope. + * + * @return AutomaticUpdates::SCOPE_* + */ + public static function unattendedScope(string $config): string { + $patterns = []; + if (preg_match_all('/^Unattended-Upgrade::(?:Allowed-Origins|Origins-Pattern):: "(.*)";$/m', $config, $matches) > 0) { + $patterns = $matches[1]; + } + if ($patterns === []) { + return AutomaticUpdates::SCOPE_UNKNOWN; + } + + foreach ($patterns as $pattern) { + if (stripos($pattern, 'security') !== false || $pattern === '${distro_id}:${distro_codename}') { + continue; + } + + return AutomaticUpdates::SCOPE_ALL; + } + + return AutomaticUpdates::SCOPE_SECURITY; + } + + /** + * The value of a scalar option in `apt-config dump` output. + */ + public static function configValue(string $config, string $key): ?string { + if (preg_match('/^' . preg_quote($key, '/') . ' "(.*)";$/m', $config, $matches) !== 1) { + return null; + } + + return $matches[1]; + } + + #[\Override] + public function getRebootReasons(): array { + if (!$this->host->fileExists(self::REBOOT_REQUIRED)) { + return []; + } + + $packages = []; + $list = $this->host->readFile(self::REBOOT_REQUIRED . '.pkgs', 65536) ?? ''; + foreach (preg_split('/\R/', $list) ?: [] as $package) { + $package = trim($package); + if (preg_match(self::PACKAGE_NAME, $package) === 1) { + $packages[$package] = true; + } + } + + return [new RebootReason( + RebootReason::KIND_DISTRIBUTION, + null, + array_keys($packages), + $this->host->modifiedAt(self::REBOOT_REQUIRED), + )]; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + return false; + } + + #[\Override] + public function getUpgradeCommands(): array { + return [ + 'all' => 'sudo apt update && sudo apt full-upgrade', + 'security' => $this->host->fileExists('/usr/bin/unattended-upgrade') ? 'sudo unattended-upgrade -v' : null, + ]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return self::DPKG_STATUS; + } +} diff --git a/lib/OsStatus/Packages/AutomaticUpdates.php b/lib/OsStatus/Packages/AutomaticUpdates.php new file mode 100644 index 00000000..dba3a9b4 --- /dev/null +++ b/lib/OsStatus/Packages/AutomaticUpdates.php @@ -0,0 +1,67 @@ + $this->state, + 'mechanism' => $this->mechanism, + 'scope' => $this->scope, + ]; + } + + public static function fromArray(mixed $data): self { + if (!is_array($data)) { + return self::unknown(); + } + + $state = in_array($data['state'] ?? null, [self::STATE_ENABLED, self::STATE_DISABLED], true) ? $data['state'] : self::STATE_UNKNOWN; + $scope = in_array($data['scope'] ?? null, [self::SCOPE_SECURITY, self::SCOPE_ALL, self::SCOPE_DOWNLOAD], true) ? $data['scope'] : self::SCOPE_UNKNOWN; + + return new self($state, Values::string($data['mechanism'] ?? null), $scope); + } +} diff --git a/lib/OsStatus/Packages/HeldPackage.php b/lib/OsStatus/Packages/HeldPackage.php new file mode 100644 index 00000000..2708b642 --- /dev/null +++ b/lib/OsStatus/Packages/HeldPackage.php @@ -0,0 +1,45 @@ + $this->name, + 'mechanism' => $this->mechanism, + 'constraint' => $this->constraint, + ]; + } + + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || !is_string($data['name'] ?? null) || !is_string($data['mechanism'] ?? null)) { + return null; + } + + return new self($data['name'], $data['mechanism'], Values::string($data['constraint'] ?? null)); + } +} diff --git a/lib/OsStatus/Packages/IPackageManager.php b/lib/OsStatus/Packages/IPackageManager.php new file mode 100644 index 00000000..67eabd09 --- /dev/null +++ b/lib/OsStatus/Packages/IPackageManager.php @@ -0,0 +1,86 @@ + + * @throws ProbeException when the package manager could not be asked + */ + public function getPendingUpdates(): array; + + /** + * @return list|null null when holds cannot be listed + */ + public function getHeldPackages(): ?array; + + /** + * When the repository metadata was last refreshed, as a unix timestamp. + */ + public function getListsRefreshedAt(): ?int; + + /** + * When packages were last installed or upgraded, as a unix timestamp. + */ + public function getLastUpgradeAt(): ?int; + + public function getAutomaticUpdates(): AutomaticUpdates; + + /** + * Reboot reasons only this package manager's tooling knows about. The + * generic kernel and library checks are made separately. + * + * @return list + */ + public function getRebootReasons(): array; + + /** + * Whether getRebootReasons() reflects a complete check of its own, so the + * generic core library check would only repeat it. + */ + public function hasOwnRebootCheck(): bool; + + /** + * Shell commands an admin runs to install the pending updates. + * + * @return array{all: string, security: ?string} + */ + public function getUpgradeCommands(): array; + + /** + * A file that changes whenever packages are installed or removed, used to + * tell that a cached status is out of date. + */ + public function getPackageDatabasePath(): ?string; +} diff --git a/lib/OsStatus/Packages/PackageManagerFactory.php b/lib/OsStatus/Packages/PackageManagerFactory.php new file mode 100644 index 00000000..52c2cebb --- /dev/null +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -0,0 +1,34 @@ +isLike('debian') || $release->isLike('ubuntu') => $this->apt->withRelease($release), + default => null, + }; + + return $candidate !== null && $candidate->isAvailable() ? $candidate : null; + } +} diff --git a/lib/OsStatus/Packages/PendingUpdate.php b/lib/OsStatus/Packages/PendingUpdate.php new file mode 100644 index 00000000..0ab3ee08 --- /dev/null +++ b/lib/OsStatus/Packages/PendingUpdate.php @@ -0,0 +1,89 @@ + $advisories advisory or CVE identifiers, e.g. "USN-7012-1", "RHSA-2026:1234" + */ + public function __construct( + public readonly string $name, + public readonly ?string $installedVersion, + public readonly string $candidateVersion, + public readonly bool $security, + /** Repository, suite or channel the update comes from */ + public readonly ?string $origin = null, + public readonly array $advisories = [], + /** Severity the distribution gives the advisory, e.g. "Important" */ + public readonly ?string $severity = null, + /** When this update was first seen pending, as a unix timestamp */ + public readonly ?int $since = null, + ) { + } + + /** + * @return array{name: string, installed: ?string, candidate: string, security: bool, origin: ?string, advisories: list, severity: ?string, since: ?int, stack: bool} + */ + public function toArray(): array { + return [ + 'name' => $this->name, + 'installed' => $this->installedVersion, + 'candidate' => $this->candidateVersion, + 'security' => $this->security, + 'origin' => $this->origin, + 'advisories' => $this->advisories, + 'severity' => $this->severity, + 'since' => $this->since, + 'stack' => $this->isStack(), + ]; + } + + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || !is_string($data['name'] ?? null) || !is_string($data['candidate'] ?? null)) { + return null; + } + + return new self( + $data['name'], + Values::string($data['installed'] ?? null), + $data['candidate'], + ($data['security'] ?? false) === true, + Values::string($data['origin'] ?? null), + Values::strings($data['advisories'] ?? null), + Values::string($data['severity'] ?? null), + Values::int($data['since'] ?? null), + ); + } + + /** + * Whether this updates something Nextcloud runs on. + */ + public function isStack(): bool { + return NextcloudStack::isStackPackage($this->name); + } + + /** + * What the first-seen time is tracked by: the package, not the version, so + * a newer update superseding a pending one keeps counting. + */ + public function key(): string { + return $this->name; + } + + public function withSince(?int $since): self { + return new self($this->name, $this->installedVersion, $this->candidateVersion, $this->security, $this->origin, $this->advisories, $this->severity, $since); + } +} diff --git a/lib/OsStatus/Packages/ProbeException.php b/lib/OsStatus/Packages/ProbeException.php new file mode 100644 index 00000000..c2e8b1c6 --- /dev/null +++ b/lib/OsStatus/Packages/ProbeException.php @@ -0,0 +1,17 @@ + [ + '/lib/x86_64-linux-gnu/libc.so.6', '/lib/aarch64-linux-gnu/libc.so.6', '/lib/*-linux-gnu*/libc.so.6', + '/usr/lib/x86_64-linux-gnu/libc.so.6', '/usr/lib/aarch64-linux-gnu/libc.so.6', + '/lib64/libc.so.6', '/usr/lib64/libc.so.6', '/usr/lib/libc.so.6', + '/lib/ld-musl-*.so.1', + ], + 'dbus' => ['/usr/bin/dbus-broker', '/usr/bin/dbus-daemon'], + ]; + + /** The Debian packages that ship each component. */ + private const DPKG_PACKAGES = [ + 'glibc' => ['libc6'], + 'dbus' => ['dbus-broker', 'dbus-daemon', 'dbus'], + ]; + + private const DPKG_INFO = '/var/lib/dpkg/info'; + + /** Early boot can touch files before the clock settles. */ + private const BOOT_SLACK = 60; + + public function __construct( + private IHost $host, + ) { + } + + /** + * @param int $bootTime unix timestamp of the last boot + * @param string|null $packageDatabase a file that changes whenever packages are installed + * @return list + */ + public function getRebootReasons(int $bootTime, ?string $packageDatabase = null): array { + $changed = []; + $since = null; + + foreach (self::COMPONENTS as $component => $patterns) { + $file = $this->find($patterns); + if ($file === null) { + continue; + } + + $changedAt = $this->host->changedAt($file); + if ($changedAt === null || $changedAt <= $bootTime + self::BOOT_SLACK) { + continue; + } + + $installedAt = $this->packageChangedAt($component) + ?? ($packageDatabase !== null ? $this->host->modifiedAt($packageDatabase) : null); + if ($installedAt !== null && $installedAt <= $bootTime + self::BOOT_SLACK) { + continue; + } + + $changed[] = $component; + $since = $since === null ? $changedAt : min($since, $changedAt); + } + + if ($changed === []) { + return []; + } + + return [new RebootReason(RebootReason::KIND_CORE_LIBRARY, null, $changed, $since)]; + } + + /** + * When dpkg last installed a package that ships the component, from the + * file lists it rewrites on every install; null when there are none. + */ + private function packageChangedAt(string $component): ?int { + $times = []; + foreach (self::DPKG_PACKAGES[$component] ?? [] as $package) { + $lists = $this->host->glob(self::DPKG_INFO . '/' . $package . ':*.list'); + $lists[] = self::DPKG_INFO . '/' . $package . '.list'; + foreach ($lists as $list) { + $times[] = $this->host->modifiedAt($list); + } + } + $times = array_filter($times, 'is_int'); + + return $times === [] ? null : max($times); + } + + /** + * @param list $patterns + */ + private function find(array $patterns): ?string { + foreach ($patterns as $pattern) { + $matches = str_contains($pattern, '*') ? $this->host->glob($pattern) : ($this->host->fileExists($pattern) ? [$pattern] : []); + if ($matches !== []) { + return $matches[0]; + } + } + + return null; + } +} diff --git a/lib/OsStatus/Reboot/KernelProbe.php b/lib/OsStatus/Reboot/KernelProbe.php new file mode 100644 index 00000000..b32cbd29 --- /dev/null +++ b/lib/OsStatus/Reboot/KernelProbe.php @@ -0,0 +1,132 @@ + + */ + public function getRebootReasons(?int $bootTime = null): array { + $running = trim($this->host->uname('r')); + $installed = $this->installedKernels(); + if ($running === '' || $installed === []) { + // Nothing to compare against: a container, or /lib/modules hidden. + return []; + } + + $flavour = self::flavour($running); + $sameFlavour = array_values(array_filter( + array_keys($installed), + static fn (string $release): bool => self::flavour($release) === $flavour, + )); + + if (!isset($installed[$running])) { + return $this->removed($installed, $sameFlavour, $bootTime); + } + + $newest = $this->newest($sameFlavour); + if ($newest === null || $newest === $running || version_compare($newest, $running, '<=')) { + return []; + } + + return [new RebootReason(RebootReason::KIND_KERNEL, $newest, [], $installed[$newest])]; + } + + /** + * The running kernel has no modules on disk. Arch and others delete them + * when they upgrade the kernel, but a kernel the provider or a netboot + * server supplies never had any, and no reboot changes that. So this is + * only a reason when an installed kernel of the same build exists, or + * when the newest one was installed after the boot. + * + * @param array $installed + * @param list $sameFlavour + * @return list + */ + private function removed(array $installed, array $sameFlavour, ?int $bootTime): array { + $newest = $this->newest($sameFlavour); + if ($newest === null) { + $newest = $this->newest(array_keys($installed)); + $installedAt = $newest !== null ? $installed[$newest] : null; + if ($newest === null || $bootTime === null || $installedAt === null || $installedAt <= $bootTime + self::BOOT_SLACK) { + return []; + } + } + + return [new RebootReason(RebootReason::KIND_KERNEL_REMOVED, $newest, [], $installed[$newest])]; + } + + /** + * Installed kernel releases, each with when it was installed. + * + * Only directories with a modules.dep count: removing a kernel package can + * leave its directory behind with DKMS leftovers in it. + * + * @return array + */ + public function installedKernels(): array { + foreach (self::MODULE_ROOTS as $root) { + $kernels = []; + foreach ($this->host->glob($root . '/*/modules.dep') as $file) { + $release = basename(dirname($file)); + $kernels[$release] = $this->host->changedAt($file); + } + + if ($kernels !== []) { + return $kernels; + } + } + + return []; + } + + /** + * Kernels of one build only differ in their numbers, so replacing every + * run of digits leaves what tells "generic" from "lowlatency", or "lts" + * from the mainline kernel. + */ + public static function flavour(string $release): string { + return (string)preg_replace('/\d+/', 'N', $release); + } + + /** + * @param list $releases + */ + private function newest(array $releases): ?string { + if ($releases === []) { + return null; + } + + usort($releases, static fn (string $a, string $b): int => version_compare($b, $a)); + + return $releases[0]; + } +} diff --git a/lib/OsStatus/Reboot/RebootReason.php b/lib/OsStatus/Reboot/RebootReason.php new file mode 100644 index 00000000..c3385307 --- /dev/null +++ b/lib/OsStatus/Reboot/RebootReason.php @@ -0,0 +1,84 @@ + $packages packages or files behind the reason + */ + public function __construct( + public readonly string $kind, + /** e.g. the newer kernel version */ + public readonly ?string $detail = null, + public readonly array $packages = [], + /** When the reason first applied, as a unix timestamp */ + public readonly ?int $since = null, + ) { + } + + /** + * Whether a live kernel patch can stand in for this reboot. + */ + public function isKernelOnly(): bool { + return $this->kind === self::KIND_KERNEL; + } + + public function key(): string { + return $this->kind . ':' . ($this->detail ?? ''); + } + + /** + * @return array{kind: string, detail: ?string, packages: list, since: ?int} + */ + public function toArray(): array { + return [ + 'kind' => $this->kind, + 'detail' => $this->detail, + 'packages' => $this->packages, + 'since' => $this->since, + ]; + } + + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || !in_array($data['kind'] ?? null, self::KINDS, true)) { + return null; + } + + return new self( + $data['kind'], + Values::string($data['detail'] ?? null), + Values::strings($data['packages'] ?? null), + Values::int($data['since'] ?? null), + ); + } + + public function withSince(?int $since): self { + return new self($this->kind, $this->detail, $this->packages, $since); + } +} diff --git a/lib/OsStatus/Restart/PhpProcessProbe.php b/lib/OsStatus/Restart/PhpProcessProbe.php new file mode 100644 index 00000000..fc02d79e --- /dev/null +++ b/lib/OsStatus/Restart/PhpProcessProbe.php @@ -0,0 +1,141 @@ +host->sapi() !== 'cli' && $this->host->sapi() !== 'phpdbg'; + } + + /** + * @param int $now unix timestamp stored with the sample + */ + public function sample(?OsRelease $release, int $now): ?PhpProcessSample { + if (!$this->canSample()) { + return null; + } + + $maps = $this->host->readFile('/proc/self/maps', 8 * 1024 * 1024); + if ($maps === null) { + return null; + } + + $libraries = self::deletedLibraries($maps); + $executable = $this->host->readLink('/proc/self/exe'); + if ($executable !== null && str_ends_with($executable, ' (deleted)')) { + array_unshift($libraries, basename(substr($executable, 0, -strlen(' (deleted)')))); + } + $libraries = array_slice(array_values(array_unique($libraries)), 0, self::MAX_LIBRARIES); + + return new PhpProcessSample( + $now, + $this->host->sapi(), + $libraries, + $this->serviceName($release), + $this->confinement(), + ); + } + + /** + * Basenames of the deleted libraries mapped into a process. + * + * @return list + */ + public static function deletedLibraries(string $maps): array { + $libraries = []; + foreach (preg_split('/\R/', $maps) ?: [] as $line) { + // address perms offset dev inode pathname + if (preg_match('/^\S+\s+\S+\s+\S+\s+\S+\s+\d+\s+(\/.+) \(deleted\)$/', $line, $matches) !== 1) { + continue; + } + + $path = $matches[1]; + foreach (self::IGNORED_PREFIXES as $prefix) { + if (str_starts_with($path, $prefix)) { + continue 2; + } + } + + if (preg_match('/\.so(\.[\d.]+)?$|\.so\./', basename($path)) !== 1) { + continue; + } + + $libraries[basename($path)] = true; + } + + return array_keys($libraries); + } + + /** + * The service that has to be restarted to load the new libraries, named + * the way the distribution packages it. + */ + private function serviceName(?OsRelease $release): ?string { + $isDebian = $release !== null && $release->isLike('debian'); + $isAlpine = $release !== null && $release->id === 'alpine'; + $isSuse = $release !== null && ($release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles'); + + return match ($this->host->sapi()) { + 'fpm-fcgi' => match (true) { + $isDebian => 'php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '-fpm', + $isAlpine => 'php-fpm' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION, + default => 'php-fpm', + }, + 'apache2handler' => ($isDebian || $isAlpine || $isSuse) ? 'apache2' : 'httpd', + 'litespeed' => 'lsws', + default => null, + }; + } + + /** + * The mandatory access control label the web server runs under, e.g. an + * AppArmor profile or an SELinux context. + */ + private function confinement(): ?string { + foreach (['/proc/self/attr/apparmor/current', '/proc/self/attr/current'] as $path) { + $label = $this->host->readFile($path, 512); + if ($label === null) { + continue; + } + + $label = trim(str_replace("\0", '', $label)); + if ($label !== '' && preg_match('/^[\x20-\x7e]{1,200}$/', $label) === 1) { + return $label; + } + } + + return null; + } +} diff --git a/lib/OsStatus/Restart/PhpProcessSample.php b/lib/OsStatus/Restart/PhpProcessSample.php new file mode 100644 index 00000000..b61d6fd3 --- /dev/null +++ b/lib/OsStatus/Restart/PhpProcessSample.php @@ -0,0 +1,62 @@ + $outdatedLibraries basenames of libraries replaced since the process started + */ + public function __construct( + public readonly int $sampledAt, + public readonly string $sapi, + public readonly array $outdatedLibraries, + /** systemd unit or init script to restart, e.g. "php8.3-fpm" */ + public readonly ?string $service, + /** AppArmor profile or SELinux context of the web server */ + public readonly ?string $confinement, + ) { + } + + public function isOutdated(): bool { + return $this->outdatedLibraries !== []; + } + + /** + * @return array{sampledAt: int, sapi: string, outdatedLibraries: list, service: ?string, confinement: ?string} + */ + public function toArray(): array { + return [ + 'sampledAt' => $this->sampledAt, + 'sapi' => $this->sapi, + 'outdatedLibraries' => $this->outdatedLibraries, + 'service' => $this->service, + 'confinement' => $this->confinement, + ]; + } + + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || !is_int($data['sampledAt'] ?? null) || !is_string($data['sapi'] ?? null)) { + return null; + } + + return new self( + $data['sampledAt'], + $data['sapi'], + Values::strings($data['outdatedLibraries'] ?? null), + Values::string($data['service'] ?? null), + Values::string($data['confinement'] ?? null), + ); + } +} diff --git a/lib/OsStatus/Support/SubscriptionProbe.php b/lib/OsStatus/Support/SubscriptionProbe.php new file mode 100644 index 00000000..b81ada80 --- /dev/null +++ b/lib/OsStatus/Support/SubscriptionProbe.php @@ -0,0 +1,101 @@ + $this->ubuntuPro(), + 'debian-elts' => $this->debianElts(), + default => null, + }; + } + + /** + * Ubuntu's extended security maintenance for the main archive is the + * "esm-infra" service of an attached Pro subscription. + */ + private function ubuntuPro(): ?bool { + $json = $this->host->readFile(self::UBUNTU_PRO_STATUS, 1024 * 1024); + if ($json === null) { + return $this->host->fileExists('/usr/bin/pro') ? null : false; + } + + return self::parseUbuntuProStatus($json); + } + + public static function parseUbuntuProStatus(string $json): ?bool { + $status = json_decode($json, true, 32); + if (!is_array($status) || !is_bool($status['attached'] ?? null)) { + return null; + } + if (!$status['attached']) { + return false; + } + + $services = $status['services'] ?? null; + foreach (is_array($services) ? $services : [] as $service) { + if (is_array($service) && ($service['name'] ?? null) === 'esm-infra') { + return ($service['status'] ?? null) === 'enabled'; + } + } + + return false; + } + + /** + * Freexian's Extended LTS is configured as an extra apt source. + */ + private function debianElts(): ?bool { + $files = array_merge( + ['/etc/apt/sources.list'], + $this->host->glob('/etc/apt/sources.list.d/*.list'), + $this->host->glob('/etc/apt/sources.list.d/*.sources'), + ); + + $readable = false; + foreach ($files as $file) { + $content = $this->host->readFile($file, 256 * 1024); + if ($content === null) { + continue; + } + $readable = true; + + foreach (preg_split('/\R/', $content) ?: [] as $line) { + $line = trim($line); + if ($line !== '' && $line[0] !== '#' && str_contains($line, 'deb.freexian.com/extended-lts')) { + return true; + } + } + } + + return $readable ? false : null; + } +} diff --git a/lib/OsStatus/Support/SupportStatus.php b/lib/OsStatus/Support/SupportStatus.php new file mode 100644 index 00000000..34c7c0c2 --- /dev/null +++ b/lib/OsStatus/Support/SupportStatus.php @@ -0,0 +1,139 @@ +rolling) { + return self::STATE_ROLLING; + } + if ($this->securityEnds === null) { + return self::STATE_UNKNOWN; + } + + $today = gmdate('Y-m-d', $now); + if ($today <= $this->securityEnds) { + return self::STATE_SUPPORTED; + } + if ($this->extendedEnds !== null && $today <= $this->extendedEnds) { + return $this->extendedActive === true ? self::STATE_EXTENDED : self::STATE_EXTENDED_AVAILABLE; + } + + return self::STATE_ENDED; + } + + /** + * The last day this system receives security updates as it is set up now. + */ + public function effectiveEnd(): ?string { + if ($this->extendedActive === true && $this->extendedEnds !== null) { + return $this->extendedEnds; + } + + return $this->securityEnds; + } + + /** + * Whole days until the effective end, negative once it has passed. + */ + public function daysLeft(int $now): ?int { + $end = $this->effectiveEnd(); + if ($end === null) { + return null; + } + + $endTime = strtotime($end . ' 23:59:59 UTC'); + if ($endTime === false) { + return null; + } + + return (int)floor(($endTime - $now) / 86400); + } + + /** + * @return array{rolling: bool, securityEnds: ?string, extendedEnds: ?string, extended: ?string, extendedActive: ?bool, ltsFrom: ?string, dataDate: ?string} + */ + public function toArray(): array { + return [ + 'rolling' => $this->rolling, + 'securityEnds' => $this->securityEnds, + 'extendedEnds' => $this->extendedEnds, + 'extended' => $this->extended, + 'extendedActive' => $this->extendedActive, + 'ltsFrom' => $this->ltsFrom, + 'dataDate' => $this->dataDate, + ]; + } + + public static function fromArray(mixed $data): self { + if (!is_array($data)) { + return self::unknown(); + } + + $date = static fn (mixed $value): ?string => is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : null; + $extended = $data['extended'] ?? null; + + return new self( + (bool)($data['rolling'] ?? false), + $date($data['securityEnds'] ?? null), + $date($data['extendedEnds'] ?? null), + in_array($extended, self::PROGRAMMES, true) ? $extended : null, + is_bool($data['extendedActive'] ?? null) ? $data['extendedActive'] : null, + $date($data['ltsFrom'] ?? null), + $date($data['dataDate'] ?? null), + ); + } + + public function withExtendedActive(?bool $active): self { + return new self($this->rolling, $this->securityEnds, $this->extendedEnds, $this->extended, $active, $this->ltsFrom, $this->dataDate); + } +} diff --git a/lib/OsStatus/Support/SupportTable.php b/lib/OsStatus/Support/SupportTable.php new file mode 100644 index 00000000..8ff7fc59 --- /dev/null +++ b/lib/OsStatus/Support/SupportTable.php @@ -0,0 +1,104 @@ +, rolling?: list, releases?: array>}|null */ + private ?array $table = null; + + private string $file = __DIR__ . '/../../../resources/os-support.json'; + + /** + * A table read from another file than the bundled one. + */ + public static function fromFile(string $file): self { + $table = new self(); + $table->file = $file; + + return $table; + } + + public function lookup(OsRelease $release): SupportStatus { + $table = $this->load(); + $dataDate = $table['generatedAt'] ?? null; + + if ($this->isRolling($release, $table['rolling'] ?? [])) { + return new SupportStatus(true, null, dataDate: $dataDate); + } + + $id = $this->tableId($release); + $cycleKind = $table['cycles'][$id] ?? null; + if ($cycleKind === null || $release->versionId === null) { + return SupportStatus::unknown($dataDate); + } + + $parts = explode('.', $release->versionId); + $cycle = $cycleKind === 'minor' && isset($parts[1]) ? $parts[0] . '.' . $parts[1] : $parts[0]; + $entry = $table['releases'][$id . ':' . $cycle] ?? null; + if ($entry === null) { + return SupportStatus::unknown($dataDate); + } + + return SupportStatus::fromArray([ + 'rolling' => false, + 'securityEnds' => $entry['securityEnds'] ?? null, + 'extendedEnds' => $entry['extendedEnds'] ?? null, + 'extended' => $entry['extended'] ?? null, + 'ltsFrom' => $entry['ltsFrom'] ?? null, + 'dataDate' => $dataDate, + ]); + } + + /** + * @param list $rolling + */ + private function isRolling(OsRelease $release, array $rolling): bool { + if (in_array($release->id, $rolling, true)) { + return true; + } + + // Arch derivatives follow Arch's rolling model whatever they call themselves. + return in_array('arch', $release->idLike, true); + } + + private function tableId(OsRelease $release): string { + return match (true) { + $release->id === 'centos' && stripos($release->name, 'stream') !== false => 'centos-stream', + default => $release->id, + }; + } + + /** + * @return array{generatedAt?: string, cycles?: array, rolling?: list, releases?: array>} + */ + private function load(): array { + if ($this->table !== null) { + return $this->table; + } + + $json = @file_get_contents($this->file); + $decoded = is_string($json) ? json_decode($json, true) : null; + + /** @psalm-suppress MixedPropertyTypeCoercion the file is generated by build/update-os-support.php */ + $this->table = is_array($decoded) ? $decoded : []; + + return $this->table; + } +} diff --git a/lib/OsStatus/Values.php b/lib/OsStatus/Values.php new file mode 100644 index 00000000..766ddee8 --- /dev/null +++ b/lib/OsStatus/Values.php @@ -0,0 +1,41 @@ + + */ + public static function strings(mixed $value): array { + if (!is_array($value)) { + return []; + } + + $strings = []; + foreach ($value as $item) { + if (is_string($item)) { + $strings[] = $item; + } + } + + return $strings; + } +} diff --git a/lib/OsStatus/Verdict/Concern.php b/lib/OsStatus/Verdict/Concern.php new file mode 100644 index 00000000..5c30c6ea --- /dev/null +++ b/lib/OsStatus/Verdict/Concern.php @@ -0,0 +1,67 @@ +|null> $facts what the level is based on, for display + */ + public function __construct( + public readonly string $id, + public readonly string $level, + public readonly array $facts = [], + ) { + } + + public static function rank(string $level): int { + return match ($level) { + self::LEVEL_ERROR => 3, + self::LEVEL_WARNING => 2, + self::LEVEL_INFO => 1, + default => 0, + }; + } + + /** + * One level less severe; used where a mitigation is in place. + * + * @param self::LEVEL_* $level + * @return self::LEVEL_* + */ + public static function lower(string $level): string { + return match ($level) { + self::LEVEL_ERROR => self::LEVEL_WARNING, + self::LEVEL_WARNING => self::LEVEL_INFO, + default => $level, + }; + } +} diff --git a/lib/OsStatus/Verdict/OsVerdict.php b/lib/OsStatus/Verdict/OsVerdict.php new file mode 100644 index 00000000..5acad466 --- /dev/null +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -0,0 +1,258 @@ +state !== OsStatus::STATE_OK) { + return new Verdict([]); + } + + $managed = $status->environment->managesPackages(); + $concerns = []; + + if ($managed) { + $concerns[] = $this->updates($status, $thresholds, $now); + $concerns[] = $this->reboot($status, $thresholds, $now); + } + $concerns[] = $this->restart($php, $phpOutdatedSince, $thresholds, $now); + $concerns[] = $this->support($status, $thresholds, $now); + if ($managed) { + $concerns[] = $this->packageLists($status, $thresholds, $now); + $concerns[] = $this->automaticUpdates($status); + } + $concerns[] = $this->failedUnits($status); + $concerns[] = $this->timeSync($status); + $concerns[] = $this->freshness($status, $now, $systemCron, $collectInterval); + + return new Verdict($concerns); + } + + /** + * Whether the stored status still describes the system. Only the + * background job collects it, and only from system cron, so without + * system cron or with cron stopped it silently stops changing. + * + * @param OsStatus|null $status null when nothing has been collected + */ + public function freshness(?OsStatus $status, int $now, bool $systemCron, int $collectInterval): Concern { + $facts = [ + 'collectedAt' => $status?->collectedAt, + 'ageSeconds' => null, + 'systemCron' => $systemCron, + ]; + if ($status === null) { + return new Concern(Concern::FRESHNESS, Concern::LEVEL_UNKNOWN, $facts); + } + + $age = max(0, $now - $status->collectedAt); + $facts['ageSeconds'] = $age; + $stale = $age > max(self::STALE_AFTER_MIN, self::STALE_AFTER_INTERVALS * $collectInterval); + + return new Concern(Concern::FRESHNESS, $stale || !$systemCron ? Concern::LEVEL_WARNING : Concern::LEVEL_OK, $facts); + } + + private function updates(OsStatus $status, Thresholds $thresholds, int $now): Concern { + if ($status->updates === null) { + return new Concern(Concern::UPDATES, Concern::LEVEL_UNKNOWN); + } + + $security = array_values(array_filter($status->updates, static fn (PendingUpdate $u): bool => $u->security)); + $relevant = $status->securityClassified ? $security : $status->updates; + $facts = [ + 'security' => count($security), + 'other' => count($status->updates) - count($security) + $status->updatesOmitted, + 'classified' => $status->securityClassified, + 'stack' => count(array_filter($relevant, static fn (PendingUpdate $u): bool => $u->isStack())), + 'days' => null, + // The package manager only knows the updates its lists on disk tell it about. + 'listsStale' => in_array($status->packageManager, self::LISTS_AGE_MATTERS, true) + && $status->listsRefreshedAt !== null + && self::days($now - $status->listsRefreshedAt) >= $thresholds->listsWarning, + ]; + + if ($relevant === []) { + return new Concern(Concern::UPDATES, Concern::LEVEL_OK, $facts); + } + + $since = min(array_map(static fn (PendingUpdate $u): int => $u->since ?? $now, $relevant)); + $days = self::days($now - $since); + $facts['days'] = $days; + + return new Concern(Concern::UPDATES, self::escalate($days, $thresholds->securityWarning, $thresholds->securityError), $facts); + } + + private function reboot(OsStatus $status, Thresholds $thresholds, int $now): Concern { + if ($status->rebootReasons === null) { + return new Concern(Concern::REBOOT, Concern::LEVEL_UNKNOWN); + } + if ($status->rebootReasons === []) { + return new Concern(Concern::REBOOT, Concern::LEVEL_OK); + } + + $since = min(array_map(static fn (RebootReason $r): int => $r->since ?? $now, $status->rebootReasons)); + $days = self::days($now - $since); + $level = self::escalate($days, $thresholds->rebootWarning, $thresholds->rebootError); + + $kernelOnly = array_filter($status->rebootReasons, static fn (RebootReason $r): bool => !$r->isKernelOnly()) === []; + $livePatched = $kernelOnly && ($status->livePatches ?? []) !== []; + if ($livePatched) { + $level = Concern::lower($level); + } + + return new Concern(Concern::REBOOT, $level, [ + 'days' => $days, + 'kinds' => array_values(array_unique(array_map(static fn (RebootReason $r): string => $r->kind, $status->rebootReasons))), + 'livePatched' => $livePatched, + ]); + } + + private function restart(?PhpProcessSample $php, ?int $phpOutdatedSince, Thresholds $thresholds, int $now): Concern { + if ($php === null) { + return new Concern(Concern::RESTART, Concern::LEVEL_UNKNOWN); + } + if (!$php->isOutdated()) { + return new Concern(Concern::RESTART, Concern::LEVEL_OK, ['php' => false, 'days' => null]); + } + + $days = self::days($now - ($phpOutdatedSince ?? $now)); + + return new Concern(Concern::RESTART, $days >= $thresholds->restartWarning ? Concern::LEVEL_WARNING : Concern::LEVEL_INFO, [ + 'php' => true, + 'days' => $days, + ]); + } + + private function support(OsStatus $status, Thresholds $thresholds, int $now): Concern { + $support = $status->support; + $state = $support->state($now); + $daysLeft = $support->daysLeft($now); + $facts = [ + 'state' => $state, + 'ends' => $support->effectiveEnd(), + 'daysLeft' => $daysLeft, + 'extended' => $support->extended, + 'extendedEnds' => $support->extendedEnds, + ]; + + $level = match ($state) { + SupportStatus::STATE_ROLLING => Concern::LEVEL_OK, + SupportStatus::STATE_UNKNOWN => Concern::LEVEL_UNKNOWN, + SupportStatus::STATE_ENDED => Concern::LEVEL_ERROR, + SupportStatus::STATE_EXTENDED_AVAILABLE => Concern::LEVEL_WARNING, + default => $daysLeft !== null && $daysLeft <= $thresholds->eolWarning ? Concern::LEVEL_WARNING : Concern::LEVEL_OK, + }; + + return new Concern(Concern::SUPPORT, $level, $facts); + } + + private function packageLists(OsStatus $status, Thresholds $thresholds, int $now): Concern { + if (!in_array($status->packageManager, self::LISTS_AGE_MATTERS, true)) { + return new Concern(Concern::PACKAGE_LISTS, Concern::LEVEL_OK, ['days' => null]); + } + if ($status->listsRefreshedAt === null) { + return new Concern(Concern::PACKAGE_LISTS, Concern::LEVEL_UNKNOWN); + } + + $days = self::days($now - $status->listsRefreshedAt); + $level = self::escalate($days, $thresholds->listsWarning, $thresholds->listsError); + + // Below the warning threshold a refresh is simply routine. + return new Concern(Concern::PACKAGE_LISTS, $level === Concern::LEVEL_INFO ? Concern::LEVEL_OK : $level, ['days' => $days]); + } + + private function automaticUpdates(OsStatus $status): Concern { + $automatic = $status->automaticUpdates; + $level = match ($automatic->state) { + AutomaticUpdates::STATE_ENABLED => Concern::LEVEL_OK, + AutomaticUpdates::STATE_DISABLED => Concern::LEVEL_INFO, + default => Concern::LEVEL_UNKNOWN, + }; + + return new Concern(Concern::AUTOMATIC_UPDATES, $level, [ + 'mechanism' => $automatic->mechanism, + 'scope' => $automatic->scope, + ]); + } + + private function failedUnits(OsStatus $status): Concern { + if ($status->failedUnits === null) { + return new Concern(Concern::FAILED_UNITS, Concern::LEVEL_UNKNOWN); + } + + $stack = array_values(array_filter($status->failedUnits, static fn (string $unit): bool => NextcloudStack::isStackService($unit))); + $level = match (true) { + $stack !== [] => Concern::LEVEL_WARNING, + $status->failedUnits !== [] => Concern::LEVEL_INFO, + default => Concern::LEVEL_OK, + }; + + return new Concern(Concern::FAILED_UNITS, $level, [ + 'units' => array_slice($status->failedUnits, 0, 20), + 'stack' => $stack, + ]); + } + + private function timeSync(OsStatus $status): Concern { + $level = match ($status->timeSynchronized) { + true => Concern::LEVEL_OK, + false => Concern::LEVEL_WARNING, + null => Concern::LEVEL_UNKNOWN, + }; + + return new Concern(Concern::TIME_SYNC, $level, ['source' => $status->timeSource]); + } + + /** + * Applies a pair of thresholds; below the warning threshold a finding is + * worth showing but not worth an alert. + * + * @return Concern::LEVEL_INFO|Concern::LEVEL_WARNING|Concern::LEVEL_ERROR + */ + private static function escalate(int $days, int $warning, int $error): string { + return match (true) { + $days >= $error => Concern::LEVEL_ERROR, + $days >= $warning => Concern::LEVEL_WARNING, + default => Concern::LEVEL_INFO, + }; + } + + private static function days(int $seconds): int { + return max(0, intdiv($seconds, self::DAY)); + } +} diff --git a/lib/OsStatus/Verdict/Thresholds.php b/lib/OsStatus/Verdict/Thresholds.php new file mode 100644 index 00000000..24cb6b10 --- /dev/null +++ b/lib/OsStatus/Verdict/Thresholds.php @@ -0,0 +1,65 @@ + config key */ + private const KEYS = [ + 'securityWarning' => ConfigLexicon::OS_SECURITY_WARNING_DAYS, + 'securityError' => ConfigLexicon::OS_SECURITY_ERROR_DAYS, + 'rebootWarning' => ConfigLexicon::OS_REBOOT_WARNING_DAYS, + 'rebootError' => ConfigLexicon::OS_REBOOT_ERROR_DAYS, + 'restartWarning' => ConfigLexicon::OS_RESTART_WARNING_DAYS, + 'eolWarning' => ConfigLexicon::OS_EOL_WARNING_DAYS, + 'listsWarning' => ConfigLexicon::OS_LISTS_WARNING_DAYS, + 'listsError' => ConfigLexicon::OS_LISTS_ERROR_DAYS, + ]; + + /** Pairs where the error threshold may not come before the warning one. */ + private const PAIRS = [ + ['securityWarning', 'securityError'], + ['rebootWarning', 'rebootError'], + ['listsWarning', 'listsError'], + ]; + + public function __construct( + public readonly int $securityWarning = 3, + public readonly int $securityError = 14, + public readonly int $rebootWarning = 7, + public readonly int $rebootError = 30, + public readonly int $restartWarning = 3, + public readonly int $eolWarning = 90, + public readonly int $listsWarning = 7, + public readonly int $listsError = 30, + ) { + } + + public static function load(IAppConfig $appConfig): self { + $values = []; + foreach (self::KEYS as $name => $key) { + $values[$name] = max(0, min(self::MAX_DAYS, $appConfig->getAppValueInt($key))); + } + foreach (self::PAIRS as [$warning, $error]) { + $values[$error] = max($values[$error], $values[$warning]); + } + + return new self(...$values); + } +} diff --git a/lib/OsStatus/Verdict/Verdict.php b/lib/OsStatus/Verdict/Verdict.php new file mode 100644 index 00000000..331bf511 --- /dev/null +++ b/lib/OsStatus/Verdict/Verdict.php @@ -0,0 +1,59 @@ + $concerns + */ + public function __construct( + public readonly array $concerns, + ) { + } + + /** + * The most severe level of any concern; "unknown" when nothing could be + * assessed, or when nothing is wrong but the pending updates are unknown. + * + * @return Concern::LEVEL_* + */ + public function level(): string { + $level = Concern::LEVEL_UNKNOWN; + $known = false; + foreach ($this->concerns as $concern) { + if ($concern->level === Concern::LEVEL_UNKNOWN) { + continue; + } + if (!$known || Concern::rank($concern->level) > Concern::rank($level)) { + $level = $concern->level; + } + $known = true; + } + + if (Concern::rank($level) <= Concern::rank(Concern::LEVEL_INFO) && $this->get(Concern::UPDATES)?->level === Concern::LEVEL_UNKNOWN) { + return Concern::LEVEL_UNKNOWN; + } + + return $level; + } + + public function get(string $id): ?Concern { + foreach ($this->concerns as $concern) { + if ($concern->id === $id) { + return $concern; + } + } + + return null; + } +} diff --git a/package-lock.json b/package-lock.json index bceee627..73824d7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "serverinfo", - "version": "8.0.0-dev.0", + "version": "8.0.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "serverinfo", - "version": "8.0.0-dev.0", + "version": "8.0.0-dev.1", "license": "AGPL-3.0-or-later", "dependencies": { "@nextcloud/axios": "^2.6.0", diff --git a/package.json b/package.json index 02743947..16caa941 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverinfo", - "version": "8.0.0-dev.0", + "version": "8.0.0-dev.1", "private": true, "description": "Monitoring app with useful server information", "license": "AGPL-3.0-or-later", diff --git a/resources/os-support.json b/resources/os-support.json new file mode 100644 index 00000000..5fff65f4 --- /dev/null +++ b/resources/os-support.json @@ -0,0 +1,538 @@ +{ + "source": "endoflife.date, MIT License, https://endoflife.date", + "generatedAt": "2026-10-19", + "cycles": { + "ubuntu": "minor", + "debian": "major", + "rhel": "major", + "almalinux": "major", + "rocky": "major", + "centos-stream": "major", + "centos": "major", + "ol": "major", + "fedora": "major", + "opensuse-leap": "minor", + "sles": "minor", + "alpine": "minor", + "amzn": "major" + }, + "rolling": [ + "arch", + "archarm", + "manjaro", + "endeavouros", + "cachyos", + "garuda", + "opensuse-tumbleweed", + "opensuse-microos", + "opensuse-slowroll", + "gentoo", + "void" + ], + "releases": { + "almalinux:8": { + "securityEnds": "2029-05-31" + }, + "almalinux:9": { + "securityEnds": "2032-05-31" + }, + "almalinux:10": { + "securityEnds": "2035-05-31" + }, + "alpine:3.4": { + "securityEnds": "2018-05-01" + }, + "alpine:3.5": { + "securityEnds": "2018-11-01" + }, + "alpine:3.6": { + "securityEnds": "2019-05-01" + }, + "alpine:3.7": { + "securityEnds": "2019-11-01" + }, + "alpine:3.8": { + "securityEnds": "2020-05-01" + }, + "alpine:3.9": { + "securityEnds": "2020-11-01" + }, + "alpine:3.10": { + "securityEnds": "2021-05-01" + }, + "alpine:3.11": { + "securityEnds": "2021-11-01" + }, + "alpine:3.12": { + "securityEnds": "2022-05-01" + }, + "alpine:3.13": { + "securityEnds": "2022-11-01" + }, + "alpine:3.14": { + "securityEnds": "2023-05-01" + }, + "alpine:3.15": { + "securityEnds": "2023-11-01" + }, + "alpine:3.16": { + "securityEnds": "2024-05-23" + }, + "alpine:3.17": { + "securityEnds": "2024-11-22" + }, + "alpine:3.18": { + "securityEnds": "2025-05-09" + }, + "alpine:3.19": { + "securityEnds": "2025-11-01" + }, + "alpine:3.20": { + "securityEnds": "2026-04-01" + }, + "alpine:3.21": { + "securityEnds": "2026-11-01" + }, + "alpine:3.22": { + "securityEnds": "2027-05-01" + }, + "alpine:3.23": { + "securityEnds": "2027-11-01" + }, + "alpine:3.24": { + "securityEnds": "2028-06-01" + }, + "amzn:2": { + "securityEnds": "2026-06-30" + }, + "amzn:2010.11": { + "securityEnds": "2023-12-31" + }, + "amzn:2011.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2012.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2012.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2013.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2013.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2014.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2014.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2015.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2015.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2016.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2016.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2017.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2017.09": { + "securityEnds": "2023-12-31" + }, + "amzn:2018.03": { + "securityEnds": "2023-12-31" + }, + "amzn:2023": { + "securityEnds": "2029-06-30" + }, + "centos-stream:8": { + "securityEnds": "2024-05-31" + }, + "centos-stream:9": { + "securityEnds": "2027-05-31" + }, + "centos-stream:10": { + "securityEnds": "2030-05-31" + }, + "centos:6": { + "securityEnds": "2020-11-30" + }, + "centos:7": { + "securityEnds": "2024-06-30" + }, + "centos:8": { + "securityEnds": "2021-12-31" + }, + "debian:7": { + "securityEnds": "2018-05-31", + "ltsFrom": "2016-04-25", + "extendedEnds": "2020-06-30", + "extended": "debian-elts" + }, + "debian:8": { + "securityEnds": "2020-06-30", + "ltsFrom": "2018-06-17", + "extendedEnds": "2025-06-30", + "extended": "debian-elts" + }, + "debian:9": { + "securityEnds": "2022-07-01", + "ltsFrom": "2020-07-18", + "extendedEnds": "2027-06-30", + "extended": "debian-elts" + }, + "debian:10": { + "securityEnds": "2024-06-30", + "ltsFrom": "2022-09-10", + "extendedEnds": "2029-06-30", + "extended": "debian-elts" + }, + "debian:11": { + "securityEnds": "2026-08-31", + "ltsFrom": "2024-08-14", + "extendedEnds": "2031-06-30", + "extended": "debian-elts" + }, + "debian:12": { + "securityEnds": "2028-06-30", + "ltsFrom": "2026-07-11", + "extendedEnds": "2033-06-30", + "extended": "debian-elts" + }, + "debian:13": { + "securityEnds": "2030-06-30", + "ltsFrom": "2028-08-09", + "extendedEnds": "2035-06-30", + "extended": "debian-elts" + }, + "fedora:26": { + "securityEnds": "2018-05-29" + }, + "fedora:27": { + "securityEnds": "2018-11-30" + }, + "fedora:28": { + "securityEnds": "2019-05-28" + }, + "fedora:29": { + "securityEnds": "2019-11-26" + }, + "fedora:30": { + "securityEnds": "2020-05-26" + }, + "fedora:31": { + "securityEnds": "2020-11-24" + }, + "fedora:32": { + "securityEnds": "2021-05-25" + }, + "fedora:33": { + "securityEnds": "2021-11-30" + }, + "fedora:34": { + "securityEnds": "2022-06-07" + }, + "fedora:35": { + "securityEnds": "2022-12-13" + }, + "fedora:36": { + "securityEnds": "2023-05-16" + }, + "fedora:37": { + "securityEnds": "2023-12-05" + }, + "fedora:38": { + "securityEnds": "2024-05-21" + }, + "fedora:39": { + "securityEnds": "2024-11-26" + }, + "fedora:40": { + "securityEnds": "2025-05-13" + }, + "fedora:41": { + "securityEnds": "2025-12-15" + }, + "fedora:42": { + "securityEnds": "2026-05-27" + }, + "fedora:43": { + "securityEnds": "2026-12-09" + }, + "fedora:44": { + "securityEnds": "2027-06-02" + }, + "ol:6": { + "securityEnds": "2021-03-31", + "extendedEnds": "2024-12-31", + "extended": "oracle-extended" + }, + "ol:7": { + "securityEnds": "2024-12-31", + "extendedEnds": "2029-07-31", + "extended": "oracle-extended" + }, + "ol:8": { + "securityEnds": "2029-07-31", + "extendedEnds": "2032-07-31", + "extended": "oracle-extended" + }, + "ol:9": { + "securityEnds": "2032-06-30", + "extendedEnds": "2035-06-30", + "extended": "oracle-extended" + }, + "ol:10": { + "securityEnds": "2035-06-30", + "extendedEnds": "2038-06-30", + "extended": "oracle-extended" + }, + "opensuse-leap:15.0": { + "securityEnds": "2019-12-03" + }, + "opensuse-leap:15.1": { + "securityEnds": "2021-02-02" + }, + "opensuse-leap:15.2": { + "securityEnds": "2022-01-04" + }, + "opensuse-leap:15.3": { + "securityEnds": "2022-12-31" + }, + "opensuse-leap:15.4": { + "securityEnds": "2023-12-07" + }, + "opensuse-leap:15.5": { + "securityEnds": "2024-12-31" + }, + "opensuse-leap:15.6": { + "securityEnds": "2026-04-30" + }, + "opensuse-leap:16.0": { + "securityEnds": "2027-10-31" + }, + "opensuse-leap:42.2": { + "securityEnds": "2018-01-26" + }, + "opensuse-leap:42.3": { + "securityEnds": "2019-07-01" + }, + "rhel:5": { + "securityEnds": "2017-03-31", + "extendedEnds": "2020-11-30", + "extended": "rhel-els" + }, + "rhel:6": { + "securityEnds": "2020-11-30", + "extendedEnds": "2024-06-30", + "extended": "rhel-els" + }, + "rhel:7": { + "securityEnds": "2024-06-30", + "extendedEnds": "2029-05-31", + "extended": "rhel-els" + }, + "rhel:8": { + "securityEnds": "2029-05-31", + "extendedEnds": "2033-05-31", + "extended": "rhel-els" + }, + "rhel:9": { + "securityEnds": "2032-05-31", + "extendedEnds": "2036-05-31", + "extended": "rhel-els" + }, + "rhel:10": { + "securityEnds": "2035-05-31", + "extendedEnds": "2039-05-31", + "extended": "rhel-els" + }, + "rocky:8": { + "securityEnds": "2029-05-31" + }, + "rocky:9": { + "securityEnds": "2032-05-31" + }, + "rocky:10": { + "securityEnds": "2035-05-31" + }, + "sles:11.3": { + "securityEnds": "2016-01-31", + "extendedEnds": "2019-01-30", + "extended": "suse-ltss" + }, + "sles:11.4": { + "securityEnds": "2019-03-31", + "extendedEnds": "2022-03-31", + "extended": "suse-ltss" + }, + "sles:12.0": { + "securityEnds": "2016-06-30", + "extendedEnds": "2019-07-01", + "extended": "suse-ltss" + }, + "sles:12.1": { + "securityEnds": "2017-05-31", + "extendedEnds": "2020-05-31", + "extended": "suse-ltss" + }, + "sles:12.2": { + "securityEnds": "2018-03-31", + "extendedEnds": "2021-03-31", + "extended": "suse-ltss" + }, + "sles:12.3": { + "securityEnds": "2019-06-30", + "extendedEnds": "2022-06-30", + "extended": "suse-ltss" + }, + "sles:12.4": { + "securityEnds": "2020-06-30", + "extendedEnds": "2023-06-30", + "extended": "suse-ltss" + }, + "sles:12.5": { + "securityEnds": "2024-10-31", + "extendedEnds": "2027-10-31", + "extended": "suse-ltss" + }, + "sles:15.0": { + "securityEnds": "2019-12-31", + "extendedEnds": "2022-12-31", + "extended": "suse-ltss" + }, + "sles:15.1": { + "securityEnds": "2021-01-31", + "extendedEnds": "2024-01-31", + "extended": "suse-ltss" + }, + "sles:15.2": { + "securityEnds": "2021-12-31", + "extendedEnds": "2024-12-31", + "extended": "suse-ltss" + }, + "sles:15.3": { + "securityEnds": "2022-12-31", + "extendedEnds": "2025-12-31", + "extended": "suse-ltss" + }, + "sles:15.4": { + "securityEnds": "2023-12-31", + "extendedEnds": "2026-12-31", + "extended": "suse-ltss" + }, + "sles:15.5": { + "securityEnds": "2024-12-31", + "extendedEnds": "2027-12-31", + "extended": "suse-ltss" + }, + "sles:15.6": { + "securityEnds": "2025-12-31", + "extendedEnds": "2028-12-31", + "extended": "suse-ltss" + }, + "sles:15.7": { + "securityEnds": "2031-07-31", + "extendedEnds": "2034-07-31", + "extended": "suse-ltss" + }, + "sles:16.0": { + "securityEnds": "2027-11-30", + "extendedEnds": "2030-11-30", + "extended": "suse-ltss" + }, + "ubuntu:12.04": { + "securityEnds": "2017-04-28", + "extendedEnds": "2019-04-26", + "extended": "ubuntu-pro" + }, + "ubuntu:14.04": { + "securityEnds": "2019-04-02", + "extendedEnds": "2024-04-02", + "extended": "ubuntu-pro" + }, + "ubuntu:16.04": { + "securityEnds": "2021-04-02", + "extendedEnds": "2026-04-02", + "extended": "ubuntu-pro" + }, + "ubuntu:17.04": { + "securityEnds": "2018-01-13" + }, + "ubuntu:17.10": { + "securityEnds": "2018-07-19" + }, + "ubuntu:18.04": { + "securityEnds": "2023-05-31", + "extendedEnds": "2028-04-26", + "extended": "ubuntu-pro" + }, + "ubuntu:18.10": { + "securityEnds": "2019-07-18" + }, + "ubuntu:19.04": { + "securityEnds": "2020-01-23" + }, + "ubuntu:19.10": { + "securityEnds": "2020-07-06" + }, + "ubuntu:20.04": { + "securityEnds": "2025-05-31", + "extendedEnds": "2030-04-23", + "extended": "ubuntu-pro" + }, + "ubuntu:20.10": { + "securityEnds": "2021-07-22" + }, + "ubuntu:21.04": { + "securityEnds": "2022-01-20" + }, + "ubuntu:21.10": { + "securityEnds": "2022-07-14" + }, + "ubuntu:22.04": { + "securityEnds": "2027-06-01", + "extendedEnds": "2032-04-21", + "extended": "ubuntu-pro" + }, + "ubuntu:22.10": { + "securityEnds": "2023-07-20" + }, + "ubuntu:23.04": { + "securityEnds": "2024-01-20" + }, + "ubuntu:23.10": { + "securityEnds": "2024-07-12" + }, + "ubuntu:24.04": { + "securityEnds": "2029-05-31", + "extendedEnds": "2034-04-25", + "extended": "ubuntu-pro" + }, + "ubuntu:24.10": { + "securityEnds": "2025-07-10" + }, + "ubuntu:25.04": { + "securityEnds": "2026-01-17" + }, + "ubuntu:25.10": { + "securityEnds": "2026-07-01" + }, + "ubuntu:26.04": { + "securityEnds": "2031-05-29", + "extendedEnds": "2036-04-23", + "extended": "ubuntu-pro" + }, + "ubuntu:26.10": { + "securityEnds": "2027-07-01" + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 39191a68..eb6a610f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,3 +18,6 @@ require_once __DIR__ . '/../../../tests/autoload.php'; Server::get(IAppManager::class)->loadApp('serverinfo'); + +// Shared test double, not a test case, so PHPUnit does not load it on its own. +require_once __DIR__ . '/lib/OsStatus/FakeHost.php'; diff --git a/tests/data/os/apache2-maps b/tests/data/os/apache2-maps new file mode 100644 index 00000000..c1502c13 --- /dev/null +++ b/tests/data/os/apache2-maps @@ -0,0 +1,80 @@ +fd65cb820000-fd65cb885000 r-xp 00000000 fc:00 468 /usr/lib/aarch64-linux-gnu/libnss_systemd.so.2 +fd65cb885000-fd65cb89a000 ---p 00065000 fc:00 468 /usr/lib/aarch64-linux-gnu/libnss_systemd.so.2 +fd65cb89a000-fd65cb8a0000 r--p 0006a000 fc:00 468 /usr/lib/aarch64-linux-gnu/libnss_systemd.so.2 +fd65cb8a0000-fd65cb8a1000 rw-p 00070000 fc:00 468 /usr/lib/aarch64-linux-gnu/libnss_systemd.so.2 +fd65d58b0000-fd65d58b9000 r-xp 00000000 fc:00 133 /usr/lib/aarch64-linux-gnu/libxxhash.so.0.8.3 +fd65d58b9000-fd65d58cf000 ---p 00009000 fc:00 133 /usr/lib/aarch64-linux-gnu/libxxhash.so.0.8.3 +fd65d58cf000-fd65d58d0000 r--p 0000f000 fc:00 133 /usr/lib/aarch64-linux-gnu/libxxhash.so.0.8.3 +fd65d58d0000-fd65d58d1000 rw-p 00010000 fc:00 133 /usr/lib/aarch64-linux-gnu/libxxhash.so.0.8.3 +fd65d58e0000-fd65d5904000 r-xp 00000000 fc:00 50 /usr/lib/aarch64-linux-gnu/liblz4.so.1.10.0 +fd65d5904000-fd65d591f000 ---p 00024000 fc:00 50 /usr/lib/aarch64-linux-gnu/liblz4.so.1.10.0 +fd65d591f000-fd65d5920000 r--p 0002f000 fc:00 50 /usr/lib/aarch64-linux-gnu/liblz4.so.1.10.0 +fd65d5920000-fd65d5921000 rw-p 00030000 fc:00 50 /usr/lib/aarch64-linux-gnu/liblz4.so.1.10.0 +fd65d5930000-fd65d59e0000 r-xp 00000000 fc:00 138780 /usr/lib/php/20250925/redis.so +fd65d59e0000-fd65d59ee000 ---p 000b0000 fc:00 138780 /usr/lib/php/20250925/redis.so +fd65d59ee000-fd65d5a00000 r--p 000be000 fc:00 138780 /usr/lib/php/20250925/redis.so +fd65d5a00000-fd65d5a01000 rw-p 000d0000 fc:00 138780 /usr/lib/php/20250925/redis.so +fd65d5a10000-fd65d5a2f000 r-xp 00000000 fc:00 242 /usr/lib/aarch64-linux-gnu/libzip.so.5.5 +fd65d5a2f000-fd65d5a3f000 ---p 0001f000 fc:00 242 /usr/lib/aarch64-linux-gnu/libzip.so.5.5 +fd65d5a3f000-fd65d5a40000 r--p 0001f000 fc:00 242 /usr/lib/aarch64-linux-gnu/libzip.so.5.5 +fd65d5a40000-fd65d5a41000 rw-p 00020000 fc:00 242 /usr/lib/aarch64-linux-gnu/libzip.so.5.5 +fd65d5a50000-fd65d5a65000 r-xp 00000000 fc:00 138661 /usr/lib/php/20250925/zip.so +fd65d5a65000-fd65d5a7d000 ---p 00015000 fc:00 138661 /usr/lib/php/20250925/zip.so +fd65d5a7d000-fd65d5a80000 r--p 0001d000 fc:00 138661 /usr/lib/php/20250925/zip.so +fd65d5a80000-fd65d5a81000 rw-p 00020000 fc:00 138661 /usr/lib/php/20250925/zip.so +fd65d5a90000-fd65d5abb000 r-xp 00000000 fc:00 90 /usr/lib/aarch64-linux-gnu/libgpg-error.so.0.41.1 +fd65d5abb000-fd65d5acf000 ---p 0002b000 fc:00 90 /usr/lib/aarch64-linux-gnu/libgpg-error.so.0.41.1 +fd65d5acf000-fd65d5ad0000 r--p 0002f000 fc:00 90 /usr/lib/aarch64-linux-gnu/libgpg-error.so.0.41.1 +fd65d5ad0000-fd65d5ad1000 rw-p 00030000 fc:00 90 /usr/lib/aarch64-linux-gnu/libgpg-error.so.0.41.1 +fd65d5ae0000-fd65d5c07000 r-xp 00000000 fc:00 3150 /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.7.0 +fd65d5c07000-fd65d5c1b000 ---p 00127000 fc:00 3150 /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.7.0 +fd65d5c1b000-fd65d5c20000 r--p 0012b000 fc:00 3150 /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.7.0 +fd65d5c20000-fd65d5c25000 rw-p 00130000 fc:00 3150 /usr/lib/aarch64-linux-gnu/libgcrypt.so.20.7.0 +fd65d5c30000-fd65d5c47000 r-xp 00000000 fc:00 3843 /usr/lib/aarch64-linux-gnu/libexslt.so.0.8.25 +fd65d5c47000-fd65d5c5f000 ---p 00017000 fc:00 3843 /usr/lib/aarch64-linux-gnu/libexslt.so.0.8.25 +fd65d5c5f000-fd65d5c60000 r--p 0001f000 fc:00 3843 /usr/lib/aarch64-linux-gnu/libexslt.so.0.8.25 +fd65d5c60000-fd65d5c61000 rw-p 00020000 fc:00 3843 /usr/lib/aarch64-linux-gnu/libexslt.so.0.8.25 +fd65d5c70000-fd65d5cab000 r-xp 00000000 fc:00 5128 /usr/lib/aarch64-linux-gnu/libxslt.so.1.1.45 +fd65d5cab000-fd65d5cbf000 ---p 0003b000 fc:00 5128 /usr/lib/aarch64-linux-gnu/libxslt.so.1.1.45 +fd65d5cbf000-fd65d5cc0000 r--p 0003f000 fc:00 5128 /usr/lib/aarch64-linux-gnu/libxslt.so.1.1.45 +fd65d5cc0000-fd65d5cc1000 rw-p 00040000 fc:00 5128 /usr/lib/aarch64-linux-gnu/libxslt.so.1.1.45 +fd65d5cd0000-fd65d5cd8000 r-xp 00000000 fc:00 138679 /usr/lib/php/20250925/xsl.so +fd65d5cd8000-fd65d5cef000 ---p 00008000 fc:00 138679 /usr/lib/php/20250925/xsl.so +fd65d5cef000-fd65d5cf0000 r--p 0000f000 fc:00 138679 /usr/lib/php/20250925/xsl.so +fd65d5cf0000-fd65d5cf1000 rw-p 00010000 fc:00 138679 /usr/lib/php/20250925/xsl.so +fd65d5d00000-fd65d5d0a000 r-xp 00000000 fc:00 138677 /usr/lib/php/20250925/xmlwriter.so +fd65d5d0a000-fd65d5d1d000 ---p 0000a000 fc:00 138677 /usr/lib/php/20250925/xmlwriter.so +fd65d5d1d000-fd65d5d20000 r--p 0000d000 fc:00 138677 /usr/lib/php/20250925/xmlwriter.so +fd65d5d20000-fd65d5d21000 rw-p 00010000 fc:00 138677 /usr/lib/php/20250925/xmlwriter.so +fd65d5d30000-fd65d5d3a000 r-xp 00000000 fc:00 138676 /usr/lib/php/20250925/xmlreader.so +fd65d5d3a000-fd65d5d4e000 ---p 0000a000 fc:00 138676 /usr/lib/php/20250925/xmlreader.so +fd65d5d4e000-fd65d5d50000 r--p 0000e000 fc:00 138676 /usr/lib/php/20250925/xmlreader.so +fd65d5d50000-fd65d5d51000 rw-p 00010000 fc:00 138676 /usr/lib/php/20250925/xmlreader.so +fd65d5d60000-fd65d5d67000 r-xp 00000000 fc:00 138722 /usr/lib/php/20250925/tokenizer.so +fd65d5d67000-fd65d5d7f000 ---p 00007000 fc:00 138722 /usr/lib/php/20250925/tokenizer.so +fd65d5d7f000-fd65d5d80000 r--p 0000f000 fc:00 138722 /usr/lib/php/20250925/tokenizer.so +fd65d5d80000-fd65d5d81000 rw-p 00010000 fc:00 138722 /usr/lib/php/20250925/tokenizer.so +fd65d5d90000-fd65d5d93000 r-xp 00000000 fc:00 138720 /usr/lib/php/20250925/sysvshm.so +fd65d5d93000-fd65d5daf000 ---p 00003000 fc:00 138720 /usr/lib/php/20250925/sysvshm.so +fd65d5daf000-fd65d5db0000 r--p 0000f000 fc:00 138720 /usr/lib/php/20250925/sysvshm.so +fd65d5db0000-fd65d5db1000 rw-p 00010000 fc:00 138720 /usr/lib/php/20250925/sysvshm.so +fd65d5dc0000-fd65d5dc3000 r-xp 00000000 fc:00 138718 /usr/lib/php/20250925/sysvsem.so +fd65d5dc3000-fd65d5ddf000 ---p 00003000 fc:00 138718 /usr/lib/php/20250925/sysvsem.so +fd65d5ddf000-fd65d5de0000 r--p 0000f000 fc:00 138718 /usr/lib/php/20250925/sysvsem.so +fd65d5de0000-fd65d5de1000 rw-p 00010000 fc:00 138718 /usr/lib/php/20250925/sysvsem.so +fd65d5df0000-fd65d5df4000 r-xp 00000000 fc:00 138717 /usr/lib/php/20250925/sysvmsg.so +fd65d5df4000-fd65d5e0f000 ---p 00004000 fc:00 138717 /usr/lib/php/20250925/sysvmsg.so +fd65d5e0f000-fd65d5e10000 r--p 0000f000 fc:00 138717 /usr/lib/php/20250925/sysvmsg.so +fd65d5e10000-fd65d5e11000 rw-p 00010000 fc:00 138717 /usr/lib/php/20250925/sysvmsg.so +fd65d5e20000-fd65d5e2f000 r-xp 00000000 fc:00 138683 /usr/lib/php/20250925/sqlite3.so +fd65d5e2f000-fd65d5e3e000 ---p 0000f000 fc:00 138683 /usr/lib/php/20250925/sqlite3.so +fd65d5e3e000-fd65d5e40000 r--p 0001e000 fc:00 138683 /usr/lib/php/20250925/sqlite3.so +fd65d5e40000-fd65d5e41000 rw-p 00020000 fc:00 138683 /usr/lib/php/20250925/sqlite3.so +fd65d5e50000-fd65d5e69000 r-xp 00000000 fc:00 138715 /usr/lib/php/20250925/sockets.so +fd65d5e69000-fd65d5e7d000 ---p 00019000 fc:00 138715 /usr/lib/php/20250925/sockets.so +fd65d5e7d000-fd65d5e80000 r--p 0001d000 fc:00 138715 /usr/lib/php/20250925/sockets.so +fd65d5e80000-fd65d5e81000 rw-p 00020000 fc:00 138715 /usr/lib/php/20250925/sockets.so +fd65d5e90000-fd65d5e9d000 r-xp 00000000 fc:00 135045 /usr/lib/php/20250925/simplexml.so +fd65d5e9d000-fd65d5eae000 ---p 0000d000 fc:00 135045 /usr/lib/php/20250925/simplexml.so +fd65d5eae000-fd65d5eb0000 r--p 0000e000 fc:00 135045 /usr/lib/php/20250925/simplexml.so +fd65d5eb0000-fd65d5eb1000 rw-p 00010000 fc:00 135045 /usr/lib/php/20250925/simplexml.so diff --git a/tests/data/os/debian-12-apt-config-dump b/tests/data/os/debian-12-apt-config-dump new file mode 100644 index 00000000..90205c5a --- /dev/null +++ b/tests/data/os/debian-12-apt-config-dump @@ -0,0 +1,223 @@ +APT ""; +APT::Architecture "arm64"; +APT::Build-Essential ""; +APT::Build-Essential:: "build-essential"; +APT::Install-Recommends "1"; +APT::Install-Suggests "0"; +APT::Sandbox ""; +APT::Sandbox::User "_apt"; +APT::NeverAutoRemove ""; +APT::NeverAutoRemove:: "^firmware-linux.*"; +APT::NeverAutoRemove:: "^linux-firmware$"; +APT::NeverAutoRemove:: "^linux-image-[a-z0-9]*$"; +APT::NeverAutoRemove:: "^linux-image-[a-z0-9]*-[a-z0-9]*$"; +APT::VersionedKernelPackages ""; +APT::VersionedKernelPackages:: "linux-.*"; +APT::VersionedKernelPackages:: "kfreebsd-.*"; +APT::VersionedKernelPackages:: "gnumach-.*"; +APT::VersionedKernelPackages:: ".*-modules"; +APT::VersionedKernelPackages:: ".*-kernel"; +APT::Never-MarkAuto-Sections ""; +APT::Never-MarkAuto-Sections:: "metapackages"; +APT::Never-MarkAuto-Sections:: "tasks"; +APT::Move-Autobit-Sections ""; +APT::Move-Autobit-Sections:: "oldlibs"; +APT::Periodic ""; +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +APT::AutoRemove ""; +APT::AutoRemove::SuggestsImportant "false"; +APT::Update ""; +APT::Update::Post-Invoke ""; +APT::Update::Post-Invoke:: "rm -f /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin || true"; +APT::Architectures ""; +APT::Architectures:: "arm64"; +APT::Compressor ""; +APT::Compressor::. ""; +APT::Compressor::.::Name "."; +APT::Compressor::.::Extension ""; +APT::Compressor::.::Binary ""; +APT::Compressor::.::Cost "0"; +APT::Compressor::zstd ""; +APT::Compressor::zstd::Name "zstd"; +APT::Compressor::zstd::Extension ".zst"; +APT::Compressor::zstd::Binary "false"; +APT::Compressor::zstd::Cost "60"; +APT::Compressor::lz4 ""; +APT::Compressor::lz4::Name "lz4"; +APT::Compressor::lz4::Extension ".lz4"; +APT::Compressor::lz4::Binary "false"; +APT::Compressor::lz4::Cost "50"; +APT::Compressor::gzip ""; +APT::Compressor::gzip::Name "gzip"; +APT::Compressor::gzip::Extension ".gz"; +APT::Compressor::gzip::Binary "gzip"; +APT::Compressor::gzip::Cost "100"; +APT::Compressor::gzip::CompressArg ""; +APT::Compressor::gzip::CompressArg:: "-6n"; +APT::Compressor::gzip::UncompressArg ""; +APT::Compressor::gzip::UncompressArg:: "-d"; +APT::Compressor::xz ""; +APT::Compressor::xz::Name "xz"; +APT::Compressor::xz::Extension ".xz"; +APT::Compressor::xz::Binary "xz"; +APT::Compressor::xz::Cost "200"; +APT::Compressor::xz::CompressArg ""; +APT::Compressor::xz::CompressArg:: "-6"; +APT::Compressor::xz::UncompressArg ""; +APT::Compressor::xz::UncompressArg:: "-d"; +APT::Compressor::bzip2 ""; +APT::Compressor::bzip2::Name "bzip2"; +APT::Compressor::bzip2::Extension ".bz2"; +APT::Compressor::bzip2::Binary "false"; +APT::Compressor::bzip2::Cost "300"; +APT::Compressor::lzma ""; +APT::Compressor::lzma::Name "lzma"; +APT::Compressor::lzma::Extension ".lzma"; +APT::Compressor::lzma::Binary "xz"; +APT::Compressor::lzma::Cost "400"; +APT::Compressor::lzma::CompressArg ""; +APT::Compressor::lzma::CompressArg:: "--format=lzma"; +APT::Compressor::lzma::CompressArg:: "-6"; +APT::Compressor::lzma::UncompressArg ""; +APT::Compressor::lzma::UncompressArg:: "--format=lzma"; +APT::Compressor::lzma::UncompressArg:: "-d"; +Dir "/"; +Dir::State "var/lib/apt"; +Dir::State::lists "lists/"; +Dir::State::cdroms "cdroms.list"; +Dir::State::extended_states "extended_states"; +Dir::State::status "/var/lib/dpkg/status"; +Dir::Cache "var/cache/apt"; +Dir::Cache::archives "archives/"; +Dir::Cache::srcpkgcache ""; +Dir::Cache::pkgcache ""; +Dir::Etc "etc/apt"; +Dir::Etc::sourcelist "sources.list"; +Dir::Etc::sourceparts "sources.list.d"; +Dir::Etc::main "apt.conf"; +Dir::Etc::netrc "auth.conf"; +Dir::Etc::netrcparts "auth.conf.d"; +Dir::Etc::parts "apt.conf.d"; +Dir::Etc::preferences "preferences"; +Dir::Etc::preferencesparts "preferences.d"; +Dir::Etc::trusted "trusted.gpg"; +Dir::Etc::trustedparts "trusted.gpg.d"; +Dir::Bin ""; +Dir::Bin::methods "/usr/lib/apt/methods"; +Dir::Bin::solvers ""; +Dir::Bin::solvers:: "/usr/lib/apt/solvers"; +Dir::Bin::planners ""; +Dir::Bin::planners:: "/usr/lib/apt/planners"; +Dir::Bin::dpkg "/usr/bin/dpkg"; +Dir::Bin::gzip "/bin/gzip"; +Dir::Bin::bzip2 "/bin/bzip2"; +Dir::Bin::xz "/usr/bin/xz"; +Dir::Bin::lz4 "/usr/bin/lz4"; +Dir::Bin::zstd "/usr/bin/zstd"; +Dir::Bin::lzma "/usr/bin/xz"; +Dir::Media ""; +Dir::Media::MountPath "/media/apt"; +Dir::Log "var/log/apt"; +Dir::Log::Terminal "term.log"; +Dir::Log::History "history.log"; +Dir::Log::Planner "eipp.log.xz"; +Dir::Ignore-Files-Silently ""; +Dir::Ignore-Files-Silently:: "~$"; +Dir::Ignore-Files-Silently:: "\.disabled$"; +Dir::Ignore-Files-Silently:: "\.bak$"; +Dir::Ignore-Files-Silently:: "\.dpkg-[a-z]+$"; +Dir::Ignore-Files-Silently:: "\.ucf-[a-z]+$"; +Dir::Ignore-Files-Silently:: "\.save$"; +Dir::Ignore-Files-Silently:: "\.orig$"; +Dir::Ignore-Files-Silently:: "\.distUpgrade$"; +Acquire ""; +Acquire::AllowInsecureRepositories "0"; +Acquire::AllowWeakRepositories "0"; +Acquire::AllowDowngradeToInsecureRepositories "0"; +Acquire::cdrom ""; +Acquire::cdrom::mount "/media/cdrom/"; +Acquire::IndexTargets ""; +Acquire::IndexTargets::deb ""; +Acquire::IndexTargets::deb::Packages ""; +Acquire::IndexTargets::deb::Packages::MetaKey "$(COMPONENT)/binary-$(ARCHITECTURE)/Packages"; +Acquire::IndexTargets::deb::Packages::flatMetaKey "Packages"; +Acquire::IndexTargets::deb::Packages::ShortDescription "Packages"; +Acquire::IndexTargets::deb::Packages::Description "$(RELEASE)/$(COMPONENT) $(ARCHITECTURE) Packages"; +Acquire::IndexTargets::deb::Packages::flatDescription "$(RELEASE) Packages"; +Acquire::IndexTargets::deb::Packages::Optional "0"; +Acquire::IndexTargets::deb::Translations ""; +Acquire::IndexTargets::deb::Translations::MetaKey "$(COMPONENT)/i18n/Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::flatMetaKey "$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::ShortDescription "Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::Description "$(RELEASE)/$(COMPONENT) Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::flatDescription "$(RELEASE) Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb-src ""; +Acquire::IndexTargets::deb-src::Sources ""; +Acquire::IndexTargets::deb-src::Sources::MetaKey "$(COMPONENT)/source/Sources"; +Acquire::IndexTargets::deb-src::Sources::flatMetaKey "Sources"; +Acquire::IndexTargets::deb-src::Sources::ShortDescription "Sources"; +Acquire::IndexTargets::deb-src::Sources::Description "$(RELEASE)/$(COMPONENT) Sources"; +Acquire::IndexTargets::deb-src::Sources::flatDescription "$(RELEASE) Sources"; +Acquire::IndexTargets::deb-src::Sources::Optional "0"; +Acquire::Changelogs ""; +Acquire::Changelogs::URI ""; +Acquire::Changelogs::URI::Origin ""; +Acquire::Changelogs::URI::Origin::Debian "https://metadata.ftp-master.debian.org/changelogs/@CHANGEPATH@_changelog"; +Acquire::Changelogs::URI::Origin::Ubuntu "https://changelogs.ubuntu.com/changelogs/pool/@CHANGEPATH@/changelog"; +Acquire::Changelogs::AlwaysOnline ""; +Acquire::Changelogs::AlwaysOnline::Origin ""; +Acquire::Changelogs::AlwaysOnline::Origin::Ubuntu "1"; +Acquire::ForceIPv4 "true"; +Acquire::http ""; +Acquire::http::Timeout "30"; +Acquire::Retries "10"; +Acquire::GzipIndexes "true"; +Acquire::Languages ""; +Acquire::Languages:: "none"; +Acquire::CompressionTypes ""; +Acquire::CompressionTypes::xz "xz"; +Acquire::CompressionTypes::bz2 "bzip2"; +Acquire::CompressionTypes::lzma "lzma"; +Acquire::CompressionTypes::gz "gzip"; +Acquire::CompressionTypes::lz4 "lz4"; +Acquire::CompressionTypes::zst "zstd"; +DPkg ""; +DPkg::Path "/usr/sbin:/usr/bin:/sbin:/bin"; +DPkg::Pre-Install-Pkgs ""; +DPkg::Pre-Install-Pkgs:: "/usr/sbin/dpkg-preconfigure --apt || true"; +DPkg::Post-Invoke ""; +DPkg::Post-Invoke:: "test -x /usr/lib/needrestart/apt-pinvoke && /usr/lib/needrestart/apt-pinvoke || true"; +DPkg::Post-Invoke:: "rm -f /var/cache/apt/archives/*.deb /var/cache/apt/archives/partial/*.deb /var/cache/apt/*.bin || true"; +Unattended-Upgrade ""; +Unattended-Upgrade::Origins-Pattern ""; +Unattended-Upgrade::Origins-Pattern:: "origin=Debian,codename=${distro_codename},label=Debian"; +Unattended-Upgrade::Origins-Pattern:: "origin=Debian,codename=${distro_codename},label=Debian-Security"; +Unattended-Upgrade::Origins-Pattern:: "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; +Binary "apt-config"; +Binary::apt ""; +Binary::apt::APT ""; +Binary::apt::APT::Color "1"; +Binary::apt::APT::Cache ""; +Binary::apt::APT::Cache::Show ""; +Binary::apt::APT::Cache::Show::Version "2"; +Binary::apt::APT::Cache::AllVersions "0"; +Binary::apt::APT::Cache::ShowVirtuals "1"; +Binary::apt::APT::Cache::Search ""; +Binary::apt::APT::Cache::Search::Version "2"; +Binary::apt::APT::Cache::ShowDependencyType "1"; +Binary::apt::APT::Cache::ShowVersion "1"; +Binary::apt::APT::Get ""; +Binary::apt::APT::Get::Upgrade-Allow-New "1"; +Binary::apt::APT::Get::Update ""; +Binary::apt::APT::Get::Update::InteractiveReleaseInfoChanges "1"; +Binary::apt::APT::Cmd ""; +Binary::apt::APT::Cmd::Show-Update-Stats "1"; +Binary::apt::APT::Cmd::Pattern-Only "1"; +Binary::apt::APT::Keep-Downloaded-Packages "0"; +Binary::apt::DPkg ""; +Binary::apt::DPkg::Progress-Fancy "1"; +Binary::apt::DPkg::Lock ""; +Binary::apt::DPkg::Lock::Timeout "120"; +CommandLine ""; +CommandLine::AsString "apt-config dump"; diff --git a/tests/data/os/debian-12-apt-get-s-pending b/tests/data/os/debian-12-apt-get-s-pending new file mode 100644 index 00000000..7195ad40 --- /dev/null +++ b/tests/data/os/debian-12-apt-get-s-pending @@ -0,0 +1,17 @@ +NOTE: This is only a simulation! + apt-get needs root privileges for real execution. + Keep also in mind that locking is deactivated, + so don't depend on the relevance to the real current situation! +Reading package lists... +Building dependency tree... +Reading state information... +Calculating upgrade... +The following packages have been kept back: + tzdata +The following packages will be upgraded: + libssl3 openssl +2 upgraded, 0 newly installed, 0 to remove and 1 not upgraded. +Inst libssl3 [3.0.20-1~deb12u2] (3.0.22-1~deb12u1 Debian-Security:12/oldstable-security [arm64]) +Inst openssl [3.0.20-1~deb12u2] (3.0.22-1~deb12u1 Debian-Security:12/oldstable-security [arm64]) +Conf libssl3 (3.0.22-1~deb12u1 Debian-Security:12/oldstable-security [arm64]) +Conf openssl (3.0.22-1~deb12u1 Debian-Security:12/oldstable-security [arm64]) diff --git a/tests/data/os/debian-12-ls-apt-lists b/tests/data/os/debian-12-ls-apt-lists new file mode 100644 index 00000000..fa190375 --- /dev/null +++ b/tests/data/os/debian-12-ls-apt-lists @@ -0,0 +1,18 @@ +/var/lib/apt/lists/: +total 19160 +drwxr-xr-x 1 root root 4096 1792431065 . +drwxr-xr-x 1 root root 4096 1792432168 .. +drwxr-xr-x 2 _apt root 4096 1792430934 auxfiles +-rw-r--r-- 1 root root 34770 1792345598 deb.debian.org_debian-security_dists_bookworm-security_InRelease +-rw-r--r-- 1 root root 706000 1792315332 deb.debian.org_debian-security_dists_bookworm-security_main_binary-arm64_Packages.lz4 +-rw-r--r-- 1 root root 55403 1792418975 deb.debian.org_debian_dists_bookworm-updates_InRelease +-rw-r--r-- 1 root root 11617 1754661485 deb.debian.org_debian_dists_bookworm-updates_main_binary-arm64_Packages.lz4 +-rw-r--r-- 1 root root 151075 1783765271 deb.debian.org_debian_dists_bookworm_InRelease +-rw-r--r-- 1 root root 18630901 1783763355 deb.debian.org_debian_dists_bookworm_main_binary-arm64_Packages.lz4 +-rw-r----- 1 root root 0 1792430934 lock +drwx------ 2 _apt root 4096 1792432161 partial + +/var/lib/apt/periodic/: +total 12 +drwxr-xr-x 2 root root 4096 1685023897 . +drwxr-xr-x 1 root root 4096 1792432168 .. diff --git a/tests/data/os/ubuntu-24.04-apt-get-s-pending b/tests/data/os/ubuntu-24.04-apt-get-s-pending new file mode 100644 index 00000000..5820e215 --- /dev/null +++ b/tests/data/os/ubuntu-24.04-apt-get-s-pending @@ -0,0 +1,27 @@ +NOTE: This is only a simulation! + apt-get needs root privileges for real execution. + Keep also in mind that locking is deactivated, + so don't depend on the relevance to the real current situation! +Reading package lists... +Building dependency tree... +Reading state information... +Calculating upgrade... +The following packages have been kept back: + sudo +The following packages will be upgraded: + curl libc-bin libc6 libcurl4t64 libssl3t64 openssl tzdata +7 upgraded, 0 newly installed, 0 to remove and 1 not upgraded. +Inst libc6 [2.39-0ubuntu8] (2.39-0ubuntu8.9 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Conf libc6 (2.39-0ubuntu8.9 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Inst libc-bin [2.39-0ubuntu8] (2.39-0ubuntu8.9 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Conf libc-bin (2.39-0ubuntu8.9 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Inst libssl3t64 [3.0.13-0ubuntu3] (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Conf libssl3t64 (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Inst openssl [3.0.13-0ubuntu3] (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Inst tzdata [2024a-2ubuntu1] (2026e-0ubuntu0.24.04.1 Ubuntu:24.04/noble-updates [all]) +Inst curl [8.5.0-2ubuntu10] (8.5.0-2ubuntu10.16 Ubuntu:24.04/noble-security [arm64]) [] +Inst libcurl4t64 [8.5.0-2ubuntu10] (8.5.0-2ubuntu10.16 Ubuntu:24.04/noble-security [arm64]) +Conf openssl (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [arm64]) +Conf tzdata (2026e-0ubuntu0.24.04.1 Ubuntu:24.04/noble-updates [all]) +Conf curl (8.5.0-2ubuntu10.16 Ubuntu:24.04/noble-security [arm64]) +Conf libcurl4t64 (8.5.0-2ubuntu10.16 Ubuntu:24.04/noble-security [arm64]) diff --git a/tests/data/os/ubuntu-24.04-os-release b/tests/data/os/ubuntu-24.04-os-release new file mode 100644 index 00000000..290ec6dc --- /dev/null +++ b/tests/data/os/ubuntu-24.04-os-release @@ -0,0 +1,9 @@ +PRETTY_NAME="Ubuntu 24.04.3 LTS" +NAME="Ubuntu" +VERSION_ID="24.04" +VERSION="24.04.3 LTS (Noble Numbat)" +VERSION_CODENAME=noble +ID=ubuntu +ID_LIKE=debian +HOME_URL="https://www.ubuntu.com/" +UBUNTU_CODENAME=noble diff --git a/tests/data/os/ubuntu-26.04-apt-config-dump b/tests/data/os/ubuntu-26.04-apt-config-dump new file mode 100644 index 00000000..8e6b6c21 --- /dev/null +++ b/tests/data/os/ubuntu-26.04-apt-config-dump @@ -0,0 +1,340 @@ +APT ""; +APT::Architecture "arm64"; +APT::Build-Essential ""; +APT::Build-Essential:: "build-essential"; +APT::Install-Recommends "1"; +APT::Install-Suggests "0"; +APT::Key ""; +APT::Key::Assert-Pubkey-Algo ">=rsa2048,ed25519,ed448,nistp256,nistp384,nistp512,brainpoolP256r1,brainpoolP320r1,brainpoolP384r1,brainpoolP512r1,secp256k1"; +APT::Key::Assert-Pubkey-Algo::Next ">=rsa2048,ed25519,ed448,nistp256,nistp384,nistp512"; +APT::Key::Assert-Pubkey-Algo::Future ">=rsa3072,ed25519,ed448"; +APT::Sandbox ""; +APT::Sandbox::User "_apt"; +APT::NeverAutoRemove ""; +APT::NeverAutoRemove:: "^firmware-linux.*"; +APT::NeverAutoRemove:: "^linux-firmware$"; +APT::NeverAutoRemove:: "^linux-image-[a-z0-9]*$"; +APT::NeverAutoRemove:: "^linux-image-[a-z0-9]*-[a-z0-9]*$"; +APT::VersionedKernelPackages ""; +APT::VersionedKernelPackages:: "linux-.*"; +APT::VersionedKernelPackages:: "kfreebsd-.*"; +APT::VersionedKernelPackages:: "gnumach-.*"; +APT::VersionedKernelPackages:: ".*-modules"; +APT::VersionedKernelPackages:: ".*-kernel"; +APT::Never-MarkAuto-Sections ""; +APT::Never-MarkAuto-Sections:: "metapackages"; +APT::Never-MarkAuto-Sections:: "tasks"; +APT::Move-Autobit-Sections ""; +APT::Move-Autobit-Sections:: "oldlibs"; +APT::Update ""; +APT::Update::Pre-Invoke ""; +APT::Update::Pre-Invoke:: "[ ! -e /run/systemd/system ] || [ $(id -u) -ne 0 ] || systemctl start --no-block apt-news.service esm-cache.service >/dev/null 2>&1 || true"; +APT::Update::Post-Invoke-Success ""; +APT::Update::Post-Invoke-Success:: "/usr/bin/test -e /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service && /usr/bin/test -S /var/run/dbus/system_bus_socket && /usr/bin/test ! -e /run/ostree-booted && /usr/bin/gdbus call --system --dest org.freedesktop.PackageKit --object-path /org/freedesktop/PackageKit --timeout 4 --method org.freedesktop.PackageKit.StateHasChanged cache-update > /dev/null; /bin/echo > /dev/null"; +APT::Update::Post-Invoke-Success:: "if /usr/bin/test -w /var/cache/swcatalog -a -e /usr/bin/appstreamcli; then appstreamcli refresh --source=os > /dev/null || true; fi"; +APT::Update::Post-Invoke-Success:: "if /usr/bin/test -w /var/lib/command-not-found/ -a -e /usr/lib/cnf-update-db; then /usr/lib/cnf-update-db > /dev/null; fi"; +APT::Periodic ""; +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +APT::Solver "3.0"; +APT::Architectures ""; +APT::Architectures:: "arm64"; +APT::Compressor ""; +APT::Compressor::. ""; +APT::Compressor::.::Name "."; +APT::Compressor::.::Extension ""; +APT::Compressor::.::Binary ""; +APT::Compressor::.::Cost "0"; +APT::Compressor::zstd ""; +APT::Compressor::zstd::Name "zstd"; +APT::Compressor::zstd::Extension ".zst"; +APT::Compressor::zstd::Binary "zstd"; +APT::Compressor::zstd::Cost "60"; +APT::Compressor::zstd::CompressArg ""; +APT::Compressor::zstd::CompressArg:: "-6"; +APT::Compressor::zstd::UncompressArg ""; +APT::Compressor::zstd::UncompressArg:: "-d"; +APT::Compressor::lz4 ""; +APT::Compressor::lz4::Name "lz4"; +APT::Compressor::lz4::Extension ".lz4"; +APT::Compressor::lz4::Binary "false"; +APT::Compressor::lz4::Cost "50"; +APT::Compressor::gzip ""; +APT::Compressor::gzip::Name "gzip"; +APT::Compressor::gzip::Extension ".gz"; +APT::Compressor::gzip::Binary "gzip"; +APT::Compressor::gzip::Cost "100"; +APT::Compressor::gzip::CompressArg ""; +APT::Compressor::gzip::CompressArg:: "-6n"; +APT::Compressor::gzip::UncompressArg ""; +APT::Compressor::gzip::UncompressArg:: "-d"; +APT::Compressor::xz ""; +APT::Compressor::xz::Name "xz"; +APT::Compressor::xz::Extension ".xz"; +APT::Compressor::xz::Binary "xz"; +APT::Compressor::xz::Cost "200"; +APT::Compressor::xz::CompressArg ""; +APT::Compressor::xz::CompressArg:: "-6"; +APT::Compressor::xz::UncompressArg ""; +APT::Compressor::xz::UncompressArg:: "-d"; +APT::Compressor::bzip2 ""; +APT::Compressor::bzip2::Name "bzip2"; +APT::Compressor::bzip2::Extension ".bz2"; +APT::Compressor::bzip2::Binary "bzip2"; +APT::Compressor::bzip2::Cost "300"; +APT::Compressor::bzip2::CompressArg ""; +APT::Compressor::bzip2::CompressArg:: "-6"; +APT::Compressor::bzip2::UncompressArg ""; +APT::Compressor::bzip2::UncompressArg:: "-d"; +APT::Compressor::lzma ""; +APT::Compressor::lzma::Name "lzma"; +APT::Compressor::lzma::Extension ".lzma"; +APT::Compressor::lzma::Binary "xz"; +APT::Compressor::lzma::Cost "400"; +APT::Compressor::lzma::CompressArg ""; +APT::Compressor::lzma::CompressArg:: "--format=lzma"; +APT::Compressor::lzma::CompressArg:: "-6"; +APT::Compressor::lzma::UncompressArg ""; +APT::Compressor::lzma::UncompressArg:: "--format=lzma"; +APT::Compressor::lzma::UncompressArg:: "-d"; +Dir "/"; +Dir::State "var/lib/apt"; +Dir::State::lists "lists/"; +Dir::State::cdroms "cdroms.list"; +Dir::State::extended_states "extended_states"; +Dir::State::status "/var/lib/dpkg/status"; +Dir::Cache "var/cache/apt"; +Dir::Cache::archives "archives/"; +Dir::Cache::srcpkgcache "srcpkgcache.bin"; +Dir::Cache::pkgcache "pkgcache.bin"; +Dir::Etc "etc/apt"; +Dir::Etc::sourcelist "sources.list"; +Dir::Etc::sourceparts "sources.list.d"; +Dir::Etc::main "apt.conf"; +Dir::Etc::netrc "auth.conf"; +Dir::Etc::netrcparts "auth.conf.d"; +Dir::Etc::parts "apt.conf.d"; +Dir::Etc::preferences "preferences"; +Dir::Etc::preferencesparts "preferences.d"; +Dir::Etc::trustedparts "trusted.gpg.d"; +Dir::Boot "boot"; +Dir::Usr "usr"; +Dir::Bin ""; +Dir::Bin::methods "/usr/lib/apt/methods"; +Dir::Bin::solvers ""; +Dir::Bin::solvers:: "/usr/lib/apt/solvers"; +Dir::Bin::planners ""; +Dir::Bin::planners:: "/usr/lib/apt/planners"; +Dir::Bin::dpkg "/usr/bin/dpkg"; +Dir::Bin::gzip "/bin/gzip"; +Dir::Bin::bzip2 "/bin/bzip2"; +Dir::Bin::xz "/usr/bin/xz"; +Dir::Bin::lz4 "/usr/bin/lz4"; +Dir::Bin::zstd "/usr/bin/zstd"; +Dir::Bin::lzma "/usr/bin/xz"; +Dir::Media ""; +Dir::Media::MountPath "/media/apt"; +Dir::Log "var/log/apt"; +Dir::Log::Terminal "term.log"; +Dir::Log::History "history.log"; +Dir::Log::Planner "eipp.log.xz"; +Dir::Ignore-Files-Silently ""; +Dir::Ignore-Files-Silently:: "~$"; +Dir::Ignore-Files-Silently:: "\.disabled$"; +Dir::Ignore-Files-Silently:: "\.bak$"; +Dir::Ignore-Files-Silently:: "\.dpkg-[a-z]+$"; +Dir::Ignore-Files-Silently:: "\.ucf-[a-z]+$"; +Dir::Ignore-Files-Silently:: "\.save$"; +Dir::Ignore-Files-Silently:: "\.orig$"; +Dir::Ignore-Files-Silently:: "\.distUpgrade$"; +Acquire ""; +Acquire::AllowInsecureRepositories "0"; +Acquire::AllowWeakRepositories "0"; +Acquire::AllowDowngradeToInsecureRepositories "0"; +Acquire::cdrom ""; +Acquire::cdrom::mount "/media/cdrom/"; +Acquire::IndexTargets ""; +Acquire::IndexTargets::deb ""; +Acquire::IndexTargets::deb::Packages ""; +Acquire::IndexTargets::deb::Packages::MetaKey "$(COMPONENT)/binary-$(ARCHITECTURE)/Packages"; +Acquire::IndexTargets::deb::Packages::flatMetaKey "Packages"; +Acquire::IndexTargets::deb::Packages::ShortDescription "Packages"; +Acquire::IndexTargets::deb::Packages::Description "$(RELEASE)/$(COMPONENT) $(ARCHITECTURE) Packages"; +Acquire::IndexTargets::deb::Packages::flatDescription "$(RELEASE) Packages"; +Acquire::IndexTargets::deb::Packages::Optional "0"; +Acquire::IndexTargets::deb::Translations ""; +Acquire::IndexTargets::deb::Translations::MetaKey "$(COMPONENT)/i18n/Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::flatMetaKey "$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::ShortDescription "Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::Description "$(RELEASE)/$(COMPONENT) Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::Translations::flatDescription "$(RELEASE) Translation-$(LANGUAGE)"; +Acquire::IndexTargets::deb::DEP-11 ""; +Acquire::IndexTargets::deb::DEP-11::MetaKey "$(COMPONENT)/dep11/Components-$(NATIVE_ARCHITECTURE).yml"; +Acquire::IndexTargets::deb::DEP-11::ShortDescription "Components-$(NATIVE_ARCHITECTURE)"; +Acquire::IndexTargets::deb::DEP-11::Description "$(RELEASE)/$(COMPONENT) $(NATIVE_ARCHITECTURE) Components"; +Acquire::IndexTargets::deb::DEP-11::KeepCompressed "true"; +Acquire::IndexTargets::deb::DEP-11::KeepCompressedAs "gz"; +Acquire::IndexTargets::deb::DEP-11-icons-small ""; +Acquire::IndexTargets::deb::DEP-11-icons-small::MetaKey "$(COMPONENT)/dep11/icons-48x48.tar"; +Acquire::IndexTargets::deb::DEP-11-icons-small::ShortDescription "icons-48x48"; +Acquire::IndexTargets::deb::DEP-11-icons-small::Description "$(RELEASE)/$(COMPONENT) Icons (48x48)"; +Acquire::IndexTargets::deb::DEP-11-icons-small::KeepCompressed "true"; +Acquire::IndexTargets::deb::DEP-11-icons-small::KeepCompressedAs "gz"; +Acquire::IndexTargets::deb::DEP-11-icons-small::DefaultEnabled "false"; +Acquire::IndexTargets::deb::DEP-11-icons ""; +Acquire::IndexTargets::deb::DEP-11-icons::MetaKey "$(COMPONENT)/dep11/icons-64x64.tar"; +Acquire::IndexTargets::deb::DEP-11-icons::ShortDescription "icons-64x64"; +Acquire::IndexTargets::deb::DEP-11-icons::Description "$(RELEASE)/$(COMPONENT) Icons (64x64)"; +Acquire::IndexTargets::deb::DEP-11-icons::KeepCompressed "true"; +Acquire::IndexTargets::deb::DEP-11-icons::KeepCompressedAs "gz"; +Acquire::IndexTargets::deb::DEP-11-icons::DefaultEnabled "false"; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi ""; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi::MetaKey "$(COMPONENT)/dep11/icons-64x64@2.tar"; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi::ShortDescription "icons-64x64@2"; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi::Description "$(RELEASE)/$(COMPONENT) Icons (64x64@2)"; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi::KeepCompressed "true"; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi::KeepCompressedAs "gz"; +Acquire::IndexTargets::deb::DEP-11-icons-hidpi::DefaultEnabled "false"; +Acquire::IndexTargets::deb::DEP-11-icons-large ""; +Acquire::IndexTargets::deb::DEP-11-icons-large::MetaKey "$(COMPONENT)/dep11/icons-128x128.tar"; +Acquire::IndexTargets::deb::DEP-11-icons-large::ShortDescription "icons-128x128"; +Acquire::IndexTargets::deb::DEP-11-icons-large::Description "$(RELEASE)/$(COMPONENT) Icons (128x128)"; +Acquire::IndexTargets::deb::DEP-11-icons-large::KeepCompressed "true"; +Acquire::IndexTargets::deb::DEP-11-icons-large::KeepCompressedAs "gz"; +Acquire::IndexTargets::deb::DEP-11-icons-large::DefaultEnabled "false"; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi ""; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi::MetaKey "$(COMPONENT)/dep11/icons-128x128@2.tar"; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi::ShortDescription "icons-128x128@2"; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi::Description "$(RELEASE)/$(COMPONENT) Icons (128x128@2)"; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi::KeepCompressed "true"; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi::KeepCompressedAs "gz"; +Acquire::IndexTargets::deb::DEP-11-icons-large-hidpi::DefaultEnabled "false"; +Acquire::IndexTargets::deb::CNF ""; +Acquire::IndexTargets::deb::CNF::MetaKey "$(COMPONENT)/cnf/Commands-$(NATIVE_ARCHITECTURE)"; +Acquire::IndexTargets::deb::CNF::ShortDescription "Commands-$(NATIVE_ARCHITECTURE)"; +Acquire::IndexTargets::deb::CNF::Description "$(RELEASE)/$(COMPONENT) $(NATIVE_ARCHITECTURE) c-n-f Metadata"; +Acquire::IndexTargets::deb-src ""; +Acquire::IndexTargets::deb-src::Sources ""; +Acquire::IndexTargets::deb-src::Sources::MetaKey "$(COMPONENT)/source/Sources"; +Acquire::IndexTargets::deb-src::Sources::flatMetaKey "Sources"; +Acquire::IndexTargets::deb-src::Sources::ShortDescription "Sources"; +Acquire::IndexTargets::deb-src::Sources::Description "$(RELEASE)/$(COMPONENT) Sources"; +Acquire::IndexTargets::deb-src::Sources::flatDescription "$(RELEASE) Sources"; +Acquire::IndexTargets::deb-src::Sources::Optional "0"; +Acquire::Changelogs ""; +Acquire::Changelogs::URI ""; +Acquire::Changelogs::URI::Origin ""; +Acquire::Changelogs::URI::Origin::Debian "https://metadata.ftp-master.debian.org/changelogs/@CHANGEPATH@_changelog"; +Acquire::Changelogs::URI::Origin::Ubuntu "https://changelogs.ubuntu.com/changelogs/pool/@CHANGEPATH@/changelog"; +Acquire::Changelogs::AlwaysOnline "true"; +Acquire::Changelogs::AlwaysOnline::Origin ""; +Acquire::Changelogs::AlwaysOnline::Origin::Ubuntu "1"; +Acquire::Snapshots ""; +Acquire::Snapshots::URI ""; +Acquire::Snapshots::URI::Origin ""; +Acquire::Snapshots::URI::Origin::Debian "https://snapshot.debian.org/archive/debian/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Origin::Ubuntu "https://snapshot.ubuntu.com/ubuntu/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Override ""; +Acquire::Snapshots::URI::Override::Label ""; +Acquire::Snapshots::URI::Override::Label::Debian-Security "https://snapshot.debian.org/archive/debian-security/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Host ""; +Acquire::Snapshots::URI::Host::archive.ubuntu.com "https://snapshot.ubuntu.com/@PATH@/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Host::deb.debian.org "https://snapshot.debian.org/archive/@PATH@/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Host::.archive.ubuntu.com "https://snapshot.ubuntu.com/@PATH@/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Host::security.ubuntu.com "https://snapshot.ubuntu.com/@PATH@/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Host::ppa.launchpadcontent.net "https://snapshot.ppa.launchpadcontent.net/@PATH@/@SNAPSHOTID@/"; +Acquire::Snapshots::URI::Host::ppa.launchpad.net "https://snapshot.ppa.launchpadcontent.net/@PATH@/@SNAPSHOTID@/"; +Acquire::http ""; +Acquire::http::User-Agent-Non-Interactive "true"; +Acquire::Languages ""; +Acquire::Languages:: "en"; +Acquire::Languages:: "none"; +Acquire::CompressionTypes ""; +Acquire::CompressionTypes::xz "xz"; +Acquire::CompressionTypes::bz2 "bzip2"; +Acquire::CompressionTypes::lzma "lzma"; +Acquire::CompressionTypes::gz "gzip"; +Acquire::CompressionTypes::lz4 "lz4"; +Acquire::CompressionTypes::zst "zstd"; +DPkg ""; +DPkg::Path "/usr/sbin:/usr/bin:/sbin:/bin"; +DPkg::Post-Invoke ""; +DPkg::Post-Invoke:: "/usr/bin/test -e /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service && /usr/bin/test -S /var/run/dbus/system_bus_socket && /usr/bin/test ! -e /run/ostree-booted && /usr/bin/gdbus call --system --dest org.freedesktop.PackageKit --object-path /org/freedesktop/PackageKit --timeout 4 --method org.freedesktop.PackageKit.StateHasChanged cache-update > /dev/null; /bin/echo > /dev/null"; +DPkg::Post-Invoke:: "test -x /usr/lib/needrestart/apt-pinvoke && /usr/lib/needrestart/apt-pinvoke -m u || true"; +DPkg::Pre-Install-Pkgs ""; +DPkg::Pre-Install-Pkgs:: "/usr/sbin/dpkg-preconfigure --apt || true"; +DPkg::Pre-Install-Pkgs:: "test -x /usr/bin/apt_hook_ubuntu_virt && /usr/bin/apt_hook_ubuntu_virt || true"; +DPkg::Tools ""; +DPkg::Tools::options ""; +DPkg::Tools::options::/usr/bin/apt_hook_ubuntu_virt ""; +DPkg::Tools::options::/usr/bin/apt_hook_ubuntu_virt::Version "2"; +binary "apt-config"; +binary::apt ""; +binary::apt::AptCli ""; +binary::apt::AptCli::Hooks ""; +binary::apt::AptCli::Hooks::Upgrade ""; +binary::apt::AptCli::Hooks::Upgrade:: "[ ! -f /usr/lib/ubuntu-advantage/apt-esm-json-hook ] || [ $(id -u) -ne 0 ] || /usr/lib/ubuntu-advantage/apt-esm-json-hook 2>> /var/log/ubuntu-advantage-apt-hook.log || true"; +binary::apt::AptCli::Hooks::Install ""; +binary::apt::AptCli::Hooks::Install:: "[ ! -f /usr/bin/snap ] || /usr/bin/snap advise-snap --from-apt 2>/dev/null || true"; +binary::apt-cdrom ""; +binary::apt-cdrom::APT ""; +binary::apt-cdrom::APT::Internal ""; +binary::apt-cdrom::APT::Internal::OpProgress ""; +binary::apt-cdrom::APT::Internal::OpProgress::EraseLines "0"; +Unattended-Upgrade ""; +Unattended-Upgrade::Allowed-Origins ""; +Unattended-Upgrade::Allowed-Origins:: "${distro_id}:${distro_codename}"; +Unattended-Upgrade::Allowed-Origins:: "${distro_id}:${distro_codename}-security"; +Unattended-Upgrade::Allowed-Origins:: "${distro_id}ESMApps:${distro_codename}-apps-security"; +Unattended-Upgrade::Allowed-Origins:: "${distro_id}ESM:${distro_codename}-infra-security"; +Unattended-Upgrade::DevRelease "auto"; +Version ""; +Version::1.2 ""; +Version::1.2::APT ""; +Version::1.2::APT::Color "1"; +Version::1.2::APT::Keep-Downloaded-Packages "0"; +Version::1.2::DPkg ""; +Version::1.2::DPkg::Progress-Fancy "1"; +Version::3.0 ""; +Version::3.0::APT ""; +Version::3.0::APT::Output-Version "30"; +Version::3.0::Pager "1"; +Version::1.1 ""; +Version::1.1::APT ""; +Version::1.1::APT::Cache ""; +Version::1.1::APT::Cache::Show ""; +Version::1.1::APT::Cache::Show::Version "2"; +Version::1.1::APT::Cache::AllVersions "0"; +Version::1.1::APT::Cache::ShowVirtuals "1"; +Version::1.1::APT::Cache::Search ""; +Version::1.1::APT::Cache::Search::Version "2"; +Version::1.1::APT::Cache::ShowDependencyType "1"; +Version::1.1::APT::Cache::ShowVersion "1"; +Version::1.1::APT::Get ""; +Version::1.1::APT::Get::Upgrade-Allow-New "1"; +Version::1.1::APT::Cmd ""; +Version::1.1::APT::Cmd::Show-Update-Stats "1"; +Version::1.5 ""; +Version::1.5::APT ""; +Version::1.5::APT::Get ""; +Version::1.5::APT::Get::Update ""; +Version::1.5::APT::Get::Update::InteractiveReleaseInfoChanges "1"; +Version::2.0 ""; +Version::2.0::APT ""; +Version::2.0::APT::Cmd ""; +Version::2.0::APT::Cmd::Pattern-Only "1"; +Version::2.0::Dpkg ""; +Version::2.0::Dpkg::Lock ""; +Version::2.0::Dpkg::Lock::Timeout "120"; +Version::0.31 ""; +Version::1.21 ""; +Version::1.21::APT ""; +Version::1.21::APT::Solver "3.0"; +Version::2.11 ""; +Version::2.11::APT ""; +Version::2.11::APT::Solver "3.0"; +Version::3.1 ""; +Version::3.1::APT ""; +Version::3.1::APT::Solver "3.0"; +CommandLine ""; +CommandLine::AsString "apt-config dump"; diff --git a/tests/data/os/ubuntu-26.04-apt-get-s-phased b/tests/data/os/ubuntu-26.04-apt-get-s-phased new file mode 100644 index 00000000..2b1413e8 --- /dev/null +++ b/tests/data/os/ubuntu-26.04-apt-get-s-phased @@ -0,0 +1,11 @@ +NOTE: This is only a simulation! + apt-get needs root privileges for real execution. + Keep also in mind that locking is deactivated, + so don't depend on the relevance to the real current situation! +Reading package lists... +Building dependency tree... +Reading state information... +Calculating upgrade... +The following upgrades have been deferred due to phasing: + open-vm-tools +0 upgraded, 0 newly installed, 0 to remove and 1 not upgraded. diff --git a/tests/data/os/ubuntu-26.04-os-release b/tests/data/os/ubuntu-26.04-os-release new file mode 100644 index 00000000..10d02aa4 --- /dev/null +++ b/tests/data/os/ubuntu-26.04-os-release @@ -0,0 +1,13 @@ +PRETTY_NAME="Ubuntu 26.04.1 LTS" +NAME="Ubuntu" +VERSION_ID="26.04" +VERSION="26.04.1 LTS (Resolute Raccoon)" +VERSION_CODENAME=resolute +ID=ubuntu +ID_LIKE=debian +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +UBUNTU_CODENAME=resolute +LOGO=ubuntu-logo diff --git a/tests/data/os/ubuntu-pro-status-unattached.json b/tests/data/os/ubuntu-pro-status-unattached.json new file mode 100644 index 00000000..8a05b406 --- /dev/null +++ b/tests/data/os/ubuntu-pro-status-unattached.json @@ -0,0 +1 @@ +{"_doc": "Content provided in json response is currently considered Experimental and may change", "_schema_version": "0.1", "version": "37.2ubuntu0.1", "machine_id": null, "attached": false, "effective": null, "expires": null, "origin": null, "services": [{"name": "anbox-cloud", "description": "Scalable Android in the cloud", "description_override": null, "available": "yes"}, {"name": "cc-eal", "description": "Common Criteria EAL2 Provisioning Packages", "description_override": null, "available": "no"}, {"name": "esm-apps", "description": "Expanded Security Maintenance for Applications", "description_override": null, "available": "yes"}, {"name": "esm-apps-legacy", "description": "Expanded Security Maintenance for Applications on Legacy Instances", "description_override": null, "available": "no"}, {"name": "esm-infra", "description": "Expanded Security Maintenance for Infrastructure", "description_override": null, "available": "yes"}, {"name": "esm-infra-legacy", "description": "Expanded Security Maintenance for Infrastructure on Legacy Instances", "description_override": null, "available": "no"}, {"name": "fips", "description": "NIST-certified FIPS crypto packages", "description_override": null, "available": "no"}, {"name": "fips-preview", "description": "Preview of FIPS crypto packages undergoing certification with NIST", "description_override": null, "available": "no"}, {"name": "fips-updates", "description": "FIPS compliant crypto packages with stable security updates", "description_override": null, "available": "no"}, {"name": "landscape", "description": "Management and administration tool for Ubuntu", "description_override": null, "available": "yes"}, {"name": "livepatch", "description": "Canonical Livepatch service", "description_override": null, "available": "yes"}, {"name": "realtime-kernel", "description": "Ubuntu kernel with PREEMPT_RT patches integrated", "description_override": null, "available": "no"}, {"name": "ros", "description": "Security Updates for the Robot Operating System", "description_override": null, "available": "no"}, {"name": "ros-updates", "description": "All Updates for the Robot Operating System", "description_override": null, "available": "no"}, {"name": "usg", "description": "Security compliance and audit tools", "description_override": null, "available": "no"}], "execution_status": "inactive", "execution_details": "No Ubuntu Pro operations are running", "features": {}, "notices": [], "contract": {"id": "", "name": "", "created_at": "", "products": [], "tech_support_level": "n/a"}, "account": {"name": "", "id": "", "created_at": "", "external_account_ids": []}, "simulated": false, "config_path": "/etc/ubuntu-advantage/uaclient.conf", "config": {"contract_url": "https://contracts.canonical.com", "security_url": "https://ubuntu.com/security", "data_dir": "/var/lib/ubuntu-advantage", "log_level": "debug", "log_file": "/var/log/ubuntu-advantage.log", "ua_config": {"apt_http_proxy": null, "apt_https_proxy": null, "global_apt_http_proxy": null, "global_apt_https_proxy": null, "ua_apt_http_proxy": null, "ua_apt_https_proxy": null, "http_proxy": null, "https_proxy": null, "apt_news": true, "apt_news_url": "https://motd.ubuntu.com/aptnews.json", "poll_for_pro_license": null, "polling_error_retry_delay": null, "metering_timer": 14400, "update_messaging_timer": 21600, "vulnerability_data_url_prefix": "https://security-metadata.canonical.com/oval/", "lxd_guest_attach": "off"}}} \ No newline at end of file diff --git a/tests/lib/Commands/ShowOsStatusTest.php b/tests/lib/Commands/ShowOsStatusTest.php new file mode 100644 index 00000000..33b0e627 --- /dev/null +++ b/tests/lib/Commands/ShowOsStatusTest.php @@ -0,0 +1,102 @@ + [Concern::LEVEL_OK, ShowOsStatus::EXIT_OK], + 'info' => [Concern::LEVEL_INFO, ShowOsStatus::EXIT_OK], + 'warning' => [Concern::LEVEL_WARNING, ShowOsStatus::EXIT_WARNING], + 'error' => [Concern::LEVEL_ERROR, ShowOsStatus::EXIT_CRITICAL], + 'unknown' => [Concern::LEVEL_UNKNOWN, ShowOsStatus::EXIT_UNKNOWN], + 'no verdict' => [null, ShowOsStatus::EXIT_UNKNOWN], + ]; + } + + #[DataProvider('dataExitCodes')] + public function testExitCodes(?string $level, int $exitCode): void { + $this->assertSame($exitCode, ShowOsStatus::exitCode($level)); + } + + public function testNotCheckedYetIsUnknown(): void { + $service = $this->createMock(OsStatusService::class); + $service->method('getStatus')->willReturn(null); + + $this->assertSame(ShowOsStatus::EXIT_UNKNOWN, $this->execute($service)); + } + + public function testUnknownVerdictIsUnknown(): void { + $service = $this->createMock(OsStatusService::class); + $service->method('getStatus')->willReturn(OsStatus::restricted(1792411200)); + $service->method('evaluate')->willReturn(new Verdict([new Concern(Concern::UPDATES, Concern::LEVEL_UNKNOWN), new Concern(Concern::SUPPORT, Concern::LEVEL_OK)])); + + $this->assertSame(ShowOsStatus::EXIT_UNKNOWN, $this->execute($service)); + } + + public function testAStaleStatusWarnsAndShowsItsAge(): void { + $status = new OsStatus(OsStatus::STATE_OK, 1792411200 - 3 * 86400 - 7200); + $service = $this->createMock(OsStatusService::class); + $service->method('getStatus')->willReturn($status); + $service->method('evaluate')->willReturn((new OsVerdict())->evaluate($status, null, null, new Thresholds(), 1792411200)); + + $written = []; + $this->assertSame(ShowOsStatus::EXIT_WARNING, $this->execute($service, $written)); + $this->assertSame('3 d 2 h', $written['statusAge'] ?? null); + } + + public static function dataAges(): array { + return [ + [0, '0 min'], + [59 * 60 + 59, '59 min'], + [3600 + 5 * 60, '1 h 5 min'], + [86400 + 3 * 3600 + 120, '1 d 3 h'], + ]; + } + + #[DataProvider('dataAges')] + public function testAges(int $seconds, string $formatted): void { + $this->assertSame($formatted, ShowOsStatus::formatAge($seconds)); + } + + public function testIsAnOccCommandWithAnOutputFormat(): void { + $attributes = (new \ReflectionClass(ShowOsStatus::class))->getAttributes(AsCommand::class); + + $this->assertCount(1, $attributes); + $command = $attributes[0]->newInstance(); + $this->assertSame('serverinfo:os-status', $command->name); + $this->assertTrue($command->supportsOutputFormat); + } + + /** + * @param array $written receives what the command writes as its result + */ + private function execute(OsStatusService $service, array &$written = [], bool $refresh = false, ?OsStatusCollector $collector = null): int { + $command = new ShowOsStatus($collector ?? $this->createMock(OsStatusCollector::class), $service); + $output = $this->createMock(IOutput::class); + $output->method('writeArrayInOutputFormat')->willReturnCallback(static function (iterable $items) use (&$written): void { + $written = is_array($items) ? $items : iterator_to_array($items); + }); + + return $command($output, $refresh); + } +} diff --git a/tests/lib/Jobs/UpdateOsStatusTest.php b/tests/lib/Jobs/UpdateOsStatusTest.php new file mode 100644 index 00000000..59b56d36 --- /dev/null +++ b/tests/lib/Jobs/UpdateOsStatusTest.php @@ -0,0 +1,38 @@ + ['cron', 1], + 'webcron' => ['webcron', 0], + 'ajax' => ['ajax', 0], + ]; + } + + #[DataProvider('dataModes')] + public function testRunsOnlyFromSystemCron(string $mode, int $runs): void { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueInt')->willReturn(3600); + $appConfig->method('getValueString')->with('core', 'backgroundjobs_mode', 'ajax')->willReturn($mode); + $collector = $this->createMock(OsStatusCollector::class); + $collector->expects($this->exactly($runs))->method('collectAndStore'); + + $job = new UpdateOsStatus($this->createMock(ITimeFactory::class), $collector, $appConfig); + (new \ReflectionMethod($job, 'run'))->invoke($job, null); + } +} diff --git a/tests/lib/OsStatus/AptTest.php b/tests/lib/OsStatus/AptTest.php new file mode 100644 index 00000000..afe4e1e6 --- /dev/null +++ b/tests/lib/OsStatus/AptTest.php @@ -0,0 +1,334 @@ +host = new FakeHost(); + $this->apt = new Apt($this->host, new SystemdProbe($this->host)); + } + + /** + * @param list $idLike + */ + private static function release(string $id, array $idLike = []): OsRelease { + return new OsRelease($id, $idLike, $id, $id, null, null); + } + + public function testPhasedUpdatesAreNotPending(): void { + $this->host->withCommand('apt-get', self::SIMULATE, FakeHost::fixture('ubuntu-26.04-apt-get-s-phased')); + + $this->assertSame([], $this->apt->getPendingUpdates()); + } + + public function testParsesSimulatedUpgrades(): void { + $updates = Apt::parseSimulation(implode("\n", [ + 'NOTE: This is only a simulation!', + 'Inst libssl3t64 [3.0.13-0ubuntu3.4] (3.0.13-0ubuntu3.5 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])', + 'Inst tzdata [2025a-0ubuntu0.24.04] (2025b-0ubuntu0.24.04 Ubuntu:24.04/noble-updates [all])', + 'Inst linux-image-6.8.0-48-generic (6.8.0-48.48 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])', + 'Inst libc6 [2.36-9+deb12u7] (2.36-9+deb12u9 Debian-Security:12/stable-security [arm64]) []', + 'Inst curl [7.88.1-10+deb12u7] (7.88.1-10+deb12u8 Debian:12.8/stable [amd64])', + 'Conf libssl3t64 (3.0.13-0ubuntu3.5 Ubuntu:24.04/noble-updates [amd64])', + 'Remv old-package [1.0]', + ]), self::release('ubuntu', ['debian'])); + + $this->assertCount(5, $updates); + [$ssl, $tz, $kernel, $libc, $curl] = $updates; + + $this->assertSame('libssl3t64', $ssl->name); + $this->assertSame('3.0.13-0ubuntu3.4', $ssl->installedVersion); + $this->assertSame('3.0.13-0ubuntu3.5', $ssl->candidateVersion); + $this->assertTrue($ssl->security); + $this->assertSame('Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security', $ssl->origin); + + $this->assertFalse($tz->security); + $this->assertNull($kernel->installedVersion, 'A newly installed package has no installed version'); + $this->assertTrue($kernel->security); + $this->assertTrue($libc->security); + $this->assertFalse($curl->security, 'A point release is not a security update'); + } + + public function testKeepsRealNamesVersionsAndArchives(): void { + $updates = Apt::parseSimulation(implode("\n", [ + 'Inst libc6:i386 [1:2.36-9+deb12u7] (1:2.36-9+deb12u9 Debian Backports:12/bookworm-backports [i386])', + 'Inst tzdata [2024a-2ubuntu1] (2026e-0ubuntu0.24.04.1 Ubuntu:24.04/noble-updates [all])', + 'Inst libssl3 [3.0.20-1~deb12u2] (3.0.22-1~deb12u1 Debian-Security:12/oldstable-security [arm64])', + 'Inst php8.3-cli (8.3.12-1+0~20240927.43+debian12~1.gbp_abc LP-PPA-ondrej-php:12/bookworm [amd64])', + ])); + + $this->assertSame(['libc6:i386', 'tzdata', 'libssl3', 'php8.3-cli'], array_map(static fn ($u) => $u->name, $updates)); + $this->assertSame('1:2.36-9+deb12u7', $updates[0]->installedVersion); + $this->assertSame('Debian Backports:12/bookworm-backports', $updates[0]->origin); + $this->assertSame('8.3.12-1+0~20240927.43+debian12~1.gbp_abc', $updates[3]->candidateVersion); + } + + public static function dataMalformedLines(): array { + return [ + 'upper-case name' => ['Inst LibSSL3 [1.0] (1.1 Debian:12/stable [amd64])'], + 'markup in the name' => ['Inst x [1.0] (1.1 Debian:12/stable [amd64])'], + 'markup in the installed version' => ['Inst libssl3 [1.0\n"); + + $this->assertSame('container-other', $this->detect()->container); + } +} diff --git a/tests/lib/OsStatus/FakeHost.php b/tests/lib/OsStatus/FakeHost.php new file mode 100644 index 00000000..489d7296 --- /dev/null +++ b/tests/lib/OsStatus/FakeHost.php @@ -0,0 +1,186 @@ + */ + public array $files = []; + /** @var array */ + public array $modified = []; + /** @var array */ + public array $changed = []; + /** @var array */ + public array $owners = []; + /** @var array */ + public array $links = []; + /** @var array> */ + public array $globs = []; + /** @var array keyed by "binary arg1 arg2" */ + public array $commands = []; + /** @var array */ + public array $env = []; + /** @var list */ + public array $binaries = []; + /** @var list binaries that may be installed, but open_basedir hides them */ + public array $hiddenBinaries = []; + /** @var list */ + public array $basedir = []; + /** @var list commands that were run, for assertions */ + public array $ran = []; + public bool $commandsAllowed = true; + public string $release = '6.8.0-45-generic'; + public string $machine = 'x86_64'; + public string $host = 'cloud.example.org'; + public string $phpSapi = 'fpm-fcgi'; + public string $family = 'Linux'; + + public static function fixture(string $name): string { + $path = __DIR__ . '/../../data/os/' . $name; + $content = file_get_contents($path); + if ($content === false) { + throw new \RuntimeException('Missing fixture ' . $name); + } + + return $content; + } + + public function withFile(string $path, string $content, ?int $modified = null): self { + $this->files[$path] = $content; + if ($modified !== null) { + $this->modified[$path] = $modified; + } + + return $this; + } + + /** + * @param list $arguments + */ + public function withCommand(string $binary, array $arguments, string $stdout, int $exitCode = 0, string $stderr = ''): self { + $this->commands[self::key($binary, $arguments)] = new CommandResult($exitCode, $stdout, $stderr); + if (!in_array($binary, $this->binaries, true)) { + $this->binaries[] = $binary; + } + + return $this; + } + + #[\Override] + public function readFile(string $path, int $maxBytes = 1048576): ?string { + return isset($this->files[$path]) ? substr($this->files[$path], 0, $maxBytes) : null; + } + + #[\Override] + public function readFileTail(string $path, int $maxBytes = 262144): ?string { + return isset($this->files[$path]) ? substr($this->files[$path], -$maxBytes) : null; + } + + #[\Override] + public function fileExists(string $path): bool { + return isset($this->files[$path]) || isset($this->modified[$path]) || isset($this->changed[$path]) || isset($this->owners[$path]); + } + + #[\Override] + public function modifiedAt(string $path): ?int { + return $this->modified[$path] ?? null; + } + + #[\Override] + public function changedAt(string $path): ?int { + return $this->changed[$path] ?? null; + } + + #[\Override] + public function readLink(string $path): ?string { + return $this->links[$path] ?? null; + } + + #[\Override] + public function glob(string $pattern): array { + return $this->globs[$pattern] ?? []; + } + + #[\Override] + public function run(string $binary, array $arguments = [], int $timeout = 10): ?CommandResult { + if (!$this->commandsAllowed) { + return null; + } + + $key = self::key($binary, $arguments); + $this->ran[] = $key; + + return $this->commands[$key] ?? null; + } + + #[\Override] + public function hasBinary(string $binary): bool { + return $this->commandsAllowed && in_array($binary, $this->binaries, true); + } + + #[\Override] + public function binaryState(string $binary): ?bool { + return match (true) { + !$this->commandsAllowed => false, + in_array($binary, $this->binaries, true) => true, + in_array($binary, $this->hiddenBinaries, true) => null, + default => false, + }; + } + + #[\Override] + public function canRunCommands(): bool { + return $this->commandsAllowed; + } + + #[\Override] + public function environment(string $name): ?string { + return $this->env[$name] ?? null; + } + + #[\Override] + public function uname(string $mode): string { + return match ($mode) { + 'r' => $this->release, + 'm' => $this->machine, + default => $this->host, + }; + } + + #[\Override] + public function osFamily(): string { + return $this->family; + } + + #[\Override] + public function openBasedir(): array { + return $this->basedir; + } + + #[\Override] + public function sapi(): string { + return $this->phpSapi; + } + + /** + * @param list $arguments + */ + private static function key(string $binary, array $arguments): string { + return trim($binary . ' ' . implode(' ', $arguments)); + } +} diff --git a/tests/lib/OsStatus/OsReleaseTest.php b/tests/lib/OsStatus/OsReleaseTest.php new file mode 100644 index 00000000..e2d68aea --- /dev/null +++ b/tests/lib/OsStatus/OsReleaseTest.php @@ -0,0 +1,62 @@ +assertNotNull($release); + $this->assertSame('ubuntu', $release->id); + $this->assertSame(['debian'], $release->idLike); + $this->assertSame('Ubuntu 26.04.1 LTS', $release->prettyName); + $this->assertSame('26.04', $release->versionId); + $this->assertTrue($release->isLike('debian')); + $this->assertFalse($release->isLike('rhel')); + } + + public function testUnquotesAndUnescapes(): void { + $release = OsRelease::parse(implode("\n", [ + 'ID=fedora', + 'ID_LIKE="rhel centos"', + 'NAME=\'Fedora Linux\'', + 'PRETTY_NAME="Fedora \"Server\" 43"', + 'VERSION_ID=43', + '# a comment', + 'garbage line', + ])); + + $this->assertNotNull($release); + $this->assertSame(['rhel', 'centos'], $release->idLike); + $this->assertSame('Fedora Linux', $release->name); + $this->assertSame('Fedora "Server" 43', $release->prettyName); + $this->assertNull($release->codename); + } + + public function testRejectsAMissingOrUnsafeId(): void { + $this->assertNull(OsRelease::parse('NAME=Something')); + $this->assertNull(OsRelease::parse('ID="../../etc"')); + } + + public function testBuildsAPrettyNameWhenMissing(): void { + $release = OsRelease::parse("ID=alpine\nNAME=\"Alpine Linux\"\nVERSION=3.20"); + + $this->assertSame('Alpine Linux 3.20', $release?->prettyName); + } + + public function testRoundTrip(): void { + $release = OsRelease::parse(FakeHost::fixture('ubuntu-26.04-os-release')); + + $this->assertEquals($release, OsRelease::fromArray($release?->toArray())); + $this->assertNull(OsRelease::fromArray(['name' => 'no id'])); + } +} diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php new file mode 100644 index 00000000..ac5f91a1 --- /dev/null +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -0,0 +1,283 @@ + */ + private array $config = []; + private ?string $stored = null; + private ?TimeSyncProbe $timeSync = null; + + protected function setUp(): void { + parent::setUp(); + $this->host = new FakeHost(); + $this->host->host = 'cloud.example.org'; + $this->host->withFile('/etc/os-release', FakeHost::fixture('ubuntu-24.04-os-release')); + } + + private function collector(int $now = self::NOW): OsStatusCollector { + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueString')->willReturnCallback(fn (string $key, string $default = ''): string => (string)($this->config[$key] ?? $default)); + $appConfig->method('getAppValueArray')->willReturnCallback(fn (string $key): array => $this->config[$key] ?? []); + $appConfig->method('setAppValueArray')->willReturnCallback(function (string $key, array $value): bool { + $this->config[$key] = $value; + + return true; + }); + + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn($now); + $os = $this->createMock(Os::class); + $os->method('getUptime')->willReturn($now - self::BOOT); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory->method('createDistributed')->willReturn($this->createMock(ICache::class)); + + $host = $this->host; + $systemd = new SystemdProbe($host); + + return new OsStatusCollector( + $host, + $appConfig, + $time, + $os, + new EnvironmentDetector($host), + new SupportTable(), + new SubscriptionProbe($host), + new PackageManagerFactory(new Apt($host, $systemd)), + new KernelProbe($host), + new CoreLibraryProbe($host), + $systemd, + $this->timeSync ?? new TimeSyncProbe($host), + new SecurityProbe($host), + new OsStatusStore($appConfig, $this->appData(), $cacheFactory), + $this->createMock(LoggerInterface::class), + ); + } + + /** + * App data that keeps the stored status in $this->stored. + */ + private function appData(): IAppData { + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('newFile')->willReturnCallback(function (string $name, mixed $content = null): ISimpleFile { + $this->stored = (string)$content; + + return $this->createMock(ISimpleFile::class); + }); + $appData = $this->createMock(IAppData::class); + $appData->method('getFolder')->willReturn($folder); + + return $appData; + } + + private function withPendingUpdates(): void { + $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], implode("\n", [ + 'Inst tzdata [2025a-0ubuntu0.24.04] (2025b-0ubuntu0.24.04 Ubuntu:24.04/noble-updates [all])', + 'Inst libssl3t64 [3.0.13-0ubuntu3.4] (3.0.13-0ubuntu3.5 Ubuntu:24.04/noble-security [amd64])', + ])); + } + + public function testRestrictedModeReadsNothing(): void { + $this->config['restricted_mode'] = 'yes'; + + $status = $this->collector()->collect(); + + $this->assertSame(OsStatus::STATE_RESTRICTED, $status->state); + $this->assertSame([], $this->host->ran); + } + + public function testOnlyLinuxIsInspected(): void { + $this->host->family = 'BSD'; + $this->host->release = '14.1-RELEASE'; + + $status = $this->collector()->collect(); + + $this->assertSame(OsStatus::STATE_UNSUPPORTED, $status->state); + $this->assertSame('14.1-RELEASE', $status->kernel); + } + + public function testAnUbuntuHost(): void { + $this->withPendingUpdates(); + $this->host->withCommand('systemd-detect-virt', ['--vm'], "kvm\n") + ->withCommand('apt-mark', ['showhold'], '') + ->withCommand('systemctl', ['list-units', '--failed', '--all', '--no-legend', '--plain', '--no-pager'], '') + ->withCommand('timedatectl', ['show', '--property=NTPSynchronized', '--value'], "yes\n") + ->withFile('/run/reboot-required', "*** System restart required ***\n", self::NOW - 2 * 86400) + ->withFile('/var/lib/ubuntu-advantage/status.json', FakeHost::fixture('ubuntu-pro-status-unattached.json')); + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = self::NOW - 3600; + + $status = $this->collector()->collect(); + + $this->assertSame(OsStatus::STATE_OK, $status->state); + $this->assertSame('Ubuntu 24.04.3 LTS', $status->release?->prettyName); + $this->assertSame('kvm', $status->environment->virtualization); + $this->assertSame('apt', $status->packageManager); + $this->assertSame(OsStatus::SOURCE_SYSTEM, $status->updatesSource); + $this->assertSame(['libssl3t64', 'tzdata'], array_map(static fn ($u) => $u->name, $status->updates ?? []), 'Security updates come first'); + $this->assertSame(self::NOW, $status->updates[0]->since); + $this->assertSame(RebootReason::KIND_DISTRIBUTION, $status->rebootReasons[0]->kind); + $this->assertSame(self::NOW - 2 * 86400, $status->rebootReasons[0]->since); + $this->assertSame(SupportStatus::STATE_SUPPORTED, $status->support->state(self::NOW)); + $this->assertFalse($status->support->extendedActive); + $this->assertSame(self::NOW - 3600, $status->listsRefreshedAt); + $this->assertSame([], $status->failedUnits); + $this->assertTrue($status->timeSynchronized); + $this->assertSame(self::BOOT, $status->bootTime); + } + + public function testFirstSeenSurvivesTheNextRun(): void { + $this->withPendingUpdates(); + $this->collector()->collect(); + $status = $this->collector(self::NOW + 86400)->collect(); + + $this->assertSame(self::NOW, $status->updates[0]->since); + } + + public function testASupersedingUpdateKeepsCounting(): void { + $this->withPendingUpdates(); + $this->collector()->collect(); + $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], + 'Inst libssl3t64 [3.0.13-0ubuntu3.4] (3.0.13-0ubuntu3.6 Ubuntu:24.04/noble-security [amd64])'); + $status = $this->collector(self::NOW + 86400)->collect(); + + $this->assertSame('3.0.13-0ubuntu3.6', $status->updates[0]->candidateVersion); + $this->assertSame(self::NOW, $status->updates[0]->since); + } + + public function testCoreLibrariesReplacedSinceBoot(): void { + $this->host->changed['/lib64/libc.so.6'] = self::BOOT + 86400; + + $this->assertSame(RebootReason::KIND_CORE_LIBRARY, $this->collector()->collect()->rebootReasons[0]->kind ?? null); + } + + public function testASystemContainerDoesNotCompareCoreLibrariesWithTheHostsBoot(): void { + $this->host->withFile('/run/systemd/container', "lxc\n"); + $this->host->changed['/lib64/libc.so.6'] = self::BOOT + 86400; + + $status = $this->collector()->collect(); + + $this->assertSame(Environment::KIND_SYSTEM_CONTAINER, $status->environment->kind); + $this->assertSame([], $status->rebootReasons); + } + + public function testAContainerIsNotAskedAboutPackagesOrReboots(): void { + $this->host->files['/.dockerenv'] = ''; + $this->withPendingUpdates(); + + $status = $this->collector()->collect(); + + $this->assertSame(Environment::KIND_APPLICATION_CONTAINER, $status->environment->kind); + $this->assertNull($status->packageManager); + $this->assertNull($status->updates); + $this->assertNull($status->rebootReasons); + $this->assertNotContains('apt-get -s -o Debug::NoLocking=1 dist-upgrade', $this->host->ran); + } + + public function testFailuresBecomeDiagnostics(): void { + $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], '', 100, "E: The package lists are damaged\n"); + + $status = $this->collector()->collect(); + + $this->assertNull($status->updates); + $this->assertContains('apt-get -s dist-upgrade failed: E: The package lists are damaged', $status->diagnostics); + } + + public function testMissingPackageListsLeaveTheUpdatesUnknown(): void { + $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], ''); + + $status = $this->collector()->collect(); + + $this->assertNull($status->updates); + $this->assertNull($status->updatesSource); + $this->assertContains('The package lists could not be found, so pending updates are unknown until the lists are refreshed.', $status->diagnostics); + } + + public function testNoUpdatesWithRefreshedPackageLists(): void { + $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], ''); + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = self::NOW - 3600; + + $this->assertSame([], $this->collector()->collect()->updates); + } + + public function testAptIsRunWhenOpenBasedirHidesIt(): void { + $this->withPendingUpdates(); + $this->host->binaries = []; + $this->host->hiddenBinaries = ['apt-get']; + $this->host->basedir = ['/var/www']; + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = self::NOW - 3600; + + $status = $this->collector()->collect(); + + $this->assertSame('apt', $status->packageManager); + $this->assertCount(2, $status->updates ?? []); + } + + public function testAThrowingProbeCostsOnlyItsOwnFinding(): void { + $this->withPendingUpdates(); + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = self::NOW - 3600; + $this->timeSync = $this->createMock(TimeSyncProbe::class); + $this->timeSync->method('probe')->willThrowException(new \RuntimeException('broken')); + + $status = $this->collector()->collect(); + + $this->assertCount(2, $status->updates ?? []); + $this->assertNull($status->timeSynchronized); + $this->assertContains('The time synchronisation could not be determined; see the Nextcloud log.', $status->diagnostics); + } + + public function testWithoutProcesses(): void { + $this->host->commandsAllowed = false; + + $status = $this->collector()->collect(); + + $this->assertSame(OsStatus::STATE_OK, $status->state); + $this->assertNull($status->updates); + $this->assertStringContainsString('proc_open', $status->diagnostics[0]); + } + + public function testCollectAndStorePersists(): void { + $this->withPendingUpdates(); + + $this->collector()->collectAndStore(); + + $this->assertSame(1, json_decode((string)$this->stored, true)['schema'] ?? null); + } +} diff --git a/tests/lib/OsStatus/OsStatusServiceTest.php b/tests/lib/OsStatus/OsStatusServiceTest.php new file mode 100644 index 00000000..f894cd7c --- /dev/null +++ b/tests/lib/OsStatus/OsStatusServiceTest.php @@ -0,0 +1,80 @@ +store = $this->createMock(OsStatusStore::class); + $this->host = new FakeHost(); + $config = $this->createMock(IAppConfig::class); + $config->method('getAppValueInt')->willReturnCallback(static fn (string $key): int => match ($key) { + 'os_security_warning_days' => 3, 'os_security_error_days' => 14, 'os_reboot_warning_days' => 7, + 'os_reboot_error_days' => 30, 'os_restart_warning_days' => 3, 'os_eol_warning_days' => 90, + 'os_lists_warning_days' => 7, 'os_lists_error_days' => 30, default => 0, + }); + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn(self::NOW); + + $serverConfig = $this->createMock(IServerConfig::class); + $serverConfig->method('getValueString')->willReturnCallback(fn (string $app, string $key, string $default = ''): string => $app === 'core' && $key === 'backgroundjobs_mode' ? $this->mode : $default); + $serverConfig->method('getValueInt')->willReturnCallback(static fn (string $app, string $key): int => $app === 'serverinfo' && $key === 'job_interval_os_status' ? 86400 : 0); + + $this->service = new OsStatusService($this->store, new OsVerdict(), new PhpProcessProbe($this->host), $config, $time, $this->host, $serverConfig); + } + + public function testOutdatedWhenPackagesChangedAfterTheCheck(): void { + $status = new OsStatus(OsStatus::STATE_OK, self::NOW - 600, packageDatabase: '/var/lib/dpkg/status'); + + $this->host->modified['/var/lib/dpkg/status'] = self::NOW - 900; + $this->assertFalse($this->service->isOutdated($status)); + + $this->host->modified['/var/lib/dpkg/status'] = self::NOW - 300; + $this->assertTrue($this->service->isOutdated($status)); + } + + public function testSamplingOnlyInWebRequests(): void { + $this->host->phpSapi = 'cli'; + $this->store->expects($this->never())->method('savePhpSample'); + + $this->service->samplePhpProcess(); + } + + public function testFreshnessFollowsTheCronModeAndTheJobInterval(): void { + $status = new OsStatus(OsStatus::STATE_OK, self::NOW - 2 * 86400); + + $fresh = $this->service->evaluate($status)?->get(Concern::FRESHNESS); + $this->assertSame(Concern::LEVEL_OK, $fresh?->level, 'Two days are within three daily runs'); + $this->assertTrue($fresh->facts['systemCron']); + + $this->mode = 'webcron'; + $stale = $this->service->evaluate($status)?->get(Concern::FRESHNESS); + $this->assertSame(Concern::LEVEL_WARNING, $stale?->level); + $this->assertFalse($stale->facts['systemCron']); + } +} diff --git a/tests/lib/OsStatus/OsStatusStoreTest.php b/tests/lib/OsStatus/OsStatusStoreTest.php new file mode 100644 index 00000000..23558cf5 --- /dev/null +++ b/tests/lib/OsStatus/OsStatusStoreTest.php @@ -0,0 +1,243 @@ + */ + private array $values = []; + private int $writes = 0; + /** @var array */ + private array $folders = []; + /** @var array folder/file => content */ + private array $files = []; + /** @var array */ + private array $cached = []; + private int $cacheReads = 0; + private int $fileReads = 0; + private IAppConfig&MockObject $config; + private IAppData&MockObject $appData; + private ICacheFactory&MockObject $cacheFactory; + private OsStatusStore $store; + + protected function setUp(): void { + parent::setUp(); + $this->config = $config = $this->createMock(IAppConfig::class); + $config->method('getAppValueArray')->willReturnCallback(fn (string $key): array => (array)($this->values[$key] ?? [])); + $config->method('setAppValueArray')->willReturnCallback(function (string $key, array $value): bool { + $this->values[$key] = $value; + $this->writes++; + + return true; + }); + $config->method('getAppValueInt')->willReturnCallback(fn (string $key): int => $this->values[$key] ?? 0); + $config->method('setAppValueInt')->willReturnCallback(function (string $key, int $value): bool { + $this->values[$key] = $value; + $this->writes++; + + return true; + }); + + $this->appData = $this->createMock(IAppData::class); + $this->appData->method('getFolder')->willReturnCallback(function (string $name): ISimpleFolder { + if (!isset($this->folders[$name])) { + throw new NotFoundException(); + } + + return $this->folder($name); + }); + $this->appData->method('newFolder')->willReturnCallback(function (string $name): ISimpleFolder { + $this->folders[$name] = true; + + return $this->folder($name); + }); + + $cache = $this->createMock(ICache::class); + $cache->method('get')->willReturnCallback(function (string $key): mixed { + $this->cacheReads++; + + return $this->cached[$key] ?? null; + }); + $cache->method('set')->willReturnCallback(function (string $key, mixed $value): bool { + $this->cached[$key] = $value; + + return true; + }); + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cacheFactory->method('createDistributed')->willReturn($cache); + + $this->store = $this->newStore(); + } + + /** + * A store as the next request sees it, sharing app data and the cache. + */ + private function newStore(): OsStatusStore { + return new OsStatusStore($this->config, $this->appData, $this->cacheFactory); + } + + private function folder(string $name): ISimpleFolder { + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('fileExists')->willReturnCallback(fn (string $file): bool => isset($this->files[$name . '/' . $file])); + $folder->method('getFile')->willReturnCallback(function (string $file) use ($name): ISimpleFile { + if (!isset($this->files[$name . '/' . $file])) { + throw new NotFoundException(); + } + + return $this->file($name . '/' . $file); + }); + $folder->method('newFile')->willReturnCallback(function (string $file, mixed $content = null) use ($name): ISimpleFile { + $this->files[$name . '/' . $file] = (string)$content; + + return $this->file($name . '/' . $file); + }); + + return $folder; + } + + private function file(string $path): ISimpleFile { + $file = $this->createMock(ISimpleFile::class); + $file->method('getContent')->willReturnCallback(function () use ($path): string { + $this->fileReads++; + + return $this->files[$path]; + }); + $file->method('putContent')->willReturnCallback(function (mixed $data) use ($path): void { + $this->files[$path] = (string)$data; + }); + + return $file; + } + + private function osStatus(int $collectedAt = 1792411200): OsStatus { + return new OsStatus(OsStatus::STATE_OK, $collectedAt, new OsRelease('debian', [], 'Debian GNU/Linux', 'Debian GNU/Linux 12 (bookworm)', '12', 'bookworm'), '6.1.0-26-amd64'); + } + + public function testTheStatusIsAFileInAppData(): void { + $this->store->save($this->osStatus()); + + $this->assertSame(1, json_decode($this->files['os-status/status.json'] ?? '', true)['schema'] ?? null); + $this->assertArrayNotHasKey('cached_os_status', $this->values, 'Not in app config'); + + $this->cached = []; + $loaded = $this->newStore()->load(); + $this->assertSame('Debian GNU/Linux 12 (bookworm)', $loaded?->release?->prettyName); + $this->assertSame('6.1.0-26-amd64', $loaded->kernel); + } + + public function testReadsGoThroughTheCache(): void { + $this->store->save($this->osStatus()); + + $this->assertSame(1792411200, $this->newStore()->load()?->collectedAt); + $this->assertSame(0, $this->fileReads, 'Saving filled the cache'); + + $this->cached = []; + $this->newStore()->load(); + $this->assertSame(1, $this->fileReads); + $this->newStore()->load(); + $this->assertSame(1, $this->fileReads, 'The file read filled the cache'); + } + + public function testASaveReplacesTheCachedStatus(): void { + $this->store->save($this->osStatus(100)); + $this->newStore()->save($this->osStatus(200)); + + $this->assertSame(200, $this->newStore()->load()?->collectedAt); + $this->assertSame(100, $this->store->load()?->collectedAt, 'A store that saved knows what it saved'); + } + + public function testOneReadPerRequest(): void { + $this->store->load(); + $this->store->load(); + + $this->assertSame(1, $this->cacheReads); + } + + public function testNothingCollectedYet(): void { + $this->assertNull($this->store->load()); + $this->assertSame('', $this->cached['status'] ?? null, 'Remembered, so app data is not asked on every request'); + $this->assertNull($this->newStore()->load()); + } + + public function testADamagedStatusReadsAsNone(): void { + $this->cached['status'] = '{"schema":'; + + $this->assertNull($this->store->load()); + } + + public function testSavingRemovesTheStatusFromAppConfig(): void { + $this->config->method('hasAppKey')->with('cached_os_status', null)->willReturn(true); + $this->config->expects($this->once())->method('deleteAppValue')->with('cached_os_status'); + + $this->store->save($this->osStatus()); + } + + public function testFirstSeenIsKeptAcrossRuns(): void { + $this->assertSame(['update:a:1' => 100], $this->store->firstSeen(['update:a:1' => null], 100, ['update'])); + $this->assertSame(['update:a:1' => 100, 'update:b:1' => 200], $this->store->firstSeen(['update:a:1' => null, 'update:b:1' => null], 200, ['update'])); + } + + public function testAResolvedFindingStartsOverWhenItReturns(): void { + $this->store->firstSeen(['update:a:1' => null], 100, ['update']); + $this->store->firstSeen([], 200, ['update']); + + $this->assertSame(['update:a:1' => 300], $this->store->firstSeen(['update:a:1' => null], 300, ['update'])); + } + + public function testOtherScopesAreLeftAlone(): void { + $this->store->firstSeen(['reboot:kernel:6.8' => 50], 100, ['reboot']); + $this->store->firstSeen([], 200, ['update']); + + $this->assertSame(['reboot:kernel:6.8' => 50], $this->store->firstSeen(['reboot:kernel:6.8' => null], 300, ['reboot'])); + } + + public function testAKnownStartWins(): void { + $this->assertSame(['reboot:distribution:' => 40], $this->store->firstSeen(['reboot:distribution:' => 40], 100, ['reboot'])); + } + + public function testPhpSamplesAreThrottledButChangesAreNot(): void { + $this->store->savePhpSample(new PhpProcessSample(1000, 'fpm-fcgi', [], 'php-fpm', null)); + $writes = $this->writes; + + $this->store->savePhpSample(new PhpProcessSample(1060, 'fpm-fcgi', [], 'php-fpm', null)); + $this->assertSame($writes, $this->writes, 'The same finding a minute later is not written again'); + + $this->store->savePhpSample(new PhpProcessSample(1120, 'fpm-fcgi', ['libssl.so.3'], 'php-fpm', null)); + $this->assertSame(['libssl.so.3'], $this->store->loadPhpSample()?->outdatedLibraries); + $this->assertSame(1120, $this->store->phpOutdatedSince()); + + $this->store->savePhpSample(new PhpProcessSample(2000, 'fpm-fcgi', [], 'php-fpm', null)); + $this->assertNull($this->store->phpOutdatedSince()); + } + + public function testThePhpSampleLeavesTheCollectorsFirstSeenTimesAlone(): void { + $this->store->firstSeen(['update:a:1' => null], 100, ['update']); + $stored = $this->values[ConfigLexicon::CACHED_OS_FIRST_SEEN]; + + $this->store->savePhpSample(new PhpProcessSample(1120, 'fpm-fcgi', ['libssl.so.3'], 'php-fpm', null)); + $this->store->savePhpSample(new PhpProcessSample(2000, 'fpm-fcgi', ['libssl.so.3'], 'php-fpm', null)); + + $this->assertSame($stored, $this->values[ConfigLexicon::CACHED_OS_FIRST_SEEN]); + $this->assertSame(1120, $this->store->phpOutdatedSince(), 'Still outdated, so still counting from the first sighting'); + } +} diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php new file mode 100644 index 00000000..d508beac --- /dev/null +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -0,0 +1,252 @@ + OsStatus::STATE_OK, + 'collectedAt' => self::NOW, + 'environment' => new Environment(Environment::KIND_HOST, null, 'kvm'), + 'support' => new SupportStatus(false, '2029-05-31', '2034-04-25', 'ubuntu-pro', false), + 'packageManager' => 'apt', + 'updates' => [], + 'securityClassified' => true, + 'listsRefreshedAt' => self::NOW - 3600, + 'automaticUpdates' => new AutomaticUpdates(AutomaticUpdates::STATE_ENABLED, 'unattended-upgrades', AutomaticUpdates::SCOPE_SECURITY), + 'rebootReasons' => [], + 'livePatches' => [], + 'failedUnits' => [], + 'timeSynchronized' => true, + ]; + + return new OsStatus(...array_merge($defaults, $overrides)); + } + + private function evaluate(OsStatus $status, ?PhpProcessSample $php = null, ?int $phpSince = null, ?Thresholds $thresholds = null): Verdict { + return (new OsVerdict())->evaluate($status, $php, $phpSince, $thresholds ?? new Thresholds(), self::NOW); + } + + private function securityUpdate(int $daysAgo, string $name = 'openssl'): PendingUpdate { + return new PendingUpdate($name, '1', '2', true, since: self::NOW - $daysAgo * self::DAY); + } + + public function testAllGood(): void { + $verdict = $this->evaluate($this->osStatus(), new PhpProcessSample(self::NOW, 'fpm-fcgi', [], 'php8.3-fpm', null)); + + $this->assertSame(Concern::LEVEL_OK, $verdict->level()); + } + + public static function dataSecurityUpdates(): array { + return [ + 'fresh' => [0, Concern::LEVEL_INFO], + 'just below the warning' => [2, Concern::LEVEL_INFO], + 'at the warning' => [3, Concern::LEVEL_WARNING], + 'just below the error' => [13, Concern::LEVEL_WARNING], + 'at the error' => [14, Concern::LEVEL_ERROR], + ]; + } + + #[DataProvider('dataSecurityUpdates')] + public function testSecurityUpdatesEscalateWithAge(int $days, string $level): void { + $verdict = $this->evaluate($this->osStatus(['updates' => [$this->securityUpdate(0, 'curl'), $this->securityUpdate($days)]])); + + $concern = $verdict->get(Concern::UPDATES); + $this->assertSame($level, $concern?->level); + $this->assertSame($days, $concern->facts['days']); + $this->assertSame(2, $concern->facts['security']); + } + + public function testOtherUpdatesAloneAreFine(): void { + $verdict = $this->evaluate($this->osStatus(['updates' => [new PendingUpdate('tzdata', '1', '2', false, since: self::NOW - 90 * self::DAY)]])); + + $this->assertSame(Concern::LEVEL_OK, $verdict->get(Concern::UPDATES)?->level); + $this->assertSame(1, $verdict->get(Concern::UPDATES)?->facts['other']); + } + + public function testUnclassifiedUpdatesCountAsRelevant(): void { + $verdict = $this->evaluate($this->osStatus([ + 'securityClassified' => false, + 'packageManager' => 'pacman', + 'updates' => [new PendingUpdate('linux', '1', '2', false, since: self::NOW - 20 * self::DAY)], + ])); + + $this->assertSame(Concern::LEVEL_ERROR, $verdict->get(Concern::UPDATES)?->level); + } + + public function testUpdatesFromOldPackageListsAreMarked(): void { + $fresh = $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 6 * self::DAY])); + $stale = $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 7 * self::DAY])); + $pacman = $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW - 90 * self::DAY])); + + $this->assertFalse($fresh->get(Concern::UPDATES)?->facts['listsStale']); + $this->assertTrue($stale->get(Concern::UPDATES)?->facts['listsStale']); + $this->assertFalse($pacman->get(Concern::UPDATES)?->facts['listsStale']); + } + + public function testUnknownUpdatesLeaveNoAllClear(): void { + $verdict = $this->evaluate($this->osStatus(['updates' => null])); + + $this->assertSame(Concern::LEVEL_UNKNOWN, $verdict->get(Concern::UPDATES)?->level); + $this->assertSame(Concern::LEVEL_UNKNOWN, $verdict->level()); + } + + public function testUnknownUpdatesDoNotHideAWarning(): void { + $verdict = $this->evaluate($this->osStatus(['updates' => null, 'timeSynchronized' => false])); + + $this->assertSame(Concern::LEVEL_WARNING, $verdict->level()); + } + + public function testRebootEscalatesAndLivePatchingSoftensKernelReboots(): void { + $kernel = new RebootReason(RebootReason::KIND_KERNEL, '6.8.0-48-generic', [], self::NOW - 10 * self::DAY); + + $this->assertSame(Concern::LEVEL_WARNING, $this->evaluate($this->osStatus(['rebootReasons' => [$kernel]]))->get(Concern::REBOOT)?->level); + + $patched = $this->evaluate($this->osStatus(['rebootReasons' => [$kernel], 'livePatches' => ['lkp_6_8_0_45']]))->get(Concern::REBOOT); + $this->assertSame(Concern::LEVEL_INFO, $patched?->level); + $this->assertTrue($patched->facts['livePatched']); + + $library = new RebootReason(RebootReason::KIND_CORE_LIBRARY, null, ['glibc'], self::NOW - 40 * self::DAY); + $mixed = $this->evaluate($this->osStatus(['rebootReasons' => [$kernel, $library], 'livePatches' => ['lkp_6_8_0_45']]))->get(Concern::REBOOT); + $this->assertSame(Concern::LEVEL_ERROR, $mixed?->level, 'A live patch does not cover the C library'); + } + + public function testRestartOfPhp(): void { + $php = new PhpProcessSample(self::NOW, 'fpm-fcgi', ['libssl.so.3'], 'php8.3-fpm', null); + + $fresh = $this->evaluate($this->osStatus(), $php, self::NOW - 3600)->get(Concern::RESTART); + $this->assertSame(Concern::LEVEL_INFO, $fresh?->level); + $this->assertTrue($fresh->facts['php']); + + $old = $this->evaluate($this->osStatus(), $php, self::NOW - 4 * self::DAY)->get(Concern::RESTART); + $this->assertSame(Concern::LEVEL_WARNING, $old?->level); + } + + public function testRestartIsUnknownWithoutAnyInformation(): void { + $this->assertSame(Concern::LEVEL_UNKNOWN, $this->evaluate($this->osStatus())->get(Concern::RESTART)?->level); + } + + public static function dataSupport(): array { + return [ + 'years left' => [new SupportStatus(false, '2031-05-29'), Concern::LEVEL_OK], + 'ends in 30 days' => [new SupportStatus(false, '2026-11-18'), Concern::LEVEL_WARNING], + 'ended, programme available' => [new SupportStatus(false, '2026-06-01', '2031-06-01', 'ubuntu-pro', false), Concern::LEVEL_WARNING], + 'ended, programme active' => [new SupportStatus(false, '2026-06-01', '2031-06-01', 'ubuntu-pro', true), Concern::LEVEL_OK], + 'ended' => [new SupportStatus(false, '2024-06-30'), Concern::LEVEL_ERROR], + 'rolling' => [new SupportStatus(true, null), Concern::LEVEL_OK], + 'unknown' => [new SupportStatus(false, null), Concern::LEVEL_UNKNOWN], + ]; + } + + #[DataProvider('dataSupport')] + public function testSupport(SupportStatus $support, string $level): void { + $this->assertSame($level, $this->evaluate($this->osStatus(['support' => $support]))->get(Concern::SUPPORT)?->level); + } + + public function testOldPackageLists(): void { + $this->assertSame(Concern::LEVEL_OK, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 6 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_WARNING, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 7 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_ERROR, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 31 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_OK, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => null]))->get(Concern::PACKAGE_LISTS)?->level); + } + + public function testHealth(): void { + $verdict = $this->evaluate($this->osStatus([ + 'failedUnits' => ['logrotate.service', 'php8.3-fpm.service'], + 'timeSynchronized' => false, + 'automaticUpdates' => new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED), + ])); + + $this->assertSame(Concern::LEVEL_WARNING, $verdict->get(Concern::FAILED_UNITS)?->level); + $this->assertSame(['php8.3-fpm.service'], $verdict->get(Concern::FAILED_UNITS)?->facts['stack']); + $this->assertSame(Concern::LEVEL_WARNING, $verdict->get(Concern::TIME_SYNC)?->level); + $this->assertSame(Concern::LEVEL_INFO, $verdict->get(Concern::AUTOMATIC_UPDATES)?->level); + $this->assertSame(Concern::LEVEL_WARNING, $verdict->level()); + } + + public function testApplicationContainersSkipWhatIsNotTheirs(): void { + $verdict = $this->evaluate($this->osStatus([ + 'environment' => new Environment(Environment::KIND_APPLICATION_CONTAINER, 'docker'), + 'updates' => null, + 'rebootReasons' => null, + ]), new PhpProcessSample(self::NOW, 'apache2handler', [], 'apache2', null)); + + $ids = array_map(static fn (Concern $c): string => $c->id, $verdict->concerns); + $this->assertNotContains(Concern::UPDATES, $ids); + $this->assertNotContains(Concern::REBOOT, $ids); + $this->assertNotContains(Concern::PACKAGE_LISTS, $ids); + $this->assertContains(Concern::RESTART, $ids); + $this->assertSame(Concern::LEVEL_OK, $verdict->level()); + } + + public function testRestrictedOrUnsupportedHaveNoVerdict(): void { + $this->assertSame(Concern::LEVEL_UNKNOWN, $this->evaluate(OsStatus::restricted(self::NOW))->level()); + $this->assertSame([], $this->evaluate(OsStatus::unsupported(self::NOW, '14.1-RELEASE', 'amd64'))->concerns); + } + + public function testCustomThresholds(): void { + $thresholds = new Thresholds(securityWarning: 0, securityError: 1); + + $this->assertSame(Concern::LEVEL_WARNING, $this->evaluate($this->osStatus(['updates' => [$this->securityUpdate(0)]]), thresholds: $thresholds)->get(Concern::UPDATES)?->level); + } + + public static function dataFreshness(): array { + return [ + 'just collected' => [0, true, 3600, Concern::LEVEL_OK], + 'six hours at an hourly interval' => [6 * 3600, true, 3600, Concern::LEVEL_OK], + 'past six hours at an hourly interval' => [6 * 3600 + 1, true, 3600, Concern::LEVEL_WARNING], + 'three daily intervals' => [3 * 86400, true, 86400, Concern::LEVEL_OK], + 'past three daily intervals' => [3 * 86400 + 1, true, 86400, Concern::LEVEL_WARNING], + 'fresh but not from system cron' => [0, false, 3600, Concern::LEVEL_WARNING], + ]; + } + + #[DataProvider('dataFreshness')] + public function testAnOldStatusIsStale(int $age, bool $systemCron, int $interval, string $level): void { + $verdict = (new OsVerdict())->evaluate($this->osStatus(['collectedAt' => self::NOW - $age]), null, null, new Thresholds(), self::NOW, $systemCron, $interval); + + $concern = $verdict->get(Concern::FRESHNESS); + $this->assertSame($level, $concern?->level); + $this->assertSame(['collectedAt' => self::NOW - $age, 'ageSeconds' => $age, 'systemCron' => $systemCron], $concern->facts); + } + + public function testAStaleStatusIsAtLeastAWarning(): void { + $verdict = (new OsVerdict())->evaluate($this->osStatus(['collectedAt' => self::NOW - 30 * self::DAY, 'updates' => null]), null, null, new Thresholds(), self::NOW); + + $this->assertSame(Concern::LEVEL_UNKNOWN, $verdict->get(Concern::UPDATES)?->level); + $this->assertSame(Concern::LEVEL_WARNING, $verdict->level(), 'Old and unknown is not "unknown" but a warning'); + + $green = (new OsVerdict())->evaluate($this->osStatus(['collectedAt' => self::NOW - 30 * self::DAY]), new PhpProcessSample(self::NOW, 'fpm-fcgi', [], 'php8.3-fpm', null), null, new Thresholds(), self::NOW); + $this->assertSame(Concern::LEVEL_WARNING, $green->level(), 'An all-clear from weeks ago is no all-clear'); + } + + public function testNothingCollectedIsUnknownFreshness(): void { + $concern = (new OsVerdict())->freshness(null, self::NOW, true, 3600); + + $this->assertSame(Concern::LEVEL_UNKNOWN, $concern->level); + $this->assertSame(['collectedAt' => null, 'ageSeconds' => null, 'systemCron' => true], $concern->facts); + } +} diff --git a/tests/lib/OsStatus/ProbesTest.php b/tests/lib/OsStatus/ProbesTest.php new file mode 100644 index 00000000..8f70f2a3 --- /dev/null +++ b/tests/lib/OsStatus/ProbesTest.php @@ -0,0 +1,285 @@ +host = new FakeHost(); + } + + /** + * @param array $kernels release => install time + */ + private function kernels(array $kernels): void { + $files = []; + foreach ($kernels as $release => $time) { + $file = "/lib/modules/$release/modules.dep"; + $files[] = $file; + $this->host->changed[$file] = $time; + } + $this->host->globs['/lib/modules/*/modules.dep'] = $files; + } + + public function testNewerKernelOfTheSameFlavour(): void { + $this->host->release = '6.8.0-45-generic'; + $this->kernels(['6.8.0-45-generic' => 100, '6.8.0-47-generic' => 200, '6.8.0-50-lowlatency' => 300, '6.8.0-9-generic' => 50]); + + $reasons = (new KernelProbe($this->host))->getRebootReasons(); + + $this->assertCount(1, $reasons); + $this->assertSame(RebootReason::KIND_KERNEL, $reasons[0]->kind); + $this->assertSame('6.8.0-47-generic', $reasons[0]->detail); + $this->assertSame(200, $reasons[0]->since); + } + + public function testRhelKernelsAcrossMinorReleases(): void { + $this->host->release = '5.14.0-427.13.1.el9_4.x86_64'; + $this->kernels(['5.14.0-427.13.1.el9_4.x86_64' => 100, '5.14.0-503.15.1.el9_5.x86_64' => 200]); + + $this->assertSame('5.14.0-503.15.1.el9_5.x86_64', (new KernelProbe($this->host))->getRebootReasons()[0]->detail); + } + + public function testRunningTheNewestKernel(): void { + $this->host->release = '6.8.0-47-generic'; + $this->kernels(['6.8.0-45-generic' => 100, '6.8.0-47-generic' => 200]); + + $this->assertSame([], (new KernelProbe($this->host))->getRebootReasons()); + } + + public function testRunningKernelWasRemoved(): void { + $this->host->release = '6.11.2-arch1-1'; + $this->kernels(['6.11.3-arch1-1' => 300]); + + $reasons = (new KernelProbe($this->host))->getRebootReasons(); + + $this->assertSame(RebootReason::KIND_KERNEL_REMOVED, $reasons[0]->kind); + $this->assertSame('6.11.3-arch1-1', $reasons[0]->detail); + } + + public function testAProviderKernelWithoutModulesIsNoReason(): void { + $this->host->release = '6.8.9-x86_64-linode164'; + $this->kernels(['6.1.0-26-amd64' => 100, '6.1.0-25-amd64' => 50]); + + $this->assertSame([], (new KernelProbe($this->host))->getRebootReasons(1000)); + $this->assertSame([], (new KernelProbe($this->host))->getRebootReasons(), 'Without a boot time nothing says it changed'); + } + + public function testAKernelOfAnotherBuildInstalledAfterBoot(): void { + $this->host->release = '6.8.9-x86_64-linode164'; + $this->kernels(['6.1.0-26-amd64' => 2000, '6.1.0-25-amd64' => 50]); + + $reasons = (new KernelProbe($this->host))->getRebootReasons(1000); + + $this->assertSame(RebootReason::KIND_KERNEL_REMOVED, $reasons[0]->kind ?? null); + $this->assertSame('6.1.0-26-amd64', $reasons[0]->detail); + $this->assertSame(2000, $reasons[0]->since); + } + + public function testARemovedKernelOfTheSameBuildNeedsNoBootTime(): void { + $this->host->release = '6.11.2-arch1-1'; + $this->kernels(['6.11.3-arch1-1' => 300]); + + $this->assertSame(RebootReason::KIND_KERNEL_REMOVED, (new KernelProbe($this->host))->getRebootReasons(1000)[0]->kind ?? null, 'The same build is installed'); + } + + public function testNoModulesToCompareWith(): void { + $this->assertSame([], (new KernelProbe($this->host))->getRebootReasons()); + } + + public function testCoreLibrariesReplacedSinceBoot(): void { + $this->host->globs['/lib/*-linux-gnu*/libc.so.6'] = ['/lib/x86_64-linux-gnu/libc.so.6']; + $this->host->changed['/lib/x86_64-linux-gnu/libc.so.6'] = 5000; + $this->host->changed['/usr/lib/systemd/systemd'] = 6000; + $this->host->changed['/usr/bin/dbus-daemon'] = 7000; + + $reasons = (new CoreLibraryProbe($this->host))->getRebootReasons(1000); + + $this->assertSame(['glibc', 'dbus'], $reasons[0]->packages); + $this->assertSame(5000, $reasons[0]->since); + } + + public function testAChangeWithoutAnUpgradeIsNoReason(): void { + $this->host->globs['/lib/*-linux-gnu*/libc.so.6'] = ['/lib/x86_64-linux-gnu/libc.so.6']; + $this->host->globs['/var/lib/dpkg/info/libc6:*.list'] = ['/var/lib/dpkg/info/libc6:amd64.list']; + $this->host->changed['/lib/x86_64-linux-gnu/libc.so.6'] = 5000; + $this->host->modified['/var/lib/dpkg/info/libc6:amd64.list'] = 500; + + $this->assertSame([], (new CoreLibraryProbe($this->host))->getRebootReasons(1000, '/var/lib/dpkg/status'), 'A chmod or restore after boot'); + + $this->host->modified['/var/lib/dpkg/info/libc6:amd64.list'] = 4990; + $this->assertSame(['glibc'], (new CoreLibraryProbe($this->host))->getRebootReasons(1000, '/var/lib/dpkg/status')[0]->packages ?? null); + } + + public function testDbusFromAnyOfItsDebianPackages(): void { + $this->host->changed['/usr/bin/dbus-daemon'] = 5000; + $this->host->modified['/var/lib/dpkg/info/dbus.list'] = 500; + $this->host->modified['/var/lib/dpkg/info/dbus-daemon.list'] = 4990; + + $this->assertSame(['dbus'], (new CoreLibraryProbe($this->host))->getRebootReasons(1000)[0]->packages ?? null); + } + + public function testThePackageDatabaseCorroboratesWithoutDpkg(): void { + $this->host->globs['/lib/ld-musl-*.so.1'] = ['/lib/ld-musl-x86_64.so.1']; + $this->host->changed['/lib/ld-musl-x86_64.so.1'] = 5000; + $this->host->modified['/lib/apk/db/installed'] = 900; + + $this->assertSame([], (new CoreLibraryProbe($this->host))->getRebootReasons(1000, '/lib/apk/db/installed')); + + $this->host->modified['/lib/apk/db/installed'] = 5000; + $this->assertSame(['glibc'], (new CoreLibraryProbe($this->host))->getRebootReasons(1000, '/lib/apk/db/installed')[0]->packages ?? null); + } + + public function testCoreLibrariesWithinTheBootSlack(): void { + $this->host->changed['/usr/bin/dbus-daemon'] = 1030; + + $this->assertSame([], (new CoreLibraryProbe($this->host))->getRebootReasons(1000)); + } + + public function testPhpWithCurrentLibraries(): void { + $this->host->withFile('/proc/self/maps', FakeHost::fixture('apache2-maps')); + $this->host->phpSapi = 'apache2handler'; + + $sample = (new PhpProcessProbe($this->host))->sample(OsRelease::parse(FakeHost::fixture('ubuntu-26.04-os-release')), 123); + + $this->assertNotNull($sample); + $this->assertFalse($sample->isOutdated()); + $this->assertSame('apache2', $sample->service); + } + + public function testPhpWithReplacedLibraries(): void { + $this->host->withFile('/proc/self/maps', FakeHost::fixture('apache2-maps') . implode("\n", [ + '7f1c2a000000-7f1c2a1c0000 r-xp 00000000 fc:00 1048602 /usr/lib/x86_64-linux-gnu/libssl.so.3 (deleted)', + '7f1c2a1c0000-7f1c2a1c2000 r--p 001c0000 fc:00 1048602 /usr/lib/x86_64-linux-gnu/libssl.so.3 (deleted)', + '7f1c2b000000-7f1c2b010000 r-xp 00000000 fc:00 1048700 /usr/lib/php/20240924/opcache.so (deleted)', + '7f1c2c000000-7f1c2c001000 rw-s 00000000 00:01 4096 /memfd:opcache_jit (deleted)', + '7f1c2d000000-7f1c2d001000 rw-s 00000000 00:05 1234 /dev/zero (deleted)', + '7f1c2e000000-7f1c2e001000 r--p 00000000 fc:00 99 /tmp/somefile.so (deleted)', + ]) . "\n"); + $this->host->links['/proc/self/exe'] = '/usr/sbin/php-fpm8.3 (deleted)'; + + $release = new OsRelease('debian', [], 'Debian', 'Debian 12', '12', 'bookworm'); + $sample = (new PhpProcessProbe($this->host))->sample($release, 123); + + $this->assertSame(['php-fpm8.3', 'libssl.so.3', 'opcache.so'], $sample?->outdatedLibraries); + $this->assertSame('php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '-fpm', $sample?->service); + } + + public function testPhpServiceNamesPerDistribution(): void { + $this->host->withFile('/proc/self/maps', ''); + $probe = new PhpProcessProbe($this->host); + $rhel = new OsRelease('almalinux', ['rhel', 'centos', 'fedora'], 'AlmaLinux', 'AlmaLinux 9', '9.4', null); + + $this->assertSame('php-fpm', $probe->sample($rhel, 1)?->service); + + $this->host->phpSapi = 'apache2handler'; + $this->assertSame('httpd', $probe->sample($rhel, 1)?->service); + + $this->host->phpSapi = 'cli'; + $this->assertNull($probe->sample($rhel, 1), 'The CLI process says nothing about the web server'); + } + + public function testPhpConfinement(): void { + $this->host->withFile('/proc/self/maps', '') + ->withFile('/proc/self/attr/apparmor/current', "unconfined\n"); + + $this->assertSame('unconfined', (new PhpProcessProbe($this->host))->sample(null, 1)?->confinement); + } + + public function testFailedUnits(): void { + $this->host->withCommand('systemctl', ['list-units', '--failed', '--all', '--no-legend', '--plain', '--no-pager'], + "php8.3-fpm.service loaded failed failed The PHP 8.3 FastCGI Process Manager\n● logrotate.service loaded failed failed Rotate log files\n"); + + $this->assertSame(['php8.3-fpm.service', 'logrotate.service'], (new SystemdProbe($this->host))->getFailedUnits()); + } + + public function testNoFailedUnits(): void { + $this->host->withCommand('systemctl', ['list-units', '--failed', '--all', '--no-legend', '--plain', '--no-pager'], ''); + + $this->assertSame([], (new SystemdProbe($this->host))->getFailedUnits()); + } + + public function testSystemdUnavailable(): void { + $this->host->withCommand('systemctl', ['list-units', '--failed', '--all', '--no-legend', '--plain', '--no-pager'], + '', 1, "System has not been booted with systemd as init system (PID 1). Can't operate.\n"); + + $this->assertNull((new SystemdProbe($this->host))->getFailedUnits()); + } + + public function testUnitStateRejectsOddNames(): void { + $this->assertNull((new SystemdProbe($this->host))->getUnitFileState('foo; reboot')); + $this->assertSame([], $this->host->ran); + } + + public function testTimeSync(): void { + $this->host->withCommand('timedatectl', ['show', '--property=NTPSynchronized', '--value'], "yes\n"); + $this->assertSame(['synchronized' => true, 'source' => 'timedatectl'], (new TimeSyncProbe($this->host))->probe()); + + $this->host->withCommand('timedatectl', ['show', '--property=NTPSynchronized', '--value'], "no\n"); + $this->assertFalse((new TimeSyncProbe($this->host))->probe()['synchronized']); + } + + public function testTimeSyncFallsBackToChrony(): void { + $this->host->withCommand('chronyc', ['-n', 'tracking'], "Reference ID : C0A80001 (192.168.0.1)\nLeap status : Not synchronised\n"); + + $this->assertSame(['synchronized' => false, 'source' => 'chrony'], (new TimeSyncProbe($this->host))->probe()); + } + + public function testTimeSyncUnknown(): void { + $this->assertSame(['synchronized' => null, 'source' => null], (new TimeSyncProbe($this->host))->probe()); + } + + public function testSecurityFrameworks(): void { + $this->host->withFile('/sys/fs/selinux/enforce', '1') + ->withFile('/sys/module/apparmor/parameters/enabled', "N\n"); + $probe = new SecurityProbe($this->host); + + $this->assertSame('enforcing', $probe->getSelinux()); + $this->assertSame('disabled', $probe->getApparmor()); + } + + public function testLivePatches(): void { + $this->host->modified['/sys/kernel'] = 1; + $this->host->globs['/sys/kernel/livepatch/*/enabled'] = ['/sys/kernel/livepatch/lkp_Ubuntu_6_8_0_45/enabled', '/sys/kernel/livepatch/old/enabled']; + $this->host->withFile('/sys/kernel/livepatch/lkp_Ubuntu_6_8_0_45/enabled', "1\n") + ->withFile('/sys/kernel/livepatch/old/enabled', "0\n"); + + $this->assertSame(['lkp_Ubuntu_6_8_0_45'], (new SecurityProbe($this->host))->getLivePatches()); + } + + public function testUbuntuPro(): void { + $this->assertFalse(SubscriptionProbe::parseUbuntuProStatus(FakeHost::fixture('ubuntu-pro-status-unattached.json'))); + $this->assertTrue(SubscriptionProbe::parseUbuntuProStatus('{"attached": true, "services": [{"name": "esm-infra", "status": "enabled"}]}')); + $this->assertFalse(SubscriptionProbe::parseUbuntuProStatus('{"attached": true, "services": [{"name": "esm-infra", "status": "disabled"}]}')); + $this->assertNull(SubscriptionProbe::parseUbuntuProStatus('not json')); + } + + public function testDebianExtendedLts(): void { + $this->host->globs['/etc/apt/sources.list.d/*.list'] = ['/etc/apt/sources.list.d/elts.list']; + $this->host->withFile('/etc/apt/sources.list', "deb http://deb.debian.org/debian bullseye main\n") + ->withFile('/etc/apt/sources.list.d/elts.list', "deb http://deb.freexian.com/extended-lts bullseye main\n"); + + $this->assertTrue((new SubscriptionProbe($this->host))->isActive('debian-elts')); + $this->assertNull((new SubscriptionProbe(new FakeHost()))->isActive('debian-elts')); + $this->assertNull((new SubscriptionProbe($this->host))->isActive('rhel-els')); + } +} diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php new file mode 100644 index 00000000..85b6a681 --- /dev/null +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -0,0 +1,53 @@ + $updates + * @return array name => security + */ + private static function names(array $updates): array { + $names = []; + foreach ($updates as $update) { + $names[$update->name] = $update->security; + } + + return $names; + } + + public function testDebian12(): void { + $updates = Apt::parseSimulation(FakeHost::fixture('debian-12-apt-get-s-pending'), new OsRelease('debian', [], 'Debian GNU/Linux', 'Debian GNU/Linux 12 (bookworm)', '12', 'bookworm')); + + // tzdata is held, so the simulation leaves it out. + $this->assertSame(['libssl3' => true, 'openssl' => true], self::names($updates)); + $this->assertSame('3.0.20-1~deb12u2', $updates[0]->installedVersion); + $this->assertSame('3.0.22-1~deb12u1', $updates[0]->candidateVersion); + $this->assertSame(AutomaticUpdates::SCOPE_ALL, Apt::unattendedScope(FakeHost::fixture('debian-12-apt-config-dump'))); + } + + public function testUbuntu2404(): void { + $updates = Apt::parseSimulation(FakeHost::fixture('ubuntu-24.04-apt-get-s-pending'), OsRelease::parse(FakeHost::fixture('ubuntu-24.04-os-release'))); + + $this->assertSame([ + 'libc6' => true, 'libc-bin' => true, 'libssl3t64' => true, 'openssl' => true, + 'tzdata' => false, 'curl' => true, 'libcurl4t64' => true, + ], self::names($updates)); + } +} diff --git a/tests/lib/OsStatus/SupportTableTest.php b/tests/lib/OsStatus/SupportTableTest.php new file mode 100644 index 00000000..a4dee51d --- /dev/null +++ b/tests/lib/OsStatus/SupportTableTest.php @@ -0,0 +1,108 @@ +table = new SupportTable(); + } + + private function release(string $id, ?string $version, string $name = 'X', array $like = []): OsRelease { + return new OsRelease($id, $like, $name, $name . ' ' . $version, $version, null); + } + + public static function dataKnownReleases(): array { + return [ + 'Ubuntu LTS with Pro' => ['ubuntu', '24.04', '2029-05-31', 'ubuntu-pro'], + 'Debian by major version' => ['debian', '12.7', null, 'debian-elts'], + 'RHEL by major version' => ['rhel', '9.4', null, 'rhel-els'], + 'Alma has no paid extension' => ['almalinux', '9.4', null, null], + 'Alpine by minor version' => ['alpine', '3.20.3', null, null], + 'Leap by minor version' => ['opensuse-leap', '15.6', null, null], + ]; + } + + #[DataProvider('dataKnownReleases')] + public function testKnownReleases(string $id, string $version, ?string $securityEnds, ?string $programme): void { + $support = $this->table->lookup($this->release($id, $version)); + + $this->assertFalse($support->rolling); + $this->assertNotNull($support->securityEnds); + if ($securityEnds !== null) { + $this->assertSame($securityEnds, $support->securityEnds); + } + $this->assertSame($programme, $support->extended); + $this->assertNotNull($support->dataDate); + } + + public function testCentosStreamIsToldApartFromCentosLinux(): void { + $stream = $this->table->lookup($this->release('centos', '9', 'CentOS Stream')); + $linux = $this->table->lookup($this->release('centos', '7', 'CentOS Linux')); + + $this->assertSame('2027-05-31', $stream->securityEnds); + $this->assertSame('2024-06-30', $linux->securityEnds); + } + + public function testRollingReleases(): void { + $this->assertTrue($this->table->lookup($this->release('arch', null))->rolling); + $this->assertTrue($this->table->lookup($this->release('opensuse-tumbleweed', '20261019'))->rolling); + $this->assertTrue($this->table->lookup($this->release('manjaro', null, 'Manjaro', ['arch']))->rolling); + } + + public function testUnknownReleases(): void { + $this->assertSame(SupportStatus::STATE_UNKNOWN, $this->table->lookup($this->release('debian', null))->state(time())); + $this->assertSame(SupportStatus::STATE_UNKNOWN, $this->table->lookup($this->release('linuxmint', '22', 'Mint', ['ubuntu']))->state(time())); + $this->assertSame(SupportStatus::STATE_UNKNOWN, $this->table->lookup($this->release('ubuntu', '99.04'))->state(time())); + $this->assertSame(SupportStatus::STATE_UNKNOWN, $this->table->lookup($this->release('raspbian', '12', 'Raspbian', ['debian']))->state(time()), 'Raspbian has its own archive'); + $this->assertSame(SupportStatus::STATE_UNKNOWN, $this->table->lookup($this->release('nixos', '25.05', 'NixOS'))->state(time())); + } + + public function testAMissingTableKnowsNothing(): void { + $table = SupportTable::fromFile('/nonexistent/os-support.json'); + + $this->assertSame(SupportStatus::STATE_UNKNOWN, $table->lookup($this->release('ubuntu', '24.04'))->state(time())); + } + + public function testStates(): void { + $at = static fn (string $day): int => (int)strtotime($day . ' 12:00:00 UTC'); + $support = new SupportStatus(false, '2029-05-31', '2034-04-25', 'ubuntu-pro'); + + $this->assertSame(SupportStatus::STATE_SUPPORTED, $support->state($at('2029-05-31'))); + $this->assertSame(SupportStatus::STATE_EXTENDED_AVAILABLE, $support->state($at('2029-06-01'))); + $this->assertSame(SupportStatus::STATE_EXTENDED, $support->withExtendedActive(true)->state($at('2029-06-01'))); + $this->assertSame(SupportStatus::STATE_ENDED, $support->withExtendedActive(true)->state($at('2034-04-26'))); + $this->assertSame(SupportStatus::STATE_ROLLING, (new SupportStatus(true, null))->state($at('2029-01-01'))); + } + + public function testDaysLeftCountToTheEffectiveEnd(): void { + $now = (int)strtotime('2029-05-21 12:00:00 UTC'); + $support = new SupportStatus(false, '2029-05-31', '2034-04-25', 'ubuntu-pro'); + + $this->assertSame(10, $support->daysLeft($now)); + $this->assertSame('2034-04-25', $support->withExtendedActive(true)->effectiveEnd()); + $this->assertSame(-1, $support->daysLeft((int)strtotime('2029-06-01 12:00:00 UTC'))); + } + + public function testFromArrayIgnoresGarbage(): void { + $support = SupportStatus::fromArray(['securityEnds' => 'soon', 'extended' => 'free-forever', 'extendedActive' => 'yes']); + + $this->assertNull($support->securityEnds); + $this->assertNull($support->extended); + $this->assertNull($support->extendedActive); + } +} diff --git a/tests/lib/OsStatus/ThresholdsAndStatusTest.php b/tests/lib/OsStatus/ThresholdsAndStatusTest.php new file mode 100644 index 00000000..65039f88 --- /dev/null +++ b/tests/lib/OsStatus/ThresholdsAndStatusTest.php @@ -0,0 +1,99 @@ + 'sudo apt update && sudo apt full-upgrade', 'security' => null], + '/var/lib/dpkg/status', + 1792000000, + ['diagnostic'], + ); + + $copy = OsStatus::fromArray(json_decode((string)json_encode($status->toArray()), true)); + + $this->assertEquals($status, $copy); + } + + public function testDamagedStatusReadsAsUnknown(): void { + $this->assertNull(OsStatus::fromArray('garbage')); + $this->assertNull(OsStatus::fromArray(['schema' => 99, 'collectedAt' => 1])); + + $status = OsStatus::fromArray(['schema' => 1, 'collectedAt' => 5, 'updates' => 'lots', 'rebootReasons' => [['kind' => 'nope']], 'environment' => ['kind' => 'moon']]); + + $this->assertNotNull($status); + $this->assertNull($status->updates); + $this->assertSame([], $status->rebootReasons); + $this->assertSame(Environment::KIND_HOST, $status->environment->kind); + } + + public function testLoadKeepsEachErrorAtOrAboveItsWarning(): void { + $config = $this->createMock(IAppConfig::class); + $config->method('getAppValueInt')->willReturnCallback(static fn (string $key): int => match ($key) { + ConfigLexicon::OS_SECURITY_WARNING_DAYS => 20, + ConfigLexicon::OS_SECURITY_ERROR_DAYS => 14, + ConfigLexicon::OS_REBOOT_WARNING_DAYS => 7, + ConfigLexicon::OS_REBOOT_ERROR_DAYS => 30, + ConfigLexicon::OS_LISTS_WARNING_DAYS => 99999, + ConfigLexicon::OS_LISTS_ERROR_DAYS => 30, + default => 3, + }); + + $thresholds = Thresholds::load($config); + + $this->assertSame(20, $thresholds->securityWarning); + $this->assertSame(20, $thresholds->securityError); + $this->assertSame(30, $thresholds->rebootError); + $this->assertSame(Thresholds::MAX_DAYS, $thresholds->listsError); + } + + public function testStackPackagesAreMarked(): void { + $this->assertTrue((new PendingUpdate('php8.3-fpm', null, '1', false))->toArray()['stack']); + $this->assertTrue((new PendingUpdate('libapache2-mod-php8.3', null, '1', false))->toArray()['stack']); + $this->assertTrue((new PendingUpdate('openssl-libs', null, '1', false))->toArray()['stack']); + $this->assertFalse((new PendingUpdate('vim', null, '1', false))->toArray()['stack']); + } +}