Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9bf0743
feat(os): collect the operating system status
karlitschek Oct 19, 2026
2f0c96c
fix(os): treat missing package lists as unknown updates
karlitschek Oct 22, 2026
284aa2b
fix(os): report unknown, exit 3, when the updates are not known
karlitschek Oct 22, 2026
421cef1
fix(os): count a pending security update per package, not per version
karlitschek Oct 22, 2026
e7d4ae8
fix(os): no core-library reboot for systemd or in system containers
karlitschek Oct 22, 2026
38a3e80
fix(os): keep each error threshold at or above its warning threshold
karlitschek Oct 22, 2026
03fb879
fix(os): collect the operating system status only from system cron
karlitschek Oct 22, 2026
52d55c0
fix(os): no support dates for Raspbian or NixOS unstable
karlitschek Oct 22, 2026
635481d
docs(os): list what is read and run, the exit code 3 and the cron req…
karlitschek Oct 22, 2026
aeb0016
fix(build): regenerate the license files from the locked dependencies
karlitschek Oct 22, 2026
2451a33
fix(os): never count interrupted command output as complete
karlitschek Oct 22, 2026
bac4a50
fix(os): try apt when open_basedir hides it, and remember missing bin…
karlitschek Oct 23, 2026
7948a5e
fix(os): take the package list age from verified files, not lists/par…
karlitschek Oct 23, 2026
9fd93ca
fix(os): no permanent "kernel removed" for provider and netboot kernels
karlitschek Oct 23, 2026
6eefcad
fix(os): require a package change before a core library asks for a re…
karlitschek Oct 23, 2026
ed28f95
fix(os): keep the PHP process's first sighting under its own key
karlitschek Oct 23, 2026
b315697
perf(os): keep the collected status in app data, not in app config
karlitschek Oct 23, 2026
6c418d5
fix(os): warn about an old status, and keep one failing probe from en…
karlitschek Oct 23, 2026
0153570
refactor(os): give SupportTable a constructor without a scalar parameter
karlitschek Oct 23, 2026
e462a8d
docs(os): describe only what this part of the stack delivers
karlitschek Oct 23, 2026
ec559b6
fix(os): only store apt updates whose names and versions look like apt's
karlitschek Oct 23, 2026
9448c0f
fix(os): count apt updates as security only from the distribution's a…
karlitschek Oct 23, 2026
934a33d
refactor(os): declare occ serverinfo:os-status with attributes
karlitschek Oct 23, 2026
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
1 change: 1 addition & 0 deletions .nextcloudignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
.nextcloudignore
.php-cs-fixer.dist.php
.tx/
build/
eslint.config.mjs
l10n/.gitkeep
psalm.xml
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,53 @@ provided end-points.
This app is part of the standard Nextcloud package and can be found in the
directory **nextcloud/apps/serverinfo**

## Operating system

The app keeps track of what keeps the Linux distribution under Nextcloud
secure:

- pending updates, security fixes first, with the packages Nextcloud itself
runs on (PHP, the web server, the database, Redis …) marked
- whether a reboot is needed, and why (a newer kernel, updated core libraries,
the distribution asking for one), with live kernel patches taken into account
- how long the release still receives security updates, and whether a paid
extension (Ubuntu Pro or Debian ELTS) is active
- automatic updates, package list age, held packages, failed system services,
time synchronisation, SELinux and AppArmor

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

Nothing is installed or changed. To find out, the app reads `/etc/os-release`,
`/proc` (uptime, `/proc/1/cgroup`), `/sys/class/dmi/id`,
`/sys/kernel/livepatch`, the SELinux and AppArmor state under `/sys`, the
package manager's configuration, state and logs, `/run/reboot-required` and the
installed kernel modules. It runs the package manager's query commands (updates
only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and
`systemd-detect-virt`. Anything it may not read or run is reported as unknown.

The status is collected by a background job once an hour, when background
jobs run from system cron; under AJAX or webcron the commands would hold a web
request for minutes. A status older than three job intervals (at least six
hours), or background jobs that do not run from system cron, count as a
warning, so an old all-clear is never shown as current. Admins can also check
it right away with:

```
occ serverinfo:os-status [--refresh] [--output=json]
```

The command shows how old the status is and exits with 0, 1, 2 or 3 for ok,
warning, error and unknown, so it can be used as a monitoring check. Unknown means the status has not been
collected yet, or nothing is wrong but the pending updates could not be
determined. When something counts as a warning or an error is set by
the `os_*_days` app config values, see `lib/Config/ConfigLexicon.php`.

Release support dates are bundled with the app (see
`build/update-os-support.php`, data from [endoflife.date](https://endoflife.date),
MIT License); the app never looks them up online.

## API

The API provides a lot of information about a running Nextcloud
Expand Down
6 changes: 6 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ path = "css/**.css"
precedence = "aggregate"
SPDX-FileCopyrightText = "Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = "resources/os-support.json"
precedence = "aggregate"
SPDX-FileCopyrightText = "2020 endoflife.date contributors"
SPDX-License-Identifier = "MIT"
6 changes: 4 additions & 2 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
<id>serverinfo</id>
<name>Monitoring</name>
<summary>Monitoring app with useful server information</summary>
<description>Provides useful server information, such as CPU load, RAM usage, disk usage, number of users, etc.</description>
<version>8.0.0-dev.0</version>
<description>Provides useful server information, such as CPU load, RAM usage, disk usage, number of users, etc. Shows pending operating system updates, required reboots and service restarts, and how long the Linux distribution is still supported.</description>
<version>8.0.0-dev.1</version>
<licence>AGPL-3.0-or-later</licence>
<author>Bjoern Schiessle</author>
<author>Ivan Sein Santiago</author>
Expand All @@ -24,10 +24,12 @@
<background-jobs>
<job>OCA\ServerInfo\Jobs\UpdateStorageStats</job>
<job>OCA\ServerInfo\Jobs\UpdateJobStats</job>
<job>OCA\ServerInfo\Jobs\UpdateOsStatus</job>
</background-jobs>
<commands>
<command>OCA\ServerInfo\Commands\UpdateStorageStats</command>
<command>OCA\ServerInfo\Commands\UpdateJobStats</command>
<command>OCA\ServerInfo\Commands\ShowOsStatus</command>
</commands>
<settings>
<admin>OCA\ServerInfo\Settings\AdminSettings</admin>
Expand Down
109 changes: 109 additions & 0 deletions build/update-os-support.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

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

/**
* Regenerates resources/os-support.json from the endoflife.date dataset
* (https://endoflife.date, MIT License).
*
* Run it before a release: php build/update-os-support.php
* The app itself never contacts endoflife.date.
*/

const API = 'https://endoflife.date/api/v1/products/%s/';

/**
* os-release ID => endoflife.date product, how VERSION_ID maps to a release
* cycle, and which paid programme (if any) extends security support.
*/
const PRODUCTS = [
'ubuntu' => ['product' => 'ubuntu', 'cycle' => 'minor', 'extended' => 'ubuntu-pro'],
'debian' => ['product' => 'debian', 'cycle' => 'major', 'extended' => 'debian-elts', 'lts' => true],
'rhel' => ['product' => 'rhel', 'cycle' => 'major', 'extended' => 'rhel-els'],
'almalinux' => ['product' => 'almalinux', 'cycle' => 'major', 'extended' => null],
'rocky' => ['product' => 'rocky-linux', 'cycle' => 'major', 'extended' => null],
'centos-stream' => ['product' => 'centos-stream', 'cycle' => 'major', 'extended' => null],
'centos' => ['product' => 'centos', 'cycle' => 'major', 'extended' => null],
'ol' => ['product' => 'oracle-linux', 'cycle' => 'major', 'extended' => 'oracle-extended'],
'fedora' => ['product' => 'fedora', 'cycle' => 'major', 'extended' => null],
'opensuse-leap' => ['product' => 'opensuse', 'cycle' => 'minor', 'extended' => null],
'sles' => ['product' => 'sles', 'cycle' => 'minor', 'extended' => 'suse-ltss'],
'alpine' => ['product' => 'alpine-linux', 'cycle' => 'minor', 'extended' => null],
'amzn' => ['product' => 'amazon-linux', 'cycle' => 'major', 'extended' => null],
];

/** os-release IDs of rolling releases, which have no end of support. */
const ROLLING = [
'arch', 'archarm', 'manjaro', 'endeavouros', 'cachyos', 'garuda',
'opensuse-tumbleweed', 'opensuse-microos', 'opensuse-slowroll',
'gentoo', 'void',
];

/** Releases whose support ended before this are left out; nothing that old runs a current Nextcloud. */
const OLDEST = '2018-01-01';

function fetchProduct(string $product): array {
$context = stream_context_create(['http' => ['timeout' => 120, 'user_agent' => 'nextcloud-serverinfo-build']]);
$json = file_get_contents(sprintf(API, $product), false, $context);
if ($json === false) {
fwrite(STDERR, "Could not download $product\n");
exit(1);
}

$data = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
if (!isset($data['result']['releases']) || !is_array($data['result']['releases'])) {
fwrite(STDERR, "Unexpected response for $product\n");
exit(1);
}

return $data['result']['releases'];
}

function date_or_null(mixed $value): ?string {
return is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : null;
}

$releases = [];
foreach (PRODUCTS as $id => $config) {
foreach (fetchProduct($config['product']) as $release) {
$cycle = (string)($release['name'] ?? '');
$securityEnds = date_or_null($release['eolFrom'] ?? null);
$extendedEnds = $config['extended'] !== null ? date_or_null($release['eoesFrom'] ?? null) : null;
if ($cycle === '' || $securityEnds === null) {
continue;
}
if (max($securityEnds, $extendedEnds ?? '') < OLDEST) {
continue;
}

$entry = ['securityEnds' => $securityEnds];
if (($config['lts'] ?? false) && ($ltsFrom = date_or_null($release['eoasFrom'] ?? null)) !== null) {
$entry['ltsFrom'] = $ltsFrom;
}
if ($extendedEnds !== null && $extendedEnds > $securityEnds) {
$entry['extendedEnds'] = $extendedEnds;
$entry['extended'] = $config['extended'];
}

$releases[$id . ':' . $cycle] = $entry;
}
}

ksort($releases, SORT_NATURAL);

$table = [
'source' => 'endoflife.date, MIT License, https://endoflife.date',
'generatedAt' => gmdate('Y-m-d'),
'cycles' => array_map(static fn (array $c): string => $c['cycle'], PRODUCTS),
'rolling' => ROLLING,
'releases' => $releases,
];

$target = __DIR__ . '/../resources/os-support.json';
file_put_contents($target, json_encode($table, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n");
echo 'Wrote ' . count($releases) . " releases to resources/os-support.json\n";
2 changes: 1 addition & 1 deletion js/serverinfo-main.mjs.license
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ This file is generated from multiple sources. Included packages:
- version: 7.8.5
- license: ISC
- serverinfo
- version: 8.0.0-dev.0
- version: 8.0.0-dev.1
- license: AGPL-3.0-or-later
- tabbable
- version: 6.5.0
Expand Down
2 changes: 1 addition & 1 deletion js/serverinfo-main.mjs.map.license
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ This file is generated from multiple sources. Included packages:
- version: 7.8.5
- license: ISC
- serverinfo
- version: 8.0.0-dev.0
- version: 8.0.0-dev.1
- license: AGPL-3.0-or-later
- tabbable
- version: 6.5.0
Expand Down
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
namespace OCA\ServerInfo\AppInfo;

use OCA\ServerInfo\Config\ConfigLexicon;
use OCA\ServerInfo\OsStatus\Host\IHost;
use OCA\ServerInfo\OsStatus\Host\LocalHost;
use OCA\ServerInfo\SetupChecks\DatabaseChecks;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
Expand All @@ -30,6 +32,7 @@ public function __construct() {
public function register(IRegistrationContext $context): void {
$context->registerConfigLexicon(ConfigLexicon::class);
$context->registerSetupCheck(DatabaseChecks::class);
$context->registerServiceAlias(IHost::class, LocalHost::class);
}

#[\Override]
Expand Down
116 changes: 116 additions & 0 deletions lib/Commands/ShowOsStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

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

namespace OCA\ServerInfo\Commands;

use OCA\ServerInfo\OsStatus\OsStatusCollector;
use OCA\ServerInfo\OsStatus\OsStatusService;
use OCA\ServerInfo\OsStatus\Verdict\Concern;
use OCP\Console\Attribute\AsCommand;
use OCP\Console\Attribute\Option;
use OCP\Console\IOutput;

/**
* @psalm-api
*/
#[AsCommand(
name: 'serverinfo:os-status',
description: 'Shows pending operating system updates, reboots and other concerns',
supportsOutputFormat: true,
)]
class ShowOsStatus {
/** Exit codes of monitoring plugins */
public const EXIT_OK = 0;
public const EXIT_WARNING = 1;
public const EXIT_CRITICAL = 2;
public const EXIT_UNKNOWN = 3;

public function __construct(
private OsStatusCollector $collector,
private OsStatusService $service,
) {
}

/**
* @return self::EXIT_* the monitoring plugin exit code, which needs 3 for unknown
*/
public function __invoke(
IOutput $output,
#[Option(description: 'Collect the status now instead of showing the stored one', shortcut: 'r')]
bool $refresh = false,
): int {
if ($refresh) {
if ($this->collector->collectAndStore() === null) {
$output->writeln('<error>The operating system status could not be collected; see the Nextcloud log.</error>');

return self::EXIT_UNKNOWN;
}
}

$status = $this->service->getStatus();
if ($status === null) {
$output->writeln('The operating system has not been checked yet. Run with --refresh to check it now.');
Comment thread
karlitschek marked this conversation as resolved.

return self::EXIT_UNKNOWN;
}

$verdict = $this->service->evaluate($status);
$concerns = [];
foreach ($verdict?->concerns ?? [] as $concern) {
$concerns[$concern->id] = ['level' => $concern->level] + array_filter(
$concern->facts,
static fn (mixed $fact): bool => $fact !== null && $fact !== [],
);
}
$pendingDays = $verdict?->get(Concern::UPDATES)?->facts['days'] ?? null;
$age = $verdict?->get(Concern::FRESHNESS)?->facts['ageSeconds'] ?? null;
$securityUpdates = $status->securityUpdates();

$output->writeArrayInOutputFormat([
'level' => $verdict?->level() ?? Concern::LEVEL_UNKNOWN,
'collectedAt' => date(DATE_ATOM, $status->collectedAt),
'statusAge' => is_int($age) ? self::formatAge($age) : 'unknown',
'release' => $status->release?->prettyName ?? 'unknown',
'kernel' => $status->kernel,
'environment' => $status->environment->kind,
'packageManager' => $status->packageManager ?? 'none',
'securityUpdates' => $securityUpdates === null ? 'unknown' : count($securityUpdates),
'updates' => $status->updates === null ? 'unknown' : count($status->updates) + $status->updatesOmitted,
'updatesPendingDays' => is_int($pendingDays) ? $pendingDays : 'none',
'rebootRequired' => $status->rebootReasons === null ? 'unknown' : ($status->rebootReasons !== [] ? 'yes' : 'no'),
'concerns' => $concerns,
]);

return self::exitCode($verdict?->level());
}

/**
* A duration in its two largest units, e.g. "3 d 4 h" or "12 min".
*/
public static function formatAge(int $seconds): string {
return match (true) {
$seconds >= 86400 => sprintf('%d d %d h', intdiv($seconds, 86400), intdiv($seconds % 86400, 3600)),
$seconds >= 3600 => sprintf('%d h %d min', intdiv($seconds, 3600), intdiv($seconds % 3600, 60)),
default => sprintf('%d min', intdiv(max(0, $seconds), 60)),
};
}

/**
* @param Concern::LEVEL_*|null $level
* @return self::EXIT_*
*/
public static function exitCode(?string $level): int {
return match ($level) {
Concern::LEVEL_OK, Concern::LEVEL_INFO => self::EXIT_OK,
Concern::LEVEL_WARNING => self::EXIT_WARNING,
Concern::LEVEL_ERROR => self::EXIT_CRITICAL,
default => self::EXIT_UNKNOWN,
};
}
}
Loading
Loading