Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
130 lines
6.2 KiB
PHP
130 lines
6.2 KiB
PHP
<?php
|
|
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 =====
|
|
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'] ?? '',
|
|
'broker_host' => 'svx.sa7aux.se', 'broker_port' => 8883]);
|
|
}
|
|
|
|
// 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,
|
|
'broker_host' => 'svx.sa7aux.se', 'broker_port' => 8883]);
|
|
}
|
|
|
|
// ===== 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]);
|