454 lines
18 KiB
PHP
454 lines
18 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);
|
|
|
|
function cfg(): array {
|
|
static $cfg = null;
|
|
if ($cfg !== null) return $cfg;
|
|
$cfg = require __DIR__ . '/config.php';
|
|
return $cfg;
|
|
}
|
|
|
|
// redis() / redis_connect() / redis_scan_mget() come from auth/lib/redis.php
|
|
// (copy of <dmr-portal>/auth/lib/redis.php).
|
|
require __DIR__ . '/lib/redis.php';
|
|
// json_in/json_out, CSRF and rate limiting come from auth/lib/http.php (copy of
|
|
// callmgr2's); the rate-limit keys keep this site's namespace.
|
|
function rate_limit_key_prefix(): string { return rkey('ratelimit:'); }
|
|
require __DIR__ . '/lib/http.php';
|
|
require __DIR__ . '/lib/acl.php';
|
|
require __DIR__ . '/lib/html.php';
|
|
require __DIR__ . '/lib/shell.php';
|
|
|
|
function rkey(string $suffix): string {
|
|
$p = cfg()['redis']['prefix'] ?? '';
|
|
return $p . ltrim($suffix, ':');
|
|
}
|
|
|
|
function base_call(string $cs): string {
|
|
$cs = trim($cs);
|
|
$cs = preg_split('/[-(]/', $cs)[0] ?? $cs;
|
|
return strtoupper(trim($cs));
|
|
}
|
|
|
|
function norm_email(string $email): string {
|
|
return strtolower(trim($email));
|
|
}
|
|
|
|
function uuidv4(): string {
|
|
$d = random_bytes(16);
|
|
$d[6] = chr((ord($d[6]) & 0x0f) | 0x40);
|
|
$d[8] = chr((ord($d[8]) & 0x3f) | 0x80);
|
|
$hex = bin2hex($d);
|
|
return sprintf('%s-%s-%s-%s-%s',
|
|
substr($hex, 0, 8),
|
|
substr($hex, 8, 4),
|
|
substr($hex, 12, 4),
|
|
substr($hex, 16, 4),
|
|
substr($hex, 20, 12)
|
|
);
|
|
}
|
|
|
|
function token_id(): string {
|
|
return rtrim(strtr(base64_encode(random_bytes(12)), '+/', '-_'), '=');
|
|
}
|
|
|
|
function token_secret(): string {
|
|
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
|
}
|
|
|
|
function token_hash(string $secret): string {
|
|
return hash('sha256', $secret, false);
|
|
}
|
|
|
|
function start_session(): void {
|
|
$s = cfg()['session'];
|
|
$params = session_get_cookie_params();
|
|
session_name($s['name'] ?? 'svxportal_sess');
|
|
session_set_cookie_params([
|
|
'lifetime' => 0,
|
|
'path' => '/',
|
|
'domain' => $params['domain'] ?? '',
|
|
'secure' => (bool)($s['cookie_secure'] ?? false),
|
|
'httponly' => true,
|
|
'samesite' => $s['cookie_samesite'] ?? 'Lax',
|
|
]);
|
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
|
}
|
|
|
|
function set_session_user(string $uuid): void {
|
|
start_session();
|
|
session_regenerate_id(true);
|
|
$_SESSION['uid'] = $uuid;
|
|
}
|
|
|
|
function session_user(): ?string {
|
|
start_session();
|
|
$u = $_SESSION['uid'] ?? null;
|
|
return is_string($u) && $u !== '' ? $u : null;
|
|
}
|
|
|
|
function require_login(): string {
|
|
$u = session_user();
|
|
if (!$u) json_out(['ok'=>false,'error'=>'not_logged_in'], 401);
|
|
check_session_timeout(7200); // 2 hour idle timeout
|
|
// Banned users are cut off completely — also mid-session, as on the dmr site
|
|
$acct = acct_get($u);
|
|
if ((int)($acct['banned'] ?? 0) === 1 || is_call_banned((string)($acct['callsign'] ?? ''))) {
|
|
session_destroy();
|
|
json_out(['ok'=>false,'error'=>'Your account has been banned.'], 403);
|
|
}
|
|
return $u;
|
|
}
|
|
|
|
function acct_key(string $uuid): string { return rkey("acct:$uuid"); }
|
|
function acct_calls_key(string $uuid): string { return rkey("acct_calls:$uuid"); }
|
|
// Reverse index for extra callsigns: SET of account uuids per callsign. Unlike
|
|
// the unique call:<CS> login index, the same callsign (e.g. a club call) may
|
|
// sit on several accounts at once.
|
|
function call_accts_key(string $callsign_upper): string { return rkey("call_accts:$callsign_upper"); }
|
|
function acct_nodes_key(string $uuid): string { return rkey("acct_nodes:$uuid"); }
|
|
function email_index_key(string $email_lower): string { return rkey("acct_by_email:$email_lower"); }
|
|
function call_index_key(string $callsign_upper): string { return rkey("call:$callsign_upper"); }
|
|
function node_index_key(string $node_id): string { return rkey("node:$node_id"); }
|
|
|
|
function find_acct_by_login(string $userOrEmail): ?string {
|
|
$v = trim($userOrEmail);
|
|
if ($v === '') return null;
|
|
|
|
if (str_contains($v, '@')) {
|
|
$uuid = redis()->get(email_index_key(norm_email($v)));
|
|
return is_string($uuid) && $uuid !== '' ? $uuid : null;
|
|
}
|
|
|
|
$uuid = redis()->get(call_index_key(base_call($v)));
|
|
return is_string($uuid) && $uuid !== '' ? $uuid : null;
|
|
}
|
|
|
|
function acct_get(string $uuid): array {
|
|
$h = redis()->hGetAll(acct_key($uuid));
|
|
return is_array($h) ? $h : [];
|
|
}
|
|
|
|
// Account flags (8-bit, as on the dmr site): 1=unreg 2=reg 4=normal 8=lvl1 16=lvl2 32=lvl3.
|
|
// The flags ARE the admin system. Accounts without the field derive a value
|
|
// from the legacy email_verified/admin_level fields.
|
|
function acct_flags_from(array $h): int {
|
|
$f = $h['acct_flags'] ?? null;
|
|
if ($f !== null && $f !== false && $f !== '') return ((int)$f) & 0x3F;
|
|
if ((string)($h['email_verified'] ?? '0') !== '1') return 1;
|
|
$flags = 2 | 4;
|
|
$legacy = (int)($h['admin_level'] ?? 1);
|
|
if ($legacy === 2) $flags |= 16;
|
|
if ($legacy >= 3) $flags |= 32;
|
|
return $flags;
|
|
}
|
|
function acct_flags(string $uuid): int { return acct_flags_from(acct_get($uuid)); }
|
|
function acct_level_from_flags(int $flags): int {
|
|
if ($flags & 32) return 3;
|
|
if ($flags & (8|16)) return 2; // lvl1 and lvl2 are both acting admins
|
|
return 1;
|
|
}
|
|
// Fine-grained admin rank for the level hierarchy: lvl1=1, lvl2=2, lvl3=3, none=0.
|
|
function acct_admin_rank_from(int $flags): int {
|
|
if ($flags & 32) return 3;
|
|
if ($flags & 16) return 2;
|
|
if ($flags & 8) return 1;
|
|
return 0;
|
|
}
|
|
function acct_admin_rank(string $uuid): int { return acct_admin_rank_from(acct_flags($uuid)); }
|
|
|
|
function acct_admin_level(string $uuid): int {
|
|
$lvl = acct_level_from_flags(acct_flags($uuid));
|
|
return $lvl;
|
|
}
|
|
|
|
function require_admin(int $minLevel): string {
|
|
$uuid = require_login();
|
|
if (acct_admin_level($uuid) < $minLevel) json_out(['ok'=>false,'error'=>'forbidden'], 403);
|
|
return $uuid;
|
|
}
|
|
|
|
// All callsigns tied to an account: the primary callsign field plus any extra
|
|
// callsigns (club calls etc.) an admin has added to the acct_calls set.
|
|
function acct_all_calls(string $uuid): array {
|
|
$calls = [];
|
|
$cs = strtoupper(trim((string)(acct_get($uuid)['callsign'] ?? '')));
|
|
if ($cs !== '') $calls[] = $cs;
|
|
foreach (redis()->sMembers(acct_calls_key($uuid)) ?: [] as $c) {
|
|
$c = strtoupper(trim((string)$c));
|
|
if ($c !== '' && !in_array($c, $calls, true)) $calls[] = $c;
|
|
}
|
|
return $calls;
|
|
}
|
|
|
|
// A node belongs to a callsign when the callsign appears anywhere in the node
|
|
// name: SA7AUX matches SA7AUX, SA7AUX-TEST, SA7AUX_TEST, SA7AUX/TEST,
|
|
// SA7AUXTEST and XXSA7AUXXX. The node name itself is never rewritten — the
|
|
// variants are distinct nodes.
|
|
function calls_match_node(array $calls, string $cs): bool {
|
|
$node = strtoupper(trim($cs));
|
|
if ($node === '') return false;
|
|
foreach ($calls as $c) {
|
|
if ($c !== '' && preg_match('/' . preg_quote($c, '/') . '/', $node)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function link_callsign(string $uuid, string $callsign): void {
|
|
$cs = base_call($callsign);
|
|
if ($cs === '') throw new RuntimeException('invalid_callsign');
|
|
|
|
$idx = call_index_key($cs);
|
|
$old = redis()->get($idx);
|
|
if (is_string($old) && $old !== '' && $old !== $uuid) throw new RuntimeException('callsign_taken');
|
|
|
|
redis()->set($idx, $uuid);
|
|
redis()->sAdd(acct_calls_key($uuid), $cs);
|
|
}
|
|
|
|
function link_node(string $uuid, string $nodeId): void {
|
|
$id = trim($nodeId);
|
|
if ($id === '') throw new RuntimeException('invalid_node');
|
|
|
|
$idx = node_index_key($id);
|
|
$old = redis()->get($idx);
|
|
if (is_string($old) && $old !== '' && $old !== $uuid) throw new RuntimeException('node_taken');
|
|
|
|
redis()->set($idx, $uuid);
|
|
redis()->sAdd(acct_nodes_key($uuid), $id);
|
|
}
|
|
|
|
// --- Node ownership ---
|
|
function pki_certs_dir(): string {
|
|
static $dir = null;
|
|
if ($dir !== null) return $dir;
|
|
$dir = '/etc/svxlink/pki/certs';
|
|
foreach (@file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
|
if (preg_match('/^CERT_CA_CERTS_DIR\s*=\s*(.+)$/', trim($line), $m)) { $dir = trim($m[1]); break; }
|
|
}
|
|
$dir = rtrim($dir, '/');
|
|
return $dir;
|
|
}
|
|
|
|
// Email carried in a node's client cert (subjectAltName, falling back to the
|
|
// subject's emailAddress), or '' if the node has no cert / no email in it.
|
|
function node_cert_email(string $cs): string {
|
|
$f = pki_certs_dir() . "/{$cs}.crt";
|
|
if (!is_file($f)) return '';
|
|
$x = @openssl_x509_parse((string)@file_get_contents($f));
|
|
if (!$x) return '';
|
|
$san = (string)($x['extensions']['subjectAltName'] ?? '');
|
|
if (preg_match('/email:([^,\s]+)/i', $san, $m)) return norm_email($m[1]);
|
|
$e = (string)($x['subject']['emailAddress'] ?? '');
|
|
return $e !== '' ? norm_email($e) : '';
|
|
}
|
|
|
|
// Pending-CSR and signed-CSR directories, resolved from svxreflector.conf the
|
|
// same way as pki_certs_dir().
|
|
function pki_conf_dir(string $key, string $fallback): string {
|
|
$dir = $fallback;
|
|
foreach (@file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
|
if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(.+)$/', trim($line), $m)) { $dir = trim($m[1]); break; }
|
|
}
|
|
return rtrim($dir, '/');
|
|
}
|
|
function pki_pending_dir(): string { return pki_conf_dir('CERT_CA_PENDING_CSRS_DIR', '/etc/svxlink/pki/pending_csrs'); }
|
|
|
|
// Path safety for node callsigns used as cert/CSR file names — same rule as the
|
|
// reflector's isCallsignPathSafe(): never '/', '..' or NUL.
|
|
function node_callsign_path_safe(string $cs): bool {
|
|
return $cs !== '' && strlen($cs) <= 64
|
|
&& !str_contains($cs, '/') && !str_contains($cs, '..') && !str_contains($cs, "\0");
|
|
}
|
|
|
|
// Validate a node callsign for portal operations: file-name safety only.
|
|
// Which names may exist on the network is the reflector's business — the live
|
|
// network has trunk/bridge nodes (SE-EASTCOAST, stream_test, SA7AUX-DMRBR)
|
|
// that no callsign-format regex covers, so the portal must not be stricter.
|
|
// Callsign check shared by the node/reflector APIs; a caller may pass its own
|
|
// stricter validator (node_cmd_api: amateur-format regex).
|
|
function valid_callsign(string $cs): bool { return node_callsign_valid($cs); }
|
|
function require_callsign(array $in, ?callable $validator = null): string {
|
|
$cs = strtoupper(trim((string)($in['callsign'] ?? '')));
|
|
$ok = $validator ? $validator($cs) : valid_callsign($cs);
|
|
if ($cs === '' || !$ok) json_out(['ok' => false, 'error' => 'invalid_callsign'], 400);
|
|
return $cs;
|
|
}
|
|
|
|
function node_callsign_valid(string $cs): bool {
|
|
return node_callsign_path_safe($cs)
|
|
&& preg_match('/^[A-Za-z0-9._-]{1,64}$/', $cs) === 1;
|
|
}
|
|
|
|
// Email carried in a pending CSR's subjectAltName (or subject emailAddress), '' if none.
|
|
function node_csr_email(string $cs): string {
|
|
$f = pki_pending_dir() . "/{$cs}.csr";
|
|
if (!is_file($f)) return '';
|
|
$out = [];
|
|
@exec('openssl req -in ' . escapeshellarg($f) . ' -noout -text 2>/dev/null', $out);
|
|
$text = implode("\n", $out);
|
|
if (preg_match('/email:([^,\s]+)/i', $text, $m)) return norm_email($m[1]);
|
|
if (preg_match('/emailAddress\s*=\s*([^,\s\/]+)/i', $text, $m)) return norm_email($m[1]);
|
|
return '';
|
|
}
|
|
|
|
// Run a CA command through the reflector PTY helper (svxlink-owned). true on
|
|
// success, or an error string.
|
|
function reflector_ca(string $sub, string $cs): true|string {
|
|
$sub = strtoupper($sub);
|
|
if (!in_array($sub, ['SIGN', 'RM'], true)) return 'bad CA subcommand';
|
|
if (!node_callsign_path_safe($cs) || preg_match('/\s/', $cs)) return 'bad callsign';
|
|
$cmd = 'sudo -u svxlink /usr/local/sbin/svxreflector-pty CA ' . escapeshellarg($sub) . ' ' . escapeshellarg($cs);
|
|
exec($cmd . ' 2>&1', $out, $rc);
|
|
return $rc === 0 ? true : (implode("\n", $out) ?: 'PTY write failed');
|
|
}
|
|
|
|
// --- Per-email certificate renewal counter ---
|
|
// 0 = new (never issued), 1 = admin issued the first cert, >=1 lets the account
|
|
// self-serve renewals. The count is keyed by the account/cert email.
|
|
function cert_email_key(string $email): string { return rkey('cert_email:' . norm_email($email)); }
|
|
function cert_email_ensure(string $email): void {
|
|
$e = norm_email($email);
|
|
if ($e === '') return;
|
|
$k = cert_email_key($e);
|
|
if (!redis()->exists($k)) redis()->hMSet($k, ['email' => $e, 'count' => '0']);
|
|
}
|
|
function cert_email_count(string $email): int {
|
|
$e = norm_email($email);
|
|
if ($e === '') return 0;
|
|
$c = redis()->hGet(cert_email_key($e), 'count');
|
|
return ($c === false || $c === null) ? 0 : (int)$c;
|
|
}
|
|
function cert_email_bump(string $email): int {
|
|
$e = norm_email($email);
|
|
if ($e === '') return 0;
|
|
$k = cert_email_key($e);
|
|
redis()->hSet($k, 'email', $e);
|
|
return (int)redis()->hIncrBy($k, 'count', 1);
|
|
}
|
|
|
|
// --- Per-node certificate issue counter ---
|
|
// Same semantics as the per-email counter, keyed by the full node callsign:
|
|
// 0 = an admin has never issued a cert for this node, >=1 = the owner may
|
|
// self-serve renewals. Bumped on every sign (admin or self), never decremented
|
|
// so the first-issue gate survives a remove->reissue cycle.
|
|
function cert_node_key(string $cs): string { return rkey('cert_node:' . strtoupper(trim($cs))); }
|
|
function cert_node_ensure(string $cs): void {
|
|
$c = strtoupper(trim($cs));
|
|
if ($c === '') return;
|
|
$k = cert_node_key($c);
|
|
if (!redis()->exists($k)) redis()->hMSet($k, ['call' => $c, 'count' => '0']);
|
|
}
|
|
function cert_node_count(string $cs): int {
|
|
$c = strtoupper(trim($cs));
|
|
if ($c === '') return 0;
|
|
$v = redis()->hGet(cert_node_key($c), 'count');
|
|
return ($v === false || $v === null) ? 0 : (int)$v;
|
|
}
|
|
function cert_node_bump(string $cs): int {
|
|
$c = strtoupper(trim($cs));
|
|
if ($c === '') return 0;
|
|
$k = cert_node_key($c);
|
|
redis()->hSet($k, 'call', $c);
|
|
return (int)redis()->hIncrBy($k, 'count', 1);
|
|
}
|
|
|
|
// A user "owns" a node if they're a site admin, one of their callsigns (primary
|
|
// or admin-added extra/club calls) appears in the node name, they're a listed
|
|
// node admin, or the node's client cert carries their verified account email.
|
|
function user_owns_node(string $uuid, string $cs): bool {
|
|
if (acct_admin_level($uuid) >= 2) return true;
|
|
$me = acct_get($uuid);
|
|
$mycalls = acct_all_calls($uuid);
|
|
if (calls_match_node($mycalls, $cs)) return true;
|
|
$admins = redis()->sMembers(rkey("node_admins:$cs")) ?: [];
|
|
foreach ($mycalls as $c) {
|
|
if (in_array($c, $admins, true)) return true;
|
|
}
|
|
if (!empty($me['email']) && !empty($me['email_verified'])) {
|
|
$ce = node_cert_email($cs);
|
|
if ($ce !== '' && $ce === norm_email((string)$me['email'])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Global callsign bans ---
|
|
// A Redis SET of base callsigns (like the DMR site's banned_callsigns set).
|
|
// A banned callsign is blocked on the portal — it cannot register or sign in.
|
|
function global_bans_key(): string { return rkey('banned_callsigns'); }
|
|
|
|
function is_call_banned(string $callsign): bool {
|
|
$cs = base_call($callsign);
|
|
if ($cs === '') return false;
|
|
return (bool) redis()->sIsMember(global_bans_key(), $cs);
|
|
}
|
|
|
|
function mail_send(string $to, string $subject, string $body): bool {
|
|
$m = cfg()['mail'] ?? [];
|
|
$from = $m['from'] ?? 'no-reply@example.com';
|
|
// Direct SSL SMTP when a relay is configured (host+user) — needs no local MTA
|
|
// (/usr/sbin/sendmail), which many boxes don't have. Same approach as the
|
|
// callmgr portal. Falls back to PHP mail() where no SMTP is configured (e.g.
|
|
// a box with a working msmtp/sendmail).
|
|
$host = $m['host'] ?? '';
|
|
$user = $m['user'] ?? '';
|
|
if ($host !== '' && $user !== '') {
|
|
return smtp_send_ssl($host, (int)($m['port'] ?? 465), $user, (string)($m['pass'] ?? ''),
|
|
$from, (string)($m['from_name'] ?? 'Reflector Dashboard'), $to, $subject, $body);
|
|
}
|
|
$headers = "From: $from\r\nContent-Type: text/plain; charset=UTF-8\r\n";
|
|
return @mail($to, $subject, $body, $headers);
|
|
}
|
|
|
|
// Minimal SSL SMTP client (AUTH LOGIN). Returns true only when the server
|
|
// accepts the message. No dependency on a local MTA.
|
|
function smtp_send_ssl(string $host, int $port, string $user, string $pass,
|
|
string $from, string $from_name, string $to,
|
|
string $subject, string $body): bool {
|
|
$ctx = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
|
|
$fp = @stream_socket_client("ssl://{$host}:{$port}", $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $ctx);
|
|
if (!$fp) { error_log("smtp: connect failed $host:$port $errstr"); return false; }
|
|
$read = function() use ($fp): string { return fgets($fp, 4096) ?: ''; };
|
|
$write = function(string $c) use ($fp): void { fwrite($fp, $c . "\r\n"); };
|
|
$expect = function(string $code) use ($read): bool { return str_starts_with($read(), $code); };
|
|
if (!$expect('220')) { fclose($fp); return false; }
|
|
$write("EHLO svxportal");
|
|
do { $line = $read(); } while ($line !== '' && strlen($line) > 3 && $line[3] === '-');
|
|
$write("AUTH LOGIN"); if (!$expect('334')) { fclose($fp); return false; }
|
|
$write(base64_encode($user)); if (!$expect('334')) { fclose($fp); return false; }
|
|
$write(base64_encode($pass)); if (!$expect('235')) { fclose($fp); error_log("smtp: auth failed"); return false; }
|
|
$write("MAIL FROM:<{$from}>"); if (!$expect('250')) { fclose($fp); return false; }
|
|
$write("RCPT TO:<{$to}>"); if (!$expect('250')) { fclose($fp); return false; }
|
|
$write("DATA"); if (!$expect('354')) { fclose($fp); return false; }
|
|
$msg = "From: {$from_name} <{$from}>\r\n";
|
|
$msg .= "To: {$to}\r\n";
|
|
$msg .= "Subject: {$subject}\r\n";
|
|
$msg .= "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n";
|
|
$msg .= "Date: " . date('r') . "\r\n\r\n";
|
|
$msg .= $body . "\r\n.";
|
|
$write($msg); if (!$expect('250')) { fclose($fp); return false; }
|
|
$write("QUIT"); fclose($fp);
|
|
return true;
|
|
}
|
|
|
|
// --- Session timeout ---
|
|
function check_session_timeout(int $max_idle = 3600): void {
|
|
start_session();
|
|
if (isset($_SESSION['last_activity']) && (time() - (int)$_SESSION['last_activity']) > $max_idle) {
|
|
session_unset();
|
|
session_destroy();
|
|
json_out(['ok' => false, 'error' => 'session_expired'], 401);
|
|
}
|
|
$_SESSION['last_activity'] = time();
|
|
}
|