360 lines
16 KiB
PHP
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);
|
|
}
|