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:
Claude
2026-09-25 00:32:16 +00:00
parent 3dfaa83712
commit 1da36c18df
5 changed files with 180 additions and 2 deletions
+28 -2
View File
@@ -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 === []) {
+3
View File
@@ -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;
+46
View File
@@ -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;
}
}
+39
View File
@@ -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));
}
}