options = array_merge([ 'lxc_rootfs_storage' => '', 'lxc_bridge' => 'vmbr0', ], $options); } public function list_templates(): array { $templates = array_merge( $this->list_qemu_templates(), $this->list_lxc_templates() ); usort( $templates, static function (array $left, array $right): int { $type_compare = strcmp((string) $left['provisioningType'], (string) $right['provisioningType']); return $type_compare !== 0 ? $type_compare : strcasecmp((string) $left['name'], (string) $right['name']); } ); return $templates; } public function provision_instance(array $input): array { return $this->normalise_type((string) ($input['provisioning_type'] ?? 'qemu')) === 'lxc' ? $this->create_lxc_container($input) : $this->clone_qemu_vm($input); } public function start_instance(string $type, int $vm_id): void { $this->request($this->guest_path($type, $vm_id) . '/status/start', 'POST'); } public function stop_instance(string $type, int $vm_id): void { $this->request($this->guest_path($type, $vm_id) . '/status/stop', 'POST'); } public function delete_instance(string $type, int $vm_id): void { $this->request($this->guest_path($type, $vm_id), 'DELETE'); } public function get_status(string $type, int $vm_id): string { $data = $this->request($this->guest_path($type, $vm_id) . '/status/current', 'GET'); return is_array($data) && isset($data['status']) ? (string) $data['status'] : 'unknown'; } public function get_ip_addresses(string $type, int $vm_id): array { return $this->normalise_type($type) === 'lxc' ? $this->get_lxc_ip_addresses($vm_id) : $this->get_qemu_ip_addresses($vm_id); } /** * @return array> */ private function list_qemu_templates(): array { $data = $this->request("/nodes/{$this->options['node']}/qemu", 'GET'); $vms = is_array($data) ? $data : []; $templates = []; foreach ($vms as $vm) { if (!is_array($vm) || empty($vm['template']) || empty($vm['vmid'])) { continue; } $templates[] = [ 'provisioningType' => 'qemu', 'vmId' => (int) $vm['vmid'], 'templateRef' => '', 'storage' => '', 'name' => isset($vm['name']) && $vm['name'] !== '' ? (string) $vm['name'] : 'template-' . (int) $vm['vmid'], 'cpuCores' => max(1, (int) ($vm['cpus'] ?? 1)), 'memoryMb' => $this->bytes_to_mb((int) ($vm['maxmem'] ?? 0)), 'diskGb' => $this->bytes_to_gb((int) ($vm['maxdisk'] ?? 0)), 'status' => isset($vm['status']) ? (string) $vm['status'] : 'unknown', ]; } return $templates; } /** * @return array> */ private function list_lxc_templates(): array { try { $storages = $this->request("/nodes/{$this->options['node']}/storage", 'GET'); } catch (Throwable) { return []; } $storages = is_array($storages) ? $storages : []; $templates = []; foreach ($storages as $storage) { if (!is_array($storage) || empty($storage['storage'])) { continue; } if (isset($storage['enabled']) && (int) $storage['enabled'] !== 1) { continue; } $content = is_array($storage['content'] ?? null) ? implode(',', $storage['content']) : (string) ($storage['content'] ?? ''); if ($content !== '' && !str_contains(strtolower($content), 'vztmpl')) { continue; } $storage_id = (string) $storage['storage']; try { $items = $this->request( add_query_arg(['content' => 'vztmpl'], "/nodes/{$this->options['node']}/storage/" . rawurlencode($storage_id) . '/content'), 'GET' ); } catch (Throwable) { continue; } foreach (is_array($items) ? $items : [] as $item) { if (!is_array($item)) { continue; } $volid = (string) ($item['volid'] ?? $item['volume'] ?? ''); if ($volid === '' || (isset($item['content']) && (string) $item['content'] !== 'vztmpl')) { continue; } $templates[] = [ 'provisioningType' => 'lxc', 'vmId' => 0, 'templateRef' => $volid, 'storage' => $storage_id, 'name' => $this->template_name_from_volid($volid), 'cpuCores' => 1, 'memoryMb' => 1024, 'diskGb' => 8, 'status' => 'available', ]; } } return $templates; } /** * @param array $input * @return array{vm_id:int} */ private function clone_qemu_vm(array $input): array { $vm_id = (int) $this->request('/cluster/nextid', 'GET'); $template_id = (int) $input['template_vm_id']; $this->request("/nodes/{$this->options['node']}/qemu/{$template_id}/clone", 'POST', [ 'newid' => $vm_id, 'name' => (string) $input['name'], 'full' => 1, ]); $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/config", 'PUT', [ 'cores' => (int) $input['cpu_cores'], 'memory' => (int) $input['memory_mb'], 'tags' => $this->tags_string($input['tags'] ?? []), ]); return ['vm_id' => $vm_id]; } /** * @param array $input * @return array{vm_id:int} */ private function create_lxc_container(array $input): array { $rootfs_storage = $this->sanitize_proxmox_identifier((string) $this->options['lxc_rootfs_storage']); if ($rootfs_storage === '') { throw new RuntimeException('Missing LXC rootfs storage configuration.'); } $vm_id = (int) $this->request('/cluster/nextid', 'GET'); $body = [ 'vmid' => $vm_id, 'hostname' => $this->sanitize_hostname((string) $input['name']), 'ostemplate' => (string) $input['lxc_template_ref'], 'cores' => (int) $input['cpu_cores'], 'memory' => (int) $input['memory_mb'], 'rootfs' => $rootfs_storage . ':' . max(1, (int) $input['disk_gb']), 'unprivileged' => 1, 'tags' => $this->tags_string($input['tags'] ?? []), ]; $bridge = $this->sanitize_proxmox_identifier((string) $this->options['lxc_bridge']); if ($bridge !== '') { $body['net0'] = 'name=eth0,bridge=' . $bridge . ',ip=dhcp'; } $this->request("/nodes/{$this->options['node']}/lxc", 'POST', $body); return ['vm_id' => $vm_id]; } private function get_qemu_ip_addresses(int $vm_id): array { $data = $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/agent/network-get-interfaces", 'GET'); $interfaces = is_array($data) && isset($data['result']) && is_array($data['result']) ? $data['result'] : []; $ips = []; foreach ($interfaces as $interface) { if (!is_array($interface) || empty($interface['ip-addresses']) || !is_array($interface['ip-addresses'])) { continue; } foreach ($interface['ip-addresses'] as $address) { if (!is_array($address) || empty($address['ip-address'])) { continue; } $ip = (string) $address['ip-address']; if ($ip === '127.0.0.1' || $ip === '::1' || str_starts_with($ip, 'fe80:')) { continue; } $ips[] = $ip; } } return array_values(array_unique($ips)); } private function get_lxc_ip_addresses(int $vm_id): array { $data = $this->request("/nodes/{$this->options['node']}/lxc/{$vm_id}/interfaces", 'GET'); $interfaces = is_array($data) ? $data : []; $ips = []; foreach ($interfaces as $interface) { if (!is_array($interface)) { continue; } foreach (['inet', 'inet6'] as $key) { if (empty($interface[$key])) { continue; } $ip = preg_replace('/\/\d+$/', '', (string) $interface[$key]); if ($ip === null || $ip === '127.0.0.1' || $ip === '::1' || str_starts_with($ip, 'fe80:')) { continue; } $ips[] = $ip; } } return array_values(array_unique($ips)); } private function guest_path(string $type, int $vm_id): string { $guest_type = $this->normalise_type($type) === 'lxc' ? 'lxc' : 'qemu'; return "/nodes/{$this->options['node']}/{$guest_type}/{$vm_id}"; } private function normalise_type(string $type): string { return strtolower($type) === 'lxc' ? 'lxc' : 'qemu'; } /** * @param mixed $tags */ private function tags_string($tags): string { if (!is_array($tags)) { return ''; } $clean = []; foreach ($tags as $tag) { $tag = strtolower(sanitize_title((string) $tag)); if ($tag !== '') { $clean[] = substr($tag, 0, 40); } } return implode(';', array_values(array_unique($clean))); } private function sanitize_hostname(string $name): string { $hostname = strtolower(sanitize_title($name)); $hostname = preg_replace('/[^a-z0-9-]/', '-', $hostname); $hostname = trim((string) $hostname, '-'); return substr($hostname !== '' ? $hostname : 'spp-lxc', 0, 63); } private function sanitize_proxmox_identifier(string $value): string { $value = sanitize_text_field($value); $value = preg_replace('/[^A-Za-z0-9_.-]/', '', $value); return $value === null ? '' : $value; } private function template_name_from_volid(string $volid): string { $name = basename(str_replace('\\', '/', $volid)); $name = preg_replace('/\.(tar\.zst|tar\.xz|tar\.gz|tar)$/', '', $name); return $name !== null && $name !== '' ? $name : $volid; } private function bytes_to_mb(int $bytes): int { if ($bytes < 1) { return 1024; } return max(128, (int) ceil($bytes / 1048576)); } private function bytes_to_gb(int $bytes): int { if ($bytes < 1) { return 8; } return max(1, (int) ceil($bytes / 1073741824)); } /** * @param array $body * @return mixed */ private function request(string $path, string $method, array $body = []) { foreach (['base_url', 'token_id', 'token_secret', 'node'] as $key) { if ($this->options[$key] === '') { throw new RuntimeException('Missing Proxmox HTTP configuration.'); } } $url = trailingslashit($this->options['base_url']) . 'api2/json' . $path; $response = wp_remote_request($url, [ 'method' => $method, 'timeout' => 30, 'headers' => [ 'Authorization' => 'PVEAPIToken=' . $this->options['token_id'] . '=' . $this->options['token_secret'], ], 'body' => $body, ]); if (is_wp_error($response)) { throw new RuntimeException($response->get_error_message()); } $status = (int) wp_remote_retrieve_response_code($response); if ($status < 200 || $status >= 300) { throw new RuntimeException('Proxmox request failed with HTTP ' . $status); } $payload = json_decode(wp_remote_retrieve_body($response), true); return is_array($payload) && array_key_exists('data', $payload) ? $payload['data'] : null; } }