feat(cluster): signed command channel to nodes (Phase 4, COMMANDS flow)

CommandBus queues typed, panel-signed (`cmd`) commands per node in
cluster_commands: FIFO by seq, dedupe_key for desired state, expiry,
pruned by cron:cluster. New ops: `commands` (long-poll after the node's
high-water, held up to 20 s; frozen while quarantined) and `ack` (own
commands only; raises cluster_nodes.cmd_seq).

For nodes with the COMMANDS flow on (toggled on Cluster Nodes),
ClusterRoute sends NodeRpc::request() as a signed node.rpc and waits for
its ack, and NodeRpc::broadcast() / SignalDispatcher::kill() queue
commands; other nodes and every LB keep the legacy transport.

On the node, `console.php cluster:exec` checks a command again against
the panel key the agent pinned and runs node.rpc through the legacy /api
handlers (InternalApiController::runCommand, NodeRpc::ACTIONS only) or
conn.kill_worker. Root actions stay on the signals table for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
This commit is contained in:
Claude
2026-09-25 13:24:24 +00:00
parent b65cb0416e
commit 4f0cf12713
21 changed files with 671 additions and 8 deletions
+47
View File
@@ -0,0 +1,47 @@
<?php
use PHPUnit\Framework\TestCase;
use XcVm\Cli\Commands\ClusterExecCommand;
use XcVm\Core\Cluster\Crypto\Enc;
use XcVm\Tests\Support\FakeClusterCrypto;
/**
* cluster:exec runs a command only when it carries the panel's `cmd`
* signature under the key the agent pinned, names this node and is not stale.
*/
final class ClusterExecCommandTest extends TestCase {
private FakeClusterCrypto $rCrypto;
private array $rState;
protected function setUp(): void {
$this->rCrypto = new FakeClusterCrypto();
$this->rState = ['node_uuid' => '0f8fad5b-d9cb-469f-a165-70867728950e', 'panel_sign_pub' => base64_encode($this->rCrypto->info()['panel_sign_pub'])];
}
private function command(array $rOverride = [], string $rTag = 'cmd'): array {
$rDoc = (string) json_encode($rOverride + ['v' => 1, 'type' => 'node.rpc', 'exp' => 1800000600, 'iat' => 1800000000, 'cmd_id' => str_repeat('a', 32), 'seq' => 1, 'node_uuid' => $this->rState['node_uuid'], 'gen' => 1, 'dedupe_key' => null, 'args' => ['action' => 'get_pids']]);
return ['doc' => $rDoc, 'sig' => Enc::b64url($this->rCrypto->sign($rTag, $rDoc))];
}
public function testAGenuineCommandVerifies(): void {
$rCmd = ClusterExecCommand::verify($this->command(), $this->rState, 1800000000);
$this->assertIsArray($rCmd);
$this->assertSame('get_pids', $rCmd['args']['action']);
}
public function testForgedOrMisaddressedCommandsAreRefused(): void {
$rGood = $this->command();
$this->assertSame('bad signature', ClusterExecCommand::verify(['doc' => str_replace('get_pids', 'kill_pid', $rGood['doc'])] + $rGood, $this->rState, 1800000000));
$this->assertSame('bad signature', ClusterExecCommand::verify($this->command([], 'den'), $this->rState, 1800000000), 'another tag');
$this->assertSame('bad signature', ClusterExecCommand::verify($rGood, ['panel_sign_pub' => base64_encode(str_repeat("\1", 32))] + $this->rState, 1800000000));
$this->assertIsString(ClusterExecCommand::verify($this->command(['node_uuid' => '11111111-1111-4111-a111-111111111111']), $this->rState, 1800000000));
$this->assertIsString(ClusterExecCommand::verify($rGood, $this->rState, 1800000600 + ClusterExecCommand::SKEW + 1), 'stale');
}
public function testUnknownTypesAndActionsDoNothing(): void {
$this->assertSame(2, ClusterExecCommand::run(['type' => 'node.root', 'args' => ['action' => 'reboot']]));
$this->assertSame(2, ClusterExecCommand::run(['type' => 'node.rpc', 'args' => ['action' => 'view_log']]), 'not in NodeRpc::ACTIONS');
$this->assertSame(2, ClusterExecCommand::run(['type' => 'conn.kill_worker', 'args' => ['pid' => 0]]));
}
}