Files
reflector-dashboard/auth/node_auth_api.php
T
bjorneandClaude Fable 5.1 25b481e2ae Public web-only release: SvxPortal front-end + auth backend, install templates and INSTALL.md
Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr),
deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles
live in install/ with CHANGEME placeholders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 23:07:05 +02:00

327 lines
17 KiB
PHP

<?php
declare(strict_types=1);
// Reflector node authentication: manage login/password nodes ([USERS]/[PASSWORDS]
// in svxreflector.conf, edited only via the scoped sudo helper svxreflector-users)
// and list them together with cert-authenticated nodes. Cert nodes are linked to
// portal accounts by callsign OR email — either one in the cert/CSR is enough:
// the CN (node name) matched against the account's callsigns (contains-match,
// calls_match_node in lib.php) or the subjectAltName email matched against the
// account's verified email.
//
// list -> 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 <callsign>.<ext>).
// 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);
}