From 27ce772eb766c555f49d1e7217874ba9bf9b9ee9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 13:08:47 -0400 Subject: [PATCH 01/11] fix(docker): set fixed macs on network endpoints Purpose of the change: - Store fixed Docker MAC addresses as first-class template network settings. - Emit Docker's endpoint-level mac-address option when the GUI owns network creation. How behavior was before: - Users had to put --mac-address in Extra Parameters. - That used the legacy container-level flag and could force slow container rebuild behavior. - Docker service network restore only treated ExtraParams MACs as the custom-primary rebuild path. Why that was a problem: - Docker 28+ generates random MACs by default, which floods routers with stale entries after restarts. - The existing workaround made Docker service startup slow for affected containers. What the new change accomplishes: - Adds a Fixed MAC address field beside Fixed IP address. - Migrates eligible --mac-address values out of Extra Parameters into . - Preserves ExtraParams-owned networking when --network/--net is already specified there. - Restores custom network endpoints with the stored template MAC without triggering the legacy rebuild path. How it works: - Builds --network=name=...,ip=...,mac-address=... for GUI-owned custom networks. - Removes the legacy --mac-address only when the replacement endpoint option is emitted. - Uses during rc.docker network restore before falling back to inspect or legacy ExtraParams. --- emhttp/languages/en_US/helptext.txt | 4 ++ .../include/CreateDocker.php | 8 +++- .../include/Helpers.php | 47 +++++++++++++++++-- etc/rc.d/rc.docker | 13 +++-- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/emhttp/languages/en_US/helptext.txt b/emhttp/languages/en_US/helptext.txt index e3fcb4a19d..340caea2ad 100644 --- a/emhttp/languages/en_US/helptext.txt +++ b/emhttp/languages/en_US/helptext.txt @@ -2379,6 +2379,10 @@ Generally speaking, it is recommended to leave this setting to its default value IMPORTANT NOTE: If adjusting port mappings, do not modify the settings for the Container port as only the Host port can be adjusted. :end +:docker_fixed_mac_help: +Assigns the container's MAC address on the selected Docker network endpoint. This avoids using the legacy container-level --mac-address option in Extra Parameters. +:end + :docker_container_network_help: This allows your container to utilize the network configuration of another container. Select the appropriate container from the list.
This setup can be particularly beneficial if you wish to route your container's traffic through a VPN. :end diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index 794faadfbe..e912a358f7 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -1109,6 +1109,11 @@ function prepareCategory() { :docker_fixed_ip_help: +_(Fixed MAC address)_ (_(optional)_): +: + +:docker_fixed_mac_help: +
@@ -1563,6 +1568,7 @@ function showSubnet(bridge) { if (bridge.match(/^(bridge|host|none)$/i) !== null) { $('.myIP').hide(); $('input[name="contMyIP"]').val(''); + $('input[name="contMyMAC"]').val(''); $('.netCONT').hide(); $('#netCONT').val(''); } else if (bridge.match(/^(container)$/i) !== null) { @@ -1570,6 +1576,7 @@ function showSubnet(bridge) { $('#netCONT').val(''); $('.myIP').hide(); $('input[name="contMyIP"]').val(''); + $('input[name="contMyMAC"]').val(''); } else { $('.myIP').show(); $('#myIP').html(': '+subnet[bridge]); @@ -1932,4 +1939,3 @@ function load_contOverview() { } - diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 2b5425128e..e3f5cf24d1 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -32,6 +32,23 @@ function xml_decode($string) { return strval(html_entity_decode($string, ENT_XML1, 'UTF-8')); } +function extractMacAddressParam($extraParams) { + if (!is_string($extraParams) || !preg_match('/(?:^|\s)--mac-address(?:=|\s+)(?:"([^"]+)"|\'([^\']+)\'|([^\s]+))/', $extraParams, $match)) { + return ''; + } + foreach (array_slice($match, 1) as $value) { + if (strlen($value ?? '')) return trim($value); + } + return ''; +} + +function removeMacAddressParam($extraParams) { + if (!is_string($extraParams) || $extraParams === '') { + return ''; + } + return trim(preg_replace('/(^|\s)--mac-address(?:=|\s+)(?:"[^"]+"|\'[^\']+\'|[^\s]+)/', '$1', $extraParams)); +} + function generateTSwebui($url, $serve, $webUI) { if (!isset($webUI)) { return ''; @@ -75,6 +92,9 @@ function postToXML($post, $setOwnership=false) { $xml->Network = xml_encode($post['contNetwork']); } $xml->MyIP = xml_encode($post['contMyIP']); + $extraNetwork = preg_match('/\-\-net(work)?=/', $post['contExtraParams'] ?? ''); + $myMAC = trim($post['contMyMAC'] ?? '') ?: ($extraNetwork ? '' : extractMacAddressParam($post['contExtraParams'] ?? '')); + $xml->MyMAC = xml_encode($myMAC); $xml->Shell = xml_encode($post['contShell']); $xml->Privileged = strtolower($post['contPrivileged']??'')=='on' ? 'true' : 'false'; $xml->Support = xml_encode($post['contSupport']); @@ -85,7 +105,7 @@ function postToXML($post, $setOwnership=false) { $xml->WebUI = xml_encode(trim($post['contWebUI'])); $xml->TemplateURL = xml_encode($post['contTemplateURL']); $xml->Icon = xml_encode(trim($post['contIcon'])); - $xml->ExtraParams = xml_encode($post['contExtraParams']); + $xml->ExtraParams = xml_encode($myMAC && !$extraNetwork ? removeMacAddressParam($post['contExtraParams']) : $post['contExtraParams']); $xml->PostArgs = xml_encode($post['contPostArgs']); $xml->CPUset = xml_encode($post['contCPUset']); $xml->DateInstalled = xml_encode(time()); @@ -149,6 +169,7 @@ function xmlToVar($xml) { $out['Registry'] = xml_decode($xml->Registry); $out['Network'] = xml_decode($xml->Network); $out['MyIP'] = xml_decode($xml->MyIP ?? ''); + $out['MyMAC'] = xml_decode($xml->MyMAC ?? '') ?: extractMacAddressParam(xml_decode($xml->ExtraParams ?? '')); $out['Shell'] = xml_decode($xml->Shell ?? 'sh'); $out['Privileged'] = xml_decode($xml->Privileged); $out['Support'] = xml_decode($xml->Support); @@ -325,13 +346,29 @@ function xmlToCommand($xml, $create_paths=false) { $xml = xmlToVar($xml); $cmdName = strlen($xml['Name']) ? '--name='.escapeshellarg($xml['Name']) : ''; $cmdPrivileged = strtolower($xml['Privileged'])=='true' ? '--privileged=true' : ''; + $extraNetwork = preg_match('/\-\-net(work)?=/',$xml['ExtraParams']); + $cmdMyIP = ''; if (preg_match('/^container:(.*)/', $xml['Network'])) { - $cmdNetwork = preg_match('/\-\-net(work)?=/',$xml['ExtraParams']) ? "" : '--net='.escapeshellarg($xml['Network']); + $cmdNetwork = $extraNetwork ? "" : '--net='.escapeshellarg($xml['Network']); } else { - $cmdNetwork = preg_match('/\-\-net(work)?=/',$xml['ExtraParams']) ? "" : '--net='.escapeshellarg(strtolower($xml['Network'])); + $networkName = strtolower($xml['Network']); + if ($extraNetwork) { + $cmdNetwork = ""; + } elseif (strlen($xml['MyMAC']) && !in_array($networkName, ['host','none'])) { + $xml['ExtraParams'] = removeMacAddressParam($xml['ExtraParams']); + $networkEndpoint = ['name='.$networkName]; + foreach (explode(' ',str_replace(',',' ',$xml['MyIP'])) as $myIP) { + if ($myIP) $networkEndpoint[] = (strpos($myIP,':')?'ip6=':'ip=').$myIP; + } + $networkEndpoint[] = 'mac-address='.$xml['MyMAC']; + $cmdNetwork = '--network='.escapeshellarg(implode(',', $networkEndpoint)); + } else { + $cmdNetwork = '--net='.escapeshellarg($networkName); + } + } + if (!strlen($xml['MyMAC']) || preg_match('/^container:(.*)/', $xml['Network']) || $extraNetwork) { + foreach (explode(' ',str_replace(',',' ',$xml['MyIP'])) as $myIP) if ($myIP) $cmdMyIP .= (strpos($myIP,':')?'--ip6=':'--ip=').escapeshellarg($myIP).' '; } - $cmdMyIP = ''; - foreach (explode(' ',str_replace(',',' ',$xml['MyIP'])) as $myIP) if ($myIP) $cmdMyIP .= (strpos($myIP,':')?'--ip6=':'--ip=').escapeshellarg($myIP).' '; $cmdCPUset = strlen($xml['CPUset']) ? '--cpuset-cpus='.escapeshellarg($xml['CPUset']) : ''; $Volumes = ['']; $Ports = ['']; diff --git a/etc/rc.d/rc.docker b/etc/rc.d/rc.docker index 631f1a946e..373a096e56 100755 --- a/etc/rc.d/rc.docker +++ b/etc/rc.d/rc.docker @@ -347,20 +347,25 @@ docker_network_start(){ REBUILD=1 fi done - MY_NETWORK= MY_IP= MY_MAC= TEMPLATE_MAC= CUSTOM_PRIMARY= + MY_NETWORK= MY_IP= MY_MAC= XML_MAC= TEMPLATE_MAC= CUSTOM_PRIMARY= while read_dom; do [[ $ENTITY == Network ]] && MY_NETWORK=$CONTENT [[ $ENTITY == MyIP ]] && MY_IP=${CONTENT// /,} && MY_IP=$(echo "$MY_IP" | tr -s "," ";") + [[ $ENTITY == MyMAC ]] && XML_MAC=${CONTENT// /} done <$XMLFILE # only restore valid networks if [[ -n $MY_NETWORK ]]; then [[ $MY_NETWORK =~ ^(br|bond|eth|wlan)[0-9]+(\.[0-9]+)?$ ]] && CUSTOM_PRIMARY=1 TEMPLATE_MAC=$(sed -nE 's@.*.*--mac-address(=|[[:space:]]+)([^ <]+).*@\2@p' "$XMLFILE" | head -n1) - MY_MAC=$(docker inspect --format="{{with index .NetworkSettings.Networks \"$MY_NETWORK\"}}{{.MacAddress}}{{end}}" $CONTAINER 2>/dev/null) - [[ -n $MY_MAC ]] || MY_MAC=$TEMPLATE_MAC + if [[ -n $XML_MAC ]]; then + MY_MAC=$XML_MAC + else + MY_MAC=$(docker inspect --format="{{with index .NetworkSettings.Networks \"$MY_NETWORK\"}}{{.MacAddress}}{{end}}" $CONTAINER 2>/dev/null) + [[ -n $MY_MAC ]] || MY_MAC=$TEMPLATE_MAC + fi netrestore_add "$MY_NETWORK" "$CONTAINER" "$MY_IP" "$MY_MAC" PRIMARY_NETWORK[$CONTAINER]=$MY_NETWORK - [[ -n $REBUILD || (-n $TEMPLATE_MAC && -n $CUSTOM_PRIMARY) ]] && REBUILD_CONTAINERS[$CONTAINER]=1 + [[ -n $REBUILD || (-z $XML_MAC && -n $TEMPLATE_MAC && -n $CUSTOM_PRIMARY) ]] && REBUILD_CONTAINERS[$CONTAINER]=1 fi fi # restore user defined networks From bc2ebbc5aaf4ebd70c08e82d1d437541f76fb137 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 13:19:09 -0400 Subject: [PATCH 02/11] fix(docker): validate fixed mac addresses Purpose of the change: - Reject invalid fixed MAC addresses before Docker tries to create container networking. - Normalize valid fixed MAC values to colon-separated lowercase form. How behavior was before: - The form accepted MAC values such as 61-F2-9A-0D-7E-C0. - Docker later failed because MACs with an odd first octet are multicast addresses and cannot be assigned to container interfaces. Why that was a problem: - Users saw a low-level Docker networking error after submitting the form. - Hyphenated or compact valid MAC values were not normalized before being stored. What the new change accomplishes: - Adds client-side and server-side validation for unicast MAC addresses. - Updates help text with an example valid MAC address. - Keeps ExtraParams-owned network settings as the source of truth when --network or --net is specified there. How it works: - Accepts colon, hyphen, or compact hexadecimal MAC input. - Normalizes valid values to colon-separated lowercase format. - Requires the first octet to be even so multicast MACs are rejected. --- emhttp/languages/en_US/helptext.txt | 4 ++- .../include/CreateDocker.php | 35 +++++++++++++++++-- .../include/Helpers.php | 30 ++++++++++++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/emhttp/languages/en_US/helptext.txt b/emhttp/languages/en_US/helptext.txt index 340caea2ad..5dfac30c2a 100644 --- a/emhttp/languages/en_US/helptext.txt +++ b/emhttp/languages/en_US/helptext.txt @@ -2380,7 +2380,9 @@ IMPORTANT NOTE: If adjusting port mappings, do not modify the settings for the :end :docker_fixed_mac_help: -Assigns the container's MAC address on the selected Docker network endpoint. This avoids using the legacy container-level --mac-address option in Extra Parameters. +Assigns the container's MAC address on the selected Docker network endpoint. Use a valid unicast MAC address; the first octet must be even, e.g. 02:42:9a:0d:7e:c0. + +This avoids using the legacy container-level --mac-address option in Extra Parameters. :end :docker_container_network_help: diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index e912a358f7..743d662819 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -69,6 +69,20 @@ function cpu_pinning() { ########################## if (isset($_POST['contName'])) { + $extraNetwork = preg_match('/\-\-net(work)?=/', $_POST['contExtraParams'] ?? ''); + if ($extraNetwork && trim($_POST['contMyMAC'] ?? '') !== '') { + readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); + echo '

',_('Error'),': ',_('Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.'),'

'; + echo '

'; + goto END; + } + $submittedMAC = trim($_POST['contMyMAC'] ?? '') ?: extractMacAddressParam($_POST['contExtraParams'] ?? ''); + if ($submittedMAC !== '' && !isValidUnicastMacAddress($submittedMAC)) { + readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); + echo '

',_('Error'),': ',_('Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.'),'

'; + echo '

'; + goto END; + } $postXML = postToXML($_POST, true); $dry_run = isset($_POST['dryRun']) && $_POST['dryRun']=='true'; $existing = _var($_POST,'existingContainer',false); @@ -736,6 +750,12 @@ function removeConfig(num) { function prepareConfig(form) { var types = [], values = [], targets = [], vcpu = []; + var myMAC = $(form).find('input[name="contMyMAC"]').val().trim().replaceAll('-', ':').toLowerCase(); + if (myMAC && !isValidUnicastMacAddress(myMAC)) { + swal({title:"_(Invalid MAC address)_",text:"_(Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.)_",type:"error",html:true}); + return false; + } + $(form).find('input[name="contMyMAC"]').val(myMAC); if ($('select[name="contNetwork"]').val()=='host') { $(form).find('input[name="confType[]"]').each(function(){types.push($(this).val());}); $(form).find('input[name="confValue[]"]').each(function(){values.push($(this));}); @@ -744,6 +764,17 @@ function prepareConfig(form) { } $(form).find('input[id^="box"]').each(function(){if ($(this).prop('checked')) vcpu.push($('#'+$(this).prop('id').replace('box','cpu')).text());}); form.contCPUset.value = vcpu.join(','); + return true; +} + +function isValidUnicastMacAddress(mac) { + if (mac.match(/^[0-9a-f]{12}$/i)) { + mac = mac.match(/.{1,2}/g).join(':'); + } + if (!mac.match(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i)) { + return false; + } + return (parseInt(mac.substring(0, 2), 16) & 1) === 0; } function makeName(type) { @@ -893,7 +924,7 @@ function prepareCategory() { ?>
-
+ @@ -1110,7 +1141,7 @@ function prepareCategory() { :docker_fixed_ip_help: _(Fixed MAC address)_ (_(optional)_): -: +: :docker_fixed_mac_help: diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index e3f5cf24d1..5d65181947 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -49,6 +49,28 @@ function removeMacAddressParam($extraParams) { return trim(preg_replace('/(^|\s)--mac-address(?:=|\s+)(?:"[^"]+"|\'[^\']+\'|[^\s]+)/', '$1', $extraParams)); } +function normalizeMacAddress($mac) { + $mac = strtolower(trim($mac ?? '')); + if ($mac === '') { + return ''; + } + if (preg_match('/^[0-9a-f]{12}$/', $mac)) { + $mac = implode(':', str_split($mac, 2)); + } else { + $mac = str_replace('-', ':', $mac); + } + return preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $mac) ? $mac : ''; +} + +function isValidUnicastMacAddress($mac) { + $mac = normalizeMacAddress($mac); + if ($mac === '') { + return false; + } + $firstOctet = hexdec(substr($mac, 0, 2)); + return ($firstOctet & 1) === 0; +} + function generateTSwebui($url, $serve, $webUI) { if (!isset($webUI)) { return ''; @@ -93,7 +115,7 @@ function postToXML($post, $setOwnership=false) { } $xml->MyIP = xml_encode($post['contMyIP']); $extraNetwork = preg_match('/\-\-net(work)?=/', $post['contExtraParams'] ?? ''); - $myMAC = trim($post['contMyMAC'] ?? '') ?: ($extraNetwork ? '' : extractMacAddressParam($post['contExtraParams'] ?? '')); + $myMAC = $extraNetwork ? '' : normalizeMacAddress(trim($post['contMyMAC'] ?? '') ?: extractMacAddressParam($post['contExtraParams'] ?? '')); $xml->MyMAC = xml_encode($myMAC); $xml->Shell = xml_encode($post['contShell']); $xml->Privileged = strtolower($post['contPrivileged']??'')=='on' ? 'true' : 'false'; @@ -169,7 +191,9 @@ function xmlToVar($xml) { $out['Registry'] = xml_decode($xml->Registry); $out['Network'] = xml_decode($xml->Network); $out['MyIP'] = xml_decode($xml->MyIP ?? ''); - $out['MyMAC'] = xml_decode($xml->MyMAC ?? '') ?: extractMacAddressParam(xml_decode($xml->ExtraParams ?? '')); + $extraParams = xml_decode($xml->ExtraParams ?? ''); + $extraNetwork = preg_match('/\-\-net(work)?=/', $extraParams); + $out['MyMAC'] = $extraNetwork ? '' : normalizeMacAddress(xml_decode($xml->MyMAC ?? '') ?: extractMacAddressParam($extraParams)); $out['Shell'] = xml_decode($xml->Shell ?? 'sh'); $out['Privileged'] = xml_decode($xml->Privileged); $out['Support'] = xml_decode($xml->Support); @@ -180,7 +204,7 @@ function xmlToVar($xml) { $out['WebUI'] = xml_decode($xml->WebUI); $out['TemplateURL'] = xml_decode($xml->TemplateURL); $out['Icon'] = xml_decode($xml->Icon); - $out['ExtraParams'] = xml_decode($xml->ExtraParams); + $out['ExtraParams'] = $extraParams; $out['PostArgs'] = xml_decode($xml->PostArgs); $out['CPUset'] = xml_decode($xml->CPUset); $out['DonateText'] = xml_decode($xml->DonateText); From e2c1ce8838bf10151ce3db1415d6558984d9040e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 13:50:16 -0400 Subject: [PATCH 03/11] fix(docker): detect space-separated network params Purpose of the change: - Keep Fixed MAC validation consistent when Extra Parameters specify Docker networking. - Catch both equals and space-separated Docker CLI network argument forms. How behavior was before: - The server-side guard only detected --net= and --network=. - The client-side form only validated MAC format and allowed a Fixed MAC with Extra Parameters networking until the server rejected it. Why that was a problem: - Users could bypass the Fixed MAC conflict guard with --net bridge or --network br0. - The browser allowed submissions that were known to fail server validation. What the new change accomplishes: - Detects --net and --network followed by either equals or whitespace. - Adds matching client-side feedback before form submission. - Updates the related container-network ExtraParams handling to recognize space-separated container network syntax. How it works: - Centralizes PHP network option detection in hasNetworkParam(). - Mirrors the same detection in the Docker form JavaScript. - Expands container: network parsing and replacement to accept both --network=container:name and --network container:name. --- .../include/CreateDocker.php | 20 +++++++++++++------ .../include/Helpers.php | 10 +++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index 743d662819..d0592dedad 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -69,7 +69,7 @@ function cpu_pinning() { ########################## if (isset($_POST['contName'])) { - $extraNetwork = preg_match('/\-\-net(work)?=/', $_POST['contExtraParams'] ?? ''); + $extraNetwork = hasNetworkParam($_POST['contExtraParams'] ?? ''); if ($extraNetwork && trim($_POST['contMyMAC'] ?? '') !== '') { readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); echo '

',_('Error'),': ',_('Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.'),'

'; @@ -216,10 +216,9 @@ function cpu_pinning() { if (preg_match('/^container:(.*)/', $Network)) { $Net_Container = str_replace("container:", "", $Network); } else { - preg_match("/--(net|network)=container:[^\s]+/", $ExtraParams, $NetworkParam); - if (!empty($NetworkParam[0])) { - $Net_Container = explode(':', $NetworkParam[0])[1]; - $Net_Container = str_replace(['"', "'"], '', $Net_Container); + preg_match("/--(?:net|network)(?:=|\s+)(['\"]?)container:([^'\"\s]+)\\1/", $ExtraParams, $NetworkParam); + if (!empty($NetworkParam[2])) { + $Net_Container = $NetworkParam[2]; } } // check if the container still exists from which the network should be used, if it doesn't exist any more recreate container with network none and don't start it @@ -227,7 +226,7 @@ function cpu_pinning() { $Net_Container_ID = $DockerClient->getContainerID($Net_Container); if (empty($Net_Container_ID)) { $cmd = str_replace('/docker run -d ', '/docker create ', $cmd); - $cmd = preg_replace("/--(net|network)=(['\"]?)container:[^'\"]+\\2/", "--network=none ", $cmd); + $cmd = preg_replace("/--(?:net|network)(?:=|\s+)(['\"]?)container:[^'\"\s]+\\1/", "--network=none ", $cmd); } } // force kill container if still running after time-out @@ -751,6 +750,11 @@ function removeConfig(num) { function prepareConfig(form) { var types = [], values = [], targets = [], vcpu = []; var myMAC = $(form).find('input[name="contMyMAC"]').val().trim().replaceAll('-', ':').toLowerCase(); + var extraParams = $(form).find('input[name="contExtraParams"]').val(); + if (myMAC && hasNetworkParam(extraParams)) { + swal({title:"_(Invalid network settings)_",text:"_(Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.)_",type:"error",html:true}); + return false; + } if (myMAC && !isValidUnicastMacAddress(myMAC)) { swal({title:"_(Invalid MAC address)_",text:"_(Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.)_",type:"error",html:true}); return false; @@ -777,6 +781,10 @@ function isValidUnicastMacAddress(mac) { return (parseInt(mac.substring(0, 2), 16) & 1) === 0; } +function hasNetworkParam(extraParams) { + return /(^|\s)--net(work)?(=|\s+)/.test(extraParams || ''); +} + function makeName(type) { var i = $("#configLocation input[name^='confType'][value='"+type+"']").length+1; return "Host "+type.replace('Variable','Key')+" "+i; diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 5d65181947..366141f203 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -49,6 +49,10 @@ function removeMacAddressParam($extraParams) { return trim(preg_replace('/(^|\s)--mac-address(?:=|\s+)(?:"[^"]+"|\'[^\']+\'|[^\s]+)/', '$1', $extraParams)); } +function hasNetworkParam($extraParams) { + return is_string($extraParams) && preg_match('/(?:^|\s)--net(?:work)?(?:=|\s+)/', $extraParams); +} + function normalizeMacAddress($mac) { $mac = strtolower(trim($mac ?? '')); if ($mac === '') { @@ -114,7 +118,7 @@ function postToXML($post, $setOwnership=false) { $xml->Network = xml_encode($post['contNetwork']); } $xml->MyIP = xml_encode($post['contMyIP']); - $extraNetwork = preg_match('/\-\-net(work)?=/', $post['contExtraParams'] ?? ''); + $extraNetwork = hasNetworkParam($post['contExtraParams'] ?? ''); $myMAC = $extraNetwork ? '' : normalizeMacAddress(trim($post['contMyMAC'] ?? '') ?: extractMacAddressParam($post['contExtraParams'] ?? '')); $xml->MyMAC = xml_encode($myMAC); $xml->Shell = xml_encode($post['contShell']); @@ -192,7 +196,7 @@ function xmlToVar($xml) { $out['Network'] = xml_decode($xml->Network); $out['MyIP'] = xml_decode($xml->MyIP ?? ''); $extraParams = xml_decode($xml->ExtraParams ?? ''); - $extraNetwork = preg_match('/\-\-net(work)?=/', $extraParams); + $extraNetwork = hasNetworkParam($extraParams); $out['MyMAC'] = $extraNetwork ? '' : normalizeMacAddress(xml_decode($xml->MyMAC ?? '') ?: extractMacAddressParam($extraParams)); $out['Shell'] = xml_decode($xml->Shell ?? 'sh'); $out['Privileged'] = xml_decode($xml->Privileged); @@ -370,7 +374,7 @@ function xmlToCommand($xml, $create_paths=false) { $xml = xmlToVar($xml); $cmdName = strlen($xml['Name']) ? '--name='.escapeshellarg($xml['Name']) : ''; $cmdPrivileged = strtolower($xml['Privileged'])=='true' ? '--privileged=true' : ''; - $extraNetwork = preg_match('/\-\-net(work)?=/',$xml['ExtraParams']); + $extraNetwork = hasNetworkParam($xml['ExtraParams']); $cmdMyIP = ''; if (preg_match('/^container:(.*)/', $xml['Network'])) { $cmdNetwork = $extraNetwork ? "" : '--net='.escapeshellarg($xml['Network']); From 131f18ba5c8a5c45e2fa5744e434373568e73f2a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 14:02:16 -0400 Subject: [PATCH 04/11] refactor(docker): reuse container form errors Purpose of the change: - Remove duplicated Docker container form error rendering. How behavior was before: - Fixed MAC validation paths each repeated log output, error markup, and Back button rendering. Why that was a problem: - The duplicated blocks made the validation code noisier and easier to drift as more validation is added. What the new change accomplishes: - Adds a shared dockerFormError() helper for early form validation failures. - Keeps the existing error messages and navigation behavior unchanged. How it works: - The helper renders the standard Docker log frame, translated error label, message, and Back button. - Existing Fixed MAC guards call the helper before jumping to END. --- .../include/CreateDocker.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index d0592dedad..1445f2e4f0 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -57,6 +57,13 @@ function cpu_pinning() { } } +function dockerFormError($message) { + global $docroot; + readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); + echo '

',_('Error'),': ',$message,'

'; + echo '

'; +} + # ██████╗ ██████╗ ██████╗ ███████╗ # ██╔════╝██╔═══██╗██╔══██╗██╔════╝ # ██║ ██║ ██║██║ ██║█████╗ @@ -71,16 +78,12 @@ function cpu_pinning() { if (isset($_POST['contName'])) { $extraNetwork = hasNetworkParam($_POST['contExtraParams'] ?? ''); if ($extraNetwork && trim($_POST['contMyMAC'] ?? '') !== '') { - readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); - echo '

',_('Error'),': ',_('Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.'),'

'; - echo '

'; + dockerFormError(_('Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.')); goto END; } $submittedMAC = trim($_POST['contMyMAC'] ?? '') ?: extractMacAddressParam($_POST['contExtraParams'] ?? ''); if ($submittedMAC !== '' && !isValidUnicastMacAddress($submittedMAC)) { - readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); - echo '

',_('Error'),': ',_('Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.'),'

'; - echo '

'; + dockerFormError(_('Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.')); goto END; } $postXML = postToXML($_POST, true); From c3da0b6ae1cb74b87bc9c8e3c8ee91f5ff7f63f9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 14:13:24 -0400 Subject: [PATCH 05/11] fix(docker): show fixed mac for bridge networking Purpose of the change: - Allow users to set a fixed MAC address when Docker Network Type is Bridge. How behavior was before: - The Fixed MAC address field was inside the Fixed IP section. - Bridge mode hides Fixed IP, so the MAC field was hidden even though Docker accepts endpoint MAC settings for bridge networking. Why that was a problem: - Users could only access the fixed MAC field for custom networks. - Bridge containers could still be created with a fixed endpoint MAC by command generation, but the GUI did not expose the field. What the new change accomplishes: - Splits Fixed MAC address into its own visibility block. - Shows Fixed MAC address for Bridge and custom networks. - Keeps Fixed IP hidden for Bridge and keeps both fields hidden for Host, None, and Container network modes. How it works: - showSubnet() now controls .myIP and .myMAC independently. - Bridge hides and clears Fixed IP while showing Fixed MAC. --- .../include/CreateDocker.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index 1445f2e4f0..e239b6df71 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -1151,6 +1151,9 @@ function prepareCategory() { :docker_fixed_ip_help: +
+ +
_(Fixed MAC address)_ (_(optional)_): : @@ -1607,21 +1610,30 @@ function prepareCategory() { function showSubnet(bridge) { - if (bridge.match(/^(bridge|host|none)$/i) !== null) { + if (bridge.match(/^(host|none)$/i) !== null) { $('.myIP').hide(); $('input[name="contMyIP"]').val(''); + $('.myMAC').hide(); $('input[name="contMyMAC"]').val(''); $('.netCONT').hide(); $('#netCONT').val(''); + } else if (bridge.match(/^(bridge)$/i) !== null) { + $('.myIP').hide(); + $('input[name="contMyIP"]').val(''); + $('.myMAC').show(); + $('.netCONT').hide(); + $('#netCONT').val(''); } else if (bridge.match(/^(container)$/i) !== null) { $('.netCONT').show(); $('#netCONT').val(''); $('.myIP').hide(); $('input[name="contMyIP"]').val(''); + $('.myMAC').hide(); $('input[name="contMyMAC"]').val(''); } else { $('.myIP').show(); $('#myIP').html(': '+subnet[bridge]); + $('.myMAC').show(); $('.netCONT').hide(); $('#netCONT').val(''); } From 004187506d3f848a3ff1acda071914a6daf438f9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 14:15:06 -0400 Subject: [PATCH 06/11] refactor(docker): defer fixed mac errors to docker Purpose of the change: - Remove custom preflight errors for advanced Fixed MAC edge cases. How behavior was before: - The form blocked Fixed MAC submissions when Extra Parameters also specified networking. - The form also pre-validated unicast MAC semantics before Docker ran. Why that was a problem: - The duplicate network case requires an advanced self-conflicting setup and Docker already reports it clearly. - Invalid MAC assignment errors are also surfaced by Docker during command execution. - The extra custom validation added code and UX paths that were not necessary for the main feature. What the new change accomplishes: - Keeps the Fixed MAC field and endpoint command generation focused. - Removes custom server and client preflight validation for duplicate network args and invalid unicast MACs. - Keeps lightweight input normalization for valid MAC formats. How it works: - prepareConfig() still normalizes hyphenated MAC input to colon-separated lowercase form. - Docker command execution remains the authority for invalid MAC values or duplicate network flags. --- .../include/CreateDocker.php | 40 ------------------- .../include/Helpers.php | 9 ----- 2 files changed, 49 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php index e239b6df71..2966cf214a 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php +++ b/emhttp/plugins/dynamix.docker.manager/include/CreateDocker.php @@ -57,13 +57,6 @@ function cpu_pinning() { } } -function dockerFormError($message) { - global $docroot; - readfile("$docroot/plugins/dynamix.docker.manager/log.htm"); - echo '

',_('Error'),': ',$message,'

'; - echo '

'; -} - # ██████╗ ██████╗ ██████╗ ███████╗ # ██╔════╝██╔═══██╗██╔══██╗██╔════╝ # ██║ ██║ ██║██║ ██║█████╗ @@ -76,16 +69,6 @@ function dockerFormError($message) { ########################## if (isset($_POST['contName'])) { - $extraNetwork = hasNetworkParam($_POST['contExtraParams'] ?? ''); - if ($extraNetwork && trim($_POST['contMyMAC'] ?? '') !== '') { - dockerFormError(_('Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.')); - goto END; - } - $submittedMAC = trim($_POST['contMyMAC'] ?? '') ?: extractMacAddressParam($_POST['contExtraParams'] ?? ''); - if ($submittedMAC !== '' && !isValidUnicastMacAddress($submittedMAC)) { - dockerFormError(_('Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.')); - goto END; - } $postXML = postToXML($_POST, true); $dry_run = isset($_POST['dryRun']) && $_POST['dryRun']=='true'; $existing = _var($_POST,'existingContainer',false); @@ -753,15 +736,6 @@ function removeConfig(num) { function prepareConfig(form) { var types = [], values = [], targets = [], vcpu = []; var myMAC = $(form).find('input[name="contMyMAC"]').val().trim().replaceAll('-', ':').toLowerCase(); - var extraParams = $(form).find('input[name="contExtraParams"]').val(); - if (myMAC && hasNetworkParam(extraParams)) { - swal({title:"_(Invalid network settings)_",text:"_(Fixed MAC address cannot be used when Extra Parameters specify --network or --net. Add mac-address to the Extra Parameters network option instead.)_",type:"error",html:true}); - return false; - } - if (myMAC && !isValidUnicastMacAddress(myMAC)) { - swal({title:"_(Invalid MAC address)_",text:"_(Fixed MAC address must be a valid unicast MAC address. The first octet must be even, for example 02:42:9a:0d:7e:c0.)_",type:"error",html:true}); - return false; - } $(form).find('input[name="contMyMAC"]').val(myMAC); if ($('select[name="contNetwork"]').val()=='host') { $(form).find('input[name="confType[]"]').each(function(){types.push($(this).val());}); @@ -774,20 +748,6 @@ function prepareConfig(form) { return true; } -function isValidUnicastMacAddress(mac) { - if (mac.match(/^[0-9a-f]{12}$/i)) { - mac = mac.match(/.{1,2}/g).join(':'); - } - if (!mac.match(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i)) { - return false; - } - return (parseInt(mac.substring(0, 2), 16) & 1) === 0; -} - -function hasNetworkParam(extraParams) { - return /(^|\s)--net(work)?(=|\s+)/.test(extraParams || ''); -} - function makeName(type) { var i = $("#configLocation input[name^='confType'][value='"+type+"']").length+1; return "Host "+type.replace('Variable','Key')+" "+i; diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 366141f203..26ef40bd7d 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -66,15 +66,6 @@ function normalizeMacAddress($mac) { return preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $mac) ? $mac : ''; } -function isValidUnicastMacAddress($mac) { - $mac = normalizeMacAddress($mac); - if ($mac === '') { - return false; - } - $firstOctet = hexdec(substr($mac, 0, 2)); - return ($firstOctet & 1) === 0; -} - function generateTSwebui($url, $serve, $webUI) { if (!isset($webUI)) { return ''; From b2263beb4f28b3a03f92ee1ad3c9f655ba8e5377 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 14:23:30 -0400 Subject: [PATCH 07/11] fix(docker): parse extra params shell tokens Purpose of the change: - Avoid treating quoted or escaped Extra Parameters values as Docker network or MAC flags. How behavior was before: - Helper functions used regexes directly on the full Extra Parameters string. - Text such as --network or --mac-address inside quoted values could be detected as real flags. Why that was a problem: - Template migration could extract or remove a MAC flag from quoted content that was not an actual Docker CLI option. - Network ownership detection could incorrectly treat quoted values as --net/--network options. What the new change accomplishes: - Adds shell-aware tokenization for Extra Parameters. - Finds --mac-address and --net/--network only when they appear as actual tokens. - Removes only the real MAC flag token and its value while preserving quoted content. How it works: - Tokenization tracks single quotes, double quotes, and escapes. - extractMacAddressParam(), removeMacAddressParam(), and hasNetworkParam() now operate on parsed token values instead of raw regex matches. --- .../include/Helpers.php | 107 ++++++++++++++++-- 1 file changed, 99 insertions(+), 8 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 26ef40bd7d..58c8919e5d 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -32,25 +32,116 @@ function xml_decode($string) { return strval(html_entity_decode($string, ENT_XML1, 'UTF-8')); } -function extractMacAddressParam($extraParams) { - if (!is_string($extraParams) || !preg_match('/(?:^|\s)--mac-address(?:=|\s+)(?:"([^"]+)"|\'([^\']+)\'|([^\s]+))/', $extraParams, $match)) { - return ''; +function extraParamsTokens($extraParams) { + if (!is_string($extraParams) || $extraParams === '') { + return []; + } + $tokens = []; + $leading = ''; + $raw = ''; + $value = ''; + $quote = ''; + $escaped = false; + $inToken = false; + $length = strlen($extraParams); + + for ($i = 0; $i < $length; $i++) { + $char = $extraParams[$i]; + if (!$inToken && ctype_space($char)) { + $leading .= $char; + continue; + } + if (!$inToken) { + $inToken = true; + $raw = ''; + $value = ''; + } + $raw .= $char; + if ($escaped) { + $value .= $char; + $escaped = false; + continue; + } + if ($quote) { + if ($char === $quote) { + $quote = ''; + } elseif ($quote === '"' && $char === '\\') { + $escaped = true; + } else { + $value .= $char; + } + continue; + } + if ($char === '\\') { + $escaped = true; + continue; + } + if ($char === '"' || $char === "'") { + $quote = $char; + continue; + } + if (ctype_space($char)) { + $tokens[] = ['leading' => $leading, 'raw' => substr($raw, 0, -1), 'value' => $value]; + $leading = $char; + $raw = ''; + $value = ''; + $inToken = false; + continue; + } + $value .= $char; } - foreach (array_slice($match, 1) as $value) { - if (strlen($value ?? '')) return trim($value); + if ($inToken) { + $tokens[] = ['leading' => $leading, 'raw' => $raw, 'value' => $value]; + } + return $tokens; +} + +function extractMacAddressParam($extraParams) { + $tokens = extraParamsTokens($extraParams); + for ($i = 0, $count = count($tokens); $i < $count; $i++) { + $value = $tokens[$i]['value']; + if ($value === '--mac-address') { + return trim($tokens[$i + 1]['value'] ?? ''); + } + if (strpos($value, '--mac-address=') === 0) { + return trim(substr($value, strlen('--mac-address='))); + } } return ''; } function removeMacAddressParam($extraParams) { - if (!is_string($extraParams) || $extraParams === '') { + $tokens = extraParamsTokens($extraParams); + if (!$tokens) { return ''; } - return trim(preg_replace('/(^|\s)--mac-address(?:=|\s+)(?:"[^"]+"|\'[^\']+\'|[^\s]+)/', '$1', $extraParams)); + $out = ''; + $skipNext = false; + foreach ($tokens as $token) { + if ($skipNext) { + $skipNext = false; + continue; + } + if ($token['value'] === '--mac-address') { + $skipNext = true; + continue; + } + if (strpos($token['value'], '--mac-address=') === 0) { + continue; + } + $out .= $token['leading'].$token['raw']; + } + return trim($out); } function hasNetworkParam($extraParams) { - return is_string($extraParams) && preg_match('/(?:^|\s)--net(?:work)?(?:=|\s+)/', $extraParams); + foreach (extraParamsTokens($extraParams) as $token) { + $value = $token['value']; + if ($value === '--net' || $value === '--network' || strpos($value, '--net=') === 0 || strpos($value, '--network=') === 0) { + return true; + } + } + return false; } function normalizeMacAddress($mac) { From fc85e36b7c364b71054932eafff51d2bb8d9171d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 14:33:18 -0400 Subject: [PATCH 08/11] refactor(docker): simplify extra params mac parsing - Replace the shell-style Extra Parameters tokenizer with a smaller conservative parser. - Before, the helper attempted to fully tokenize Extra Parameters to migrate legacy --mac-address values and detect --network/--net ownership. - That was more complex than this migration path needs and increased maintenance risk around shell quoting edge cases. - Now, only simple top-level unquoted --mac-address, --network, and --net forms are interpreted by the migration helpers. - Quoted values are masked or preserved so labels and environment values containing those strings are left untouched. --- .../include/Helpers.php | 121 ++++-------------- 1 file changed, 28 insertions(+), 93 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 58c8919e5d..487eaa7ac1 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -32,116 +32,51 @@ function xml_decode($string) { return strval(html_entity_decode($string, ENT_XML1, 'UTF-8')); } -function extraParamsTokens($extraParams) { - if (!is_string($extraParams) || $extraParams === '') { - return []; - } - $tokens = []; - $leading = ''; - $raw = ''; - $value = ''; - $quote = ''; - $escaped = false; - $inToken = false; - $length = strlen($extraParams); +function extraParamsWithQuotedValuesMasked($extraParams) { + return preg_replace('/"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"|\'[^\']*\'/', '""', $extraParams); +} - for ($i = 0; $i < $length; $i++) { - $char = $extraParams[$i]; - if (!$inToken && ctype_space($char)) { - $leading .= $char; - continue; - } - if (!$inToken) { - $inToken = true; - $raw = ''; - $value = ''; - } - $raw .= $char; - if ($escaped) { - $value .= $char; - $escaped = false; - continue; - } - if ($quote) { - if ($char === $quote) { - $quote = ''; - } elseif ($quote === '"' && $char === '\\') { - $escaped = true; - } else { - $value .= $char; - } - continue; - } - if ($char === '\\') { - $escaped = true; - continue; - } - if ($char === '"' || $char === "'") { - $quote = $char; - continue; - } - if (ctype_space($char)) { - $tokens[] = ['leading' => $leading, 'raw' => substr($raw, 0, -1), 'value' => $value]; - $leading = $char; - $raw = ''; - $value = ''; - $inToken = false; +function replaceUnquotedExtraParams($extraParams, $callback) { + $parts = preg_split('/("[^"\\\\]*(?:\\\\.[^"\\\\]*)*"|\'[^\']*\')/', $extraParams, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) { + return $extraParams; + } + foreach ($parts as $i => $part) { + if ($part === '' || $part[0] === '"' || $part[0] === "'") { continue; } - $value .= $char; - } - if ($inToken) { - $tokens[] = ['leading' => $leading, 'raw' => $raw, 'value' => $value]; + $parts[$i] = $callback($part); } - return $tokens; + return implode('', $parts); } function extractMacAddressParam($extraParams) { - $tokens = extraParamsTokens($extraParams); - for ($i = 0, $count = count($tokens); $i < $count; $i++) { - $value = $tokens[$i]['value']; - if ($value === '--mac-address') { - return trim($tokens[$i + 1]['value'] ?? ''); - } - if (strpos($value, '--mac-address=') === 0) { - return trim(substr($value, strlen('--mac-address='))); - } + if (!is_string($extraParams)) { + return ''; + } + $extraParams = extraParamsWithQuotedValuesMasked($extraParams); + if (preg_match('/(?:^|\s)--mac-address=([^\s\'"]+)/', $extraParams, $match)) { + return trim($match[1]); + } + if (preg_match('/(?:^|\s)--mac-address\s+([^\s\'"]+)/', $extraParams, $match)) { + return trim($match[1]); } return ''; } function removeMacAddressParam($extraParams) { - $tokens = extraParamsTokens($extraParams); - if (!$tokens) { + if (!is_string($extraParams) || $extraParams === '') { return ''; } - $out = ''; - $skipNext = false; - foreach ($tokens as $token) { - if ($skipNext) { - $skipNext = false; - continue; - } - if ($token['value'] === '--mac-address') { - $skipNext = true; - continue; - } - if (strpos($token['value'], '--mac-address=') === 0) { - continue; - } - $out .= $token['leading'].$token['raw']; - } - return trim($out); + $extraParams = replaceUnquotedExtraParams($extraParams, function($part) { + $part = preg_replace('/(^|\s)--mac-address=[^\s\'"]+/', '$1', $part); + return preg_replace('/(^|\s)--mac-address\s+[^\s\'"]+/', '$1', $part); + }); + return trim($extraParams); } function hasNetworkParam($extraParams) { - foreach (extraParamsTokens($extraParams) as $token) { - $value = $token['value']; - if ($value === '--net' || $value === '--network' || strpos($value, '--net=') === 0 || strpos($value, '--network=') === 0) { - return true; - } - } - return false; + return is_string($extraParams) && preg_match('/(?:^|\s)--net(?:work)?(?:=|\s+)[^\s\'"]+/', extraParamsWithQuotedValuesMasked($extraParams)); } function normalizeMacAddress($mac) { From 93903c057c972300719b31f230a35b379c92277b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 15:04:12 -0400 Subject: [PATCH 09/11] fix(docker): classify leading-colon ipv6 addresses - Update Docker command generation to test IPv6 detection with an explicit strpos comparison. - Before, addresses starting with :: produced strpos(..., ':') === 0 and were treated as IPv4 because 0 is falsy. - That could emit ip= or --ip= for IPv6 values that begin with a colon. - Now, both endpoint-level network options and legacy --ip/--ip6 flags classify any address containing a colon as IPv6. - The existing empty-address guard remains unchanged. --- emhttp/plugins/dynamix.docker.manager/include/Helpers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 487eaa7ac1..3df738c90d 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -403,7 +403,7 @@ function xmlToCommand($xml, $create_paths=false) { $xml['ExtraParams'] = removeMacAddressParam($xml['ExtraParams']); $networkEndpoint = ['name='.$networkName]; foreach (explode(' ',str_replace(',',' ',$xml['MyIP'])) as $myIP) { - if ($myIP) $networkEndpoint[] = (strpos($myIP,':')?'ip6=':'ip=').$myIP; + if ($myIP) $networkEndpoint[] = (strpos($myIP,':') !== false ? 'ip6=' : 'ip=').$myIP; } $networkEndpoint[] = 'mac-address='.$xml['MyMAC']; $cmdNetwork = '--network='.escapeshellarg(implode(',', $networkEndpoint)); @@ -412,7 +412,7 @@ function xmlToCommand($xml, $create_paths=false) { } } if (!strlen($xml['MyMAC']) || preg_match('/^container:(.*)/', $xml['Network']) || $extraNetwork) { - foreach (explode(' ',str_replace(',',' ',$xml['MyIP'])) as $myIP) if ($myIP) $cmdMyIP .= (strpos($myIP,':')?'--ip6=':'--ip=').escapeshellarg($myIP).' '; + foreach (explode(' ',str_replace(',',' ',$xml['MyIP'])) as $myIP) if ($myIP) $cmdMyIP .= (strpos($myIP,':') !== false ? '--ip6=' : '--ip=').escapeshellarg($myIP).' '; } $cmdCPUset = strlen($xml['CPUset']) ? '--cpuset-cpus='.escapeshellarg($xml['CPUset']) : ''; $Volumes = ['']; From bab527fbfd6c4cb845307cd3e0fefd2e9b9bfb1b Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 15:19:40 -0400 Subject: [PATCH 10/11] fix(docker): restore fixed macs on network restart - Reconnect Docker network endpoints when a stored fixed MAC differs from the current endpoint MAC. - Before, the network restore path returned as soon as an endpoint existed, so Docker daemon restarts could keep a newly randomized MAC even though the template stored MyMAC. - That meant a dummy container edit recreated the container with the right MAC, but normal Docker restart did not repair the existing endpoint. - Now, restart restore keeps the fast path when the endpoint is absent, has no stored MAC, or already matches, and only disconnects/reconnects when the stored MAC needs to be restored. - This applies to custom network names such as eth0 as well as br0 because it runs through the same netrestore path. --- etc/rc.d/rc.docker | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/etc/rc.d/rc.docker b/etc/rc.d/rc.docker index 373a096e56..7d23010089 100755 --- a/etc/rc.d/rc.docker +++ b/etc/rc.d/rc.docker @@ -248,6 +248,7 @@ netrestore_connect(){ local MY_OPTS= local IP= local ENDPOINT_ID= + local ENDPOINT_MAC= local OUT= container_exist "$CONTAINER" || return 0 @@ -263,9 +264,6 @@ netrestore_connect(){ return 1 fi - ENDPOINT_ID=$(docker inspect --format="{{with index .NetworkSettings.Networks \"$NETWORK\"}}{{.EndpointID}}{{end}}" "$CONTAINER" 2>/dev/null) - [[ -n $ENDPOINT_ID ]] && return 0 - for IP in ${MY_TT//;/ }; do [[ -n $IP ]] || continue if [[ $IP =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then @@ -278,6 +276,18 @@ netrestore_connect(){ done [[ -n $MY_MAC ]] && MY_OPTS="--driver-opt=com.docker.network.endpoint.macaddress=$MY_MAC" + ENDPOINT_ID=$(docker inspect --format="{{with index .NetworkSettings.Networks \"$NETWORK\"}}{{.EndpointID}}{{end}}" "$CONTAINER" 2>/dev/null) + if [[ -n $ENDPOINT_ID ]]; then + [[ -n $MY_MAC ]] || return 0 + ENDPOINT_MAC=$(docker inspect --format="{{with index .NetworkSettings.Networks \"$NETWORK\"}}{{.MacAddress}}{{end}}" "$CONTAINER" 2>/dev/null) + [[ ${ENDPOINT_MAC,,} == ${MY_MAC,,} ]] && return 0 + log "reconnecting $CONTAINER to network $NETWORK to restore MAC $MY_MAC" + if ! OUT=$(docker network disconnect -f "$NETWORK" "$CONTAINER" 2>&1); then + log "failed to disconnect $CONTAINER from network $NETWORK: $OUT" + return 1 + fi + fi + log "connecting $CONTAINER to network $NETWORK" if ! OUT=$(docker network connect $MY_OPTS $MY_IP $NETWORK $CONTAINER 2>&1); then log "failed to connect $CONTAINER to network $NETWORK: $OUT" From ba558022eeca1117211b2383e377bec219f69a7f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 21 Apr 2026 15:39:45 -0400 Subject: [PATCH 11/11] fix(docker): apply fixed macs through network API - Switch the Docker network restore path from endpoint driver options to the Docker network connect API when a fixed MAC is stored. - Before, rc.docker passed com.docker.network.endpoint.macaddress through docker network connect --driver-opt. - Docker 29.3.1 persisted that value in DriverOpts but still assigned a random endpoint MacAddress on macvlan networks. - That meant containers could reboot with random MACs even though MyMAC was stored in the template. - Now, fixed-MAC reconnects send EndpointConfig.MacAddress, with IPv4 and IPv6 addresses preserved through IPAMConfig, matching the working docker run endpoint form. --- etc/rc.d/rc.docker | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/etc/rc.d/rc.docker b/etc/rc.d/rc.docker index 7d23010089..a420587985 100755 --- a/etc/rc.d/rc.docker +++ b/etc/rc.d/rc.docker @@ -245,8 +245,14 @@ netrestore_connect(){ local MY_TT=$3 local MY_MAC=$4 local MY_IP= - local MY_OPTS= + local MY_IPV4= + local MY_IPV6= local IP= + local IPAM_JSON= + local ENDPOINT_JSON= + local CONNECT_JSON= + local CODE= + local BODY= local ENDPOINT_ID= local ENDPOINT_MAC= local OUT= @@ -267,15 +273,16 @@ netrestore_connect(){ for IP in ${MY_TT//;/ }; do [[ -n $IP ]] || continue if [[ $IP =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + MY_IPV4=$IP MY_IP="$MY_IP --ip $IP" elif [[ $IP =~ : ]]; then + MY_IPV6=$IP MY_IP="$MY_IP --ip6 $IP" else log "skipping invalid stored IP for $CONTAINER on network $NETWORK: $IP" fi done - [[ -n $MY_MAC ]] && MY_OPTS="--driver-opt=com.docker.network.endpoint.macaddress=$MY_MAC" ENDPOINT_ID=$(docker inspect --format="{{with index .NetworkSettings.Networks \"$NETWORK\"}}{{.EndpointID}}{{end}}" "$CONTAINER" 2>/dev/null) if [[ -n $ENDPOINT_ID ]]; then [[ -n $MY_MAC ]] || return 0 @@ -288,8 +295,24 @@ netrestore_connect(){ fi fi + if [[ -n $MY_MAC ]]; then + [[ -n $MY_IPV4 ]] && IPAM_JSON="\"IPv4Address\":\"$MY_IPV4\"" + [[ -n $MY_IPV6 ]] && IPAM_JSON="${IPAM_JSON:+$IPAM_JSON,}\"IPv6Address\":\"$MY_IPV6\"" + ENDPOINT_JSON="\"MacAddress\":\"$MY_MAC\"" + [[ -n $IPAM_JSON ]] && ENDPOINT_JSON="\"IPAMConfig\":{$IPAM_JSON},$ENDPOINT_JSON" + CONNECT_JSON="{\"Container\":\"$CONTAINER\",\"EndpointConfig\":{$ENDPOINT_JSON}}" + OUT=$(curl --unix-socket /var/run/docker.sock -sS -w $'\n%{http_code}' -X POST -H "Content-Type: application/json" --data "$CONNECT_JSON" "http://localhost/networks/$NETWORK/connect" 2>&1) + CODE=${OUT##*$'\n'} + BODY=${OUT%$'\n'$CODE} + if [[ $CODE != 2* ]]; then + log "failed to connect $CONTAINER to network $NETWORK: $BODY" + return 1 + fi + return 0 + fi + log "connecting $CONTAINER to network $NETWORK" - if ! OUT=$(docker network connect $MY_OPTS $MY_IP $NETWORK $CONTAINER 2>&1); then + if ! OUT=$(docker network connect $MY_IP $NETWORK $CONTAINER 2>&1); then log "failed to connect $CONTAINER to network $NETWORK: $OUT" return 1 fi