Files
reflector-dashboard/auth/admin_reflector_api.php
T

360 lines
16 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);
$in = json_in();
$action = $in['action'] ?? '';
// These are the READ-ONLY actions. LVL1 is a read-only admin: it may run these
// but nothing else (LVL2/LVL3 have rank >= 2). The same set is csrf-exempt.
const REFLECTOR_RO_ACTIONS = ['pending', 'list_certs', 'cert_info', 'config_read', 'service_status'];
if (!in_array($action, REFLECTOR_RO_ACTIONS, true)) {
csrf_check();
if (acct_admin_rank($uuid) < 2) {
json_out(['ok' => false, 'error' => 'forbidden', 'reason' => 'read_only_admin'], 403);
}
}
// --- Parse svxreflector.conf for paths (same as svx-ca-admin.py) ---
define('REFLECTOR_CONF', '/etc/svxlink/svxreflector.conf');
function reflector_conf(): array {
static $cache = null;
if ($cache !== null) return $cache;
$cache = [];
$section = '';
foreach (file(REFLECTOR_CONF, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (preg_match('/^\[(.+)\]$/', $line, $m)) { $section = $m[1]; continue; }
if (str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$cache[$section . '.' . trim($k)] = trim($v);
}
}
return $cache;
}
function conf_get(string $section, string $key, string $default = ''): string {
return reflector_conf()[$section . '.' . $key] ?? $default;
}
$pki_dir = conf_get('GLOBAL', 'CERT_PKI_DIR', '/etc/svxlink/pki');
$pending_dir = conf_get('GLOBAL', 'CERT_CA_PENDING_CSRS_DIR', $pki_dir . '/pending_csrs');
$csrs_dir = conf_get('GLOBAL', 'CERT_CA_CSRS_DIR', $pki_dir . '/csrs');
$certs_dir = conf_get('GLOBAL', 'CERT_CA_CERTS_DIR', $pki_dir . '/certs');
$pty_path = conf_get('GLOBAL', 'COMMAND_PTY', '/dev/shm/reflector_ctrl');
// Node callsigns are validated the way the reflector does it: against the
// ACCEPT_CALLSIGN/REJECT_CALLSIGN regexes in svxreflector.conf plus path
// safety (node_callsign_valid in lib.php).
// valid_callsign() comes from auth/lib.php.
// require_callsign() comes from auth/lib.php.
// Write command to reflector PTY via root-owned helper running as svxlink
// (the PTY slave is mode 0600 owned by svxlink; www-data cannot write it directly)
function pty_write(string $pty_path, string $command): true|string {
$parts = preg_split('/\s+/', trim($command));
if (!$parts || $parts[0] === '')
return 'Empty PTY command';
$cmd = 'sudo -u svxlink /usr/local/sbin/svxreflector-pty '
. implode(' ', array_map('escapeshellarg', $parts));
exec($cmd . ' 2>&1', $out, $rc);
return $rc === 0 ? true : (implode("\n", $out) ?: 'PTY write failed');
}
// Restore masked secrets before writing config back. When an operator below LVL3
// saves, config_read gave them every KEY/KEYFILE/PASSWORD/private value (and the
// whole [PASSWORDS] section) as `********`. Any line whose value is purely
// asterisks is put back from $old, matched by (section,key). A value the operator
// actually changed (not all-asterisks) is left as typed and written through.
function svxconf_restore_masked_secrets(string $new, string $old): string {
// Build (SECTION => key => value) from the live file.
$oldMap = [];
$sec = '';
foreach (preg_split('/\r?\n/', $old) as $ln) {
$t = trim($ln);
if ($t === '' || $t[0] === '#' || $t[0] === ';') continue;
if ($t[0] === '[' && substr($t, -1) === ']') { $sec = strtoupper(trim(substr($t, 1, -1))); continue; }
$eq = strpos($ln, '=');
if ($eq === false) continue;
$oldMap[$sec][trim(substr($ln, 0, $eq))] = trim(substr($ln, $eq + 1));
}
// Walk the incoming file; restore purely-masked values in place.
$sec = '';
$out = [];
foreach (preg_split('/\r?\n/', $new) as $ln) {
$t = trim($ln);
if ($t !== '' && $t[0] === '[' && substr($t, -1) === ']') {
$sec = strtoupper(trim(substr($t, 1, -1)));
$out[] = $ln;
continue;
}
$eq = strpos($ln, '=');
if ($eq !== false && $t !== '' && $t[0] !== '#' && $t[0] !== ';') {
$k = trim(substr($ln, 0, $eq));
$v = trim(substr($ln, $eq + 1));
if ($v !== '' && preg_match('/^\*+$/', $v) && isset($oldMap[$sec][$k])) {
$ln = rtrim(substr($ln, 0, $eq)) . '=' . $oldMap[$sec][$k];
}
}
$out[] = $ln;
}
return implode("\n", $out);
}
switch ($action) {
// ---- List pending CSRs ----
case 'pending':
$list = [];
$files = glob(rtrim($pending_dir, '/') . '/*.csr');
if ($files) {
foreach ($files as $f) {
$cs = basename($f, '.csr');
$csr = @openssl_csr_get_subject(file_get_contents($f));
$list[] = [
'callsign' => $cs,
'subject' => $csr ? ('CN=' . ($csr['CN'] ?? '?')) : '?',
'mtime' => filemtime($f) ?: 0,
];
}
usort($list, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
}
json_out(['ok' => true, 'pending' => $list]);
// ---- List signed client certs ----
case 'list_certs':
$list = [];
$files = glob(rtrim($certs_dir, '/') . '/*.crt');
if ($files) {
foreach ($files as $f) {
$cs = basename($f, '.crt');
if (str_starts_with($cs, 'svxreflector_') || str_starts_with($cs, 'SvxReflector_'))
continue;
$list[] = [
'callsign' => $cs,
'mtime' => filemtime($f) ?: 0,
];
}
usort($list, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
}
json_out(['ok' => true, 'certs' => $list]);
// ---- Certificate details ----
case 'cert_info':
$cs = require_callsign($in);
$crtFile = rtrim($certs_dir, '/') . "/{$cs}.crt";
if (!is_file($crtFile))
json_out(['ok' => false, 'error' => "No certificate for {$cs}"], 404);
$pem = file_get_contents($crtFile);
$x = @openssl_x509_parse($pem);
if (!$x)
json_out(['ok' => false, 'error' => 'Cannot parse certificate'], 500);
$fp = @openssl_x509_fingerprint($pem, 'sha256');
$issuer = '';
if (isset($x['issuer']) && is_array($x['issuer']))
$issuer = implode(', ', array_map(fn($k, $v) => "$k=$v", array_keys($x['issuer']), array_values($x['issuer'])));
json_out(['ok' => true, 'info' => [
'callsign' => $cs,
'subjectCN' => $x['subject']['CN'] ?? '',
'issuer' => $issuer,
'serial' => $x['serialNumberHex'] ?? (string)($x['serialNumber'] ?? ''),
'validFrom' => $x['validFrom_time_t'] ?? 0,
'validTo' => $x['validTo_time_t'] ?? 0,
'fingerprint' => $fp ? strtoupper(implode(':', str_split($fp, 2))) : '',
'hasCsr' => is_file(rtrim($csrs_dir, '/') . "/{$cs}.csr"),
]]);
// ---- Add a pending CSR (paste PEM) ----
case 'add_pending':
$pem = trim((string)($in['csr'] ?? ''));
if ($pem === '' || !str_contains($pem, 'BEGIN CERTIFICATE REQUEST'))
json_out(['ok' => false, 'error' => 'Not a PEM certificate request'], 400);
$subj = @openssl_csr_get_subject($pem);
if (!$subj || empty($subj['CN']))
json_out(['ok' => false, 'error' => 'Invalid CSR (no CN found)'], 400);
$cs = strtoupper(trim($subj['CN']));
if (!valid_callsign($cs))
json_out(['ok' => false, 'error' => "CN '{$cs}' is not a valid callsign"], 400);
if (is_file(rtrim($pending_dir, '/') . "/{$cs}.csr"))
json_out(['ok' => false, 'error' => "Pending CSR already exists for {$cs}"], 409);
if (is_file(rtrim($certs_dir, '/') . "/{$cs}.crt"))
json_out(['ok' => false, 'error' => "Certificate already issued for {$cs}"], 409);
$dest = rtrim($pending_dir, '/') . "/{$cs}.csr";
if (@file_put_contents($dest, $pem . "\n") === false)
json_out(['ok' => false, 'error' => 'Cannot write CSR file'], 500);
@chmod($dest, 0664);
json_out(['ok' => true, 'message' => "Added pending CSR for {$cs}", 'callsign' => $cs]);
// ---- Re-queue an issued cert's CSR back to pending (for re-signing) ----
case 'repend':
$cs = require_callsign($in);
$signedCsr = rtrim($csrs_dir, '/') . "/{$cs}.csr";
$pend = rtrim($pending_dir, '/') . "/{$cs}.csr";
if (!is_file($signedCsr))
json_out(['ok' => false, 'error' => "No CSR on file for {$cs}; cannot re-pend"], 404);
if (is_file($pend))
json_out(['ok' => false, 'error' => "{$cs} is already pending"], 409);
if (!@copy($signedCsr, $pend))
json_out(['ok' => false, 'error' => 'Cannot write pending CSR'], 500);
@chmod($pend, 0664);
json_out(['ok' => true, 'message' => "{$cs} moved back to pending"]);
// ---- Sign a pending CSR via PTY (CA SIGN) ----
case 'sign':
$cs = require_callsign($in);
if (!is_file(rtrim($pending_dir, '/') . "/{$cs}.csr"))
json_out(['ok' => false, 'error' => "No pending CSR for {$cs}"], 404);
$csr_email = node_csr_email($cs); // read before signing (CSR leaves pending)
$msg = pty_write($pty_path, "CA SIGN {$cs}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
// Bump the counters: the first admin sign takes them 0 -> 1, which
// unlocks self-service renewals for the node (per-node counter) and
// for the account email carried in the CSR (per-email counter).
cert_node_bump($cs);
if ($csr_email !== '') cert_email_bump($csr_email);
json_out(['ok' => true, 'message' => "Sent CA SIGN {$cs} to reflector"]);
// ---- Revoke — remove cert + CSR via PTY (CA RM) ----
case 'revoke':
$cs = require_callsign($in);
$crtFile = rtrim($certs_dir, '/') . "/{$cs}.crt";
if (!is_file($crtFile))
json_out(['ok' => false, 'error' => "No certificate for {$cs}"], 404);
$msg = pty_write($pty_path, "CA RM {$cs}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Removed certificate for {$cs}"]);
// ---- Remove (reject) a pending CSR ----
case 'rmpending':
$cs = require_callsign($in);
$csrFile = rtrim($pending_dir, '/') . "/{$cs}.csr";
if (!is_file($csrFile))
json_out(['ok' => false, 'error' => "No pending CSR for {$cs}"], 404);
@unlink($csrFile);
json_out(['ok' => true, 'message' => "Removed pending CSR for {$cs}"]);
// ---- Block a node via PTY ----
case 'block':
$cs = require_callsign($in);
$secs = (int)($in['seconds'] ?? 0);
if ($secs < 1)
json_out(['ok' => false, 'error' => 'invalid_seconds'], 400);
$msg = pty_write($pty_path, "NODE BLOCK {$cs} {$secs}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Blocked {$cs} for {$secs}s"]);
// ---- Unblock a node via PTY ----
case 'unblock':
$cs = require_callsign($in);
$msg = pty_write($pty_path, "NODE BLOCK {$cs} 0"); // GeuReflector: unblock = block 0s
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Unblocked {$cs}"]);
// ---- Service control (reload/restart/stop) ----
case 'service_reload':
case 'service_restart':
case 'service_stop':
case 'service_status':
if ($action === 'service_stop' && acct_admin_level($uuid) < 3) json_out(['ok'=>false,'error'=>'superadmin_required'], 403);
$svc_action = str_replace('service_', '', $action);
$cmd = 'sudo /usr/local/sbin/svxreflector-systemctl ' . escapeshellarg($svc_action);
$output = [];
$rc = 0;
exec($cmd . ' 2>&1', $output, $rc);
$out = implode("\n", $output);
if ($rc === 0) json_out(['ok' => true, 'message' => $out]);
else json_out(['ok' => false, 'error' => $out], 500);
// ---- Config: read all sections via PTY ----
case 'config_read':
$conf_content = @file_get_contents('/etc/svxlink/svxreflector.conf');
if ($conf_content === false)
json_out(['ok' => false, 'error' => 'Cannot read config file'], 500);
// Parse into sections
$sections = [];
$current_section = '';
foreach (explode("\n", $conf_content) as $line) {
$trimmed = trim($line);
if ($trimmed === '' || $trimmed[0] === '#') continue;
if (preg_match('/^\[(.+)\]$/', $trimmed, $m)) {
$current_section = $m[1];
if (!isset($sections[$current_section])) $sections[$current_section] = [];
continue;
}
if (str_contains($trimmed, '=') && $current_section !== '') {
[$k, $v] = explode('=', $trimmed, 2);
$sections[$current_section][trim($k)] = trim($v);
}
}
// Mask sensitive data for non-superadmin
$level = acct_admin_level($uuid);
if ($level < 3) {
// Mask raw content
$conf_content = preg_replace('/^(.*(?:KEY|KEYFILE|PASSWORD|private).*=\s*).+$/mi', '$1********', $conf_content);
// Mask sections
foreach ($sections as $sec => &$vals) {
if (strtoupper($sec) === 'PASSWORDS') {
foreach ($vals as $k => &$v) $v = '********';
}
foreach ($vals as $k => &$v) {
if (preg_match('/key|keyfile|private|password/i', $k)) $v = '********';
}
}
unset($vals, $v);
}
// LVL2 may edit config (secrets stay masked; restored on save), LVL3 fully.
json_out(['ok' => true, 'sections' => $sections, 'raw' => $conf_content, 'can_edit' => acct_admin_rank($uuid) >= 2]);
// ---- Config: write a value via PTY ----
case 'config_set':
$section = strtoupper(trim($in['section'] ?? ''));
$tag = trim($in['tag'] ?? '');
$value = trim($in['value'] ?? '');
if ($section === '' || $tag === '')
json_out(['ok' => false, 'error' => 'missing section or tag'], 400);
$msg = pty_write($pty_path, "CFG {$section} {$tag} {$value}");
if ($msg !== true)
json_out(['ok' => false, 'error' => $msg], 500);
json_out(['ok' => true, 'message' => "Set {$section}/{$tag}={$value}"]);
// ---- Config: save to file ----
// LVL1 is already blocked by the read-only guard at the top. LVL2 may save
// but never sees secrets (they arrive masked as ********); LVL3 sees the real
// values.
case 'config_save':
$raw = $in['raw'] ?? '';
if ($raw === '')
json_out(['ok' => false, 'error' => 'empty config'], 400);
$path = '/etc/svxlink/svxreflector.conf';
// Below LVL3 the editor showed masked secrets — restore any purely-masked
// value (^\*+$) from the live file so a blind save can't wipe a real
// secret. A value the operator actually typed is written through.
if (acct_admin_rank($uuid) < 3) {
$old = is_readable($path) ? (string)file_get_contents($path) : '';
$raw = svxconf_restore_masked_secrets((string)$raw, $old);
}
// Backup first
@copy($path, $path . '.bak');
if (file_put_contents($path, $raw) === false)
json_out(['ok' => false, 'error' => 'Cannot write config file'], 500);
json_out(['ok' => true, 'message' => 'Config saved']);
default:
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}