Files
reflector-dashboard/auth/node_cmd_api.php
T

150 lines
7.2 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);
// Remote-control a SvxLink node over MQTT, manage the portal's publish settings, and
// provision per-node broker credentials (stored in the node profile).
require __DIR__ . '/lib.php';
$uuid = require_login();
$in = json_in();
$action = $in['action'] ?? 'tg';
// Amateur-format callsign (stricter than the node-name rule in auth/lib.php);
// passed to the shared require_callsign() as its validator.
function valid_amateur_callsign(string $cs): bool {
return (bool)preg_match('/^[A-Z0-9]{1,3}[0-9][A-Z0-9]{0,3}[A-Z](-[A-Z0-9]{1,10})?$/', $cs);
}
function admin_level(string $uuid): int { return acct_admin_level($uuid); }
function require_admin2(string $uuid): void { if (admin_level($uuid) < 2) json_out(['ok' => false, 'error' => 'forbidden'], 403); }
// Node ownership: user_owns_node() in lib.php (base callsign / node admin /
// cert email match / site admin).
// Scoped provisioning helper on the server (writes /etc/mosquitto/passwd, reloads mosquitto).
// Uses exec() (proc_open is disabled in this FPM pool); the password is passed via a 0600
// temp file (no stdin, and kept out of the process argv). The helper reads + shreds it.
function provision_call(string $sub, string $cs, ?string $pw = null): array {
$arg = '';
$tmp = '';
if ($pw !== null) {
$tmp = tempnam(sys_get_temp_dir(), 'mqp');
file_put_contents($tmp, $pw);
chmod($tmp, 0600);
$arg = ' ' . escapeshellarg($tmp);
}
$cmd = 'sudo /usr/local/sbin/svx-mqtt-provision ' . escapeshellarg($sub) . ' ' . escapeshellarg($cs) . $arg;
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))];
}
// ===== Node-owner actions: read / (re)generate this node's MQTT credentials =====
// Broker the NODES connect to (host:port shown to the node owner). Runtime config,
// never hardcoded: auth/config.php 'mqtt' => ['node_broker_host' => ..., 'node_broker_port' => ...].
function node_broker(): array {
$c = cfg()['mqtt'] ?? [];
$host = trim((string)($c['node_broker_host'] ?? ''));
$port = (int)($c['node_broker_port'] ?? 0);
if ($host === '' || $port <= 0)
json_out(['ok' => false, 'error' => 'mqtt_broker_not_configured',
'detail' => "set mqtt.node_broker_host / node_broker_port in auth/config.php"], 500);
return ['broker_host' => $host, 'broker_port' => $port];
}
if ($action === 'mqtt_node_get' || $action === 'mqtt_node_provision') {
$cs = require_callsign($in, 'valid_amateur_callsign');
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
$key = rkey('node_mqtt:' . $cs);
$user = strtolower($cs);
if ($action === 'mqtt_node_get') {
$rec = redis()->hGetAll($key) ?: [];
json_out(['ok' => true, 'provisioned' => !empty($rec['pass']),
'username' => $user, 'password' => $rec['pass'] ?? ''] + node_broker());
}
// provision / regenerate
csrf_check();
$pw = bin2hex(random_bytes(12)); // 24 hex chars, no shell-special chars
[$rc, $err] = provision_call('provision', $cs, $pw);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'provision_failed', 'detail' => $err], 502);
redis()->hSet($key, 'user', $user);
redis()->hSet($key, 'pass', $pw);
redis()->hSet($key, 'ts', (string)time());
redis()->hSet($key, 'by', $uuid);
json_out(['ok' => true, 'username' => $user, 'password' => $pw] + node_broker());
}
// ===== Portal MQTT publish settings (admin) =====
function mqtt_settings(): array {
$s = redis()->hGetAll(rkey('portal/mqtt')) ?: [];
$c = cfg()['mqtt'] ?? [];
return [
'host' => (string)($s['host'] ?? $c['pub_host'] ?? '127.0.0.1'),
'port' => (int) ($s['port'] ?? $c['pub_port'] ?? 1883),
'user' => (string)($s['user'] ?? $c['pub_user'] ?? ''),
'pass' => (string)($s['pass'] ?? $c['pub_pass'] ?? ''),
];
}
if ($action === 'mqtt_get') {
require_admin2($uuid);
$s = mqtt_settings();
json_out(['ok' => true, 'host' => $s['host'], 'port' => $s['port'], 'user' => $s['user'], 'pass_set' => $s['pass'] !== '']);
}
if ($action === 'mqtt_set') {
require_admin2($uuid); csrf_check();
// The MQTT broker password is a system secret. LVL1 is read-only; LVL2 may
// set it blind (blank keeps the current one), LVL3 fully.
if (acct_admin_rank($uuid) < 2) json_out(['ok' => false, 'error' => 'forbidden', 'reason' => 'read_only_admin'], 403);
$host = trim((string)($in['host'] ?? ''));
if ($host === '') json_out(['ok' => false, 'error' => 'host_required'], 400);
$port = (int)($in['port'] ?? 1883);
if ($port < 1 || $port > 65535) json_out(['ok' => false, 'error' => 'bad_port'], 400);
$k = rkey('portal/mqtt');
redis()->hSet($k, 'host', $host);
redis()->hSet($k, 'port', (string)$port);
redis()->hSet($k, 'user', trim((string)($in['user'] ?? '')));
if (isset($in['pass']) && $in['pass'] !== '') redis()->hSet($k, 'pass', (string)$in['pass']);
json_out(['ok' => true]);
}
// ===== Publish a command (node owner OR admin) =====
csrf_check();
$cs = require_callsign($in, 'valid_amateur_callsign');
if (!user_owns_node($uuid, $cs)) json_out(['ok' => false, 'error' => 'not_your_node'], 403);
$call = strtolower($cs);
if ($action === 'tg') {
// Kept for programmatic use: activate/monitor a talkgroup by number (node wraps 91/94<tg>#).
$tg = trim((string)($in['tg'] ?? ''));
if (!preg_match('/^[0-9]{1,7}$/', $tg)) json_out(['ok' => false, 'error' => 'invalid_tg'], 400);
$mode = (($in['mode'] ?? 'active') === 'monitor') ? 'monitor' : 'active';
$topic = "svxnode/{$call}/cmd/tg";
$payload = json_encode(['tg' => $tg, 'mode' => $mode]);
} elseif ($action === 'dtmf') {
// Full SvxLink DTMF command sent literally, e.g. 91240# 92# 929#
$code = strtoupper(trim((string)($in['dtmf'] ?? '')));
if (!preg_match('/^[0-9A-D*#]{1,16}$/', $code)) json_out(['ok' => false, 'error' => 'invalid_dtmf'], 400);
$topic = "svxnode/{$call}/cmd/dtmf";
$payload = $code;
} else {
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
$s = mqtt_settings();
if ($s['user'] === '' || $s['pass'] === '') json_out(['ok' => false, 'error' => 'mqtt_not_configured'], 500);
$cmd = 'mosquitto_pub'
. ' -h ' . escapeshellarg($s['host']) . ' -p ' . escapeshellarg((string)$s['port'])
. ' -u ' . escapeshellarg($s['user']) . ' -P ' . escapeshellarg($s['pass'])
. ' -q 1 -t ' . escapeshellarg($topic) . ' -m ' . escapeshellarg((string)$payload);
exec($cmd . ' 2>&1', $out, $rc);
if ($rc !== 0) json_out(['ok' => false, 'error' => 'publish_failed', 'detail' => implode("\n", $out)], 502);
json_out(['ok' => true, 'topic' => $topic, 'payload' => $payload]);