Files
reflector-dashboard/auth/lib.php
T

454 lines
18 KiB
PHP

<?php
/*
* SvxPortal — 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'] ?? 'SvxPortal'), $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();
}