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)); + } +}