215 lines
8.1 KiB
PHP
215 lines
8.1 KiB
PHP
<?php
|
|
/*
|
|
* Reflector Dashboard — web front-end for SvxReflector
|
|
* Copyright (C) 2026 SA7AUX (Björn)
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify it under
|
|
* the terms of the GNU Affero General Public License as published by the Free
|
|
* Software Foundation, either version 3 of the License, or (at your option) any
|
|
* later version. See the LICENSE file for details.
|
|
*/
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/lib.php';
|
|
|
|
$uuid = require_admin(2);
|
|
|
|
header('Content-Type: text/event-stream');
|
|
header('Cache-Control: no-cache');
|
|
header('Connection: keep-alive');
|
|
header('X-Accel-Buffering: no');
|
|
|
|
set_time_limit(0);
|
|
ini_set('output_buffering', '0');
|
|
if (function_exists('apache_setenv')) {
|
|
apache_setenv('no-gzip', '1');
|
|
}
|
|
|
|
session_write_close();
|
|
|
|
// Parse pending_csrs path from svxreflector.conf
|
|
$conf_lines = @file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
|
$pki_dir = '/etc/svxlink/pki';
|
|
$pending_dir = '';
|
|
$certs_dir = '';
|
|
foreach ($conf_lines as $line) {
|
|
$line = trim($line);
|
|
if (str_starts_with($line, 'CERT_PKI_DIR=')) $pki_dir = substr($line, strlen('CERT_PKI_DIR='));
|
|
if (str_starts_with($line, 'CERT_CA_PENDING_CSRS_DIR=')) $pending_dir = substr($line, strlen('CERT_CA_PENDING_CSRS_DIR='));
|
|
if (str_starts_with($line, 'CERT_CA_CERTS_DIR=')) $certs_dir = substr($line, strlen('CERT_CA_CERTS_DIR='));
|
|
}
|
|
if ($pending_dir === '') $pending_dir = $pki_dir . '/pending_csrs';
|
|
if ($certs_dir === '') $certs_dir = $pki_dir . '/certs';
|
|
|
|
$last_hash = '';
|
|
|
|
function sse_send(string $event, $data): void {
|
|
echo "event: {$event}\n";
|
|
echo 'data: ' . json_encode($data) . "\n\n";
|
|
if (ob_get_level()) ob_flush();
|
|
flush();
|
|
}
|
|
|
|
function scan_pending(string $dir): array {
|
|
$list = [];
|
|
$files = glob(rtrim($dir, '/') . '/*.csr');
|
|
if ($files === false) return $list;
|
|
foreach ($files as $f) {
|
|
$list[] = ['callsign' => basename($f, '.csr'), 'mtime' => filemtime($f) ?: 0];
|
|
}
|
|
usort($list, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
|
|
return $list;
|
|
}
|
|
|
|
// Warn about infrastructure certs (root/issuing CA + server cert) nearing
|
|
// expiry. Node certs auto-renew at 2/3 lifetime, so they are NOT included.
|
|
function scan_ca_certs(string $certs_dir): array {
|
|
$list = [];
|
|
$files = glob(rtrim($certs_dir, '/') . '/*.crt');
|
|
if ($files === false) return $list;
|
|
$now = time();
|
|
$warn = 30 * 86400;
|
|
foreach ($files as $f) {
|
|
$base = basename($f, '.crt');
|
|
if (!str_starts_with($base, 'svxreflector_') && !str_starts_with($base, 'SvxReflector_')) continue;
|
|
$pem = @file_get_contents($f);
|
|
if ($pem === false) continue;
|
|
$x = @openssl_x509_parse($pem);
|
|
if (!$x || !isset($x['validTo_time_t'])) continue;
|
|
$vt = (int)$x['validTo_time_t'];
|
|
$left = $vt - $now;
|
|
if ($left > $warn) continue;
|
|
$list[] = [
|
|
'name' => $base,
|
|
'validTo' => $vt,
|
|
'days_left' => (int)floor($left / 86400),
|
|
'expired' => $left <= 0,
|
|
];
|
|
}
|
|
usort($list, fn($a, $b) => $a['validTo'] <=> $b['validTo']);
|
|
return $list;
|
|
}
|
|
|
|
function scan_unverified(): array {
|
|
$r = redis();
|
|
$pattern = call_index_key('*');
|
|
$cursor = null;
|
|
$list = [];
|
|
do {
|
|
$result = $r->scan($cursor, $pattern, 200);
|
|
if ($result === false) break;
|
|
foreach ($result as $key) {
|
|
$uuid = $r->get($key);
|
|
if (!is_string($uuid) || $uuid === '') continue;
|
|
$a = $r->hMGet(acct_key($uuid), ['callsign', 'email_verified', 'firstname', 'created_at']);
|
|
if ((int)($a['email_verified'] ?? 0) === 0) {
|
|
// Also check for accounts that are verified but not activated (no last_login)
|
|
continue;
|
|
}
|
|
// Check if account has never logged in (new registration, verified but pending admin review)
|
|
$last = $r->hGet(acct_key($uuid), 'last_login_at');
|
|
if ($last && (int)$last > 0) continue; // Already logged in, not new
|
|
// This is a verified account that has never logged in = pending activation
|
|
}
|
|
} while ($cursor > 0);
|
|
return $list;
|
|
}
|
|
|
|
function scan_new_accounts(): array {
|
|
$r = redis();
|
|
$pattern = call_index_key('*');
|
|
$cursor = null;
|
|
$list = [];
|
|
do {
|
|
$result = $r->scan($cursor, $pattern, 200);
|
|
if ($result === false) break;
|
|
foreach ($result as $key) {
|
|
$uuid = $r->get($key);
|
|
if (!is_string($uuid) || $uuid === '') continue;
|
|
$a = $r->hMGet(acct_key($uuid), ['callsign', 'email_verified', 'firstname', 'created_at', 'last_login_at', 'banned', 'acct_flags', 'admin_level']);
|
|
$verified = (int)($a['email_verified'] ?? 0);
|
|
$flags = acct_flags_from(is_array($a) ? $a : []);
|
|
$approved = ($flags & 4) !== 0; // normal bit — required to log in
|
|
$banned = (int)($a['banned'] ?? 0);
|
|
// Show accounts that still need an admin: unverified (awaiting email)
|
|
// OR verified but not yet approved (normal bit not set → can't log in).
|
|
if (!$banned && (!$verified || !$approved)) {
|
|
$list[] = [
|
|
'uuid' => $uuid,
|
|
'callsign' => $a['callsign'] ?? '',
|
|
'firstname' => $a['firstname'] ?? '',
|
|
'verified' => $verified,
|
|
'approved' => $approved ? 1 : 0,
|
|
'flags' => $flags,
|
|
'created' => (int)($a['created_at'] ?? 0),
|
|
];
|
|
}
|
|
}
|
|
} while ($cursor > 0);
|
|
usort($list, fn($a, $b) => $b['created'] <=> $a['created']);
|
|
return $list;
|
|
}
|
|
|
|
// Open TG requests awaiting admin approval (SVX TG list JSON file).
|
|
function scan_tg_requests(): array {
|
|
$p = cfg()['tglist_file'] ?? '/etc/svxlink/tglist.json';
|
|
if (!is_string($p) || !is_file($p)) return [];
|
|
$j = json_decode((string)@file_get_contents($p), true);
|
|
$out = [];
|
|
if (is_array($j) && isset($j['requests']) && is_array($j['requests'])) {
|
|
foreach ($j['requests'] as $rq) {
|
|
if (!is_array($rq)) continue;
|
|
$out[] = ['uuid' => (string)($rq['uuid'] ?? ''), 'tg' => (int)($rq['tg'] ?? 0),
|
|
'name' => (string)($rq['name'] ?? ''), 'callsign' => (string)($rq['callsign'] ?? ''), 'ts' => (int)($rq['ts'] ?? 0)];
|
|
}
|
|
}
|
|
usort($out, fn($a, $b) => $b['ts'] <=> $a['ts']);
|
|
return $out;
|
|
}
|
|
|
|
function notif_payload(array $st): array {
|
|
return [
|
|
'pending_count' => count($st['pending']),
|
|
'pending' => $st['pending'],
|
|
'new_accounts_count' => count($st['new_accounts']),
|
|
'new_accounts' => $st['new_accounts'],
|
|
'ca_certs_count' => count($st['ca_certs']),
|
|
'ca_certs' => $st['ca_certs'],
|
|
'tg_requests_count' => count($st['tg_requests']),
|
|
'tg_requests' => $st['tg_requests'],
|
|
'total' => count($st['pending']) + count($st['new_accounts']) + count($st['ca_certs']) + count($st['tg_requests']),
|
|
];
|
|
}
|
|
|
|
// Initial send
|
|
$pending = scan_pending($pending_dir);
|
|
$new_accounts = scan_new_accounts();
|
|
$ca_certs = scan_ca_certs($certs_dir);
|
|
$state = ['pending' => $pending, 'new_accounts' => $new_accounts, 'ca_certs' => $ca_certs, 'tg_requests' => scan_tg_requests()];
|
|
$last_hash = md5(json_encode($state));
|
|
sse_send('notifications', notif_payload($state));
|
|
|
|
$tick = 0;
|
|
while (true) {
|
|
sleep(3);
|
|
if (connection_aborted()) break;
|
|
|
|
clearstatcache(true, $pending_dir);
|
|
$pending = scan_pending($pending_dir);
|
|
$new_accounts = ($tick % 10 === 0) ? scan_new_accounts() : ($state['new_accounts'] ?? []);
|
|
$ca_certs = ($tick % 10 === 0) ? scan_ca_certs($certs_dir) : ($state['ca_certs'] ?? []);
|
|
$state = ['pending' => $pending, 'new_accounts' => $new_accounts, 'ca_certs' => $ca_certs, 'tg_requests' => scan_tg_requests()];
|
|
$hash = md5(json_encode($state));
|
|
|
|
if ($hash !== $last_hash) {
|
|
$last_hash = $hash;
|
|
sse_send('notifications', notif_payload($state));
|
|
}
|
|
|
|
if (++$tick % 5 === 0) {
|
|
echo ": keepalive\n\n";
|
|
if (ob_get_level()) ob_flush();
|
|
flush();
|
|
}
|
|
}
|