Skip to content

Commit 7684654

Browse files
committed
feat(services): add log and login/session 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. * LogTailReader - last N WARN/ERROR entries from the JSON log (no shell, line-bounded) * LoginStats - bruteforce attempt counters and the top offending IPs over recent windows * ActivityRate - oc_activity row counts over the last hour / day / week * ActiveConnections - session counts derived from oc_authtoken Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
1 parent 10a0218 commit 7684654

4 files changed

Lines changed: 382 additions & 0 deletions

File tree

lib/ActiveConnections.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\IDBConnection;
13+
14+
class ActiveConnections {
15+
public function __construct(
16+
private IDBConnection $db,
17+
) {
18+
}
19+
20+
/**
21+
* Approximate active sessions/connections by counting auth tokens
22+
* with recent activity. Each token corresponds to one client (browser
23+
* tab, mobile app, desktop client, etc.).
24+
*
25+
* @return array{
26+
* last5min: int,
27+
* last1h: int,
28+
* totalTokens: int,
29+
* byType: array<string, int>
30+
* }
31+
*/
32+
public function getActiveConnections(): array {
33+
try {
34+
return [
35+
'last5min' => $this->countSince(time() - 300),
36+
'last1h' => $this->countSince(time() - 3600),
37+
'totalTokens' => $this->countTotal(),
38+
'byType' => $this->byType(),
39+
];
40+
} catch (\Throwable) {
41+
return ['last5min' => 0, 'last1h' => 0, 'totalTokens' => 0, 'byType' => []];
42+
}
43+
}
44+
45+
private function countSince(int $ts): int {
46+
$qb = $this->db->getQueryBuilder();
47+
$qb->select($qb->func()->count('id'))
48+
->from('authtoken')
49+
->where($qb->expr()->gte('last_activity', $qb->createNamedParameter($ts)));
50+
$result = $qb->executeQuery();
51+
$count = (int)$result->fetchOne();
52+
$result->closeCursor();
53+
return $count;
54+
}
55+
56+
private function countTotal(): int {
57+
$qb = $this->db->getQueryBuilder();
58+
$qb->select($qb->func()->count('id'))->from('authtoken');
59+
$result = $qb->executeQuery();
60+
$count = (int)$result->fetchOne();
61+
$result->closeCursor();
62+
return $count;
63+
}
64+
65+
/**
66+
* @return array<string, int>
67+
*/
68+
private function byType(): array {
69+
$qb = $this->db->getQueryBuilder();
70+
$qb->select('type')
71+
->selectAlias($qb->func()->count('id'), 'count')
72+
->from('authtoken')
73+
->where($qb->expr()->gte('last_activity', $qb->createNamedParameter(time() - 3600)))
74+
->groupBy('type');
75+
$result = $qb->executeQuery();
76+
$out = ['session' => 0, 'permanent' => 0];
77+
while (($row = $result->fetch()) !== false) {
78+
$type = (int)($row['type'] ?? 0) === 0 ? 'session' : 'permanent';
79+
$out[$type] = (int)($row['count'] ?? 0);
80+
}
81+
$result->closeCursor();
82+
return $out;
83+
}
84+
}

lib/ActivityRate.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\App\IAppManager;
13+
use OCP\IDBConnection;
14+
15+
class ActivityRate {
16+
public function __construct(
17+
private IAppManager $appManager,
18+
private IDBConnection $db,
19+
) {
20+
}
21+
22+
/**
23+
* Counts user-facing activity events from the activity app over
24+
* recent time windows. Useful as a "is anything happening on the
25+
* server right now" signal.
26+
*
27+
* @return array{
28+
* installed: bool,
29+
* last1h: int,
30+
* last24h: int,
31+
* last7d: int,
32+
* topActions: list<array{action: string, count: int}>
33+
* }
34+
*/
35+
public function getActivityRate(): array {
36+
if (!$this->appManager->isInstalled('activity')) {
37+
return ['installed' => false, 'last1h' => 0, 'last24h' => 0, 'last7d' => 0, 'topActions' => []];
38+
}
39+
40+
try {
41+
return [
42+
'installed' => true,
43+
'last1h' => $this->countSince(time() - 3600),
44+
'last24h' => $this->countSince(time() - 86400),
45+
'last7d' => $this->countSince(time() - 7 * 86400),
46+
'topActions' => $this->topActions(),
47+
];
48+
} catch (\Throwable) {
49+
return ['installed' => true, 'last1h' => 0, 'last24h' => 0, 'last7d' => 0, 'topActions' => []];
50+
}
51+
}
52+
53+
private function countSince(int $ts): int {
54+
$qb = $this->db->getQueryBuilder();
55+
$qb->select($qb->func()->count('activity_id'))
56+
->from('activity')
57+
->where($qb->expr()->gte('timestamp', $qb->createNamedParameter($ts)));
58+
$result = $qb->executeQuery();
59+
$count = (int)$result->fetchOne();
60+
$result->closeCursor();
61+
return $count;
62+
}
63+
64+
/**
65+
* @return list<array{action: string, count: int}>
66+
*/
67+
private function topActions(int $limit = 5): array {
68+
$qb = $this->db->getQueryBuilder();
69+
$qb->select('subjectparams', 'type')
70+
->selectAlias($qb->func()->count('activity_id'), 'count')
71+
->from('activity')
72+
->where($qb->expr()->gte('timestamp', $qb->createNamedParameter(time() - 86400)))
73+
->groupBy('type', 'subjectparams')
74+
->orderBy('count', 'DESC')
75+
->setMaxResults($limit);
76+
$result = $qb->executeQuery();
77+
$out = [];
78+
while (($row = $result->fetch()) !== false) {
79+
$out[] = [
80+
'action' => (string)($row['type'] ?? 'unknown'),
81+
'count' => (int)($row['count'] ?? 0),
82+
];
83+
}
84+
$result->closeCursor();
85+
return $out;
86+
}
87+
}

lib/LogTailReader.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\IConfig;
13+
14+
class LogTailReader {
15+
private const READ_CHUNK = 96 * 1024;
16+
17+
public function __construct(
18+
private IConfig $config,
19+
) {
20+
}
21+
22+
/**
23+
* Tail the Nextcloud JSON log and return the last $limit entries
24+
* with severity >= $minLevel (default: WARN = 2). Skips DEBUG/INFO.
25+
*
26+
* @return array{
27+
* entries: list<array{time: string, level: int, app: string, message: string}>,
28+
* available: bool,
29+
* reason?: string
30+
* }
31+
*/
32+
public function recentErrors(int $limit = 8, int $minLevel = 2): array {
33+
$logType = $this->config->getSystemValue('log_type', 'file');
34+
if ($logType !== 'file') {
35+
return ['entries' => [], 'available' => false, 'reason' => 'log_type_not_file'];
36+
}
37+
38+
$path = $this->resolvePath();
39+
if ($path === null || !is_readable($path)) {
40+
return ['entries' => [], 'available' => false, 'reason' => 'log_not_readable'];
41+
}
42+
43+
$tail = $this->tailFile($path, self::READ_CHUNK);
44+
if ($tail === '') {
45+
return ['entries' => [], 'available' => true];
46+
}
47+
48+
$lines = explode("\n", $tail);
49+
$collected = [];
50+
// Iterate from newest to oldest.
51+
for ($i = count($lines) - 1; $i >= 0 && count($collected) < $limit; $i--) {
52+
$line = trim($lines[$i]);
53+
if ($line === '') {
54+
continue;
55+
}
56+
$decoded = json_decode($line, true);
57+
if (!is_array($decoded)) {
58+
continue;
59+
}
60+
$level = isset($decoded['level']) ? (int)$decoded['level'] : 0;
61+
if ($level < $minLevel) {
62+
continue;
63+
}
64+
$collected[] = [
65+
'time' => (string)($decoded['time'] ?? ''),
66+
'level' => $level,
67+
'app' => (string)($decoded['app'] ?? ''),
68+
'message' => $this->snippet((string)($decoded['message'] ?? '')),
69+
];
70+
}
71+
72+
return ['entries' => $collected, 'available' => true];
73+
}
74+
75+
private function resolvePath(): ?string {
76+
$dataDir = $this->config->getSystemValue('datadirectory', '');
77+
$default = $dataDir !== '' ? rtrim($dataDir, '/') . '/nextcloud.log' : '';
78+
$logFile = $this->config->getSystemValue('logfile', $default);
79+
if (!is_string($logFile) || $logFile === '') {
80+
return null;
81+
}
82+
return $logFile;
83+
}
84+
85+
private function tailFile(string $path, int $chunk): string {
86+
$size = @filesize($path);
87+
if ($size === false || $size === 0) {
88+
return '';
89+
}
90+
$handle = @fopen($path, 'rb');
91+
if ($handle === false) {
92+
return '';
93+
}
94+
try {
95+
$readFrom = max(0, $size - $chunk);
96+
fseek($handle, $readFrom);
97+
$data = fread($handle, $chunk) ?: '';
98+
// Drop the leading partial line so JSON parsing doesn't choke.
99+
if ($readFrom > 0) {
100+
$nl = strpos($data, "\n");
101+
if ($nl !== false) {
102+
$data = substr($data, $nl + 1);
103+
}
104+
}
105+
return $data;
106+
} finally {
107+
fclose($handle);
108+
}
109+
}
110+
111+
private function snippet(string $msg, int $max = 200): string {
112+
$msg = trim($msg);
113+
if (function_exists('mb_strlen') && mb_strlen($msg) > $max) {
114+
return mb_substr($msg, 0, $max - 1) . '';
115+
}
116+
if (strlen($msg) > $max) {
117+
return substr($msg, 0, $max - 1) . '';
118+
}
119+
return $msg;
120+
}
121+
}

lib/LoginStats.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\ServerInfo;
11+
12+
use OCP\IDBConnection;
13+
14+
class LoginStats {
15+
public function __construct(
16+
private IDBConnection $db,
17+
) {
18+
}
19+
20+
/**
21+
* @return array{
22+
* bruteforceAttempts24h: int,
23+
* bruteforceAttempts1h: int,
24+
* bruteforceTotal: int,
25+
* topIps: list<array{ip: string, count: int}>,
26+
* available: bool
27+
* }
28+
*/
29+
public function getStats(): array {
30+
try {
31+
$total = $this->countAttempts();
32+
} catch (\Throwable) {
33+
return [
34+
'bruteforceAttempts24h' => 0,
35+
'bruteforceAttempts1h' => 0,
36+
'bruteforceTotal' => 0,
37+
'topIps' => [],
38+
'available' => false,
39+
];
40+
}
41+
42+
return [
43+
'bruteforceAttempts24h' => $this->countAttempts(time() - 86400),
44+
'bruteforceAttempts1h' => $this->countAttempts(time() - 3600),
45+
'bruteforceTotal' => $total,
46+
'topIps' => $this->topIps(),
47+
'available' => true,
48+
];
49+
}
50+
51+
private function countAttempts(?int $sinceTimestamp = null): int {
52+
$qb = $this->db->getQueryBuilder();
53+
$qb->select($qb->func()->count('id'))->from('bruteforce_attempts');
54+
if ($sinceTimestamp !== null) {
55+
$qb->where($qb->expr()->gte('occurred', $qb->createNamedParameter($sinceTimestamp)));
56+
}
57+
$result = $qb->executeQuery();
58+
$count = (int)$result->fetchOne();
59+
$result->closeCursor();
60+
return $count;
61+
}
62+
63+
/**
64+
* @return list<array{ip: string, count: int}>
65+
*/
66+
private function topIps(int $limit = 5): array {
67+
$qb = $this->db->getQueryBuilder();
68+
$qb->select('ip')
69+
->selectAlias($qb->func()->count('id'), 'count')
70+
->from('bruteforce_attempts')
71+
->where($qb->expr()->gte('occurred', $qb->createNamedParameter(time() - 86400)))
72+
->groupBy('ip')
73+
->orderBy('count', 'DESC')
74+
->setMaxResults($limit);
75+
try {
76+
$result = $qb->executeQuery();
77+
} catch (\Throwable) {
78+
return [];
79+
}
80+
$out = [];
81+
while (($row = $result->fetch()) !== false) {
82+
$out[] = [
83+
'ip' => (string)($row['ip'] ?? ''),
84+
'count' => (int)($row['count'] ?? 0),
85+
];
86+
}
87+
$result->closeCursor();
88+
return $out;
89+
}
90+
}

0 commit comments

Comments
 (0)