From 9bf0743c508dadb271b61ca4d5710c2ded2682c0 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 19 Oct 2026 16:01:28 -0400 Subject: [PATCH 01/23] feat(os): collect the operating system status Collect what keeps the Linux distribution under Nextcloud secure, as far as the web server user can see it: pending updates with security fixes told apart (apt), reboots that installed updates wait for (the running kernel against /lib/modules, core libraries replaced since boot, the distribution's own flag), libraries replaced under the PHP serving Nextcloud, how long the release still receives security updates (bundled from endoflife.date by build/update-os-support.php, nothing fetched at runtime), automatic updates, held packages, failed system services, time synchronisation and mandatory access control. Containers, LXC guests and snaps are told apart, so nothing is claimed that cannot be known from inside. Commands run through proc_open() with an argument vector, a fixed search path, LC_ALL=C, a time limit and an output cap. A background job collects the status hourly. One verdict with configurable thresholds judges it, and occ serverinfo:os-status shows it, exiting 0/1/2 for monitoring checks. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- .nextcloudignore | 1 + README.md | 37 ++ REUSE.toml | 6 + appinfo/info.xml | 6 +- build/update-os-support.php | 109 ++++ js/serverinfo-main.mjs.license | 4 +- js/serverinfo-main.mjs.map.license | 4 +- lib/AppInfo/Application.php | 3 + lib/Commands/ShowOsStatus.php | 86 +++ lib/Config/ConfigLexicon.php | 48 ++ lib/Jobs/UpdateOsStatus.php | 41 ++ lib/OsStatus/Environment.php | 77 +++ lib/OsStatus/EnvironmentDetector.php | 143 +++++ lib/OsStatus/Health/SecurityProbe.php | 74 +++ lib/OsStatus/Health/SystemdProbe.php | 74 +++ lib/OsStatus/Health/TimeSyncProbe.php | 50 ++ lib/OsStatus/Host/CommandResult.php | 34 ++ lib/OsStatus/Host/CommandRunner.php | 246 ++++++++ lib/OsStatus/Host/IHost.php | 90 +++ lib/OsStatus/Host/LocalHost.php | 146 +++++ lib/OsStatus/Host/SystemTimeZone.php | 54 ++ lib/OsStatus/NextcloudStack.php | 72 +++ lib/OsStatus/OsRelease.php | 111 ++++ lib/OsStatus/OsStatus.php | 207 +++++++ lib/OsStatus/OsStatusCollector.php | 274 +++++++++ lib/OsStatus/OsStatusService.php | 93 +++ lib/OsStatus/OsStatusStore.php | 137 +++++ lib/OsStatus/Packages/Apt.php | 273 +++++++++ lib/OsStatus/Packages/AutomaticUpdates.php | 67 +++ lib/OsStatus/Packages/HeldPackage.php | 45 ++ lib/OsStatus/Packages/IPackageManager.php | 82 +++ .../Packages/PackageManagerFactory.php | 34 ++ lib/OsStatus/Packages/PendingUpdate.php | 85 +++ lib/OsStatus/Packages/ProbeException.php | 17 + lib/OsStatus/Reboot/CoreLibraryProbe.php | 85 +++ lib/OsStatus/Reboot/KernelProbe.php | 111 ++++ lib/OsStatus/Reboot/RebootReason.php | 84 +++ lib/OsStatus/Restart/PhpProcessProbe.php | 141 +++++ lib/OsStatus/Restart/PhpProcessSample.php | 62 ++ lib/OsStatus/Support/SubscriptionProbe.php | 101 ++++ lib/OsStatus/Support/SupportStatus.php | 139 +++++ lib/OsStatus/Support/SupportTable.php | 101 ++++ lib/OsStatus/Values.php | 41 ++ lib/OsStatus/Verdict/Concern.php | 65 +++ lib/OsStatus/Verdict/OsVerdict.php | 220 +++++++ lib/OsStatus/Verdict/Thresholds.php | 55 ++ lib/OsStatus/Verdict/Verdict.php | 54 ++ package-lock.json | 4 +- package.json | 2 +- resources/os-support.json | 539 ++++++++++++++++++ tests/bootstrap.php | 3 + tests/data/os/apache2-maps | 80 +++ tests/data/os/debian-12-apt-config-dump | 223 ++++++++ tests/data/os/debian-12-apt-get-s-pending | 17 + tests/data/os/ubuntu-24.04-apt-get-s-pending | 27 + tests/data/os/ubuntu-24.04-os-release | 9 + tests/data/os/ubuntu-26.04-apt-config-dump | 340 +++++++++++ tests/data/os/ubuntu-26.04-apt-get-s-phased | 11 + tests/data/os/ubuntu-26.04-os-release | 13 + .../data/os/ubuntu-pro-status-unattached.json | 1 + tests/lib/OsStatus/AptTest.php | 172 ++++++ tests/lib/OsStatus/CommandRunnerTest.php | 63 ++ .../lib/OsStatus/EnvironmentDetectorTest.php | 104 ++++ tests/lib/OsStatus/FakeHost.php | 174 ++++++ tests/lib/OsStatus/OsReleaseTest.php | 62 ++ tests/lib/OsStatus/OsStatusCollectorTest.php | 188 ++++++ tests/lib/OsStatus/OsStatusServiceTest.php | 60 ++ tests/lib/OsStatus/OsStatusStoreTest.php | 72 +++ tests/lib/OsStatus/OsVerdictTest.php | 198 +++++++ tests/lib/OsStatus/ProbesTest.php | 228 ++++++++ tests/lib/OsStatus/RealOutputTest.php | 52 ++ tests/lib/OsStatus/SupportTableTest.php | 106 ++++ .../lib/OsStatus/ThresholdsAndStatusTest.php | 76 +++ 73 files changed, 6904 insertions(+), 9 deletions(-) create mode 100644 build/update-os-support.php create mode 100644 lib/Commands/ShowOsStatus.php create mode 100644 lib/Jobs/UpdateOsStatus.php create mode 100644 lib/OsStatus/Environment.php create mode 100644 lib/OsStatus/EnvironmentDetector.php create mode 100644 lib/OsStatus/Health/SecurityProbe.php create mode 100644 lib/OsStatus/Health/SystemdProbe.php create mode 100644 lib/OsStatus/Health/TimeSyncProbe.php create mode 100644 lib/OsStatus/Host/CommandResult.php create mode 100644 lib/OsStatus/Host/CommandRunner.php create mode 100644 lib/OsStatus/Host/IHost.php create mode 100644 lib/OsStatus/Host/LocalHost.php create mode 100644 lib/OsStatus/Host/SystemTimeZone.php create mode 100644 lib/OsStatus/NextcloudStack.php create mode 100644 lib/OsStatus/OsRelease.php create mode 100644 lib/OsStatus/OsStatus.php create mode 100644 lib/OsStatus/OsStatusCollector.php create mode 100644 lib/OsStatus/OsStatusService.php create mode 100644 lib/OsStatus/OsStatusStore.php create mode 100644 lib/OsStatus/Packages/Apt.php create mode 100644 lib/OsStatus/Packages/AutomaticUpdates.php create mode 100644 lib/OsStatus/Packages/HeldPackage.php create mode 100644 lib/OsStatus/Packages/IPackageManager.php create mode 100644 lib/OsStatus/Packages/PackageManagerFactory.php create mode 100644 lib/OsStatus/Packages/PendingUpdate.php create mode 100644 lib/OsStatus/Packages/ProbeException.php create mode 100644 lib/OsStatus/Reboot/CoreLibraryProbe.php create mode 100644 lib/OsStatus/Reboot/KernelProbe.php create mode 100644 lib/OsStatus/Reboot/RebootReason.php create mode 100644 lib/OsStatus/Restart/PhpProcessProbe.php create mode 100644 lib/OsStatus/Restart/PhpProcessSample.php create mode 100644 lib/OsStatus/Support/SubscriptionProbe.php create mode 100644 lib/OsStatus/Support/SupportStatus.php create mode 100644 lib/OsStatus/Support/SupportTable.php create mode 100644 lib/OsStatus/Values.php create mode 100644 lib/OsStatus/Verdict/Concern.php create mode 100644 lib/OsStatus/Verdict/OsVerdict.php create mode 100644 lib/OsStatus/Verdict/Thresholds.php create mode 100644 lib/OsStatus/Verdict/Verdict.php create mode 100644 resources/os-support.json create mode 100644 tests/data/os/apache2-maps create mode 100644 tests/data/os/debian-12-apt-config-dump create mode 100644 tests/data/os/debian-12-apt-get-s-pending create mode 100644 tests/data/os/ubuntu-24.04-apt-get-s-pending create mode 100644 tests/data/os/ubuntu-24.04-os-release create mode 100644 tests/data/os/ubuntu-26.04-apt-config-dump create mode 100644 tests/data/os/ubuntu-26.04-apt-get-s-phased create mode 100644 tests/data/os/ubuntu-26.04-os-release create mode 100644 tests/data/os/ubuntu-pro-status-unattached.json create mode 100644 tests/lib/OsStatus/AptTest.php create mode 100644 tests/lib/OsStatus/CommandRunnerTest.php create mode 100644 tests/lib/OsStatus/EnvironmentDetectorTest.php create mode 100644 tests/lib/OsStatus/FakeHost.php create mode 100644 tests/lib/OsStatus/OsReleaseTest.php create mode 100644 tests/lib/OsStatus/OsStatusCollectorTest.php create mode 100644 tests/lib/OsStatus/OsStatusServiceTest.php create mode 100644 tests/lib/OsStatus/OsStatusStoreTest.php create mode 100644 tests/lib/OsStatus/OsVerdictTest.php create mode 100644 tests/lib/OsStatus/ProbesTest.php create mode 100644 tests/lib/OsStatus/RealOutputTest.php create mode 100644 tests/lib/OsStatus/SupportTableTest.php create mode 100644 tests/lib/OsStatus/ThresholdsAndStatusTest.php diff --git a/.nextcloudignore b/.nextcloudignore index d776fa6c2..a131ffefe 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 ff2c14f43..1dec6d4f7 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,43 @@ 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 +- whether the PHP serving Nextcloud still runs libraries that an update + replaced, and which service to restart +- how long the release still receives security updates, including paid + extensions such as Ubuntu Pro, Debian ELTS or RHEL ELS +- 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. + +The status is collected by a background job once an hour. Admins can also +check it right away with: + +``` +occ serverinfo:os-status [--refresh] [--output=json] +``` + +The command exits with 0, 1 or 2 for ok, warning and error, so it can be used +as a monitoring check. 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 858aeb84b..db68c96ce 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 48e4d34bc..2e71af346 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 000000000..ee6312fe9 --- /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', 'nixos-unstable', +]; + +/** 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 a758fd8f2..ce3f1a3ae 100644 --- a/js/serverinfo-main.mjs.license +++ b/js/serverinfo-main.mjs.license @@ -66,7 +66,7 @@ This file is generated from multiple sources. Included packages: - version: 4.1.0 - license: MIT - @vitejs/plugin-vue - - version: 6.0.7 + - version: 6.0.10 - license: MIT - @vue/reactivity - version: 3.5.43 @@ -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 a758fd8f2..ce3f1a3ae 100644 --- a/js/serverinfo-main.mjs.map.license +++ b/js/serverinfo-main.mjs.map.license @@ -66,7 +66,7 @@ This file is generated from multiple sources. Included packages: - version: 4.1.0 - license: MIT - @vitejs/plugin-vue - - version: 6.0.7 + - version: 6.0.10 - license: MIT - @vue/reactivity - version: 3.5.43 @@ -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 bf748796e..b0507a46d 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 000000000..629ecd348 --- /dev/null +++ b/lib/Commands/ShowOsStatus.php @@ -0,0 +1,86 @@ +setName('serverinfo:os-status') + ->setDescription('Shows pending operating system updates, reboots and other concerns') + ->addOption('refresh', 'r', InputOption::VALUE_NONE, 'Collect the status now instead of showing the stored one'); + } + + public function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('refresh')) { + if ($this->collector->collectAndStore() === null) { + $output->writeln('The operating system status could not be collected; see the Nextcloud log.'); + + return 1; + } + } + + $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 1; + } + + $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; + + $this->writeArrayInOutputFormat($input, $output, [ + 'level' => $verdict?->level() ?? Concern::LEVEL_UNKNOWN, + 'collectedAt' => date(DATE_ATOM, $status->collectedAt), + 'release' => $status->release?->prettyName ?? 'unknown', + 'kernel' => $status->kernel, + 'environment' => $status->environment->kind, + 'packageManager' => $status->packageManager ?? 'none', + 'securityUpdates' => count($status->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, + ]); + + // Exit codes in the style of monitoring plugins: 0 ok, 1 warning, 2 critical. + return match ($verdict?->level()) { + Concern::LEVEL_ERROR => 2, + Concern::LEVEL_WARNING => 1, + default => 0, + }; + } +} diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 394fd77c6..00f71a407 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_STATUS = 'cached_os_status'; + public const CACHED_OS_FIRST_SEEN = 'cached_os_first_seen'; + public const CACHED_OS_PHP_PROCESS = 'cached_os_php_process'; + 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_STATUS, + ValueType::ARRAY, + [], + 'Operating system status as last collected by OCA\ServerInfo\Jobs\UpdateOsStatus', + lazy: true, + ), + new Entry( + self::CACHED_OS_FIRST_SEEN, + ValueType::ARRAY, + [], + 'When each pending security update, reboot reason and restart 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::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 000000000..585edebbb --- /dev/null +++ b/lib/Jobs/UpdateOsStatus.php @@ -0,0 +1,41 @@ +setInterval(max(300, $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::JOB_INTERVAL_OS_STATUS))); + } + + #[\Override] + protected function run($argument): void { + $this->collector->collectAndStore(); + } +} diff --git a/lib/OsStatus/Environment.php b/lib/OsStatus/Environment.php new file mode 100644 index 000000000..b02f4ebea --- /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 000000000..858eddebd --- /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 000000000..770ae102a --- /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 000000000..4dea254c8 --- /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 000000000..6b614dc50 --- /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 000000000..afff5b498 --- /dev/null +++ b/lib/OsStatus/Host/CommandResult.php @@ -0,0 +1,34 @@ +exitCode === 0 && !$this->timedOut && !$this->truncated; + } +} diff --git a/lib/OsStatus/Host/CommandRunner.php b/lib/OsStatus/Host/CommandRunner.php new file mode 100644 index 000000000..91f221730 --- /dev/null +++ b/lib/OsStatus/Host/CommandRunner.php @@ -0,0 +1,246 @@ +candidates($binary) as [$path, $verified]) { + if ($verified) { + return $path; + } + } + + return null; + } + + /** + * @param list $arguments + */ + public function run(string $binary, array $arguments, int $timeout): ?CommandResult { + if (!$this->isAvailable()) { + return null; + } + + foreach ($this->candidates($binary) as [$path, $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; + } + + return $result; + } + + return 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); + } + + private function hasOpenBasedir(): bool { + return trim((string)ini_get('open_basedir')) !== ''; + } + + /** + * @param list $arguments + */ + private 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; + + while ($open !== []) { + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + $timedOut = true; + break; + } + + $read = array_values($open); + $write = null; + $except = null; + $seconds = (int)$remaining; + $ready = @stream_select($read, $write, $except, $seconds, (int)(($remaining - (float)$seconds) * 1000000.0)); + if ($ready === false) { + break; + } + + 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); + + return new CommandResult($exitCode, $output[1], $output[2], $timedOut, $truncated); + } + + /** + * 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 000000000..90eba25b8 --- /dev/null +++ b/lib/OsStatus/Host/IHost.php @@ -0,0 +1,90 @@ + + */ + 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; + + public function hasBinary(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 000000000..50d7c40cf --- /dev/null +++ b/lib/OsStatus/Host/LocalHost.php @@ -0,0 +1,146 @@ + 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 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 000000000..b4fb412f8 --- /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 000000000..ac29748b7 --- /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 000000000..3e2c2bfbc --- /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 000000000..d95ab8a56 --- /dev/null +++ b/lib/OsStatus/OsStatusCollector.php @@ -0,0 +1,274 @@ +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->environmentDetector->detect(); + $uptime = $this->os->getUptime(); + $bootTime = $uptime >= 0 ? $now - $uptime : null; + + $support = $release !== null ? $this->supportTable->lookup($release) : SupportStatus::unknown(); + if ($support->extended !== null) { + $support = $support->withExtendedActive($this->subscriptions->isActive($support->extended)); + } + + $packageManager = null; + if ($release !== null && $environment->managesPackages()) { + $packageManager = $this->packageManagers->forRelease($release); + 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.'; + } + } + + $rebootReasons = $environment->managesPackages() + ? $this->rebootReasons($packageManager, $environment, $bootTime) + : null; + + [$updates, $rebootReasons] = $this->applyFirstSeen( + $updates, + $packageManager?->classifiesSecurity() ?? false, + $rebootReasons, + $now, + ); + + $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->timeSync->probe(); + + return new OsStatus( + OsStatus::STATE_OK, + $now, + $release, + $kernel, + $architecture, + $environment, + $support, + $packageManager?->getId(), + $updates, + $updatesSource, + $packageManager?->classifiesSecurity() ?? false, + $omitted, + $packageManager?->getHeldPackages(), + $packageManager?->getListsRefreshedAt(), + $packageManager?->getLastUpgradeAt(), + $packageManager?->getAutomaticUpdates() ?? AutomaticUpdates::unknown(), + $rebootReasons, + $this->security->getLivePatches(), + $this->systemd->getFailedUnits(), + $timeSync['synchronized'], + $timeSync['source'], + $this->security->getSelinux(), + $this->security->getApparmor(), + $packageManager?->getUpgradeCommands() ?? ['all' => null, 'security' => null], + $packageManager?->getPackageDatabasePath(), + $bootTime, + $diagnostics, + ); + } + + /** + * 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() as $reason) { + $add($reason); + } + + $hasDistributionAnswer = ($packageManager?->hasOwnRebootCheck() ?? false) + || array_filter($reasons, static fn (RebootReason $r): bool => $r->kind === RebootReason::KIND_DISTRIBUTION) !== []; + if (!$hasDistributionAnswer && $bootTime !== null) { + foreach ($this->coreLibraryProbe->getRebootReasons($bootTime) 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 000000000..7b184073b --- /dev/null +++ b/lib/OsStatus/OsStatusService.php @@ -0,0 +1,93 @@ +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(), + ); + } + + /** + * 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 000000000..817219455 --- /dev/null +++ b/lib/OsStatus/OsStatusStore.php @@ -0,0 +1,137 @@ +appConfig->setAppValueArray(ConfigLexicon::CACHED_OS_STATUS, $status->toArray()); + } + + public function load(): ?OsStatus { + return OsStatus::fromArray($this->appConfig->getAppValueArray(ConfigLexicon::CACHED_OS_STATUS)); + } + + /** + * 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()); + $this->firstSeen($sample->isOutdated() ? ['php-process' => null] : [], $sample->sampledAt, ['php-process']); + } + + 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. + */ + public function phpOutdatedSince(): ?int { + $seen = $this->appConfig->getAppValueArray(ConfigLexicon::CACHED_OS_FIRST_SEEN); + $since = $seen['php-process'] ?? null; + + return is_int($since) ? $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 000000000..630472d8d --- /dev/null +++ b/lib/OsStatus/Packages/Apt.php @@ -0,0 +1,273 @@ +host->hasBinary('apt-get'); + } + + #[\Override] + public function classifiesSecurity(): bool { + return true; + } + + #[\Override] + public function getPendingUpdates(): array { + $result = $this->host->run('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], 120); + if ($result === null) { + throw new ProbeException('apt-get could not be run.'); + } + if (!$result->succeeded()) { + throw new ProbeException('apt-get -s dist-upgrade failed: ' . self::firstLine($result->stderr ?: $result->stdout)); + } + + return self::parseSimulation($result->stdout); + } + + /** + * 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]) + * + * @return list + */ + public static function parseSimulation(string $output): 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])))); + $security = false; + foreach ($origins as $origin) { + $slash = strrpos($origin, '/'); + $suite = $slash === false ? $origin : substr($origin, $slash + 1); + if (str_ends_with($suite, '-security') || stripos($origin, 'Debian-Security') !== false) { + $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); + } + + #[\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('/^[a-z0-9][a-z0-9+.:-]*$/', $name) === 1) { + $held[] = new HeldPackage($name, 'apt-mark hold'); + } + } + + return $held; + } + + /** + * `apt-get update` writes into lists/partial every time it runs, whether + * or not anything changed upstream; the InRelease files themselves carry + * the mirror's timestamp, not ours. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + $times = array_filter([ + $this->host->modifiedAt('/var/lib/apt/lists/partial'), + $this->host->modifiedAt('/var/lib/apt/periodic/update-success-stamp'), + ], '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('/^[a-z0-9][a-z0-9+.:-]*$/', $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; + } + + private static function firstLine(string $text): string { + $line = trim((string)strtok(trim($text), "\n")); + + return mb_substr($line, 0, 200); + } +} diff --git a/lib/OsStatus/Packages/AutomaticUpdates.php b/lib/OsStatus/Packages/AutomaticUpdates.php new file mode 100644 index 000000000..dba3a9b4d --- /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 000000000..2708b6429 --- /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 000000000..570b2ed49 --- /dev/null +++ b/lib/OsStatus/Packages/IPackageManager.php @@ -0,0 +1,82 @@ + + * @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 000000000..9609e5c3e --- /dev/null +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -0,0 +1,34 @@ +isLike('debian') || $release->isLike('ubuntu') => $this->apt, + 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 000000000..728f6c053 --- /dev/null +++ b/lib/OsStatus/Packages/PendingUpdate.php @@ -0,0 +1,85 @@ + $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); + } + + public function key(): string { + return $this->name . ':' . $this->candidateVersion; + } + + 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 000000000..c2e8b1c61 --- /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', + ], + 'systemd' => ['/usr/lib/systemd/systemd', '/lib/systemd/systemd'], + 'dbus' => ['/usr/bin/dbus-broker', '/usr/bin/dbus-daemon'], + ]; + + public function __construct( + private IHost $host, + ) { + } + + /** + * @param int $bootTime unix timestamp of the last boot + * @return list + */ + public function getRebootReasons(int $bootTime): array { + $changed = []; + $since = null; + + foreach (self::COMPONENTS as $component => $patterns) { + $file = $this->find($patterns); + if ($file === null) { + continue; + } + + $changedAt = $this->host->changedAt($file); + // A minute of slack: early boot can touch files before the clock settles. + if ($changedAt !== null && $changedAt > $bootTime + 60) { + $changed[] = $component; + $since = $since === null ? $changedAt : min($since, $changedAt); + } + } + + if ($changed === []) { + return []; + } + + return [new RebootReason(RebootReason::KIND_CORE_LIBRARY, null, $changed, $since)]; + } + + /** + * @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 000000000..b06fb2886 --- /dev/null +++ b/lib/OsStatus/Reboot/KernelProbe.php @@ -0,0 +1,111 @@ + + */ + public function getRebootReasons(): array { + $running = trim($this->host->uname('r')); + $installed = $this->installedKernels(); + if ($running === '' || $installed === []) { + // Nothing to compare against: a container, or /lib/modules hidden. + return []; + } + + if (!isset($installed[$running])) { + // Arch and others delete the running kernel's modules on upgrade. + $newest = $this->newest(array_keys($installed)); + + return [new RebootReason( + RebootReason::KIND_KERNEL_REMOVED, + $newest, + [], + $newest !== null ? $installed[$newest] : null, + )]; + } + + $flavour = self::flavour($running); + $sameFlavour = array_values(array_filter( + array_keys($installed), + static fn (string $release): bool => self::flavour($release) === $flavour, + )); + $newest = $this->newest($sameFlavour); + if ($newest === null || $newest === $running || version_compare($newest, $running, '<=')) { + return []; + } + + return [new RebootReason(RebootReason::KIND_KERNEL, $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 000000000..c3385307e --- /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 000000000..4bebf55bc --- /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 000000000..b61d6fd3f --- /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 000000000..b81ada808 --- /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 000000000..34c7c0c24 --- /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 000000000..b91fe72da --- /dev/null +++ b/lib/OsStatus/Support/SupportTable.php @@ -0,0 +1,101 @@ +, rolling?: list, releases?: array>}|null */ + private ?array $table = null; + + /** + * @param string|null $supportDataFile a different table, for tests + */ + public function __construct( + private ?string $supportDataFile = null, + ) { + } + + 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', + $release->id === 'raspbian' => 'debian', + 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->supportDataFile ?? __DIR__ . '/../../../resources/os-support.json'); + $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 000000000..766ddee88 --- /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 000000000..960a8ffd9 --- /dev/null +++ b/lib/OsStatus/Verdict/Concern.php @@ -0,0 +1,65 @@ +|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 000000000..9174a94ad --- /dev/null +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -0,0 +1,220 @@ +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); + + return new Verdict($concerns); + } + + 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, + ]; + + 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 000000000..5cb48feb7 --- /dev/null +++ b/lib/OsStatus/Verdict/Thresholds.php @@ -0,0 +1,55 @@ + 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, + ]; + + 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))); + } + + return new self(...$values); + } +} diff --git a/lib/OsStatus/Verdict/Verdict.php b/lib/OsStatus/Verdict/Verdict.php new file mode 100644 index 000000000..c8fd0ab97 --- /dev/null +++ b/lib/OsStatus/Verdict/Verdict.php @@ -0,0 +1,54 @@ + $concerns + */ + public function __construct( + public readonly array $concerns, + ) { + } + + /** + * The most severe level of any concern; "unknown" when nothing could be assessed. + * + * @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; + } + + 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 bceee627b..73824d7f2 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 02743947e..16caa9411 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 000000000..312cfe2fa --- /dev/null +++ b/resources/os-support.json @@ -0,0 +1,539 @@ +{ + "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", + "nixos-unstable" + ], + "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 39191a680..eb6a610f4 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 000000000..c1502c137 --- /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 000000000..90205c5a8 --- /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 000000000..7195ad40d --- /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/ubuntu-24.04-apt-get-s-pending b/tests/data/os/ubuntu-24.04-apt-get-s-pending new file mode 100644 index 000000000..5820e2158 --- /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 000000000..290ec6dc0 --- /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 000000000..8e6b6c219 --- /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 000000000..2b1413e8c --- /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 000000000..10d02aa41 --- /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 000000000..8a05b4063 --- /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/OsStatus/AptTest.php b/tests/lib/OsStatus/AptTest.php new file mode 100644 index 000000000..0667dc5c8 --- /dev/null +++ b/tests/lib/OsStatus/AptTest.php @@ -0,0 +1,172 @@ +host = new FakeHost(); + $this->apt = new Apt($this->host, new SystemdProbe($this->host)); + } + + 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]', + ])); + + $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 testFailuresAreReported(): void { + $this->host->withCommand('apt-get', self::SIMULATE, '', 100, "E: Could not open lock file\n"); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('E: Could not open lock file'); + $this->apt->getPendingUpdates(); + } + + public function testMissingAptGetIsReported(): void { + $this->expectException(ProbeException::class); + $this->apt->getPendingUpdates(); + } + + public function testHeldPackages(): void { + $this->host->withCommand('apt-mark', ['showhold'], "mariadb-server\nlinux-image-generic\n"); + + $held = $this->apt->getHeldPackages(); + + $this->assertSame(['mariadb-server', 'linux-image-generic'], array_map(static fn ($h) => $h->name, $held ?? [])); + $this->assertSame('apt-mark hold', $held[0]->mechanism); + } + + public function testUnattendedUpgradesLimitedToSecurity(): void { + $this->host->withCommand('apt-config', ['dump'], FakeHost::fixture('ubuntu-26.04-apt-config-dump')) + ->withCommand('systemctl', ['is-enabled', 'apt-daily-upgrade.timer'], "enabled\n"); + $this->host->files['/usr/bin/unattended-upgrade'] = ''; + + $automatic = $this->apt->getAutomaticUpdates(); + + $this->assertSame(AutomaticUpdates::STATE_ENABLED, $automatic->state); + $this->assertSame('unattended-upgrades', $automatic->mechanism); + $this->assertSame(AutomaticUpdates::SCOPE_SECURITY, $automatic->scope); + } + + public function testDisabledTimerMeansNoAutomaticUpdates(): void { + $this->host->withCommand('apt-config', ['dump'], FakeHost::fixture('ubuntu-26.04-apt-config-dump')) + ->withCommand('systemctl', ['is-enabled', 'apt-daily-upgrade.timer'], "masked\n", 1); + $this->host->files['/usr/bin/unattended-upgrade'] = ''; + + $this->assertSame(AutomaticUpdates::STATE_DISABLED, $this->apt->getAutomaticUpdates()->state); + } + + public function testNotInstalledMeansNoAutomaticUpdates(): void { + $this->host->withCommand('apt-config', ['dump'], FakeHost::fixture('ubuntu-26.04-apt-config-dump')); + + $this->assertSame(AutomaticUpdates::STATE_DISABLED, $this->apt->getAutomaticUpdates()->state); + } + + public function testUnattendedScope(): void { + $debian = implode("\n", [ + 'Unattended-Upgrade::Origins-Pattern:: "origin=Debian,codename=${distro_codename},label=Debian";', + 'Unattended-Upgrade::Origins-Pattern:: "origin=Debian,codename=${distro_codename},label=Debian-Security";', + ]); + $this->assertSame(AutomaticUpdates::SCOPE_ALL, Apt::unattendedScope($debian)); + + $updates = 'Unattended-Upgrade::Allowed-Origins:: "${distro_id}:${distro_codename}-updates";'; + $this->assertSame(AutomaticUpdates::SCOPE_ALL, Apt::unattendedScope($updates)); + + $this->assertSame(AutomaticUpdates::SCOPE_UNKNOWN, Apt::unattendedScope('APT::Periodic "";')); + } + + public function testRebootRequired(): void { + $this->host->withFile('/run/reboot-required', "*** System restart required ***\n", 1760400000) + ->withFile('/run/reboot-required.pkgs', "linux-image-6.8.0-48-generic\nlibc6\nlibc6\n; rm -rf\n"); + + $reasons = $this->apt->getRebootReasons(); + + $this->assertCount(1, $reasons); + $this->assertSame(RebootReason::KIND_DISTRIBUTION, $reasons[0]->kind); + $this->assertSame(['linux-image-6.8.0-48-generic', 'libc6'], $reasons[0]->packages); + $this->assertSame(1760400000, $reasons[0]->since); + } + + public function testNoRebootRequired(): void { + $this->assertSame([], $this->apt->getRebootReasons()); + } + + public function testListsRefreshTimeComesFromAptsOwnFiles(): void { + $this->host->modified['/var/lib/apt/lists/partial'] = 1760400000; + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = 1760300000; + + $this->assertSame(1760400000, $this->apt->getListsRefreshedAt()); + } + + public function testLastUpgradeIsReadInTheSystemTimeZone(): void { + $this->host->links['/etc/localtime'] = '/usr/share/zoneinfo/Europe/Berlin'; + $this->host->withFile('/var/log/dpkg.log', implode("\n", [ + '2026-10-12 06:41:00 startup archives unpack', + '2026-10-12 06:41:02 upgrade libssl3:amd64 3.0.13-1 3.0.13-2', + '2026-10-12 06:41:05 status installed libssl3:amd64 3.0.13-2', + '2026-10-13 09:00:00 remove foo:amd64 1.0 ', + ]) . "\n"); + + $this->assertSame((int)strtotime('2026-10-12 04:41:02 UTC'), $this->apt->getLastUpgradeAt()); + } + + public function testLastUpgradeFallsBackToTheDpkgDatabase(): void { + $this->host->modified['/var/lib/dpkg/status'] = 1760000000; + + $this->assertSame(1760000000, $this->apt->getLastUpgradeAt()); + } + + public function testUpgradeCommands(): void { + $this->assertNull($this->apt->getUpgradeCommands()['security']); + + $this->host->files['/usr/bin/unattended-upgrade'] = ''; + $this->assertSame('sudo unattended-upgrade -v', $this->apt->getUpgradeCommands()['security']); + } +} diff --git a/tests/lib/OsStatus/CommandRunnerTest.php b/tests/lib/OsStatus/CommandRunnerTest.php new file mode 100644 index 000000000..fa857c971 --- /dev/null +++ b/tests/lib/OsStatus/CommandRunnerTest.php @@ -0,0 +1,63 @@ +runner = new CommandRunner(); + if (!$this->runner->isAvailable() || $this->runner->resolve('sh') === null) { + $this->markTestSkipped('No shell to run'); + } + } + + public function testOutputAndExitCode(): void { + $result = $this->runner->run('sh', ['-c', 'echo out; echo err >&2; exit 3'], 10); + + $this->assertNotNull($result); + $this->assertSame("out\n", $result->stdout); + $this->assertSame("err\n", $result->stderr); + $this->assertSame(3, $result->exitCode); + $this->assertFalse($result->succeeded()); + } + + public function testArgumentsAreNotInterpretedByAShell(): void { + $result = $this->runner->run('sh', ['-c', 'printf "%s|" "$@"', 'sh', '$(id)', '; echo injected', '*'], 10); + + $this->assertSame('$(id)|; echo injected|*|', $result?->stdout); + } + + public function testTheLocaleIsForcedToC(): void { + $this->assertSame("C\n", $this->runner->run('sh', ['-c', 'echo "$LC_ALL"'], 10)?->stdout); + } + + public function testSlowCommandsAreKilled(): void { + $start = microtime(true); + $result = $this->runner->run('sh', ['-c', 'sleep 30'], 1); + + $this->assertTrue($result?->timedOut); + $this->assertFalse($result->succeeded()); + $this->assertLessThan(10, microtime(true) - $start); + } + + public function testUnknownBinaries(): void { + $this->assertNull($this->runner->run('serverinfo-no-such-binary', [], 5)); + $this->assertNull($this->runner->run('../../bin/sh', [], 5)); + $this->assertNull($this->runner->run('sh -c id', [], 5)); + $this->assertNull($this->runner->run('/bin/../bin/sh', [], 5)); + } +} diff --git a/tests/lib/OsStatus/EnvironmentDetectorTest.php b/tests/lib/OsStatus/EnvironmentDetectorTest.php new file mode 100644 index 000000000..68dfd5d47 --- /dev/null +++ b/tests/lib/OsStatus/EnvironmentDetectorTest.php @@ -0,0 +1,104 @@ +host = new FakeHost(); + } + + private function detect(): Environment { + return (new EnvironmentDetector($this->host))->detect(); + } + + public function testVirtualMachine(): void { + $this->host->withCommand('systemd-detect-virt', ['--vm'], "vmware\n") + ->withCommand('systemd-detect-virt', ['--container'], "none\n", 1); + + $environment = $this->detect(); + + $this->assertSame(Environment::KIND_HOST, $environment->kind); + $this->assertSame('vmware', $environment->virtualization); + $this->assertTrue($environment->managesPackages()); + $this->assertTrue($environment->ownsKernel()); + } + + public function testBareMetal(): void { + $this->host->withCommand('systemd-detect-virt', ['--vm'], "none\n", 1); + + $this->assertNull($this->detect()->virtualization); + } + + public function testDmiFallback(): void { + $this->host->withFile('/sys/class/dmi/id/sys_vendor', "QEMU\n"); + + $this->assertSame('kvm', $this->detect()->virtualization); + } + + public function testDocker(): void { + $this->host->files['/.dockerenv'] = ''; + + $environment = $this->detect(); + + $this->assertSame(Environment::KIND_APPLICATION_CONTAINER, $environment->kind); + $this->assertSame('docker', $environment->container); + $this->assertFalse($environment->managesPackages()); + } + + public function testLxcIsManagedLikeAMachineButNotItsKernel(): void { + $this->host->withFile('/run/systemd/container', "lxc\n"); + + $environment = $this->detect(); + + $this->assertSame(Environment::KIND_SYSTEM_CONTAINER, $environment->kind); + $this->assertTrue($environment->managesPackages()); + $this->assertFalse($environment->ownsKernel()); + } + + public function testKubernetesFromTheEnvironment(): void { + $this->host->env['KUBERNETES_SERVICE_HOST'] = '10.0.0.1'; + + $this->assertSame('kubernetes', $this->detect()->container); + } + + public function testPodmanFromCgroups(): void { + $this->host->withFile('/proc/1/cgroup', "0::/machine.slice/libpod-0123abcd.scope\n"); + + $this->assertSame('podman', $this->detect()->container); + } + + public function testSnap(): void { + $this->host->env['SNAP'] = '/snap/nextcloud/1'; + $this->host->env['SNAP_NAME'] = 'nextcloud'; + + $this->assertSame(Environment::KIND_SNAP, $this->detect()->kind); + } + + public function testWslIsAHostWithoutItsOwnKernel(): void { + $this->host->release = '5.15.153.1-microsoft-standard-WSL2'; + + $environment = $this->detect(); + + $this->assertSame(Environment::KIND_HOST, $environment->kind); + $this->assertFalse($environment->ownsKernel()); + } + + public function testUnknownContainerNamesAreNotEchoedVerbatim(): void { + $this->host->withFile('/run/systemd/container', "\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 000000000..27d0b8354 --- /dev/null +++ b/tests/lib/OsStatus/FakeHost.php @@ -0,0 +1,174 @@ + */ + 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 */ + 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 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 000000000..e2d68aea4 --- /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 000000000..c67fc0eea --- /dev/null +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -0,0 +1,188 @@ + */ + private array $config = []; + + 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); + + $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, + new TimeSyncProbe($host), + new SecurityProbe($host), + new OsStatusStore($appConfig), + $this->createMock(LoggerInterface::class), + ); + } + + 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/lists/partial'] = 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 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 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, $this->config[ConfigLexicon::CACHED_OS_STATUS]['schema'] ?? null); + } +} diff --git a/tests/lib/OsStatus/OsStatusServiceTest.php b/tests/lib/OsStatus/OsStatusServiceTest.php new file mode 100644 index 000000000..483358372 --- /dev/null +++ b/tests/lib/OsStatus/OsStatusServiceTest.php @@ -0,0 +1,60 @@ +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); + + $this->service = new OsStatusService($this->store, new OsVerdict(), new PhpProcessProbe($this->host), $config, $time, $this->host); + } + + 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(); + } +} diff --git a/tests/lib/OsStatus/OsStatusStoreTest.php b/tests/lib/OsStatus/OsStatusStoreTest.php new file mode 100644 index 000000000..77789a924 --- /dev/null +++ b/tests/lib/OsStatus/OsStatusStoreTest.php @@ -0,0 +1,72 @@ + */ + private array $values = []; + private int $writes = 0; + private OsStatusStore $store; + + protected function setUp(): void { + parent::setUp(); + $config = $this->createMock(IAppConfig::class); + $config->method('getAppValueArray')->willReturnCallback(fn (string $key): array => $this->values[$key] ?? []); + $config->method('setAppValueArray')->willReturnCallback(function (string $key, array $value): bool { + $this->values[$key] = $value; + $this->writes++; + + return true; + }); + $this->store = new OsStatusStore($config); + } + + 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()); + } +} diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php new file mode 100644 index 000000000..d8590d896 --- /dev/null +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -0,0 +1,198 @@ + 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 testUnknownUpdatesDoNotRaiseTheLevel(): void { + $verdict = $this->evaluate($this->osStatus(['updates' => null])); + + $this->assertSame(Concern::LEVEL_UNKNOWN, $verdict->get(Concern::UPDATES)?->level); + $this->assertSame(Concern::LEVEL_OK, $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); + } + + 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); + } +} diff --git a/tests/lib/OsStatus/ProbesTest.php b/tests/lib/OsStatus/ProbesTest.php new file mode 100644 index 000000000..a0f2198d2 --- /dev/null +++ b/tests/lib/OsStatus/ProbesTest.php @@ -0,0 +1,228 @@ +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 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'] = 900; + $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 testCoreLibrariesWithinTheBootSlack(): void { + $this->host->changed['/usr/lib/systemd/systemd'] = 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 000000000..13fc45563 --- /dev/null +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -0,0 +1,52 @@ + $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')); + + // 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')); + + $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 000000000..1d6e3fdcc --- /dev/null +++ b/tests/lib/OsStatus/SupportTableTest.php @@ -0,0 +1,106 @@ +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())); + } + + public function testAMissingTableKnowsNothing(): void { + $table = new SupportTable('/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 000000000..a53f787da --- /dev/null +++ b/tests/lib/OsStatus/ThresholdsAndStatusTest.php @@ -0,0 +1,76 @@ + '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 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']); + } +} From 2f0c96cab40c2bc13d44c07848fb22ae8e6f2515 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:40:45 -0400 Subject: [PATCH 02/23] fix(os): treat missing package lists as unknown updates Without package lists apt-get -s finds no newer versions, which read as "no security updates". When the lists cannot be found and nothing is pending, the updates are now unknown, with a diagnostic. Updates found in lists past the warning age carry a listsStale fact, and occ reports the security update count as unknown when the updates are. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/Commands/ShowOsStatus.php | 3 ++- lib/OsStatus/OsStatusCollector.php | 11 ++++++++++- lib/OsStatus/Verdict/OsVerdict.php | 6 +++++- tests/lib/OsStatus/OsStatusCollectorTest.php | 17 +++++++++++++++++ tests/lib/OsStatus/OsVerdictTest.php | 10 ++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/Commands/ShowOsStatus.php b/lib/Commands/ShowOsStatus.php index 629ecd348..df240d0b0 100644 --- a/lib/Commands/ShowOsStatus.php +++ b/lib/Commands/ShowOsStatus.php @@ -61,6 +61,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { ); } $pendingDays = $verdict?->get(Concern::UPDATES)?->facts['days'] ?? null; + $securityUpdates = $status->securityUpdates(); $this->writeArrayInOutputFormat($input, $output, [ 'level' => $verdict?->level() ?? Concern::LEVEL_UNKNOWN, @@ -69,7 +70,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { 'kernel' => $status->kernel, 'environment' => $status->environment->kind, 'packageManager' => $status->packageManager ?? 'none', - 'securityUpdates' => count($status->securityUpdates() ?? []), + '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'), diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index d95ab8a56..a08d7a7db 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -25,6 +25,7 @@ use OCA\ServerInfo\OsStatus\Support\SubscriptionProbe; use OCA\ServerInfo\OsStatus\Support\SupportStatus; use OCA\ServerInfo\OsStatus\Support\SupportTable; +use OCA\ServerInfo\OsStatus\Verdict\OsVerdict; use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; use Psr\Log\LoggerInterface; @@ -117,6 +118,14 @@ public function collect(): OsStatus { } } + $listsRefreshedAt = $packageManager?->getListsRefreshedAt(); + // 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->rebootReasons($packageManager, $environment, $bootTime) : null; @@ -151,7 +160,7 @@ public function collect(): OsStatus { $packageManager?->classifiesSecurity() ?? false, $omitted, $packageManager?->getHeldPackages(), - $packageManager?->getListsRefreshedAt(), + $listsRefreshedAt, $packageManager?->getLastUpgradeAt(), $packageManager?->getAutomaticUpdates() ?? AutomaticUpdates::unknown(), $rebootReasons, diff --git a/lib/OsStatus/Verdict/OsVerdict.php b/lib/OsStatus/Verdict/OsVerdict.php index 9174a94ad..66587e6b0 100644 --- a/lib/OsStatus/Verdict/OsVerdict.php +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -25,7 +25,7 @@ */ class OsVerdict { /** Package managers whose metadata is refreshed in place and can go stale */ - private const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk']; + public const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk']; private const DAY = 86400; @@ -66,6 +66,10 @@ private function updates(OsStatus $status, Thresholds $thresholds, int $now): Co '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 === []) { diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index c67fc0eea..4668e5635 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -168,6 +168,23 @@ public function testFailuresBecomeDiagnostics(): void { $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/lists/partial'] = self::NOW - 3600; + + $this->assertSame([], $this->collector()->collect()->updates); + } + public function testWithoutProcesses(): void { $this->host->commandsAllowed = false; diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php index d8590d896..cbe51ef52 100644 --- a/tests/lib/OsStatus/OsVerdictTest.php +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -97,6 +97,16 @@ public function testUnclassifiedUpdatesCountAsRelevant(): void { $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 testUnknownUpdatesDoNotRaiseTheLevel(): void { $verdict = $this->evaluate($this->osStatus(['updates' => null])); From 284aa2b531a81487060ffdc30a7263424a7fd04f Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:42:17 -0400 Subject: [PATCH 03/23] fix(os): report unknown, exit 3, when the updates are not known occ serverinfo:os-status follows the monitoring-plugin exit codes, where 3 is UNKNOWN. A status that was never collected, could not be collected or has no verdict now exits 3 instead of 1 or 0. The overall level is unknown when nothing is wrong but the pending updates are unknown, so an unknown update state no longer reads as an all-clear. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/Commands/ShowOsStatus.php | 27 ++++++++--- lib/OsStatus/Verdict/Concern.php | 2 +- lib/OsStatus/Verdict/Verdict.php | 7 ++- tests/lib/Commands/ShowOsStatusTest.php | 61 +++++++++++++++++++++++++ tests/lib/OsStatus/OsVerdictTest.php | 11 ++++- 5 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 tests/lib/Commands/ShowOsStatusTest.php diff --git a/lib/Commands/ShowOsStatus.php b/lib/Commands/ShowOsStatus.php index df240d0b0..25ef7cea2 100644 --- a/lib/Commands/ShowOsStatus.php +++ b/lib/Commands/ShowOsStatus.php @@ -21,6 +21,12 @@ * @psalm-api */ class ShowOsStatus extends Base { + /** Exit codes of monitoring plugins */ + public const EXIT_OK = 0; + public const EXIT_WARNING = 1; + public const EXIT_CRITICAL = 2; + public const EXIT_UNKNOWN = 3; + public function __construct( private OsStatusCollector $collector, private OsStatusService $service, @@ -41,7 +47,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { if ($this->collector->collectAndStore() === null) { $output->writeln('The operating system status could not be collected; see the Nextcloud log.'); - return 1; + return self::EXIT_UNKNOWN; } } @@ -49,7 +55,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { if ($status === null) { $output->writeln('The operating system has not been checked yet. Run with --refresh to check it now.'); - return 1; + return self::EXIT_UNKNOWN; } $verdict = $this->service->evaluate($status); @@ -77,11 +83,18 @@ public function execute(InputInterface $input, OutputInterface $output): int { 'concerns' => $concerns, ]); - // Exit codes in the style of monitoring plugins: 0 ok, 1 warning, 2 critical. - return match ($verdict?->level()) { - Concern::LEVEL_ERROR => 2, - Concern::LEVEL_WARNING => 1, - default => 0, + return self::exitCode($verdict?->level()); + } + + /** + * @param Concern::LEVEL_*|null $level + */ + 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/OsStatus/Verdict/Concern.php b/lib/OsStatus/Verdict/Concern.php index 960a8ffd9..452b311f0 100644 --- a/lib/OsStatus/Verdict/Concern.php +++ b/lib/OsStatus/Verdict/Concern.php @@ -17,7 +17,7 @@ final class Concern { public const LEVEL_INFO = 'info'; public const LEVEL_WARNING = 'warning'; public const LEVEL_ERROR = 'error'; - /** Could not be determined; never raises the overall level */ + /** Could not be determined; never raises the overall level to a warning or an error */ public const LEVEL_UNKNOWN = 'unknown'; public const UPDATES = 'updates'; diff --git a/lib/OsStatus/Verdict/Verdict.php b/lib/OsStatus/Verdict/Verdict.php index c8fd0ab97..331bf5116 100644 --- a/lib/OsStatus/Verdict/Verdict.php +++ b/lib/OsStatus/Verdict/Verdict.php @@ -22,7 +22,8 @@ public function __construct( } /** - * The most severe level of any concern; "unknown" when nothing could be assessed. + * 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_* */ @@ -39,6 +40,10 @@ public function level(): string { $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; } diff --git a/tests/lib/Commands/ShowOsStatusTest.php b/tests/lib/Commands/ShowOsStatusTest.php new file mode 100644 index 000000000..cf147e32f --- /dev/null +++ b/tests/lib/Commands/ShowOsStatusTest.php @@ -0,0 +1,61 @@ + [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)); + } + + private function execute(OsStatusService $service): int { + $command = new ShowOsStatus($this->createMock(OsStatusCollector::class), $service); + $input = $this->createMock(InputInterface::class); + $input->method('getOption')->willReturnMap([['refresh', false], ['output', 'json']]); + + return (new \ReflectionMethod($command, 'execute'))->invoke($command, $input, $this->createMock(OutputInterface::class)); + } +} diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php index cbe51ef52..e8599f435 100644 --- a/tests/lib/OsStatus/OsVerdictTest.php +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -107,11 +107,17 @@ public function testUpdatesFromOldPackageListsAreMarked(): void { $this->assertFalse($pacman->get(Concern::UPDATES)?->facts['listsStale']); } - public function testUnknownUpdatesDoNotRaiseTheLevel(): void { + 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_OK, $verdict->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 { @@ -193,6 +199,7 @@ public function testApplicationContainersSkipWhatIsNotTheirs(): void { $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 { From 421cef185471fa74344bc719a944ef8183261073 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:42:51 -0400 Subject: [PATCH 04/23] fix(os): count a pending security update per package, not per version A security update superseded by a newer one before it was installed started a new first-seen entry, so the pending days, and with them the thresholds, started over. Hosts with frequently updated packages could stay below the error threshold indefinitely. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/PendingUpdate.php | 6 +++++- tests/lib/OsStatus/OsStatusCollectorTest.php | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/OsStatus/Packages/PendingUpdate.php b/lib/OsStatus/Packages/PendingUpdate.php index 728f6c053..0ab3ee084 100644 --- a/lib/OsStatus/Packages/PendingUpdate.php +++ b/lib/OsStatus/Packages/PendingUpdate.php @@ -75,8 +75,12 @@ 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 . ':' . $this->candidateVersion; + return $this->name; } public function withSince(?int $since): self { diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 4668e5635..0068b36f2 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -146,6 +146,17 @@ public function testFirstSeenSurvivesTheNextRun(): void { $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 testAContainerIsNotAskedAboutPackagesOrReboots(): void { $this->host->files['/.dockerenv'] = ''; $this->withPendingUpdates(); From e7d4ae81eaa646a10135b8974a1da94c6314eacd Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:43:18 -0400 Subject: [PATCH 05/23] fix(os): no core-library reboot for systemd or in system containers systemd's package re-executes PID 1 on upgrade, so a newer systemd binary needs no reboot; on Debian, which has no /run/reboot-required by default, every systemd update became a reboot warning. In a system container without lxcfs the uptime is the host's, so a container created after the host booted reported a reboot straight away; the probe now skips system containers. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/OsStatusCollector.php | 3 ++- lib/OsStatus/Reboot/CoreLibraryProbe.php | 3 ++- tests/lib/OsStatus/OsStatusCollectorTest.php | 16 ++++++++++++++++ tests/lib/OsStatus/ProbesTest.php | 4 ++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index a08d7a7db..3a438bf6a 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -215,7 +215,8 @@ private function rebootReasons(?IPackageManager $packageManager, Environment $en $hasDistributionAnswer = ($packageManager?->hasOwnRebootCheck() ?? false) || array_filter($reasons, static fn (RebootReason $r): bool => $r->kind === RebootReason::KIND_DISTRIBUTION) !== []; - if (!$hasDistributionAnswer && $bootTime !== null) { + // 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) as $reason) { $add($reason); } diff --git a/lib/OsStatus/Reboot/CoreLibraryProbe.php b/lib/OsStatus/Reboot/CoreLibraryProbe.php index 905f826df..18937c11f 100644 --- a/lib/OsStatus/Reboot/CoreLibraryProbe.php +++ b/lib/OsStatus/Reboot/CoreLibraryProbe.php @@ -20,6 +20,8 @@ * build time as the modification time, so only the change time says when a * file was installed. * + * systemd is not among them: its package re-executes PID 1 on upgrade. + * * @psalm-api */ class CoreLibraryProbe { @@ -31,7 +33,6 @@ class CoreLibraryProbe { '/lib64/libc.so.6', '/usr/lib64/libc.so.6', '/usr/lib/libc.so.6', '/lib/ld-musl-*.so.1', ], - 'systemd' => ['/usr/lib/systemd/systemd', '/lib/systemd/systemd'], 'dbus' => ['/usr/bin/dbus-broker', '/usr/bin/dbus-daemon'], ]; diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 0068b36f2..3a8707576 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -157,6 +157,22 @@ public function testASupersedingUpdateKeepsCounting(): void { $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(); diff --git a/tests/lib/OsStatus/ProbesTest.php b/tests/lib/OsStatus/ProbesTest.php index a0f2198d2..a03350eab 100644 --- a/tests/lib/OsStatus/ProbesTest.php +++ b/tests/lib/OsStatus/ProbesTest.php @@ -83,7 +83,7 @@ public function testNoModulesToCompareWith(): void { 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'] = 900; + $this->host->changed['/usr/lib/systemd/systemd'] = 6000; $this->host->changed['/usr/bin/dbus-daemon'] = 7000; $reasons = (new CoreLibraryProbe($this->host))->getRebootReasons(1000); @@ -93,7 +93,7 @@ public function testCoreLibrariesReplacedSinceBoot(): void { } public function testCoreLibrariesWithinTheBootSlack(): void { - $this->host->changed['/usr/lib/systemd/systemd'] = 1030; + $this->host->changed['/usr/bin/dbus-daemon'] = 1030; $this->assertSame([], (new CoreLibraryProbe($this->host))->getRebootReasons(1000)); } From 38a3e80645b9f6cd5eed82fce7b845066f297e7d Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:43:59 -0400 Subject: [PATCH 06/23] fix(os): keep each error threshold at or above its warning threshold With an error threshold below its warning threshold, a finding went straight from info to error and the warning stage disappeared. Loading the thresholds now raises each error value to its warning value. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Verdict/Thresholds.php | 10 ++++++++ .../lib/OsStatus/ThresholdsAndStatusTest.php | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/OsStatus/Verdict/Thresholds.php b/lib/OsStatus/Verdict/Thresholds.php index 5cb48feb7..24cb6b106 100644 --- a/lib/OsStatus/Verdict/Thresholds.php +++ b/lib/OsStatus/Verdict/Thresholds.php @@ -32,6 +32,13 @@ final class Thresholds { '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, @@ -49,6 +56,9 @@ public static function load(IAppConfig $appConfig): self { 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/tests/lib/OsStatus/ThresholdsAndStatusTest.php b/tests/lib/OsStatus/ThresholdsAndStatusTest.php index a53f787da..65039f88d 100644 --- a/tests/lib/OsStatus/ThresholdsAndStatusTest.php +++ b/tests/lib/OsStatus/ThresholdsAndStatusTest.php @@ -9,6 +9,7 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\OsStatus\Environment; use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\OsStatus; @@ -17,6 +18,8 @@ use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; use OCA\ServerInfo\OsStatus\Support\SupportStatus; +use OCA\ServerInfo\OsStatus\Verdict\Thresholds; +use OCP\AppFramework\Services\IAppConfig; class ThresholdsAndStatusTest extends \Test\TestCase { public function testStatusRoundTrip(): void { @@ -67,6 +70,26 @@ public function testDamagedStatusReadsAsUnknown(): void { $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']); From 03fb879e21222e1400fd819fd17f53c5d17265b0 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:44:52 -0400 Subject: [PATCH 07/23] fix(os): collect the operating system status only from system cron The commands can take minutes in the worst case. Under AJAX or webcron the background job runs inside a web request and would hold a web server worker that long, so it now does nothing unless background jobs run from system cron; occ serverinfo:os-status --refresh still works everywhere. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/Jobs/UpdateOsStatus.php | 9 ++++++- tests/lib/Jobs/UpdateOsStatusTest.php | 38 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/lib/Jobs/UpdateOsStatusTest.php diff --git a/lib/Jobs/UpdateOsStatus.php b/lib/Jobs/UpdateOsStatus.php index 585edebbb..b0f1561cb 100644 --- a/lib/Jobs/UpdateOsStatus.php +++ b/lib/Jobs/UpdateOsStatus.php @@ -22,13 +22,16 @@ * Time sensitive on purpose: a maintenance window would hold a pending * security update back from the admin for most of a day. * + * Runs only from system cron: the commands may take minutes, which under + * AJAX or webcron would hold a web server worker. + * * @psalm-api */ class UpdateOsStatus extends TimedJob { public function __construct( ITimeFactory $time, private OsStatusCollector $collector, - IAppConfig $appConfig, + private IAppConfig $appConfig, ) { parent::__construct($time); $this->setInterval(max(300, $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::JOB_INTERVAL_OS_STATUS))); @@ -36,6 +39,10 @@ public function __construct( #[\Override] protected function run($argument): void { + if ($this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax') !== 'cron') { + return; + } + $this->collector->collectAndStore(); } } diff --git a/tests/lib/Jobs/UpdateOsStatusTest.php b/tests/lib/Jobs/UpdateOsStatusTest.php new file mode 100644 index 000000000..59b56d369 --- /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); + } +} From 52d55c0d012f6fde63dd84297dbcea4d5dc323c3 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:45:11 -0400 Subject: [PATCH 08/23] fix(os): no support dates for Raspbian or NixOS unstable Raspbian is a separate archive with its own security handling, so Debian's end dates do not necessarily apply; its support is unknown now. NixOS has ID=nixos in every channel, so "nixos-unstable" never matched and is gone from the rolling releases. /dev/shm/ was already covered by /dev/ among the ignored PHP mappings. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- build/update-os-support.php | 2 +- lib/OsStatus/Restart/PhpProcessProbe.php | 2 +- lib/OsStatus/Support/SupportTable.php | 1 - resources/os-support.json | 3 +-- tests/lib/OsStatus/SupportTableTest.php | 2 ++ 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build/update-os-support.php b/build/update-os-support.php index ee6312fe9..b16439f2e 100644 --- a/build/update-os-support.php +++ b/build/update-os-support.php @@ -41,7 +41,7 @@ const ROLLING = [ 'arch', 'archarm', 'manjaro', 'endeavouros', 'cachyos', 'garuda', 'opensuse-tumbleweed', 'opensuse-microos', 'opensuse-slowroll', - 'gentoo', 'void', 'nixos-unstable', + 'gentoo', 'void', ]; /** Releases whose support ended before this are left out; nothing that old runs a current Nextcloud. */ diff --git a/lib/OsStatus/Restart/PhpProcessProbe.php b/lib/OsStatus/Restart/PhpProcessProbe.php index 4bebf55bc..fc02d79e5 100644 --- a/lib/OsStatus/Restart/PhpProcessProbe.php +++ b/lib/OsStatus/Restart/PhpProcessProbe.php @@ -25,7 +25,7 @@ */ class PhpProcessProbe { /** Mappings that are deleted by design rather than by an upgrade. */ - private const IGNORED_PREFIXES = ['/dev/', '/memfd:', '/tmp/', '/var/tmp/', '/run/', '/dev/shm/', '/SYSV', '/proc/', '/var/lib/php', '/var/cache/']; + private const IGNORED_PREFIXES = ['/dev/', '/memfd:', '/tmp/', '/var/tmp/', '/run/', '/SYSV', '/proc/', '/var/lib/php', '/var/cache/']; /** Report at most this many libraries; the restart fixes all of them. */ private const MAX_LIBRARIES = 20; diff --git a/lib/OsStatus/Support/SupportTable.php b/lib/OsStatus/Support/SupportTable.php index b91fe72da..8f49e9d23 100644 --- a/lib/OsStatus/Support/SupportTable.php +++ b/lib/OsStatus/Support/SupportTable.php @@ -77,7 +77,6 @@ private function isRolling(OsRelease $release, array $rolling): bool { private function tableId(OsRelease $release): string { return match (true) { $release->id === 'centos' && stripos($release->name, 'stream') !== false => 'centos-stream', - $release->id === 'raspbian' => 'debian', default => $release->id, }; } diff --git a/resources/os-support.json b/resources/os-support.json index 312cfe2fa..5fff65f4d 100644 --- a/resources/os-support.json +++ b/resources/os-support.json @@ -27,8 +27,7 @@ "opensuse-microos", "opensuse-slowroll", "gentoo", - "void", - "nixos-unstable" + "void" ], "releases": { "almalinux:8": { diff --git a/tests/lib/OsStatus/SupportTableTest.php b/tests/lib/OsStatus/SupportTableTest.php index 1d6e3fdcc..faf505728 100644 --- a/tests/lib/OsStatus/SupportTableTest.php +++ b/tests/lib/OsStatus/SupportTableTest.php @@ -68,6 +68,8 @@ 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 { From 635481df28827d4601187500df9fbb33223aebc3 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:45:23 -0400 Subject: [PATCH 09/23] docs(os): list what is read and run, the exit code 3 and the cron requirement Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1dec6d4f7..4a464f5aa 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,26 @@ Debian and Ubuntu are supported. Inside Docker, Podman, Kubernetes or a snap the 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. - -The status is collected by a background job once an hour. Admins can also -check it right away with: +Nothing is installed or changed. To find out, the app reads `/etc/os-release`, +`/proc` (uptime, `/proc/1/cgroup`, its own process maps), `/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. Admins can also check it right away with: ``` occ serverinfo:os-status [--refresh] [--output=json] ``` -The command exits with 0, 1 or 2 for ok, warning and error, so it can be used -as a monitoring check. When something counts as a warning or an error is set by +The command 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 From aeb0016b81f14ae5defa632d905421b58fd20970 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 16:46:17 -0400 Subject: [PATCH 10/23] fix(build): regenerate the license files from the locked dependencies The license files listed @vitejs/plugin-vue 6.0.10 and the lock file had follow-redirects downgraded to 1.16.0, both from a node_modules out of sync with the lock file. The lock file is master's again with only the version bump, and npm ci && npm run build regenerated the license files. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- js/serverinfo-main.mjs.license | 2 +- js/serverinfo-main.mjs.map.license | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/serverinfo-main.mjs.license b/js/serverinfo-main.mjs.license index ce3f1a3ae..1037a3dd0 100644 --- a/js/serverinfo-main.mjs.license +++ b/js/serverinfo-main.mjs.license @@ -66,7 +66,7 @@ This file is generated from multiple sources. Included packages: - version: 4.1.0 - license: MIT - @vitejs/plugin-vue - - version: 6.0.10 + - version: 6.0.7 - license: MIT - @vue/reactivity - version: 3.5.43 diff --git a/js/serverinfo-main.mjs.map.license b/js/serverinfo-main.mjs.map.license index ce3f1a3ae..1037a3dd0 100644 --- a/js/serverinfo-main.mjs.map.license +++ b/js/serverinfo-main.mjs.map.license @@ -66,7 +66,7 @@ This file is generated from multiple sources. Included packages: - version: 4.1.0 - license: MIT - @vitejs/plugin-vue - - version: 6.0.10 + - version: 6.0.7 - license: MIT - @vue/reactivity - version: 3.5.43 From 2451a3360ac1d1f338451146f3b71d6f384ca712 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 18:16:47 -0400 Subject: [PATCH 11/23] fix(os): never count interrupted command output as complete When stream_select() failed, for example because a signal interrupted it, the runner left its read loop as if the command had closed its output. The process was not killed, the result was neither timed out nor truncated, and a cut-off apt-get simulation counted as a complete list of pending updates. A failed wait is now retried, and after three failures in a row the process is killed and the result is marked as unreadable, which succeeded() treats as a failure like a timeout or truncation. CommandResult::failure() names what went wrong: the time limit, the output cap, the failed read, or else the first line the command printed. apt's diagnostic uses it, so a timeout no longer shows whatever line apt had printed before it was killed. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Host/CommandResult.php | 26 +++++++++++- lib/OsStatus/Host/CommandRunner.php | 34 ++++++++++++--- lib/OsStatus/Packages/Apt.php | 11 ++--- tests/lib/OsStatus/AptTest.php | 20 +++++++++ tests/lib/OsStatus/CommandRunnerTest.php | 54 ++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/lib/OsStatus/Host/CommandResult.php b/lib/OsStatus/Host/CommandResult.php index afff5b498..77ec1dac0 100644 --- a/lib/OsStatus/Host/CommandResult.php +++ b/lib/OsStatus/Host/CommandResult.php @@ -21,6 +21,8 @@ public function __construct( public readonly bool $timedOut = false, /** The output was longer than the runner keeps, so it is cut off */ public readonly bool $truncated = false, + /** Waiting for output failed, so the output may be incomplete */ + public readonly bool $readFailed = false, ) { } @@ -29,6 +31,28 @@ public function __construct( * worse than none. */ public function succeeded(): bool { - return $this->exitCode === 0 && !$this->timedOut && !$this->truncated; + return $this->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 index 91f221730..16d3237c5 100644 --- a/lib/OsStatus/Host/CommandRunner.php +++ b/lib/OsStatus/Host/CommandRunner.php @@ -27,6 +27,9 @@ class CommandRunner { /** How long a process that closed its output is given to exit before it is killed. */ private const EXIT_GRACE_SECONDS = 2.0; + /** Waiting for output may be interrupted by a signal; failing this often in a row is an error. */ + private const MAX_SELECT_FAILURES = 3; + public function isAvailable(): bool { return function_exists('proc_open') && function_exists('proc_get_status') @@ -141,6 +144,8 @@ private function execute(string $path, array $arguments, int $timeout): ?Command $deadline = microtime(true) + (float)$timeout; $timedOut = false; $truncated = false; + $readFailed = false; + $selectFailures = 0; while ($open !== []) { $remaining = $deadline - microtime(true); @@ -150,13 +155,14 @@ private function execute(string $path, array $arguments, int $timeout): ?Command } $read = array_values($open); - $write = null; - $except = null; - $seconds = (int)$remaining; - $ready = @stream_select($read, $write, $except, $seconds, (int)(($remaining - (float)$seconds) * 1000000.0)); - if ($ready === false) { + 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); @@ -185,9 +191,23 @@ private function execute(string $path, array $arguments, int $timeout): ?Command fclose($stream); } - $exitCode = $this->finish($process, $timedOut || $truncated); + $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 new CommandResult($exitCode, $output[1], $output[2], $timedOut, $truncated); + return @stream_select($read, $write, $except, $seconds, (int)(($timeout - (float)$seconds) * 1000000.0)); } /** diff --git a/lib/OsStatus/Packages/Apt.php b/lib/OsStatus/Packages/Apt.php index 630472d8d..6cef41d1b 100644 --- a/lib/OsStatus/Packages/Apt.php +++ b/lib/OsStatus/Packages/Apt.php @@ -27,6 +27,7 @@ class Apt implements IPackageManager { private const REBOOT_REQUIRED = '/run/reboot-required'; private const DPKG_LOG = '/var/log/dpkg.log'; private const DPKG_STATUS = '/var/lib/dpkg/status'; + private const SIMULATE_TIMEOUT = 120; public function __construct( private IHost $host, @@ -51,12 +52,12 @@ public function classifiesSecurity(): bool { #[\Override] public function getPendingUpdates(): array { - $result = $this->host->run('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], 120); + $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: ' . self::firstLine($result->stderr ?: $result->stdout)); + throw new ProbeException('apt-get -s dist-upgrade failed: ' . $result->failure(self::SIMULATE_TIMEOUT)); } return self::parseSimulation($result->stdout); @@ -264,10 +265,4 @@ public function getUpgradeCommands(): array { public function getPackageDatabasePath(): ?string { return self::DPKG_STATUS; } - - private static function firstLine(string $text): string { - $line = trim((string)strtok(trim($text), "\n")); - - return mb_substr($line, 0, 200); - } } diff --git a/tests/lib/OsStatus/AptTest.php b/tests/lib/OsStatus/AptTest.php index 0667dc5c8..92b1c4ff7 100644 --- a/tests/lib/OsStatus/AptTest.php +++ b/tests/lib/OsStatus/AptTest.php @@ -10,10 +10,12 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\OsStatus\Packages\Apt; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\ProbeException; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; +use PHPUnit\Framework\Attributes\DataProvider; class AptTest extends \Test\TestCase { private const SIMULATE = ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade']; @@ -69,6 +71,24 @@ public function testFailuresAreReported(): void { $this->apt->getPendingUpdates(); } + public static function dataRunnerFailures(): array { + return [ + 'time limit' => [new CommandResult(137, "Inst libssl3 [1] (2 Debian-Security:12/stable-security [amd64])\n", '', timedOut: true), 'apt-get -s dist-upgrade failed: timed out after 120 s'], + 'output cap' => [new CommandResult(137, "Inst libssl3 [1] (2 Debian-Security:12/stable-security [amd64])\n", '', truncated: true), 'apt-get -s dist-upgrade failed: output too large'], + 'no output' => [new CommandResult(100, ''), 'apt-get -s dist-upgrade failed: exit code 100'], + ]; + } + + #[DataProvider('dataRunnerFailures')] + public function testRunnerFailuresSayWhatHappened(CommandResult $result, string $message): void { + $this->host->withCommand('apt-get', self::SIMULATE, ''); + $this->host->commands['apt-get ' . implode(' ', self::SIMULATE)] = $result; + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage($message); + $this->apt->getPendingUpdates(); + } + public function testMissingAptGetIsReported(): void { $this->expectException(ProbeException::class); $this->apt->getPendingUpdates(); diff --git a/tests/lib/OsStatus/CommandRunnerTest.php b/tests/lib/OsStatus/CommandRunnerTest.php index fa857c971..d0a13e322 100644 --- a/tests/lib/OsStatus/CommandRunnerTest.php +++ b/tests/lib/OsStatus/CommandRunnerTest.php @@ -54,6 +54,60 @@ public function testSlowCommandsAreKilled(): void { $this->assertLessThan(10, microtime(true) - $start); } + public function testOutputBeyondTheCapIsCutOffAndFails(): void { + $result = $this->runner->run('sh', ['-c', 'dd if=/dev/zero bs=1048576 count=9 2>/dev/null'], 30); + + $this->assertTrue($result?->truncated); + $this->assertSame(8 * 1024 * 1024, strlen($result->stdout)); + $this->assertFalse($result->succeeded()); + $this->assertSame('output too large', $result->failure(30)); + } + + public function testWaitingThatKeepsFailingIsAFailure(): void { + $runner = new class extends CommandRunner { + #[\Override] + protected function select(array &$read, float $timeout): int|false { + return false; + } + }; + + $start = microtime(true); + $result = $runner->run('sh', ['-c', 'echo out; sleep 30'], 20); + + $this->assertTrue($result?->readFailed); + $this->assertFalse($result->succeeded()); + $this->assertSame('output could not be read', $result->failure(20)); + $this->assertLessThan(10, microtime(true) - $start, 'The process is killed, not waited for'); + } + + public function testAnInterruptedWaitIsRetried(): void { + $runner = new class extends CommandRunner { + private bool $interrupted = false; + + #[\Override] + protected function select(array &$read, float $timeout): int|false { + if (!$this->interrupted) { + $this->interrupted = true; + + return false; + } + + return parent::select($read, $timeout); + } + }; + + $result = $runner->run('sh', ['-c', 'echo out'], 10); + + $this->assertSame("out\n", $result?->stdout); + $this->assertTrue($result->succeeded()); + } + + public function testFailureReasons(): void { + $this->assertSame('timed out after 120 s', $this->runner->run('sh', ['-c', 'sleep 30'], 1)?->failure(120)); + $this->assertSame('E: broken', $this->runner->run('sh', ['-c', 'echo out; printf "E: broken\\nmore\\n" >&2; exit 100'], 10)?->failure(10)); + $this->assertSame('exit code 4', $this->runner->run('sh', ['-c', 'exit 4'], 10)?->failure(10)); + } + public function testUnknownBinaries(): void { $this->assertNull($this->runner->run('serverinfo-no-such-binary', [], 5)); $this->assertNull($this->runner->run('../../bin/sh', [], 5)); From bac4a5042ce393228022180b6d56895125c5098e Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 22:43:38 -0400 Subject: [PATCH 12/23] fix(os): try apt when open_basedir hides it, and remember missing binaries Under open_basedir the system directories cannot be inspected, so hasBinary('apt-get') was false and apt was never found, although processes can still be started from those directories. Each later lookup of a missing binary also tried every search directory again, up to six forks per call. IHost::binaryState() answers true, false, or null when only running the binary can tell. hasBinary() keeps meaning "known to be there". apt is available unless it is known to be missing, so the factory picks it and running it gives the answer. CommandRunner remembers per binary the path it ran from, or that it is missing once every guess failed to start, so a missing binary costs its forks once per process. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Host/CommandRunner.php | 58 ++++++++++++++++--- lib/OsStatus/Host/IHost.php | 12 ++++ lib/OsStatus/Host/LocalHost.php | 5 ++ lib/OsStatus/Packages/Apt.php | 2 +- lib/OsStatus/Packages/IPackageManager.php | 4 ++ .../Packages/PackageManagerFactory.php | 2 +- tests/lib/OsStatus/AptTest.php | 10 ++++ tests/lib/OsStatus/CommandRunnerTest.php | 37 ++++++++++++ tests/lib/OsStatus/FakeHost.php | 12 ++++ tests/lib/OsStatus/OsStatusCollectorTest.php | 13 +++++ 10 files changed, 144 insertions(+), 11 deletions(-) diff --git a/lib/OsStatus/Host/CommandRunner.php b/lib/OsStatus/Host/CommandRunner.php index 16d3237c5..8cc430727 100644 --- a/lib/OsStatus/Host/CommandRunner.php +++ b/lib/OsStatus/Host/CommandRunner.php @@ -30,6 +30,9 @@ class CommandRunner { /** Waiting for output may be interrupted by a signal; failing this often in a row is an error. */ private const MAX_SELECT_FAILURES = 3; + /** @var array 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') @@ -38,13 +41,19 @@ public function isAvailable(): bool { } public function resolve(string $binary): ?string { - foreach ($this->candidates($binary) as [$path, $verified]) { - if ($verified) { - return $path; - } - } + $found = $this->found[$binary] ?? $this->lookUp($binary); - return null; + 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); } /** @@ -55,7 +64,15 @@ public function run(string $binary, array $arguments, int $timeout): ?CommandRes return null; } - foreach ($this->candidates($binary) as [$path, $verified]) { + $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; @@ -67,12 +84,35 @@ public function run(string $binary, array $arguments, int $timeout): ?CommandRes 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. * @@ -115,14 +155,14 @@ private function isExecutable(string $path): bool { return @is_file($path) && @is_executable($path); } - private function hasOpenBasedir(): bool { + protected function hasOpenBasedir(): bool { return trim((string)ini_get('open_basedir')) !== ''; } /** * @param list $arguments */ - private function execute(string $path, array $arguments, int $timeout): ?CommandResult { + protected function execute(string $path, array $arguments, int $timeout): ?CommandResult { $descriptors = [ 0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], diff --git a/lib/OsStatus/Host/IHost.php b/lib/OsStatus/Host/IHost.php index 90eba25b8..a8bf9cfde 100644 --- a/lib/OsStatus/Host/IHost.php +++ b/lib/OsStatus/Host/IHost.php @@ -15,6 +15,8 @@ * Every method answers "unknown" (null, false or an empty list) instead of * throwing, because a web server is routinely denied parts of the system: * open_basedir, ProcSubset=pid, disabled functions, missing binaries. + * + * @psalm-api */ interface IHost { /** @@ -60,8 +62,18 @@ public function glob(string $pattern): array; */ 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; diff --git a/lib/OsStatus/Host/LocalHost.php b/lib/OsStatus/Host/LocalHost.php index 50d7c40cf..f6a95675a 100644 --- a/lib/OsStatus/Host/LocalHost.php +++ b/lib/OsStatus/Host/LocalHost.php @@ -107,6 +107,11 @@ 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(); diff --git a/lib/OsStatus/Packages/Apt.php b/lib/OsStatus/Packages/Apt.php index 6cef41d1b..9cd3b5941 100644 --- a/lib/OsStatus/Packages/Apt.php +++ b/lib/OsStatus/Packages/Apt.php @@ -42,7 +42,7 @@ public function getId(): string { #[\Override] public function isAvailable(): bool { - return $this->host->hasBinary('apt-get'); + return $this->host->binaryState('apt-get') !== false; } #[\Override] diff --git a/lib/OsStatus/Packages/IPackageManager.php b/lib/OsStatus/Packages/IPackageManager.php index 570b2ed49..67eabd092 100644 --- a/lib/OsStatus/Packages/IPackageManager.php +++ b/lib/OsStatus/Packages/IPackageManager.php @@ -23,6 +23,10 @@ interface IPackageManager { */ public function getId(): string; + /** + * False only when the tool is known to be missing; when open_basedir + * hides it, it is assumed to be there and running it tells. + */ public function isAvailable(): bool; /** diff --git a/lib/OsStatus/Packages/PackageManagerFactory.php b/lib/OsStatus/Packages/PackageManagerFactory.php index 9609e5c3e..df0ddfe16 100644 --- a/lib/OsStatus/Packages/PackageManagerFactory.php +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -13,7 +13,7 @@ /** * Picks the package manager of a distribution from its os-release identity, - * and only then checks that the tool is actually installed. + * and only then checks that the tool is not known to be missing. * * @psalm-api */ diff --git a/tests/lib/OsStatus/AptTest.php b/tests/lib/OsStatus/AptTest.php index 92b1c4ff7..28c013287 100644 --- a/tests/lib/OsStatus/AptTest.php +++ b/tests/lib/OsStatus/AptTest.php @@ -89,6 +89,16 @@ public function testRunnerFailuresSayWhatHappened(CommandResult $result, string $this->apt->getPendingUpdates(); } + public function testAvailability(): void { + $this->assertFalse($this->apt->isAvailable(), 'Known to be missing'); + + $this->host->hiddenBinaries = ['apt-get']; + $this->assertTrue($this->apt->isAvailable(), 'Hidden by open_basedir, so it is tried'); + + $this->host->binaries = ['apt-get']; + $this->assertTrue($this->apt->isAvailable()); + } + public function testMissingAptGetIsReported(): void { $this->expectException(ProbeException::class); $this->apt->getPendingUpdates(); diff --git a/tests/lib/OsStatus/CommandRunnerTest.php b/tests/lib/OsStatus/CommandRunnerTest.php index d0a13e322..3d032dfdd 100644 --- a/tests/lib/OsStatus/CommandRunnerTest.php +++ b/tests/lib/OsStatus/CommandRunnerTest.php @@ -9,6 +9,7 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\OsStatus\Host\CommandRunner; /** @@ -108,6 +109,42 @@ public function testFailureReasons(): void { $this->assertSame('exit code 4', $this->runner->run('sh', ['-c', 'exit 4'], 10)?->failure(10)); } + public function testBinaryStates(): void { + $this->assertTrue($this->runner->state('sh')); + $this->assertFalse($this->runner->state('serverinfo-no-such-binary')); + } + + public function testUnderOpenBasedirAMissingBinaryIsTriedOnce(): void { + $runner = new class extends CommandRunner { + public int $started = 0; + + #[\Override] + protected function hasOpenBasedir(): bool { + return true; + } + + #[\Override] + protected function execute(string $path, array $arguments, int $timeout): ?CommandResult { + $this->started++; + + return parent::execute($path, $arguments, $timeout); + } + }; + + $this->assertNull($runner->state('serverinfo-no-such-binary'), 'Hidden directories cannot tell'); + $this->assertNull($runner->run('serverinfo-no-such-binary', [], 5)); + $started = $runner->started; + $this->assertGreaterThan(0, $started); + + $this->assertFalse($runner->state('serverinfo-no-such-binary'), 'Running it told'); + $this->assertNull($runner->run('serverinfo-no-such-binary', [], 5)); + $this->assertSame($started, $runner->started, 'A missing binary is not tried again'); + + $this->assertSame("ok\n", $runner->run('sh', ['-c', 'echo ok'], 5)?->stdout); + $this->assertSame("ok\n", $runner->run('sh', ['-c', 'echo ok'], 5)?->stdout); + $this->assertSame($started + 2, $runner->started, 'A found binary runs from where it was found'); + } + public function testUnknownBinaries(): void { $this->assertNull($this->runner->run('serverinfo-no-such-binary', [], 5)); $this->assertNull($this->runner->run('../../bin/sh', [], 5)); diff --git a/tests/lib/OsStatus/FakeHost.php b/tests/lib/OsStatus/FakeHost.php index 27d0b8354..489d7296e 100644 --- a/tests/lib/OsStatus/FakeHost.php +++ b/tests/lib/OsStatus/FakeHost.php @@ -37,6 +37,8 @@ class FakeHost implements IHost { 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 */ @@ -131,6 +133,16 @@ 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; diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 3a8707576..c869cf66f 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -212,6 +212,19 @@ public function testNoUpdatesWithRefreshedPackageLists(): void { $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 testWithoutProcesses(): void { $this->host->commandsAllowed = false; From 7948a5e3a66a0451c1736053d8a70deab471cc95 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 22:44:34 -0400 Subject: [PATCH 13/23] fix(os): take the package list age from verified files, not lists/partial apt-get update writes into lists/partial on every run, also when it fails. With an expired key on the security mirror every refresh failed, yet the lists counted as refreshed today and no security updates were pending. The age now comes from update-success-stamp, which a hook touches only after a successful update. Where that hook is not installed, as on Debian 12 and a plain Ubuntu 26.04 server, the newest InRelease (or Release) file is used: apt moves it into place only after verifying it, with the mirror's modification time, so the lists were refreshed then at the earliest. The new fixture is the www-data view of /var/lib/apt/lists on an up-to-date Debian 12 container, from the same capture run as the other Debian 12 fixtures. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Apt.php | 27 ++++++++--- tests/data/os/debian-12-ls-apt-lists | 18 ++++++++ tests/lib/OsStatus/AptTest.php | 48 ++++++++++++++++++-- tests/lib/OsStatus/OsStatusCollectorTest.php | 4 +- 4 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 tests/data/os/debian-12-ls-apt-lists diff --git a/lib/OsStatus/Packages/Apt.php b/lib/OsStatus/Packages/Apt.php index 9cd3b5941..0ef2dcf04 100644 --- a/lib/OsStatus/Packages/Apt.php +++ b/lib/OsStatus/Packages/Apt.php @@ -28,6 +28,8 @@ class Apt implements IPackageManager { private const DPKG_LOG = '/var/log/dpkg.log'; private const DPKG_STATUS = '/var/lib/dpkg/status'; private const SIMULATE_TIMEOUT = 120; + private const LISTS = '/var/lib/apt/lists'; + private const UPDATE_SUCCESS_STAMP = '/var/lib/apt/periodic/update-success-stamp'; public function __construct( private IHost $host, @@ -117,16 +119,27 @@ public function getHeldPackages(): ?array { } /** - * `apt-get update` writes into lists/partial every time it runs, whether - * or not anything changed upstream; the InRelease files themselves carry - * the mirror's timestamp, not ours. + * 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 { - $times = array_filter([ - $this->host->modifiedAt('/var/lib/apt/lists/partial'), - $this->host->modifiedAt('/var/lib/apt/periodic/update-success-stamp'), - ], 'is_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); } 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 000000000..fa1903758 --- /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/lib/OsStatus/AptTest.php b/tests/lib/OsStatus/AptTest.php index 28c013287..e5aff619a 100644 --- a/tests/lib/OsStatus/AptTest.php +++ b/tests/lib/OsStatus/AptTest.php @@ -168,11 +168,51 @@ public function testNoRebootRequired(): void { $this->assertSame([], $this->apt->getRebootReasons()); } - public function testListsRefreshTimeComesFromAptsOwnFiles(): void { - $this->host->modified['/var/lib/apt/lists/partial'] = 1760400000; - $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = 1760300000; + /** + * Fills the host with the timestamps of a real `ls -la --time-style=+%s` + * of /var/lib/apt/lists. + */ + private function listsFromFixture(string $name): void { + foreach (preg_split('/\R/', FakeHost::fixture($name)) ?: [] as $line) { + if (preg_match('/^[-d]\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(\d+)\s+(\S+)$/', $line, $matches) !== 1 || in_array($matches[2], ['.', '..'], true)) { + continue; + } - $this->assertSame(1760400000, $this->apt->getListsRefreshedAt()); + $path = '/var/lib/apt/lists/' . $matches[2]; + $this->host->modified[$path] = (int)$matches[1]; + foreach (['*InRelease', '*_Release'] as $pattern) { + if (fnmatch($pattern, $matches[2])) { + $this->host->globs['/var/lib/apt/lists/' . $pattern][] = $path; + } + } + } + } + + public function testListsRefreshTimeIsTheSuccessStamp(): void { + $this->listsFromFixture('debian-12-ls-apt-lists'); + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = 1792430940; + + $this->assertSame(1792430940, $this->apt->getListsRefreshedAt()); + } + + public function testListsRefreshTimeFallsBackToTheNewestReleaseFile(): void { + $this->listsFromFixture('debian-12-ls-apt-lists'); + + $this->assertSame(1792418975, $this->apt->getListsRefreshedAt(), 'bookworm-updates, not the newer lists/partial'); + } + + public function testListsRefreshTimeFromReleaseFilesWithoutInRelease(): void { + $this->host->globs['/var/lib/apt/lists/*_Release'] = ['/var/lib/apt/lists/mirror.example.org_debian_dists_bookworm_Release']; + $this->host->modified['/var/lib/apt/lists/mirror.example.org_debian_dists_bookworm_Release'] = 1792000000; + $this->host->modified['/var/lib/apt/lists/partial'] = 1792400000; + + $this->assertSame(1792000000, $this->apt->getListsRefreshedAt()); + } + + public function testNoListsNoRefreshTime(): void { + $this->host->modified['/var/lib/apt/lists/partial'] = 1792400000; + + $this->assertNull($this->apt->getListsRefreshedAt(), 'A failing apt-get update still writes lists/partial'); } public function testLastUpgradeIsReadInTheSystemTimeZone(): void { diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index c869cf66f..2643e8b38 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -117,7 +117,7 @@ public function testAnUbuntuHost(): void { ->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/lists/partial'] = self::NOW - 3600; + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = self::NOW - 3600; $status = $this->collector()->collect(); @@ -207,7 +207,7 @@ public function testMissingPackageListsLeaveTheUpdatesUnknown(): void { public function testNoUpdatesWithRefreshedPackageLists(): void { $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], ''); - $this->host->modified['/var/lib/apt/lists/partial'] = self::NOW - 3600; + $this->host->modified['/var/lib/apt/periodic/update-success-stamp'] = self::NOW - 3600; $this->assertSame([], $this->collector()->collect()->updates); } From 9fd93ca4252e4f22e1b20ac13c31ded3a28541c7 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 22:45:38 -0400 Subject: [PATCH 14/23] fix(os): no permanent "kernel removed" for provider and netboot kernels KernelProbe reported "kernel removed" whenever the running kernel had no directory in /lib/modules. A VPS that boots the provider's kernel, or a netbooted machine, never has one, so it got a reboot warning that a reboot could not clear and that became an error after 30 days. A missing running kernel is now a reason only when an installed kernel has the same flavour, which is the Arch case of modules deleted on upgrade, or when the newest installed kernel was installed after the boot. A distribution kernel upgraded on such a provider host then asks for a reboot until the next boot, and is quiet after it. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/OsStatusCollector.php | 2 +- lib/OsStatus/Reboot/KernelProbe.php | 47 +++++++++++++++++++++-------- tests/lib/OsStatus/ProbesTest.php | 26 ++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index 3a438bf6a..eb6c54588 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -209,7 +209,7 @@ private function rebootReasons(?IPackageManager $packageManager, Environment $en foreach ($packageManager?->getRebootReasons() ?? [] as $reason) { $add($reason); } - foreach ($this->kernelProbe->getRebootReasons() as $reason) { + foreach ($this->kernelProbe->getRebootReasons($bootTime) as $reason) { $add($reason); } diff --git a/lib/OsStatus/Reboot/KernelProbe.php b/lib/OsStatus/Reboot/KernelProbe.php index b06fb2886..b32cbd29a 100644 --- a/lib/OsStatus/Reboot/KernelProbe.php +++ b/lib/OsStatus/Reboot/KernelProbe.php @@ -22,15 +22,19 @@ class KernelProbe { private const MODULE_ROOTS = ['/lib/modules', '/usr/lib/modules']; + /** Early boot can touch files before the clock settles. */ + private const BOOT_SLACK = 60; + public function __construct( private IHost $host, ) { } /** + * @param int|null $bootTime unix timestamp of the last boot, null when unknown * @return list */ - public function getRebootReasons(): array { + public function getRebootReasons(?int $bootTime = null): array { $running = trim($this->host->uname('r')); $installed = $this->installedKernels(); if ($running === '' || $installed === []) { @@ -38,23 +42,16 @@ public function getRebootReasons(): array { return []; } - if (!isset($installed[$running])) { - // Arch and others delete the running kernel's modules on upgrade. - $newest = $this->newest(array_keys($installed)); - - return [new RebootReason( - RebootReason::KIND_KERNEL_REMOVED, - $newest, - [], - $newest !== null ? $installed[$newest] : null, - )]; - } - $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 []; @@ -63,6 +60,30 @@ public function getRebootReasons(): array { 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. * diff --git a/tests/lib/OsStatus/ProbesTest.php b/tests/lib/OsStatus/ProbesTest.php index a03350eab..0e42d8de0 100644 --- a/tests/lib/OsStatus/ProbesTest.php +++ b/tests/lib/OsStatus/ProbesTest.php @@ -76,6 +76,32 @@ public function testRunningKernelWasRemoved(): void { $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()); } From 6eefcad9af048ab0d6e6b10450baa47bd1fa5441 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 22:46:25 -0400 Subject: [PATCH 15/23] fix(os): require a package change before a core library asks for a reboot CoreLibraryProbe took a libc or dbus inode change time after boot as an upgrade. A chmod or chown of the file, or a restore from a backup, also sets the change time, and gave a reboot reason that only a reboot cleared. The modification time is no alternative: dpkg keeps the build time there. A changed file now also needs its package to have changed after boot. dpkg rewrites /var/lib/dpkg/info/.list on every install of a package, so its mtime says when libc6 or the dbus package was last installed. Without such a list, the package manager's database file must have changed after boot. Only where neither exists does the change time decide alone. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/OsStatusCollector.php | 2 +- lib/OsStatus/Reboot/CoreLibraryProbe.php | 52 +++++++++++++++++++++--- tests/lib/OsStatus/ProbesTest.php | 31 ++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index eb6c54588..a2aff2fc8 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -217,7 +217,7 @@ private function rebootReasons(?IPackageManager $packageManager, Environment $en || 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) as $reason) { + foreach ($this->coreLibraryProbe->getRebootReasons($bootTime, $packageManager?->getPackageDatabasePath()) as $reason) { $add($reason); } } diff --git a/lib/OsStatus/Reboot/CoreLibraryProbe.php b/lib/OsStatus/Reboot/CoreLibraryProbe.php index 18937c11f..5a56ed998 100644 --- a/lib/OsStatus/Reboot/CoreLibraryProbe.php +++ b/lib/OsStatus/Reboot/CoreLibraryProbe.php @@ -18,7 +18,10 @@ * such a tool. A package upgrade writes each file under a new name and renames * it into place, which sets the inode change time; package managers keep the * build time as the modification time, so only the change time says when a - * file was installed. + * file was installed. A chmod, chown or restore from a backup sets the change + * time as well, so the package itself has to have changed since boot too: + * dpkg rewrites a package's file list on every install, and otherwise the + * package database must have changed. * * systemd is not among them: its package re-executes PID 1 on upgrade. * @@ -36,6 +39,17 @@ class CoreLibraryProbe { '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, ) { @@ -43,9 +57,10 @@ public function __construct( /** * @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): array { + public function getRebootReasons(int $bootTime, ?string $packageDatabase = null): array { $changed = []; $since = null; @@ -56,11 +71,18 @@ public function getRebootReasons(int $bootTime): array { } $changedAt = $this->host->changedAt($file); - // A minute of slack: early boot can touch files before the clock settles. - if ($changedAt !== null && $changedAt > $bootTime + 60) { - $changed[] = $component; - $since = $since === null ? $changedAt : min($since, $changedAt); + 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 === []) { @@ -70,6 +92,24 @@ public function getRebootReasons(int $bootTime): array { 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 */ diff --git a/tests/lib/OsStatus/ProbesTest.php b/tests/lib/OsStatus/ProbesTest.php index 0e42d8de0..8f70f2a39 100644 --- a/tests/lib/OsStatus/ProbesTest.php +++ b/tests/lib/OsStatus/ProbesTest.php @@ -118,6 +118,37 @@ public function testCoreLibrariesReplacedSinceBoot(): void { $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; From ed28f95cbc3cc16b9dfe8b419641b4e30d596602 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 22:46:58 -0400 Subject: [PATCH 16/23] fix(os): keep the PHP process's first sighting under its own key savePhpSample() in web requests and firstSeen() in the background job both read, changed and wrote cached_os_first_seen without a lock. When they overlapped, one write dropped the other's entries, the "days pending" count of updates and reboots started over, and warnings came later than the thresholds say. When web requests first saw outdated PHP libraries is now kept in cached_os_php_outdated_since, so cached_os_first_seen has a single writer, the collector. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/Config/ConfigLexicon.php | 10 +++++++++- lib/OsStatus/OsStatusStore.php | 16 ++++++++++++---- tests/lib/OsStatus/OsStatusStoreTest.php | 23 +++++++++++++++++++++-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 00f71a407..80137c648 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -31,6 +31,7 @@ class ConfigLexicon implements ILexicon { public const CACHED_OS_STATUS = 'cached_os_status'; 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'; @@ -95,7 +96,7 @@ public function getAppConfigs(): array { self::CACHED_OS_FIRST_SEEN, ValueType::ARRAY, [], - 'When each pending security update, reboot reason and restart was first seen, for the alert thresholds', + 'When each pending security update and reboot reason was first seen, for the alert thresholds', lazy: true, ), new Entry( @@ -105,6 +106,13 @@ public function getAppConfigs(): 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, diff --git a/lib/OsStatus/OsStatusStore.php b/lib/OsStatus/OsStatusStore.php index 817219455..1fb1bdddb 100644 --- a/lib/OsStatus/OsStatusStore.php +++ b/lib/OsStatus/OsStatusStore.php @@ -52,7 +52,13 @@ public function savePhpSample(PhpProcessSample $sample): void { } $this->appConfig->setAppValueArray(ConfigLexicon::CACHED_OS_PHP_PROCESS, $sample->toArray()); - $this->firstSeen($sample->isOutdated() ? ['php-process' => null] : [], $sample->sampledAt, ['php-process']); + + $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 { @@ -61,12 +67,14 @@ public function loadPhpSample(): ?PhpProcessSample { /** * 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 { - $seen = $this->appConfig->getAppValueArray(ConfigLexicon::CACHED_OS_FIRST_SEEN); - $since = $seen['php-process'] ?? null; + $since = $this->appConfig->getAppValueInt(ConfigLexicon::CACHED_OS_PHP_OUTDATED_SINCE); - return is_int($since) ? $since : null; + return $since > 0 ? $since : null; } /** diff --git a/tests/lib/OsStatus/OsStatusStoreTest.php b/tests/lib/OsStatus/OsStatusStoreTest.php index 77789a924..35cefc5b3 100644 --- a/tests/lib/OsStatus/OsStatusStoreTest.php +++ b/tests/lib/OsStatus/OsStatusStoreTest.php @@ -9,12 +9,13 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\OsStatus\OsStatusStore; use OCA\ServerInfo\OsStatus\Restart\PhpProcessSample; use OCP\AppFramework\Services\IAppConfig; class OsStatusStoreTest extends \Test\TestCase { - /** @var array */ + /** @var array */ private array $values = []; private int $writes = 0; private OsStatusStore $store; @@ -22,13 +23,20 @@ class OsStatusStoreTest extends \Test\TestCase { protected function setUp(): void { parent::setUp(); $config = $this->createMock(IAppConfig::class); - $config->method('getAppValueArray')->willReturnCallback(fn (string $key): array => $this->values[$key] ?? []); + $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->store = new OsStatusStore($config); } @@ -69,4 +77,15 @@ public function testPhpSamplesAreThrottledButChangesAreNot(): void { $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'); + } } From b3156973d083c95497c3c50f14241c6cde8560ca Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 22 Oct 2026 22:49:14 -0400 Subject: [PATCH 17/23] perf(os): keep the collected status in app data, not in app config With up to 500 listed updates the stored status comes to 100-200 KB of JSON. As a lazy app config value it was loaded and cached together with every app's lazy values, so any request that read a lazy key of any app carried it. The status is now os-status/status.json in the app's data. OsStatusStore keeps its method names. Reads come from admin pages and setup checks, and app data can be on object storage, so a distributed cache entry sits in front of the file: a save replaces it, and a read that finds no file remembers that too. Within a request the status is read once. A save removes the old cached_os_status app config value, which has left the lexicon. The small values stay in app config. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/Config/ConfigLexicon.php | 8 - lib/OsStatus/OsStatusStore.php | 91 ++++++++++- tests/lib/OsStatus/OsStatusCollectorTest.php | 30 +++- tests/lib/OsStatus/OsStatusStoreTest.php | 156 ++++++++++++++++++- 4 files changed, 270 insertions(+), 15 deletions(-) diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 80137c648..d13de9626 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -28,7 +28,6 @@ class ConfigLexicon implements ILexicon { public const CACHED_DB_CHECK = 'cached_db_check'; - public const CACHED_OS_STATUS = 'cached_os_status'; 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'; @@ -85,13 +84,6 @@ 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_STATUS, - ValueType::ARRAY, - [], - 'Operating system status as last collected by OCA\ServerInfo\Jobs\UpdateOsStatus', - lazy: true, - ), new Entry( self::CACHED_OS_FIRST_SEEN, ValueType::ARRAY, diff --git a/lib/OsStatus/OsStatusStore.php b/lib/OsStatus/OsStatusStore.php index 1fb1bdddb..873036cf5 100644 --- a/lib/OsStatus/OsStatusStore.php +++ b/lib/OsStatus/OsStatusStore.php @@ -12,28 +12,115 @@ use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\OsStatus\Restart\PhpProcessSample; use OCP\AppFramework\Services\IAppConfig; +use OCP\Files\GenericFileException; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\ICache; +use OCP\ICacheFactory; /** * Keeps what the collector found, so pages, setup checks, notifications and * the API read a stored answer instead of running commands. * + * The status itself can be a few hundred kilobytes, so it is a file in the + * app's data with the distributed cache in front of it; app config, whose + * lazy values are all loaded together, keeps only the small values. + * * @psalm-api */ class OsStatusStore { /** A new sample with the same findings is stored at most this often. */ private const PHP_SAMPLE_INTERVAL = 300; + private const FOLDER = 'os-status'; + private const FILE = 'status.json'; + private const CACHE_KEY = 'status'; + private const CACHE_TTL = 86400; + /** The app config key the status was kept under before it moved to app data */ + private const LEGACY_STATUS_KEY = 'cached_os_status'; + + private ICache $cache; + private bool $loaded = false; + private ?OsStatus $status = null; + public function __construct( private IAppConfig $appConfig, + private IAppData $appData, + ICacheFactory $cacheFactory, ) { + $this->cache = $cacheFactory->createDistributed('serverinfo-os'); } + /** + * @throws NotPermittedException|NotFoundException|\JsonException when the status cannot be written + */ public function save(OsStatus $status): void { - $this->appConfig->setAppValueArray(ConfigLexicon::CACHED_OS_STATUS, $status->toArray()); + $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 { - return OsStatus::fromArray($this->appConfig->getAppValueArray(ConfigLexicon::CACHED_OS_STATUS)); + 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); + } } /** diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 2643e8b38..b335b8c64 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -9,7 +9,6 @@ namespace OCA\ServerInfo\Tests\OsStatus; -use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\Os; use OCA\ServerInfo\OsStatus\Environment; use OCA\ServerInfo\OsStatus\EnvironmentDetector; @@ -29,6 +28,11 @@ use OCA\ServerInfo\OsStatus\Support\SupportTable; use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\IAppData; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\ICache; +use OCP\ICacheFactory; use Psr\Log\LoggerInterface; class OsStatusCollectorTest extends \Test\TestCase { @@ -38,6 +42,7 @@ class OsStatusCollectorTest extends \Test\TestCase { private FakeHost $host; /** @var array */ private array $config = []; + private ?string $stored = null; protected function setUp(): void { parent::setUp(); @@ -61,6 +66,9 @@ private function collector(int $now = self::NOW): OsStatusCollector { $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); @@ -78,11 +86,27 @@ private function collector(int $now = self::NOW): OsStatusCollector { $systemd, new TimeSyncProbe($host), new SecurityProbe($host), - new OsStatusStore($appConfig), + 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])', @@ -240,6 +264,6 @@ public function testCollectAndStorePersists(): void { $this->collector()->collectAndStore(); - $this->assertSame(1, $this->config[ConfigLexicon::CACHED_OS_STATUS]['schema'] ?? null); + $this->assertSame(1, json_decode((string)$this->stored, true)['schema'] ?? null); } } diff --git a/tests/lib/OsStatus/OsStatusStoreTest.php b/tests/lib/OsStatus/OsStatusStoreTest.php index 35cefc5b3..23558cf59 100644 --- a/tests/lib/OsStatus/OsStatusStoreTest.php +++ b/tests/lib/OsStatus/OsStatusStoreTest.php @@ -10,19 +10,39 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\Config\ConfigLexicon; +use OCA\ServerInfo\OsStatus\OsRelease; +use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\OsStatusStore; use OCA\ServerInfo\OsStatus\Restart\PhpProcessSample; use OCP\AppFramework\Services\IAppConfig; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\ICache; +use OCP\ICacheFactory; +use PHPUnit\Framework\MockObject\MockObject; class OsStatusStoreTest extends \Test\TestCase { /** @var array */ 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(); - $config = $this->createMock(IAppConfig::class); + $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; @@ -37,7 +57,139 @@ protected function setUp(): void { return true; }); - $this->store = new OsStatusStore($config); + + $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 { From 6c418d5c07c806432e70b50f8e4be3fc8a8a6698 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:37:32 +0200 Subject: [PATCH 18/23] fix(os): warn about an old status, and keep one failing probe from ending the run Nothing compared the time of collection with the job interval. When cron died, or the admin switched to AJAX or webcron, the job stopped collecting without a word, and the last status stayed green for weeks: on the page, in the setup checks and in occ serverinfo:os-status, whose exit code 0 kept monitoring quiet. In the collector only the pending updates were guarded, so an exception from any other probe dropped the whole run. The verdict has a freshness concern. It is a warning when the status is older than three job intervals, and never sooner than after six hours, or when background jobs do not run from system cron. It is unknown when nothing was collected. Its facts are collectedAt, ageSeconds and systemCron. A warning is a warning in the overall level as well, so an old status with unknown updates is no longer just "unknown". OsStatusService reads the cron mode and the interval the way the job does, so the verdict stays a function of its inputs. The occ command shows the age of the status, and its exit code follows the verdict. Every probe of the collector now runs on its own: one that throws is logged, costs its own finding and leaves a diagnostic. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 9 ++- lib/Commands/ShowOsStatus.php | 13 ++++ lib/Jobs/UpdateOsStatus.php | 17 ++++- lib/OsStatus/OsStatusCollector.php | 73 ++++++++++++++------ lib/OsStatus/OsStatusService.php | 5 ++ lib/OsStatus/Verdict/Concern.php | 2 + lib/OsStatus/Verdict/OsVerdict.php | 36 +++++++++- tests/lib/Commands/ShowOsStatusTest.php | 32 ++++++++- tests/lib/OsStatus/OsStatusCollectorTest.php | 16 ++++- tests/lib/OsStatus/OsStatusServiceTest.php | 22 +++++- tests/lib/OsStatus/OsVerdictTest.php | 37 ++++++++++ 11 files changed, 231 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4a464f5aa..49bf99176 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,17 @@ only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and 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. Admins can also check it right away with: +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 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 +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`. diff --git a/lib/Commands/ShowOsStatus.php b/lib/Commands/ShowOsStatus.php index 25ef7cea2..c5622416e 100644 --- a/lib/Commands/ShowOsStatus.php +++ b/lib/Commands/ShowOsStatus.php @@ -67,11 +67,13 @@ public function execute(InputInterface $input, OutputInterface $output): int { ); } $pendingDays = $verdict?->get(Concern::UPDATES)?->facts['days'] ?? null; + $age = $verdict?->get(Concern::FRESHNESS)?->facts['ageSeconds'] ?? null; $securityUpdates = $status->securityUpdates(); $this->writeArrayInOutputFormat($input, $output, [ '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, @@ -86,6 +88,17 @@ public function execute(InputInterface $input, OutputInterface $output): int { 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 */ diff --git a/lib/Jobs/UpdateOsStatus.php b/lib/Jobs/UpdateOsStatus.php index b0f1561cb..57ba63493 100644 --- a/lib/Jobs/UpdateOsStatus.php +++ b/lib/Jobs/UpdateOsStatus.php @@ -28,21 +28,34 @@ * @psalm-api */ class UpdateOsStatus extends TimedJob { + private const MIN_INTERVAL = 300; + public function __construct( ITimeFactory $time, private OsStatusCollector $collector, private IAppConfig $appConfig, ) { parent::__construct($time); - $this->setInterval(max(300, $appConfig->getValueInt(Application::APP_ID, ConfigLexicon::JOB_INTERVAL_OS_STATUS))); + $this->setInterval(self::interval($appConfig)); } #[\Override] protected function run($argument): void { - if ($this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax') !== 'cron') { + 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/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index a2aff2fc8..d4733300a 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -87,18 +87,21 @@ public function collect(): OsStatus { $diagnostics[] = '/etc/os-release could not be read, so the distribution is unknown.'; } - $environment = $this->environmentDetector->detect(); - $uptime = $this->os->getUptime(); + $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->supportTable->lookup($release) : SupportStatus::unknown(); + $support = $release !== null + ? $this->probe('The support period', fn (): SupportStatus => $this->supportTable->lookup($release), SupportStatus::unknown(), $diagnostics) + : SupportStatus::unknown(); if ($support->extended !== null) { - $support = $support->withExtendedActive($this->subscriptions->isActive($support->extended)); + $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->packageManagers->forRelease($release); + $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); } @@ -118,7 +121,9 @@ public function collect(): OsStatus { } } - $listsRefreshedAt = $packageManager?->getListsRefreshedAt(); + $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; @@ -127,14 +132,15 @@ public function collect(): OsStatus { } $rebootReasons = $environment->managesPackages() - ? $this->rebootReasons($packageManager, $environment, $bootTime) + ? $this->probe('Whether a reboot is needed', fn (): array => $this->rebootReasons($packageManager, $environment, $bootTime), null, $diagnostics) : null; - [$updates, $rebootReasons] = $this->applyFirstSeen( - $updates, - $packageManager?->classifiesSecurity() ?? false, - $rebootReasons, - $now, + $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; @@ -144,7 +150,7 @@ public function collect(): OsStatus { $updates = array_slice($updates, 0, self::MAX_LISTED_UPDATES); } - $timeSync = $this->timeSync->probe(); + $timeSync = $this->probe('The time synchronisation', fn (): array => $this->timeSync->probe(), ['synchronized' => null, 'source' => null], $diagnostics); return new OsStatus( OsStatus::STATE_OK, @@ -157,19 +163,21 @@ public function collect(): OsStatus { $packageManager?->getId(), $updates, $updatesSource, - $packageManager?->classifiesSecurity() ?? false, + $classified, $omitted, - $packageManager?->getHeldPackages(), + $packageManager !== null ? $this->probe('The held packages', fn (): ?array => $packageManager->getHeldPackages(), null, $diagnostics) : null, $listsRefreshedAt, - $packageManager?->getLastUpgradeAt(), - $packageManager?->getAutomaticUpdates() ?? AutomaticUpdates::unknown(), + $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->security->getLivePatches(), - $this->systemd->getFailedUnits(), + $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->security->getSelinux(), - $this->security->getApparmor(), + $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, @@ -177,6 +185,29 @@ public function collect(): OsStatus { ); } + /** + * 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. diff --git a/lib/OsStatus/OsStatusService.php b/lib/OsStatus/OsStatusService.php index 7b184073b..a5c7120d9 100644 --- a/lib/OsStatus/OsStatusService.php +++ b/lib/OsStatus/OsStatusService.php @@ -9,6 +9,7 @@ namespace OCA\ServerInfo\OsStatus; +use OCA\ServerInfo\Jobs\UpdateOsStatus; use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\OsStatus\Restart\PhpProcessProbe; use OCA\ServerInfo\OsStatus\Restart\PhpProcessSample; @@ -17,6 +18,7 @@ use OCA\ServerInfo\OsStatus\Verdict\Verdict; use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig as IServerConfig; /** * Reads the stored status and judges it. Everything that shows the @@ -32,6 +34,7 @@ public function __construct( private IAppConfig $appConfig, private ITimeFactory $timeFactory, private IHost $host, + private IServerConfig $serverConfig, ) { } @@ -74,6 +77,8 @@ public function evaluate(?OsStatus $status = null): ?Verdict { $this->store->phpOutdatedSince(), $this->getThresholds(), $this->timeFactory->getTime(), + UpdateOsStatus::runsFromSystemCron($this->serverConfig), + UpdateOsStatus::interval($this->serverConfig), ); } diff --git a/lib/OsStatus/Verdict/Concern.php b/lib/OsStatus/Verdict/Concern.php index 452b311f0..5c30c6ea2 100644 --- a/lib/OsStatus/Verdict/Concern.php +++ b/lib/OsStatus/Verdict/Concern.php @@ -28,6 +28,8 @@ final class Concern { public const AUTOMATIC_UPDATES = 'automatic-updates'; public const FAILED_UNITS = 'failed-units'; public const TIME_SYNC = 'time-sync'; + /** Whether the stored status still describes the system */ + public const FRESHNESS = 'freshness'; /** * @param self::LEVEL_* $level diff --git a/lib/OsStatus/Verdict/OsVerdict.php b/lib/OsStatus/Verdict/OsVerdict.php index 66587e6b0..5acad4661 100644 --- a/lib/OsStatus/Verdict/OsVerdict.php +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -27,9 +27,18 @@ class OsVerdict { /** Package managers whose metadata is refreshed in place and can go stale */ public const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk']; + /** A status older than this many collection intervals is stale, */ + public const STALE_AFTER_INTERVALS = 3; + /** but never younger than this, in seconds. */ + public const STALE_AFTER_MIN = 6 * 3600; + private const DAY = 86400; - public function evaluate(OsStatus $status, ?PhpProcessSample $php, ?int $phpOutdatedSince, Thresholds $thresholds, int $now): Verdict { + /** + * @param bool $systemCron whether background jobs run from system cron, which the collection needs + * @param int $collectInterval seconds between two collections + */ + public function evaluate(OsStatus $status, ?PhpProcessSample $php, ?int $phpOutdatedSince, Thresholds $thresholds, int $now, bool $systemCron = true, int $collectInterval = 3600): Verdict { if ($status->state !== OsStatus::STATE_OK) { return new Verdict([]); } @@ -49,10 +58,35 @@ public function evaluate(OsStatus $status, ?PhpProcessSample $php, ?int $phpOutd } $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); diff --git a/tests/lib/Commands/ShowOsStatusTest.php b/tests/lib/Commands/ShowOsStatusTest.php index cf147e32f..a980cfdd2 100644 --- a/tests/lib/Commands/ShowOsStatusTest.php +++ b/tests/lib/Commands/ShowOsStatusTest.php @@ -14,9 +14,12 @@ use OCA\ServerInfo\OsStatus\OsStatusCollector; use OCA\ServerInfo\OsStatus\OsStatusService; use OCA\ServerInfo\OsStatus\Verdict\Concern; +use OCA\ServerInfo\OsStatus\Verdict\OsVerdict; +use OCA\ServerInfo\OsStatus\Verdict\Thresholds; use OCA\ServerInfo\OsStatus\Verdict\Verdict; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; class ShowOsStatusTest extends \Test\TestCase { @@ -51,11 +54,36 @@ public function testUnknownVerdictIsUnknown(): void { $this->assertSame(ShowOsStatus::EXIT_UNKNOWN, $this->execute($service)); } - private function execute(OsStatusService $service): int { + 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)); + + $output = new BufferedOutput(); + $this->assertSame(ShowOsStatus::EXIT_WARNING, $this->execute($service, $output)); + $this->assertSame('3 d 2 h', json_decode($output->fetch(), true)['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)); + } + + private function execute(OsStatusService $service, ?OutputInterface $output = null): int { $command = new ShowOsStatus($this->createMock(OsStatusCollector::class), $service); $input = $this->createMock(InputInterface::class); $input->method('getOption')->willReturnMap([['refresh', false], ['output', 'json']]); - return (new \ReflectionMethod($command, 'execute'))->invoke($command, $input, $this->createMock(OutputInterface::class)); + return (new \ReflectionMethod($command, 'execute'))->invoke($command, $input, $output ?? $this->createMock(OutputInterface::class)); } } diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index b335b8c64..ac5f91a13 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -43,6 +43,7 @@ class OsStatusCollectorTest extends \Test\TestCase { /** @var array */ private array $config = []; private ?string $stored = null; + private ?TimeSyncProbe $timeSync = null; protected function setUp(): void { parent::setUp(); @@ -84,7 +85,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { new KernelProbe($host), new CoreLibraryProbe($host), $systemd, - new TimeSyncProbe($host), + $this->timeSync ?? new TimeSyncProbe($host), new SecurityProbe($host), new OsStatusStore($appConfig, $this->appData(), $cacheFactory), $this->createMock(LoggerInterface::class), @@ -249,6 +250,19 @@ public function testAptIsRunWhenOpenBasedirHidesIt(): void { $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; diff --git a/tests/lib/OsStatus/OsStatusServiceTest.php b/tests/lib/OsStatus/OsStatusServiceTest.php index 483358372..f894cd7c4 100644 --- a/tests/lib/OsStatus/OsStatusServiceTest.php +++ b/tests/lib/OsStatus/OsStatusServiceTest.php @@ -13,9 +13,11 @@ use OCA\ServerInfo\OsStatus\OsStatusService; use OCA\ServerInfo\OsStatus\OsStatusStore; use OCA\ServerInfo\OsStatus\Restart\PhpProcessProbe; +use OCA\ServerInfo\OsStatus\Verdict\Concern; use OCA\ServerInfo\OsStatus\Verdict\OsVerdict; use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig as IServerConfig; use PHPUnit\Framework\MockObject\MockObject; class OsStatusServiceTest extends \Test\TestCase { @@ -23,6 +25,7 @@ class OsStatusServiceTest extends \Test\TestCase { private OsStatusStore&MockObject $store; private FakeHost $host; + private string $mode = 'cron'; private OsStatusService $service; protected function setUp(): void { @@ -38,7 +41,11 @@ protected function setUp(): void { $time = $this->createMock(ITimeFactory::class); $time->method('getTime')->willReturn(self::NOW); - $this->service = new OsStatusService($this->store, new OsVerdict(), new PhpProcessProbe($this->host), $config, $time, $this->host); + $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 { @@ -57,4 +64,17 @@ public function testSamplingOnlyInWebRequests(): void { $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/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php index e8599f435..d508beacb 100644 --- a/tests/lib/OsStatus/OsVerdictTest.php +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -212,4 +212,41 @@ public function testCustomThresholds(): void { $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); + } } From 01535701551806508871f433f66c97b2eeef81f4 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:37:51 +0200 Subject: [PATCH 19/23] refactor(os): give SupportTable a constructor without a scalar parameter SupportTable took an optional ?string $supportDataFile in its constructor for tests. The container autowires constructor parameters by name, so a registered service or parameter called supportDataFile would have replaced the bundled table without anyone noticing. The constructor has no parameters now. Tests use SupportTable::fromFile() for a different table. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Support/SupportTable.php | 14 +++++++++----- tests/lib/OsStatus/SupportTableTest.php | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/OsStatus/Support/SupportTable.php b/lib/OsStatus/Support/SupportTable.php index 8f49e9d23..8ff7fc59f 100644 --- a/lib/OsStatus/Support/SupportTable.php +++ b/lib/OsStatus/Support/SupportTable.php @@ -23,12 +23,16 @@ class SupportTable { /** @var array{generatedAt?: string, cycles?: array, rolling?: list, releases?: array>}|null */ private ?array $table = null; + private string $file = __DIR__ . '/../../../resources/os-support.json'; + /** - * @param string|null $supportDataFile a different table, for tests + * A table read from another file than the bundled one. */ - public function __construct( - private ?string $supportDataFile = null, - ) { + public static function fromFile(string $file): self { + $table = new self(); + $table->file = $file; + + return $table; } public function lookup(OsRelease $release): SupportStatus { @@ -89,7 +93,7 @@ private function load(): array { return $this->table; } - $json = @file_get_contents($this->supportDataFile ?? __DIR__ . '/../../../resources/os-support.json'); + $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 */ diff --git a/tests/lib/OsStatus/SupportTableTest.php b/tests/lib/OsStatus/SupportTableTest.php index faf505728..a4dee51d9 100644 --- a/tests/lib/OsStatus/SupportTableTest.php +++ b/tests/lib/OsStatus/SupportTableTest.php @@ -73,7 +73,7 @@ public function testUnknownReleases(): void { } public function testAMissingTableKnowsNothing(): void { - $table = new SupportTable('/nonexistent/os-support.json'); + $table = SupportTable::fromFile('/nonexistent/os-support.json'); $this->assertSame(SupportStatus::STATE_UNKNOWN, $table->lookup($this->release('ubuntu', '24.04'))->state(time())); } From e462a8df414e52210898f1dbc72bd4b26b80cd8e Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:38:19 +0200 Subject: [PATCH 20/23] docs(os): describe only what this part of the stack delivers The README promised that the PHP serving Nextcloud is checked for libraries an update replaced, and named RHEL ELS among the paid support extensions. Here nothing samples the PHP process yet, so that check always reads as unknown, and only Ubuntu Pro and Debian ELTS are detected. Both claims and the mention of reading the process maps are gone; the later parts of the stack add them back with the code. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 49bf99176..dbd5166d1 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,8 @@ secure: 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 -- whether the PHP serving Nextcloud still runs libraries that an update - replaced, and which service to restart -- how long the release still receives security updates, including paid - extensions such as Ubuntu Pro, Debian ELTS or RHEL ELS +- 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 @@ -40,7 +38,7 @@ 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`, its own process maps), `/sys/class/dmi/id`, +`/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 From ec559b619a3e6fb8bbecca8cf53aaf25263d09ee Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 09:55:43 +0200 Subject: [PATCH 21/23] fix(os): only store apt updates whose names and versions look like apt's The name, versions and archives of a pending update were taken from the "Inst" lines of `apt-get -s` as they came and kept in the stored status. Anything a repository puts there, such as markup or very long strings, ended up in the status the page, the API and the notifications show. A line is now only kept when its package name matches the pattern the held packages and the reboot list already use, now one shared constant, when both versions are at most 128 characters of what Debian versions are made of (letters, digits and ".+~:_-"), and when every archive is at most 128 characters of letters, digits, spaces and ".+~:_/-". Spaces are allowed there because real archive labels carry them, e.g. "Debian Backports". Lines that fail are left out. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Apt.php | 19 +++++++++++++++++-- tests/lib/OsStatus/AptTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/OsStatus/Packages/Apt.php b/lib/OsStatus/Packages/Apt.php index 0ef2dcf04..5439a9c8e 100644 --- a/lib/OsStatus/Packages/Apt.php +++ b/lib/OsStatus/Packages/Apt.php @@ -31,6 +31,13 @@ class Apt implements IPackageManager { private const LISTS = '/var/lib/apt/lists'; private const UPDATE_SUCCESS_STAMP = '/var/lib/apt/periodic/update-success-stamp'; + /** A Debian package name, with an optional ":arch" qualifier */ + private const PACKAGE_NAME = '/^[a-z0-9][a-z0-9+.:-]*$/'; + /** A Debian version: [epoch:]upstream[-revision] */ + private const VERSION = '/^[A-Za-z0-9][A-Za-z0-9.+~:_-]{0,127}$/'; + /** An archive as apt describes it, e.g. "Debian-Security:12/stable-security" or "Debian Backports:12/bookworm-backports" */ + private const ORIGIN = '/^[A-Za-z0-9][A-Za-z0-9 .+~:_\/-]{0,127}$/'; + public function __construct( private IHost $host, private SystemdProbe $systemd, @@ -68,6 +75,7 @@ public function getPendingUpdates(): array { /** * 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. * * @return list */ @@ -79,6 +87,13 @@ public static function parseSimulation(string $output): array { } $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) { $slash = strrpos($origin, '/'); @@ -110,7 +125,7 @@ public function getHeldPackages(): ?array { $held = []; foreach (preg_split('/\R/', trim($result->stdout)) ?: [] as $name) { $name = trim($name); - if (preg_match('/^[a-z0-9][a-z0-9+.:-]*$/', $name) === 1) { + if (preg_match(self::PACKAGE_NAME, $name) === 1) { $held[] = new HeldPackage($name, 'apt-mark hold'); } } @@ -248,7 +263,7 @@ public function getRebootReasons(): array { $list = $this->host->readFile(self::REBOOT_REQUIRED . '.pkgs', 65536) ?? ''; foreach (preg_split('/\R/', $list) ?: [] as $package) { $package = trim($package); - if (preg_match('/^[a-z0-9][a-z0-9+.:-]*$/', $package) === 1) { + if (preg_match(self::PACKAGE_NAME, $package) === 1) { $packages[$package] = true; } } diff --git a/tests/lib/OsStatus/AptTest.php b/tests/lib/OsStatus/AptTest.php index e5aff619a..7ea70792a 100644 --- a/tests/lib/OsStatus/AptTest.php +++ b/tests/lib/OsStatus/AptTest.php @@ -63,6 +63,39 @@ public function testParsesSimulatedUpgrades(): void { $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