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>
327 lines
17 KiB
PHP
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);
|
|
}
|