Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/CloudInit.php
2025-11-30 11:57:22 +01:00

675 lines
22 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Illuminate\Support\Facades\Validator;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
class CloudInit extends Component
{
private const VALID_OS_TYPES = [
'other',
'wxp',
'w2k',
'w2k3',
'w2k8',
'wvista',
'win7',
'win8',
'win10',
'win11',
'l24',
'l26',
'solaris',
];
private const MANAGEMENT_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCBeSz45D52fvy4EKPUvI4Upq2t1Ae+MJxB4Lla17f7kHFmoDCJvHfXUCxNv+Tr7FtbCWCdZ2yj8jvS8TNuJ+dzUORbRMgJcokmMLj486RYT7luRHPi+kU5CCv+yIOTNl308wTNmY12rmj+9/81gJA+ipll6UlNTh4T//koSwtcu0mw8ElyV8vjz+s+2vTbPRP+TMPGsFATyJjcjvFvtISW/TT0MuUkbk/bLpv8iurMjFSORa2/lnnMfnznSEL3LQC7CrWvpiCV7mhPn4ZLR/MksvzNnjjKzJHw2u5b8NmiztCfrPUTE/LDNwXh6WtIgqXt7BcjRUT1lR+MMLkhb1lf rsa-key-20240918';
public string $ciUser = '';
public string $password = '';
public string $password_confirmation = '';
public string $dnsDomain = '';
public array $dnsServers = [];
public string $newDnsServer = '';
public array $sshKeys = [];
public string $newSshKey = '';
public bool $upgradePackages = false;
public array $networks = [];
public bool $isEditingPassword = false;
public string $passwordError = '';
public ?int $editingNetworkIndex = null;
public array $editingNetwork = [
'ip' => '',
'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'] ?? '';
$deleteOstype = false;
// 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)) {
$deleteOstype = true;
}
$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 if we need to clear ostype
if ($deleteOstype) {
$payload['delete'] = 'ostype';
}
if (empty($payload)) {
$this->notify('No changes to apply.', 'info');
return;
}
try {
$proxmox = new Proxmox;
$proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $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 . '/qemu/' . $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 . '/qemu/' . $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('urldecode', preg_split("/[\r\n]+/", $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 = [];
if (($network['dhcp'] ?? false) === true) {
$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 . '/qemu/' . $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;
}
$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;
}
$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,
]);
}
}