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']); } }