'', 'gw' => '', 'ip6' => '', 'gw6' => '', ]; public bool $showNetworkModal = false; public string $osType = ''; public array $osTypeOptions = [ ['value' => 'l26', 'label' => 'Modern Linux (Kernel 2.6 - 6.x)'], ['value' => 'l24', 'label' => 'Legacy Linux (Kernel 2.4)'], ['value' => 'solaris', 'label' => 'Solaris / OpenSolaris'], ['value' => 'other', 'label' => 'Generic / Other OS'], ['value' => 'win11', 'label' => 'Windows 11 / Server 2022'], ['value' => 'win10', 'label' => 'Windows 10 / Server 2016+'], ['value' => 'win8', 'label' => 'Windows 8 / Server 2012'], ['value' => 'win7', 'label' => 'Windows 7 / Server 2008 R2'], ['value' => 'w2k8', 'label' => 'Windows Server 2008'], ['value' => 'w2k3', 'label' => 'Windows Server 2003'], ['value' => 'w2k', 'label' => 'Windows 2000'], ['value' => 'wxp', 'label' => 'Windows XP'], ]; public array $original = []; protected array $rawConfig = []; public function mount(Server $server): void { parent::mount($server); $this->loadCloudInitConfig(false); } public function refreshCloudInit() { $this->loadCloudInitConfig(); $this->notify('Cloud-init data refreshed.', 'success'); } public function resetCloudInit() { $this->hydrateFromOriginal(); $this->notify('Changes reverted to the values currently stored on the server.', 'info'); } public function applyCloudInit() { $originalOsType = $this->original['osType'] ?? ''; $originalCiUser = $this->original['ciUser'] ?? ''; $deleteKeys = []; // If ostype is being cleared (empty) and was previously set, we need to delete it if ($this->osType === '' && $originalOsType !== '' && in_array($originalOsType, self::VALID_OS_TYPES, true)) { $deleteKeys[] = 'ostype'; } // If ciuser is being cleared (empty) and was previously set, we need to delete it if ($this->ciUser === '' && $originalCiUser !== '') { $deleteKeys[] = 'ciuser'; } $payload = [ 'ciuser' => $this->ciUser !== '' ? $this->ciUser : null, 'searchdomain' => trim($this->dnsDomain), // Allow empty string to clear the setting 'nameserver' => !empty($this->dnsServers) ? implode(' ', array_filter(array_map('trim', $this->dnsServers))) : '', // Allow empty string to clear the setting 'ciupgrade' => $this->upgradePackages ? 1 : 0, 'ostype' => in_array($this->osType, self::VALID_OS_TYPES, true) && $this->osType !== '' ? $this->osType : null, // Omit parameter if empty or not valid ]; $normalizedKeys = array_filter(array_map('trim', $this->sshKeys)); $encodedKeys = array_map('rawurlencode', $normalizedKeys); $encodedManagementKey = rawurlencode(self::MANAGEMENT_KEY); $allKeys = array_merge($encodedKeys, [$encodedManagementKey]); $payload['sshkeys'] = implode('%0A', $allKeys); foreach ($this->networks as $network) { if (!isset($network['key'])) { continue; } $built = $this->buildIpConfigString($network); if ($built !== null) { $payload[$network['key']] = $built; } } // Filter out null values, but keep empty strings (for clearing searchdomain and nameserver) $payload = array_filter($payload, fn ($value) => $value !== null); // Add delete parameter for keys that need to be explicitly removed if (!empty($deleteKeys)) { $payload['delete'] = implode(',', $deleteKeys); } if (empty($payload)) { $this->notify('No changes to apply.', 'info'); return; } try { $proxmox = new Proxmox; $proxmox->request( '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/config', 'put', $payload ); $messageParts = ['Cloud-init settings saved.']; $messageLevel = 'success'; // Regenerate the cloud-init ISO so changes are picked up on next boot. try { $proxmox->request( '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/cloudinit/cmd', 'post', ['command' => 'generate'] ); $messageParts[] = 'Cloud-init ISO regenerated successfully.'; } catch (\Throwable $th) { if ((int) $th->getCode() === 501) { $messageParts[] = 'Please note that servers do not support automatic cloud-init regeneration, so please reboot your server to apply the changes.'; } else { $messageLevel = 'warning'; $messageParts[] = 'Cloud-init regeneration could not be confirmed (' . $th->getMessage() . ').'; } } // Reload config and update original to reflect new baseline $this->loadCloudInitConfig(true); $messageParts[] = 'Changes will apply the next time the VM boots and a cloud-init re-run is triggered.'; $this->notify(implode(' ', $messageParts), $messageLevel); } catch (\Exception $e) { $this->notify('Failed to save cloud-init settings: ' . $e->getMessage(), 'info'); } } protected function loadCloudInitConfig(bool $forceOriginal = true): void { try { $proxmox = new Proxmox; $response = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/config'); $config = $response->json()['data'] ?? []; $this->rawConfig = $config; $this->ciUser = $config['ciuser'] ?? ''; $this->dnsDomain = $config['searchdomain'] ?? ''; $this->dnsServers = $this->parseNameserverList($config['nameserver'] ?? ''); $this->sshKeys = $this->extractUserKeys($config['sshkeys'] ?? ''); $this->upgradePackages = (bool) ($config['ciupgrade'] ?? false); $this->osType = in_array($config['ostype'] ?? '', self::VALID_OS_TYPES, true) ? $config['ostype'] : ''; $this->networks = $this->extractNetworkConfigs($config); if ($forceOriginal || !isset($this->original['ciuser'])) { // Store original values as deep copies $this->original = [ 'ciuser' => (string)$this->ciUser, 'dnsDomain' => (string)$this->dnsDomain, 'dnsServers' => array_values(array_map('strval', $this->dnsServers)), 'sshKeys' => array_values(array_map('strval', $this->sshKeys)), 'upgradePackages' => (bool)$this->upgradePackages, 'osType' => (string)$this->osType, 'networks' => array_map(function($network) { return [ 'key' => $network['key'] ?? '', 'ip' => $network['ip'] ?? '', 'gw' => $network['gw'] ?? '', 'ip6' => $network['ip6'] ?? '', 'gw6' => $network['gw6'] ?? '', 'dhcp' => $network['dhcp'] ?? false, ]; }, $this->networks), ]; } } catch (\Exception $e) { $this->notify('Unable to load cloud-init data: ' . $e->getMessage(), 'error'); } } protected function hydrateFromOriginal(): void { $this->ciUser = $this->original['ciuser'] ?? ''; $this->dnsDomain = $this->original['dnsDomain'] ?? ''; $this->dnsServers = $this->original['dnsServers'] ?? []; $this->sshKeys = $this->original['sshKeys'] ?? []; $this->upgradePackages = $this->original['upgradePackages'] ?? false; $this->osType = $this->original['osType'] ?? ''; $this->networks = $this->original['networks'] ?? []; } protected function extractUserKeys(?string $rawKeys): array { if (empty($rawKeys)) { return []; } $keys = array_filter(array_map('trim', preg_split("/[\r\n]+/", urldecode($rawKeys)))); return array_values(array_filter($keys, function ($key) { $normalized = preg_replace('/\s+/', ' ', trim($key)); return $normalized !== self::MANAGEMENT_KEY; })); } protected function extractNetworkConfigs(array $config): array { $networks = []; // First, get all network devices (net0, net1, etc.) $networkDevices = []; foreach ($config as $key => $value) { if (is_string($key) && preg_match('/^net\d+$/', $key)) { // Extract the number from netX to create ipconfigX preg_match('/^net(\d+)$/', $key, $matches); if (isset($matches[1])) { $networkDevices[] = (int) $matches[1]; } } } // Sort network device numbers sort($networkDevices); // If no network devices found, default to net0 if (empty($networkDevices)) { $networkDevices = [0]; } // For each network device, create or find corresponding ipconfig foreach ($networkDevices as $deviceNum) { $ipconfigKey = 'ipconfig' . $deviceNum; $ipconfigValue = $config[$ipconfigKey] ?? null; $networks[] = array_merge( ['key' => $ipconfigKey], $this->parseIpConfigString(is_string($ipconfigValue) ? $ipconfigValue : '') ); } return $networks; } protected function parseIpConfigString(?string $value): array { $config = [ 'ip' => '', 'gw' => '', 'ip6' => '', 'gw6' => '', 'dhcp' => false, ]; if (!$value) { return $config; } $segments = array_filter(array_map('trim', explode(',', $value))); foreach ($segments as $segment) { if (!str_contains($segment, '=')) { continue; } [$key, $val] = array_map('trim', explode('=', $segment, 2)); $key = strtolower($key); if ($key === 'ip' && strtolower($val) === 'dhcp') { $config['dhcp'] = true; continue; } if (array_key_exists($key, $config)) { $config[$key] = $val; } } return $config; } protected function buildIpConfigString(array $network): ?string { $parts = []; $isDhcp = ($network['dhcp'] ?? false) === true; if ($isDhcp) { $parts[] = 'ip=dhcp'; } elseif (!empty($network['ip'])) { $parts[] = 'ip=' . trim($network['ip']); if (!empty($network['gw'])) { $parts[] = 'gw=' . trim($network['gw']); } } if (!empty($network['ip6'])) { $parts[] = 'ip6=' . trim($network['ip6']); if (!empty($network['gw6'])) { $parts[] = 'gw6=' . trim($network['gw6']); } } if (empty($parts)) { return null; } return implode(',', $parts); } protected function parseNameserverList(?string $value): array { if (!$value) { return []; } $entries = preg_split('/\s+/', trim($value)); return array_values(array_filter(array_map('trim', $entries))); } public function startEditingPassword(): void { $this->isEditingPassword = true; $this->password = ''; $this->password_confirmation = ''; $this->passwordError = ''; } public function cancelEditingPassword(): void { $this->isEditingPassword = false; $this->password = ''; $this->password_confirmation = ''; $this->passwordError = ''; } public function startEditingNetwork(int $index): void { if (!isset($this->networks[$index])) { return; } $this->editingNetworkIndex = $index; $this->editingNetwork = [ 'ip' => $this->networks[$index]['ip'] ?? '', 'gw' => $this->networks[$index]['gw'] ?? '', 'ip6' => $this->networks[$index]['ip6'] ?? '', 'gw6' => $this->networks[$index]['gw6'] ?? '', ]; $this->showNetworkModal = true; } public function cancelEditingNetwork(): void { $this->editingNetworkIndex = null; $this->editingNetwork = [ 'ip' => '', 'gw' => '', 'ip6' => '', 'gw6' => '', ]; $this->showNetworkModal = false; } public function saveNetwork(): void { if ($this->editingNetworkIndex === null || !isset($this->networks[$this->editingNetworkIndex])) { return; } $this->networks[$this->editingNetworkIndex]['ip'] = $this->editingNetwork['ip']; $this->networks[$this->editingNetworkIndex]['gw'] = $this->editingNetwork['gw']; $this->networks[$this->editingNetworkIndex]['ip6'] = $this->editingNetwork['ip6']; $this->networks[$this->editingNetworkIndex]['gw6'] = $this->editingNetwork['gw6']; // Preserve existing DHCP flag automatically (no UI control) $this->cancelEditingNetwork(); } protected function validatePasswordFields(): bool { $validator = Validator::make( [ 'password' => $this->password, 'password_confirmation' => $this->password_confirmation, ], [ 'password' => 'required|string|min:8|confirmed', ] ); if ($validator->fails()) { $this->passwordError = implode(' ', $validator->errors()->all()); return false; } $this->passwordError = ''; return true; } public function updatePassword(): void { if (!$this->validatePasswordFields()) { return; } try { $this->server->password = $this->password; $this->server->save(); $this->server->refresh(); $this->notify('Password saved to database successfully.', 'success'); $this->cancelEditingPassword(); } catch (\Exception $e) { $this->passwordError = 'Failed to save password: ' . $e->getMessage(); $this->notify('Failed to save password', 'error'); } } public function applyPassword(): void { if (!$this->validatePasswordFields()) { return; } try { $this->server->password = $this->password; $this->server->save(); $this->server->refresh(); $proxmox = new Proxmox; $proxmox->request( '/nodes/' . $this->server->node->name . '/' . $this->server->node->vmType() . '/' . $this->server->vm_id . '/config', 'put', [ 'cipassword' => $this->password, ] ); $this->notify('Password applied. Reboot the server for cloud-init to set the new password.', 'success'); $this->cancelEditingPassword(); } catch (\Exception $e) { $this->passwordError = 'Failed to apply password: ' . $e->getMessage(); $this->notify('Failed to apply password to Proxmox', 'error'); } } public function getHasChangesProperty(): bool { return $this->ciUser !== ($this->original['ciuser'] ?? '') || $this->dnsDomain !== ($this->original['dnsDomain'] ?? '') || $this->dnsServers !== ($this->original['dnsServers'] ?? []) || $this->sshKeys !== ($this->original['sshKeys'] ?? []) || (bool) $this->upgradePackages !== (bool) ($this->original['upgradePackages'] ?? false) || $this->osType !== ($this->original['osType'] ?? '') || $this->networks !== ($this->original['networks'] ?? []); } public function getAccessCredentialsSyncedProperty(): bool { // If original hasn't been loaded yet, consider synced if (!isset($this->original['ciuser'])) { return true; } $originalCiUser = (string)($this->original['ciuser'] ?? ''); $currentCiUser = (string)$this->ciUser; $ciUserMatch = $currentCiUser === $originalCiUser; $originalUpgrade = (bool)($this->original['upgradePackages'] ?? false); $currentUpgrade = (bool)$this->upgradePackages; $upgradeMatch = $currentUpgrade === $originalUpgrade; $originalSshKeys = isset($this->original['sshKeys']) && is_array($this->original['sshKeys']) ? $this->original['sshKeys'] : []; $currentSshKeys = is_array($this->sshKeys) ? $this->sshKeys : []; $sshKeysMatch = $this->arraysEqual($currentSshKeys, $originalSshKeys); return $ciUserMatch && $sshKeysMatch && $upgradeMatch; } public function getDnsSettingsSyncedProperty(): bool { // If original hasn't been loaded yet, consider synced if (!isset($this->original['dnsDomain'])) { return true; } $originalDnsDomain = (string)($this->original['dnsDomain'] ?? ''); $currentDnsDomain = (string)$this->dnsDomain; $dnsDomainMatch = $currentDnsDomain === $originalDnsDomain; $originalDnsServers = isset($this->original['dnsServers']) && is_array($this->original['dnsServers']) ? $this->original['dnsServers'] : []; $currentDnsServers = is_array($this->dnsServers) ? $this->dnsServers : []; $dnsServersMatch = $this->arraysEqual($currentDnsServers, $originalDnsServers); return $dnsDomainMatch && $dnsServersMatch; } public function getOsTypeSyncedProperty(): bool { // If original hasn't been loaded yet, consider synced if (!isset($this->original['osType'])) { return true; } $originalOsType = (string)($this->original['osType'] ?? ''); $currentOsType = (string)$this->osType; return $currentOsType === $originalOsType; } protected function arraysEqual(array $a, array $b): bool { // Normalize arrays by trimming strings $normalize = function(array $arr): array { $result = []; foreach ($arr as $item) { $normalized = is_string($item) ? trim($item) : trim((string)$item); if ($normalized !== '') { $result[] = $normalized; } } return $result; }; $a = $normalize($a); $b = $normalize($b); // Check count first if (count($a) !== count($b)) { return false; } // Sort both arrays for order-independent comparison sort($a); sort($b); // Compare element by element foreach ($a as $index => $value) { if (!isset($b[$index]) || $value !== $b[$index]) { return false; } } return true; } public function addSshKey(): void { $key = trim($this->newSshKey); if ($key === '') { return; } $validPrefixes = ['ssh-rsa ', 'ssh-ed25519 ', 'ecdsa-sha2-', 'ssh-dss ']; $hasValidPrefix = false; foreach ($validPrefixes as $prefix) { if (str_starts_with($key, $prefix)) { $hasValidPrefix = true; break; } } if (!$hasValidPrefix) { $this->notify('Invalid SSH key format. Key must start with ssh-rsa, ssh-ed25519, ecdsa-sha2, or ssh-dss.', 'error'); return; } $this->sshKeys[] = $key; $this->newSshKey = ''; } public function removeSshKey(int $index): void { if (!isset($this->sshKeys[$index])) { return; } unset($this->sshKeys[$index]); $this->sshKeys = array_values($this->sshKeys); } public function addDnsServer(): void { $entry = trim($this->newDnsServer); if ($entry === '') { return; } if (!filter_var($entry, FILTER_VALIDATE_IP)) { $this->notify('Please enter a valid IPv4 or IPv6 address.', 'error'); return; } $this->dnsServers[] = $entry; $this->newDnsServer = ''; } public function removeDnsServer(int $index): void { if (!isset($this->dnsServers[$index])) { return; } unset($this->dnsServers[$index]); $this->dnsServers = array_values($this->dnsServers); } public function quickAddDnsServers(string $provider): void { $servers = match($provider) { 'quad9' => ['9.9.9.9', '2620:fe::fe'], 'cloudflare' => ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111', '2606:4700:4700::1001'], 'google' => ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888', '2001:4860:4860::8844'], 'nat64' => ['2a01:4f8:c2c:123f::1', '2a00:1098:2b::1', '2a00:1098:2c::1'], default => [], }; foreach ($servers as $server) { $server = trim($server); if ($server !== '' && !in_array($server, $this->dnsServers, true)) { $this->dnsServers[] = $server; } } } public function render() { return view('proxmox::cloud-init')->layoutData([ 'sidebar' => true, ]); } }