= 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); }