http !== null) { return $this->http; } $token = config('hosting.proxmox.token'); if (empty($token)) { throw new ProxmoxException('PROXMOX_TOKEN is not configured.'); } $verify = filter_var(config('hosting.proxmox.verify_ssl'), FILTER_VALIDATE_BOOLEAN); return $this->http = Http::baseUrl(rtrim(config('hosting.proxmox.url'), '/').'/api2/json') ->withHeaders([ 'Authorization' => str_starts_with($token, 'PVEAPIToken=') ? $token : 'PVEAPIToken='.$token, ]) ->acceptJson() ->timeout((int) config('hosting.proxmox.timeout', 120)) ->when(! $verify, fn (PendingRequest $request) => $request->withoutVerifying()); } public function getNextVmid(): int { $response = $this->request('GET', '/cluster/nextid'); return (int) $response['data']; } public function vmExists(int $vmid): bool { try { $this->getVMStatus($vmid); return true; } catch (ProxmoxException $e) { if (str_contains($e->getMessage(), 'does not exist') || $e->getCode() === 404) { return false; } throw $e; } } public function createVM( int $vmid, string $name, int $cpu, int $ramMb, int $diskGb, string $ipConfig, ?int $templateVmid = null, ): void { if ($this->vmExists($vmid)) { throw new ProxmoxException("VM {$vmid} already exists.", step: 'proxmox_create'); } $node = config('hosting.proxmox.node'); $storage = config('hosting.proxmox.storage'); $bridge = config('hosting.proxmox.bridge'); $templateVmid ??= config('hosting.proxmox.template_vmid') ? (int) config('hosting.proxmox.template_vmid') : null; Log::info('Proxmox: creating VM', compact('vmid', 'name', 'cpu', 'ramMb', 'diskGb')); if ($templateVmid) { $this->cloneFromTemplateVmid((int) $templateVmid, $vmid, $name); $this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb); $this->configureCloudInit($vmid, $ipConfig, $name); } else { $this->createVmFromScratch($vmid, $name, $cpu, $ramMb, $diskGb, $ipConfig, $node, $storage, $bridge); } } public function setCloudInitIps(int $vmid, string $ipconfig0, ?string $ipconfig1, string $name): void { $this->configureCloudInit($vmid, $ipconfig0, $name, $ipconfig1); } public function node(): string { return (string) config('hosting.proxmox.node'); } public function startVM(int $vmid): void { $this->powerAction($vmid, 'start'); } public function shutdownVM(int $vmid, int $timeout = 60): void { $this->powerAction($vmid, 'shutdown', ['timeout' => $timeout]); } public function stopVM(int $vmid): void { $this->powerAction($vmid, 'stop'); } public function rebootVM(int $vmid, int $timeout = 60): void { $this->powerAction($vmid, 'reboot', ['timeout' => $timeout]); } public function resetVM(int $vmid): void { $this->powerAction($vmid, 'reset'); } public function powerAction(int $vmid, string $action, array $params = []): void { $node = $this->node(); Log::info('Proxmox: power action', ['vmid' => $vmid, 'action' => $action]); $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/status/{$action}", $params); } public function getVmConfig(int $vmid): array { $node = $this->node(); $response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/config"); return $response['data'] ?? []; } /** * @return array */ public function listIsos(?string $storage = null): array { $storage ??= config('hosting.proxmox.iso_storage', 'local'); $node = $this->node(); $response = $this->request('GET', "/nodes/{$node}/storage/{$storage}/content", [ 'content' => 'iso', ]); $items = $response['data'] ?? []; return collect($items) ->filter(fn ($item) => is_array($item) && ($item['content'] ?? '') === 'iso') ->map(fn (array $item) => [ 'volid' => (string) ($item['volid'] ?? ''), 'label' => (string) ($item['volid'] ?? $item['name'] ?? 'unknown'), 'size' => (int) ($item['size'] ?? 0), ]) ->filter(fn (array $item) => $item['volid'] !== '') ->values() ->all(); } public function mountIso(int $vmid, string $isoVolid, ?string $device = null): void { $device ??= config('hosting.proxmox.iso_device', 'ide2'); $node = $this->node(); if (! str_contains($isoVolid, ':')) { $storage = config('hosting.proxmox.iso_storage', 'local'); $isoVolid = "{$storage}:iso/{$isoVolid}"; } Log::info('Proxmox: mounting ISO', ['vmid' => $vmid, 'iso' => $isoVolid, 'device' => $device]); $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [ $device => "{$isoVolid},media=cdrom", 'boot' => "order={$device};scsi0", ]); } public function unmountIso(int $vmid, ?string $device = null): void { $device ??= config('hosting.proxmox.iso_device', 'ide2'); $node = $this->node(); Log::info('Proxmox: unmounting ISO', ['vmid' => $vmid, 'device' => $device]); $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [ 'delete' => $device, 'boot' => 'order=scsi0', ]); } /** * @return array{port: string, ticket: string, cert: ?string, node: string, vmid: int} */ public function createVncProxy(int $vmid): array { $node = $this->node(); $response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/vncproxy", [ 'websocket' => 1, ]); $data = $response['data'] ?? []; return [ 'port' => (string) ($data['port'] ?? ''), 'ticket' => (string) ($data['ticket'] ?? ''), 'cert' => isset($data['cert']) ? (string) $data['cert'] : null, 'node' => $node, 'vmid' => $vmid, ]; } public function buildVncWebSocketUrl(int $vmid, string $port, string $ticket): string { $base = rtrim(config('hosting.proxmox.console_ws_url') ?: config('hosting.proxmox.url'), '/'); $parsed = parse_url($base); $scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'wss' : 'ws'; $host = $parsed['host'] ?? 'localhost'; $portNum = $parsed['port'] ?? (($parsed['scheme'] ?? 'https') === 'https' ? 8006 : 80); $node = $this->node(); return sprintf( '%s://%s:%s/api2/json/nodes/%s/qemu/%d/vncwebsocket?port=%s&vncticket=%s', $scheme, $host, $portNum, $node, $vmid, urlencode($port), urlencode($ticket), ); } public function normalizeLiveStatus(array $status): array { return [ 'status' => (string) ($status['status'] ?? 'unknown'), 'uptime' => (int) ($status['uptime'] ?? 0), 'cpu' => (float) ($status['cpu'] ?? 0), 'mem' => (int) ($status['mem'] ?? 0), 'maxmem' => (int) ($status['maxmem'] ?? 0), 'disk' => (int) ($status['disk'] ?? 0), 'maxdisk' => (int) ($status['maxdisk'] ?? 0), ]; } public function waitForTask(string $upid, int $maxSeconds = 600): void { $node = $this->node(); $deadline = time() + $maxSeconds; while (time() < $deadline) { $response = $this->request('GET', '/nodes/'.$node.'/tasks/'.rawurlencode($upid).'/status'); $status = $response['data']['status'] ?? ''; if ($status === 'stopped') { $exit = $response['data']['exitstatus'] ?? 'OK'; if ($exit !== 'OK') { throw new ProxmoxException('Proxmox task failed: '.$exit, step: 'proxmox_task'); } return; } sleep(2); } throw new ProxmoxException('Proxmox task timeout: '.$upid, step: 'proxmox_task'); } public function createSnapshot(int $vmid, string $name): void { $node = $this->node(); $response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot", [ 'snapname' => $name, ]); if (! empty($response['data'])) { $this->waitForTask($response['data']); } } public function rollbackSnapshot(int $vmid, string $name): void { $node = $this->node(); $response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}/rollback"); if (! empty($response['data'])) { $this->waitForTask($response['data']); } } public function deleteSnapshot(int $vmid, string $name): void { $node = $this->node(); $this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}"); } /** * @return array */ public function listSnapshots(int $vmid): array { $node = $this->node(); $response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/snapshot"); return collect($response['data'] ?? []) ->filter(fn ($item) => is_array($item)) ->map(fn (array $item) => [ 'name' => (string) ($item['name'] ?? ''), 'description' => (string) ($item['description'] ?? ''), ]) ->filter(fn ($item) => $item['name'] !== '') ->values() ->all(); } public function startBackup(int $vmid, string $storage): string { $node = $this->node(); $response = $this->request('POST', "/nodes/{$node}/vzdump", [ 'vmid' => $vmid, 'storage' => $storage, 'mode' => 'snapshot', 'compress' => 'zstd', ]); return (string) ($response['data'] ?? ''); } public function getFirewallRules(int $vmid): array { $node = $this->node(); $response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/firewall/rules"); return $response['data'] ?? []; } public function setFirewallRules(int $vmid, array $rules): void { $node = $this->node(); // Replace all rules: delete existing then add - simplified: enable firewall + pos rules via PUT pos $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1]); foreach ($rules as $pos => $rule) { $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/firewall/rules", array_merge($rule, ['pos' => $pos])); } } public function uploadIsoToStorage(string $storage, string $filename, string $localPath): void { $node = $this->node(); $client = $this->http(); $response = $client->attach( 'content', file_get_contents($localPath), $filename )->post("/nodes/{$node}/storage/{$storage}/upload", [ 'content' => 'iso', 'filename' => $filename, ]); if ($response->failed()) { throw new ProxmoxException('ISO upload failed: '.$response->body(), step: 'proxmox_upload'); } } public function deleteStorageFile(string $storage, string $volid): void { $node = $this->node(); $this->request('DELETE', "/nodes/{$node}/storage/{$storage}/content/".urlencode($volid)); } public function cloneFromTemplateVmid(int $templateVmid, int $newVmid, string $name): void { $node = $this->node(); $response = $this->request('POST', "/nodes/{$node}/qemu/{$templateVmid}/clone", [ 'newid' => $newVmid, 'name' => $name, 'full' => 1, 'target' => $node, ]); if (! empty($response['data'])) { $this->waitForTask($response['data']); } } public function deleteVM(int $vmid): void { if (! $this->vmExists($vmid)) { Log::warning('Proxmox: VM already absent, skipping delete', ['vmid' => $vmid]); return; } try { $status = $this->getVMStatus($vmid); if (($status['status'] ?? '') === 'running') { $this->stopVM($vmid); $this->waitUntilStopped($vmid); } } catch (ProxmoxException) { // continue with delete attempt } $node = config('hosting.proxmox.node'); Log::info('Proxmox: deleting VM', ['vmid' => $vmid]); $this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}"); } public function getVMStatus(int $vmid): array { $node = config('hosting.proxmox.node'); $response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/status/current"); return $response['data'] ?? []; } public function buildIpConfig(string $ip, ?string $gateway = null, ?int $cidr = null): string { $cidr ??= (int) config('hosting.network.cidr'); $gateway ??= config('hosting.network.gateway'); return "ip={$ip}/{$cidr},gw={$gateway}"; } /** * @param iterable $devices */ public function applyDevices(int $vmid, iterable $devices): void { $node = config('hosting.proxmox.node'); $storage = config('hosting.proxmox.storage'); $params = []; foreach ($devices as $device) { $config = $device->config ?? []; $slot = $device->slot; match ($device->type) { \App\Models\VmDevice::TYPE_DISK => $params[$slot ?? 'scsi1'] = sprintf( '%s:%d', $config['storage'] ?? $storage, (int) ($config['size_gb'] ?? 10), ), \App\Models\VmDevice::TYPE_NETWORK => $params[$slot ?? 'net1'] = sprintf( '%s,bridge=%s', $config['model'] ?? 'virtio', $config['bridge'] ?? config('hosting.proxmox.bridge'), ), \App\Models\VmDevice::TYPE_USB => $params[$slot ?? 'usb0'] = $config['host_id'] ?? 'host=0', \App\Models\VmDevice::TYPE_PCI => $params[$slot ?? 'hostpci0'] = $config['address'] ?? '', default => null, }; } $params = array_filter($params, fn ($v) => $v !== '' && $v !== null); if ($params === []) { return; } Log::info('Proxmox: applying VM devices', ['vmid' => $vmid, 'devices' => array_keys($params)]); $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params); } public function updateVmResources(int $vmid, int $cpu, int $ramMb, int $diskGb): void { $this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb); } private function resizeVmIfNeeded(int $vmid, int $cpu, int $ramMb, int $diskGb): void { $node = config('hosting.proxmox.node'); $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [ 'cores' => $cpu, 'memory' => $ramMb, ]); // Disk resize depends on storage layout; best-effort grow on scsi0 try { $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/resize", [ 'disk' => 'scsi0', 'size' => $diskGb.'G', ]); } catch (ProxmoxException $e) { Log::warning('Proxmox: disk resize skipped', ['vmid' => $vmid, 'error' => $e->getMessage()]); } } private function configureCloudInit(int $vmid, string $ipConfig, string $name, ?string $ipConfig1 = null): void { $node = config('hosting.proxmox.node'); $bridge = config('hosting.proxmox.bridge'); $params = [ 'name' => $name, 'ipconfig0' => $ipConfig, 'net0' => "virtio,bridge={$bridge}", ]; if ($ipConfig1) { $publicBridge = config('hosting.proxmox.public_bridge', 'vmbr1'); $params['ipconfig1'] = $ipConfig1; $params['net1'] = "virtio,bridge={$publicBridge}"; } $this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params); } private function createVmFromScratch( int $vmid, string $name, int $cpu, int $ramMb, int $diskGb, string $ipConfig, string $node, string $storage, string $bridge, ): void { $this->request('POST', "/nodes/{$node}/qemu", [ 'vmid' => $vmid, 'name' => $name, 'cores' => $cpu, 'memory' => $ramMb, 'ostype' => 'l26', 'scsihw' => 'virtio-scsi-pci', 'scsi0' => "{$storage}:{$diskGb}", 'ide2' => "{$storage}:cloudinit", 'boot' => 'order=scsi0', 'net0' => "virtio,bridge={$bridge}", 'ipconfig0' => $ipConfig, 'agent' => 1, ]); } private function waitUntilStopped(int $vmid, int $maxSeconds = 60): void { $deadline = time() + $maxSeconds; while (time() < $deadline) { $status = $this->getVMStatus($vmid); if (($status['status'] ?? '') !== 'running') { return; } sleep(2); } throw new ProxmoxException("Timeout waiting for VM {$vmid} to stop.", step: 'proxmox_stop'); } private function request(string $method, string $uri, array $data = []): array { try { $client = $this->http(); $response = match (strtoupper($method)) { 'GET' => $client->get($uri, $data), 'POST' => $client->asForm()->post($uri, $data), 'PUT' => $client->asForm()->put($uri, $data), 'DELETE' => $client->delete($uri, $data), default => throw new ProxmoxException("Unsupported HTTP method: {$method}"), }; if ($response->failed()) { $message = $response->json('errors') ?? $response->json('message') ?? $response->body(); throw new ProxmoxException( 'Proxmox API error: '.(is_string($message) ? $message : json_encode($message)), step: 'proxmox_api', context: ['uri' => $uri, 'status' => $response->status()], code: $response->status(), ); } return $response->json() ?? []; } catch (ConnectionException $e) { throw new ProxmoxException( 'Proxmox connection timeout or unreachable: '.$e->getMessage(), step: 'proxmox_connection', previous: $e, ); } catch (RequestException $e) { throw new ProxmoxException( 'Proxmox HTTP error: '.$e->getMessage(), step: 'proxmox_http', previous: $e, ); } } }