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 nextVmid(): int { return (int) $this->http()->get('/cluster/nextid')->throw()->json('data'); } public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string { return (string) $this->http()->asForm() ->post("/nodes/{$node}/qemu/{$templateVmid}/clone", ['newid' => $newVmid, 'name' => $name, 'full' => 1]) ->throw()->json('data'); } public function setCloudInit(string $node, int $vmid, array $params): void { // PUT is the (synchronous) VM config-update operation in Proxmox. $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/config", $params)->throw(); } public function resizeDisk(string $node, int $vmid, string $disk, string $size): void { $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/resize", ['disk' => $disk, 'size' => $size])->throw(); } public function startVm(string $node, int $vmid): string { return (string) $this->http()->asForm() ->post("/nodes/{$node}/qemu/{$vmid}/status/start")->throw()->json('data'); } public function vmStatus(string $node, int $vmid): array { return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->throw()->json('data', []); } public function vmExists(string $node, int $vmid): bool { return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->successful(); } public function guestAgentPing(string $node, int $vmid): bool { return $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/agent/ping")->successful(); } public function guestExec(string $node, int $vmid, string $command): array { // The guest agent runs a program directly, not a shell — wrap compound // commands (cd/&&/pipes/redirects/env) in an explicit shell. Proxmox is // form-encoded and takes `command` as an argv list. $pid = $this->http()->asForm() ->post("/nodes/{$node}/qemu/{$vmid}/agent/exec", ['command' => ['/bin/sh', '-c', $command]]) ->throw()->json('data.pid'); // Poll exec-status until the command has exited. for ($i = 0; $i < 600; $i++) { $status = $this->http() ->get("/nodes/{$node}/qemu/{$vmid}/agent/exec-status", ['pid' => $pid]) ->throw()->json('data', []); if (($status['exited'] ?? 0)) { return [ 'exitcode' => (int) ($status['exitcode'] ?? 0), 'out-data' => (string) ($status['out-data'] ?? ''), ]; } usleep(500000); } return ['exitcode' => 255, 'out-data' => 'guest exec timed out']; } public function taskStatus(string $node, string $upid): array { return $this->http()->get("/nodes/{$node}/tasks/{$upid}/status")->throw()->json('data', []); } public function applyFirewall(string $node, int $vmid, array $rules): void { foreach ($rules as $rule) { $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/firewall/rules", $rule)->throw(); } // Default-deny inbound so only the explicit ACCEPT rules (80/443) are open. $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/firewall/options", [ 'enable' => 1, 'policy_in' => 'DROP', 'policy_out' => 'ACCEPT', ])->throw(); } public function createBackupJob(string $node, int $vmid, string $schedule): string { // Deterministic job id → creating it twice (crash/retry) is idempotent. $jobId = 'clupilot-'.$vmid; $response = $this->http()->asForm()->post('/cluster/backup', [ 'id' => $jobId, 'vmid' => $vmid, 'schedule' => $schedule, 'storage' => 'local', 'mode' => 'snapshot', 'enabled' => 1, ]); if ($response->failed() && ! str_contains($response->body(), 'already')) { $response->throw(); } return $jobId; } }