Files
reflector-dashboard/auth/admin_nodes.php
T

141 lines
5.8 KiB
PHP

<?php
/*
* Reflector Dashboard — 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);
require __DIR__ . '/lib.php';
$uuid = require_admin(2);
// Preferred: the account uuid, so all of the account's callsigns (primary +
// admin-added extra/club calls) are matched. 'callsign' kept as fallback.
$acct_uuid = trim($_GET['uuid'] ?? '');
$callsign = strtoupper(trim($_GET['callsign'] ?? ''));
if ($acct_uuid !== '') {
$calls = acct_all_calls($acct_uuid);
} elseif ($callsign !== '') {
$u = redis()->get(call_index_key(base_call($callsign)));
$calls = (is_string($u) && $u !== '') ? acct_all_calls($u) : [$callsign];
} else {
json_out(['ok' => false, 'error' => 'missing_callsign'], 400);
}
if (!$calls) json_out(['ok' => true, 'nodes' => (object)[]]);
// Parse paths from svxreflector.conf
$conf_lines = @file('/etc/svxlink/svxreflector.conf', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$pki_dir = '/etc/svxlink/pki';
$pending_dir = '';
$certs_dir = '';
foreach ($conf_lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'CERT_PKI_DIR=')) $pki_dir = substr($line, strlen('CERT_PKI_DIR='));
if (str_starts_with($line, 'CERT_CA_PENDING_CSRS_DIR=')) $pending_dir = substr($line, strlen('CERT_CA_PENDING_CSRS_DIR='));
if (str_starts_with($line, 'CERT_CA_CERTS_DIR=')) $certs_dir = substr($line, strlen('CERT_CA_CERTS_DIR='));
}
if ($pending_dir === '') $pending_dir = $pki_dir . '/pending_csrs';
if ($certs_dir === '') $certs_dir = $pki_dir . '/certs';
// Renewal count for a node, via the per-email counter (cert email, else CSR email).
function node_renewals(string $cs): int {
$e = node_cert_email($cs);
if ($e === '') $e = node_csr_email($cs);
return $e === '' ? 0 : cert_email_count($e);
}
// Fetch reflector status
$ch = curl_init('http://127.0.0.1:8098/api/status');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 2]);
$json = curl_exec($ch);
curl_close($ch);
$data = json_decode($json ?: '', true);
$all_nodes = (is_array($data) && isset($data['nodes'])) ? $data['nodes'] : [];
$r = redis();
$matched = [];
foreach ($all_nodes as $node_call => $node_info) {
// Only LOCAL client nodes — remote trunk nodes belong to other reflectors.
if (!empty($node_info['remoteTrunk'])) continue;
$sysop = strtoupper(trim($node_info['sysop'] ?? ''));
$match = ($sysop !== '' && in_array($sysop, $calls, true))
|| calls_match_node($calls, (string)$node_call);
// Also match if one of the account's callsigns is a node admin
if (!$match) {
$admins = $r->sMembers(rkey("node_admins:" . strtoupper($node_call)));
if (is_array($admins)) {
foreach ($calls as $c) {
if (in_array($c, $admins, true)) { $match = true; break; }
}
}
}
if ($match) {
// Node admins (sysop always included)
$stored = $r->sMembers(rkey("node_admins:" . strtoupper($node_call)));
if (!is_array($stored)) $stored = [];
$all_admins = $stored;
if ($sysop !== '' && !in_array($sysop, $all_admins, true)) array_unshift($all_admins, $sysop);
sort($all_admins);
$node_info['node_admins'] = $all_admins;
// Cert status
$node_info['has_cert'] = is_file(rtrim($certs_dir, '/') . '/' . $node_call . '.crt');
$node_info['has_pending_csr'] = is_file(rtrim($pending_dir, '/') . '/' . $node_call . '.csr');
$node_info['renewals'] = node_renewals($node_call);
$matched[$node_call] = $node_info;
}
}
// --- Also include OFFLINE nodes for this callsign (not currently connected) ---
// Password nodes from the reflector conf (via the scoped helper) and cert nodes
// from the PKI, matched by base callsign or node-admin membership. Mirrors the
// profile's node list so admins can manage admins even when a node is down.
$add_offline = function (string $nc, array $extra) use (&$matched, $r, $calls, $certs_dir, $pending_dir) {
$nc = strtoupper(trim($nc));
if ($nc === '' || isset($matched[$nc])) return;
$base = explode('-', $nc)[0];
$admins = $r->sMembers(rkey("node_admins:$nc"));
if (!is_array($admins)) $admins = [];
if (!calls_match_node($calls, $nc)) {
$isadm = false;
foreach ($calls as $c) {
if (in_array($c, $admins, true)) { $isadm = true; break; }
}
if (!$isadm) return;
}
$all_admins = $admins;
if ($base !== '' && !in_array($base, $all_admins, true)) array_unshift($all_admins, $base);
sort($all_admins);
$matched[$nc] = $extra + [
'offline' => true,
'sysop' => $base,
'node_admins' => $all_admins,
'has_cert' => is_file(rtrim($certs_dir, '/') . "/$nc.crt"),
'has_pending_csr' => is_file(rtrim($pending_dir, '/') . "/$nc.csr"),
'renewals' => node_renewals($nc),
];
};
// Password nodes (offline included) from the reflector conf via the scoped helper
$pw_out = [];
@exec('sudo /usr/local/sbin/svxreflector-users list 2>/dev/null', $pw_out);
foreach ($pw_out as $pn) $add_offline($pn, ['password' => true]);
// Cert nodes (offline included) from the PKI
foreach (glob(rtrim($certs_dir, '/') . '/*.crt') ?: [] as $f) {
$cn = basename($f, '.crt');
if (str_starts_with($cn, 'svxreflector_') || str_starts_with($cn, 'SvxReflector_')) continue;
$add_offline($cn, ['cert' => true]);
}
json_out(['ok' => true, 'nodes' => $matched]);