mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-11-01 03:03:23 +01:00
The suite ran on in-memory SQLite, an engine production never uses: it hid column lengths, MySQL-only syntax and type coercion (the identity bug fixed in the previous commit passed CI that way). SQLite is gone: - TestDb connects only to MariaDB/MySQL (XCVM_TEST_DB_DSN/_USER/_PASS; root over the local socket, else 127.0.0.1:3306). Each instance gets a throwaway schema, dropped with it, in the installer's sql_mode, with an explicit connect timeout (a boot under test can zero default_socket_timeout). - InstallSchema and the tests take the production DDL as is: the SQLite reducers, the keys they re-added, the faked information_schema and the sqliteCreateFunction() stand-ins are removed; test DDL is MySQL DDL. - ReplicaBootTest, DbConnectRefusalTest and LazyDatabaseHandlerTest reach the test server instead of SQLite files; RecordingFinalizer no longer swallows an information_schema failure "for tests on SQLite". - `make test-db` starts a throwaway mariadb:11.4 (tmpfs) for a machine without a server; the CI PHPUnit job gets the same as a service. The suite also runs on a panel host with the bundled PHP and its own php.ini: tests reach the code through MAIN_HOME, not `<repo>/src`; repository checks and process-isolated tests (ionCube Loader + OPcache CLI segfault on a script read from stdin) are in the skip-on-panel group; xcvm_test_child_php() starts a child that fakes \XC_VM without the real extension; Redis-down tests use RedisManager::useConnector() instead of an eval'd \XC_VM. Stale opt-in tests fixed on the way: the real-agent provisioning test answers the agent-started check, the extension integration test sends the typed starts and reads the binary signature raw, a fake producer starts as PHP_BINARY, and the certbot test waits for the line its background command writes.
175 lines
9.0 KiB
PHP
175 lines
9.0 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Cli\Commands\ClusterLockdownCommand;
|
|
use XcVm\Domain\Cluster\ClusterLockdown;
|
|
use XcVm\Domain\Cluster\DbAllowlist;
|
|
use XcVm\Infrastructure\Database\DatabaseFactory;
|
|
|
|
/**
|
|
* cluster:lockdown (plan, section 10, step 5): MariaDB and Redis bound to
|
|
* loopback, the XCVM_DB chain narrowed to MAIN and the extra list, refused
|
|
* while a node below mode 2 or a proxy could still need either port unless
|
|
* --force, all of it undone by --undo, and never run by anything but an
|
|
* operator. A fake runner stands in for iptables and systemctl.
|
|
*/
|
|
final class ClusterLockdownTest extends TestCase {
|
|
private TestDb $rDb;
|
|
|
|
private string $rDir;
|
|
|
|
/** @var array<string, array{chain: ?list<string>, jumps: int}> */
|
|
private array $rFw = [];
|
|
|
|
/** @var list<string> */
|
|
private array $rCalls = [];
|
|
|
|
protected function setUp(): void {
|
|
$this->rDb = new TestDb();
|
|
$this->rDb->exec('CREATE TABLE `servers` (`id` int, `server_type` int, `is_main` int, `server_ip` varchar(255), `private_ip` varchar(255), `proxy_signed` int DEFAULT 0)');
|
|
$this->rDb->exec("CREATE TABLE `cluster_nodes` (`server_id` int, `mode` int, `state` varchar(16) DEFAULT 'active')");
|
|
$this->rDb->exec("CREATE TABLE `settings` (`cluster_db_allowlist` int DEFAULT 0, `cluster_db_allowlist_extra` varchar(1024) DEFAULT '')");
|
|
$this->rDb->exec('CREATE TABLE `cluster_meta` (`name` varchar(64) PRIMARY KEY, `value` text, `updated_at` int)');
|
|
$this->rDb->exec('CREATE TABLE `cluster_audit` (`id` INTEGER PRIMARY KEY AUTO_INCREMENT, `time` int, `server_id` int, `actor` varchar(64), `event` varchar(64), `detail` text, `ip` varchar(64))');
|
|
$this->rDb->exec("INSERT INTO `settings` VALUES (0, '')");
|
|
$this->rDb->exec("INSERT INTO `servers` (`id`, `server_type`, `is_main`, `server_ip`, `private_ip`) VALUES (1, 0, 1, '203.0.113.1', '10.0.0.1'), (2, 0, 0, '203.0.113.2', ''), (3, 0, 0, '203.0.113.3', '')");
|
|
$this->rDb->exec("INSERT INTO `cluster_nodes` (`server_id`, `mode`) VALUES (2, 1), (3, 2)");
|
|
DatabaseFactory::set($this->rDb);
|
|
$this->rDir = sys_get_temp_dir() . '/xcvm-lock-' . bin2hex(random_bytes(4));
|
|
mkdir($this->rDir . '/mysql', 0755, true);
|
|
file_put_contents($this->rDir . '/redis.conf', "bind *\nprotected-mode yes\nport 6379\n");
|
|
$this->rFw = ['/sbin/iptables' => ['chain' => null, 'jumps' => 0], '/sbin/ip6tables' => ['chain' => null, 'jumps' => 0]];
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
DatabaseFactory::reset();
|
|
exec('rm -rf ' . escapeshellarg($this->rDir));
|
|
}
|
|
|
|
private function lockdown(): ClusterLockdown {
|
|
$rExec = function (array $rArgv, ?string $rStdin): array {
|
|
$this->rCalls[] = implode(' ', $rArgv);
|
|
if ($rArgv[0] === 'systemctl') {
|
|
return [0, ''];
|
|
}
|
|
if (str_ends_with($rArgv[0], '-restore')) {
|
|
$rTool = substr($rArgv[0], 0, -8);
|
|
$this->rFw[$rTool]['chain'] = array_merge(['-N XCVM_DB'], array_values(array_filter(explode("\n", (string) $rStdin), static fn ($l) => str_starts_with($l, '-A '))));
|
|
return [0, ''];
|
|
}
|
|
$rTool = $rArgv[0];
|
|
switch ($rArgv[1]) {
|
|
case '-S':
|
|
return $this->rFw[$rTool]['chain'] === null ? [1, ''] : [0, implode("\n", $this->rFw[$rTool]['chain']) . "\n"];
|
|
case '-C':
|
|
return [$this->rFw[$rTool]['jumps'] > 0 ? 0 : 1, ''];
|
|
case '-I':
|
|
$this->rFw[$rTool]['jumps']++;
|
|
return [0, ''];
|
|
case '-D':
|
|
if ($this->rFw[$rTool]['jumps'] === 0) {
|
|
return [1, ''];
|
|
}
|
|
$this->rFw[$rTool]['jumps']--;
|
|
return [0, ''];
|
|
case '-F':
|
|
return [0, ''];
|
|
case '-X':
|
|
$this->rFw[$rTool]['chain'] = null;
|
|
return [0, ''];
|
|
}
|
|
return [127, ''];
|
|
};
|
|
$rAllowlist = new DbAllowlist($rExec, static fn (string $rHost): array => [], [4 => '/sbin/iptables', 6 => '/sbin/ip6tables']);
|
|
return new ClusterLockdown($rAllowlist, $rExec, $this->rDir . '/mysql/', $this->rDir . '/redis.conf');
|
|
}
|
|
|
|
public function testItRefusesWhileANodeBelowModeTwoOrAProxyRemains(): void {
|
|
$this->rDb->exec("INSERT INTO `servers` (`id`, `server_type`, `is_main`, `server_ip`, `private_ip`) VALUES (4, 1, 0, '203.0.113.4', '')");
|
|
$this->assertSame(['nodes' => [2], 'proxies' => [4]], ClusterLockdown::blockers());
|
|
ob_start();
|
|
$rCode = (new ClusterLockdownCommand($this->lockdown(), false))->execute([]);
|
|
$rText = (string) ob_get_clean();
|
|
$this->assertSame(1, $rCode);
|
|
$this->assertStringContainsString('load balancer(s) 2 are below mode 2', $rText);
|
|
$this->assertStringContainsString('proxy server(s) 4', $rText);
|
|
$this->assertFileDoesNotExist($this->rDir . '/mysql/' . ClusterLockdown::MYSQL_DROPIN, 'nothing changed');
|
|
$this->assertSame([], $this->rCalls, 'the firewall was not touched');
|
|
$this->assertNull(ClusterLockdown::state());
|
|
|
|
// A proxy that signs its channel (ProxyKey, D8) uses MAIN's API only.
|
|
$this->rDb->exec('UPDATE `servers` SET `proxy_signed` = 1 WHERE `id` = 4');
|
|
$this->assertSame(['nodes' => [2], 'proxies' => []], ClusterLockdown::blockers());
|
|
}
|
|
|
|
public function testLockdownBindsToLoopbackAndNarrowsTheChainThenUndoRestoresIt(): void {
|
|
$this->rDb->exec('UPDATE `cluster_nodes` SET `mode` = 2');
|
|
$this->assertSame(['nodes' => [], 'proxies' => []], ClusterLockdown::blockers());
|
|
ob_start();
|
|
$rCode = (new ClusterLockdownCommand($this->lockdown(), false))->execute(['--restart']);
|
|
$rText = (string) ob_get_clean();
|
|
$this->assertSame(0, $rCode, $rText);
|
|
|
|
$this->assertStringContainsString("bind-address = 127.0.0.1\n", (string) file_get_contents($this->rDir . '/mysql/' . ClusterLockdown::MYSQL_DROPIN));
|
|
$this->assertStringStartsWith(ClusterLockdown::REDIS_BIND . "\n", (string) file_get_contents($this->rDir . '/redis.conf'));
|
|
// The setting is off, yet the chain is on: MAIN and loopback only.
|
|
$this->assertSame(DbAllowlist::chainLines(['10.0.0.1/32', '203.0.113.1/32']), $this->rFw['/sbin/iptables']['chain']);
|
|
$this->assertSame(1, $this->rFw['/sbin/iptables']['jumps']);
|
|
$this->assertContains('systemctl restart mariadb', $this->rCalls);
|
|
$this->assertTrue(DbAllowlist::lockedDown());
|
|
|
|
$rUndo = $this->lockdown()->undo(false);
|
|
$this->assertContains('MariaDB: drop-in removed', $rUndo);
|
|
$this->assertContains('Redis: bind *', $rUndo);
|
|
$this->assertFileDoesNotExist($this->rDir . '/mysql/' . ClusterLockdown::MYSQL_DROPIN);
|
|
$this->assertStringStartsWith("bind *\n", (string) file_get_contents($this->rDir . '/redis.conf'), 'the bind it replaced');
|
|
$this->assertNull($this->rFw['/sbin/iptables']['chain'], 'with the allowlist setting off, undo removes the chain');
|
|
$this->assertFalse(DbAllowlist::lockedDown());
|
|
}
|
|
|
|
public function testTheCronKeepsTheLockdownChainWhateverTheSetting(): void {
|
|
$this->rDb->exec('UPDATE `cluster_nodes` SET `mode` = 2');
|
|
$this->lockdown()->apply(false);
|
|
$this->rFw['/sbin/iptables'] = ['chain' => null, 'jumps' => 0]; // a flush
|
|
$rAllowlist = (new \ReflectionProperty(ClusterLockdown::class, 'rAllowlist'))->getValue($this->lockdown());
|
|
$this->assertSame([4 => 'applied', 6 => 'ok'], $rAllowlist->sync('root'));
|
|
$this->assertSame(DbAllowlist::chainLines(['10.0.0.1/32', '203.0.113.1/32']), $this->rFw['/sbin/iptables']['chain']);
|
|
}
|
|
|
|
public function testWithAnExtraListTheBindsStayAndTheFirewallAdmitsIt(): void {
|
|
$this->rDb->exec('UPDATE `cluster_nodes` SET `mode` = 2');
|
|
$this->rDb->exec("UPDATE `settings` SET `cluster_db_allowlist_extra` = '198.51.100.0/24'");
|
|
$rLines = $this->lockdown()->apply(false);
|
|
$this->assertFileDoesNotExist($this->rDir . '/mysql/' . ClusterLockdown::MYSQL_DROPIN);
|
|
$this->assertStringStartsWith("bind *\n", (string) file_get_contents($this->rDir . '/redis.conf'));
|
|
$this->assertStringContainsString('keep their binds', $rLines[0]);
|
|
$this->assertSame(DbAllowlist::chainLines(['10.0.0.1/32', '198.51.100.0/24', '203.0.113.1/32']), $this->rFw['/sbin/iptables']['chain']);
|
|
}
|
|
|
|
public function testForceLocksDownAndRecordsWhomItCutOff(): void {
|
|
$rLines = $this->lockdown()->apply(true);
|
|
$this->assertNotEmpty($rLines);
|
|
$this->assertSame(['nodes' => [2], 'proxies' => []], ClusterLockdown::state()['forced']);
|
|
$this->assertNotContains('-A XCVM_DB -s 203.0.113.2/32 -j ACCEPT', $this->rFw['/sbin/iptables']['chain'], 'the mode-1 node is cut off');
|
|
}
|
|
|
|
/** Manual only: no cron, daemon or request path runs it. */
|
|
#[\PHPUnit\Framework\Attributes\Group('skip-on-panel')]
|
|
public function testNothingButTheOperatorRunsIt(): void {
|
|
$rSrc = MAIN_HOME;
|
|
$rHits = [];
|
|
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($rSrc, \FilesystemIterator::SKIP_DOTS)) as $rFile) {
|
|
if (!str_ends_with($rFile->getPathname(), '.php') || str_contains($rFile->getPathname(), '/vendor/')) {
|
|
continue;
|
|
}
|
|
$rBody = (string) file_get_contents($rFile->getPathname());
|
|
if (str_contains($rBody, 'ClusterLockdown') || str_contains($rBody, 'cluster:lockdown')) {
|
|
$rHits[] = substr($rFile->getPathname(), strlen($rSrc));
|
|
}
|
|
}
|
|
sort($rHits);
|
|
$this->assertSame(['Cli/Commands/ClusterLockdownCommand.php', 'Domain/Cluster/ClusterLockdown.php', 'Domain/Cluster/DbAllowlist.php', 'Domain/Cluster/LockdownRefused.php'], $rHits);
|
|
$this->assertStringContainsString('Cli/Commands/ClusterLockdownCommand.php', (string) file_get_contents(dirname(__DIR__, 2) . '/Makefile'));
|
|
}
|
|
}
|