Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ secure:
- automatic updates, package list age, held packages, failed system services,
time synchronisation, SELinux and AppArmor

Debian and Ubuntu are supported. Inside Docker, Podman, Kubernetes or a snap the status says
Debian, Ubuntu, RHEL and its rebuilds, Fedora, openSUSE and SLES, Arch and
Alpine are supported. Inside Docker, Podman, Kubernetes or a snap the status says
what can and cannot be known from inside; LXC and systemd-nspawn guests are
treated like machines without a kernel of their own.

Expand All @@ -45,6 +46,16 @@ installed kernel modules. It runs the package manager's query commands (updates
only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and
`systemd-detect-virt`. Anything it may not read or run is reported as unknown.

These queries stay offline, with one exception: on Arch, `checkupdates`
downloads the package databases from the mirrors in
`/etc/pacman.d/mirrorlist` into a temporary directory of the web server user,
and `arch-audit` fetches the security advisories from security.archlinux.org.
These are the only outbound connections the operating system status makes. To keep Arch offline as well, run
`occ config:app:set serverinfo os_allow_network --type=boolean --value=false`.
Pending updates then come from the databases root last synced, the age of
those databases counts as the package list age, and security fixes are not
told apart.

The status is collected by a background job once an hour, when background
jobs run from system cron; under AJAX or webcron the commands would hold a web
request for minutes. A status older than three job intervals (at least six
Expand All @@ -69,8 +80,10 @@ 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
run code an update replaced, and only root can refresh the package lists. On
Fedora and other systems with dnf 5, only root can list pending updates at all:
dnf 5 would first copy its whole cache into the web server's home. The
optional script `bin/serverinfo-os-report` adds all of 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.

Expand Down
8 changes: 8 additions & 0 deletions lib/Config/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ConfigLexicon implements ILexicon {
public const OS_EOL_WARNING_DAYS = 'os_eol_warning_days';
public const OS_LISTS_WARNING_DAYS = 'os_lists_warning_days';
public const OS_LISTS_ERROR_DAYS = 'os_lists_error_days';
public const OS_ALLOW_NETWORK = 'os_allow_network';

#[\Override]
public function getStrictness(): Strictness {
Expand Down Expand Up @@ -127,6 +128,13 @@ public function getAppConfigs(): array {
new Entry(self::OS_EOL_WARNING_DAYS, ValueType::INT, 90, 'Days before the end of security support that a warning starts', lazy: true),
new Entry(self::OS_LISTS_WARNING_DAYS, ValueType::INT, 7, 'Days without a package list refresh before a warning', lazy: true),
new Entry(self::OS_LISTS_ERROR_DAYS, ValueType::INT, 30, 'Days without a package list refresh before an error', lazy: true),
new Entry(
self::OS_ALLOW_NETWORK,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting this to true means every hourly cron run on Arch makes outbound connections as the web server user. checkupdates syncs the core/extra databases, several MB whenever they changed, and arch-audit fetches its advisory list. checkupdates also writes $TMPDIR/checkup-db-<uid>. Admins with egress filtering get an hourly diagnostic, mirrors get about 24 database pulls a day per instance, and the README says the app never looks anything up online. Suggest defaulting to false (opt-in), or at least throttling these to about once a day.

ValueType::BOOL,
true,
'Whether the operating system status may use the network as the web server user: on Arch, checkupdates syncs its own package databases from the pacman mirrors and arch-audit fetches the security advisories from security.archlinux.org',
lazy: true,
),
];
}

Expand Down
12 changes: 12 additions & 0 deletions lib/OsStatus/OsStatusCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use OCA\ServerInfo\OsStatus\Host\IHost;
use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates;
use OCA\ServerInfo\OsStatus\Packages\IPackageManager;
use OCA\ServerInfo\OsStatus\Packages\IReportsDiagnostics;
use OCA\ServerInfo\OsStatus\Packages\NeedsRootException;
use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory;
use OCA\ServerInfo\OsStatus\Packages\PendingUpdate;
use OCA\ServerInfo\OsStatus\Packages\ProbeException;
Expand Down Expand Up @@ -117,10 +119,13 @@ public function collect(): OsStatus {

$updates = null;
$updatesSource = null;
$needsRoot = null;
if ($packageManager !== null) {
try {
$updates = $packageManager->getPendingUpdates();
$updatesSource = OsStatus::SOURCE_SYSTEM;
} catch (NeedsRootException $e) {
$needsRoot = $e->getMessage();
} catch (ProbeException $e) {
$diagnostics[] = $e->getMessage();
} catch (\Throwable $e) {
Expand All @@ -133,6 +138,9 @@ public function collect(): OsStatus {
$updates = $reportedUpdates;
$updatesSource = OsStatus::SOURCE_HELPER;
}
if ($needsRoot !== null && $updatesSource !== OsStatus::SOURCE_HELPER) {
$diagnostics[] = $needsRoot;
}

$listsRefreshedAt = $this->latest(
$packageManager !== null ? $this->probe('The package list age', fn (): ?int => $packageManager->getListsRefreshedAt(), null, $diagnostics) : null,
Expand Down Expand Up @@ -165,6 +173,10 @@ public function collect(): OsStatus {
$updates = array_slice($updates, 0, self::MAX_LISTED_UPDATES);
}

if ($packageManager instanceof IReportsDiagnostics) {
array_push($diagnostics, ...$packageManager->getDiagnostics());
}

$timeSync = $this->probe('The time synchronisation', fn (): array => $this->timeSync->probe(), ['synchronized' => null, 'source' => null], $diagnostics);

return new OsStatus(
Expand Down
228 changes: 228 additions & 0 deletions lib/OsStatus/Packages/Apk.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo\OsStatus\Packages;

use OCA\ServerInfo\OsStatus\Host\IHost;

/**
* Alpine Linux and its derivatives such as postmarketOS. Most Nextcloud
* installs on Alpine are containers, where the container check comes first;
* this covers Alpine hosts and LXC guests. apk only reads its cached indexes
* here and never goes online.
*
* @psalm-api
*/
class Apk implements IPackageManager {
private const WORLD = '/etc/apk/world';

public function __construct(
private IHost $host,
) {
}

#[\Override]
public function getId(): string {
return 'apk';
}

#[\Override]
public function isAvailable(): bool {
return $this->host->binaryState('apk') !== false;
}

#[\Override]
public function classifiesSecurity(): bool {
return false;
}

#[\Override]
public function getPendingUpdates(): array {
$result = $this->host->run('apk', ['--no-network', 'list', '--upgradable'], 120);
if ($result === null) {
throw new ProbeException('apk could not be run.');
}
if (!$result->succeeded()) {
throw new ProbeException('apk list --upgradable failed: ' . $result->failure(120));
}

// apk lists every newer version it knows of, including those the
// constraints in /etc/apk/world keep it from installing.
$constraints = [];
foreach (self::parseWorld($this->host->readFile(self::WORLD, 262144) ?? '') as [$name, $operator, $version]) {
if ($operator !== null) {
$constraints[$name][] = [$operator, $version];
}
}

return array_values(array_filter(
self::parseUpgradable($result->stdout),
static function (PendingUpdate $update) use ($constraints): bool {
foreach ($constraints[$update->name] ?? [] as [$operator, $version]) {
if (!self::satisfies($update->candidateVersion, $operator, $version)) {
return false;
}
}

return true;
},
));
}

/**
* Parses `apk list --upgradable`, e.g.
* busybox-1.36.1-r16 x86_64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r15]
*
* @return list<PendingUpdate>
*/
public static function parseUpgradable(string $output): array {
$updates = [];
foreach (preg_split('/\R/', $output) ?: [] as $line) {
if (preg_match('/^(\S+)-(\d\S*-r\d+) \S+ \{[^}]*\} \([^)]*\) \[upgradable from: (\S+)\]$/', trim($line), $matches) !== 1) {
continue;
}

$name = $matches[1];
$installed = str_starts_with($matches[3], $name . '-') ? substr($matches[3], strlen($name) + 1) : null;
$updates[] = new PendingUpdate($name, $installed, $matches[2], false);
}

return $updates;
}

/**
* Packages whose version /etc/apk/world restricts in a way that can hold
* back an upgrade: "=", "<", "<=" and the fuzzy "~". A repository tag
* alone ("curl@edge") only lets the package come from the tagged
* repository as well, and upgrades go on from there.
*/
#[\Override]
public function getHeldPackages(): ?array {
$world = $this->host->readFile(self::WORLD, 262144);
if ($world === null) {
return null;
}

$held = [];
foreach (self::parseWorld($world) as [$name, $operator, $version, $tag]) {
if ($operator !== null && !in_array($operator, ['>', '>='], true)) {
$held[] = new HeldPackage($name, 'apk world', ($tag ?? '') . $operator . $version);
}
}

return $held;
}

/**
* Parses the dependencies in /etc/apk/world: name[@tag][operator version].
* Conflicts ("!name") are left out.
*
* @return list<array{0: string, 1: ?string, 2: string, 3: ?string}> [name, operator, version, tag]
*/
public static function parseWorld(string $world): array {
$entries = [];
foreach (preg_split('/\s+/', trim($world)) ?: [] as $entry) {
if (preg_match('/^([A-Za-z0-9][A-Za-z0-9._+-]*?)(@[A-Za-z0-9._-]+)?(?:(<=|>=|~=|=~|[<>=~])(\S+))?$/', $entry, $matches) !== 1) {
continue;
}
$operator = ($matches[3] ?? '') !== '' ? $matches[3] : null;
$entries[] = [
$matches[1],
$operator === '~=' || $operator === '=~' ? '~' : $operator,
$matches[4] ?? '',
($matches[2] ?? '') !== '' ? $matches[2] : null,
];
}

return $entries;
}

/**
* Whether a version meets a world constraint. "~1.2" matches 1.2 and
* every 1.2.x or 1.2-rN.
*/
public static function satisfies(string $version, string $operator, string $constraint): bool {
if ($operator === '~') {
return $version === $constraint
|| (str_starts_with($version, $constraint) && in_array($version[strlen($constraint)], ['.', '-', '_'], true));
}

$order = self::compareVersions($version, $constraint);

return match ($operator) {
'=' => $order === 0,
'<' => $order < 0,
'<=' => $order <= 0,
'>' => $order > 0,
'>=' => $order >= 0,
default => true,
};
}

/**
* Compares apk versions such as "3.5.8-r0" or "1.2_rc1-r3": the
* upstream version as PHP's version_compare() orders it, then the -rN
* package release.
*/
public static function compareVersions(string $a, string $b): int {
[$baseA, $releaseA] = self::splitRelease($a);
[$baseB, $releaseB] = self::splitRelease($b);

return version_compare($baseA, $baseB) ?: $releaseA <=> $releaseB;
}

/**
* @return array{0: string, 1: int}
*/
private static function splitRelease(string $version): array {
$release = 0;
if (preg_match('/^(.*)-r(\d+)$/', $version, $matches) === 1) {
$version = $matches[1];
$release = (int)$matches[2];
}

// apk's _pre sorts like a release candidate; version_compare() does not know it.
return [str_replace(['_pre', '_'], ['.rc', '.'], $version), $release];
}

#[\Override]
public function getListsRefreshedAt(): ?int {
return ProbeHelper::newestModified($this->host, ['/var/cache/apk/APKINDEX.*.tar.gz']);
}

#[\Override]
public function getLastUpgradeAt(): ?int {
return $this->host->modifiedAt('/lib/apk/db/installed');
}

#[\Override]
public function getAutomaticUpdates(): AutomaticUpdates {
return AutomaticUpdates::unknown();
}

#[\Override]
public function getRebootReasons(): array {
return [];
}

#[\Override]
public function hasOwnRebootCheck(): bool {
return false;
}

#[\Override]
public function getUpgradeCommands(): array {
return ['all' => 'sudo apk upgrade --update-cache', 'security' => null];
}

#[\Override]
public function getPackageDatabasePath(): ?string {
return '/lib/apk/db/installed';
}
}
Loading
Loading