From d6c84ac1e322c4e2e7340ca0bd37ca4028fa964a Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 19 Oct 2026 16:01:28 -0400 Subject: [PATCH] feat(os): add an optional root helper bin/serverinfo-os-report, run by root from cron, adds what the web server cannot see: services that still run code an update replaced (needrestart, dnf needs-restarting, zypper ps, or a scan of /proc), fresh package lists, and pending dnf updates. It is installed outside the web root and only reads the system. Its report is used only when root owns it and its directory, it is recent, and it was written on this host since the last boot. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 22 + bin/serverinfo-os-report | 443 ++++++++++++++++++ lib/Commands/ShowOsStatus.php | 1 + lib/Config/ConfigLexicon.php | 8 + lib/OsStatus/Helper/HelperReport.php | 122 +++++ lib/OsStatus/Helper/HelperReportReader.php | 136 ++++++ lib/OsStatus/Helper/HelperState.php | 84 ++++ lib/OsStatus/Host/IHost.php | 7 + lib/OsStatus/Host/LocalHost.php | 19 + lib/OsStatus/OsStatus.php | 14 +- lib/OsStatus/OsStatusCollector.php | 59 ++- lib/OsStatus/Restart/ServiceRestart.php | 45 ++ lib/OsStatus/Verdict/Concern.php | 1 + lib/OsStatus/Verdict/OsVerdict.php | 54 ++- tests/data/os/alma-9-dnf-check-update-pending | 10 + tests/data/os/alma-9-dnf-updateinfo-security | 9 + tests/data/os/fedora-44-dnf-advisory-security | 5 + .../os/fedora-44-dnf-check-update-pending | 8 + tests/lib/OsStatus/FakeHost.php | 10 + tests/lib/OsStatus/HelperReportReaderTest.php | 162 +++++++ tests/lib/OsStatus/HelperScriptTest.php | 138 ++++++ tests/lib/OsStatus/OsStatusCollectorTest.php | 42 ++ tests/lib/OsStatus/OsVerdictTest.php | 13 +- .../lib/OsStatus/ThresholdsAndStatusTest.php | 6 + 24 files changed, 1394 insertions(+), 24 deletions(-) create mode 100755 bin/serverinfo-os-report create mode 100644 lib/OsStatus/Helper/HelperReport.php create mode 100644 lib/OsStatus/Helper/HelperReportReader.php create mode 100644 lib/OsStatus/Helper/HelperState.php create mode 100644 lib/OsStatus/Restart/ServiceRestart.php create mode 100644 tests/data/os/alma-9-dnf-check-update-pending create mode 100644 tests/data/os/alma-9-dnf-updateinfo-security create mode 100644 tests/data/os/fedora-44-dnf-advisory-security create mode 100644 tests/data/os/fedora-44-dnf-check-update-pending create mode 100644 tests/lib/OsStatus/HelperReportReaderTest.php create mode 100644 tests/lib/OsStatus/HelperScriptTest.php diff --git a/README.md b/README.md index 1dec6d4f..b82f0147 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ 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. +### Root helper + +The web server cannot see everything: only root can tell which services still +run code an update replaced, and only root can refresh the package lists. The +optional script `bin/serverinfo-os-report` adds that. Run as root, it writes a +small JSON report that the app reads. It only reads the system; it never +installs, upgrades, restarts or reboots anything. + +Install it outside the Nextcloud directory: the web server can write there, so +a root cron job pointing into it would hand the web server root. + +``` +sudo install -o root -g root -m 0755 apps/serverinfo/bin/serverinfo-os-report /usr/local/sbin/ +echo '17 */6 * * * root /usr/local/sbin/serverinfo-os-report --refresh' | sudo tee /etc/cron.d/serverinfo-os-report +``` + +The report is written to `/var/lib/serverinfo/os-report.json`. Use +`--output` and `occ config:app:set serverinfo os_helper_report_path --value …` +to put it elsewhere. The app ignores a report that anyone but root could have +written, one written before the last reboot or on another host, and one older +than 26 hours. + ## API The API provides a lot of information about a running Nextcloud diff --git a/bin/serverinfo-os-report b/bin/serverinfo-os-report new file mode 100755 index 00000000..ff9c9c23 --- /dev/null +++ b/bin/serverinfo-os-report @@ -0,0 +1,443 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# serverinfo-os-report: optional root helper for the Nextcloud Monitoring app. +# +# The Monitoring app runs as the web server user, which can see most of the +# operating system status by itself. This helper adds what only root can see: +# +# - services still running code that an upgrade replaced (needrestart, +# dnf needs-restarting, zypper ps, or a scan of /proc as a fallback) +# - fresh package lists (--refresh), so pending updates are current +# - pending dnf updates, which dnf 5 does not tell other users +# - SUSE LTSS registration +# +# It only reads the system and writes one JSON file. It never installs, +# upgrades, restarts or reboots anything. +# +# Install it outside the Nextcloud directory, which the web server can write +# to, so that the web server can never change what root runs: +# +# sudo install -o root -g root -m 0755 apps/serverinfo/bin/serverinfo-os-report /usr/local/sbin/ +# echo '17 */6 * * * root /usr/local/sbin/serverinfo-os-report --refresh' | sudo tee /etc/cron.d/serverinfo-os-report +# +# Report format (schema 1), all fields but the first five optional: +# schema, helperVersion, generatedAt (unix time), hostname, bootTime (unix time), +# distribution, listsRefreshedAt (unix time, after a successful --refresh), +# pendingUpdates (list of {name, installed, candidate, security, origin, +# advisories, severity}, null when not collected), +# restartServices (list of systemd units, null when unknown), +# rebootReasons (list of {kind: kernel|microcode, detail}), +# subscription ({kind, active}), errors (list of strings) + +set -u +# Command output is split into words in places; it must never be expanded as +# a file name pattern. +set -f +umask 022 + +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +LC_ALL=C +LANG=C +export PATH LC_ALL LANG + +VERSION=1.0.0 +SCHEMA=1 +OUTPUT=/var/lib/serverinfo/os-report.json +REFRESH=0 +DRY_RUN=0 +ERRORS= +SERVICES= +REBOOT_REASONS= + +# Architectures that end a package name in the output of the rpm tools. +RPM_ARCHES='x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64' + +usage() { + cat <&2 + exit 1 +} + +# Records a problem for the report. Only printable ASCII is kept, so the +# message can be placed in JSON after escaping quotes and backslashes. +note_error() { + message=$(printf '%s' "$1" | tr -c '\040-\176' ' ' | cut -c1-300) + ERRORS="$ERRORS$message +" +} + +json_text() { + printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" +} + +# Package, unit and version tokens are emitted only when they consist of +# characters that need no escaping in JSON; anything else is dropped. +is_token() { + case "$1" in + '' | *[!A-Za-z0-9@._+:~^-]*) return 1 ;; + *) return 0 ;; + esac +} + +run_limited() { + limit=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$limit" "$@" + else + "$@" + fi +} + +os_value() { + sed -n "s/^$1=//p" /etc/os-release 2>/dev/null | head -n 1 | tr -d "\"'" +} + +add_service() { + unit=$1 + case "$unit" in + *.service) ;; + *) unit="$unit.service" ;; + esac + is_token "$unit" || return 0 + case " +$SERVICES" in + *" +$unit +"*) return 0 ;; + esac + SERVICES="$SERVICES$unit +" +} + +# One unit per line. +add_services() { + while IFS= read -r unit; do + unit=$(printf '%s' "$unit" | tr -d ' \t\r') + [ -n "$unit" ] && add_service "$unit" + done </dev/null 2>&1 || return 1 + output=$(run_limited 300 needrestart -b -r l 2>/dev/null) || return 1 + printf '%s\n' "$output" | grep -q '^NEEDRESTART-VER:' || return 1 + + add_services "$(printf '%s\n' "$output" | sed -n 's/^NEEDRESTART-SVC: *//p')" + + kernel_status=$(printf '%s\n' "$output" | sed -n 's/^NEEDRESTART-KSTA: *//p') + expected=$(printf '%s\n' "$output" | sed -n 's/^NEEDRESTART-KEXP: *//p') + case "$kernel_status" in + 2 | 3) add_reboot_reason kernel "$expected" ;; + esac + microcode_status=$(printf '%s\n' "$output" | sed -n 's/^NEEDRESTART-UCSTA: *//p') + [ "$microcode_status" = 2 ] && add_reboot_reason microcode '' + return 0 +} + +from_dnf() { + command -v dnf >/dev/null 2>&1 || return 1 + output=$(run_limited 300 dnf -q needs-restarting -s 2>/dev/null) || return 1 + add_services "$output" + return 0 +} + +from_zypper() { + command -v zypper >/dev/null 2>&1 || return 1 + # Exits 4 without lsof; the /proc scan then answers instead. + output=$(run_limited 300 zypper --non-interactive --quiet ps -sss 2>/dev/null) || return 1 + add_services "$output" + return 0 +} + +# Fallback for any systemd distribution: processes that map a library which +# has been deleted since, attributed to their systemd unit. +from_proc() { + [ -d /proc/1 ] || return 1 + set +f + directories=$(printf '%s\n' /proc/[0-9]*) + set -f + for dir in $directories; do + [ -r "$dir/maps" ] || continue + awk ' + / \(deleted\)$/ && $6 ~ /\.so/ && $6 !~ /^\/(dev|memfd:|tmp|var\/tmp|run|proc|SYSV)/ { found = 1; exit } + END { exit found ? 0 : 1 } + ' "$dir/maps" 2>/dev/null || continue + unit=$(sed -n \ + -e 's#^0::/.*/\([^/]*\.service\)$#\1#p' \ + -e 's#^[0-9]*:name=systemd:/.*/\([^/]*\.service\)$#\1#p' \ + "$dir/cgroup" 2>/dev/null | head -n 1) + case "$unit" in + '' | user@*) continue ;; + esac + add_service "$unit" + done + return 0 +} + +# Prints pending dnf updates as a JSON list, from the output of +# `dnf check-update` ($1), the security advisory list of dnf 4 or 5 ($2) and +# `rpm -qa` with name and epoch:version-release ($3). Values that are not +# plain tokens are dropped, so nothing needs escaping. +dnf_updates_json() { + { + printf '%s\n' "$3" | sed 's/^/I /' + printf '%s\n' "$2" | sed 's/^/A /' + printf '%s\n' "$1" | sed 's/^/U /' + } | awk -v arches="$RPM_ARCHES" ' + function token(s) { return s ~ /^[A-Za-z0-9@._+:~^-]+$/ } + function strip_arch(s) { sub("\\.(" arches ")$", "", s); return s } + function rank(s) { + s = tolower(s) + return s == "critical" ? 4 : s == "important" ? 3 : s == "moderate" ? 2 : s == "low" ? 1 : 0 + } + $1 == "I" && NF == 3 { version = $3; sub(/^0:/, "", version); installed[$2] = version; next } + $1 == "A" { + if ($3 ~ /\/Sec\.$/) { severity = $3; sub(/\/Sec\.$/, "", severity); nevra = $4 } + else if ($3 == "security") { severity = $4; nevra = $5 } + else next + if (!token($2) || !token(nevra)) next + name = strip_arch(nevra) + sub(/-[^-]+-[^-]+$/, "", name) + if (!((name, $2) in seen)) { + seen[name, $2] = 1 + ids[name] = ids[name] (ids[name] == "" ? "" : ",") "\"" $2 "\"" + } + if (severity ~ /^[A-Za-z]+$/ && rank(severity) > rank(highest[name])) highest[name] = severity + next + } + $1 == "U" && $2 ~ /^Obsoleting/ { stop = 1 } + $1 == "U" && !stop && NF == 4 && $2 ~ ("\\.(" arches ")$") { + if (!token($2) || !token($3) || !token($4)) next + name = strip_arch($2) + out = out (count++ ? "," : "") sprintf("{\"name\":\"%s\",\"installed\":%s,\"candidate\":\"%s\",\"security\":%s,\"origin\":\"%s\",\"advisories\":[%s],\"severity\":%s}", + name, + (name in installed) ? "\"" installed[name] "\"" : "null", + $3, + (name in ids) ? "true" : "false", + $4, + ids[name], + (name in highest) ? "\"" highest[name] "\"" : "null") + } + END { printf "[%s]", out } + ' +} + +dnf_pending_updates() { + command -v dnf >/dev/null 2>&1 || return 1 + updates=$(run_limited 600 dnf -q --cacheonly check-update 2>/dev/null) + case $? in + 0) printf '[]'; return 0 ;; + 100) ;; + *) return 1 ;; + esac + if command -v dnf5 >/dev/null 2>&1; then + advisories=$(run_limited 600 dnf -q --cacheonly advisory list --security 2>/dev/null) || return 1 + else + advisories=$(run_limited 600 dnf -q --cacheonly updateinfo list --security --available 2>/dev/null) || return 1 + fi + installed=$(rpm -qa --qf '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE}\n' 2>/dev/null) || return 1 + dnf_updates_json "$updates" "$advisories" "$installed" +} + +main() { + while [ $# -gt 0 ]; do + case "$1" in + --refresh) REFRESH=1 ;; + --dry-run) DRY_RUN=1 ;; + --output) + [ $# -ge 2 ] || die '--output needs a file name' + OUTPUT=$2 + shift + ;; + --output=*) OUTPUT=${1#--output=} ;; + --version) echo "$VERSION"; exit 0 ;; + --help | -h) usage; exit 0 ;; + *) usage >&2; exit 1 ;; + esac + shift + done + + case "$OUTPUT" in + /*.json) ;; + *) die 'the output must be an absolute path ending in .json' ;; + esac + case "$OUTPUT" in + */../* | */./* | *[!A-Za-z0-9._/-]*) die 'the output path may only contain letters, digits, dots, dashes, underscores and slashes' ;; + esac + + [ "$(id -u)" -eq 0 ] || die 'must run as root' + cd / || exit 1 + + # --- identity ----------------------------------------------------------- + + HOSTNAME_VALUE=$(hostname 2>/dev/null || cat /proc/sys/kernel/hostname 2>/dev/null) + is_token "$HOSTNAME_VALUE" || die 'the host name could not be determined' + + BOOT_TIME=$(awk '/^btime / { print $2 }' /proc/stat 2>/dev/null) + case "$BOOT_TIME" in + '' | *[!0-9]*) BOOT_TIME=null ;; + esac + + DISTRO_ID=$(os_value ID) + DISTRO_LIKE=$(os_value ID_LIKE) + is_token "$DISTRO_ID" || DISTRO_ID=unknown + + case " $DISTRO_ID $DISTRO_LIKE " in + *' debian '* | *' ubuntu '*) FAMILY=apt ;; + *' suse '* | *' opensuse'* | *' sles '*) FAMILY=zypper ;; + *' fedora '* | *' rhel '* | *' centos '* | *' amzn '*) FAMILY=dnf ;; + *' arch '*) FAMILY=pacman ;; + *' alpine '*) FAMILY=apk ;; + *) FAMILY=unknown ;; + esac + + # --- package lists ------------------------------------------------------ + + LISTS_REFRESHED_AT=null + if [ "$REFRESH" -eq 1 ]; then + refreshed=0 + case "$FAMILY" in + apt) run_limited 900 apt-get update -qq -o DPkg::Lock::Timeout=300 >/dev/null 2>&1 && refreshed=1 ;; + dnf) run_limited 900 dnf -q makecache >/dev/null 2>&1 && refreshed=1 ;; + zypper) run_limited 900 zypper --non-interactive --quiet refresh >/dev/null 2>&1 && refreshed=1 ;; + apk) run_limited 900 apk update -q >/dev/null 2>&1 && refreshed=1 ;; + # pacman -Sy without -u leaves Arch half upgraded; checkupdates, which + # the app runs itself, syncs into a private database instead. + *) refreshed=2 ;; + esac + case "$refreshed" in + 1) LISTS_REFRESHED_AT=$(date +%s) ;; + 0) note_error "Refreshing the package lists with $FAMILY failed." ;; + esac + fi + + PENDING=null + if [ "$FAMILY" = dnf ]; then + PENDING=$(dnf_pending_updates) || { + PENDING=null + note_error 'Pending updates could not be listed with dnf.' + } + fi + + # --- services that need a restart --------------------------------------- + + services_known=0 + case "$FAMILY" in + apt) from_needrestart && services_known=1 ;; + dnf) from_dnf && services_known=1 ;; + zypper) from_zypper && services_known=1 ;; + esac + if [ "$services_known" -eq 0 ]; then + from_proc && services_known=1 + fi + [ "$services_known" -eq 1 ] || note_error 'Services that need a restart could not be determined.' + + # --- subscriptions ------------------------------------------------------ + + SUBSCRIPTION=null + if [ "$FAMILY" = zypper ] && command -v SUSEConnect >/dev/null 2>&1; then + status=$(run_limited 120 SUSEConnect --status 2>/dev/null | tr -d ' \n') + case "$status" in + *'"identifier":"SLES-LTSS"'*'"status":"Registered"'*) SUBSCRIPTION='{"kind":"suse-ltss","active":true}' ;; + '['*) SUBSCRIPTION='{"kind":"suse-ltss","active":false}' ;; + esac + fi + + # --- report ------------------------------------------------------------- + + services_json=null + if [ "$services_known" -eq 1 ]; then + services_json=$(printf '%s' "$SERVICES" | awk 'NF { printf "%s\"%s\"", (n++ ? "," : ""), $0 }') + services_json="[$services_json]" + fi + + errors_json= + old_ifs=$IFS + IFS=' +' + for message in $ERRORS; do + errors_json="${errors_json:+$errors_json,}$(json_text "$message")" + done + IFS=$old_ifs + + REPORT=$(cat < "$temporary" || die "cannot write $temporary" + chmod 0644 "$temporary" + # rename(2) replaces a symbolic link at the target instead of following it. + mv -f "$temporary" "$OUTPUT" || die "cannot replace $OUTPUT" + trap - EXIT +} + +# Tests load the functions above without running anything. +if [ "${SERVERINFO_OS_REPORT_FUNCTIONS_ONLY:-}" != 1 ]; then + main "$@" +fi diff --git a/lib/Commands/ShowOsStatus.php b/lib/Commands/ShowOsStatus.php index 629ecd34..e41a1630 100644 --- a/lib/Commands/ShowOsStatus.php +++ b/lib/Commands/ShowOsStatus.php @@ -73,6 +73,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { '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'), + 'helper' => $status->helper->state, 'concerns' => $concerns, ]); diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 00f71a40..4512253e 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -32,6 +32,7 @@ class ConfigLexicon implements ILexicon { 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_HELPER_REPORT_PATH = 'os_helper_report_path'; 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'; @@ -111,6 +112,13 @@ public function getAppConfigs(): array { 60 * 60, 'How often the operating system status is collected, in seconds', ), + new Entry( + self::OS_HELPER_REPORT_PATH, + ValueType::STRING, + '/var/lib/serverinfo/os-report.json', + 'Where the optional root helper bin/serverinfo-os-report writes its report; an absolute path ending in .json', + lazy: true, + ), 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), diff --git a/lib/OsStatus/Helper/HelperReport.php b/lib/OsStatus/Helper/HelperReport.php new file mode 100644 index 00000000..e792caa0 --- /dev/null +++ b/lib/OsStatus/Helper/HelperReport.php @@ -0,0 +1,122 @@ +|null $pendingUpdates + * @param list|null $restartServices + * @param list $rebootReasons + * @param list $errors + */ + public function __construct( + public readonly int $generatedAt, + public readonly string $helperVersion, + public readonly string $hostname, + public readonly ?int $bootTime, + public readonly ?int $listsRefreshedAt, + public readonly ?array $pendingUpdates, + public readonly ?array $restartServices, + public readonly array $rebootReasons, + /** Whether a paid extended support subscription is active, null when unknown */ + public readonly ?bool $subscriptionActive, + public readonly array $errors, + ) { + } + + /** + * @throws \InvalidArgumentException when required fields are missing or malformed + */ + public static function fromArray(array $data): self { + $generatedAt = $data['generatedAt'] ?? null; + $version = $data['helperVersion'] ?? null; + $hostname = $data['hostname'] ?? null; + if (!is_int($generatedAt) || !is_string($version) || preg_match('/^\d+\.\d+\.\d+$/', $version) !== 1 + || !is_string($hostname) || $hostname === '') { + throw new \InvalidArgumentException('Required fields are missing'); + } + + $pendingUpdates = null; + if (is_array($data['pendingUpdates'] ?? null)) { + $pendingUpdates = self::mapList($data['pendingUpdates'], static fn (mixed $item): ?PendingUpdate => PendingUpdate::fromArray($item)); + } + + $restartServices = null; + if (is_array($data['restartServices'] ?? null)) { + $restartServices = self::mapList( + $data['restartServices'], + static fn (mixed $unit): ?ServiceRestart => is_string($unit) ? ServiceRestart::fromArray(['unit' => $unit]) : null, + ); + } + + $rebootReasons = []; + if (is_array($data['rebootReasons'] ?? null)) { + $rebootReasons = self::mapList($data['rebootReasons'], static fn (mixed $item): ?RebootReason => RebootReason::fromArray($item)); + } + + $errors = []; + if (is_array($data['errors'] ?? null)) { + $errors = self::mapList( + $data['errors'], + static fn (mixed $error): ?string => is_string($error) ? mb_substr($error, 0, 300) : null, + ); + $errors = array_slice($errors, 0, 20); + } + + $subscription = $data['subscription'] ?? null; + + return new self( + $generatedAt, + $version, + $hostname, + Values::int($data['bootTime'] ?? null), + Values::int($data['listsRefreshedAt'] ?? null), + $pendingUpdates, + $restartServices, + $rebootReasons, + is_array($subscription) && is_bool($subscription['active'] ?? null) ? $subscription['active'] : null, + $errors, + ); + } + + /** + * @template T + * @param array $items + * @param callable(mixed): (T|null) $map + * @return list + */ + private static function mapList(array $items, callable $map): array { + $mapped = []; + foreach (array_slice(array_values($items), 0, self::MAX_ITEMS) as $item) { + $value = $map($item); + if ($value !== null) { + $mapped[] = $value; + } + } + + return $mapped; + } +} diff --git a/lib/OsStatus/Helper/HelperReportReader.php b/lib/OsStatus/Helper/HelperReportReader.php new file mode 100644 index 00000000..6f926174 --- /dev/null +++ b/lib/OsStatus/Helper/HelperReportReader.php @@ -0,0 +1,136 @@ +host->fileExists($path)) { + $state = $this->isOutsideOpenBasedir($path) ? HelperState::STATE_UNREADABLE : HelperState::STATE_ABSENT; + + return [new HelperState($state, $path), null]; + } + + if (!$this->isRootOnly($path) || !$this->isRootOnly(dirname($path))) { + return [new HelperState(HelperState::STATE_INSECURE, $path), null]; + } + + $content = $this->host->readFile($path, self::MAX_BYTES + 1); + if ($content === null) { + return [new HelperState(HelperState::STATE_UNREADABLE, $path), null]; + } + if (strlen($content) > self::MAX_BYTES) { + return [new HelperState(HelperState::STATE_INVALID, $path), null]; + } + + $data = json_decode($content, true, 16); + if (!is_array($data)) { + return [new HelperState(HelperState::STATE_INVALID, $path), null]; + } + if (($data['schema'] ?? null) !== HelperReport::SCHEMA) { + return [new HelperState(HelperState::STATE_UNSUPPORTED, $path), null]; + } + + try { + $report = HelperReport::fromArray($data); + } catch (\InvalidArgumentException) { + return [new HelperState(HelperState::STATE_INVALID, $path), null]; + } + + $state = match (true) { + $report->hostname !== $this->host->hostname() => HelperState::STATE_OTHER_HOST, + $bootTime !== null && $report->bootTime !== null && abs($report->bootTime - $bootTime) > self::BOOT_TIME_TOLERANCE => HelperState::STATE_PREVIOUS_BOOT, + $now - $report->generatedAt > self::MAX_AGE || $report->generatedAt > $now + 300 => HelperState::STATE_STALE, + default => HelperState::STATE_OK, + }; + + $helperState = new HelperState( + $state, + $path, + $report->generatedAt, + $report->helperVersion, + version_compare($report->helperVersion, self::BUNDLED_VERSION, '<'), + $report->errors, + ); + + return [$helperState, $state === HelperState::STATE_OK ? $report : null]; + } + + private function isOutsideOpenBasedir(string $path): bool { + $allowed = $this->host->openBasedir(); + if ($allowed === []) { + return false; + } + + foreach ($allowed as $directory) { + if (str_starts_with($path, rtrim($directory, '/') . '/')) { + return false; + } + } + + return true; + } + + /** + * Owned by root and writable by neither group nor others. + */ + private function isRootOnly(string $path): bool { + $ownership = $this->host->ownership($path); + + return $ownership !== null && $ownership['owner'] === 0 && ($ownership['mode'] & 0o022) === 0; + } +} diff --git a/lib/OsStatus/Helper/HelperState.php b/lib/OsStatus/Helper/HelperState.php new file mode 100644 index 00000000..a710cf55 --- /dev/null +++ b/lib/OsStatus/Helper/HelperState.php @@ -0,0 +1,84 @@ + $errors problems the helper itself reported + */ + public function __construct( + public readonly string $state, + public readonly string $path, + public readonly ?int $generatedAt = null, + public readonly ?string $version = null, + /** The installed helper is older than the one bundled with the app */ + public readonly bool $outdated = false, + public readonly array $errors = [], + ) { + } + + /** + * @return array{state: string, path: string, generatedAt: ?int, version: ?string, outdated: bool, errors: list} + */ + public function toArray(): array { + return [ + 'state' => $this->state, + 'path' => $this->path, + 'generatedAt' => $this->generatedAt, + 'version' => $this->version, + 'outdated' => $this->outdated, + 'errors' => $this->errors, + ]; + } + + public static function fromArray(mixed $data, string $defaultPath): self { + if (!is_array($data) || !in_array($data['state'] ?? null, self::STATES, true)) { + return new self(self::STATE_ABSENT, $defaultPath); + } + + return new self( + $data['state'], + is_string($data['path'] ?? null) ? $data['path'] : $defaultPath, + Values::int($data['generatedAt'] ?? null), + Values::string($data['version'] ?? null), + ($data['outdated'] ?? false) === true, + Values::strings($data['errors'] ?? null), + ); + } +} diff --git a/lib/OsStatus/Host/IHost.php b/lib/OsStatus/Host/IHost.php index 90eba25b..109c5c32 100644 --- a/lib/OsStatus/Host/IHost.php +++ b/lib/OsStatus/Host/IHost.php @@ -43,6 +43,11 @@ public function modifiedAt(string $path): ?int; */ public function changedAt(string $path): ?int; + /** + * @return array{owner: int, mode: int}|null + */ + public function ownership(string $path): ?array; + public function readLink(string $path): ?string; /** @@ -71,6 +76,8 @@ public function environment(string $name): ?string; */ public function uname(string $mode): string; + public function hostname(): string; + /** * As PHP_OS_FAMILY, e.g. "Linux" or "BSD". */ diff --git a/lib/OsStatus/Host/LocalHost.php b/lib/OsStatus/Host/LocalHost.php index 50d7c40c..3cdfcc7a 100644 --- a/lib/OsStatus/Host/LocalHost.php +++ b/lib/OsStatus/Host/LocalHost.php @@ -83,6 +83,18 @@ public function changedAt(string $path): ?int { return $time === false ? null : $time; } + #[\Override] + public function ownership(string $path): ?array { + clearstatcache(true, $path); + $owner = @fileowner($path); + $mode = @fileperms($path); + if ($owner === false || $mode === false) { + return null; + } + + return ['owner' => $owner, 'mode' => $mode]; + } + #[\Override] public function readLink(string $path): ?string { $target = @readlink($path); @@ -124,6 +136,13 @@ public function uname(string $mode): string { return php_uname($mode); } + #[\Override] + public function hostname(): string { + $hostname = gethostname(); + + return is_string($hostname) && $hostname !== '' ? $hostname : php_uname('n'); + } + #[\Override] public function osFamily(): string { return PHP_OS_FAMILY; diff --git a/lib/OsStatus/OsStatus.php b/lib/OsStatus/OsStatus.php index 3e2c2bfb..64e72149 100644 --- a/lib/OsStatus/OsStatus.php +++ b/lib/OsStatus/OsStatus.php @@ -9,10 +9,13 @@ namespace OCA\ServerInfo\OsStatus; +use OCA\ServerInfo\OsStatus\Helper\HelperReportReader; +use OCA\ServerInfo\OsStatus\Helper\HelperState; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\HeldPackage; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; +use OCA\ServerInfo\OsStatus\Restart\ServiceRestart; use OCA\ServerInfo\OsStatus\Support\SupportStatus; /** @@ -32,6 +35,7 @@ final class OsStatus { /** Source of a finding */ public const SOURCE_SYSTEM = 'system'; + public const SOURCE_HELPER = 'helper'; /** * @param self::STATE_* $state @@ -39,6 +43,7 @@ final class OsStatus { * @param self::SOURCE_*|null $updatesSource * @param list|null $held * @param list|null $rebootReasons + * @param list|null $restartServices * @param list|null $livePatches * @param list|null $failedUnits * @param array{all: ?string, security: ?string} $upgradeCommands @@ -64,6 +69,7 @@ public function __construct( public readonly ?int $lastUpgradeAt = null, public readonly AutomaticUpdates $automaticUpdates = new AutomaticUpdates(AutomaticUpdates::STATE_UNKNOWN), public readonly ?array $rebootReasons = null, + public readonly ?array $restartServices = null, public readonly ?array $livePatches = null, public readonly ?array $failedUnits = null, public readonly ?bool $timeSynchronized = null, @@ -72,6 +78,7 @@ public function __construct( public readonly ?string $apparmor = null, public readonly array $upgradeCommands = ['all' => null, 'security' => null], public readonly ?string $packageDatabase = null, + public readonly HelperState $helper = new HelperState(HelperState::STATE_ABSENT, HelperReportReader::DEFAULT_PATH), public readonly ?int $bootTime = null, public readonly array $diagnostics = [], ) { @@ -116,6 +123,7 @@ public function toArray(): array { 'lastUpgradeAt' => $this->lastUpgradeAt, 'automaticUpdates' => $this->automaticUpdates->toArray(), 'rebootReasons' => $this->rebootReasons === null ? null : array_map(static fn (RebootReason $r): array => $r->toArray(), $this->rebootReasons), + 'restartServices' => $this->restartServices === null ? null : array_map(static fn (ServiceRestart $s): array => $s->toArray(), $this->restartServices), 'livePatches' => $this->livePatches, 'failedUnits' => $this->failedUnits, 'timeSynchronized' => $this->timeSynchronized, @@ -124,6 +132,7 @@ public function toArray(): array { 'apparmor' => $this->apparmor, 'upgradeCommands' => $this->upgradeCommands, 'packageDatabase' => $this->packageDatabase, + 'helper' => $this->helper->toArray(), 'bootTime' => $this->bootTime, 'diagnostics' => $this->diagnostics, ]; @@ -140,7 +149,8 @@ public static function fromArray(mixed $data): ?self { $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; + $source = in_array($data['updatesSource'] ?? null, [self::SOURCE_SYSTEM, self::SOURCE_HELPER], true) + ? $data['updatesSource'] : null; $commands = is_array($data['upgradeCommands'] ?? null) ? $data['upgradeCommands'] : []; return new self( @@ -161,6 +171,7 @@ public static function fromArray(mixed $data): ?self { Values::int($data['lastUpgradeAt'] ?? null), AutomaticUpdates::fromArray($data['automaticUpdates'] ?? null), self::listOf($data['rebootReasons'] ?? null, static fn (mixed $item): ?RebootReason => RebootReason::fromArray($item)), + self::listOf($data['restartServices'] ?? null, static fn (mixed $item): ?ServiceRestart => ServiceRestart::fromArray($item)), self::stringList($data['livePatches'] ?? null), self::stringList($data['failedUnits'] ?? null), is_bool($data['timeSynchronized'] ?? null) ? $data['timeSynchronized'] : null, @@ -172,6 +183,7 @@ public static function fromArray(mixed $data): ?self { 'security' => is_string($commands['security'] ?? null) ? $commands['security'] : null, ], Values::string($data['packageDatabase'] ?? null), + HelperState::fromArray($data['helper'] ?? null, HelperReportReader::DEFAULT_PATH), Values::int($data['bootTime'] ?? null), self::stringList($data['diagnostics'] ?? null) ?? [], ); diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index d95ab8a5..b2e946bf 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -9,10 +9,13 @@ namespace OCA\ServerInfo\OsStatus; +use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\Os; use OCA\ServerInfo\OsStatus\Health\SecurityProbe; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\Health\TimeSyncProbe; +use OCA\ServerInfo\OsStatus\Helper\HelperReport; +use OCA\ServerInfo\OsStatus\Helper\HelperReportReader; use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\IPackageManager; @@ -22,6 +25,7 @@ use OCA\ServerInfo\OsStatus\Reboot\CoreLibraryProbe; use OCA\ServerInfo\OsStatus\Reboot\KernelProbe; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; +use OCA\ServerInfo\OsStatus\Restart\ServiceRestart; use OCA\ServerInfo\OsStatus\Support\SubscriptionProbe; use OCA\ServerInfo\OsStatus\Support\SupportStatus; use OCA\ServerInfo\OsStatus\Support\SupportTable; @@ -55,6 +59,7 @@ public function __construct( private SystemdProbe $systemd, private TimeSyncProbe $timeSync, private SecurityProbe $security, + private HelperReportReader $helperReader, private OsStatusStore $store, private LoggerInterface $logger, ) { @@ -90,9 +95,12 @@ public function collect(): OsStatus { $uptime = $this->os->getUptime(); $bootTime = $uptime >= 0 ? $now - $uptime : null; + $helperPath = $this->appConfig->getAppValueString(ConfigLexicon::OS_HELPER_REPORT_PATH, HelperReportReader::DEFAULT_PATH); + [$helperState, $report] = $this->helperReader->read($helperPath, $bootTime, $now); + $support = $release !== null ? $this->supportTable->lookup($release) : SupportStatus::unknown(); if ($support->extended !== null) { - $support = $support->withExtendedActive($this->subscriptions->isActive($support->extended)); + $support = $support->withExtendedActive($this->subscriptions->isActive($support->extended) ?? $report?->subscriptionActive); } $packageManager = null; @@ -116,15 +124,22 @@ public function collect(): OsStatus { $diagnostics[] = 'Pending updates could not be determined; see the Nextcloud log.'; } } + $reportedUpdates = $report?->pendingUpdates; + if ($updates === null && $reportedUpdates !== null && $environment->managesPackages()) { + $updates = $reportedUpdates; + $updatesSource = OsStatus::SOURCE_HELPER; + } $rebootReasons = $environment->managesPackages() - ? $this->rebootReasons($packageManager, $environment, $bootTime) + ? $this->rebootReasons($packageManager, $environment, $bootTime, $report) : null; + $restartServices = $environment->managesPackages() ? $report?->restartServices : null; - [$updates, $rebootReasons] = $this->applyFirstSeen( + [$updates, $rebootReasons, $restartServices] = $this->applyFirstSeen( $updates, - $packageManager?->classifiesSecurity() ?? false, + $packageManager?->classifiesSecurity() ?? ($updatesSource === OsStatus::SOURCE_HELPER), $rebootReasons, + $restartServices, $now, ); @@ -136,6 +151,7 @@ public function collect(): OsStatus { } $timeSync = $this->timeSync->probe(); + $listsRefreshedAt = $this->latest($packageManager?->getListsRefreshedAt(), $report?->listsRefreshedAt); return new OsStatus( OsStatus::STATE_OK, @@ -148,13 +164,14 @@ public function collect(): OsStatus { $packageManager?->getId(), $updates, $updatesSource, - $packageManager?->classifiesSecurity() ?? false, + $packageManager?->classifiesSecurity() ?? ($updatesSource === OsStatus::SOURCE_HELPER), $omitted, $packageManager?->getHeldPackages(), - $packageManager?->getListsRefreshedAt(), + $listsRefreshedAt, $packageManager?->getLastUpgradeAt(), $packageManager?->getAutomaticUpdates() ?? AutomaticUpdates::unknown(), $rebootReasons, + $restartServices, $this->security->getLivePatches(), $this->systemd->getFailedUnits(), $timeSync['synchronized'], @@ -163,6 +180,7 @@ public function collect(): OsStatus { $this->security->getApparmor(), $packageManager?->getUpgradeCommands() ?? ['all' => null, 'security' => null], $packageManager?->getPackageDatabasePath(), + $helperState, $bootTime, $diagnostics, ); @@ -188,7 +206,7 @@ public function collectAndStore(): ?OsStatus { /** * @return list */ - private function rebootReasons(?IPackageManager $packageManager, Environment $environment, ?int $bootTime): array { + private function rebootReasons(?IPackageManager $packageManager, Environment $environment, ?int $bootTime, ?HelperReport $report): 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)) { @@ -203,6 +221,9 @@ private function rebootReasons(?IPackageManager $packageManager, Environment $en foreach ($this->kernelProbe->getRebootReasons() as $reason) { $add($reason); } + foreach ($report?->rebootReasons ?? [] as $reason) { + $add($reason); + } $hasDistributionAnswer = ($packageManager?->hasOwnRebootCheck() ?? false) || array_filter($reasons, static fn (RebootReason $r): bool => $r->kind === RebootReason::KIND_DISTRIBUTION) !== []; @@ -221,9 +242,10 @@ private function rebootReasons(?IPackageManager $packageManager, Environment $en * * @param list|null $updates * @param list|null $reasons - * @return array{0: list|null, 1: list|null} + * @param list|null $services + * @return array{0: list|null, 1: list|null, 2: list|null} */ - private function applyFirstSeen(?array $updates, bool $classified, ?array $reasons, int $now): array { + private function applyFirstSeen(?array $updates, bool $classified, ?array $reasons, ?array $services, int $now): array { $current = []; $scope = []; @@ -241,8 +263,15 @@ private function applyFirstSeen(?array $updates, bool $classified, ?array $reaso $current['reboot:' . $reason->key()] = $reason->since; } } + if ($services !== null) { + $scope[] = 'restart'; + foreach ($services as $service) { + $current['restart:' . $service->unit] = null; + } + } + if ($scope === []) { - return [$updates, $reasons]; + return [$updates, $reasons, $services]; } $seen = $this->store->firstSeen($current, $now, $scope); @@ -256,6 +285,10 @@ private function applyFirstSeen(?array $updates, bool $classified, ?array $reaso static fn (RebootReason $r): RebootReason => $r->withSince($seen['reboot:' . $r->key()] ?? $r->since), $reasons, ), + $services === null ? null : array_map( + static fn (ServiceRestart $s): ServiceRestart => $s->withSince($seen['restart:' . $s->unit] ?? null), + $services, + ), ]; } @@ -271,4 +304,10 @@ private static function sortUpdates(array $updates): array { return $updates; } + + private function latest(?int ...$times): ?int { + $times = array_filter($times, 'is_int'); + + return $times === [] ? null : max($times); + } } diff --git a/lib/OsStatus/Restart/ServiceRestart.php b/lib/OsStatus/Restart/ServiceRestart.php new file mode 100644 index 00000000..bd07dee1 --- /dev/null +++ b/lib/OsStatus/Restart/ServiceRestart.php @@ -0,0 +1,45 @@ + $this->unit, 'since' => $this->since, 'stack' => NextcloudStack::isStackService($this->unit)]; + } + + public static function fromArray(mixed $data): ?self { + if (!is_array($data) || !is_string($data['unit'] ?? null) || preg_match('/^[A-Za-z0-9@._:\\\\-]{1,200}$/', $data['unit']) !== 1) { + return null; + } + + return new self($data['unit'], Values::int($data['since'] ?? null)); + } + + public function withSince(?int $since): self { + return new self($this->unit, $since); + } +} diff --git a/lib/OsStatus/Verdict/Concern.php b/lib/OsStatus/Verdict/Concern.php index 960a8ffd..8a08ee4f 100644 --- a/lib/OsStatus/Verdict/Concern.php +++ b/lib/OsStatus/Verdict/Concern.php @@ -28,6 +28,7 @@ final class Concern { public const AUTOMATIC_UPDATES = 'automatic-updates'; public const FAILED_UNITS = 'failed-units'; public const TIME_SYNC = 'time-sync'; + public const HELPER = 'helper'; /** * @param self::LEVEL_* $level diff --git a/lib/OsStatus/Verdict/OsVerdict.php b/lib/OsStatus/Verdict/OsVerdict.php index 9174a94a..e0e7041a 100644 --- a/lib/OsStatus/Verdict/OsVerdict.php +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -9,12 +9,14 @@ namespace OCA\ServerInfo\OsStatus\Verdict; +use OCA\ServerInfo\OsStatus\Helper\HelperState; use OCA\ServerInfo\OsStatus\NextcloudStack; use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; use OCA\ServerInfo\OsStatus\Restart\PhpProcessSample; +use OCA\ServerInfo\OsStatus\Restart\ServiceRestart; use OCA\ServerInfo\OsStatus\Support\SupportStatus; /** @@ -41,7 +43,7 @@ public function evaluate(OsStatus $status, ?PhpProcessSample $php, ?int $phpOutd $concerns[] = $this->updates($status, $thresholds, $now); $concerns[] = $this->reboot($status, $thresholds, $now); } - $concerns[] = $this->restart($php, $phpOutdatedSince, $thresholds, $now); + $concerns[] = $this->restart($status, $php, $phpOutdatedSince, $thresholds, $now); $concerns[] = $this->support($status, $thresholds, $now); if ($managed) { $concerns[] = $this->packageLists($status, $thresholds, $now); @@ -49,6 +51,7 @@ public function evaluate(OsStatus $status, ?PhpProcessSample $php, ?int $phpOutd } $concerns[] = $this->failedUnits($status); $concerns[] = $this->timeSync($status); + $concerns[] = $this->helper($status); return new Verdict($concerns); } @@ -104,20 +107,36 @@ private function reboot(OsStatus $status, Thresholds $thresholds, int $now): Con ]); } - private function restart(?PhpProcessSample $php, ?int $phpOutdatedSince, Thresholds $thresholds, int $now): Concern { - if ($php === null) { + private function restart(OsStatus $status, ?PhpProcessSample $php, ?int $phpOutdatedSince, Thresholds $thresholds, int $now): Concern { + $services = $status->environment->managesPackages() ? $status->restartServices : null; + if ($php === null && $services === null) { return new Concern(Concern::RESTART, Concern::LEVEL_UNKNOWN); } - if (!$php->isOutdated()) { - return new Concern(Concern::RESTART, Concern::LEVEL_OK, ['php' => false, 'days' => null]); + + $phpOutdated = $php !== null && $php->isOutdated(); + $services ??= []; + $facts = [ + 'php' => $phpOutdated, + 'services' => count($services), + 'stack' => count(array_filter($services, static fn (ServiceRestart $s): bool => NextcloudStack::isStackService($s->unit))), + 'days' => null, + ]; + + if (!$phpOutdated && $services === []) { + return new Concern(Concern::RESTART, Concern::LEVEL_OK, $facts); } - $days = self::days($now - ($phpOutdatedSince ?? $now)); + $since = $now; + foreach ($services as $service) { + $since = min($since, $service->since ?? $now); + } + if ($phpOutdated) { + $since = min($since, $phpOutdatedSince ?? $now); + } + $days = self::days($now - $since); + $facts['days'] = $days; - return new Concern(Concern::RESTART, $days >= $thresholds->restartWarning ? Concern::LEVEL_WARNING : Concern::LEVEL_INFO, [ - 'php' => true, - 'days' => $days, - ]); + return new Concern(Concern::RESTART, $days >= $thresholds->restartWarning ? Concern::LEVEL_WARNING : Concern::LEVEL_INFO, $facts); } private function support(OsStatus $status, Thresholds $thresholds, int $now): Concern { @@ -200,6 +219,21 @@ private function timeSync(OsStatus $status): Concern { return new Concern(Concern::TIME_SYNC, $level, ['source' => $status->timeSource]); } + private function helper(OsStatus $status): Concern { + $helper = $status->helper; + $level = match ($helper->state) { + HelperState::STATE_OK => $helper->outdated || $helper->errors !== [] ? Concern::LEVEL_INFO : Concern::LEVEL_OK, + HelperState::STATE_ABSENT => Concern::LEVEL_OK, + HelperState::STATE_INSECURE => Concern::LEVEL_WARNING, + default => Concern::LEVEL_INFO, + }; + + return new Concern(Concern::HELPER, $level, [ + 'state' => $helper->state, + 'outdated' => $helper->outdated, + ]); + } + /** * Applies a pair of thresholds; below the warning threshold a finding is * worth showing but not worth an alert. diff --git a/tests/data/os/alma-9-dnf-check-update-pending b/tests/data/os/alma-9-dnf-check-update-pending new file mode 100644 index 00000000..69dc4265 --- /dev/null +++ b/tests/data/os/alma-9-dnf-check-update-pending @@ -0,0 +1,10 @@ + +curl-minimal.aarch64 7.76.1-40.el9_8.8 baseos +glibc.aarch64 2.34-283.el9_8 baseos +glibc-common.aarch64 2.34-283.el9_8 baseos +glibc-minimal-langpack.aarch64 2.34-283.el9_8 baseos +libcurl-minimal.aarch64 7.76.1-40.el9_8.8 baseos +openssl.aarch64 1:3.5.8-2.el9_8 baseos +openssl-fips-provider.aarch64 1:3.5.8-2.el9_8 baseos +openssl-libs.aarch64 1:3.5.8-2.el9_8 baseos +tzdata.noarch 2026e-1.el9_8 baseos diff --git a/tests/data/os/alma-9-dnf-updateinfo-security b/tests/data/os/alma-9-dnf-updateinfo-security new file mode 100644 index 00000000..d320318b --- /dev/null +++ b/tests/data/os/alma-9-dnf-updateinfo-security @@ -0,0 +1,9 @@ +Last metadata expiration check: 0:11:04 ago on Mon Oct 19 18:12:41 2026. +ALSA-2026:80511 Moderate/Sec. curl-minimal-7.76.1-40.el9_8.8.aarch64 +ALSA-2026:79281 Moderate/Sec. glibc-2.34-283.el9_8.aarch64 +ALSA-2026:79281 Moderate/Sec. glibc-common-2.34-283.el9_8.aarch64 +ALSA-2026:79281 Moderate/Sec. glibc-minimal-langpack-2.34-283.el9_8.aarch64 +ALSA-2026:80511 Moderate/Sec. libcurl-minimal-7.76.1-40.el9_8.8.aarch64 +ALSA-2026:77396 Important/Sec. openssl-1:3.5.8-2.el9_8.aarch64 +ALSA-2026:77396 Important/Sec. openssl-libs-1:3.5.8-2.el9_8.aarch64 +ALSA-2026:75571 Important/Sec. sudo-1.9.17p2-3.el9_8.3.aarch64 diff --git a/tests/data/os/fedora-44-dnf-advisory-security b/tests/data/os/fedora-44-dnf-advisory-security new file mode 100644 index 00000000..b53994cf --- /dev/null +++ b/tests/data/os/fedora-44-dnf-advisory-security @@ -0,0 +1,5 @@ +Name Type Severity Package Issued +FEDORA-2026-12f3f3d569 security Important openssl-libs-1:3.5.9-1.fc44.aarch64 2026-10-03 01:09:41 +FEDORA-2026-7d4732cbab security Important sudo-1.9.17-10.p2.fc44.aarch64 2026-10-09 01:06:38 +FEDORA-2026-a06b828d65 security Moderate curl-8.18.0-14.fc44.aarch64 2026-10-14 01:20:20 +FEDORA-2026-a06b828d65 security Moderate libcurl-8.18.0-14.fc44.aarch64 2026-10-14 01:20:20 diff --git a/tests/data/os/fedora-44-dnf-check-update-pending b/tests/data/os/fedora-44-dnf-check-update-pending new file mode 100644 index 00000000..4b6bcb3a --- /dev/null +++ b/tests/data/os/fedora-44-dnf-check-update-pending @@ -0,0 +1,8 @@ +Upgrades +curl.aarch64 8.18.0-14.fc44 updates +glibc.aarch64 2.43-9.fc44 updates +glibc-common.aarch64 2.43-9.fc44 updates +glibc-minimal-langpack.aarch64 2.43-9.fc44 updates +libcurl.aarch64 8.18.0-14.fc44 updates +openssl-libs.aarch64 1:3.5.9-1.fc44 updates +tzdata.noarch 2026e-1.fc44 updates diff --git a/tests/lib/OsStatus/FakeHost.php b/tests/lib/OsStatus/FakeHost.php index 27d0b835..4ae96067 100644 --- a/tests/lib/OsStatus/FakeHost.php +++ b/tests/lib/OsStatus/FakeHost.php @@ -104,6 +104,11 @@ public function changedAt(string $path): ?int { return $this->changed[$path] ?? null; } + #[\Override] + public function ownership(string $path): ?array { + return $this->owners[$path] ?? null; + } + #[\Override] public function readLink(string $path): ?string { return $this->links[$path] ?? null; @@ -150,6 +155,11 @@ public function uname(string $mode): string { }; } + #[\Override] + public function hostname(): string { + return $this->host; + } + #[\Override] public function osFamily(): string { return $this->family; diff --git a/tests/lib/OsStatus/HelperReportReaderTest.php b/tests/lib/OsStatus/HelperReportReaderTest.php new file mode 100644 index 00000000..cd3bb363 --- /dev/null +++ b/tests/lib/OsStatus/HelperReportReaderTest.php @@ -0,0 +1,162 @@ +host = new FakeHost(); + $this->reader = new HelperReportReader($this->host); + $this->owned(self::PATH, 0o100644); + $this->owned('/var/lib/serverinfo', 0o40755); + } + + private function owned(string $path, int $mode, int $owner = 0): void { + $this->host->owners[$path] = ['owner' => $owner, 'mode' => $mode]; + } + + private function report(array $overrides = []): string { + return (string)json_encode(array_merge([ + 'schema' => 1, + 'helperVersion' => HelperReportReader::BUNDLED_VERSION, + 'generatedAt' => self::NOW - 3600, + 'hostname' => 'cloud.example.org', + 'bootTime' => self::BOOT + 3, + 'listsRefreshedAt' => self::NOW - 3600, + 'restartServices' => ['php8.3-fpm.service', 'bad unit; rm -rf /'], + 'rebootReasons' => [['kind' => 'kernel', 'detail' => '6.8.0-48-generic'], ['kind' => 'bogus']], + 'subscription' => ['kind' => 'suse-ltss', 'active' => true], + 'errors' => ['Refreshing the package lists with apt failed.'], + ], $overrides)); + } + + /** + * @return array{0: HelperState, 1: mixed} + */ + private function read(?string $content = null): array { + $this->host->files[self::PATH] = $content ?? $this->report(); + + return $this->reader->read(self::PATH, self::BOOT, self::NOW); + } + + public function testAValidReport(): void { + [$state, $report] = $this->read(); + + $this->assertSame(HelperState::STATE_OK, $state->state); + $this->assertFalse($state->outdated); + $this->assertSame(['Refreshing the package lists with apt failed.'], $state->errors); + $this->assertSame(['php8.3-fpm.service'], array_map(static fn ($s) => $s->unit, $report->restartServices)); + $this->assertCount(1, $report->rebootReasons); + $this->assertTrue($report->subscriptionActive); + } + + public function testNoReport(): void { + unset($this->host->owners[self::PATH]); + + [$state, $report] = $this->reader->read(self::PATH, self::BOOT, self::NOW); + + $this->assertSame(HelperState::STATE_ABSENT, $state->state); + $this->assertNull($report); + } + + public function testAReportTheWebServerCannotRead(): void { + [$state] = $this->reader->read(self::PATH, self::BOOT, self::NOW); + + $this->assertSame(HelperState::STATE_UNREADABLE, $state->state); + } + + public function testOutsideOpenBasedir(): void { + $this->host->basedir = ['/var/www/', '/tmp/']; + unset($this->host->owners[self::PATH]); + + $this->assertSame(HelperState::STATE_UNREADABLE, $this->reader->read(self::PATH, self::BOOT, self::NOW)[0]->state); + } + + public static function dataInsecure(): array { + return [ + 'owned by the web server' => [self::PATH, 0o100644, 33], + 'group writable' => [self::PATH, 0o100664, 0], + 'world writable' => [self::PATH, 0o100646, 0], + 'directory owned by the web server' => ['/var/lib/serverinfo', 0o40755, 33], + 'directory world writable' => ['/var/lib/serverinfo', 0o41777, 0], + ]; + } + + #[DataProvider('dataInsecure')] + public function testReportsOthersCouldForgeAreIgnored(string $path, int $mode, int $owner): void { + $this->owned($path, $mode, $owner); + + [$state, $report] = $this->read(); + + $this->assertSame(HelperState::STATE_INSECURE, $state->state); + $this->assertNull($report); + } + + public static function dataRejected(): array { + $base = [ + 'schema' => 1, 'helperVersion' => '1.0.0', 'generatedAt' => self::NOW - 60, + 'hostname' => 'cloud.example.org', 'bootTime' => self::BOOT, + ]; + + return [ + 'not json' => ['{"schema": 1,', HelperState::STATE_INVALID], + 'not an object' => ['"report"', HelperState::STATE_INVALID], + 'future schema' => [json_encode(['schema' => 2] + $base), HelperState::STATE_UNSUPPORTED], + 'missing host' => [json_encode(array_diff_key($base, ['hostname' => 1])), HelperState::STATE_INVALID], + 'bad version' => [json_encode(['helperVersion' => 'latest'] + $base), HelperState::STATE_INVALID], + 'other host' => [json_encode(['hostname' => 'elsewhere'] + $base), HelperState::STATE_OTHER_HOST], + 'previous boot' => [json_encode(['bootTime' => self::BOOT - 86400] + $base), HelperState::STATE_PREVIOUS_BOOT], + 'stale' => [json_encode(['generatedAt' => self::NOW - 27 * 3600] + $base), HelperState::STATE_STALE], + 'from the future' => [json_encode(['generatedAt' => self::NOW + 3600] + $base), HelperState::STATE_STALE], + 'too large' => [json_encode($base + ['padding' => str_repeat('x', 1024 * 1024)]), HelperState::STATE_INVALID], + 'too deep' => [str_repeat('[', 40) . str_repeat(']', 40), HelperState::STATE_INVALID], + ]; + } + + #[DataProvider('dataRejected')] + public function testRejectedReports(string $content, string $expected): void { + [$state, $report] = $this->read($content); + + $this->assertSame($expected, $state->state); + $this->assertNull($report); + } + + public function testAnOlderHelperIsFlagged(): void { + [$state] = $this->read($this->report(['helperVersion' => '0.9.0'])); + + $this->assertSame(HelperState::STATE_OK, $state->state); + $this->assertTrue($state->outdated); + } + + public function testUnsafePathsFallBackToTheDefault(): void { + $this->assertSame(HelperReportReader::DEFAULT_PATH, HelperReportReader::sanitizePath('/etc/shadow')); + $this->assertSame(HelperReportReader::DEFAULT_PATH, HelperReportReader::sanitizePath('/var/lib/../../etc/x.json')); + $this->assertSame(HelperReportReader::DEFAULT_PATH, HelperReportReader::sanitizePath('relative.json')); + $this->assertSame('/srv/reports/os.json', HelperReportReader::sanitizePath('/srv/reports/os.json')); + } + + public function testBundledVersionMatchesTheScript(): void { + $script = (string)file_get_contents(__DIR__ . '/../../../bin/serverinfo-os-report'); + + $this->assertMatchesRegularExpression('/^VERSION=' . preg_quote(HelperReportReader::BUNDLED_VERSION, '/') . '$/m', $script); + $this->assertMatchesRegularExpression('/^OUTPUT=' . preg_quote(HelperReportReader::DEFAULT_PATH, '/') . '$/m', $script); + } +} diff --git a/tests/lib/OsStatus/HelperScriptTest.php b/tests/lib/OsStatus/HelperScriptTest.php new file mode 100644 index 00000000..0ec334b3 --- /dev/null +++ b/tests/lib/OsStatus/HelperScriptTest.php @@ -0,0 +1,138 @@ +runner = new CommandRunner(); + if (!$this->runner->isAvailable() || $this->runner->resolve('sh') === null) { + $this->markTestSkipped('No shell to run'); + } + } + + private function runScript(string ...$arguments): array { + $result = $this->runner->run('sh', [realpath(self::SCRIPT) ?: self::SCRIPT, ...$arguments], 30); + $this->assertNotNull($result); + + return [$result->exitCode, $result->stdout, $result->stderr]; + } + + public function testIsValidPosixShell(): void { + $this->assertSame(0, $this->runner->run('sh', ['-n', realpath(self::SCRIPT) ?: self::SCRIPT], 10)?->exitCode); + } + + public function testVersion(): void { + $this->assertSame([0, HelperReportReader::BUNDLED_VERSION . "\n", ''], $this->runScript('--version')); + } + + public function testHelp(): void { + [$exit, $stdout] = $this->runScript('--help'); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('--refresh', $stdout); + } + + public function testUnknownOptions(): void { + $this->assertSame(1, $this->runScript('--install-everything')[0]); + } + + public function testUnsafeOutputPathsAreRefusedBeforeAnythingElse(): void { + foreach (['relative.json', '/etc/shadow', '/var/lib/../../etc/x.json', '/var/lib/a b.json', '/var/lib/x.json;id'] as $path) { + [$exit, , $stderr] = $this->runScript('--output', $path); + $this->assertSame(1, $exit, $path); + $this->assertStringNotContainsString('must run as root', $stderr, $path); + } + } + + /** + * Runs dnf_updates_json from the script on captured output. + */ + private function dnfUpdates(string $checkUpdate, string $advisories, string $installed): array { + $script = realpath(self::SCRIPT) ?: self::SCRIPT; + $result = $this->runner->run('sh', [ + '-c', + 'SERVERINFO_OS_REPORT_FUNCTIONS_ONLY=1; . "$1"; dnf_updates_json "$2" "$3" "$4"', + 'sh', $script, $checkUpdate, $advisories, $installed, + ], 30); + $this->assertSame(0, $result?->exitCode, (string)$result?->stderr); + + $report = HelperReport::fromArray([ + 'generatedAt' => 1, 'helperVersion' => '1.0.0', 'hostname' => 'h', + 'pendingUpdates' => json_decode($result->stdout, true, 16, JSON_THROW_ON_ERROR), + ]); + + $updates = []; + foreach ($report->pendingUpdates ?? [] as $update) { + $updates[$update->name] = [$update->installedVersion, $update->candidateVersion, $update->security, $update->advisories, $update->severity]; + } + + return $updates; + } + + public function testDnf4PendingUpdatesForTheReport(): void { + $updates = $this->dnfUpdates( + FakeHost::fixture('alma-9-dnf-check-update-pending'), + FakeHost::fixture('alma-9-dnf-updateinfo-security'), + "glibc 0:2.34-275.el9_8\nopenssl-libs 1:3.5.8-1.el9_8\n", + ); + + $this->assertCount(9, $updates); + $this->assertSame(['2.34-275.el9_8', '2.34-283.el9_8', true, ['ALSA-2026:79281'], 'Moderate'], $updates['glibc']); + $this->assertSame(['1:3.5.8-1.el9_8', '1:3.5.8-2.el9_8', true, ['ALSA-2026:77396'], 'Important'], $updates['openssl-libs']); + $this->assertSame([null, '2026e-1.el9_8', false, [], null], $updates['tzdata']); + $this->assertArrayNotHasKey('sudo', $updates, 'An advisory alone does not make an update pending'); + } + + public function testDnf5PendingUpdatesForTheReport(): void { + $updates = $this->dnfUpdates( + FakeHost::fixture('fedora-44-dnf-check-update-pending'), + FakeHost::fixture('fedora-44-dnf-advisory-security'), + '', + ); + + $this->assertSame(['curl', 'glibc', 'glibc-common', 'glibc-minimal-langpack', 'libcurl', 'openssl-libs', 'tzdata'], array_keys($updates)); + $this->assertSame([null, '1:3.5.9-1.fc44', true, ['FEDORA-2026-12f3f3d569'], 'Important'], $updates['openssl-libs']); + $this->assertFalse($updates['glibc'][2]); + } + + public function testDnfOutputThatIsNotPlainIsDropped(): void { + $updates = $this->dnfUpdates( + "evil\",\"x.x86_64 1-1 repo\nfine.noarch 1-2 base\"os\nok.noarch 2-1 base\n", + 'RHSA-1"]} Important/Sec. ok-2-1.noarch', + '', + ); + + $this->assertSame(['ok'], array_keys($updates)); + $this->assertSame([], $updates['ok'][3]); + } + + public function testRequiresRoot(): void { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('Running as root'); + } + + [$exit, , $stderr] = $this->runScript('--dry-run'); + + $this->assertSame(1, $exit); + $this->assertStringContainsString('must run as root', $stderr); + } +} diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index c67fc0ee..859ae757 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -16,6 +16,8 @@ use OCA\ServerInfo\OsStatus\Health\SecurityProbe; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\Health\TimeSyncProbe; +use OCA\ServerInfo\OsStatus\Helper\HelperReportReader; +use OCA\ServerInfo\OsStatus\Helper\HelperState; use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\OsStatusCollector; use OCA\ServerInfo\OsStatus\OsStatusStore; @@ -78,6 +80,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { $systemd, new TimeSyncProbe($host), new SecurityProbe($host), + new HelperReportReader($host), new OsStatusStore($appConfig), $this->createMock(LoggerInterface::class), ); @@ -130,11 +133,13 @@ public function testAnUbuntuHost(): void { $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->assertNull($status->restartServices, 'Without the helper other services are unknown'); $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(HelperState::STATE_ABSENT, $status->helper->state); $this->assertSame(self::BOOT, $status->bootTime); } @@ -159,6 +164,43 @@ public function testAContainerIsNotAskedAboutPackagesOrReboots(): void { $this->assertNotContains('apt-get -s -o Debug::NoLocking=1 dist-upgrade', $this->host->ran); } + public function testHelperFillsWhatTheWebServerCannotSee(): void { + $this->withPendingUpdates(); + $this->host->owners[HelperReportReader::DEFAULT_PATH] = ['owner' => 0, 'mode' => 0o100644]; + $this->host->owners['/var/lib/serverinfo'] = ['owner' => 0, 'mode' => 0o40755]; + $this->host->withFile(HelperReportReader::DEFAULT_PATH, (string)json_encode([ + 'schema' => 1, 'helperVersion' => '1.0.0', 'generatedAt' => self::NOW - 600, 'hostname' => 'cloud.example.org', + 'bootTime' => self::BOOT, 'listsRefreshedAt' => self::NOW - 600, + 'restartServices' => ['php8.3-fpm.service'], + 'rebootReasons' => [['kind' => 'microcode', 'detail' => null]], + ])); + + $status = $this->collector()->collect(); + + $this->assertSame(HelperState::STATE_OK, $status->helper->state); + $this->assertSame('php8.3-fpm.service', $status->restartServices[0]->unit); + $this->assertSame(self::NOW, $status->restartServices[0]->since); + $this->assertSame(self::NOW - 600, $status->listsRefreshedAt); + $this->assertContains(RebootReason::KIND_MICROCODE, array_map(static fn ($r) => $r->kind, $status->rebootReasons ?? [])); + } + + public function testUpdatesFromTheHelperWhenTheWebServerCannotAsk(): void { + $this->host->owners[HelperReportReader::DEFAULT_PATH] = ['owner' => 0, 'mode' => 0o100644]; + $this->host->owners['/var/lib/serverinfo'] = ['owner' => 0, 'mode' => 0o40755]; + $this->host->withFile(HelperReportReader::DEFAULT_PATH, (string)json_encode([ + 'schema' => 1, 'helperVersion' => '1.0.0', 'generatedAt' => self::NOW - 600, 'hostname' => 'cloud.example.org', + 'bootTime' => self::BOOT, + 'pendingUpdates' => [['name' => 'openssl', 'installed' => '1', 'candidate' => '2', 'security' => true]], + ])); + + $status = $this->collector()->collect(); + + $this->assertNull($status->packageManager, 'apt-get is not available to the web server here'); + $this->assertSame(OsStatus::SOURCE_HELPER, $status->updatesSource); + $this->assertSame('openssl', $status->updates[0]->name); + $this->assertContains('No supported package manager was found for Ubuntu 24.04.3 LTS.', $status->diagnostics); + } + public function testFailuresBecomeDiagnostics(): void { $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], '', 100, "E: The package lists are damaged\n"); diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php index d8590d89..f93a6bee 100644 --- a/tests/lib/OsStatus/OsVerdictTest.php +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -10,11 +10,13 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\OsStatus\Environment; +use OCA\ServerInfo\OsStatus\Helper\HelperState; use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; use OCA\ServerInfo\OsStatus\Restart\PhpProcessSample; +use OCA\ServerInfo\OsStatus\Restart\ServiceRestart; use OCA\ServerInfo\OsStatus\Support\SupportStatus; use OCA\ServerInfo\OsStatus\Verdict\Concern; use OCA\ServerInfo\OsStatus\Verdict\OsVerdict; @@ -38,6 +40,7 @@ private function osStatus(array $overrides = []): OsStatus { 'listsRefreshedAt' => self::NOW - 3600, 'automaticUpdates' => new AutomaticUpdates(AutomaticUpdates::STATE_ENABLED, 'unattended-upgrades', AutomaticUpdates::SCOPE_SECURITY), 'rebootReasons' => [], + 'restartServices' => [], 'livePatches' => [], 'failedUnits' => [], 'timeSynchronized' => true, @@ -118,19 +121,20 @@ public function testRebootEscalatesAndLivePatchingSoftensKernelReboots(): void { $this->assertSame(Concern::LEVEL_ERROR, $mixed?->level, 'A live patch does not cover the C library'); } - public function testRestartOfPhp(): void { + public function testRestartOfPhpAndServices(): 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); + $old = $this->evaluate($this->osStatus(['restartServices' => [new ServiceRestart('redis-server.service', self::NOW - 4 * self::DAY)]]))->get(Concern::RESTART); $this->assertSame(Concern::LEVEL_WARNING, $old?->level); + $this->assertSame(1, $old->facts['stack']); } public function testRestartIsUnknownWithoutAnyInformation(): void { - $this->assertSame(Concern::LEVEL_UNKNOWN, $this->evaluate($this->osStatus())->get(Concern::RESTART)?->level); + $this->assertSame(Concern::LEVEL_UNKNOWN, $this->evaluate($this->osStatus(['restartServices' => null]))->get(Concern::RESTART)?->level); } public static function dataSupport(): array { @@ -162,12 +166,14 @@ public function testHealth(): void { 'failedUnits' => ['logrotate.service', 'php8.3-fpm.service'], 'timeSynchronized' => false, 'automaticUpdates' => new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED), + 'helper' => new HelperState(HelperState::STATE_INSECURE, '/var/lib/serverinfo/os-report.json'), ])); $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->get(Concern::HELPER)?->level); $this->assertSame(Concern::LEVEL_WARNING, $verdict->level()); } @@ -176,6 +182,7 @@ public function testApplicationContainersSkipWhatIsNotTheirs(): void { 'environment' => new Environment(Environment::KIND_APPLICATION_CONTAINER, 'docker'), 'updates' => null, 'rebootReasons' => null, + 'restartServices' => null, ]), new PhpProcessSample(self::NOW, 'apache2handler', [], 'apache2', null)); $ids = array_map(static fn (Concern $c): string => $c->id, $verdict->concerns); diff --git a/tests/lib/OsStatus/ThresholdsAndStatusTest.php b/tests/lib/OsStatus/ThresholdsAndStatusTest.php index a53f787d..45cfac88 100644 --- a/tests/lib/OsStatus/ThresholdsAndStatusTest.php +++ b/tests/lib/OsStatus/ThresholdsAndStatusTest.php @@ -10,12 +10,14 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\OsStatus\Environment; +use OCA\ServerInfo\OsStatus\Helper\HelperState; use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\HeldPackage; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; +use OCA\ServerInfo\OsStatus\Restart\ServiceRestart; use OCA\ServerInfo\OsStatus\Support\SupportStatus; class ThresholdsAndStatusTest extends \Test\TestCase { @@ -38,6 +40,7 @@ public function testStatusRoundTrip(): void { 1792300000, new AutomaticUpdates(AutomaticUpdates::STATE_ENABLED, 'unattended-upgrades', AutomaticUpdates::SCOPE_SECURITY), [new RebootReason(RebootReason::KIND_KERNEL, '7.0.0-40-generic', [], 1792100000)], + [new ServiceRestart('php8.5-fpm.service', 1792200000)], [], ['logrotate.service'], true, @@ -46,6 +49,7 @@ public function testStatusRoundTrip(): void { 'enabled', ['all' => 'sudo apt update && sudo apt full-upgrade', 'security' => null], '/var/lib/dpkg/status', + new HelperState(HelperState::STATE_OK, '/var/lib/serverinfo/os-report.json', 1792410000, '1.0.0'), 1792000000, ['diagnostic'], ); @@ -72,5 +76,7 @@ public function testStackPackagesAreMarked(): void { $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']); + $this->assertTrue((new ServiceRestart('mariadb.service'))->toArray()['stack']); + $this->assertFalse((new ServiceRestart('ssh.service'))->toArray()['stack']); } }