mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-25 19:03:14 +01:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
This commit is contained in:
@@ -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 === []) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Config;
|
||||
|
||||
/**
|
||||
* OPENSSL_EXTRA across the cluster.
|
||||
*
|
||||
* OPENSSL_EXTRA (ConstantsInitializer::resolveOpensslExtra: config/openssl_extra,
|
||||
* else the built-in default) keys stream tokens, hmac_keys and image-cache
|
||||
* names. A MAIN and its LBs must hold the same value: a token MAIN mints for a
|
||||
* redirect is read on the LB with the LB's value. A MAIN set up by the current
|
||||
* installer holds a random value, while an LB added with server:install does
|
||||
* not get that file and runs on the default.
|
||||
*
|
||||
* Each node publishes a fingerprint of its value (never the value) in
|
||||
* servers.server_hardware, so server:diagnose can compare them.
|
||||
*
|
||||
* @package XC_VM_Core_Config
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use XcVm\Core\Config\OpensslExtra;
|
||||
|
||||
/**
|
||||
* OpensslExtra — a MAIN and its LBs must hold the same OPENSSL_EXTRA, or the
|
||||
* tokens MAIN mints for its redirects are rejected by the LB.
|
||||
*
|
||||
* fingerprint / reportedFingerprint: what each node publishes in
|
||||
* servers.server_hardware, so the values can be compared without leaving the
|
||||
* node.
|
||||
*/
|
||||
final class OpensslExtraTest extends TestCase {
|
||||
|
||||
public function testFingerprintIsAShortStableDigestThatHidesTheValue(): void {
|
||||
$rSecret = 'fNiu3XD448xTDa27xoY4';
|
||||
$rPrint = OpensslExtra::fingerprint($rSecret);
|
||||
|
||||
$this->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([]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use XcVm\Cli\Commands\ServerDiagnoseCommand;
|
||||
use XcVm\Core\Config\OpensslExtra;
|
||||
|
||||
/**
|
||||
* server:diagnose — compares the OPENSSL_EXTRA fingerprint a node publishes in
|
||||
* server_hardware with the MAIN's. A mismatch means every token MAIN mints for
|
||||
* a redirect is rejected by that node. A node that publishes none runs an
|
||||
* older build; proxies publish none and are not checked.
|
||||
*/
|
||||
final class ServerDiagnoseOpensslExtraTest extends TestCase {
|
||||
|
||||
private function row(int $rID, ?string $rValue, array $rOverride = []): array {
|
||||
$rHardware = ['cores' => 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user