Files
reflector-dashboard/auth/events.php
T

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();
}
}