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