From c06ca0ae5d26cc8c153ba46b8938a84faed7239c Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 09:58:02 +0200 Subject: [PATCH] feat(engine): SSH / WireGuard / Proxmox service layer RemoteShell (phpseclib + fake), WireguardHub (local wg + fake), ProxmoxClient (REST + fake). Interfaces bound in AppServiceProvider; tests swap fakes via fakeServices() helper. Co-Authored-By: Claude Opus 4.8 --- app/Services/Proxmox/FakeProxmoxClient.php | 70 ++++++++++++ app/Services/Proxmox/HttpProxmoxClient.php | 91 ++++++++++++++++ app/Services/Proxmox/ProxmoxClient.php | 30 ++++++ app/Services/Ssh/CommandResult.php | 27 +++++ app/Services/Ssh/FakeRemoteShell.php | 108 +++++++++++++++++++ app/Services/Ssh/PhpseclibRemoteShell.php | 77 +++++++++++++ app/Services/Ssh/RemoteShell.php | 21 ++++ app/Services/Wireguard/FakeWireguardHub.php | 46 ++++++++ app/Services/Wireguard/LocalWireguardHub.php | 54 ++++++++++ app/Services/Wireguard/WireguardHub.php | 23 ++++ tests/Feature/Provisioning/ServicesTest.php | 48 +++++++++ tests/Pest.php | 19 ++++ 12 files changed, 614 insertions(+) create mode 100644 app/Services/Proxmox/FakeProxmoxClient.php create mode 100644 app/Services/Proxmox/HttpProxmoxClient.php create mode 100644 app/Services/Proxmox/ProxmoxClient.php create mode 100644 app/Services/Ssh/CommandResult.php create mode 100644 app/Services/Ssh/FakeRemoteShell.php create mode 100644 app/Services/Ssh/PhpseclibRemoteShell.php create mode 100644 app/Services/Ssh/RemoteShell.php create mode 100644 app/Services/Wireguard/FakeWireguardHub.php create mode 100644 app/Services/Wireguard/LocalWireguardHub.php create mode 100644 app/Services/Wireguard/WireguardHub.php create mode 100644 tests/Feature/Provisioning/ServicesTest.php diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php new file mode 100644 index 0000000..c7684e6 --- /dev/null +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -0,0 +1,70 @@ +> */ + public array $nodes = [['node' => 'pve']]; + + /** @var array */ + public array $status = [ + 'cpuinfo' => ['cpus' => 16], + 'memory' => ['total' => 68719476736], // 64 GiB + 'pveversion' => 'pve-manager/8.2.2', + ]; + + /** @var array> */ + public array $storage = [ + ['storage' => 'local-lvm', 'type' => 'lvmthin', 'total' => 1099511627776], // 1 TiB + ]; + + /** @var array */ + public array $createdRoles = []; + + /** @var array{token_id: string, secret: string}|null */ + public ?array $createdToken = null; + + public int $tokenCalls = 0; + + public function forHost(Host $host): static + { + $this->host = $host; + + return $this; + } + + public function listNodes(): array + { + return $this->nodes; + } + + public function nodeStatus(string $node): array + { + return $this->status; + } + + public function nodeStorage(string $node): array + { + return $this->storage; + } + + public function createRole(string $roleId, string $privs): void + { + $this->createdRoles[] = $roleId; + } + + public function createUserAndToken(string $user, string $roleId): array + { + $this->tokenCalls++; + + return $this->createdToken = [ + 'token_id' => $user.'!clupilot', + 'secret' => 'fake-secret-'.substr(md5($user.$roleId), 0, 12), + ]; + } +} diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php new file mode 100644 index 0000000..85e4226 --- /dev/null +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -0,0 +1,91 @@ +baseUrl = 'https://'.$host->wg_ip.':8006/api2/json'; + $this->token = (string) $host->api_token_ref; + + return $this; + } + + private function http(): PendingRequest + { + return Http::withoutVerifying() + ->withHeaders(['Authorization' => 'PVEAPIToken='.$this->token]) + ->baseUrl($this->baseUrl) + ->timeout(15); + } + + public function listNodes(): array + { + return $this->http()->get('/nodes')->throw()->json('data', []); + } + + public function nodeStatus(string $node): array + { + return $this->http()->get("/nodes/{$node}/status")->throw()->json('data', []); + } + + public function nodeStorage(string $node): array + { + return $this->http()->get("/nodes/{$node}/storage")->throw()->json('data', []); + } + + public function createRole(string $roleId, string $privs): void + { + $response = $this->http()->asForm()->post('/access/roles', [ + 'roleid' => $roleId, + 'privs' => $privs, + ]); + + // Idempotent: an existing role is not an error. + if ($response->failed() && ! str_contains($response->body(), 'already exists')) { + $response->throw(); + } + } + + public function createUserAndToken(string $user, string $roleId): array + { + // Ensure the user exists (ignore "already exists"), grant the role, mint a token. + $this->http()->asForm()->post('/access/users', [ + 'userid' => $user, + 'password' => Str::random(40), + ]); + + $this->http()->asForm()->put('/access/acl', [ + 'path' => '/', + 'users' => $user, + 'roles' => $roleId, + ])->throw(); + + $tokenName = config('provisioning.proxmox.token_name', 'clupilot'); + + $data = $this->http()->asForm() + ->post("/access/users/{$user}/token/{$tokenName}", ['privsep' => 0]) + ->throw() + ->json('data', []); + + return [ + 'token_id' => $data['full-tokenid'] ?? "{$user}!{$tokenName}", + 'secret' => $data['value'] ?? '', + ]; + } +} diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php new file mode 100644 index 0000000..0fb3a4b --- /dev/null +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -0,0 +1,30 @@ +> */ + public function listNodes(): array; + + /** @return array */ + public function nodeStatus(string $node): array; + + /** @return array> */ + public function nodeStorage(string $node): array; + + public function createRole(string $roleId, string $privs): void; + + /** @return array{token_id: string, secret: string} */ + public function createUserAndToken(string $user, string $roleId): array; +} diff --git a/app/Services/Ssh/CommandResult.php b/app/Services/Ssh/CommandResult.php new file mode 100644 index 0000000..b63cdea --- /dev/null +++ b/app/Services/Ssh/CommandResult.php @@ -0,0 +1,27 @@ +exitCode === 0; + } + + public static function success(string $stdout = ''): self + { + return new self(0, $stdout); + } + + public static function failure(int $exitCode = 1, string $stderr = ''): self + { + return new self($exitCode, '', $stderr); + } +} diff --git a/app/Services/Ssh/FakeRemoteShell.php b/app/Services/Ssh/FakeRemoteShell.php new file mode 100644 index 0000000..2e7cf9f --- /dev/null +++ b/app/Services/Ssh/FakeRemoteShell.php @@ -0,0 +1,108 @@ + */ + private array $scripts = []; + + /** @var array */ + private array $recorded = []; + + /** @var array */ + private array $files = []; + + /** @var array */ + public array $connections = []; + + public string $fingerprint = 'SHA256:fakehostkeyfingerprint'; + + private CommandResult $default; + + public function __construct() + { + $this->default = CommandResult::success(); + } + + public function script(string $substring, CommandResult|callable $result): static + { + $this->scripts[$substring] = $result; + + return $this; + } + + public function defaultResult(CommandResult $result): static + { + $this->default = $result; + + return $this; + } + + public function connectWithPassword(string $host, string $user, string $password): void + { + $this->connections[] = ['password', $host, $user]; + } + + public function connectWithKey(string $host, string $user, string $privateKey): void + { + $this->connections[] = ['key', $host, $user]; + } + + public function run(string $command): CommandResult + { + $this->recorded[] = $command; + + foreach ($this->scripts as $substring => $result) { + if (str_contains($command, $substring)) { + return is_callable($result) ? $result($command) : $result; + } + } + + return $this->default; + } + + public function putFile(string $remotePath, string $contents): void + { + $this->files[$remotePath] = $contents; + } + + public function hostKeyFingerprint(): string + { + return $this->fingerprint; + } + + // --- introspection helpers for tests --- + + /** @return array */ + public function recorded(): array + { + return $this->recorded; + } + + public function ran(string $substring): bool + { + foreach ($this->recorded as $command) { + if (str_contains($command, $substring)) { + return true; + } + } + + return false; + } + + /** @return array */ + public function files(): array + { + return $this->files; + } + + /** @return array */ + public function connectionsWith(string $type): array + { + return array_values(array_filter($this->connections, fn ($c) => $c[0] === $type)); + } +} diff --git a/app/Services/Ssh/PhpseclibRemoteShell.php b/app/Services/Ssh/PhpseclibRemoteShell.php new file mode 100644 index 0000000..9ab4b72 --- /dev/null +++ b/app/Services/Ssh/PhpseclibRemoteShell.php @@ -0,0 +1,77 @@ +login($user, $password)) { + throw new RuntimeException("SSH password login failed for {$user}@{$host}"); + } + + $this->ssh = $ssh; + } + + public function connectWithKey(string $host, string $user, string $privateKey): void + { + $key = PublicKeyLoader::load($privateKey); + $ssh = new SSH2($host); + + if (! $ssh->login($user, $key)) { + throw new RuntimeException("SSH key login failed for {$user}@{$host}"); + } + + $this->ssh = $ssh; + } + + public function run(string $command): CommandResult + { + $stdout = (string) $this->ssh()->exec($command); + $exit = $this->ssh()->getExitStatus(); + + return new CommandResult(is_int($exit) ? $exit : 0, $stdout, ''); + } + + public function putFile(string $remotePath, string $contents): void + { + $b64 = base64_encode($contents); + $dir = escapeshellarg(dirname($remotePath)); + $path = escapeshellarg($remotePath); + + $result = $this->run("mkdir -p {$dir} && printf %s ".escapeshellarg($b64)." | base64 -d > {$path}"); + + if (! $result->ok()) { + throw new RuntimeException("Failed to write remote file {$remotePath}"); + } + } + + public function hostKeyFingerprint(): string + { + $key = $this->ssh()->getServerPublicHostKey(); + + return $key === false ? '' : 'SHA256:'.base64_encode(hash('sha256', (string) $key, true)); + } + + private function ssh(): SSH2 + { + if ($this->ssh === null) { + throw new RuntimeException('RemoteShell is not connected.'); + } + + return $this->ssh; + } +} diff --git a/app/Services/Ssh/RemoteShell.php b/app/Services/Ssh/RemoteShell.php new file mode 100644 index 0000000..e78eceb --- /dev/null +++ b/app/Services/Ssh/RemoteShell.php @@ -0,0 +1,21 @@ + pubkey => ip */ + private array $peers = []; + + private int $next = 2; + + public string $endpointValue = 'vpn.clupilot.test:51820'; + + public string $publicKeyValue = 'HUBPUBLICKEY0000000000000000000000000000000='; + + public function allocateIp(): string + { + return '10.66.0.'.$this->next++; + } + + public function addPeer(string $publicKey, string $ip): void + { + $this->peers[$publicKey] = $ip; + } + + public function removePeer(string $publicKey): void + { + unset($this->peers[$publicKey]); + } + + public function endpoint(): string + { + return $this->endpointValue; + } + + public function publicKey(): string + { + return $this->publicKeyValue; + } + + /** @return array */ + public function peers(): array + { + return $this->peers; + } +} diff --git a/app/Services/Wireguard/LocalWireguardHub.php b/app/Services/Wireguard/LocalWireguardHub.php new file mode 100644 index 0000000..9021c94 --- /dev/null +++ b/app/Services/Wireguard/LocalWireguardHub.php @@ -0,0 +1,54 @@ +whereNotNull('wg_ip')->pluck('wg_ip')->all(); + $prefix = Str::beforeLast(config('provisioning.wireguard.subnet', '10.66.0.0/24'), '.'); + + for ($octet = 2; $octet <= 254; $octet++) { + $ip = "{$prefix}.{$octet}"; + if (! in_array($ip, $used, true)) { + return $ip; + } + } + + throw new RuntimeException('WireGuard management subnet exhausted.'); + } + + public function addPeer(string $publicKey, string $ip): void + { + Process::run(['wg', 'set', 'wg0', 'peer', $publicKey, 'allowed-ips', $ip.'/32'])->throw(); + Process::run('wg-quick save wg0'); + } + + public function removePeer(string $publicKey): void + { + Process::run(['wg', 'set', 'wg0', 'peer', $publicKey, 'remove'])->throw(); + Process::run('wg-quick save wg0'); + } + + public function endpoint(): string + { + return (string) config('provisioning.wireguard.endpoint'); + } + + public function publicKey(): string + { + return (string) config('provisioning.wireguard.hub_public_key'); + } +} diff --git a/app/Services/Wireguard/WireguardHub.php b/app/Services/Wireguard/WireguardHub.php new file mode 100644 index 0000000..f9289e5 --- /dev/null +++ b/app/Services/Wireguard/WireguardHub.php @@ -0,0 +1,23 @@ +script('dpkg -l proxmox-ve', CommandResult::success('ii proxmox-ve 8.2')); + $shell->connectWithPassword('1.2.3.4', 'root', 'pw'); + + expect($shell->run('dpkg -l proxmox-ve')->stdout)->toContain('proxmox-ve') + ->and($shell->run('whoami')->ok())->toBeTrue() // default success + ->and($shell->ran('dpkg -l proxmox-ve'))->toBeTrue(); + + $shell->putFile('/etc/wireguard/wg0.conf', '[Interface]'); + expect($shell->files()['/etc/wireguard/wg0.conf'])->toBe('[Interface]') + ->and($shell->connectionsWith('password'))->toHaveCount(1); +}); + +it('allocates ips and tracks peers (FakeWireguardHub)', function () { + $hub = new FakeWireguardHub; + + $a = $hub->allocateIp(); + $b = $hub->allocateIp(); + expect($a)->not->toBe($b); + + $hub->addPeer('PUBKEY', '10.66.0.9'); + expect($hub->peers())->toBe(['PUBKEY' => '10.66.0.9']); + + $hub->removePeer('PUBKEY'); + expect($hub->peers())->toBe([]); +}); + +it('reports capacity and mints a token for a host (FakeProxmoxClient)', function () { + $host = Host::factory()->create(['wg_ip' => '10.66.0.5']); + $client = (new FakeProxmoxClient)->forHost($host); + + expect($client->listNodes())->not->toBeEmpty() + ->and($client->nodeStatus('pve')['memory']['total'])->toBeGreaterThan(0) + ->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0); + + $token = $client->createUserAndToken('automation@pve', 'CluPilotAutomation'); + expect($token)->toHaveKeys(['token_id', 'secret']) + ->and($client->tokenCalls)->toBe(1); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 941e024..c56f3a2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -48,3 +48,22 @@ function something() { // .. } + +/** + * Bind fake provisioning services into the container and return them so a test + * can script/inspect SSH, WireGuard and Proxmox interactions. + * + * @return array{shell: \App\Services\Ssh\FakeRemoteShell, hub: \App\Services\Wireguard\FakeWireguardHub, pve: \App\Services\Proxmox\FakeProxmoxClient} + */ +function fakeServices(): array +{ + $shell = new \App\Services\Ssh\FakeRemoteShell; + $hub = new \App\Services\Wireguard\FakeWireguardHub; + $pve = new \App\Services\Proxmox\FakeProxmoxClient; + + app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); + app()->instance(\App\Services\Wireguard\WireguardHub::class, $hub); + app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); + + return ['shell' => $shell, 'hub' => $hub, 'pve' => $pve]; +}