Files
XC_VM/tests/Unit/ServerSyncOpensslExtraCommandTest.php
T
Claude 6c391f25d9 fix(servers): harden the OPENSSL_EXTRA repair after review
server:sync-openssl-extra now refuses to send when the main's own
published fingerprint differs from the value it read. Run as root, it
could read a config/openssl_extra that xc_vm cannot read, while the
main's php-fpm runs on the default, and push a value that breaks every
LB. The command and cron:root_signals now share one contract:
OpensslExtra::SIGNAL_ACTION, signal() and applySignal(), with
publish() for cron:servers.

Encryption::readToken now tries the sealed format with both values
before the legacy one. A wrong-value legacy decrypt() could return
garbage (about 1 in 256), so a sealed token minted with the replaced
value was refused. Legacy tokens keep that inherent miss rate.

proxy_api trusts the posted server_id and handed any registered proxy
an LB's signal rows, including the main's OPENSSL_EXTRA. It now skips
set_openssl_extra rows until the source-IP binding lands.

Tests cover sync() with TestDb, the signal contract and the case's own
break. The docs cover the check and the repair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
2026-09-25 00:56:05 +00:00

198 lines
8.1 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Cli\Commands\ServerSyncOpensslExtraCommand;
use XcVm\Core\Config\OpensslExtra;
/**
* server:sync-openssl-extra — queues MAIN's OPENSSL_EXTRA for the streaming
* LBs that report another one. A node that does not report a fingerprint runs
* a build whose root cron cannot apply the value, and an offline node would
* leave it waiting in the signals table, so both need --force; proxies and the
* MAIN never get it. Nothing is sent when the main has no value of its own, or
* when the value this process reads is not the one the main publishes (a file
* root can read and xc_vm's php-fpm cannot).
*
* The LB side (cron:root_signals) applies the row through
* OpensslExtra::applySignal, covered in OpensslExtraTest.
*/
final class ServerSyncOpensslExtraCommandTest extends TestCase {
private TestDb $db;
private string $rFile;
protected function setUp(): void {
$this->db = new TestDb();
$this->db->exec('CREATE TABLE signals (signal_id INTEGER PRIMARY KEY, server_id INTEGER, `time` INTEGER, custom_data TEXT);');
ServerSyncOpensslExtraCommand::setDb($this->db);
$this->rFile = sys_get_temp_dir() . '/xcvm_sync_openssl_extra_' . uniqid();
file_put_contents($this->rFile, 'main-extra-on-disk');
}
protected function tearDown(): void {
try {
(new ReflectionProperty(ServerSyncOpensslExtraCommand::class, 'db'))->setValue(null, null);
} finally {
@unlink($this->rFile);
}
}
/** Main #1 (publishes this process's value), LBs #2 and #5 on another value, #3 in sync, proxy #4. */
private function cluster(?string $rMainValue = OPENSSL_EXTRA): array {
$rMainHardware = $rMainValue === null ? ['cores' => 8] : [OpensslExtra::HARDWARE_KEY => OpensslExtra::fingerprint($rMainValue)];
return [
1 => ['id' => 1, 'is_main' => 1, 'server_type' => 0, 'server_online' => true, 'server_name' => 'Main', 'server_hardware' => json_encode($rMainHardware)],
2 => $this->lb(['id' => 2]),
3 => $this->lb(['id' => 3, 'server_hardware' => json_encode([OpensslExtra::HARDWARE_KEY => OpensslExtra::fingerprint(OPENSSL_EXTRA)])]),
4 => $this->lb(['id' => 4, 'server_type' => 1, 'server_hardware' => json_encode(['cores' => 2])]),
5 => $this->lb(['id' => 5]),
];
}
/** @return array{0:int,1:string} [exit code, output] */
private function sync(array $rServers, bool $rAll, int $rTargetID, bool $rForce): array {
ob_start();
try {
$rCode = ServerSyncOpensslExtraCommand::sync($rServers, 1, $rAll, $rTargetID, $rForce, $this->rFile);
} finally {
$rOutput = (string) ob_get_clean();
}
return [$rCode, $rOutput];
}
/** @return array<int,string> server_id => custom_data of every queued signal */
private function queued(): array {
$this->db->query('SELECT server_id, custom_data FROM signals ORDER BY signal_id;');
$rQueued = [];
foreach ($this->db->get_rows() as $rRow) {
$this->assertArrayNotHasKey((int) $rRow['server_id'], $rQueued, 'one signal per node');
$rQueued[(int) $rRow['server_id']] = $rRow['custom_data'];
}
return $rQueued;
}
public function testEachMismatchedLbGetsOneSignalCarryingTheMainsValue(): void {
[$rCode] = $this->sync($this->cluster(), true, 0, false);
$this->assertSame(0, $rCode);
$rSignal = OpensslExtra::signal(OPENSSL_EXTRA);
$this->assertSame([2 => $rSignal, 5 => $rSignal], $this->queued());
$this->assertSame(['action' => OpensslExtra::SIGNAL_ACTION, 'value' => OPENSSL_EXTRA], json_decode($rSignal, true));
}
public function testForceNeverQueuesTheMainOrAProxy(): void {
[$rCode] = $this->sync($this->cluster(), true, 0, true);
$this->assertSame(0, $rCode);
$this->assertSame([2, 3, 5], array_keys($this->queued()));
}
public function testASingleTargetIsQueuedAlone(): void {
$this->assertSame(0, $this->sync($this->cluster(), false, 5, false)[0]);
$this->assertSame([5], array_keys($this->queued()));
[$rCode, $rOutput] = $this->sync($this->cluster(), false, 1, true);
$this->assertSame(0, $rCode);
$this->assertStringContainsString('#1 Main: skipped, the main', $rOutput);
$this->assertSame([5], array_keys($this->queued()));
}
/** Without config/openssl_extra the main runs on the built-in default, as its LBs do. */
public function testNothingIsQueuedWhenTheMainHasNoValueOfItsOwn(): void {
foreach (['', " \n"] as $rEmpty) {
file_put_contents($this->rFile, $rEmpty);
$this->assertSame(0, $this->sync($this->cluster(), true, 0, true)[0]);
}
@unlink($this->rFile);
$this->assertSame(0, $this->sync($this->cluster(), true, 0, true)[0]);
$this->assertSame([], $this->queued());
}
/** Run as root, the command can read a file xc_vm cannot: the main's php-fpm then mints with another value. */
public function testNothingIsQueuedUnlessTheMainPublishesTheValueToSend(): void {
[$rCode, $rOutput] = $this->sync($this->cluster('built-in-default'), true, 0, true);
$this->assertSame(1, $rCode);
$this->assertStringContainsString('chown xc_vm:xc_vm', $rOutput);
[$rCode, $rOutput] = $this->sync($this->cluster(null), true, 0, true);
$this->assertSame(1, $rCode);
$this->assertStringContainsString('cron:servers', $rOutput);
$this->assertSame([], $this->queued());
}
public function testItRunsOnlyOnTheMainAndForAKnownServer(): void {
$rServers = $this->cluster();
$rServers[1]['is_main'] = 0;
$this->assertSame(1, $this->sync($rServers, true, 0, true)[0]);
$this->assertSame(1, $this->sync($this->cluster(), false, 9, true)[0]);
$this->assertSame([], $this->queued());
}
public function testTargetsListsEveryNodeButTheMainWithItsSkipReason(): void {
$rTargets = ServerSyncOpensslExtraCommand::targets($this->cluster(), true, 0, OpensslExtra::fingerprint(OPENSSL_EXTRA), false);
$this->assertSame([2, 3, 4, 5], array_keys($rTargets));
$this->assertNull($rTargets[2]);
$this->assertSame('already in sync', $rTargets[3]);
$this->assertSame('a proxy', $rTargets[4]);
$this->assertNull($rTargets[5]);
}
/** Its own case with its own break: falling through would run the next action (restart_services). */
public function testTheLbAppliesTheSignalInItsOwnCase(): void {
$rSource = (string) file_get_contents(MAIN_HOME . 'Cli/CronJobs/RootSignalsCronJob.php');
$this->assertSame(1, preg_match('/case OpensslExtra::SIGNAL_ACTION:(.*?)\n\s*case /s', $rSource, $rMatch));
$this->assertStringContainsString('OpensslExtra::applySignal($rData, !empty($rServers[SERVER_ID][\'is_main\']), CONFIG_PATH, time())', $rMatch[1]);
$this->assertMatchesRegularExpression('/break;\s*$/', $rMatch[1]);
}
private function lb(array $rOverride = []): array {
return array_merge([
'id' => 2,
'is_main' => 0,
'server_type' => 0,
'server_online' => true,
'server_hardware' => json_encode(['cores' => 4, OpensslExtra::HARDWARE_KEY => OpensslExtra::fingerprint('lb-extra')]),
], $rOverride);
}
private function skip(array $rServer, bool $rForce): ?string {
return ServerSyncOpensslExtraCommand::skipReason($rServer, OpensslExtra::fingerprint('main-extra'), $rForce);
}
public function testAnLbThatReportsAnotherValueIsSynced(): void {
$this->assertNull($this->skip($this->lb(), false));
$this->assertNull($this->skip($this->lb(), true));
}
public function testAnLbInSyncIsSyncedOnlyWhenForced(): void {
$rInSync = $this->lb(['server_hardware' => json_encode([OpensslExtra::HARDWARE_KEY => OpensslExtra::fingerprint('main-extra')])]);
$this->assertSame('already in sync', $this->skip($rInSync, false));
$this->assertNull($this->skip($rInSync, true));
}
public function testAnOutdatedOrOfflineLbIsSyncedOnlyWhenForced(): void {
$rOutdated = $this->lb(['server_hardware' => json_encode(['cores' => 4])]);
$rOffline = $this->lb(['server_online' => false]);
$this->assertStringStartsWith('unknown (node not updated)', (string) $this->skip($rOutdated, false));
$this->assertNull($this->skip($rOutdated, true));
$this->assertStringStartsWith('offline', (string) $this->skip($rOffline, false));
$this->assertNull($this->skip($rOffline, true));
}
public function testProxiesAndTheMainAreNeverSynced(): void {
$this->assertNotNull($this->skip($this->lb(['server_type' => 1]), true));
$this->assertNotNull($this->skip($this->lb(['is_main' => 1]), true));
}
}