diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php index aaf7a951d..328030292 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php @@ -616,7 +616,8 @@ function config_to_xml($config, $vmclone=false) { if ($strDevType == 'file' || $strDevType == 'block') { $strSourceType = ($strDevType == 'file' ? 'file' : 'dev'); if (isset($disk['discard'])) $strDevUnmap = " discard=\"{$disk['discard']}\" "; else $strDevUnmap = " discard=\"ignore\" "; - $diskstr .= " + $strDevice = (isset($disk['deviceType']) && in_array($disk['deviceType'], ['disk', 'lun'])) ? $disk['deviceType'] : 'disk'; + $diskstr .= " @@ -1229,7 +1230,9 @@ function get_disk_stats($domain, $sort=true) { $arrDomain = $arrDomain->devices->disk; $ret = []; foreach ($arrDomain as $disk) { - if ($disk->attributes()->device != "disk") continue; + $diskDeviceType = $disk->attributes()->device->__toString(); + // Only process disk-type devices (disk, lun), skip cdrom and floppy + if (!in_array($diskDeviceType, ['disk', 'lun'])) continue; $tmp = libvirt_domain_get_block_info($dom, $disk->target->attributes()->dev); if ($tmp) { $tmp['bus'] = $disk->target->attributes()->bus->__toString(); @@ -1237,6 +1240,7 @@ function get_disk_stats($domain, $sort=true) { $tmp["discard"] = $disk->driver->attributes()->discard ?? "ignore"; $tmp["rotation"] = $disk->target->attributes()->rotation_rate ?? "0"; $tmp['serial'] = $disk->serial; + $tmp['deviceType'] = $diskDeviceType; // Libvirt reports 0 bytes for raw disk images that haven't been // written to yet so we just report the raw disk size for now @@ -1262,7 +1266,8 @@ function get_disk_stats($domain, $sort=true) { 'boot order' => $disk->boot->attributes()->order , 'rotation' => $disk->target->attributes()->rotation_rate ?? "0", 'serial' => $disk->serial, - 'discard' => $disk->driver->attributes()->discard ?? "ignore" + 'discard' => $disk->driver->attributes()->discard ?? "ignore", + 'deviceType' => $diskDeviceType ]; } } @@ -1770,12 +1775,24 @@ function domain_delete($domain) { $tmp = $this->domain_undefine($dom); if (!$tmp) return $this->_set_last_error(); // remove the first disk only - if (array_key_exists('file', $disks[0])) { + $dir = ''; + if (!empty($disks) && array_key_exists('file', $disks[0])) { $disk = $disks[0]['file']; $pathinfo = pathinfo($disk); $dir = $pathinfo['dirname']; + } elseif (is_dir("/mnt/user/domains/$domain")) { + $dir = "/mnt/user/domains/$domain"; + } elseif (is_dir("/mnt/cache/domains/$domain")) { + $dir = "/mnt/cache/domains/$domain"; + } + + if ($dir) { // remove the vm config $cfg_vm = $dir.'/'.$domain.'.cfg'; + if (is_file($dir.'/cloud-init.img')) unlink($dir.'/cloud-init.img'); + if (is_file($dir.'/cloud-init.json')) unlink($dir.'/cloud-init.json'); + if (is_file($dir.'/cloud-init.user-data')) unlink($dir.'/cloud-init.user-data'); + if (is_file($dir.'/cloud-init.network-config')) unlink($dir.'/cloud-init.network-config'); if (is_file($cfg_vm)) unlink($cfg_vm); $cfg = $dir.'/'.$pathinfo['filename'].'.cfg'; $xml = $dir.'/'.$pathinfo['filename'].'.xml'; diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index 3a6a06fd0..e3a46dc0e 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -10,6 +10,209 @@ * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. */ + +function my_yaml_encode($data, $indent = 0) { + if (!is_array($data)) { + return json_encode($data); + } + + $out = ""; + $prefix = str_repeat(" ", $indent); + $indexed = array_keys($data) === range(0, count($data) - 1); + + foreach ($data as $key => $val) { + if ($indexed) { + $out .= $prefix . "- "; + if (is_array($val)) { + $sub = my_yaml_encode($val, $indent + 1); + $out .= ltrim($sub) . "\n"; + } else { + $out .= my_yaml_encode($val) . "\n"; + } + } else { + $out .= $prefix . $key . ": "; + if (is_array($val)) { + if (empty($val)) { + $out .= "[]\n"; + } else { + $out .= "\n" . my_yaml_encode($val, $indent + 1) . "\n"; + } + } else { + $out .= my_yaml_encode($val) . "\n"; + } + } + } + return rtrim($out); +} + +function generate_cloud_init_userdata($cloudInitData) { + if (empty($cloudInitData) || ($cloudInitData['mode'] ?? 'basic') !== 'basic') { + return $cloudInitData['userdata'] ?? ''; + } + + $hostname = $cloudInitData['hostname'] ?? ''; + $timezone = $cloudInitData['timezone'] ?? ''; + $user = $cloudInitData['user'] ?? 'root'; + $pass = $cloudInitData['password'] ?? ''; + $keys = $cloudInitData['ssh_keys'] ?? ''; + $root_login = $cloudInitData['root_login'] ?? 0; + $update_pkg = $cloudInitData['update_pkg'] ?? 0; + $packages = $cloudInitData['packages'] ?? ''; + $runcmd = $cloudInitData['runcmd'] ?? ''; + + $config = []; + + // Hostname & Timezone + if (!empty($hostname)) { + $config['hostname'] = $hostname; + $config['fqdn'] = $hostname; + } + if (!empty($timezone)) $config['timezone'] = $timezone; + + // Packages + if ($update_pkg) { + $config['package_update'] = true; + $config['package_upgrade'] = true; + } + if (!empty($packages)) { + $arrPkg = array_filter(array_map('trim', explode("\n", $packages))); + if (!empty($arrPkg)) { + $config['packages'] = array_values($arrPkg); + } + } + + // Users + $userConfig = ['name' => $user]; + if (!empty($keys)) { + $arrKeys = array_filter(array_map('trim', explode("\n", $keys))); + if (!empty($arrKeys)) { + $userConfig['ssh-authorized-keys'] = array_values($arrKeys); + } + } + if (!empty($pass)) { + $userConfig['plain_text_passwd'] = $pass; + $userConfig['lock_passwd'] = false; + } + if ($user != 'root') { + $userConfig['sudo'] = 'ALL=(ALL) NOPASSWD:ALL'; + $userConfig['shell'] = '/bin/bash'; + } + $config['users'] = [$userConfig]; + + // General SSH/Auth + $config['chpasswd'] = ['expire' => false]; + $config['ssh_pwauth'] = true; + if ($root_login) { + $config['disable_root'] = false; + } + + // RunCMD + if (!empty($runcmd)) { + $arrCmd = array_filter(array_map('trim', explode("\n", $runcmd))); + if (!empty($arrCmd)) { + $config['runcmd'] = array_values($arrCmd); + } + } + + return "#cloud-config\n" . my_yaml_encode($config); +} + +function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig, $customISOPath = null) { + $strPath = rtrim($strPath, '/'); + if (!is_dir($strPath)) { + return false; + } + + if (!empty($customISOPath)) { + $strImgPath = $customISOPath; + // If custom path is a directory (ends in / or is existing dir), append filename + if (substr($strImgPath, -1) === '/' || is_dir($strImgPath)) { + $strImgPath = rtrim($strImgPath, '/') . '/cloud-init.img'; + } + } else { + $strImgPath = $strPath . '/cloud-init.img'; + } + + // Create directory for custom path if needed + $dir = dirname($strImgPath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $strMountPoint = $strPath . '/cloud-init-mount'; + + // Create blank 4MB image + $output = []; + exec("dd if=/dev/zero of=" . escapeshellarg($strImgPath) . " bs=1M count=4 2>&1", $output, $return_var); + if ($return_var !== 0) { + error_log("Cloud-Init image creation failed (dd): " . implode("\n", $output)); + return false; + } + + // Format as VFAT with label 'cidata' (try mkfs.vfat first, then mkdosfs) + $output = []; + exec("which mkfs.vfat mkdosfs 2>/dev/null | head -n1", $mkfsTool); + $mkfsCmd = !empty($mkfsTool[0]) ? $mkfsTool[0] : '/sbin/mkdosfs'; + + exec($mkfsCmd . " -n cidata " . escapeshellarg($strImgPath) . " 2>&1", $output, $return_var); + if ($return_var !== 0) { + error_log("Cloud-Init image formatting failed (vfat): " . implode("\n", $output)); + return false; + } + + // Create mount point + if (!is_dir($strMountPoint)) { + mkdir($strMountPoint, 0777, true); + } + + // Mount image + $output = []; + exec("mount -o loop " . escapeshellarg($strImgPath) . " " . escapeshellarg($strMountPoint) . " 2>&1", $output, $return_var); + if ($return_var !== 0) { + error_log("Cloud-Init image mount failed: " . implode("\n", $output)); + return false; + } + + // Write files + if (file_put_contents($strMountPoint . '/user-data', $strUserData) === false) { + error_log("Cloud-Init write failed: " . $strMountPoint . '/user-data'); + exec("umount " . escapeshellarg($strMountPoint)); + rmdir($strMountPoint); + return false; + } + + if (!empty($strNetworkConfig)) { + if (file_put_contents($strMountPoint . '/network-config', $strNetworkConfig) === false) { + error_log("Cloud-Init write failed: " . $strMountPoint . '/network-config'); + exec("umount " . escapeshellarg($strMountPoint)); + rmdir($strMountPoint); + return false; + } + } + + if (file_put_contents($strMountPoint . '/meta-data', "instance-id: " . uniqid() . "\nlocal-hostname: localhost\n") === false) { + error_log("Cloud-Init write failed: " . $strMountPoint . '/meta-data'); + exec("umount " . escapeshellarg($strMountPoint)); + rmdir($strMountPoint); + return false; + } + + // Sync and Unmount + exec("sync"); + $output = []; + exec("umount " . escapeshellarg($strMountPoint) . " 2>&1", $output, $return_var); + + if ($return_var !== 0) { + error_log("Cloud-Init image unmount failed: " . implode("\n", $output)); + return false; + } + + // Clean up mount point + rmdir($strMountPoint); + + return $strImgPath; +} + ?> $disk['boot order'], 'rotation' => $disk['rotation'], 'serial' => $disk['serial'], - 'select' => $default_option + 'select' => $default_option, + 'deviceType' => $disk['deviceType'] ?? 'disk' ]; } if (empty($arrDisks)) { diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 2b4db8e26..5d81d699e 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -131,6 +131,21 @@ 'target' => '', 'mode' => '' ] + ], + 'cloudinit' => [ + 'enabled' => 0, + 'mode' => 'basic', + 'user' => 'ubuntu', + 'password' => '', + 'ssh_keys' => '', + 'hostname' => '', + 'timezone' => '', + 'root_login' => 0, + 'update_pkg' => 1, + 'packages' => '', + 'runcmd' => '', + 'userdata' => "#cloud-config\n", + 'networkconfig' => "version: 2\n" ] ]; $hdrXML = "\n"; // XML encoding declaration @@ -152,8 +167,84 @@ $reply = ['error' => $lv->get_last_error()]; } } else { + // form view #file_put_contents("/tmp/createpost",json_encode($_POST)); + + // Cloud-Init Processing + if (isset($_POST['cloudinit']['enabled']) && $_POST['cloudinit']['enabled'] == 1) { + $strVMPath = $domain_cfg['DOMAINDIR'] . $_POST['domain']['name']; + + if (!empty($strVMPath)) { + if (!is_dir($strVMPath)) { + mkdir($strVMPath, 0755, true); + } + + $cloudInitMode = $_POST['cloudinit']['mode'] ?? 'basic'; + $strUserData = ''; + $strNetworkConfig = $_POST['cloudinit']['networkconfig'] ?? ''; + + // Save Config + $arrCloudInitConfig = [ + 'enabled' => 1, + 'mode' => $cloudInitMode, + 'hostname' => $_POST['cloudinit']['hostname'] ?? '', + 'timezone' => $_POST['cloudinit']['timezone'] ?? '', + 'user' => $_POST['cloudinit']['user'] ?? '', + 'password' => $_POST['cloudinit']['password'] ?? '', + 'ssh_keys' => $_POST['cloudinit']['ssh_keys'] ?? '', + 'root_login' => $_POST['cloudinit']['root_login'] ?? 0, + 'update_pkg' => $_POST['cloudinit']['update_pkg'] ?? 0, + 'packages' => $_POST['cloudinit']['packages'] ?? '', + 'runcmd' => $_POST['cloudinit']['runcmd'] ?? '', + 'userdata' => $_POST['cloudinit']['userdata'] ?? '', + 'userdata' => $_POST['cloudinit']['userdata'] ?? '', + 'networkconfig' => $_POST['cloudinit']['networkconfig'] ?? '', + 'iso_path' => $_POST['cloudinit']['iso_path'] ?? '' + ]; + file_put_contents($strVMPath . '/cloud-init.json', json_encode($arrCloudInitConfig, JSON_PRETTY_PRINT)); + + if ($cloudInitMode == 'basic') { + $strUserData = generate_cloud_init_userdata($_POST['cloudinit']); + } else { + $strUserData = $_POST['cloudinit']['userdata'] ?? ''; + } + + // Save source files (generated or raw) + file_put_contents($strVMPath . '/cloud-init.user-data', $strUserData); + file_put_contents($strVMPath . '/cloud-init.network-config', $strNetworkConfig); + + $customISOPath = $_POST['cloudinit']['iso_path'] ?? null; + if ($customISOPath) { + // Security: Enforce path to be under /mnt/ + if (strpos($customISOPath, '/mnt/') !== 0 || preg_match('~(^|/)\.\.(?:/|$)~', $customISOPath)) { + echo json_encode(['error' => _('Cloud-Init ISO path must be within /mnt/')]); + exit; + } + // Security: Basic injection check (though escapeshellarg is used later) + if (preg_match('/[;&|`$]/', $customISOPath)) { + echo json_encode(['error' => _('Invalid characters in Cloud-Init ISO path')]); + exit; + } + } + $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig, $customISOPath); + + if (!$isoPath) { + echo json_encode(['error' => _('Failed to create Cloud-Init ISO')]); + exit; + } + + // Add Cloud-Init as disk + $newDiskIndex = count($_POST['disk'] ?? []); + $_POST['disk'][$newDiskIndex] = [ + 'deviceType' => 'disk', + 'driver' => 'raw', + 'image' => $isoPath, + 'bus' => 'virtio' + ]; + } + } + if ($lv->domain_new($_POST)) { // Fire off the vnc/spice popup if available $dom = $lv->get_domain_by_name($_POST['domain']['name']); @@ -246,6 +337,100 @@ } $newuuid = $uuid; $olduuid = $uuid; + + // Cloud-Init Processing - runs for both XML view and form view + if (isset($_POST['cloudinit']['enabled']) && $_POST['cloudinit']['enabled'] == 1) { + $strVMPath = $domain_cfg['DOMAINDIR'] . $_POST['domain']['name']; + + if (!empty($strVMPath)) { + if (!is_dir($strVMPath)) { + mkdir($strVMPath, 0777, true); + } + + $cloudInitMode = $_POST['cloudinit']['mode'] ?? 'basic'; + $strUserData = ''; + $strNetworkConfig = $_POST['cloudinit']['networkconfig'] ?? ''; + + // Save Config + $arrCloudInitConfig = [ + 'enabled' => 1, + 'mode' => $cloudInitMode, + 'hostname' => $_POST['cloudinit']['hostname'] ?? '', + 'timezone' => $_POST['cloudinit']['timezone'] ?? '', + 'user' => $_POST['cloudinit']['user'] ?? '', + 'password' => $_POST['cloudinit']['password'] ?? '', + 'ssh_keys' => $_POST['cloudinit']['ssh_keys'] ?? '', + 'root_login' => $_POST['cloudinit']['root_login'] ?? 0, + 'update_pkg' => $_POST['cloudinit']['update_pkg'] ?? 0, + 'packages' => $_POST['cloudinit']['packages'] ?? '', + 'runcmd' => $_POST['cloudinit']['runcmd'] ?? '', + 'userdata' => $_POST['cloudinit']['userdata'] ?? '', + 'userdata' => $_POST['cloudinit']['userdata'] ?? '', + 'networkconfig' => $_POST['cloudinit']['networkconfig'] ?? '', + 'iso_path' => $_POST['cloudinit']['iso_path'] ?? '' + ]; + file_put_contents($strVMPath . '/cloud-init.json', json_encode($arrCloudInitConfig, JSON_PRETTY_PRINT)); + + if ($cloudInitMode == 'basic') { + $strUserData = generate_cloud_init_userdata($_POST['cloudinit']); + } else { + $strUserData = $_POST['cloudinit']['userdata'] ?? ''; + } + + // Save source files (generated or raw) + file_put_contents($strVMPath . '/cloud-init.user-data', $strUserData); + file_put_contents($strVMPath . '/cloud-init.network-config', $strNetworkConfig); + + // Generate Cloud-Init disk image + $customISOPath = $_POST['cloudinit']['iso_path'] ?? null; + if ($customISOPath) { + // Security: Enforce path to be under /mnt/ + if (strpos($customISOPath, '/mnt/') !== 0 || preg_match('~(^|/)\.\.(?:/|$)~', $customISOPath)) { + echo json_encode(['error' => _('Cloud-Init ISO path must be within /mnt/')]); + exit; + } + // Security: Basic injection check (though escapeshellarg is used later) + if (preg_match('/[;&|`$]/', $customISOPath)) { + echo json_encode(['error' => _('Invalid characters in Cloud-Init ISO path')]); + exit; + } + } + $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig, $customISOPath); + + if (!$isoPath) { + echo json_encode(['error' => _('Failed to create Cloud-Init ISO')]); + exit; + } + + if ($isoPath) { + $found = false; + if (isset($_POST['disk']) && is_array($_POST['disk'])) { + foreach ($_POST['disk'] as $key => &$disk) { + if (isset($disk['image'])) { + // Check for exact match OR if it looks like a cloud-init disk (to replace old path) + $basename = basename($disk['image']); + if ($disk['image'] === $isoPath || $basename === 'cloud-init.img' || $basename === 'cloud-init.iso') { + $disk['image'] = $isoPath; // Update logic: replace with new path + $found = true; + break; + } + } + } + } + + if (!$found) { + $newDiskIndex = count($_POST['disk'] ?? []); + $_POST['disk'][$newDiskIndex] = [ + 'deviceType' => 'disk', + 'driver' => 'raw', + 'image' => $isoPath, + 'bus' => 'virtio' + ]; + } + } + } + } + // construct updated config if (isset($_POST['xmldesc'])) { // XML view @@ -301,6 +486,32 @@ $strXML = $lv->domain_get_xml($dom); $boolNew = false; $arrConfig = array_replace_recursive($arrConfigDefaults, domain_to_config($uuid)); + + // Load Cloud-Init Config if exists + $strVMName = $arrConfig['domain']['name']; + if (!empty($strVMName)) { + if (empty($domain_cfg)) $domain_cfg = parse_ini_file("/boot/config/domain.cfg"); + $strVMPath = $domain_cfg['DOMAINDIR'] . $strVMName; + + // Load JSON config first for UI fields + if (file_exists($strVMPath . '/cloud-init.json')) { + $json_content = file_get_contents($strVMPath . '/cloud-init.json'); + $arrCloudInitConfig = json_decode($json_content, true); + if ($arrCloudInitConfig) { + $arrConfig['cloudinit'] = array_merge($arrConfig['cloudinit'], $arrCloudInitConfig); + } + } elseif (file_exists($strVMPath . '/cloud-init.user-data')) { + // Fallback for legacy manually implemented or raw edits + $arrConfig['cloudinit']['enabled'] = 1; + $arrConfig['cloudinit']['mode'] = 'advanced'; + $arrConfig['cloudinit']['userdata'] = file_get_contents($strVMPath . '/cloud-init.user-data'); + } + + if (file_exists($strVMPath . '/cloud-init.network-config')) { + $arrConfig['cloudinit']['networkconfig'] = file_get_contents($strVMPath . '/cloud-init.network-config'); + } + } + $arrVMUSBs = getVMUSBs($strXML); } else { // edit new VM @@ -887,6 +1098,146 @@ +
+ + + + + +
_(Cloud-Init)_: + +
+
+ + + + + +
_(Configuration Mode)_: + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
_(Hostname)_: + +
_(Timezone)_: + +
_(Default User)_: + +
_(Password)_: + +
_(SSH Authorized Keys)_: + +
_(Allow Root Login)_: + +
_(Update Packages)_: + +
_(Install Packages)_: + +
_(Run Commands)_: + +
+
+ +
+ + + + + +
_(User Data)_: + +
+
+ + + + + + +
_(Network Config)_: + +
+ + + + + +
_(ISO Path)_: + +
+
+

Configure Cloud-Init for the VM. Basic mode creates a default user with password and keys. Advanced mode allows full control over user-data YAML.

+
+
+
+ + + $arrDisk) { $strLabel = ($i > 0) ? appendOrdinalSuffix($i + 1) : _('Primary'); ?> @@ -964,6 +1315,7 @@ + _(vDisk Size)_: @@ -2984,7 +3336,7 @@ function resetForm() { if (audio && !sound.includes(audio)) form.append(''); }); - var postdata = form.find('input,select,textarea[name="qemucmdline"]').serialize().replace(/'/g,"%27"); + var postdata = form.find('input,select,textarea').not('[name="xmldesc"]').serialize().replace(/'/g,"%27"); // keep checkbox visually unchecked form.find('input[name="usb[]"],input[name="usbopt[]"],input[name="pci[]"]').each(function(){ @@ -3053,7 +3405,7 @@ function resetForm() { if (audio && !sound.includes(audio)) form.append(''); }); - var postdata = form.find('input,select,textarea[name="qemucmdline"]').serialize().replace(/'/g,"%27"); + var postdata = form.find('input,select,textarea').not('[name="xmldesc"]').serialize().replace(/'/g,"%27"); // keep checkbox visually unchecked form.find('input[name="usb[]"],input[name="usbopt[]"],input[name="pci[]"]').each(function(){