From 09604183070b2cfb4bb690709ebe03653afd9e1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:40:01 +0000 Subject: [PATCH] feat(cluster): root commands over the command channel NodeActions (reboot, update, service restarts) become signed node.root commands for nodes with the COMMANDS flow whose root-owned pin of the panel key is in place; others keep the signals table. - RootPin: /etc/xc_vm/cluster/{main_sign.pub,node,root.seq}, trusted only when root-owned and not group/other writable - cluster:exec hands node.root to root through config/cluster/root-inbox - cluster:root (root cron, 58 s watch) verifies against the pin, raises its own high-water before running, runs RootSignalsCronJob actions and writes results without following planted links - cluster:pin-root pins a code-enrolled node; LbInstallFlow pins over SSH at install - cluster_nodes.root_ready (migration 039) from the agent's heartbeat; the Cluster Nodes page shows it and the panel fingerprint Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf --- src/Cli/Commands/ClusterExecCommand.php | 46 +- src/Cli/Commands/ClusterPinRootCommand.php | 58 +++ src/Cli/Commands/ClusterRootCommand.php | 113 +++++ src/Cli/Commands/LbInstallFlow.php | 11 + src/Cli/Commands/StartupCommand.php | 4 + src/Cli/CronJobs/RootSignalsCronJob.php | 400 +++++++++--------- src/Core/Cluster/NodeActions.php | 8 + src/Core/Cluster/RootPin.php | 143 +++++++ src/Core/Localization/lang/ar.ini | 5 + src/Core/Localization/lang/bg.ini | 5 + src/Core/Localization/lang/de.ini | 5 + src/Core/Localization/lang/en.ini | 5 + src/Core/Localization/lang/es.ini | 5 + src/Core/Localization/lang/fr.ini | 5 + src/Core/Localization/lang/pt.ini | 5 + src/Core/Localization/lang/ru.ini | 5 + src/Domain/Cluster/ClusterAdmin.php | 2 +- src/Domain/Cluster/ClusterRoute.php | 26 +- src/Domain/Cluster/CommandBus.php | 12 +- src/Domain/Cluster/HeartbeatService.php | 9 +- .../Admin/ClusterNodesController.php | 2 + src/Public/Views/admin/cluster_nodes.php | 13 +- src/bin/install/database.sql | 1 + .../down/039_add_cluster_node_root_ready.sql | 2 + .../up/039_add_cluster_node_root_ready.sql | 4 + tests/Unit/ClusterApiTest.php | 21 + tests/Unit/ClusterEnrolCodeTest.php | 1 + tests/Unit/ClusterRootCommandTest.php | 114 +++++ tests/Unit/ClusterSchemaTest.php | 4 +- tests/Unit/LbProvisionClusterTest.php | 8 + 30 files changed, 838 insertions(+), 204 deletions(-) create mode 100644 src/Cli/Commands/ClusterPinRootCommand.php create mode 100644 src/Cli/Commands/ClusterRootCommand.php create mode 100644 src/Core/Cluster/RootPin.php create mode 100644 src/migrations/database/down/039_add_cluster_node_root_ready.sql create mode 100644 src/migrations/database/up/039_add_cluster_node_root_ready.sql create mode 100644 tests/Unit/ClusterRootCommandTest.php diff --git a/src/Cli/Commands/ClusterExecCommand.php b/src/Cli/Commands/ClusterExecCommand.php index bd27ae16..a2ba5968 100644 --- a/src/Cli/Commands/ClusterExecCommand.php +++ b/src/Cli/Commands/ClusterExecCommand.php @@ -6,6 +6,7 @@ use XcVm\Cli\CommandInterface; use XcVm\Core\Cluster\Crypto\Enc; use XcVm\Core\Cluster\Crypto\PanelSig; use XcVm\Core\Cluster\NodeRpc; +use XcVm\Core\Cluster\RootPin; use XcVm\Domain\Server\ServerRepository; use XcVm\Public\Controllers\Api\InternalApiController; @@ -21,7 +22,10 @@ use XcVm\Public\Controllers\Api\InternalApiController; * - `conn.kill_worker {pid, rtmp}` — a viewer's PHP worker (this user's * processes only) or an RTMP client. * - * Runs as xc_vm; root actions are not taken here. + * - `node.root {action, …}` — handed to root (cluster:root) through the + * root inbox; root checks it against its own pin of the panel key. + * + * Runs as xc_vm. * * Usage: `console.php cluster:exec < command.json` * @@ -47,9 +51,49 @@ class ClusterExecCommand implements CommandInterface { fwrite(STDERR, 'cluster:exec: ' . $rCmd . "\n"); return 2; } + if (($rCmd['type'] ?? '') === 'node.root') { + return self::handToRoot($rIn, (int) ($rCmd['seq'] ?? 0)); + } return self::run($rCmd); } + /** Seconds cluster:exec waits for cluster:root's result before acking "queued". */ + public const ROOT_WAIT = 5; + + /** + * `node.root`: root runs it (cluster:root), after checking it against its + * own pin of the panel key; here the signed command is only handed over, + * and root's result waited for briefly. + * + * @param array $rIn {doc, sig} + */ + public static function handToRoot(array $rIn, int $rSeq, int $rWait = self::ROOT_WAIT): int { + $rInbox = RootPin::inbox(); + if ($rSeq <= 0 || !is_dir($rInbox)) { + fwrite(STDERR, "cluster:exec: no root inbox (the node's root pin is not in place)\n"); + return 2; + } + $rTmp = $rInbox . '.' . $rSeq . '.tmp'; + $rDone = $rInbox . $rSeq . '.done'; + @unlink($rDone); + if (@file_put_contents($rTmp, (string) json_encode(['doc' => $rIn['doc'], 'sig' => $rIn['sig']])) === false || !@rename($rTmp, $rInbox . $rSeq . '.json')) { + fwrite(STDERR, "cluster:exec: cannot write the root inbox\n"); + return 2; + } + $rDeadline = microtime(true) + $rWait; + while (microtime(true) < $rDeadline) { + if (is_file($rDone)) { + $rOut = json_decode((string) file_get_contents($rDone), true); + @unlink($rDone); + echo is_array($rOut) ? (string) ($rOut['result'] ?? '') : ''; + return is_array($rOut) && !empty($rOut['ok']) ? 0 : 1; + } + usleep(200000); + } + echo json_encode(['queued' => true]); + return 0; + } + /** * Check a command against the agent's pinned panel key and identity. * diff --git a/src/Cli/Commands/ClusterPinRootCommand.php b/src/Cli/Commands/ClusterPinRootCommand.php new file mode 100644 index 00000000..cfcf0138 --- /dev/null +++ b/src/Cli/Commands/ClusterPinRootCommand.php @@ -0,0 +1,58 @@ +` (root) + * + * @package XC_VM_CLI_Commands + */ +class ClusterPinRootCommand implements CommandInterface { + public function getName(): string { + return 'cluster:pin-root'; + } + + public function getDescription(): string { + return "Pin MAIN's panel key for root commands, checked against its fingerprint (root)"; + } + + public function execute(array $rArgs): int { + if ((posix_getpwuid(posix_geteuid())['name'] ?? null) !== 'root') { + echo "Please run as root!\n"; + return 1; + } + $rFp = strtolower(trim((string) ($rArgs[0] ?? ''))); + $rState = json_decode((string) @file_get_contents(CONFIG_PATH . 'cluster/agent.json'), true); + $rPub = is_array($rState) ? base64_decode((string) ($rState['panel_sign_pub'] ?? ''), true) : false; + $rNode = is_array($rState) ? (string) ($rState['node_uuid'] ?? '') : ''; + if (!preg_match('/^[0-9a-f]{64}$/', $rFp)) { + echo "Usage: cluster:pin-root (64 hex digits, from MAIN)\n"; + return 1; + } + if ($rPub === false || strlen($rPub) !== 32 || $rNode === '') { + echo "This node is not enrolled (config/cluster/agent.json). Exiting\n"; + return 1; + } + if (!hash_equals($rFp, hash('sha256', $rPub))) { + echo "The agent's panel key does not match that fingerprint: nothing pinned. Exiting\n"; + return 1; + } + if (!RootPin::write($rPub, $rNode) || RootPin::read() === null) { + echo 'Could not write ' . RootPin::dir() . ". Exiting\n"; + return 1; + } + @mkdir(RootPin::inbox(), 0700, true); + @chown(RootPin::inbox(), 'xc_vm'); + echo "OK: root commands for node {$rNode} are checked against panel key {$rFp}\n"; + return 0; + } +} diff --git a/src/Cli/Commands/ClusterRootCommand.php b/src/Cli/Commands/ClusterRootCommand.php new file mode 100644 index 00000000..758e371c --- /dev/null +++ b/src/Cli/Commands/ClusterRootCommand.php @@ -0,0 +1,113 @@ +executeAction($rAction, ServerRepository::getAll(), $db); + return (string) ob_get_clean(); + }, time()); + if (time() >= $rUntil) { + break; + } + sleep(1); + } while (true); + return 0; + } + + /** + * One pass over the inbox, oldest seq first. + * + * @param callable(array): string $rRun runs an action, returns its output + * @return list what was done + */ + public static function drain(callable $rRun, int $rNow): array { + $rPin = RootPin::read(); + if ($rPin === null) { + return []; + } + $rDone = []; + $rFiles = glob(RootPin::inbox() . '*.json') ?: []; + natsort($rFiles); + foreach ($rFiles as $rFile) { + $rSeq = (int) basename($rFile, '.json'); + $rDonePath = RootPin::inbox() . $rSeq . '.done'; + if ($rSeq <= 0 || is_link($rFile) || !is_file($rFile) || filesize($rFile) > RootPin::MAX_COMMAND) { + @unlink($rFile); + continue; + } + $rIn = json_decode((string) file_get_contents($rFile), true); + @unlink($rFile); + $rCmd = RootPin::verify($rPin, (string) ($rIn['doc'] ?? ''), (string) Enc::b64urlDecode((string) ($rIn['sig'] ?? '')), $rNow, RootPin::highWater()); + if (is_string($rCmd)) { + RootPin::writeDone($rDonePath, (string) json_encode(['ok' => false, 'result' => 'refused by root: ' . $rCmd])); + $rDone[] = ['seq' => $rSeq, 'ok' => false, 'detail' => $rCmd]; + continue; + } + // At most once: the high-water goes up before the action runs. + if (!RootPin::raiseHighWater((int) $rCmd['seq'])) { + continue; + } + try { + $rOutput = $rRun((array) $rCmd['args']); + $rOk = true; + } catch (\Throwable $rE) { + $rOutput = $rE->getMessage(); + $rOk = false; + } + RootPin::writeDone($rDonePath, (string) json_encode(['ok' => $rOk, 'result' => substr(trim($rOutput), 0, 4096)])); + $rDone[] = ['seq' => (int) $rCmd['seq'], 'ok' => $rOk, 'detail' => (string) $rCmd['args']['action']]; + } + return $rDone; + } +} diff --git a/src/Cli/Commands/LbInstallFlow.php b/src/Cli/Commands/LbInstallFlow.php index 2bdfaa4d..4bc8357a 100644 --- a/src/Cli/Commands/LbInstallFlow.php +++ b/src/Cli/Commands/LbInstallFlow.php @@ -5,6 +5,7 @@ namespace XcVm\Cli\Commands; use XcVm\Core\Cluster\Crypto\ClusterCrypto; use XcVm\Core\Cluster\Crypto\ClusterCryptoFactory; use XcVm\Core\Cluster\Crypto\ClusterRefusedException; +use XcVm\Core\Cluster\RootPin; use XcVm\Core\Config\ConfigReader; use XcVm\Core\Config\SettingsManager; use XcVm\Core\Updates\GitHubReleases; @@ -568,6 +569,16 @@ class LbInstallFlow { if (trim((string) $rDone['output']) !== 'OK') { return $rFail('xc_agent install failed on the node: ' . trim((string) $rDone['output']) . ' Exiting'); } + // Root's own pin of the panel key, for root commands (cluster:root): written + // over this verified SSH session, root-owned, so nothing the panel's user + // can write becomes root's trust anchor. A new pin starts root's seq afresh. + $rPinDir = RootPin::DIR; + call_user_func($rRunSSH, $rConn, 'sudo mkdir -p ' . escapeshellarg($rPinDir) . ' && sudo chown root:root /etc/xc_vm ' . escapeshellarg($rPinDir) . ' && sudo chmod 0755 /etc/xc_vm ' . escapeshellarg($rPinDir) + . ' && echo ' . escapeshellarg(bin2hex($rPanelPub)) . ' | sudo tee ' . escapeshellarg($rPinDir . 'main_sign.pub') . ' >/dev/null' + . ' && echo ' . escapeshellarg($rUuid) . ' | sudo tee ' . escapeshellarg($rPinDir . 'node') . ' >/dev/null' + . ' && sudo chmod 0644 ' . escapeshellarg($rPinDir . 'main_sign.pub') . ' ' . escapeshellarg($rPinDir . 'node') + . ' && sudo rm -f ' . escapeshellarg($rPinDir . 'root.seq') + . ' && sudo -u xc_vm mkdir -p ' . escapeshellarg(dirname(self::AGENT_STATE) . '/root-inbox') . ' && sudo chmod 0700 ' . escapeshellarg(dirname(self::AGENT_STATE) . '/root-inbox')); call_user_func($rRunSSH, $rConn, 'sudo -u xc_vm bash ' . escapeshellarg(MAIN_HOME . 'bin/xc_agent/run.sh') . ' >/dev/null 2>&1 &'); echo 'Node enrolled (uuid ' . $rUuid . ', SAS ' . $rSas . "); it finishes with enrol_complete within 30 minutes\n"; return true; diff --git a/src/Cli/Commands/StartupCommand.php b/src/Cli/Commands/StartupCommand.php index 3c832e3c..aa0594ee 100644 --- a/src/Cli/Commands/StartupCommand.php +++ b/src/Cli/Commands/StartupCommand.php @@ -140,6 +140,10 @@ class StartupCommand implements CommandInterface { private function installRootCrontab(): void { $rCrons = []; $rCrons[] = '* * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:root_signals # XC_VM'; + // MAIN's signed root commands (cluster API, Phase 4); a no-op until the node's root pin exists. + if (file_exists(MAIN_HOME . 'Cli/Commands/ClusterRootCommand.php')) { + $rCrons[] = '* * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cluster:root # XC_VM'; + } if (file_exists(MAIN_HOME . 'Cli/CronJobs/RootMysqlCronJob.php')) { $rCrons[] = '* * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:root_mysql # XC_VM'; } diff --git a/src/Cli/CronJobs/RootSignalsCronJob.php b/src/Cli/CronJobs/RootSignalsCronJob.php index 80411f2b..6452c2fd 100644 --- a/src/Cli/CronJobs/RootSignalsCronJob.php +++ b/src/Cli/CronJobs/RootSignalsCronJob.php @@ -466,7 +466,7 @@ class RootSignalsCronJob implements CommandInterface { $rCheck = ['php' => false, 'services' => false, 'ports' => false, 'ramdisk' => false]; foreach ($rRows as $rRow) { $rData = json_decode($rRow['custom_data'], true); - switch ($rData['action']) { + switch ($rData['action'] ?? '') { case 'disable_ramdisk': case 'enable_ramdisk': $rCheck['ramdisk'] = true; @@ -567,199 +567,7 @@ class RootSignalsCronJob implements CommandInterface { if (!empty($rRow['signal_id'])) { $db->query('DELETE FROM `signals` WHERE `signal_id` = ?;', $rRow['signal_id']); } - switch ($rData['action']) { - case 'reboot': - echo 'Rebooting system...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'REBOOT', 'System rebooted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - $db->close_mysql(); - shell_exec('sudo reboot'); - break; - case OpensslExtra::SIGNAL_ACTION: - // Sent by server:sync-openssl-extra on MAIN. install() keeps the value it - // replaces open for tokens minted just before; php-fpm reads the new one - // on its next request. The MAIN's own value (hmac_keys, image names) never - // changes this way. - $rSet = OpensslExtra::applySignal($rData, !empty($rServers[SERVER_ID]['is_main']), CONFIG_PATH, time()); - if ($rSet === null) { - break; - } - echo 'Setting OPENSSL_EXTRA...' . "\n"; - if ($rSet) { - // This cron runs as root: hand the files to FPM's user directly, - // with no shell in between. - foreach (['openssl_extra', 'openssl_extra.prev'] as $rFile) { - if (is_file(CONFIG_PATH . $rFile)) { - @chown(CONFIG_PATH . $rFile, 'xc_vm'); - @chgrp(CONFIG_PATH . $rFile, 'xc_vm'); - } - } - } - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'OPENSSL_EXTRA', ?, 'root', 'localhost', NULL, ?);", SERVER_ID, $rSet ? 'OPENSSL_EXTRA set to the value sent by MAIN.' : 'Failed to write the OPENSSL_EXTRA sent by MAIN.', time()); - break; - case 'restart_services': - echo 'Restarting services...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RESTART', 'XC_VM services restarted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo systemctl stop xc_vm'); - shell_exec('sudo systemctl start xc_vm'); - break; - case 'stop_services': - echo 'Stopping services...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'STOP', 'XC_VM services stopped on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo systemctl stop xc_vm'); - break; - case 'reload_nginx': - echo 'Reloading nginx...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RELOAD', 'NGINX services reloaded on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - break; - case 'disable_ramdisk': - echo 'Disabling ramdisk...' . "\n"; - $rFstab = file_get_contents('/etc/fstab'); - $rOutput = []; - foreach (explode("\n", $rFstab) as $rLine) { - if (substr($rLine, 0, 31) == 'tmpfs /home/xc_vm/content/streams') { - $rLine = '#' . $rLine; - } - $rOutput[] = $rLine; - } - file_put_contents('/etc/fstab', implode("\n", $rOutput)); - shell_exec('sudo umount -l ' . STREAMS_PATH); - shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); - break; - case 'enable_ramdisk': - echo 'Enabling ramdisk...' . "\n"; - $rFstab = file_get_contents('/etc/fstab'); - $rOutput = []; - foreach (explode("\n", $rFstab) as $rLine) { - if (substr($rLine, 0, 32) == '#tmpfs /home/xc_vm/content/streams') { - $rLine = ltrim($rLine, '#'); - } - $rOutput[] = $rLine; - } - file_put_contents('/etc/fstab', implode("\n", $rOutput)); - shell_exec('sudo mount ' . STREAMS_PATH); - shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); - break; - case 'certbot_generate': - echo 'Generating certbot certificate.' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'CERTBOT', 'Attempting to generate certbot certificate on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php certbot "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); - break; - case 'update_binaries': - echo 'Updating binaries...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'BINARIES', 'Updating XC_VM binaries from XC_VM server...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php binaries 2>&1 &'); - break; - case 'install_module': - echo 'Installing module distributed from MAIN...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Installing module distributed from MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:install "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); - break; - case 'delete_module': - echo 'Deleting module removed on MAIN...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Deleting module removed on MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:delete "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); - break; - case 'update': - echo 'Updating...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', 'Updating XC_VM...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update update 2>&1 &'); - break; - case 'rollback': - $rRbVersion = isset($rData['version']) ? trim((string) $rData['version']) : ''; - if (preg_match('/^\d+\.\d+\.\d+$/', $rRbVersion)) { - echo 'Rolling back to ' . $rRbVersion . '...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', ?, 'root', 'localhost', NULL, ?);", SERVER_ID, 'Rolling back XC_VM to ' . $rRbVersion . '...', time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update rollback ' . escapeshellarg($rRbVersion) . ' 2>&1 &'); - } - break; - case 'set_services': - echo 'Setting PHP Services' . "\n"; - $rServices = intval($rData['count']); - if ($rData['reload']) { - shell_exec('sudo systemctl stop xc_vm'); - } - shell_exec('sudo rm ' . MAIN_HOME . 'bin/php/etc/*.conf'); - $rNewScript = '#! /bin/bash' . "\n"; - $rNewBalance = 'upstream php {' . "\n" . ' least_conn;' . "\n"; - $rTemplate = file_get_contents(MAIN_HOME . 'bin/php/etc/template'); - foreach (range(1, $rServices) as $i) { - $rNewScript .= 'start-stop-daemon --start --quiet --pidfile ' . MAIN_HOME . 'bin/php/sockets/' . $i . '.pid --exec ' . MAIN_HOME . 'bin/php/sbin/php-fpm -- --daemonize --fpm-config ' . MAIN_HOME . 'bin/php/etc/' . $i . '.conf' . "\n"; - $rNewBalance .= ' server unix:' . MAIN_HOME . 'bin/php/sockets/' . $i . '.sock;' . "\n"; - file_put_contents(MAIN_HOME . 'bin/php/etc/' . $i . '.conf', str_replace('#PATH#', MAIN_HOME, str_replace('#ID#', (string) $i, $rTemplate))); - } - file_put_contents(MAIN_HOME . 'bin/daemons.sh', $rNewScript); - file_put_contents(MAIN_HOME . 'bin/nginx/conf/balance.conf', $rNewBalance . '}'); - shell_exec('sudo chown xc_vm:xc_vm ' . MAIN_HOME . 'bin/php/etc/*'); - if ($rData['reload']) { - shell_exec('sudo systemctl start xc_vm'); - } - break; - case 'set_governor': - $rNewGovernor = $rData['data']; - if (!empty($rNewGovernor) && shell_exec('which cpufreq-info')) { - $rGovernors = array_filter(explode(' ', trim(shell_exec('cpufreq-info -g')))); - $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); - if ($rGovernor[2] != $rNewGovernor && in_array($rNewGovernor, $rGovernors)) { - shell_exec("sudo bash -c 'for ((i=0;i<\$(nproc);i++)); do cpufreq-set -c \$i -g " . $rNewGovernor . "; done'"); - sleep(2); - $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); - $db->query('UPDATE `servers` SET `governor` = ? WHERE `id` = ?;', json_encode($rGovernor), SERVER_ID); - } - } - break; - case 'set_sysctl': - $rNewConfig = $rData['data']; - if (!empty($rNewConfig)) { - $rSysCtl = file_get_contents('/etc/sysctl.conf'); - if ($rSysCtl != $rNewConfig) { - shell_exec('sudo modprobe ip_conntrack > /dev/null'); - file_put_contents('/etc/sysctl.conf', $rNewConfig); - shell_exec('sudo sysctl -p > /dev/null'); - $db->query('UPDATE `servers` SET `sysctl` = ? WHERE `id` = ?;', $rNewConfig, SERVER_ID); - } - } - break; - case 'set_port': - echo 'Setting NGINX Port' . "\n"; - if (intval($rData['type']) == 0) { - $rListen = []; - foreach ($rData['ports'] as $rPort) { - if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { - $rListen[] = 'listen ' . intval($rPort) . ';'; - } - } - file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf', implode(' ', $rListen)); - // MAIN: its old HTTP ports, kept for the cluster API alone after a port change. - if (NodeRole::isMain() && class_exists(ClusterEndpoint::class)) { - file_put_contents(MAIN_HOME . 'bin/nginx/conf/cluster_legacy.conf', ClusterEndpoint::nginxConf(ClusterEndpoint::legacyPorts(SettingsManager::getAll()), array_map('intval', $rData['ports']))); - } - file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/live.conf', 'on_play http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_publish http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_play_done http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp;'); - if ($rData['reload']) { - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - } - } elseif (intval($rData['type']) == 1) { - $rListen = []; - foreach ($rData['ports'] as $rPort) { - if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { - $rListen[] = 'listen ' . intval($rPort) . ' ssl;'; - } - } - file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/https.conf', implode(' ', $rListen)); - if ($rData['reload']) { - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - } - } elseif (intval($rData['type']) == 2) { - file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/port.conf', 'listen ' . intval($rData['ports'][0]) . ';'); - if ($rData['reload']) { - shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); - } - } - // no break - default: - break; - } + $this->executeAction(is_array($rData) ? $rData : [], $rServers, $db); } } // Purges every node's signals, not just this one's: MAIN only. @@ -772,6 +580,210 @@ class RootSignalsCronJob implements CommandInterface { } } + /** + * Run one root action: a `signals` row's custom_data here, or a verified + * `node.root` command handed over by cluster:root (Phase 4). Same code for + * both, so the two transports cannot drift apart. + * + * @param array $rData {action, …} + * @param array> $rServers + */ + public function executeAction(array $rData, array $rServers, object $db): void { + switch ($rData['action'] ?? '') { + case 'reboot': + echo 'Rebooting system...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'REBOOT', 'System rebooted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + $db->close_mysql(); + shell_exec('sudo reboot'); + break; + case OpensslExtra::SIGNAL_ACTION: + // Sent by server:sync-openssl-extra on MAIN. install() keeps the value it + // replaces open for tokens minted just before; php-fpm reads the new one + // on its next request. The MAIN's own value (hmac_keys, image names) never + // changes this way. + $rSet = OpensslExtra::applySignal($rData, !empty($rServers[SERVER_ID]['is_main']), CONFIG_PATH, time()); + if ($rSet === null) { + break; + } + echo 'Setting OPENSSL_EXTRA...' . "\n"; + if ($rSet) { + // This cron runs as root: hand the files to FPM's user directly, + // with no shell in between. + foreach (['openssl_extra', 'openssl_extra.prev'] as $rFile) { + if (is_file(CONFIG_PATH . $rFile)) { + @chown(CONFIG_PATH . $rFile, 'xc_vm'); + @chgrp(CONFIG_PATH . $rFile, 'xc_vm'); + } + } + } + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'OPENSSL_EXTRA', ?, 'root', 'localhost', NULL, ?);", SERVER_ID, $rSet ? 'OPENSSL_EXTRA set to the value sent by MAIN.' : 'Failed to write the OPENSSL_EXTRA sent by MAIN.', time()); + break; + case 'restart_services': + echo 'Restarting services...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RESTART', 'XC_VM services restarted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo systemctl stop xc_vm'); + shell_exec('sudo systemctl start xc_vm'); + break; + case 'stop_services': + echo 'Stopping services...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'STOP', 'XC_VM services stopped on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo systemctl stop xc_vm'); + break; + case 'reload_nginx': + echo 'Reloading nginx...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RELOAD', 'NGINX services reloaded on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + break; + case 'disable_ramdisk': + echo 'Disabling ramdisk...' . "\n"; + $rFstab = file_get_contents('/etc/fstab'); + $rOutput = []; + foreach (explode("\n", $rFstab) as $rLine) { + if (substr($rLine, 0, 31) == 'tmpfs /home/xc_vm/content/streams') { + $rLine = '#' . $rLine; + } + $rOutput[] = $rLine; + } + file_put_contents('/etc/fstab', implode("\n", $rOutput)); + shell_exec('sudo umount -l ' . STREAMS_PATH); + shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); + break; + case 'enable_ramdisk': + echo 'Enabling ramdisk...' . "\n"; + $rFstab = file_get_contents('/etc/fstab'); + $rOutput = []; + foreach (explode("\n", $rFstab) as $rLine) { + if (substr($rLine, 0, 32) == '#tmpfs /home/xc_vm/content/streams') { + $rLine = ltrim($rLine, '#'); + } + $rOutput[] = $rLine; + } + file_put_contents('/etc/fstab', implode("\n", $rOutput)); + shell_exec('sudo mount ' . STREAMS_PATH); + shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); + break; + case 'certbot_generate': + echo 'Generating certbot certificate.' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'CERTBOT', 'Attempting to generate certbot certificate on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php certbot "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); + break; + case 'update_binaries': + echo 'Updating binaries...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'BINARIES', 'Updating XC_VM binaries from XC_VM server...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php binaries 2>&1 &'); + break; + case 'install_module': + echo 'Installing module distributed from MAIN...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Installing module distributed from MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:install "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); + break; + case 'delete_module': + echo 'Deleting module removed on MAIN...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Deleting module removed on MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:delete "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); + break; + case 'update': + echo 'Updating...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', 'Updating XC_VM...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update update 2>&1 &'); + break; + case 'rollback': + $rRbVersion = isset($rData['version']) ? trim((string) $rData['version']) : ''; + if (preg_match('/^\d+\.\d+\.\d+$/', $rRbVersion)) { + echo 'Rolling back to ' . $rRbVersion . '...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', ?, 'root', 'localhost', NULL, ?);", SERVER_ID, 'Rolling back XC_VM to ' . $rRbVersion . '...', time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update rollback ' . escapeshellarg($rRbVersion) . ' 2>&1 &'); + } + break; + case 'set_services': + echo 'Setting PHP Services' . "\n"; + $rServices = intval($rData['count']); + if ($rData['reload']) { + shell_exec('sudo systemctl stop xc_vm'); + } + shell_exec('sudo rm ' . MAIN_HOME . 'bin/php/etc/*.conf'); + $rNewScript = '#! /bin/bash' . "\n"; + $rNewBalance = 'upstream php {' . "\n" . ' least_conn;' . "\n"; + $rTemplate = file_get_contents(MAIN_HOME . 'bin/php/etc/template'); + foreach (range(1, $rServices) as $i) { + $rNewScript .= 'start-stop-daemon --start --quiet --pidfile ' . MAIN_HOME . 'bin/php/sockets/' . $i . '.pid --exec ' . MAIN_HOME . 'bin/php/sbin/php-fpm -- --daemonize --fpm-config ' . MAIN_HOME . 'bin/php/etc/' . $i . '.conf' . "\n"; + $rNewBalance .= ' server unix:' . MAIN_HOME . 'bin/php/sockets/' . $i . '.sock;' . "\n"; + file_put_contents(MAIN_HOME . 'bin/php/etc/' . $i . '.conf', str_replace('#PATH#', MAIN_HOME, str_replace('#ID#', (string) $i, $rTemplate))); + } + file_put_contents(MAIN_HOME . 'bin/daemons.sh', $rNewScript); + file_put_contents(MAIN_HOME . 'bin/nginx/conf/balance.conf', $rNewBalance . '}'); + shell_exec('sudo chown xc_vm:xc_vm ' . MAIN_HOME . 'bin/php/etc/*'); + if ($rData['reload']) { + shell_exec('sudo systemctl start xc_vm'); + } + break; + case 'set_governor': + $rNewGovernor = $rData['data']; + if (!empty($rNewGovernor) && shell_exec('which cpufreq-info')) { + $rGovernors = array_filter(explode(' ', trim(shell_exec('cpufreq-info -g')))); + $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); + if ($rGovernor[2] != $rNewGovernor && in_array($rNewGovernor, $rGovernors)) { + shell_exec("sudo bash -c 'for ((i=0;i<\$(nproc);i++)); do cpufreq-set -c \$i -g " . $rNewGovernor . "; done'"); + sleep(2); + $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); + $db->query('UPDATE `servers` SET `governor` = ? WHERE `id` = ?;', json_encode($rGovernor), SERVER_ID); + } + } + break; + case 'set_sysctl': + $rNewConfig = $rData['data']; + if (!empty($rNewConfig)) { + $rSysCtl = file_get_contents('/etc/sysctl.conf'); + if ($rSysCtl != $rNewConfig) { + shell_exec('sudo modprobe ip_conntrack > /dev/null'); + file_put_contents('/etc/sysctl.conf', $rNewConfig); + shell_exec('sudo sysctl -p > /dev/null'); + $db->query('UPDATE `servers` SET `sysctl` = ? WHERE `id` = ?;', $rNewConfig, SERVER_ID); + } + } + break; + case 'set_port': + echo 'Setting NGINX Port' . "\n"; + if (intval($rData['type']) == 0) { + $rListen = []; + foreach ($rData['ports'] as $rPort) { + if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { + $rListen[] = 'listen ' . intval($rPort) . ';'; + } + } + file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf', implode(' ', $rListen)); + // MAIN: its old HTTP ports, kept for the cluster API alone after a port change. + if (NodeRole::isMain() && class_exists(ClusterEndpoint::class)) { + file_put_contents(MAIN_HOME . 'bin/nginx/conf/cluster_legacy.conf', ClusterEndpoint::nginxConf(ClusterEndpoint::legacyPorts(SettingsManager::getAll()), array_map('intval', $rData['ports']))); + } + file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/live.conf', 'on_play http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_publish http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_play_done http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp;'); + if ($rData['reload']) { + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + } + } elseif (intval($rData['type']) == 1) { + $rListen = []; + foreach ($rData['ports'] as $rPort) { + if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { + $rListen[] = 'listen ' . intval($rPort) . ' ssl;'; + } + } + file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/https.conf', implode(' ', $rListen)); + if ($rData['reload']) { + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + } + } elseif (intval($rData['type']) == 2) { + file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/port.conf', 'listen ' . intval($rData['ports'][0]) . ';'); + if ($rData['reload']) { + shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); + } + } + // no break + default: + break; + } + } + public function shutdown(): void { global $db; if ($this->rSaveIPTables) { diff --git a/src/Core/Cluster/NodeActions.php b/src/Core/Cluster/NodeActions.php index 3a47a626..62946674 100644 --- a/src/Core/Cluster/NodeActions.php +++ b/src/Core/Cluster/NodeActions.php @@ -3,6 +3,7 @@ namespace XcVm\Core\Cluster; use XcVm\Core\Config\OpensslExtra; +use XcVm\Domain\Cluster\ClusterRoute; /** * Node Actions @@ -95,6 +96,13 @@ final class NodeActions { if (!is_string($rAction) || !in_array($rAction, self::ROOT_ACTIONS, true)) { throw new \InvalidArgumentException('Unknown node root action: ' . (is_string($rAction) ? $rAction : 'none')); } + // A node with the COMMANDS flow and its root pin gets a signed node.root (MAIN only). + if (class_exists(ClusterRoute::class)) { + [$rRouted, $rQueued] = ClusterRoute::root($rServerID, $rData); + if ($rRouted) { + return $rQueued; + } + } return SignalDispatcher::rootAction($rServerID, $rPayload, $rDb); } } diff --git a/src/Core/Cluster/RootPin.php b/src/Core/Cluster/RootPin.php new file mode 100644 index 00000000..c3917ef9 --- /dev/null +++ b/src/Core/Cluster/RootPin.php @@ -0,0 +1,143 @@ +.json in (command + sig), .done out + * ``` + * + * The agent's own copy of the panel key lives in files xc_vm can write, so a + * compromised panel process could plant a key; root trusts only the copy in + * /etc, written over SSH at install (or by `cluster:pin-root` with the + * fingerprint read on MAIN). Everything root reads from the inbox is data: + * a command runs only when its signature verifies under the pinned key. + */ +final class RootPin { + public const DIR = '/etc/xc_vm/cluster/'; + + /** Largest command file root reads from the inbox. */ + public const MAX_COMMAND = 65536; + + private static ?string $rDir = null; + + private static ?string $rInbox = null; + + /** Tests: other directories; null restores the defaults. */ + public static function useDirs(?string $rDir, ?string $rInbox): void { + self::$rDir = $rDir; + self::$rInbox = $rInbox; + } + + public static function dir(): string { + return self::$rDir ?? self::DIR; + } + + public static function inbox(): string { + return self::$rInbox ?? ((defined('CONFIG_PATH') ? CONFIG_PATH : '/home/xc_vm/config/') . 'cluster/root-inbox/'); + } + + /** + * The pin, when it is in place and safe to trust: the directory owned by + * root (unless tests moved it) and writable by nobody else. + * + * @return array{pub: string, node: string}|null + */ + public static function read(): ?array { + $rDir = self::dir(); + $rStat = @stat($rDir); + if ($rStat === false || is_link(rtrim($rDir, '/')) || ($rStat['mode'] & 0022) !== 0 || (self::$rDir === null && $rStat['uid'] !== 0)) { + return null; + } + $rPub = @hex2bin(trim((string) @file_get_contents($rDir . 'main_sign.pub'))); + $rNode = trim((string) @file_get_contents($rDir . 'node')); + if ($rPub === false || strlen($rPub) !== 32 || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $rNode)) { + return null; + } + return ['pub' => $rPub, 'node' => $rNode]; + } + + /** Write the pin (root; the install flow does the same over SSH). */ + public static function write(string $rPub, string $rNode): bool { + $rDir = self::dir(); + if (!is_dir($rDir) && !@mkdir($rDir, 0755, true)) { + return false; + } + @chmod($rDir, 0755); + @unlink($rDir . 'root.seq'); // a new pin starts root's seq afresh (a re-enrolled node) + return @file_put_contents($rDir . 'main_sign.pub', bin2hex($rPub) . "\n") !== false + && @file_put_contents($rDir . 'node', $rNode . "\n") !== false + && @chmod($rDir . 'main_sign.pub', 0644) && @chmod($rDir . 'node', 0644); + } + + /** Does the agent's panel key match root's pin? (the agent reports this) */ + public static function matches(string $rAgentPub, string $rNode): bool { + $rPin = self::read(); + return $rPin !== null && hash_equals($rPin['pub'], $rAgentPub) && $rPin['node'] === $rNode; + } + + /** + * Check a command against the pin: panel signature (tag `cmd`), a + * `node.root` for this node, a known action, not stale, and above root's + * own high-water. + * + * @param array{pub: string, node: string} $rPin + * @return array|string the command, or why it is refused + */ + public static function verify(array $rPin, string $rDoc, string $rSig, int $rNow, int $rHighWater): array|string { + if (!PanelSig::verify($rPin['pub'], 'cmd', $rDoc, $rSig)) { + return 'bad signature'; + } + $rCmd = json_decode($rDoc, true); + if (!is_array($rCmd) || ($rCmd['type'] ?? null) !== 'node.root' || ($rCmd['node_uuid'] ?? null) !== $rPin['node']) { + return 'not a root command for this node'; + } + if ((int) ($rCmd['exp'] ?? 0) + 300 < $rNow) { + return 'expired'; + } + if ((int) ($rCmd['seq'] ?? 0) <= $rHighWater) { + return 'seq not above ' . $rHighWater; + } + if (!in_array($rCmd['args']['action'] ?? null, NodeActions::ROOT_ACTIONS, true)) { + return 'unknown root action'; + } + return $rCmd; + } + + public static function highWater(): int { + return (int) trim((string) @file_get_contents(self::dir() . 'root.seq')); + } + + public static function raiseHighWater(int $rSeq): bool { + return @file_put_contents(self::dir() . 'root.seq', (string) $rSeq, LOCK_EX) !== false; + } + + /** + * Write a result where xc_vm can read it, without following anything + * planted at that path: whatever is there is removed, and the file is + * created exclusively. + */ + public static function writeDone(string $rPath, string $rJson): bool { + if (is_link($rPath) || file_exists($rPath)) { + @unlink($rPath); + } + $rHandle = @fopen($rPath, 'x'); + if ($rHandle === false) { + return false; + } + fwrite($rHandle, $rJson); + fclose($rHandle); + @chmod($rPath, 0644); + return true; + } +} diff --git a/src/Core/Localization/lang/ar.ini b/src/Core/Localization/lang/ar.ini index 0757a51a..04a6703b 100644 --- a/src/Core/Localization/lang/ar.ini +++ b/src/Core/Localization/lang/ar.ini @@ -1886,6 +1886,11 @@ cluster_commands_flow = "الأوامر" cluster_commands_help = "تدفق الأوامر: عند تفعيله يرسل MAIN إلى هذه العقدة أوامر موقّعة (استدعاءات مثل تشغيل/إيقاف البث والفحص والمسح وقطع المشاهدين) عبر واجهة العنقود بدلًا من /api وجدول الإشارات." cluster_commands_on_done = "تم تفعيل تدفق الأوامر: يرسل MAIN الآن أوامر موقّعة إلى هذه العقدة." cluster_commands_off_done = "تم تعطيل تدفق الأوامر: يعود MAIN إلى استخدام /api وإشارات العقدة." +cluster_panel_fp = "بصمة اللوحة" +cluster_panel_fp_help = "SHA-256 لمفتاح توقيع لوحة MAIN. على عقدة مسجلة برمز، يثبّته root بالأمر: console.php cluster:pin-root <البصمة>" +cluster_root_pin = "تثبيت root" +cluster_root_ready_help = "أوامر root: مفتاح اللوحة الذي ثبّته root على العقدة يطابق مفتاح الوكيل؛ تُرسل إعادة التشغيل والتحديث وإجراءات root الأخرى كأوامر موقّعة." +cluster_root_missing_help = "لا يوجد تثبيت root على هذه العقدة: تبقى إجراءات root في جدول الإشارات. يُثبّت عند التثبيت عبر SSH، أو على العقدة بالأمر: console.php cluster:pin-root <بصمة اللوحة>" restreamer_bypass_proxy = "وكيل تجاوز Restreamer" channel_sorting_type = "نوع فرز القناة" sort_vod_by_date = "فرز VOD حسب التاريخ" diff --git a/src/Core/Localization/lang/bg.ini b/src/Core/Localization/lang/bg.ini index aea8867a..f08fca0c 100644 --- a/src/Core/Localization/lang/bg.ini +++ b/src/Core/Localization/lang/bg.ini @@ -1608,6 +1608,11 @@ cluster_commands_flow = "Команди" cluster_commands_help = "Поток на командите: при включване MAIN изпраща на възела подписани команди (RPC като старт/стоп на потоци, проби и сканиране, и прекъсване на зрители) през API на клъстера вместо през неговия /api и таблицата със сигнали." cluster_commands_on_done = "Потокът на командите е включен: MAIN вече изпраща подписани команди до възела." cluster_commands_off_done = "Потокът на командите е изключен: MAIN отново използва /api и сигналите на възела." +cluster_panel_fp = "Отпечатък на панела" +cluster_panel_fp_help = "SHA-256 на ключа за подписване на панела на MAIN. На възел, регистриран с код, root го закрепва с: console.php cluster:pin-root <отпечатък>" +cluster_root_pin = "Root закрепване" +cluster_root_ready_help = "Root команди: закрепеният от root ключ на панела на възела съвпада с този на агента; reboot, update и други root действия минават като подписани команди." +cluster_root_missing_help = "Няма root закрепване на този възел: root действията остават в таблицата със сигнали. Закрепва се при SSH инсталацията или на възела с: console.php cluster:pin-root <отпечатък на панела>" fanout_debug = "Дебъг дневник Fanout" fanout_debug_off = "Изкл." fanout_debug_all = "Всички категории" diff --git a/src/Core/Localization/lang/de.ini b/src/Core/Localization/lang/de.ini index 499dd090..db87f0a4 100644 --- a/src/Core/Localization/lang/de.ini +++ b/src/Core/Localization/lang/de.ini @@ -1607,6 +1607,11 @@ cluster_commands_flow = "Befehle" cluster_commands_help = "Befehls-Flow: an, sendet MAIN diesem Knoten signierte Befehle (RPCs wie Stream-Start/Stopp, Probes und Scans sowie Zuschauer-Kills) über die Cluster-API statt über seine /api und die Signaltabelle." cluster_commands_on_done = "Befehls-Flow an: MAIN sendet diesem Knoten jetzt signierte Befehle." cluster_commands_off_done = "Befehls-Flow aus: MAIN nutzt wieder die /api und die Signale des Knotens." +cluster_panel_fp = "Panel-Fingerabdruck" +cluster_panel_fp_help = "SHA-256 des Panel-Signaturschlüssels von MAIN. Auf einem per Code angemeldeten Knoten pinnt root ihn mit: console.php cluster:pin-root " +cluster_root_pin = "Root-Pin" +cluster_root_ready_help = "Root-Befehle: Der von root gepinnte Panel-Schlüssel des Knotens stimmt mit dem des Agenten überein; Reboot, Update und andere Root-Aktionen laufen als signierte Befehle." +cluster_root_missing_help = "Kein Root-Pin auf diesem Knoten: Root-Aktionen bleiben bei der Signaltabelle. Gepinnt bei der SSH-Installation oder auf dem Knoten: console.php cluster:pin-root " fanout_debug = "Fanout-Debug-Protokoll" fanout_debug_off = "Aus" fanout_debug_all = "Alle Kategorien" diff --git a/src/Core/Localization/lang/en.ini b/src/Core/Localization/lang/en.ini index 09221c67..ab74b411 100644 --- a/src/Core/Localization/lang/en.ini +++ b/src/Core/Localization/lang/en.ini @@ -1897,6 +1897,11 @@ cluster_commands_flow = "Commands" cluster_commands_help = "Commands flow: when on, MAIN sends this node signed commands (RPCs such as stream start/stop, probes and scans, and viewer kills) over the cluster API instead of its /api and the signals table." cluster_commands_on_done = "Commands flow on: MAIN now sends this node signed commands." cluster_commands_off_done = "Commands flow off: MAIN uses the node's legacy /api and signals again." +cluster_panel_fp = "Panel fingerprint" +cluster_panel_fp_help = "SHA-256 of MAIN's panel signing key. On a node enrolled by code, root pins it with: console.php cluster:pin-root " +cluster_root_pin = "Root pin" +cluster_root_ready_help = "Root commands: the node's root-owned pin of the panel key matches its agent; reboot, update and other root actions go as signed commands." +cluster_root_missing_help = "No root pin on this node: root actions keep the signals table. Pinned at SSH install, or on the node: console.php cluster:pin-root " fanout_debug = "Fanout Debug Log" fanout_debug_off = "Off" fanout_debug_all = "All categories" diff --git a/src/Core/Localization/lang/es.ini b/src/Core/Localization/lang/es.ini index f770f6c5..79c94e9c 100644 --- a/src/Core/Localization/lang/es.ini +++ b/src/Core/Localization/lang/es.ini @@ -1614,6 +1614,11 @@ cluster_commands_flow = "Comandos" cluster_commands_help = "Flujo de comandos: activado, MAIN envía a este nodo comandos firmados (RPC como iniciar/detener streams, sondeos y escaneos, y expulsión de espectadores) por la API del clúster, en lugar de su /api y la tabla de señales." cluster_commands_on_done = "Flujo de comandos activado: MAIN envía ahora comandos firmados a este nodo." cluster_commands_off_done = "Flujo de comandos desactivado: MAIN vuelve a usar la /api y las señales del nodo." +cluster_panel_fp = "Huella del panel" +cluster_panel_fp_help = "SHA-256 de la clave de firma del panel de MAIN. En un nodo inscrito con código, root la fija con: console.php cluster:pin-root " +cluster_root_pin = "Fijación root" +cluster_root_ready_help = "Comandos root: la clave del panel fijada por root en el nodo coincide con la del agente; reboot, update y otras acciones root van como comandos firmados." +cluster_root_missing_help = "Sin fijación root en este nodo: las acciones root siguen en la tabla de señales. Se fija en la instalación por SSH, o en el nodo con: console.php cluster:pin-root " fanout_debug = "Registro de depuración Fanout" fanout_debug_off = "Desactivado" fanout_debug_all = "Todas las categorías" diff --git a/src/Core/Localization/lang/fr.ini b/src/Core/Localization/lang/fr.ini index 116ba2a9..e1f44495 100644 --- a/src/Core/Localization/lang/fr.ini +++ b/src/Core/Localization/lang/fr.ini @@ -1607,6 +1607,11 @@ cluster_commands_flow = "Commandes" cluster_commands_help = "Flux de commandes : activé, MAIN envoie à ce nœud des commandes signées (RPC comme démarrer/arrêter un flux, sondes et scans, et déconnexion de spectateurs) par l'API du cluster, au lieu de sa /api et de la table des signaux." cluster_commands_on_done = "Flux de commandes activé : MAIN envoie désormais des commandes signées à ce nœud." cluster_commands_off_done = "Flux de commandes désactivé : MAIN utilise de nouveau la /api et les signaux du nœud." +cluster_panel_fp = "Empreinte du panel" +cluster_panel_fp_help = "SHA-256 de la clé de signature du panel de MAIN. Sur un nœud inscrit par code, root l'épingle avec : console.php cluster:pin-root " +cluster_root_pin = "Épinglage root" +cluster_root_ready_help = "Commandes root : la clé du panel épinglée par root sur le nœud correspond à celle de l'agent ; reboot, update et autres actions root passent en commandes signées." +cluster_root_missing_help = "Pas d'épinglage root sur ce nœud : les actions root restent dans la table des signaux. Épinglé à l'installation SSH, ou sur le nœud : console.php cluster:pin-root " fanout_debug = "Journal de débogage Fanout" fanout_debug_off = "Désactivé" fanout_debug_all = "Toutes les catégories" diff --git a/src/Core/Localization/lang/pt.ini b/src/Core/Localization/lang/pt.ini index 6936bf14..f31b74ae 100644 --- a/src/Core/Localization/lang/pt.ini +++ b/src/Core/Localization/lang/pt.ini @@ -1607,6 +1607,11 @@ cluster_commands_flow = "Comandos" cluster_commands_help = "Fluxo de comandos: ligado, o MAIN envia a este nó comandos assinados (RPCs como iniciar/parar streams, probes e scans, e kills de espetadores) pela API do cluster, em vez da /api e da tabela de sinais." cluster_commands_on_done = "Fluxo de comandos ligado: o MAIN passa a enviar comandos assinados a este nó." cluster_commands_off_done = "Fluxo de comandos desligado: o MAIN volta a usar a /api e os sinais do nó." +cluster_panel_fp = "Impressão digital do painel" +cluster_panel_fp_help = "SHA-256 da chave de assinatura do painel do MAIN. Num nó inscrito por código, o root fixa-a com: console.php cluster:pin-root " +cluster_root_pin = "Fixação root" +cluster_root_ready_help = "Comandos root: a chave do painel fixada pelo root no nó corresponde à do agente; reboot, update e outras ações root vão como comandos assinados." +cluster_root_missing_help = "Sem fixação root neste nó: as ações root continuam na tabela de sinais. É fixada na instalação por SSH, ou no nó com: console.php cluster:pin-root " fanout_debug = "Registo de depuração Fanout" fanout_debug_off = "Desligado" fanout_debug_all = "Todas as categorias" diff --git a/src/Core/Localization/lang/ru.ini b/src/Core/Localization/lang/ru.ini index 09382af2..122659c1 100644 --- a/src/Core/Localization/lang/ru.ini +++ b/src/Core/Localization/lang/ru.ini @@ -1607,6 +1607,11 @@ cluster_commands_flow = "Команды" cluster_commands_help = "Поток команд: при включении MAIN отправляет узлу подписанные команды (RPC: запуск/остановка потоков, пробы и сканирование, отключение зрителей) через API кластера вместо его /api и таблицы сигналов." cluster_commands_on_done = "Поток команд включён: MAIN теперь отправляет узлу подписанные команды." cluster_commands_off_done = "Поток команд выключен: MAIN снова использует /api и сигналы узла." +cluster_panel_fp = "Отпечаток панели" +cluster_panel_fp_help = "SHA-256 ключа подписи панели MAIN. На узле, зарегистрированном по коду, root закрепляет его командой: console.php cluster:pin-root <отпечаток>" +cluster_root_pin = "Закрепление root" +cluster_root_ready_help = "Команды root: закреплённый root ключ панели на узле совпадает с ключом агента; reboot, update и другие действия root идут подписанными командами." +cluster_root_missing_help = "На этом узле нет закрепления root: действия root остаются в таблице сигналов. Закрепляется при установке по SSH или на узле: console.php cluster:pin-root <отпечаток панели>" fanout_debug = "Отладочный лог Fanout" fanout_debug_off = "Выкл." fanout_debug_all = "Все категории" diff --git a/src/Domain/Cluster/ClusterAdmin.php b/src/Domain/Cluster/ClusterAdmin.php index 3264e32d..22c43e55 100644 --- a/src/Domain/Cluster/ClusterAdmin.php +++ b/src/Domain/Cluster/ClusterAdmin.php @@ -22,7 +22,7 @@ final class ClusterAdmin { public static function nodes(array $rServers, int $rOfflineAfterSec): array { $rReady = ClusterMeta::readyAtMs(); // its own query: before ours, not between query() and get_rows() $rNow = ClusterClock::nowMs(); - self::db()->query('SELECT `server_id`, `node_uuid`, `state`, `mode`, `flows`, `gen`, `epoch`, `token_exp`, `last_seen_at`, `agent_version`, `quarantine_reason` FROM `cluster_nodes` ORDER BY `server_id`;'); + self::db()->query('SELECT `server_id`, `node_uuid`, `state`, `mode`, `flows`, `root_ready`, `gen`, `epoch`, `token_exp`, `last_seen_at`, `agent_version`, `quarantine_reason` FROM `cluster_nodes` ORDER BY `server_id`;'); $rOut = []; foreach (self::db()->get_rows() as $rRow) { $rLastSeen = $rRow['last_seen_at'] === null ? null : (int) $rRow['last_seen_at']; diff --git a/src/Domain/Cluster/ClusterRoute.php b/src/Domain/Cluster/ClusterRoute.php index b30c7a84..5b1e2c35 100644 --- a/src/Domain/Cluster/ClusterRoute.php +++ b/src/Domain/Cluster/ClusterRoute.php @@ -77,15 +77,37 @@ final class ClusterRoute { } } + /** + * A root action (NodeActions): a signed `node.root` command, run on the + * node by cluster:root after it checks the signature against its + * root-owned pin of the panel key. Only for nodes that report that pin. + * + * @param array $rPayload {action, …} as the signals row carried it + * @return array{0: bool, 1: bool} + */ + public static function root(int $rServerID, array $rPayload): array { + $rCrypto = self::target($rServerID, true); + if ($rCrypto === null) { + return [false, false]; + } + try { + CommandBus::enqueue($rCrypto, $rServerID, 'node.root', $rPayload); + return [true, true]; + } catch (\Throwable) { + return [true, false]; + } + } + /** Tests: supply the extension handle. Null restores the factory. */ public static function useCrypto(?callable $rFactory): void { self::$rCrypto = $rFactory; } /** The extension, when this node takes commands; null for the legacy path. */ - private static function target(int $rServerID): ?ClusterCrypto { + private static function target(int $rServerID, bool $rRoot = false): ?ClusterCrypto { try { - if (empty(SettingsManager::get('cluster_api_enabled')) || !CommandBus::accepts(NodeRegistry::byServer($rServerID))) { + $rNode = empty(SettingsManager::get('cluster_api_enabled')) ? null : NodeRegistry::byServer($rServerID); + if (!($rRoot ? CommandBus::acceptsRoot($rNode) : CommandBus::accepts($rNode))) { return null; } return self::$rCrypto !== null ? (self::$rCrypto)() : ClusterCryptoFactory::create(); diff --git a/src/Domain/Cluster/CommandBus.php b/src/Domain/Cluster/CommandBus.php index 7ac0e49e..f2a721f4 100644 --- a/src/Domain/Cluster/CommandBus.php +++ b/src/Domain/Cluster/CommandBus.php @@ -31,7 +31,7 @@ final class CommandBus { public const TTL = ['conn.' => 300, 'node.root' => 86400, 'default' => 600]; /** Types this increment carries. */ - public const TYPES = ['node.rpc', 'conn.kill_worker']; + public const TYPES = ['node.rpc', 'node.root', 'conn.kill_worker']; /** Types that are restrictive (always signable); the extension decides, this is informational. */ public const RESTRICTIVE = ['conn.drop', 'conn.drop_line', 'conn.kill_worker', 'stream.stop', 'vod.stop', 'token.rotate_now', 'node.quarantine', 'node.fence', 'resync', 'config.changed']; @@ -48,6 +48,16 @@ final class CommandBus { return $rNode !== null && $rNode['state'] === 'active' && (int) $rNode['mode'] >= 1 && ((int) $rNode['flows'] & NodeRegistry::FLOW_COMMANDS) !== 0; } + /** + * Does the node take root commands too? Only once its root-owned pin of + * the panel key is in place (cluster:root refuses without it). + * + * @param array|null $rNode + */ + public static function acceptsRoot(?array $rNode): bool { + return self::accepts($rNode) && !empty($rNode['root_ready']); + } + /** * Sign and queue a command. Returns its cmd_id. * diff --git a/src/Domain/Cluster/HeartbeatService.php b/src/Domain/Cluster/HeartbeatService.php index 7c66faf5..86d0cf1e 100644 --- a/src/Domain/Cluster/HeartbeatService.php +++ b/src/Domain/Cluster/HeartbeatService.php @@ -41,10 +41,15 @@ final class HeartbeatService { */ public static function record(array $rNode, array $rPayload, int $rNodeTsMs): void { $rNow = ClusterClock::nowMs(); - NodeRegistry::update((int) $rNode['server_id'], [ + $rFields = [ 'last_seen_at' => $rNow, 'clock_offset_ms' => max(-2147483648, min(2147483647, $rNodeTsMs - $rNow)), - ]); + ]; + // Whether the node's root-owned panel-key pin is in place (root commands). + if (array_key_exists('root_ready', $rPayload)) { + $rFields['root_ready'] = empty($rPayload['root_ready']) ? 0 : 1; + } + NodeRegistry::update((int) $rNode['server_id'], $rFields); $rTelemetry = $rPayload['telemetry'] ?? null; if (is_array($rTelemetry) && defined('TMP_PATH')) { $rJson = (string) json_encode(['at' => $rNow, 'telemetry' => $rTelemetry], JSON_UNESCAPED_SLASHES); diff --git a/src/Public/Controllers/Admin/ClusterNodesController.php b/src/Public/Controllers/Admin/ClusterNodesController.php index 43441719..6cabc912 100644 --- a/src/Public/Controllers/Admin/ClusterNodesController.php +++ b/src/Public/Controllers/Admin/ClusterNodesController.php @@ -5,6 +5,7 @@ namespace XcVm\Public\Controllers\Admin; use XcVm\Core\Cluster\Crypto\ClusterCryptoFactory; use XcVm\Core\Config\SettingsManager; use XcVm\Domain\Cluster\ClusterAdmin; +use XcVm\Domain\Cluster\ClusterMeta; use XcVm\Domain\Server\ServerRepository; /** @@ -50,6 +51,7 @@ class ClusterNodesController extends BaseAdminController { 'clusterPending' => ClusterAdmin::pending($rServers), 'clusterLbs' => ClusterAdmin::loadBalancers($rServers), 'clusterFlash' => $rFlash, + 'clusterPanelFp' => $rEnabled && $rAvailable ? (string) (ClusterMeta::get('panel_fp') ?? '') : '', ]); } } diff --git a/src/Public/Views/admin/cluster_nodes.php b/src/Public/Views/admin/cluster_nodes.php index 590c404c..b3698092 100644 --- a/src/Public/Views/admin/cluster_nodes.php +++ b/src/Public/Views/admin/cluster_nodes.php @@ -22,6 +22,9 @@ $rWhen = static fn(?int $rTs): string => $rTs ? gmdate('Y-m-d H:i:s', $rTs) . '

+ + : +
@@ -84,6 +87,7 @@ $rWhen = static fn(?int $rTs): string => $rTs ? gmdate('Y-m-d H:i:s', $rTs) . ' + @@ -93,7 +97,7 @@ $rWhen = static fn(?int $rTs): string => $rTs ? gmdate('Y-m-d H:i:s', $rTs) . ' - + @@ -121,6 +125,13 @@ $rWhen = static fn(?int $rTs): string => $rTs ? gmdate('Y-m-d H:i:s', $rTs) . ' + + + root + + — + + (gen ) diff --git a/src/bin/install/database.sql b/src/bin/install/database.sql index 8f8271e0..206f08c3 100644 --- a/src/bin/install/database.sql +++ b/src/bin/install/database.sql @@ -150,6 +150,7 @@ CREATE TABLE IF NOT EXISTS `cluster_nodes` ( `state` varchar(16) COLLATE utf8_unicode_ci NOT NULL DEFAULT 'enrolling', `mode` tinyint(3) unsigned NOT NULL DEFAULT '0', `flows` int(10) unsigned NOT NULL DEFAULT '0', + `root_ready` tinyint(1) NOT NULL DEFAULT '0', `gen` int(10) unsigned NOT NULL DEFAULT '1', `node_sign_pub` binary(32) DEFAULT NULL, `node_box_pub` binary(32) DEFAULT NULL, diff --git a/src/migrations/database/down/039_add_cluster_node_root_ready.sql b/src/migrations/database/down/039_add_cluster_node_root_ready.sql new file mode 100644 index 00000000..46688117 --- /dev/null +++ b/src/migrations/database/down/039_add_cluster_node_root_ready.sql @@ -0,0 +1,2 @@ +-- Reverse 039_add_cluster_node_root_ready.sql. +ALTER TABLE `cluster_nodes` DROP COLUMN IF EXISTS `root_ready`; diff --git a/src/migrations/database/up/039_add_cluster_node_root_ready.sql b/src/migrations/database/up/039_add_cluster_node_root_ready.sql new file mode 100644 index 00000000..ce7001e8 --- /dev/null +++ b/src/migrations/database/up/039_add_cluster_node_root_ready.sql @@ -0,0 +1,4 @@ +-- Whether a node can take root commands: its root-owned panel-key pin +-- (/etc/xc_vm/cluster) matches the key its agent holds. Reported in every +-- heartbeat; until it is, root actions keep the signals table. +ALTER TABLE `cluster_nodes` ADD COLUMN IF NOT EXISTS `root_ready` tinyint(1) NOT NULL DEFAULT '0' AFTER `flows`; diff --git a/tests/Unit/ClusterApiTest.php b/tests/Unit/ClusterApiTest.php index f7236503..5d902a88 100644 --- a/tests/Unit/ClusterApiTest.php +++ b/tests/Unit/ClusterApiTest.php @@ -57,6 +57,7 @@ final class ClusterApiTest extends TestCase { $this->rDb->exec($this->ddl((string) file_get_contents(dirname(__DIR__, 2) . '/src/migrations/database/up/' . $rName . '.sql'))); } $this->rDb->exec('ALTER TABLE `cluster_node_epochs` ADD COLUMN `agent_eph_pub` binary(32) DEFAULT NULL'); + $this->rDb->exec('ALTER TABLE `cluster_nodes` ADD COLUMN `root_ready` tinyint(1) NOT NULL DEFAULT 0'); $this->rDb->exec('CREATE TABLE `servers` (`id` INTEGER PRIMARY KEY, `status` int NOT NULL DEFAULT 0)'); $this->rDb->exec('INSERT INTO `servers` (`id`, `status`) VALUES (5, 0)'); DatabaseFactory::set($this->rDb); @@ -744,4 +745,24 @@ final class ClusterApiTest extends TestCase { \XcVm\Domain\Cluster\ClusterRoute::useCrypto(null); } } + + public function testRootCommandsNeedTheNodesRootPin(): void { + $rKeys = $this->active(); + SettingsManager::set($this->rSettings); + NodeRegistry::update(self::SID, ['flows' => NodeRegistry::FLOW_COMMANDS]); + \XcVm\Domain\Cluster\ClusterRoute::useCrypto(fn() => $this->rCrypto); + try { + $this->assertSame([false, false], \XcVm\Domain\Cluster\ClusterRoute::root(self::SID, ['action' => 'reload_nginx']), 'no pin reported: the signals table'); + // The agent reports its root pin in a heartbeat. + [$rRes, $rCtx] = $this->call('heartbeat', ['root_ready' => true], 1, $rKeys); + $this->reply($rRes, $rCtx, $rKeys); + $this->assertSame(1, (int) NodeRegistry::byServer(self::SID)['root_ready']); + $this->assertSame([true, true], \XcVm\Domain\Cluster\ClusterRoute::root(self::SID, ['action' => 'reload_nginx'])); + $rDoc = json_decode(\XcVm\Domain\Cluster\CommandBus::pending(self::SID, 0)[0]['doc'], true); + $this->assertSame(['node.root', ['action' => 'reload_nginx']], [$rDoc['type'], $rDoc['args']]); + $this->assertSame(86400, $rDoc['exp'] - $rDoc['iat'], 'root commands live a day'); + } finally { + \XcVm\Domain\Cluster\ClusterRoute::useCrypto(null); + } + } } diff --git a/tests/Unit/ClusterEnrolCodeTest.php b/tests/Unit/ClusterEnrolCodeTest.php index 0ea09930..69f60ec4 100644 --- a/tests/Unit/ClusterEnrolCodeTest.php +++ b/tests/Unit/ClusterEnrolCodeTest.php @@ -44,6 +44,7 @@ final class ClusterEnrolCodeTest extends TestCase { } $this->rDb->exec('ALTER TABLE `cluster_node_epochs` ADD COLUMN `agent_eph_pub` binary(32) DEFAULT NULL'); $this->rDb->exec('ALTER TABLE `cluster_enrol_requests` ADD COLUMN `agent_eph_pub` binary(32) DEFAULT NULL'); + $this->rDb->exec('ALTER TABLE `cluster_nodes` ADD COLUMN `root_ready` tinyint(1) NOT NULL DEFAULT 0'); DatabaseFactory::set($this->rDb); SettingsManager::set($this->rSettings); $this->rCrypto = new FakeClusterCrypto(); diff --git a/tests/Unit/ClusterRootCommandTest.php b/tests/Unit/ClusterRootCommandTest.php new file mode 100644 index 00000000..43f36310 --- /dev/null +++ b/tests/Unit/ClusterRootCommandTest.php @@ -0,0 +1,114 @@ +rBase = sys_get_temp_dir() . '/rootpin_' . bin2hex(random_bytes(4)) . '/'; + mkdir($this->rBase . 'etc', 0755, true); + mkdir($this->rBase . 'inbox', 0700, true); + RootPin::useDirs($this->rBase . 'etc/', $this->rBase . 'inbox/'); + $this->rCrypto = new FakeClusterCrypto(); + $this->assertTrue(RootPin::write($this->rCrypto->info()['panel_sign_pub'], self::NODE)); + } + + protected function tearDown(): void { + RootPin::useDirs(null, null); + exec('rm -rf ' . escapeshellarg($this->rBase)); + } + + private function command(int $rSeq, array $rOver = [], string $rTag = 'cmd'): array { + $rDoc = (string) json_encode($rOver + ['v' => 1, 'type' => 'node.root', 'exp' => 1800000600, 'iat' => 1800000000, 'cmd_id' => bin2hex(random_bytes(16)), 'seq' => $rSeq, 'node_uuid' => self::NODE, 'gen' => 1, 'dedupe_key' => null, 'args' => ['action' => 'reload_nginx']]); + return ['doc' => $rDoc, 'sig' => Enc::b64url($this->rCrypto->sign($rTag, $rDoc))]; + } + + private function inbox(int $rSeq, array $rCmd): void { + file_put_contents($this->rBase . 'inbox/' . $rSeq . '.json', json_encode($rCmd)); + } + + public function testThePinIsReadOnlyFromASafeDirectory(): void { + $rPin = RootPin::read(); + $this->assertSame([$this->rCrypto->info()['panel_sign_pub'], self::NODE], [$rPin['pub'], $rPin['node']]); + $this->assertTrue(RootPin::matches($this->rCrypto->info()['panel_sign_pub'], self::NODE)); + $this->assertFalse(RootPin::matches(str_repeat("\1", 32), self::NODE)); + chmod($this->rBase . 'etc', 0775); + $this->assertNull(RootPin::read(), 'a group-writable directory is not trusted'); + } + + public function testVerification(): void { + $rPin = RootPin::read(); + $rOk = $this->command(3); + $this->assertIsArray(RootPin::verify($rPin, $rOk['doc'], (string) Enc::b64urlDecode($rOk['sig']), 1800000000, 2)); + $rCases = [ + 'bad signature' => [$this->command(3, [], 'den'), 2], + 'another node' => [$this->command(3, ['node_uuid' => '11111111-1111-4111-a111-111111111111']), 2], + 'not root' => [$this->command(3, ['type' => 'node.rpc']), 2], + 'stale' => [$this->command(3, ['exp' => 1799999000]), 2], + 'replay' => [$this->command(3), 3], + 'unknown action' => [$this->command(3, ['args' => ['action' => 'rm_rf']]), 2], + ]; + foreach ($rCases as $rName => [$rCmd, $rHigh]) { + $this->assertIsString(RootPin::verify($rPin, $rCmd['doc'], (string) Enc::b64urlDecode($rCmd['sig']), 1800000000, $rHigh), $rName); + } + } + + public function testDrainRunsEachCommandOnce(): void { + $rRan = []; + $rRun = static function (array $rAction) use (&$rRan): string { + $rRan[] = $rAction['action']; + return "Reloading nginx...\n"; + }; + $this->inbox(1, $this->command(1)); + $this->inbox(2, $this->command(2, ['args' => ['action' => 'update']])); + $rDone = ClusterRootCommand::drain($rRun, 1800000000); + $this->assertSame(['reload_nginx', 'update'], $rRan); + $this->assertSame(2, RootPin::highWater()); + $this->assertSame(['ok' => true, 'result' => 'Reloading nginx...'], json_decode((string) file_get_contents($this->rBase . 'inbox/1.done'), true)); + $this->assertCount(2, $rDone); + $this->assertSame([], glob($this->rBase . 'inbox/*.json'), 'the inbox is emptied'); + + // The same command again (a replay into the inbox): refused, not run. + $this->inbox(2, $this->command(2)); + ClusterRootCommand::drain($rRun, 1800000000); + $this->assertCount(2, $rRan); + $this->assertStringContainsString('refused by root', (string) file_get_contents($this->rBase . 'inbox/2.done')); + } + + public function testAPlantedSymlinkIsNotFollowed(): void { + $rTarget = $this->rBase . 'precious'; + file_put_contents($rTarget, 'keep me'); + symlink($rTarget, $this->rBase . 'inbox/5.done'); + $this->inbox(5, $this->command(5)); + ClusterRootCommand::drain(static fn() => 'x', 1800000000); + $this->assertSame('keep me', file_get_contents($rTarget)); + $this->assertFalse(is_link($this->rBase . 'inbox/5.done')); + } + + public function testExecHandsRootCommandsOver(): void { + ob_start(); + $rCode = ClusterExecCommand::handToRoot($this->command(7), 7, 0); + $rOut = ob_get_clean(); + $this->assertSame(0, $rCode); + $this->assertSame('{"queued":true}', $rOut, 'no root result yet: acked as queued'); + $this->assertFileExists($this->rBase . 'inbox/7.json'); + // Root picks it up and answers. + ClusterRootCommand::drain(static fn() => 'done', 1800000000); + $this->assertSame(7, RootPin::highWater()); + } +} diff --git a/tests/Unit/ClusterSchemaTest.php b/tests/Unit/ClusterSchemaTest.php index 85c27187..bea9d6f0 100644 --- a/tests/Unit/ClusterSchemaTest.php +++ b/tests/Unit/ClusterSchemaTest.php @@ -3,13 +3,13 @@ use PHPUnit\Framework\TestCase; /** - * The cluster schema exists twice: as migrations 028–038 for upgrades and in + * The cluster schema exists twice: as migrations 028–039 for upgrades and in * database.sql for fresh installs. Both were loaded into MariaDB 10.11 and * compared column by column when written; this test keeps them from drifting * where CI has no database. */ final class ClusterSchemaTest extends TestCase { - private const MIGRATIONS = ['028_add_cluster_settings', '029_create_cluster_nodes', '030_create_cluster_commands', '031_create_cluster_enrolment', '032_create_cluster_audit', '033_add_crontab_role', '034_create_cluster_changes', '035_add_cluster_epoch_eph', '036_add_cluster_enrol_request_eph', '037_enable_cluster_cron', '038_add_cluster_endpoint_settings']; + private const MIGRATIONS = ['028_add_cluster_settings', '029_create_cluster_nodes', '030_create_cluster_commands', '031_create_cluster_enrolment', '032_create_cluster_audit', '033_add_crontab_role', '034_create_cluster_changes', '035_add_cluster_epoch_eph', '036_add_cluster_enrol_request_eph', '037_enable_cluster_cron', '038_add_cluster_endpoint_settings', '039_add_cluster_node_root_ready']; private function src(string $rPath): string { return (string) file_get_contents(dirname(__DIR__, 2) . '/src/' . $rPath); diff --git a/tests/Unit/LbProvisionClusterTest.php b/tests/Unit/LbProvisionClusterTest.php index 22d319c2..a4ae304b 100644 --- a/tests/Unit/LbProvisionClusterTest.php +++ b/tests/Unit/LbProvisionClusterTest.php @@ -148,6 +148,14 @@ final class LbProvisionClusterTest extends TestCase { $this->assertLessThan($rInstallAt, $rProbeAt); $this->assertStringContainsString('bin/xc_agent/run.sh', end($this->rCommands)); $this->assertStringContainsString(bin2hex($this->rCrypto->info()['panel_sign_pub']), $this->rCommands[$rProbeAt]); + // Root's own pin of the panel key, root-owned, before the agent starts. + $rPinAt = array_key_first(array_filter($this->rCommands, static fn($c) => str_contains($c, 'main_sign.pub'))); + $this->assertNotNull($rPinAt, 'the root pin is written'); + $this->assertStringContainsString("echo '" . bin2hex($this->rCrypto->info()['panel_sign_pub']) . "' | sudo tee '/etc/xc_vm/cluster/main_sign.pub'", $this->rCommands[$rPinAt]); + $this->assertStringContainsString('sudo chown root:root', $this->rCommands[$rPinAt]); + $this->assertStringContainsString("sudo rm -f '/etc/xc_vm/cluster/root.seq'", $this->rCommands[$rPinAt]); + $rRunAt = array_key_last(array_filter($this->rCommands, static fn($c) => str_contains($c, 'bash') && str_contains($c, 'run.sh'))); + $this->assertLessThan($rRunAt, $rPinAt, 'pinned before the agent runs'); } public function testUnreachableMainStopsBeforeAnyToken(): void {