From 7b1053d658892e1a96d6ac4b3eb13dbdb8458f96 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 14:59:15 -0400 Subject: [PATCH 1/4] fix(docker): prevent case-duplicate user templates - Purpose: keep Docker user template saves consistent when container names differ only by case. - Before: saving a container template used `my-$Name.xml` directly, while other paths performed case-insensitive lookup for legacy FAT boot media. - Problem: on case-sensitive internal/ZFS boot media, edits or case-only renames could create a new `my-Name.xml` instead of reusing an existing `my-name.xml`. - New behavior: Docker template saves reuse an existing exact or case-insensitive user-template path before writing. - Implementation: add deterministic user-template path lookup and use it from create/edit, docker init, and Docker startup network restore. - Scope: this change does not add cleanup or delete duplicate templates. --- .../include/CreateDocker.php | 2 +- .../include/DockerClient.php | 15 ++++++++++ .../scripts/docker_init | 18 ++++++++++-- etc/rc.d/rc.docker | 29 +++++++++++++++---- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index 2966cf214a..0c9facfcd9 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -81,7 +81,7 @@ function cpu_pinning() { $userTmplDir = $dockerManPaths['templates-user']; if (!is_dir($userTmplDir)) mkdir($userTmplDir, 0777, true); if ($Name) { - $filename = sprintf('%s/my-%s.xml', $userTmplDir, $Name); + $filename = $DockerTemplates->getUserTemplatePath($Name); if (is_file($filename)) { $oldXML = simplexml_load_file($filename); if ($oldXML->Icon != $_POST['contIcon']) { diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php index 5475cfe0b0..0a4729948a 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php @@ -139,6 +139,21 @@ public function getTemplates($type) { return $tmpls; } + public function getUserTemplatePath($Container) { + global $dockerManPaths; + $dir = $dockerManPaths['templates-user']; + if (!is_dir($dir)) @mkdir($dir, 0755, true); + $target = "my-$Container.xml"; + $targetLower = strtolower($target); + $match = false; + foreach (glob("$dir/my-*.xml") ?: [] as $template) { + $name = basename($template); + if ($name == $target) return $template; + if (!$match && strtolower($name) == $targetLower) $match = $template; + } + return $match ?: "$dir/$target"; + } + public function downloadTemplates($Dest=null, $Urls=null) { /* Don't download any templates. Leave code in place for future reference. */ /* remove existing limetech templates that are all not valid */ diff --git a/emhttp/plugins/dynamix.docker.manager/scripts/docker_init b/emhttp/plugins/dynamix.docker.manager/scripts/docker_init index 8431d2a254..07d81bf062 100755 --- a/emhttp/plugins/dynamix.docker.manager/scripts/docker_init +++ b/emhttp/plugins/dynamix.docker.manager/scripts/docker_init @@ -4,19 +4,33 @@ $autostart = @file("/var/lib/docker/unraid-autostart",FILE_IGNORE_NEW_LINES); if ( ! $autostart ) exit(); +function user_template($container) { + $dir = "/boot/config/plugins/dockerMan/templates-user"; + $target = "my-$container.xml"; + $targetLower = strtolower($target); + $match = false; + foreach (glob("$dir/my-*.xml") ?: [] as $template) { + $name = basename($template); + if ($name == $target) return $template; + if (!$match && strtolower($name) == $targetLower) $match = $template; + } + return $match; +} + $flag = false; $newAuto = []; foreach ($autostart as $container) { if (! trim($container) ) continue; $cont = explode(" ",$container); - if ( ! is_file("/boot/config/plugins/dockerMan/templates-user/my-{$cont[0]}.xml")) { + $template = user_template($cont[0]); + if (!$template) { $newAuto[] = $container; continue; } $doc = new DOMDocument(); - if (!$doc->load("/boot/config/plugins/dockerMan/templates-user/my-{$cont[0]}.xml")) { + if (!$doc->load($template)) { $newAuto[] = $container; continue; } diff --git a/etc/rc.d/rc.docker b/etc/rc.d/rc.docker index a420587985..576174ca29 100755 --- a/etc/rc.d/rc.docker +++ b/etc/rc.d/rc.docker @@ -47,6 +47,24 @@ active(){ fi } +# return the user template for a container, preserving legacy case-insensitive +# matching without returning multiple files on case-sensitive boot devices +docker_user_template(){ + local dir=/boot/config/plugins/dockerMan/templates-user + local target="my-$1.xml" + local target_lc=${target,,} + local match= + local file + local name + for file in "$dir"/my-*.xml; do + [[ -e $file ]] || continue + name=${file##*/} + [[ $name == "$target" ]] && echo "$file" && return + [[ -z $match && ${name,,} == "$target_lc" ]] && match=$file + done + [[ -n $match ]] && echo "$match" +} + # wait for interface to go up carrier(){ local n e @@ -360,23 +378,22 @@ docker_network_start(){ # get container settings for custom networks to reconnect later declare -A NETRESTORE PRIMARY_NETWORK REBUILD_CONTAINERS USED_SUBNETS4 USED_SUBNETS6 RESTORED_NETWORKS for CONTAINER in $(docker container ls -a --format='{{.Names}}'); do - # the file case (due to fat32) might be different so use find to match - XMLFILE=$(find /boot/config/plugins/dockerMan/templates-user -maxdepth 1 -iname my-${CONTAINER}.xml) - if [[ -n $XMLFILE ]]; then + XMLFILE=$(docker_user_template "$CONTAINER") + if [[ -f $XMLFILE ]]; then REBUILD= MAIN= # update custom network reference (if changed) for NIC in $NICS; do [[ ${NIC:0:3} == eth ]] && NIC=$(active $NIC) X=${NIC//[^0-9]/} - REF=$(grep -Pom1 "\K(br|bond|eth|wlan)$X" $XMLFILE) + REF=$(grep -Pom1 "\K(br|bond|eth|wlan)$X" "$XMLFILE") if [[ $X == 0 ]] && ! carrier $NIC 1; then continue fi [[ $X == 0 && $NIC != wlan0 ]] && MAIN=$NIC [[ $NIC == wlan0 && -n $MAIN ]] && continue if [[ -n $REF && $REF != $NIC ]]; then - sed -ri "s/(br|bond|eth|wlan)$X(\.[0-9]+)?<\/Network>/$NIC\2<\/Network>/" $XMLFILE + sed -ri "s/(br|bond|eth|wlan)$X(\.[0-9]+)?<\/Network>/$NIC\2<\/Network>/" "$XMLFILE" REBUILD=1 fi done @@ -385,7 +402,7 @@ docker_network_start(){ [[ $ENTITY == Network ]] && MY_NETWORK=$CONTENT [[ $ENTITY == MyIP ]] && MY_IP=${CONTENT// /,} && MY_IP=$(echo "$MY_IP" | tr -s "," ";") [[ $ENTITY == MyMAC ]] && XML_MAC=${CONTENT// /} - done <$XMLFILE + done <"$XMLFILE" # only restore valid networks if [[ -n $MY_NETWORK ]]; then [[ $MY_NETWORK =~ ^(br|bond|eth|wlan)[0-9]+(\.[0-9]+)?$ ]] && CUSTOM_PRIMARY=1 From 82af5ff9733ac59db2fbbe8f7d391d711adc550d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 16:21:17 -0400 Subject: [PATCH 2/4] fix(docker): resolve renamed template path - Purpose: address PR review feedback on Docker template rename handling. - Before: the renamed-container cleanup path compared and deleted a manually constructed my-.xml path. - Problem: on case-sensitive boot storage, that constructed path can miss the actual existing template filename when only case differs. - Now: the existing template path is resolved through DockerTemplates::getUserTemplatePath before comparison and deletion. - How: reuse the same case-aware lookup helper used for saving the current container template, without adding any duplicate cleanup pass. --- .../plugins/dynamix.docker.manager/include/CreateDocker.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index 0c9facfcd9..cbaaa30365 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -137,8 +137,9 @@ function cpu_pinning() { // force kill container if still running after 10 seconds removeContainer($existing,1); // remove old template - if (strtolower($filename) != strtolower("$userTmplDir/my-$existing.xml")) { - @unlink("$userTmplDir/my-$existing.xml"); + $oldFilename = $DockerTemplates->getUserTemplatePath($existing); + if (strtolower($filename) != strtolower($oldFilename)) { + @unlink($oldFilename); } } // Extract real Entrypoint and Cmd from container for Tailscale From fdc0598a7484450d72a8d43090b423a45f5035ae Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 20:17:41 -0400 Subject: [PATCH 3/4] fix(docker): keep template edit and save paths aligned - Purpose: address duplicate case-variant template feedback from PR testing. - Before: the Docker edit path could open one case-variant XML while saving preferred another exact my-.xml path. - Problem: on case-sensitive boot storage, existing duplicate templates could silently split edits across two files. - Now: edit forms carry the opened user-template path and unchanged-name saves write back to that source file. - How: getUserTemplate and name-scoped getTemplateValue now prioritize the same case-aware resolver used by saves, while CreateDocker preserves the opened source path for normal edits and uses it as the old template during renames. --- .../include/CreateDocker.php | 10 ++++++-- .../include/DockerClient.php | 25 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index cbaaa30365..18bba233c5 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -80,8 +80,11 @@ function cpu_pinning() { // Saving the generated configuration file. $userTmplDir = $dockerManPaths['templates-user']; if (!is_dir($userTmplDir)) mkdir($userTmplDir, 0777, true); + $sourceTemplate = _var($_POST,'sourceTemplate',false); + if ($sourceTemplate) $sourceTemplate = unscript(urldecode($sourceTemplate)); + $sourceUserTemplate = $sourceTemplate && is_file($sourceTemplate) && dirname($sourceTemplate)==$userTmplDir && preg_match('/^my-.*\.xml$/', basename($sourceTemplate)); if ($Name) { - $filename = $DockerTemplates->getUserTemplatePath($Name); + $filename = ($sourceUserTemplate && $existing === $Name) ? $sourceTemplate : $DockerTemplates->getUserTemplatePath($Name); if (is_file($filename)) { $oldXML = simplexml_load_file($filename); if ($oldXML->Icon != $_POST['contIcon']) { @@ -137,7 +140,7 @@ function cpu_pinning() { // force kill container if still running after 10 seconds removeContainer($existing,1); // remove old template - $oldFilename = $DockerTemplates->getUserTemplatePath($existing); + $oldFilename = $sourceUserTemplate ? $sourceTemplate : $DockerTemplates->getUserTemplatePath($existing); if (strtolower($filename) != strtolower($oldFilename)) { @unlink($oldFilename); } @@ -899,6 +902,9 @@ function prepareCategory() {
+ + + doesContainerExist($templateName)):?> diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php index 0a4729948a..51a8d95058 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php @@ -266,8 +266,23 @@ public function downloadTemplates($Dest=null, $Urls=null) { return $output; } + private function getXmlName($path) { + if (!is_file($path)) return false; + $doc = new DOMDocument('1.0', 'utf-8'); + if (!$doc->load($path)) return false; + return $doc->getElementsByTagName('Name')->item(0)->nodeValue??''; + } + public function getTemplateValue($Repository, $field, $scope='all',$name='') { - foreach ($this->getTemplates($scope) as $file) { + $templates = $this->getTemplates($scope); + if ($name && ($scope=='all' || $scope=='user') && ($userTemplate = $this->getUserTemplate($name))) { + $ordered = [['path' => $userTemplate, 'prefix' => basename(dirname($userTemplate)), 'name' => basename($userTemplate, '.xml')]]; + foreach ($templates as $template) { + if ($template['path'] != $userTemplate) $ordered[] = $template; + } + $templates = $ordered; + } + foreach ($templates as $file) { $doc = new DOMDocument(); $doc->load($file['path']); if ($name) { @@ -283,11 +298,11 @@ public function getTemplateValue($Repository, $field, $scope='all',$name='') { } public function getUserTemplate($Container) { + $userTemplate = $this->getUserTemplatePath($Container); + if ($this->getXmlName($userTemplate)===$Container) return $userTemplate; foreach ($this->getTemplates('user') as $file) { - $doc = new DOMDocument('1.0', 'utf-8'); - $doc->load($file['path']); - $Name = $doc->getElementsByTagName('Name')->item(0)->nodeValue??''; - if ($Name==$Container) return $file['path']; + if ($file['path']==$userTemplate) continue; + if ($this->getXmlName($file['path'])===$Container) return $file['path']; } return false; } From 4bdaba8a550aa391ab85272305cdc0886ff77e55 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 20:23:07 -0400 Subject: [PATCH 4/4] fix(docker): simplify template source tracking - Purpose: simplify the duplicate case-variant template fix after review. - Before: the branch reordered template-value lookups and changed getUserTemplate selection rules to keep edit and save paths aligned. - Problem: that was broader and harder to reason about than necessary for the reported split-state edit/save bug. - Now: edit forms post only the opened user-template basename, and unchanged-name saves write back to that exact template file. - How: CreateDocker reconstructs accepted my-*.xml basenames under templates-user, rejects path separators such as ../../, and falls back to getUserTemplatePath for new saves and renames while DockerClient sanitizes container-derived template path names. --- .../include/CreateDocker.php | 8 ++++-- .../include/DockerClient.php | 26 +++++-------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index 18bba233c5..739c7cce58 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -82,7 +82,11 @@ function cpu_pinning() { if (!is_dir($userTmplDir)) mkdir($userTmplDir, 0777, true); $sourceTemplate = _var($_POST,'sourceTemplate',false); if ($sourceTemplate) $sourceTemplate = unscript(urldecode($sourceTemplate)); - $sourceUserTemplate = $sourceTemplate && is_file($sourceTemplate) && dirname($sourceTemplate)==$userTmplDir && preg_match('/^my-.*\.xml$/', basename($sourceTemplate)); + $sourceUserTemplate = $sourceTemplate && basename($sourceTemplate)==$sourceTemplate && preg_match('/^my-[^\/\\\\]+\.xml$/', $sourceTemplate); + if ($sourceUserTemplate) { + $sourceTemplate = "$userTmplDir/$sourceTemplate"; + $sourceUserTemplate = is_file($sourceTemplate); + } if ($Name) { $filename = ($sourceUserTemplate && $existing === $Name) ? $sourceTemplate : $DockerTemplates->getUserTemplatePath($Name); if (is_file($filename)) { @@ -903,7 +907,7 @@ function prepareCategory() { - + doesContainerExist($templateName)):?> diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php index 51a8d95058..e7277b3375 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php @@ -143,6 +143,7 @@ public function getUserTemplatePath($Container) { global $dockerManPaths; $dir = $dockerManPaths['templates-user']; if (!is_dir($dir)) @mkdir($dir, 0755, true); + $Container = str_replace(['/', '\\'], '', $Container); $target = "my-$Container.xml"; $targetLower = strtolower($target); $match = false; @@ -266,23 +267,8 @@ public function downloadTemplates($Dest=null, $Urls=null) { return $output; } - private function getXmlName($path) { - if (!is_file($path)) return false; - $doc = new DOMDocument('1.0', 'utf-8'); - if (!$doc->load($path)) return false; - return $doc->getElementsByTagName('Name')->item(0)->nodeValue??''; - } - public function getTemplateValue($Repository, $field, $scope='all',$name='') { - $templates = $this->getTemplates($scope); - if ($name && ($scope=='all' || $scope=='user') && ($userTemplate = $this->getUserTemplate($name))) { - $ordered = [['path' => $userTemplate, 'prefix' => basename(dirname($userTemplate)), 'name' => basename($userTemplate, '.xml')]]; - foreach ($templates as $template) { - if ($template['path'] != $userTemplate) $ordered[] = $template; - } - $templates = $ordered; - } - foreach ($templates as $file) { + foreach ($this->getTemplates($scope) as $file) { $doc = new DOMDocument(); $doc->load($file['path']); if ($name) { @@ -298,11 +284,11 @@ public function getTemplateValue($Repository, $field, $scope='all',$name='') { } public function getUserTemplate($Container) { - $userTemplate = $this->getUserTemplatePath($Container); - if ($this->getXmlName($userTemplate)===$Container) return $userTemplate; foreach ($this->getTemplates('user') as $file) { - if ($file['path']==$userTemplate) continue; - if ($this->getXmlName($file['path'])===$Container) return $file['path']; + $doc = new DOMDocument('1.0', 'utf-8'); + $doc->load($file['path']); + $Name = $doc->getElementsByTagName('Name')->item(0)->nodeValue??''; + if ($Name==$Container) return $file['path']; } return false; }