From 260642cc413d9f8fe9b02958fab1c7caeb045749 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 28 Apr 2026 13:59:09 +0200 Subject: [PATCH] feat(services): add lifecycle and connectivity services Adds four read-only service classes used by the upcoming admin dashboard cards. None are wired into existing controllers yet, so this change is a pure addition with no behavior change. * EolInfo - PHP and Nextcloud end-of-life date warnings * OsUpdates - reads /var/lib/update-notifier/updates-available and /var/run/reboot-required (no sudo, no shell) * AppStoreReachability - cached HEAD probe to apps.nextcloud.com * FederationStats - sent / received / trusted-server counts Signed-off-by: Frank Karlitschek --- lib/AppStoreReachability.php | 75 ++++++++++++++++++++ lib/EolInfo.php | 105 ++++++++++++++++++++++++++++ lib/FederationStats.php | 130 +++++++++++++++++++++++++++++++++++ lib/OsUpdates.php | 118 +++++++++++++++++++++++++++++++ 4 files changed, 428 insertions(+) create mode 100644 lib/AppStoreReachability.php create mode 100644 lib/EolInfo.php create mode 100644 lib/FederationStats.php create mode 100644 lib/OsUpdates.php diff --git a/lib/AppStoreReachability.php b/lib/AppStoreReachability.php new file mode 100644 index 00000000..3588e34f --- /dev/null +++ b/lib/AppStoreReachability.php @@ -0,0 +1,75 @@ +appConfig->getValueInt('serverinfo', 'appstore_check_at', 0); + if ($cachedAt > 0 && (time() - $cachedAt) < self::CACHE_TTL) { + return [ + 'reachable' => $this->appConfig->getValueBool('serverinfo', 'appstore_check_reachable'), + 'statusCode' => $this->appConfig->getValueInt('serverinfo', 'appstore_check_status'), + 'latencyMs' => $this->appConfig->getValueInt('serverinfo', 'appstore_check_latency'), + 'checkedAt' => $cachedAt, + 'cached' => true, + ]; + } + + $client = $this->clientService->newClient(); + $start = microtime(true); + $reachable = false; + $status = 0; + try { + $response = $client->head('https://apps.nextcloud.com/api/v1/platform/29.0.0/apps.json', [ + 'timeout' => self::TIMEOUT, + 'connect_timeout' => self::TIMEOUT, + 'verify' => true, + ]); + $status = $response->getStatusCode(); + $reachable = $status >= 200 && $status < 400; + } catch (\Throwable) { + $reachable = false; + } + $latency = (int)round((microtime(true) - $start) * 1000); + $now = time(); + $this->appConfig->setValueInt('serverinfo', 'appstore_check_at', $now); + $this->appConfig->setValueBool('serverinfo', 'appstore_check_reachable', $reachable); + $this->appConfig->setValueInt('serverinfo', 'appstore_check_status', $status); + $this->appConfig->setValueInt('serverinfo', 'appstore_check_latency', $latency); + return [ + 'reachable' => $reachable, + 'statusCode' => $status, + 'latencyMs' => $latency, + 'checkedAt' => $now, + 'cached' => false, + ]; + } +} diff --git a/lib/EolInfo.php b/lib/EolInfo.php new file mode 100644 index 00000000..edec0286 --- /dev/null +++ b/lib/EolInfo.php @@ -0,0 +1,105 @@ + version "8.1" => "2025-12-31" + */ + private const PHP_EOL = [ + '7.4' => '2022-11-28', + '8.0' => '2023-11-26', + '8.1' => '2025-12-31', + '8.2' => '2026-12-31', + '8.3' => '2027-12-31', + '8.4' => '2028-12-31', + '8.5' => '2029-12-31', + ]; + + /** + * Nextcloud major version EOL (community/security end). + * Approximate based on the Nextcloud release schedule. + * + * @var array "27" => "2024-06-30" + */ + private const NC_EOL = [ + '27' => '2024-06-30', + '28' => '2024-12-31', + '29' => '2025-06-30', + '30' => '2025-12-31', + '31' => '2026-06-30', + '32' => '2026-12-31', + '33' => '2027-06-30', + '34' => '2028-06-30', + '35' => '2028-12-31', + ]; + + public function __construct( + private IConfig $config, + private ServerVersion $serverVersion, + ) { + } + + /** + * @return array{ + * php: array{version: string, eol: ?string, daysUntilEol: ?int, status: string}, + * nextcloud: array{version: string, major: string, eol: ?string, daysUntilEol: ?int, status: string} + * } + */ + public function getEolInfo(): array { + $phpMajor = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + $phpEol = self::PHP_EOL[$phpMajor] ?? null; + $ncVersionParts = $this->serverVersion->getVersion(); + $ncVersion = implode('.', $ncVersionParts); + $ncMajor = (string)(int)($ncVersionParts[0] ?? 0); + $ncEol = self::NC_EOL[$ncMajor] ?? null; + + return [ + 'php' => $this->makeEntry($phpMajor, $phpEol), + 'nextcloud' => array_merge( + $this->makeEntry($ncMajor, $ncEol), + ['version' => $ncVersion, 'major' => $ncMajor], + ), + ]; + } + + /** + * @return array{version: string, eol: ?string, daysUntilEol: ?int, status: string} + */ + private function makeEntry(string $version, ?string $eolDate): array { + if ($eolDate === null) { + return ['version' => $version, 'eol' => null, 'daysUntilEol' => null, 'status' => 'unknown']; + } + try { + $eolTs = (new \DateTimeImmutable($eolDate))->getTimestamp(); + } catch (\Throwable) { + return ['version' => $version, 'eol' => $eolDate, 'daysUntilEol' => null, 'status' => 'unknown']; + } + $days = (int)floor(($eolTs - time()) / 86400); + $status = 'ok'; + if ($days < 0) { + $status = 'critical'; + } elseif ($days < 90) { + $status = 'warning'; + } + return [ + 'version' => $version, + 'eol' => $eolDate, + 'daysUntilEol' => $days, + 'status' => $status, + ]; + } +} diff --git a/lib/FederationStats.php b/lib/FederationStats.php new file mode 100644 index 00000000..d746fa27 --- /dev/null +++ b/lib/FederationStats.php @@ -0,0 +1,130 @@ + + * } + */ + public function getFederationStats(): array { + $enabled = $this->appManager->isInstalled('federation') || $this->appManager->isInstalled('federatedfilesharing'); + + try { + $sent = $this->countShareType(IShare::TYPE_REMOTE); + $sentGroup = $this->countShareType(IShare::TYPE_REMOTE_GROUP); + $received = $this->countReceived(); + $trusted = $this->countTrustedServers(); + $top = $this->topPeers(); + } catch (\Throwable) { + $sent = 0; + $sentGroup = 0; + $received = 0; + $trusted = 0; + $top = []; + } + + return [ + 'enabled' => $enabled, + 'sharesSent' => $sent, + 'sharesSentToGroups' => $sentGroup, + 'sharesReceived' => $received, + 'trustedServers' => $trusted, + 'topPeers' => $top, + ]; + } + + private function countShareType(int $type): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id')) + ->from('share') + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter($type))); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } + + private function countReceived(): int { + try { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id'))->from('share_external'); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } catch (\Throwable) { + return 0; + } + } + + private function countTrustedServers(): int { + try { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id'))->from('trusted_servers'); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } catch (\Throwable) { + return 0; + } + } + + /** + * @return list + */ + private function topPeers(int $limit = 5): array { + try { + $qb = $this->db->getQueryBuilder(); + $qb->select('share_with') + ->selectAlias($qb->func()->count('id'), 'count') + ->from('share') + ->where($qb->expr()->in('share_type', $qb->createNamedParameter( + [IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], + \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY, + ))) + ->groupBy('share_with') + ->orderBy('count', 'DESC') + ->setMaxResults($limit); + $result = $qb->executeQuery(); + $out = []; + while (($row = $result->fetch()) !== false) { + $with = (string)($row['share_with'] ?? ''); + $at = strrpos($with, '@'); + $server = $at !== false ? substr($with, $at + 1) : $with; + $out[] = [ + 'server' => $server, + 'count' => (int)($row['count'] ?? 0), + ]; + } + $result->closeCursor(); + return $out; + } catch (\Throwable) { + return []; + } + } +} diff --git a/lib/OsUpdates.php b/lib/OsUpdates.php new file mode 100644 index 00000000..e431185a --- /dev/null +++ b/lib/OsUpdates.php @@ -0,0 +1,118 @@ +, + * summary: string, + * source: string + * } + */ + public function getOsUpdates(): array { + $distro = $this->detectDistro(); + $source = ''; + + $updates = 0; + $security = 0; + $summary = ''; + + // Debian/Ubuntu: update-notifier writes a human-readable file on apt index update. + $notifier = '/var/lib/update-notifier/updates-available'; + if (is_readable($notifier)) { + $content = (string)@file_get_contents($notifier); + $source = $notifier; + [$updates, $security] = $this->parseUpdateNotifier($content); + $summary = trim($content); + } + + // RHEL/Fedora: dnf-automatic writes to this directory on check. + if ($source === '' && is_readable('/var/lib/dnf/updates.txt')) { + $content = (string)@file_get_contents('/var/lib/dnf/updates.txt'); + $source = '/var/lib/dnf/updates.txt'; + $lines = preg_split('/\r?\n/', trim($content)) ?: []; + $updates = count(array_filter($lines, static fn ($l) => trim($l) !== '')); + $summary = $updates > 0 ? "$updates package(s) can be updated." : 'System is up to date.'; + } + + $rebootFlag = file_exists('/var/run/reboot-required') || file_exists('/run/reboot-required'); + $rebootPkgs = []; + foreach (['/var/run/reboot-required.pkgs', '/run/reboot-required.pkgs'] as $pkgFile) { + if (is_readable($pkgFile)) { + $lines = @file($pkgFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $rebootPkgs = array_values(array_unique(array_map('trim', $lines))); + break; + } + } + + $supported = $source !== '' || $rebootFlag || $distro !== ''; + + return [ + 'supported' => $supported, + 'distro' => $distro, + 'updatesAvailable' => $updates, + 'securityUpdates' => $security, + 'rebootRequired' => $rebootFlag, + 'rebootPackages' => $rebootPkgs, + 'summary' => $summary, + 'source' => $source, + ]; + } + + private function detectDistro(): string { + if (!is_readable('/etc/os-release')) { + return ''; + } + $lines = @file('/etc/os-release', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $kv = []; + foreach ($lines as $line) { + if (preg_match('/^([A-Z_]+)="?([^"]*)"?$/', $line, $m)) { + $kv[$m[1]] = $m[2]; + } + } + $pretty = $kv['PRETTY_NAME'] ?? ($kv['NAME'] ?? ''); + return (string)$pretty; + } + + /** + * Parses Ubuntu/Debian's `updates-available` text. The file looks like: + * "5 updates can be installed immediately. + * 2 of these updates are security updates." + * + * @return array{0: int, 1: int} [total updates, security updates] + */ + private function parseUpdateNotifier(string $content): array { + $updates = 0; + $security = 0; + + if (preg_match('/(\d+)\s+updates?\s+can\s+be\s+(?:installed|applied)/i', $content, $m)) { + $updates = (int)$m[1]; + } elseif (preg_match('/(\d+)\s+package(?:s)?\s+can\s+be\s+upgraded/i', $content, $m)) { + $updates = (int)$m[1]; + } + + if (preg_match('/(\d+)\s+(?:of\s+these\s+updates\s+are\s+)?security/i', $content, $m)) { + $security = (int)$m[1]; + } + + return [$updates, $security]; + } +}