From f25f58fd2221aad462bd1569f2dc70028a5d42f4 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:18:11 +0200 Subject: [PATCH 01/13] feat(vm-manager): add Cloud-Init support for Unraid VMs Implement Cloud-Init configuration UI and backend for automatic VM provisioning on first boot. This enables zero-touch deployment of Linux VMs using cloud images. Features: - Add Cloud-Init section in VM Advanced View with enable toggle - Implement Basic mode with form-based configuration: - Identity: hostname, default user, password, SSH keys, root login - System: timezone - Packages: update/upgrade toggle, package installation list - Commands: arbitrary shell commands on boot - Implement Advanced mode for raw user-data and network-config YAML - Persist all settings to cloud-init.json alongside VM files - Generate VFAT disk image (cloud-init.img) with cidata label - Attach Cloud-Init disk as VirtIO device for optimal performance - Support device type override in libvirt XML generation Technical changes: - Custom.form.php: UI components and save/load logic for create/update - libvirt_helpers.php: create_cloud_init_iso() with VFAT generation - libvirt.php: Added device attribute passthrough for disk elements (required) Tested with: - Ubuntu 24.04.2 LTS Cloud Image (noble-server-cloudimg-amd64.img) Source: https://cloud-images.ubuntu.com/noble/current/ Date: 2026-01-08 - Verified: hostname, timezone, user creation, password auth, ssh key, package installation, and runcmd execution all applied correctly - Confirmed VFAT cidata label detected by Cloud-Init NoCloud datasource --- .../dynamix.vm.manager/include/libvirt.php | 3 +- .../include/libvirt_helpers.php | 62 +++ .../templates/Custom.form.php | 420 +++++++++++++++++- 3 files changed, 482 insertions(+), 3 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php index aaf7a951dd..2b99ce719d 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 = $disk['device'] ?? 'disk'; + $diskstr .= " diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index 3a6a06fd08..089112948c 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -10,6 +10,68 @@ * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. */ + +function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { + $strPath = rtrim($strPath, '/'); + if (!is_dir($strPath)) { + return false; + } + + $strImgPath = $strPath . '/cloud-init.img'; + $strMountPoint = $strPath . '/cloud-init-mount'; + + // Create blank 4MB image + 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 + 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 + file_put_contents($strMountPoint . '/user-data', $strUserData); + if (!empty($strNetworkConfig)) { + file_put_contents($strMountPoint . '/network-config', $strNetworkConfig); + } + file_put_contents($strMountPoint . '/meta-data', "instance-id: " . uniqid() . "\nlocal-hostname: localhost\n"); + + // Sync and Unmount + exec("sync"); + exec("umount " . escapeshellarg($strMountPoint) . " 2>&1", $output, $return_var); + + // Clean up mount point + rmdir($strMountPoint); + + if ($return_var !== 0) { + error_log("Cloud-Init image unmount failed: " . implode("\n", $output)); + return false; + } + + return $strImgPath; +} + ?> '', '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,132 @@ $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, 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'] ?? '', + 'networkconfig' => $_POST['cloudinit']['networkconfig'] ?? '' + ]; + file_put_contents($strVMPath . '/cloud-init.json', json_encode($arrCloudInitConfig, JSON_PRETTY_PRINT)); + + if ($cloudInitMode == 'basic') { + // Generate User Data from Basic Fields + $hostname = $_POST['cloudinit']['hostname'] ?? ''; + $timezone = $_POST['cloudinit']['timezone'] ?? ''; + $user = $_POST['cloudinit']['user'] ?? 'root'; + $pass = $_POST['cloudinit']['password'] ?? ''; + $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'] ?? ''; + + $strUserData = "#cloud-config\n"; + + // Hostname & Timezone + if (!empty($hostname)) { + $strUserData .= "hostname: " . $hostname . "\n"; + $strUserData .= "fqdn: " . $hostname . "\n"; + } + if (!empty($timezone)) $strUserData .= "timezone: " . $timezone . "\n"; + + // Packages + if ($update_pkg) { + $strUserData .= "package_update: true\n"; + $strUserData .= "package_upgrade: true\n"; + } + if (!empty($packages)) { + $strUserData .= "packages:\n"; + $arrPkg = explode("\n", $packages); + foreach ($arrPkg as $p) { + if (trim($p)) $strUserData .= " - " . trim($p) . "\n"; + } + } + + // Users + $strUserData .= "users:\n"; + $strUserData .= " - name: " . $user . "\n"; + if (!empty($keys)) { + $strUserData .= " ssh-authorized-keys:\n"; + $arrKeys = explode("\n", $keys); + foreach ($arrKeys as $k) { + if (trim($k)) $strUserData .= " - " . trim($k) . "\n"; + } + } + if (!empty($pass)) { + $strUserData .= " plain_text_passwd: " . $pass . "\n"; + $strUserData .= " lock_passwd: false\n"; + } + if ($user != 'root') { + $strUserData .= " sudo: ALL=(ALL) NOPASSWD:ALL\n"; + $strUserData .= " shell: /bin/bash\n"; + } + + // General SSH/Auth + $strUserData .= "chpasswd: { expire: False }\n"; + $strUserData .= "ssh_pwauth: True\n"; + if ($root_login) { + $strUserData .= "disable_root: false\n"; + } + + // RunCMD + if (!empty($runcmd)) { + $strUserData .= "runcmd:\n"; + $arrCmd = explode("\n", $runcmd); + foreach ($arrCmd as $c) { + if (trim($c)) $strUserData .= " - " . trim($c) . "\n"; + } + } + } 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); + + $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + + if ($isoPath) { + // Add Cloud-Init as disk + $newDiskIndex = count($_POST['disk'] ?? []); + $_POST['disk'][$newDiskIndex] = [ + 'device' => '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 +385,119 @@ } $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'] ?? '', + 'networkconfig' => $_POST['cloudinit']['networkconfig'] ?? '' + ]; + file_put_contents($strVMPath . '/cloud-init.json', json_encode($arrCloudInitConfig, JSON_PRETTY_PRINT)); + + if ($cloudInitMode == 'basic') { + // Generate User Data from Basic Fields + $hostname = $_POST['cloudinit']['hostname'] ?? ''; + $timezone = $_POST['cloudinit']['timezone'] ?? ''; + $user = $_POST['cloudinit']['user'] ?? 'root'; + $pass = $_POST['cloudinit']['password'] ?? ''; + $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'] ?? ''; + + $strUserData = "#cloud-config\n"; + + // Hostname & Timezone + if (!empty($hostname)) { + $strUserData .= "hostname: " . $hostname . "\n"; + $strUserData .= "fqdn: " . $hostname . "\n"; + } + if (!empty($timezone)) $strUserData .= "timezone: " . $timezone . "\n"; + + // Packages + if ($update_pkg) { + $strUserData .= "package_update: true\n"; + $strUserData .= "package_upgrade: true\n"; + } + if (!empty($packages)) { + $strUserData .= "packages:\n"; + $arrPkg = explode("\n", $packages); + foreach ($arrPkg as $p) { + if (trim($p)) $strUserData .= " - " . trim($p) . "\n"; + } + } + + // Users + $strUserData .= "users:\n"; + $strUserData .= " - name: " . $user . "\n"; + if (!empty($keys)) { + $strUserData .= " ssh-authorized-keys:\n"; + $arrKeys = explode("\n", $keys); + foreach ($arrKeys as $k) { + if (trim($k)) $strUserData .= " - " . trim($k) . "\n"; + } + } + if (!empty($pass)) { + $strUserData .= " plain_text_passwd: " . $pass . "\n"; + $strUserData .= " lock_passwd: false\n"; + } + if ($user != 'root') { + $strUserData .= " sudo: ALL=(ALL) NOPASSWD:ALL\n"; + $strUserData .= " shell: /bin/bash\n"; + } + + // General SSH/Auth + $strUserData .= "chpasswd: { expire: False }\n"; + $strUserData .= "ssh_pwauth: True\n"; + if ($root_login) { + $strUserData .= "disable_root: false\n"; + } + + // RunCMD + if (!empty($runcmd)) { + $strUserData .= "runcmd:\n"; + $arrCmd = explode("\n", $runcmd); + foreach ($arrCmd as $c) { + if (trim($c)) $strUserData .= " - " . trim($c) . "\n"; + } + } + } 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 + create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + } + } + // construct updated config if (isset($_POST['xmldesc'])) { // XML view @@ -301,6 +553,38 @@ $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; + + // Debug logging to specific file + // file_put_contents("/var/log/cloudinit_debug.log", "Loading config for $strVMName from $strVMPath\n", FILE_APPEND); + + // 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); + // file_put_contents("/var/log/cloudinit_debug.log", "Loaded JSON: " . print_r($arrCloudInitConfig, true) . "\n", FILE_APPEND); + } else { + // file_put_contents("/var/log/cloudinit_debug.log", "Failed to decode JSON: $json_content\n", FILE_APPEND); + } + } 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 +1171,138 @@ +
+ + + + + +
_(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)_: + +
+
+

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'); ?> @@ -2984,7 +3400,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').serialize().replace(/'/g,"%27"); // keep checkbox visually unchecked form.find('input[name="usb[]"],input[name="usbopt[]"],input[name="pci[]"]').each(function(){ @@ -3053,7 +3469,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').serialize().replace(/'/g,"%27"); // keep checkbox visually unchecked form.find('input[name="usb[]"],input[name="usbopt[]"],input[name="pci[]"]').each(function(){ From 13ca5188f4c9126c042e5a06743a4bd0ce841c5c Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:51:41 +0200 Subject: [PATCH 02/13] fix(vm-manager): interactive editor ignoring form changes Exclude xmldesc textarea from form view POST data serialization --- emhttp/plugins/dynamix.vm.manager/include/libvirt.php | 10 +++++++--- .../dynamix.vm.manager/include/libvirt_helpers.php | 3 ++- .../dynamix.vm.manager/templates/Custom.form.php | 7 ++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php index 2b99ce719d..025b3606be 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php @@ -616,7 +616,7 @@ 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\" "; - $strDevice = $disk['device'] ?? 'disk'; + $strDevice = $disk['deviceType'] ?? 'disk'; $diskstr .= " @@ -1230,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(); @@ -1238,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 @@ -1263,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 ]; } } diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index 089112948c..f1d0fc86de 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -1432,7 +1432,8 @@ function domain_to_config($uuid) { 'boot' => $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 95d23d5ad8..5dc680d9da 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -284,7 +284,7 @@ // Add Cloud-Init as disk $newDiskIndex = count($_POST['disk'] ?? []); $_POST['disk'][$newDiskIndex] = [ - 'device' => 'disk', + 'deviceType' => 'disk', 'driver' => 'raw', 'image' => $isoPath, 'bus' => 'virtio' @@ -1380,6 +1380,7 @@ function toggleCloudInitMode(mode) { + _(vDisk Size)_: @@ -3400,7 +3401,7 @@ function resetForm() { if (audio && !sound.includes(audio)) form.append(''); }); - var postdata = form.find('input,select,textarea').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(){ @@ -3469,7 +3470,7 @@ function resetForm() { if (audio && !sound.includes(audio)) form.append(''); }); - var postdata = form.find('input,select,textarea').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(){ From 179fb41ffffd55e3598981410c23a29f54e12df3 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:58:43 +0200 Subject: [PATCH 03/13] refactor(vm-manager): deduplicate cloud-init yaml generation Refactored the duplicate Cloud-Init YAML generation logic into a reusable helper function 'generate_cloud_init_userdata'. This updates both the VM creation and update paths to use the shared function. Addresses CodeRabbit feedback: https://github.com/unraid/webgui/pull/2536#discussion_r2738490820 --- .../include/libvirt_helpers.php | 75 ++++++++++ .../templates/Custom.form.php | 136 +----------------- 2 files changed, 77 insertions(+), 134 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index f1d0fc86de..5faa0b184c 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -11,6 +11,81 @@ * all copies or substantial portions of the Software. */ +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'] ?? ''; + + $strUserData = "#cloud-config\n"; + + // Hostname & Timezone + if (!empty($hostname)) { + $strUserData .= "hostname: " . $hostname . "\n"; + $strUserData .= "fqdn: " . $hostname . "\n"; + } + if (!empty($timezone)) $strUserData .= "timezone: " . $timezone . "\n"; + + // Packages + if ($update_pkg) { + $strUserData .= "package_update: true\n"; + $strUserData .= "package_upgrade: true\n"; + } + if (!empty($packages)) { + $strUserData .= "packages:\n"; + $arrPkg = explode("\n", $packages); + foreach ($arrPkg as $p) { + if (trim($p)) $strUserData .= " - " . trim($p) . "\n"; + } + } + + // Users + $strUserData .= "users:\n"; + $strUserData .= " - name: " . $user . "\n"; + if (!empty($keys)) { + $strUserData .= " ssh-authorized-keys:\n"; + $arrKeys = explode("\n", $keys); + foreach ($arrKeys as $k) { + if (trim($k)) $strUserData .= " - " . trim($k) . "\n"; + } + } + if (!empty($pass)) { + $strUserData .= " plain_text_passwd: " . $pass . "\n"; + $strUserData .= " lock_passwd: false\n"; + } + if ($user != 'root') { + $strUserData .= " sudo: ALL=(ALL) NOPASSWD:ALL\n"; + $strUserData .= " shell: /bin/bash\n"; + } + + // General SSH/Auth + $strUserData .= "chpasswd: { expire: False }\n"; + $strUserData .= "ssh_pwauth: True\n"; + if ($root_login) { + $strUserData .= "disable_root: false\n"; + } + + // RunCMD + if (!empty($runcmd)) { + $strUserData .= "runcmd:\n"; + $arrCmd = explode("\n", $runcmd); + foreach ($arrCmd as $c) { + if (trim($c)) $strUserData .= " - " . trim($c) . "\n"; + } + } + + return $strUserData; +} + function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { $strPath = rtrim($strPath, '/'); if (!is_dir($strPath)) { diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 5dc680d9da..f2ad6f58be 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -203,73 +203,7 @@ file_put_contents($strVMPath . '/cloud-init.json', json_encode($arrCloudInitConfig, JSON_PRETTY_PRINT)); if ($cloudInitMode == 'basic') { - // Generate User Data from Basic Fields - $hostname = $_POST['cloudinit']['hostname'] ?? ''; - $timezone = $_POST['cloudinit']['timezone'] ?? ''; - $user = $_POST['cloudinit']['user'] ?? 'root'; - $pass = $_POST['cloudinit']['password'] ?? ''; - $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'] ?? ''; - - $strUserData = "#cloud-config\n"; - - // Hostname & Timezone - if (!empty($hostname)) { - $strUserData .= "hostname: " . $hostname . "\n"; - $strUserData .= "fqdn: " . $hostname . "\n"; - } - if (!empty($timezone)) $strUserData .= "timezone: " . $timezone . "\n"; - - // Packages - if ($update_pkg) { - $strUserData .= "package_update: true\n"; - $strUserData .= "package_upgrade: true\n"; - } - if (!empty($packages)) { - $strUserData .= "packages:\n"; - $arrPkg = explode("\n", $packages); - foreach ($arrPkg as $p) { - if (trim($p)) $strUserData .= " - " . trim($p) . "\n"; - } - } - - // Users - $strUserData .= "users:\n"; - $strUserData .= " - name: " . $user . "\n"; - if (!empty($keys)) { - $strUserData .= " ssh-authorized-keys:\n"; - $arrKeys = explode("\n", $keys); - foreach ($arrKeys as $k) { - if (trim($k)) $strUserData .= " - " . trim($k) . "\n"; - } - } - if (!empty($pass)) { - $strUserData .= " plain_text_passwd: " . $pass . "\n"; - $strUserData .= " lock_passwd: false\n"; - } - if ($user != 'root') { - $strUserData .= " sudo: ALL=(ALL) NOPASSWD:ALL\n"; - $strUserData .= " shell: /bin/bash\n"; - } - - // General SSH/Auth - $strUserData .= "chpasswd: { expire: False }\n"; - $strUserData .= "ssh_pwauth: True\n"; - if ($root_login) { - $strUserData .= "disable_root: false\n"; - } - - // RunCMD - if (!empty($runcmd)) { - $strUserData .= "runcmd:\n"; - $arrCmd = explode("\n", $runcmd); - foreach ($arrCmd as $c) { - if (trim($c)) $strUserData .= " - " . trim($c) . "\n"; - } - } + $strUserData = generate_cloud_init_userdata($_POST['cloudinit']); } else { $strUserData = $_POST['cloudinit']['userdata'] ?? ''; } @@ -418,73 +352,7 @@ file_put_contents($strVMPath . '/cloud-init.json', json_encode($arrCloudInitConfig, JSON_PRETTY_PRINT)); if ($cloudInitMode == 'basic') { - // Generate User Data from Basic Fields - $hostname = $_POST['cloudinit']['hostname'] ?? ''; - $timezone = $_POST['cloudinit']['timezone'] ?? ''; - $user = $_POST['cloudinit']['user'] ?? 'root'; - $pass = $_POST['cloudinit']['password'] ?? ''; - $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'] ?? ''; - - $strUserData = "#cloud-config\n"; - - // Hostname & Timezone - if (!empty($hostname)) { - $strUserData .= "hostname: " . $hostname . "\n"; - $strUserData .= "fqdn: " . $hostname . "\n"; - } - if (!empty($timezone)) $strUserData .= "timezone: " . $timezone . "\n"; - - // Packages - if ($update_pkg) { - $strUserData .= "package_update: true\n"; - $strUserData .= "package_upgrade: true\n"; - } - if (!empty($packages)) { - $strUserData .= "packages:\n"; - $arrPkg = explode("\n", $packages); - foreach ($arrPkg as $p) { - if (trim($p)) $strUserData .= " - " . trim($p) . "\n"; - } - } - - // Users - $strUserData .= "users:\n"; - $strUserData .= " - name: " . $user . "\n"; - if (!empty($keys)) { - $strUserData .= " ssh-authorized-keys:\n"; - $arrKeys = explode("\n", $keys); - foreach ($arrKeys as $k) { - if (trim($k)) $strUserData .= " - " . trim($k) . "\n"; - } - } - if (!empty($pass)) { - $strUserData .= " plain_text_passwd: " . $pass . "\n"; - $strUserData .= " lock_passwd: false\n"; - } - if ($user != 'root') { - $strUserData .= " sudo: ALL=(ALL) NOPASSWD:ALL\n"; - $strUserData .= " shell: /bin/bash\n"; - } - - // General SSH/Auth - $strUserData .= "chpasswd: { expire: False }\n"; - $strUserData .= "ssh_pwauth: True\n"; - if ($root_login) { - $strUserData .= "disable_root: false\n"; - } - - // RunCMD - if (!empty($runcmd)) { - $strUserData .= "runcmd:\n"; - $arrCmd = explode("\n", $runcmd); - foreach ($arrCmd as $c) { - if (trim($c)) $strUserData .= " - " . trim($c) . "\n"; - } - } + $strUserData = generate_cloud_init_userdata($_POST['cloudinit']); } else { $strUserData = $_POST['cloudinit']['userdata'] ?? ''; } From dd3bdf40d1fdfeb217b3dc121893293150684568 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:13:52 +0200 Subject: [PATCH 04/13] fix(vm-manager): mitigate yaml injection in cloud-init generation Replaced manual string concatenation in Cloud-Init YAML generation with structured array construction and a safe YAML encoder 'my_yaml_encode'. This ensures user input is properly quoted and escaped, preventing potential YAML injection attacks where new keys could be injected via newlines. Addresses CodeRabbit feedback: https://github.com/unraid/webgui/pull/2536#discussion_r2738490817 --- .../include/libvirt_helpers.php | 87 +++++++++++++------ 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index 5faa0b184c..b2ec530f2d 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -11,6 +11,40 @@ * 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'] ?? ''; @@ -26,64 +60,61 @@ function generate_cloud_init_userdata($cloudInitData) { $packages = $cloudInitData['packages'] ?? ''; $runcmd = $cloudInitData['runcmd'] ?? ''; - $strUserData = "#cloud-config\n"; + $config = []; // Hostname & Timezone if (!empty($hostname)) { - $strUserData .= "hostname: " . $hostname . "\n"; - $strUserData .= "fqdn: " . $hostname . "\n"; + $config['hostname'] = $hostname; + $config['fqdn'] = $hostname; } - if (!empty($timezone)) $strUserData .= "timezone: " . $timezone . "\n"; + if (!empty($timezone)) $config['timezone'] = $timezone; // Packages if ($update_pkg) { - $strUserData .= "package_update: true\n"; - $strUserData .= "package_upgrade: true\n"; + $config['package_update'] = true; + $config['package_upgrade'] = true; } if (!empty($packages)) { - $strUserData .= "packages:\n"; - $arrPkg = explode("\n", $packages); - foreach ($arrPkg as $p) { - if (trim($p)) $strUserData .= " - " . trim($p) . "\n"; + $arrPkg = array_filter(array_map('trim', explode("\n", $packages))); + if (!empty($arrPkg)) { + $config['packages'] = array_values($arrPkg); } } // Users - $strUserData .= "users:\n"; - $strUserData .= " - name: " . $user . "\n"; + $userConfig = ['name' => $user]; if (!empty($keys)) { - $strUserData .= " ssh-authorized-keys:\n"; - $arrKeys = explode("\n", $keys); - foreach ($arrKeys as $k) { - if (trim($k)) $strUserData .= " - " . trim($k) . "\n"; + $arrKeys = array_filter(array_map('trim', explode("\n", $keys))); + if (!empty($arrKeys)) { + $userConfig['ssh-authorized-keys'] = array_values($arrKeys); } } if (!empty($pass)) { - $strUserData .= " plain_text_passwd: " . $pass . "\n"; - $strUserData .= " lock_passwd: false\n"; + $userConfig['plain_text_passwd'] = $pass; + $userConfig['lock_passwd'] = false; } if ($user != 'root') { - $strUserData .= " sudo: ALL=(ALL) NOPASSWD:ALL\n"; - $strUserData .= " shell: /bin/bash\n"; + $userConfig['sudo'] = 'ALL=(ALL) NOPASSWD:ALL'; + $userConfig['shell'] = '/bin/bash'; } + $config['users'] = [$userConfig]; // General SSH/Auth - $strUserData .= "chpasswd: { expire: False }\n"; - $strUserData .= "ssh_pwauth: True\n"; + $config['chpasswd'] = ['expire' => false]; + $config['ssh_pwauth'] = true; if ($root_login) { - $strUserData .= "disable_root: false\n"; + $config['disable_root'] = false; } // RunCMD if (!empty($runcmd)) { - $strUserData .= "runcmd:\n"; - $arrCmd = explode("\n", $runcmd); - foreach ($arrCmd as $c) { - if (trim($c)) $strUserData .= " - " . trim($c) . "\n"; + $arrCmd = array_filter(array_map('trim', explode("\n", $runcmd))); + if (!empty($arrCmd)) { + $config['runcmd'] = array_values($arrCmd); } } - return $strUserData; + return "#cloud-config\n" . my_yaml_encode($config); } function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { From 1920e9c3e6280cea04e2e467b93f031910eed828 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:51:24 +0200 Subject: [PATCH 05/13] fix(vm-manager): improve safety and cleanup in cloud-init handling Implemented feedback from CodeRabbit to improve robustness and security: - Added error handling for Cloud-Init file writes in 'libvirt_helpers.php'. - Corrected race condition ensuring `rmdir` only runs after successful `umount`. - Deepened security by restricting new VM directory permissions to `0755`. - Added cleanup of `cloud-init.*` files in 'domain_delete'. Addresses following CodeRabbit feedbacks: - https://github.com/unraid/webgui/pull/2536#discussion_r2738490813 - https://github.com/unraid/webgui/pull/2536#discussion_r2738490799 - https://github.com/unraid/webgui/pull/2536#discussion_r2738490809 --- .../dynamix.vm.manager/include/libvirt.php | 14 ++++++++- .../include/libvirt_helpers.php | 29 +++++++++++++++---- .../templates/Custom.form.php | 2 +- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php index 025b3606be..a9a55f1134 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php @@ -1775,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 b2ec530f2d..eb8511f994 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -157,24 +157,41 @@ function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { } // Write files - file_put_contents($strMountPoint . '/user-data', $strUserData); + 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)) { - file_put_contents($strMountPoint . '/network-config', $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; } - file_put_contents($strMountPoint . '/meta-data', "instance-id: " . uniqid() . "\nlocal-hostname: localhost\n"); // Sync and Unmount exec("sync"); exec("umount " . escapeshellarg($strMountPoint) . " 2>&1", $output, $return_var); - // Clean up mount point - rmdir($strMountPoint); - if ($return_var !== 0) { error_log("Cloud-Init image unmount failed: " . implode("\n", $output)); return false; } + // Clean up mount point + rmdir($strMountPoint); + return $strImgPath; } diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index f2ad6f58be..9266235404 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -177,7 +177,7 @@ if (!empty($strVMPath)) { if (!is_dir($strVMPath)) { - mkdir($strVMPath, 0777, true); + mkdir($strVMPath, 0755, true); } $cloudInitMode = $_POST['cloudinit']['mode'] ?? 'basic'; From 7e82cbb8a253669caa0346213c1fc4edbffc795d Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:01:57 +0200 Subject: [PATCH 06/13] chore(vm-manager): remove temporary debug logging Removed temporary file_put_contents debug logging calls and associated dead code (empty else block) from Custom.form.php that were used during development of the Cloud-Init refactoring. --- emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 9266235404..26cfafec7d 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -427,9 +427,6 @@ if (!empty($strVMName)) { if (empty($domain_cfg)) $domain_cfg = parse_ini_file("/boot/config/domain.cfg"); $strVMPath = $domain_cfg['DOMAINDIR'] . $strVMName; - - // Debug logging to specific file - // file_put_contents("/var/log/cloudinit_debug.log", "Loading config for $strVMName from $strVMPath\n", FILE_APPEND); // Load JSON config first for UI fields if (file_exists($strVMPath . '/cloud-init.json')) { @@ -437,9 +434,6 @@ $arrCloudInitConfig = json_decode($json_content, true); if ($arrCloudInitConfig) { $arrConfig['cloudinit'] = array_merge($arrConfig['cloudinit'], $arrCloudInitConfig); - // file_put_contents("/var/log/cloudinit_debug.log", "Loaded JSON: " . print_r($arrCloudInitConfig, true) . "\n", FILE_APPEND); - } else { - // file_put_contents("/var/log/cloudinit_debug.log", "Failed to decode JSON: $json_content\n", FILE_APPEND); } } elseif (file_exists($strVMPath . '/cloud-init.user-data')) { // Fallback for legacy manually implemented or raw edits From e367e610573109e65ab38786625b8c8c9752e003 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:05:09 +0200 Subject: [PATCH 07/13] fix(libvirt): reset output buffer in cloud-init iso creation Resets the $output array before exec calls in create_cloud_init_iso to prevent output accumulation. This ensures that error logs for a specific command (like mount) do not contain unrelated output from previous commands (like formatting). --- emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index eb8511f994..ea69ad7bb2 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -127,6 +127,7 @@ function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { $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)); @@ -150,6 +151,7 @@ function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { } // 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)); @@ -182,6 +184,7 @@ function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { // Sync and Unmount exec("sync"); + $output = []; exec("umount " . escapeshellarg($strMountPoint) . " 2>&1", $output, $return_var); if ($return_var !== 0) { From fced67a912281c581d09729a844a11ac51a28b8b Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:19:20 +0200 Subject: [PATCH 08/13] fix(libvirt): whitelist allowed disk device types in libvirt XML generation --- emhttp/plugins/dynamix.vm.manager/include/libvirt.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php index a9a55f1134..3280302926 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt.php @@ -616,7 +616,7 @@ 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\" "; - $strDevice = $disk['deviceType'] ?? 'disk'; + $strDevice = (isset($disk['deviceType']) && in_array($disk['deviceType'], ['disk', 'lun'])) ? $disk['deviceType'] : 'disk'; $diskstr .= " From 8ad110d972f0884939ec4678869fa13dd56594d5 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:23:02 +0200 Subject: [PATCH 09/13] fix(vm-manager): improve cloud-init ISO handling and error reporting - Abort VM creation if cloud-init ISO generation fails, preventing invalid VM states. - Capture generated ISO path during VM updates and automatically attach it as a SATA CD-ROM device. Addresses CodeRabbit feedback: https://github.com/unraid/webgui/pull/2536#discussion_r2740662761 --- .../templates/Custom.form.php | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 26cfafec7d..fbad936981 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -214,16 +214,19 @@ $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); - if ($isoPath) { - // Add Cloud-Init as disk - $newDiskIndex = count($_POST['disk'] ?? []); - $_POST['disk'][$newDiskIndex] = [ - 'deviceType' => 'disk', - 'driver' => 'raw', - 'image' => $isoPath, - 'bus' => 'virtio' - ]; + 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' + ]; } } @@ -362,7 +365,16 @@ file_put_contents($strVMPath . '/cloud-init.network-config', $strNetworkConfig); // Generate Cloud-Init disk image - create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + + if ($isoPath) { + $newDiskIndex = count($_POST['disk'] ?? []); + $_POST['disk'][$newDiskIndex] = [ + 'source' => $isoPath, + 'bus' => 'sata', + 'type' => 'cdrom' + ]; + } } } From a0f44b650c0bbb53530566b6cbf8f70817e6a5a3 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:36:54 +0200 Subject: [PATCH 10/13] fix(vm-manager): correct disk schema regression in updatevm path - The recent commit 8ad110d97 incorrectly used 'source' and 'type' keys for the Cloud-Init ISO disk attachment in the update path. - This change standardizes the schema to use 'deviceType', 'image', 'driver', and 'bus' keys, allowing config_to_xml to correctly process the disk. Fixes regression introduced in: https://github.com/unraid/webgui/pull/2536/changes/8ad110d972f0884939ec4678869fa13dd56594d5 --- .../plugins/dynamix.vm.manager/templates/Custom.form.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index fbad936981..50c4e16ca2 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -370,9 +370,10 @@ if ($isoPath) { $newDiskIndex = count($_POST['disk'] ?? []); $_POST['disk'][$newDiskIndex] = [ - 'source' => $isoPath, - 'bus' => 'sata', - 'type' => 'cdrom' + 'deviceType' => 'disk', + 'driver' => 'raw', + 'image' => $isoPath, + 'bus' => 'virtio' ]; } } From 7d65ad917220a77c25989d7c3067da9accd066fd Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:44:13 +0200 Subject: [PATCH 11/13] fix(vm-manager): add error handling for cloud-init in updatevm - Ensure VM update is aborted if Cloud-Init ISO creation fails. - This consistency prevents silent failures where the VM would be updated without the intended Cloud-Init configuration. Addresses CodeRabbit feedback: https://github.com/unraid/webgui/pull/2536#discussion_r2740761630 --- emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 50c4e16ca2..977ca02ee7 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -367,6 +367,11 @@ // Generate Cloud-Init disk image $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + if (!$isoPath) { + echo json_encode(['error' => _('Failed to create Cloud-Init ISO')]); + exit; + } + if ($isoPath) { $newDiskIndex = count($_POST['disk'] ?? []); $_POST['disk'][$newDiskIndex] = [ From b65ba925f95f6c0727c85f6960f529ce1eba0953 Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:10:46 +0200 Subject: [PATCH 12/13] fix(vm-manager): resolve duplicate cloud-init disks and improve ISO handling - Fixes a bug where Cloud-Init disks were duplicated on VM updates by implementing smarter disk replacement logic. - Adds support for custom ISO paths with automatic directory handling and validation. - Implements security hardening to restrict paths to /mnt/ and prevent injection. - improving error handling to strictly abort operations on ISO creation failure. --- .../include/libvirt_helpers.php | 19 ++++- .../templates/Custom.form.php | 77 ++++++++++++++++--- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php index ea69ad7bb2..e3a46dc0e0 100644 --- a/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php +++ b/emhttp/plugins/dynamix.vm.manager/include/libvirt_helpers.php @@ -117,13 +117,28 @@ function generate_cloud_init_userdata($cloudInitData) { return "#cloud-config\n" . my_yaml_encode($config); } -function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig) { +function create_cloud_init_iso($strPath, $strUserData, $strNetworkConfig, $customISOPath = null) { $strPath = rtrim($strPath, '/'); if (!is_dir($strPath)) { return false; } - $strImgPath = $strPath . '/cloud-init.img'; + 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 diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 977ca02ee7..2a775131d1 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -198,7 +198,9 @@ 'packages' => $_POST['cloudinit']['packages'] ?? '', 'runcmd' => $_POST['cloudinit']['runcmd'] ?? '', 'userdata' => $_POST['cloudinit']['userdata'] ?? '', - 'networkconfig' => $_POST['cloudinit']['networkconfig'] ?? '' + '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)); @@ -212,7 +214,20 @@ file_put_contents($strVMPath . '/cloud-init.user-data', $strUserData); file_put_contents($strVMPath . '/cloud-init.network-config', $strNetworkConfig); - $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + $customISOPath = $_POST['cloudinit']['iso_path'] ?? null; + if ($customISOPath) { + // Security: Enforce path to be under /mnt/ + if (strpos($customISOPath, '/mnt/') !== 0) { + 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')]); @@ -350,7 +365,9 @@ 'packages' => $_POST['cloudinit']['packages'] ?? '', 'runcmd' => $_POST['cloudinit']['runcmd'] ?? '', 'userdata' => $_POST['cloudinit']['userdata'] ?? '', - 'networkconfig' => $_POST['cloudinit']['networkconfig'] ?? '' + '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)); @@ -365,7 +382,20 @@ file_put_contents($strVMPath . '/cloud-init.network-config', $strNetworkConfig); // Generate Cloud-Init disk image - $isoPath = create_cloud_init_iso($strVMPath, $strUserData, $strNetworkConfig); + $customISOPath = $_POST['cloudinit']['iso_path'] ?? null; + if ($customISOPath) { + // Security: Enforce path to be under /mnt/ + if (strpos($customISOPath, '/mnt/') !== 0) { + 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')]); @@ -373,13 +403,30 @@ } if ($isoPath) { - $newDiskIndex = count($_POST['disk'] ?? []); - $_POST['disk'][$newDiskIndex] = [ - 'deviceType' => 'disk', - 'driver' => 'raw', - 'image' => $isoPath, - 'bus' => 'virtio' - ]; + $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' + ]; + } } } } @@ -1157,6 +1204,14 @@ + + + + + +
_(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.

From 1e6026ae250a769d0686c32e1308ccdbe21ae5cb Mon Sep 17 00:00:00 2001 From: Cristea Florian Victor <80767544+retrozenith@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:21:04 +0200 Subject: [PATCH 13/13] fix(vm-manager): harden custom ISO path validation against traversal - Reject paths containing '..' segments to prevent directory traversal outside /mnt/. - This ensures that the 'starts with /mnt/' check cannot be bypassed. Addresses CodeRabbit feedback: https://github.com/unraid/webgui/pull/2536#discussion_r2740928506 --- emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php index 2a775131d1..5d81d699e5 100755 --- a/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php +++ b/emhttp/plugins/dynamix.vm.manager/templates/Custom.form.php @@ -217,7 +217,7 @@ $customISOPath = $_POST['cloudinit']['iso_path'] ?? null; if ($customISOPath) { // Security: Enforce path to be under /mnt/ - if (strpos($customISOPath, '/mnt/') !== 0) { + if (strpos($customISOPath, '/mnt/') !== 0 || preg_match('~(^|/)\.\.(?:/|$)~', $customISOPath)) { echo json_encode(['error' => _('Cloud-Init ISO path must be within /mnt/')]); exit; } @@ -385,7 +385,7 @@ $customISOPath = $_POST['cloudinit']['iso_path'] ?? null; if ($customISOPath) { // Security: Enforce path to be under /mnt/ - if (strpos($customISOPath, '/mnt/') !== 0) { + if (strpos($customISOPath, '/mnt/') !== 0 || preg_match('~(^|/)\.\.(?:/|$)~', $customISOPath)) { echo json_encode(['error' => _('Cloud-Init ISO path must be within /mnt/')]); exit; }