unified node list; site admins see all, others their own // add {node, owner?} -> site admin only; generates the password, returned once // getpw {node} -> owner/node admin/site admin; show the current password // (the conf stores it in plaintext — same trust model as // the MQTT credentials, which are also shown on demand) // resetpw {node} -> owner/node admin/site admin; generate a new password // del {node} -> site admin only require __DIR__ . '/lib.php'; $uuid = require_login(); csrf_check(); $in = json_in(); $action = strtolower(trim((string)($in['action'] ?? 'list'))); // Node callsigns are validated the way the reflector does it: against the // ACCEPT_CALLSIGN/REJECT_CALLSIGN regexes in svxreflector.conf plus path // safety (never '/', '..' or NUL — cert/CSR files are named .). // valid_callsign() comes from auth/lib.php. // Run the gateway script. The password never touches argv: it's passed via a 0600 // temp file the helper reads and shreds (same pattern as svx-mqtt-provision). function users_ctl(string $sub, string $cs = '', ?string $pw = null): array { $cmd = 'sudo /usr/local/sbin/svxreflector-users ' . escapeshellarg($sub); if ($cs !== '') $cmd .= ' ' . escapeshellarg($cs); $tmp = ''; if ($pw !== null) { $tmp = tempnam(sys_get_temp_dir(), 'sru'); file_put_contents($tmp, $pw); chmod($tmp, 0600); $cmd .= ' ' . escapeshellarg($tmp); } exec($cmd . ' 2>&1', $out, $rc); if ($tmp !== '' && is_file($tmp)) @unlink($tmp); // helper shreds it; clean up if it failed early return [$rc, trim(implode("\n", $out))]; } function gen_password(): string { $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $pw = ''; for ($i = 0; $i < 20; $i++) $pw .= $chars[random_int(0, 61)]; return $pw; } function require_node(array $in): string { $cs = strtoupper(trim((string)($in['node'] ?? ''))); if ($cs === '' || !valid_callsign($cs)) json_out(['ok' => false, 'error' => 'invalid_node'], 400); return $cs; } $is_admin = acct_admin_level($uuid) >= 2; // The caller's own verified account email (used to link certs/CSRs to them for // self-service). Empty if the account email is not verified. $me_acct = acct_get($uuid); $my_email = (!empty($me_acct['email']) && (int)($me_acct['email_verified'] ?? 0) === 1) ? norm_email((string)$me_acct['email']) : ''; // Admins may view another account's reflector logins by passing its uuid as // 'target' (used by the admin account-edit page to show the same section as the // profile). Ownership matching is then scoped to that account. $target_uuid = $is_admin ? trim((string)($in['target'] ?? '')) : ''; if ($target_uuid !== '') { $tgt = acct_get($target_uuid); $view_calls = acct_all_calls($target_uuid); $view_email = (!empty($tgt['email']) && (int)($tgt['email_verified'] ?? 0) === 1) ? norm_email((string)$tgt['email']) : ''; } else { $view_calls = acct_all_calls($uuid); $view_email = $my_email; } // Ownership WITHOUT the site-admin-sees-all shortcut: one of the viewed // account's callsigns appears in the node name, the account is a listed node // admin, or the given cert/CSR email matches the account's verified email. $owned_by_view = function (string $cs, string $node_email) use ($view_calls, $view_email): bool { if (calls_match_node($view_calls, $cs)) return true; $adm = redis()->sMembers(rkey("node_admins:$cs")) ?: []; foreach ($view_calls as $c) { if ($c !== '' && in_array($c, $adm, true)) return true; } return $view_email !== '' && $node_email !== '' && $node_email === $view_email; }; // Whether the viewed account owns a node — callsign match, node-admin // membership, or its verified email in the node's cert. $view_owns = function (string $cs) use ($target_uuid, $is_admin, $uuid, $owned_by_view): bool { // Own profile with no target: admins see everything, users see their own. if ($target_uuid === '') { if ($is_admin) return true; return user_owns_node($uuid, $cs); } return $owned_by_view($cs, node_cert_email($cs)); }; switch ($action) { // ---- Unified node list: password nodes from the conf + cert nodes from the PKI ---- case 'list': [$rc, $out] = users_ctl('list'); if ($rc !== 0) json_out(['ok' => false, 'error' => 'list_failed', 'detail' => $out], 502); $nodes = []; foreach (array_filter(array_map('trim', explode("\n", $out))) as $cs) { $nodes[$cs] = ['node' => $cs, 'password' => true, 'cert' => false]; } foreach (glob(pki_certs_dir() . '/*.crt') ?: [] as $f) { $cs = basename($f, '.crt'); if (str_starts_with($cs, 'svxreflector_') || str_starts_with($cs, 'SvxReflector_')) continue; $nodes[$cs] ??= ['node' => $cs, 'password' => false, 'cert' => false]; $nodes[$cs]['cert'] = true; $x = @openssl_x509_parse((string)@file_get_contents($f)); $ce = node_cert_email($cs); $nodes[$cs]['email'] = $ce; $nodes[$cs]['validTo'] = (int)($x['validTo_time_t'] ?? 0); if ($ce !== '') cert_email_ensure($ce); cert_node_ensure($cs); $nodes[$cs]['renewals'] = max($ce !== '' ? cert_email_count($ce) : 0, cert_node_count($cs)); $nodes[$cs]['owned'] = $owned_by_view($cs, $ce); } $list = []; foreach ($nodes as $cs => $n) { if (!$view_owns($cs)) continue; $admins = redis()->sMembers(rkey("node_admins:$cs")) ?: []; sort($admins); $n['admins'] = $admins; $list[] = $n; } usort($list, fn($a, $b) => strcmp($a['node'], $b['node'])); // Pending CSRs the viewed account may act on: matched by callsign OR // verified email (or node-admin membership). These appear as // "certificate request" rows so the owner (or an admin) can // accept/issue the renewal once the first cert was issued. $pending = []; foreach (glob(pki_pending_dir() . '/*.csr') ?: [] as $f) { $cs = basename($f, '.csr'); if (str_starts_with($cs, 'svxreflector_') || str_starts_with($cs, 'SvxReflector_')) continue; $ce = node_csr_email($cs); $mine = $owned_by_view($cs, $ce); if (!$view_owns($cs) && !$mine) continue; if ($ce !== '') cert_email_ensure($ce); cert_node_ensure($cs); $cnt = max($ce !== '' ? cert_email_count($ce) : 0, cert_node_count($cs)); $pending[] = [ 'node' => $cs, 'email' => $ce, 'renewals' => $cnt, 'mine' => $mine, // The owner self-signs once count>=1; an admin viewing may always sign. 'canSelfSign' => ($mine && $cnt >= 1) || ($target_uuid !== '' && $is_admin), 'canReject' => ($target_uuid !== '' && $is_admin), ]; } json_out(['ok' => true, 'nodes' => $list, 'pending' => $pending, 'is_admin' => $is_admin]); // ---- Create a password node (site admin only); password shown once. // Optional 'password' sets a chosen one (validated); omitted = generated. // Optional 'admins' (array of callsigns) become node admins. ---- case 'add': if (!$is_admin) json_out(['ok' => false, 'error' => 'forbidden'], 403); $cs = require_node($in); $pw = trim((string)($in['password'] ?? '')); if ($pw !== '' && !preg_match('#^[A-Za-z0-9._!%&@\#*+=:/-]{8,64}$#', $pw)) json_out(['ok' => false, 'error' => 'bad_password', 'detail' => '8-64 chars: letters, digits, ._!%&@#*+=:/-'], 400); if ($pw === '') $pw = gen_password(); [$rc, $out] = users_ctl('add', $cs, $pw); if ($rc !== 0) json_out(['ok' => false, 'error' => 'add_failed', 'detail' => $out], 502); // Optional admins — need not share the node's callsign; record explicitly. foreach ((array)($in['admins'] ?? []) as $adm) { $adm = strtoupper(trim((string)$adm)); if ($adm !== '' && valid_callsign($adm)) redis()->sAdd(rkey("node_admins:$cs"), $adm); } json_out(['ok' => true, 'node' => $cs, 'password' => $pw]); // ---- Show the current password (owner/node admin/site admin) ---- case 'getpw': $cs = require_node($in); if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403); [$rc, $out] = users_ctl('getpw', $cs); if ($rc !== 0) json_out(['ok' => false, 'error' => 'getpw_failed', 'detail' => $out], 502); json_out(['ok' => true, 'node' => $cs, 'password' => $out]); // ---- Set/generate password for an existing node (owner/node admin/site admin). // Optional 'password' sets a chosen one; omitted = generate. ---- case 'resetpw': $cs = require_node($in); if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403); $pw = trim((string)($in['password'] ?? '')); if ($pw !== '' && !preg_match('#^[A-Za-z0-9._!%&@\#*+=:/-]{8,64}$#', $pw)) json_out(['ok' => false, 'error' => 'bad_password', 'detail' => '8-64 chars: letters, digits, ._!%&@#*+=:/-'], 400); if ($pw === '') $pw = gen_password(); [$rc, $out] = users_ctl('setpw', $cs, $pw); if ($rc !== 0) json_out(['ok' => false, 'error' => 'resetpw_failed', 'detail' => $out], 502); json_out(['ok' => true, 'node' => $cs, 'password' => $pw]); // ---- Self-service: accept/issue a pending CSR for your own cert ---- // Allowed when the caller owns the CSR — its callsign matches one of the // account's callsigns OR its email matches the verified account email (or // the caller is a node admin) — AND an admin has already issued the first // cert (either counter >= 1). Admins may sign any pending CSR. Signing // goes through the reflector PTY. case 'self_sign': $cs = require_node($in); if (!is_file(pki_pending_dir() . "/{$cs}.csr")) json_out(['ok' => false, 'error' => 'no_pending_csr'], 404); $ce = node_csr_email($cs); if (!$is_admin) { if (!$owned_by_view($cs, $ce)) json_out(['ok' => false, 'error' => 'not_your_csr'], 403); $cnt = max($ce !== '' ? cert_email_count($ce) : 0, cert_node_count($cs)); if ($cnt < 1) json_out(['ok' => false, 'error' => 'first_issue_needs_admin'], 403); } $r = reflector_ca('SIGN', $cs); if ($r !== true) json_out(['ok' => false, 'error' => 'sign_failed', 'detail' => $r], 502); $n = cert_node_bump($cs); if ($ce !== '') $n = max($n, cert_email_bump($ce)); json_out(['ok' => true, 'node' => $cs, 'renewals' => $n]); // ---- Revoke — remove the cert (CA RM) but KEEP the renewal counters, so // the node can re-issue/renew. Allowed for a callsign-admin/owner of the // node (callsign match, node-admin membership, verified email) OR any site // admin (lvl >= 1). A plain user with no relation to the node cannot. ---- case 'self_revoke': case 'revoke': $cs = require_node($in); if (!is_file(pki_certs_dir() . "/{$cs}.crt")) json_out(['ok' => false, 'error' => 'no_cert'], 404); if (acct_admin_level($uuid) < 1 && !$owned_by_view($cs, node_cert_email($cs))) json_out(['ok' => false, 'error' => 'not_your_cert'], 403); $r = reflector_ca('RM', $cs); if ($r !== true) json_out(['ok' => false, 'error' => 'revoke_failed', 'detail' => $r], 502); json_out(['ok' => true, 'node' => $cs]); // ---- Delete certificate — PERMANENT full removal (lvl2-3 site admins only, // NOT callsign-admins). Removes the signed cert + any pending CSR, resets the // renewal counters and clears node-admin membership so nothing can re-issue. ---- case 'delete_cert': $cs = require_node($in); if (acct_admin_level($uuid) < 2) json_out(['ok' => false, 'error' => 'admin2_required'], 403); $ce = node_cert_email($cs); if (is_file(pki_certs_dir() . "/{$cs}.crt")) { $r = reflector_ca('RM', $cs); if ($r !== true) json_out(['ok' => false, 'error' => 'delete_failed', 'detail' => $r], 502); } @unlink(pki_pending_dir() . "/{$cs}.csr"); // drop any pending CSR redis()->del(cert_node_key($cs)); // reset per-node renewal counter if ($ce !== '') redis()->del(cert_email_key($ce)); // reset per-email renewal counter redis()->del(rkey("node_admins:$cs")); // clear callsign admins json_out(['ok' => true, 'node' => $cs]); // ---- Linked portal account for a node (owner/node admin/site admin) ---- // Scans the primary-callsign (call:) and extra-callsign (call_accts:) // indexes for the longest callsign contained in the node name, falling // back to the cert's subjectAltName email — same linking rules as // user_owns_node(). case 'info': $cs = require_node($in); if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403); $linked_via = null; $acct_uuid = null; $node_uc = strtoupper($cs); $best_call = ''; $rds = redis(); foreach ([[call_index_key(''), 'callsign'], [rkey('call_accts:'), 'extra_callsign']] as [$prefix, $via]) { $cursor = null; do { $result = $rds->scan($cursor, $prefix . '*', 200); if ($result === false) break; foreach ($result as $key) { $call = substr($key, strlen($prefix)); if ($call === '' || strlen($call) <= strlen($best_call)) continue; if (!calls_match_node([$call], $node_uc)) continue; if ($via === 'callsign') { $u = $rds->get($key); } else { $mem = $rds->sMembers($key) ?: []; sort($mem); $u = $mem[0] ?? null; } if (!is_string($u) || $u === '') continue; $best_call = $call; $acct_uuid = $u; $linked_via = $via; } } while ($cursor > 0); } if ($acct_uuid === null) { $email = node_cert_email($cs); if ($email !== null && $email !== '') { $u = redis()->get(email_index_key(norm_email($email))); if (is_string($u) && $u !== '') { $acct_uuid = $u; $linked_via = 'cert_email'; } } } $account = null; if ($acct_uuid !== null) { $a = acct_get($acct_uuid); $account = [ 'callsign' => $a['callsign'] ?? '', 'name' => trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? '')), 'email' => $a['email'] ?? '', 'admin_level' => acct_level_from_flags(acct_flags_from($a)), 'email_verified' => (int)($a['email_verified'] ?? 0), 'banned' => (int)($a['banned'] ?? 0), 'last_login_at' => (int)($a['last_login_at'] ?? 0), 'city' => $a['city'] ?? '', 'country' => $a['country'] ?? '', ]; } json_out(['ok' => true, 'node' => $cs, 'linked_via' => $linked_via, 'account' => $account]); // ---- Remove a password node (site admin only) ---- case 'del': if (!$is_admin) json_out(['ok' => false, 'error' => 'forbidden'], 403); $cs = require_node($in); [$rc, $out] = users_ctl('del', $cs); if ($rc !== 0) json_out(['ok' => false, 'error' => 'del_failed', 'detail' => $out], 502); // Keep the admin list if the node still exists via a cert. if (!is_file(pki_certs_dir() . "/{$cs}.crt")) redis()->del(rkey("node_admins:$cs")); json_out(['ok' => true, 'node' => $cs]); default: json_out(['ok' => false, 'error' => 'unknown_action'], 400); }