From 1da36c18dfd8c5a60d5d92bf37323d4b60cfcdb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:32:16 +0000 Subject: [PATCH] feat(diagnose): report an OPENSSL_EXTRA mismatch between main and LB OPENSSL_EXTRA keys the stream tokens MAIN mints for the redirects an LB serves, and hmac_keys. It is config/openssl_extra, or the built-in default when that file is absent. The current installer writes a random value on a fresh MAIN, but nothing ships it to an LB, so an LB added with server:install runs on the default and rejects every token MAIN mints for it. Nothing showed which clusters are affected. Each node's cron:servers now publishes a keyed 64-bit fingerprint of its value (never the value) as server_hardware.openssl_extra_fp, in the UPDATE it already runs every minute. server:diagnose compares the node's fingerprint with the main's, from the main and on the node itself, and reports "OPENSSL_EXTRA mismatch: tokens minted on MAIN are rejected by this node". A node or main that does not publish one reads as "unknown (... not updated)"; proxies are skipped. No behaviour changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a --- src/Cli/Commands/ServerDiagnoseCommand.php | 30 ++++++++- src/Cli/CronJobs/ServersCronJob.php | 3 + src/Core/Config/OpensslExtra.php | 46 +++++++++++++ tests/Unit/OpensslExtraTest.php | 39 +++++++++++ tests/Unit/ServerDiagnoseOpensslExtraTest.php | 64 +++++++++++++++++++ 5 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/Core/Config/OpensslExtra.php create mode 100644 tests/Unit/OpensslExtraTest.php create mode 100644 tests/Unit/ServerDiagnoseOpensslExtraTest.php diff --git a/src/Cli/Commands/ServerDiagnoseCommand.php b/src/Cli/Commands/ServerDiagnoseCommand.php index 09e49332..beba67b3 100644 --- a/src/Cli/Commands/ServerDiagnoseCommand.php +++ b/src/Cli/Commands/ServerDiagnoseCommand.php @@ -4,6 +4,7 @@ namespace XcVm\Cli\Commands; use XcVm\Cli\CommandInterface; use XcVm\Cli\CronJobs\ServersCronJob; +use XcVm\Core\Config\OpensslExtra; use XcVm\Core\Config\SettingsManager; use XcVm\Core\Util\Encryption; use XcVm\Domain\Server\ServerRepository; @@ -78,13 +79,13 @@ class ServerDiagnoseCommand implements CommandInterface { echo "Server #{$rTargetID} is the MAIN server — nothing to diagnose (it is always 'online' to itself).\n"; return 0; } - return $this->diagnoseFromMain($rServers[$rTargetID], $rTargetID); + return $this->diagnoseFromMain($rServers[$rTargetID], $rTargetID, $this->findMain($rServers)); } // ───────────────────────────────────────────────────────────────────────── // Mode A — remote probe from the MAIN // ───────────────────────────────────────────────────────────────────────── - private function diagnoseFromMain(array $rServer, int $rServerID): int { + private function diagnoseFromMain(array $rServer, int $rServerID, ?array $rMain): int { self::db(); $rIP = (string) $rServer['server_ip']; @@ -118,6 +119,7 @@ class ServerDiagnoseCommand implements CommandInterface { $this->clockSection($rServer, $rProblems); $this->signalSection($rServerID, $rNow, $rProblems); + $this->opensslExtraSection($rServer, $rMain, $rProblems); return $this->summary($rProblems); } @@ -223,6 +225,9 @@ class ServerDiagnoseCommand implements CommandInterface { // 7. Clock. $this->clockSection($rMe, $rProblems); + // 8. Do tokens minted on the main open here? + $this->opensslExtraSection($rMe, $rMain, $rProblems); + return $this->summary($rProblems); } @@ -270,6 +275,27 @@ class ServerDiagnoseCommand implements CommandInterface { } } + /** + * Compare the OPENSSL_EXTRA fingerprints the node and the main publish in + * server_hardware (cron:servers). Proxies publish none and are skipped. + */ + private function opensslExtraSection(array $rServer, ?array $rMain, array &$rProblems): void { + if ($rMain === null || (int) ($rServer['server_type'] ?? 0) === 1) { + return; + } + $rNodePrint = OpensslExtra::reportedFingerprint($rServer); + $rMainPrint = OpensslExtra::reportedFingerprint($rMain); + if ($rNodePrint === null || $rMainPrint === null) { + $this->line('OPENSSL_EXTRA', 'unknown (' . ($rNodePrint === null ? 'node' : 'main') . ' not updated)', true); + return; + } + $rOk = hash_equals($rMainPrint, $rNodePrint); + $this->line('OPENSSL_EXTRA', $rOk ? 'matches main' : 'MISMATCH with main', $rOk); + if (!$rOk) { + $rProblems[] = 'OPENSSL_EXTRA mismatch: tokens minted on MAIN are rejected by this node (playback redirected from the main fails).'; + } + } + private function summary(array $rProblems): int { echo str_repeat('-', 64) . "\n"; if ($rProblems === []) { diff --git a/src/Cli/CronJobs/ServersCronJob.php b/src/Cli/CronJobs/ServersCronJob.php index 31811706..a309df17 100644 --- a/src/Cli/CronJobs/ServersCronJob.php +++ b/src/Cli/CronJobs/ServersCronJob.php @@ -4,6 +4,7 @@ namespace XcVm\Cli\CronJobs; use XcVm\Cli\CommandInterface; use XcVm\Cli\CronTrait; +use XcVm\Core\Config\OpensslExtra; use XcVm\Core\Config\SettingsManager; use XcVm\Core\Config\SettingsRepository; use XcVm\Core\Process\ProcessManager; @@ -156,6 +157,8 @@ class ServersCronJob implements CommandInterface { } $rHardware = ['total_ram' => $rStats['total_mem'], 'total_used' => $rStats['total_mem_used'], 'cores' => $rStats['cpu_cores'], 'threads' => $rStats['cpu_cores'], 'kernel' => $rStats['kernel'], 'total_running_streams' => $rStats['total_running_streams'], 'cpu_name' => $rStats['cpu_name'], 'cpu_usage' => $rStats['cpu'], 'network_speed' => $rStats['network_speed'], 'bytes_sent' => $rStats['bytes_sent'], 'bytes_received' => $rStats['bytes_received']]; + // Lets server:diagnose tell whether this node reads MAIN's tokens (never the value itself). + $rHardware[OpensslExtra::HARDWARE_KEY] = OpensslExtra::fingerprint(OPENSSL_EXTRA); if (@fsockopen($rServers[SERVER_ID]['server_ip'], $rServers[SERVER_ID]['http_broadcast_port'], $rErrNo, $rErrStr, 3) || @fsockopen($rServers[SERVER_ID]['server_ip'], $rServers[SERVER_ID]['https_broadcast_port'], $rErrNo, $rErrStr, 3)) { $rRemoteStatus = true; diff --git a/src/Core/Config/OpensslExtra.php b/src/Core/Config/OpensslExtra.php new file mode 100644 index 00000000..fb08889d --- /dev/null +++ b/src/Core/Config/OpensslExtra.php @@ -0,0 +1,46 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +final class OpensslExtra { + /** The server_hardware key cron:servers publishes the fingerprint under. */ + public const HARDWARE_KEY = 'openssl_extra_fp'; + + /** + * A fingerprint of $rValue that can be compared across nodes: an HMAC keyed + * by the value, cut to 64 bits. It does not reveal the value. + */ + public static function fingerprint(string $rValue): string { + return substr(hash_hmac('sha256', 'xc_vm openssl_extra fingerprint v1', $rValue), 0, 16); + } + + /** + * The fingerprint a server row publishes, or null when it publishes none + * (a proxy, or a node that has not been updated). + */ + public static function reportedFingerprint(array $rServer): ?string { + $rHardware = json_decode((string) ($rServer['server_hardware'] ?? ''), true); + $rPrint = is_array($rHardware) ? ($rHardware[self::HARDWARE_KEY] ?? null) : null; + + return (is_string($rPrint) && $rPrint !== '') ? $rPrint : null; + } +} diff --git a/tests/Unit/OpensslExtraTest.php b/tests/Unit/OpensslExtraTest.php new file mode 100644 index 00000000..5b4531b9 --- /dev/null +++ b/tests/Unit/OpensslExtraTest.php @@ -0,0 +1,39 @@ +assertSame($rPrint, OpensslExtra::fingerprint($rSecret)); + $this->assertMatchesRegularExpression('/^[0-9a-f]{16}$/', $rPrint); + $this->assertNotSame(OpensslExtra::fingerprint('a'), OpensslExtra::fingerprint('b')); + $this->assertStringNotContainsString($rSecret, $rPrint); + $this->assertNotSame(substr(hash('sha256', $rSecret), 0, 16), $rPrint, 'keyed, not a bare hash of the value'); + $this->assertNotSame(substr(md5($rSecret), 0, 16), $rPrint); + } + + public function testReportedFingerprintIsReadFromServerHardware(): void { + $rPrint = OpensslExtra::fingerprint('x'); + + $this->assertSame($rPrint, OpensslExtra::reportedFingerprint(['server_hardware' => json_encode(['cores' => 4, OpensslExtra::HARDWARE_KEY => $rPrint])])); + $this->assertNull(OpensslExtra::reportedFingerprint(['server_hardware' => json_encode(['cores' => 4])]), 'node not updated yet'); + $this->assertNull(OpensslExtra::reportedFingerprint(['server_hardware' => json_encode([OpensslExtra::HARDWARE_KEY => ''])])); + $this->assertNull(OpensslExtra::reportedFingerprint(['server_hardware' => json_encode([OpensslExtra::HARDWARE_KEY => 12])])); + $this->assertNull(OpensslExtra::reportedFingerprint(['server_hardware' => 'not json'])); + $this->assertNull(OpensslExtra::reportedFingerprint(['server_hardware' => null])); + $this->assertNull(OpensslExtra::reportedFingerprint([])); + } +} diff --git a/tests/Unit/ServerDiagnoseOpensslExtraTest.php b/tests/Unit/ServerDiagnoseOpensslExtraTest.php new file mode 100644 index 00000000..51d7ad5e --- /dev/null +++ b/tests/Unit/ServerDiagnoseOpensslExtraTest.php @@ -0,0 +1,64 @@ + 4]; + if ($rValue !== null) { + $rHardware[OpensslExtra::HARDWARE_KEY] = OpensslExtra::fingerprint($rValue); + } + + return array_merge(['id' => $rID, 'is_main' => (int) ($rID === 1), 'server_type' => 0, 'server_hardware' => json_encode($rHardware)], $rOverride); + } + + /** @return array{0:string,1:string[]} [output, problems] */ + private function check(array $rServer, ?array $rMain): array { + $rMethod = new ReflectionMethod(ServerDiagnoseCommand::class, 'opensslExtraSection'); + $rMethod->setAccessible(true); + $rProblems = []; + ob_start(); + $rMethod->invokeArgs(new ServerDiagnoseCommand(), [$rServer, $rMain, &$rProblems]); + + return [(string) ob_get_clean(), $rProblems]; + } + + public function testAMismatchIsReportedAsAProblem(): void { + [$rOutput, $rProblems] = $this->check($this->row(2, 'fNiu3XD448xTDa27xoY4'), $this->row(1, 'random-main-extra')); + + $this->assertStringContainsString('[WARN]', $rOutput); + $this->assertCount(1, $rProblems); + $this->assertStringStartsWith('OPENSSL_EXTRA mismatch: tokens minted on MAIN are rejected by this node', $rProblems[0]); + } + + public function testMatchingValuesPass(): void { + [$rOutput, $rProblems] = $this->check($this->row(2, 'same-extra'), $this->row(1, 'same-extra')); + + $this->assertStringContainsString('[OK]', $rOutput); + $this->assertSame([], $rProblems); + } + + public function testAMissingFingerprintReadsAsUnknown(): void { + [$rOutput, $rProblems] = $this->check($this->row(2, null), $this->row(1, 'main-extra')); + $this->assertStringContainsString('unknown (node not updated)', $rOutput); + $this->assertSame([], $rProblems); + + [$rOutput, $rProblems] = $this->check($this->row(2, 'lb-extra'), $this->row(1, null)); + $this->assertStringContainsString('unknown (main not updated)', $rOutput); + $this->assertSame([], $rProblems); + } + + public function testProxiesAndAMissingMainAreSkipped(): void { + $this->assertSame(['', []], $this->check($this->row(3, null, ['server_type' => 1]), $this->row(1, 'main-extra'))); + $this->assertSame(['', []], $this->check($this->row(2, 'lb-extra'), null)); + } +}