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