From 00a8773460b1fc680bce4ec724609930a666f145 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Mon, 8 Dec 2025 19:29:55 +0000 Subject: [PATCH 01/26] fix missing files. --- emhttp/plugins/dynamix.vm.manager/novnc/defaults.json | 1 + emhttp/plugins/dynamix.vm.manager/novnc/mandatory.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 emhttp/plugins/dynamix.vm.manager/novnc/defaults.json create mode 100644 emhttp/plugins/dynamix.vm.manager/novnc/mandatory.json diff --git a/emhttp/plugins/dynamix.vm.manager/novnc/defaults.json b/emhttp/plugins/dynamix.vm.manager/novnc/defaults.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/emhttp/plugins/dynamix.vm.manager/novnc/defaults.json @@ -0,0 +1 @@ +{} diff --git a/emhttp/plugins/dynamix.vm.manager/novnc/mandatory.json b/emhttp/plugins/dynamix.vm.manager/novnc/mandatory.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/emhttp/plugins/dynamix.vm.manager/novnc/mandatory.json @@ -0,0 +1 @@ +{} From d86781f4e8f9cc01b4f7f91f4f34ff322c53040a Mon Sep 17 00:00:00 2001 From: Squidly271 Date: Fri, 12 Dec 2025 12:32:39 -0500 Subject: [PATCH 02/26] Backport ReadMe not populating in templates --- emhttp/plugins/dynamix.docker.manager/include/Helpers.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 723ee22af3..2b5425128e 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -79,6 +79,7 @@ function postToXML($post, $setOwnership=false) { $xml->Privileged = strtolower($post['contPrivileged']??'')=='on' ? 'true' : 'false'; $xml->Support = xml_encode($post['contSupport']); $xml->Project = xml_encode($post['contProject']); + $xml->ReadMe = xml_encode($post['contReadMe']); $xml->Overview = xml_encode($post['contOverview']); $xml->Category = xml_encode($post['contCategory']); $xml->WebUI = xml_encode(trim($post['contWebUI'])); @@ -152,6 +153,7 @@ function xmlToVar($xml) { $out['Privileged'] = xml_decode($xml->Privileged); $out['Support'] = xml_decode($xml->Support); $out['Project'] = xml_decode($xml->Project); + $out['ReadMe'] = xml_decode($xml->ReadMe); $out['Overview'] = stripslashes(xml_decode($xml->Overview)); $out['Category'] = xml_decode($xml->Category); $out['WebUI'] = xml_decode($xml->WebUI); From 0c064967288168d876d8068b2a65cc9c4330f919 Mon Sep 17 00:00:00 2001 From: ljm42 Date: Fri, 12 Dec 2025 10:41:52 -0700 Subject: [PATCH 03/26] backport: fix: disable rc.avahidnsconfd by default backport from #2469 --- etc/rc.d/rc.M | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/etc/rc.d/rc.M b/etc/rc.d/rc.M index 714bdf993f..04bbd07409 100755 --- a/etc/rc.d/rc.M +++ b/etc/rc.d/rc.M @@ -246,7 +246,8 @@ fi # Start avahi: if [[ -x /etc/rc.d/rc.avahidaemon ]]; then /etc/rc.d/rc.avahidaemon start - /etc/rc.d/rc.avahidnsconfd start + # disable by default, users can start manually if needed + # /etc/rc.d/rc.avahidnsconfd start fi # Start Samba (a file/print server for Windows machines). From 49b48445f8b007944d133fd8ea7da168d08bf14e Mon Sep 17 00:00:00 2001 From: ljm42 Date: Fri, 12 Dec 2025 10:45:53 -0700 Subject: [PATCH 04/26] backport: feat: add lsusb details to diagnostics backport from #2470 --- emhttp/plugins/dynamix/scripts/diagnostics | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/scripts/diagnostics b/emhttp/plugins/dynamix/scripts/diagnostics index 97563e220f..027b04ae2f 100755 --- a/emhttp/plugins/dynamix/scripts/diagnostics +++ b/emhttp/plugins/dynamix/scripts/diagnostics @@ -497,7 +497,7 @@ run("lscpu 2>/dev/null|todos >".escapeshellarg("/$diag/system/lscpu.txt")); run("lsscsi -vgl 2>/dev/null|todos >".escapeshellarg("/$diag/system/lsscsi.txt")); run("lspci -knn 2>/dev/null|todos >".escapeshellarg("/$diag/system/lspci.txt")); run("lspci -vv 2>/dev/null| awk -b '/ASPM/{print $0}' RS=|grep -P '(^[a-z0-9:.]+|ASPM |Disabled;|Enabled;)'|todos >".escapeshellarg("/$diag/system/aspm-status.txt")); -run("lsusb 2>/dev/null|todos >".escapeshellarg("/$diag/system/lsusb.txt")); +run("lsusb -vt 2>/dev/null|todos >".escapeshellarg("/$diag/system/lsusb.txt")); run("free -mth 2>/dev/null|todos >".escapeshellarg("/$diag/system/memory.txt")); run("lsof -Pni 2>/dev/null|todos >".escapeshellarg("/$diag/system/lsof.txt")); run("lsmod|sort 2>/dev/null|todos >".escapeshellarg("/$diag/system/lsmod.txt")); From e90c7005dad0a2f74aa13b95f001014dab1f673a Mon Sep 17 00:00:00 2001 From: Tom Mortensen Date: Wed, 17 Dec 2025 07:55:32 -0800 Subject: [PATCH 05/26] move redirect.htm from webgui repo to api repo --- emhttp/redirect.htm | 50 --------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 emhttp/redirect.htm diff --git a/emhttp/redirect.htm b/emhttp/redirect.htm deleted file mode 100644 index 8d4be51332..0000000000 --- a/emhttp/redirect.htm +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - Redirect Page - - - - -
-
- - - - From e8b0f569bdf6659e7cc1a7fcc88949eeaa2d1094 Mon Sep 17 00:00:00 2001 From: Tom Mortensen Date: Wed, 17 Dec 2025 07:56:11 -0800 Subject: [PATCH 06/26] fix: Possible XSS via email test functionality --- emhttp/plugins/dynamix/include/SMTPtest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix/include/SMTPtest.php b/emhttp/plugins/dynamix/include/SMTPtest.php index 2d3b2f1e31..9e6854f1cc 100644 --- a/emhttp/plugins/dynamix/include/SMTPtest.php +++ b/emhttp/plugins/dynamix/include/SMTPtest.php @@ -40,7 +40,7 @@ function PsKill($pid) { if (PsExecute("$docroot/webGui/scripts/notify -s 'Unraid SMTP Test' -d 'Test message received!' -i 'alert' -l '/Settings/Notifications' -t")) { $result = exec("tail -3 /var/log/syslog|awk '/sSMTP/ {getline;print}'|cut -d']' -f2|cut -d'(' -f1"); $color = strpos($result, 'Sent mail') ? 'green' : 'red'; - echo _("Test result")."$result"; + echo _("Test result")."".htmlspecialchars($result).""; } else { echo _("Test result").": "._('No reply from mail server').""; } From b0e59c6898e374807bc2c1dc03b2df10089bc799 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 21 Feb 2026 09:50:44 -0500 Subject: [PATCH 07/26] fix: persist rclone config via rc.local with hardened init script --- etc/rc.d/rc.local | 6 ++ sbin/rclone_config_init | 161 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100755 sbin/rclone_config_init diff --git a/etc/rc.d/rc.local b/etc/rc.d/rc.local index 292bf1fdeb..867c847a86 100755 --- a/etc/rc.d/rc.local +++ b/etc/rc.d/rc.local @@ -56,6 +56,7 @@ mkdir -p $CONFIG/plugins/dockerMan mkdir -p $CONFIG/plugins/dynamix/users mkdir -p $CONFIG/plugins-error/ mkdir -p $CONFIG/pools +mkdir -p $CONFIG/rclone mkdir -p $CONFIG/shares mkdir -p $CONFIG/ssh/root mkdir -p $CONFIG/ssl/certs @@ -207,6 +208,11 @@ else shopt -u nullglob fi +# Re-run rclone config setup after plugin install phase (plugins-nextboot may have added rclone). +if [[ -x /usr/local/sbin/rclone_config_init ]]; then + /usr/local/sbin/rclone_config_init "$CONFIG" +fi + # Install languages log "Installing language packs" shopt -s nullglob diff --git a/sbin/rclone_config_init b/sbin/rclone_config_init new file mode 100755 index 0000000000..b7bff08f2f --- /dev/null +++ b/sbin/rclone_config_init @@ -0,0 +1,161 @@ +#!/bin/bash +# +# script: rclone_config_init +# +# Keep rclone config on persistent storage while preserving default lookup paths. +# This helper must never break boot flow; keep execution non-fatal. +set +e +set +u +set +o pipefail + +CONFIG="${1:-/boot/config}" +RCLONE_DEFAULT_BOOT_CONFIG="$CONFIG/rclone/rclone.conf" +RCLONE_PLUGIN_BOOT_CONFIG="$CONFIG/plugins/rclone/.rclone.conf" +RCLONE_ROOT_CONFIG="${2:-/root/.config/rclone/rclone.conf}" +RCLONE_BACKUP_DIR="$CONFIG/rclone/backups" +RCLONE_USE_PLUGIN_CONFIG=false + +if [[ -e "$CONFIG/plugins/rclone.plg" || -e "$RCLONE_PLUGIN_BOOT_CONFIG" ]]; then + RCLONE_BOOT_CONFIG="$RCLONE_PLUGIN_BOOT_CONFIG" + RCLONE_USE_PLUGIN_CONFIG=true +else + RCLONE_BOOT_CONFIG="$RCLONE_DEFAULT_BOOT_CONFIG" +fi + +log_issue() { + if declare -F log >/dev/null 2>&1; then + log "$1" + else + echo "$1" + fi +} + +ensure_paths() { + if ! mkdir -p "$(dirname "$RCLONE_ROOT_CONFIG")" "$RCLONE_BACKUP_DIR" "$(dirname "$RCLONE_BOOT_CONFIG")" 2>/dev/null; then + log_issue "ERROR: failed to ensure rclone config directories exist" + return 1 + fi +} + +backup_rclone_config() { + local src="$1" + local backup + backup="$RCLONE_BACKUP_DIR/$(basename "$src").$(date +%Y%m%d-%H%M%S)" + if cp -a -- "$src" "$backup"; then + return 0 + fi + log_issue "ERROR: failed to backup rclone config from $src" + return 1 +} + +link_to_boot_config() { + local link_path="$1" + local link_label="$2" + local error_action="$3" + + if ln -s "$RCLONE_BOOT_CONFIG" "$link_path"; then + return 0 + fi + + log_issue "ERROR: failed to $error_action $link_label symlink to $RCLONE_BOOT_CONFIG" + return 1 +} + +link_rclone_config() { + local link_path="$1" + local link_label="$2" + + if [[ -L "$link_path" ]]; then + local link_target + link_target=$(readlink -f "$link_path" 2>/dev/null || readlink "$link_path" 2>/dev/null || true) + if [[ "$link_target" != "$RCLONE_BOOT_CONFIG" ]]; then + if [[ -f "$link_target" && ! -e "$RCLONE_BOOT_CONFIG" ]]; then + if cp -a -- "$link_target" "$RCLONE_BOOT_CONFIG"; then + : + else + log_issue "ERROR: failed to migrate $link_label from $link_target to $RCLONE_BOOT_CONFIG" + return + fi + elif [[ -f "$link_target" ]]; then + backup_rclone_config "$link_target" || log_issue "WARNING: continuing $link_label symlink migration without backup" + fi + + if rm -f "$link_path"; then + link_to_boot_config "$link_path" "$link_label" "update" + else + log_issue "ERROR: failed to update $link_label symlink to $RCLONE_BOOT_CONFIG" + fi + fi + elif [[ -f "$link_path" ]]; then + if [[ ! -e "$RCLONE_BOOT_CONFIG" ]]; then + if cp -a -- "$link_path" "$RCLONE_BOOT_CONFIG"; then + rm -f "$link_path" + else + log_issue "ERROR: failed to migrate $link_label to $RCLONE_BOOT_CONFIG" + return + fi + else + if backup_rclone_config "$link_path"; then + rm -f "$link_path" + else + log_issue "ERROR: leaving $link_label in place due to backup failure" + return + fi + fi + + link_to_boot_config "$link_path" "$link_label" "create" + elif [[ ! -e "$link_path" ]]; then + link_to_boot_config "$link_path" "$link_label" "create" + fi +} + +ensure_default_boot_config_in_non_plugin_mode() { + [[ "$RCLONE_USE_PLUGIN_CONFIG" == "true" ]] && return + + if [[ -L "$RCLONE_DEFAULT_BOOT_CONFIG" ]]; then + local link_target + link_target=$(readlink -f "$RCLONE_DEFAULT_BOOT_CONFIG" 2>/dev/null || readlink "$RCLONE_DEFAULT_BOOT_CONFIG" 2>/dev/null || true) + + if rm -f "$RCLONE_DEFAULT_BOOT_CONFIG"; then + if [[ -f "$link_target" && "$link_target" != "$RCLONE_DEFAULT_BOOT_CONFIG" ]]; then + if cp -a -- "$link_target" "$RCLONE_DEFAULT_BOOT_CONFIG"; then + return + fi + log_issue "ERROR: failed to restore rclone default config from $link_target" + fi + else + log_issue "ERROR: failed to remove stale rclone default config symlink at $RCLONE_DEFAULT_BOOT_CONFIG" + return + fi + fi + + if [[ ! -e "$RCLONE_DEFAULT_BOOT_CONFIG" ]]; then + if [[ -f "$RCLONE_ROOT_CONFIG" && ! -L "$RCLONE_ROOT_CONFIG" ]]; then + if cp -a -- "$RCLONE_ROOT_CONFIG" "$RCLONE_DEFAULT_BOOT_CONFIG"; then + return + fi + log_issue "ERROR: failed to seed rclone default config from $RCLONE_ROOT_CONFIG" + fi + + if ( : > "$RCLONE_DEFAULT_BOOT_CONFIG" ) 2>/dev/null; then + : + else + log_issue "ERROR: failed to initialize rclone default config at $RCLONE_DEFAULT_BOOT_CONFIG" + fi + fi +} + +run_rclone_config_init() { + ensure_paths || true + + # Keep both persistent locations and root default path aligned. + if [[ "$RCLONE_USE_PLUGIN_CONFIG" == "true" ]]; then + link_rclone_config "$RCLONE_DEFAULT_BOOT_CONFIG" "rclone default config" + else + ensure_default_boot_config_in_non_plugin_mode + fi + link_rclone_config "$RCLONE_ROOT_CONFIG" "rclone root config" +} + +run_rclone_config_init || log_issue "WARNING: rclone_config_init completed with non-fatal errors" +exit 0 From 5d3093775ec771571738cf6edf45ed3622c57dc5 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 21 Feb 2026 10:03:52 -0500 Subject: [PATCH 08/26] fix: address rclone_config_init backup and nitpick issues --- sbin/rclone_config_init | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/sbin/rclone_config_init b/sbin/rclone_config_init index b7bff08f2f..2b655ef9f6 100755 --- a/sbin/rclone_config_init +++ b/sbin/rclone_config_init @@ -40,7 +40,7 @@ ensure_paths() { backup_rclone_config() { local src="$1" local backup - backup="$RCLONE_BACKUP_DIR/$(basename "$src").$(date +%Y%m%d-%H%M%S)" + backup="$RCLONE_BACKUP_DIR/$(basename "$src").$(date +%Y%m%d-%H%M%S)-$$-$RANDOM" if cp -a -- "$src" "$backup"; then return 0 fi @@ -70,9 +70,7 @@ link_rclone_config() { link_target=$(readlink -f "$link_path" 2>/dev/null || readlink "$link_path" 2>/dev/null || true) if [[ "$link_target" != "$RCLONE_BOOT_CONFIG" ]]; then if [[ -f "$link_target" && ! -e "$RCLONE_BOOT_CONFIG" ]]; then - if cp -a -- "$link_target" "$RCLONE_BOOT_CONFIG"; then - : - else + if ! cp -a -- "$link_target" "$RCLONE_BOOT_CONFIG"; then log_issue "ERROR: failed to migrate $link_label from $link_target to $RCLONE_BOOT_CONFIG" return fi @@ -137,9 +135,7 @@ ensure_default_boot_config_in_non_plugin_mode() { log_issue "ERROR: failed to seed rclone default config from $RCLONE_ROOT_CONFIG" fi - if ( : > "$RCLONE_DEFAULT_BOOT_CONFIG" ) 2>/dev/null; then - : - else + if ! touch "$RCLONE_DEFAULT_BOOT_CONFIG" 2>/dev/null; then log_issue "ERROR: failed to initialize rclone default config at $RCLONE_DEFAULT_BOOT_CONFIG" fi fi From aeb2ac24b03580177d04477224f2bcb87dbd512d Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:25:43 +0000 Subject: [PATCH 09/26] fix for peer address. --- etc/rc.d/rc.library.source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/rc.d/rc.library.source b/etc/rc.d/rc.library.source index fb5f5f9d03..29b19049ec 100644 --- a/etc/rc.d/rc.library.source +++ b/etc/rc.d/rc.library.source @@ -57,7 +57,7 @@ good(){ show(){ case $# in 1) ip -br addr show scope global primary -deprecated to $1 2>/dev/null | awk '{gsub("@.+","",$1);print $1;exit}' ;; - 2) ip -br addr show scope global primary -deprecated $1 $2 2>/dev/null | awk '{$1=$2="";print;exit}' | sed -r 's/ metric [0-9]+//g' ;; + 2) ip -br addr show scope global primary -deprecated $1 $2 2>/dev/null | awk '{$1=$2="";print;exit}' | sed -r 's/ metric [0-9]+//g;s/ peer [0-9a-fA-F:.]+\/[0-9]+//g;s/^ +//g' ;; esac } From c32847edf07c97845d06f09602b8fafcca74e003 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:40:47 +0000 Subject: [PATCH 10/26] Fix rc.sshd: auto-restart SSH daemon after network recovery --- etc/rc.d/rc.sshd | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etc/rc.d/rc.sshd b/etc/rc.d/rc.sshd index aa82e5620f..8ac53a2bc9 100755 --- a/etc/rc.d/rc.sshd +++ b/etc/rc.d/rc.sshd @@ -126,6 +126,9 @@ sshd_update(){ if sshd_running && check && [[ "$(this ListenAddress)" != "${BIND[@]}" ]]; then log "Updating $DAEMON..." sshd_reload + elif ! sshd_running && [[ $USE_SSH == yes ]]; then + log "Recovering $DAEMON..." + sshd_start fi } From af6c3ec92434c8c82eb7b7c787df1cc4e663d6f0 Mon Sep 17 00:00:00 2001 From: Eli Bosley <11823237+elibosley@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:47:20 -0500 Subject: [PATCH 11/26] Revert "fix: persist rclone config via rc.local with hardened init script" --- etc/rc.d/rc.local | 6 -- sbin/rclone_config_init | 157 ---------------------------------------- 2 files changed, 163 deletions(-) delete mode 100755 sbin/rclone_config_init diff --git a/etc/rc.d/rc.local b/etc/rc.d/rc.local index 867c847a86..292bf1fdeb 100755 --- a/etc/rc.d/rc.local +++ b/etc/rc.d/rc.local @@ -56,7 +56,6 @@ mkdir -p $CONFIG/plugins/dockerMan mkdir -p $CONFIG/plugins/dynamix/users mkdir -p $CONFIG/plugins-error/ mkdir -p $CONFIG/pools -mkdir -p $CONFIG/rclone mkdir -p $CONFIG/shares mkdir -p $CONFIG/ssh/root mkdir -p $CONFIG/ssl/certs @@ -208,11 +207,6 @@ else shopt -u nullglob fi -# Re-run rclone config setup after plugin install phase (plugins-nextboot may have added rclone). -if [[ -x /usr/local/sbin/rclone_config_init ]]; then - /usr/local/sbin/rclone_config_init "$CONFIG" -fi - # Install languages log "Installing language packs" shopt -s nullglob diff --git a/sbin/rclone_config_init b/sbin/rclone_config_init deleted file mode 100755 index 2b655ef9f6..0000000000 --- a/sbin/rclone_config_init +++ /dev/null @@ -1,157 +0,0 @@ -#!/bin/bash -# -# script: rclone_config_init -# -# Keep rclone config on persistent storage while preserving default lookup paths. -# This helper must never break boot flow; keep execution non-fatal. -set +e -set +u -set +o pipefail - -CONFIG="${1:-/boot/config}" -RCLONE_DEFAULT_BOOT_CONFIG="$CONFIG/rclone/rclone.conf" -RCLONE_PLUGIN_BOOT_CONFIG="$CONFIG/plugins/rclone/.rclone.conf" -RCLONE_ROOT_CONFIG="${2:-/root/.config/rclone/rclone.conf}" -RCLONE_BACKUP_DIR="$CONFIG/rclone/backups" -RCLONE_USE_PLUGIN_CONFIG=false - -if [[ -e "$CONFIG/plugins/rclone.plg" || -e "$RCLONE_PLUGIN_BOOT_CONFIG" ]]; then - RCLONE_BOOT_CONFIG="$RCLONE_PLUGIN_BOOT_CONFIG" - RCLONE_USE_PLUGIN_CONFIG=true -else - RCLONE_BOOT_CONFIG="$RCLONE_DEFAULT_BOOT_CONFIG" -fi - -log_issue() { - if declare -F log >/dev/null 2>&1; then - log "$1" - else - echo "$1" - fi -} - -ensure_paths() { - if ! mkdir -p "$(dirname "$RCLONE_ROOT_CONFIG")" "$RCLONE_BACKUP_DIR" "$(dirname "$RCLONE_BOOT_CONFIG")" 2>/dev/null; then - log_issue "ERROR: failed to ensure rclone config directories exist" - return 1 - fi -} - -backup_rclone_config() { - local src="$1" - local backup - backup="$RCLONE_BACKUP_DIR/$(basename "$src").$(date +%Y%m%d-%H%M%S)-$$-$RANDOM" - if cp -a -- "$src" "$backup"; then - return 0 - fi - log_issue "ERROR: failed to backup rclone config from $src" - return 1 -} - -link_to_boot_config() { - local link_path="$1" - local link_label="$2" - local error_action="$3" - - if ln -s "$RCLONE_BOOT_CONFIG" "$link_path"; then - return 0 - fi - - log_issue "ERROR: failed to $error_action $link_label symlink to $RCLONE_BOOT_CONFIG" - return 1 -} - -link_rclone_config() { - local link_path="$1" - local link_label="$2" - - if [[ -L "$link_path" ]]; then - local link_target - link_target=$(readlink -f "$link_path" 2>/dev/null || readlink "$link_path" 2>/dev/null || true) - if [[ "$link_target" != "$RCLONE_BOOT_CONFIG" ]]; then - if [[ -f "$link_target" && ! -e "$RCLONE_BOOT_CONFIG" ]]; then - if ! cp -a -- "$link_target" "$RCLONE_BOOT_CONFIG"; then - log_issue "ERROR: failed to migrate $link_label from $link_target to $RCLONE_BOOT_CONFIG" - return - fi - elif [[ -f "$link_target" ]]; then - backup_rclone_config "$link_target" || log_issue "WARNING: continuing $link_label symlink migration without backup" - fi - - if rm -f "$link_path"; then - link_to_boot_config "$link_path" "$link_label" "update" - else - log_issue "ERROR: failed to update $link_label symlink to $RCLONE_BOOT_CONFIG" - fi - fi - elif [[ -f "$link_path" ]]; then - if [[ ! -e "$RCLONE_BOOT_CONFIG" ]]; then - if cp -a -- "$link_path" "$RCLONE_BOOT_CONFIG"; then - rm -f "$link_path" - else - log_issue "ERROR: failed to migrate $link_label to $RCLONE_BOOT_CONFIG" - return - fi - else - if backup_rclone_config "$link_path"; then - rm -f "$link_path" - else - log_issue "ERROR: leaving $link_label in place due to backup failure" - return - fi - fi - - link_to_boot_config "$link_path" "$link_label" "create" - elif [[ ! -e "$link_path" ]]; then - link_to_boot_config "$link_path" "$link_label" "create" - fi -} - -ensure_default_boot_config_in_non_plugin_mode() { - [[ "$RCLONE_USE_PLUGIN_CONFIG" == "true" ]] && return - - if [[ -L "$RCLONE_DEFAULT_BOOT_CONFIG" ]]; then - local link_target - link_target=$(readlink -f "$RCLONE_DEFAULT_BOOT_CONFIG" 2>/dev/null || readlink "$RCLONE_DEFAULT_BOOT_CONFIG" 2>/dev/null || true) - - if rm -f "$RCLONE_DEFAULT_BOOT_CONFIG"; then - if [[ -f "$link_target" && "$link_target" != "$RCLONE_DEFAULT_BOOT_CONFIG" ]]; then - if cp -a -- "$link_target" "$RCLONE_DEFAULT_BOOT_CONFIG"; then - return - fi - log_issue "ERROR: failed to restore rclone default config from $link_target" - fi - else - log_issue "ERROR: failed to remove stale rclone default config symlink at $RCLONE_DEFAULT_BOOT_CONFIG" - return - fi - fi - - if [[ ! -e "$RCLONE_DEFAULT_BOOT_CONFIG" ]]; then - if [[ -f "$RCLONE_ROOT_CONFIG" && ! -L "$RCLONE_ROOT_CONFIG" ]]; then - if cp -a -- "$RCLONE_ROOT_CONFIG" "$RCLONE_DEFAULT_BOOT_CONFIG"; then - return - fi - log_issue "ERROR: failed to seed rclone default config from $RCLONE_ROOT_CONFIG" - fi - - if ! touch "$RCLONE_DEFAULT_BOOT_CONFIG" 2>/dev/null; then - log_issue "ERROR: failed to initialize rclone default config at $RCLONE_DEFAULT_BOOT_CONFIG" - fi - fi -} - -run_rclone_config_init() { - ensure_paths || true - - # Keep both persistent locations and root default path aligned. - if [[ "$RCLONE_USE_PLUGIN_CONFIG" == "true" ]]; then - link_rclone_config "$RCLONE_DEFAULT_BOOT_CONFIG" "rclone default config" - else - ensure_default_boot_config_in_non_plugin_mode - fi - link_rclone_config "$RCLONE_ROOT_CONFIG" "rclone root config" -} - -run_rclone_config_init || log_issue "WARNING: rclone_config_init completed with non-fatal errors" -exit 0 From 77554d8e3eacbdba5ab8e7153b03b28c1652cff0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sun, 12 Apr 2026 10:40:06 +0100 Subject: [PATCH 12/26] fix(docker): filter ghost containers from WebGUI - Purpose: backport the Docker ghost/dead container filtering change to the 7.2 release branch. - Before: stale dead Docker entries could be included in the WebGUI container list and inspect failures could leak bad rows into the UI. - Problem: Docker shutdown races can leave orphaned metadata that users cannot act on, which makes the Docker page show ghost containers. - Change: skip invalid, dead, and uninspectable container entries while preserving normal container listing behavior. - How: validate each container record before inspect, skip dead state/status rows, skip failed inspect payloads, and fall back to the list name when inspect omits Name. --- .../dynamix.docker.manager/include/DockerClient.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php index c6d5685fd7..e9a3c53038 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php @@ -960,11 +960,22 @@ public function getDockerContainers() { if (is_array($this::$containersCache)) return $this::$containersCache; $this::$containersCache = []; foreach ($this->getDockerJSON("/containers/json?all=1") as $ct) { + if (!is_array($ct) || empty($ct['Id'])) { + continue; + } + // Docker can leave stale "dead" entries after shutdown races; filter them from the UI list. + if (($ct['State'] ?? '') === 'dead' || stripos($ct['Status'] ?? '', 'dead') === 0) { + continue; + } $info = $this->getContainerDetails($ct['Id']); + // If inspect fails for a stale entry, skip it from the UI list. + if (empty($info) || !is_array($info) || !empty($info['message']) || empty($info['Config']) || empty($info['State'])) { + continue; + } $c = []; $c['Image'] = DockerUtil::ensureImageTag($info['Config']['Image']); $c['ImageId'] = $this->extractID($ct['ImageID']); - $c['Name'] = substr($info['Name'], 1); + $c['Name'] = ltrim(($info['Name'] ?? $ct['Names'][0] ?? ''), '/'); $c['Status'] = $ct['Status'] ?: 'None'; $c['Running'] = $info['State']['Running']; $c['Paused'] = $info['State']['Paused']; From cfc473477a60919a556bbb02d9faf60591592bfd Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:25:50 +0000 Subject: [PATCH 13/26] fix(mover): allow empty action without pools - Purpose: backport the no-pool mover empty fix to the 7.2 branch.\n- Before: the UI disabled or hid mover controls when no cache/pool devices existed, even though the empty-array-disk action can still run without a pool assignment.\n- Why: users could not invoke mover to empty an array disk on systems with no configured pool.\n- What: keep mover controls available for the empty action when user shares are enabled and no pool devices exist.\n- How: update the scheduler button text/state and add matching Array Operation button handling for no-pool systems while preserving disabled states during parity, mover, and BTRFS operations.\n- Source: cherry-picked from fd5251ad4fab628c5ea0800642d6b312da25ade9. --- emhttp/plugins/dynamix/ArrayOperation.page | 23 ++++++++++++++++++++++ emhttp/plugins/dynamix/MoverSettings.page | 12 ++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/emhttp/plugins/dynamix/ArrayOperation.page b/emhttp/plugins/dynamix/ArrayOperation.page index 74381e295b..f08dc3c605 100644 --- a/emhttp/plugins/dynamix/ArrayOperation.page +++ b/emhttp/plugins/dynamix/ArrayOperation.page @@ -394,6 +394,10 @@ mymonitor.on('message', function(state) { $('#mover-button').prop('disabled',false); $('#mover-text').html("_(Move)_ _(will immediately invoke the Mover)_.  onclick=\"$.cookie('one','tab2')\">(_(Schedule)_)"); + + + $('#mover-button').prop('disabled',false); + $('#mover-text').html("_(Empty)_ _(will immediately invoke the Mover to Empty a disk)_.  onclick=\"$.cookie('one','tab2')\">(_(Schedule)_)"); break; case '1': // parity running @@ -406,6 +410,10 @@ mymonitor.on('message', function(state) { $('#mover-button').prop('disabled',true); $('#mover-text').html("_(Disabled)_ -- _(Parity operation is running)_"); + + + $('#mover-button').prop('disabled',true); + $('#mover-text').html("_(Empty)_ _(will immediately invoke the Mover to Empty a disk)_.  onclick=\"$.cookie('one','tab2')\">(_(Schedule)_)"); break; case '2': // mover running @@ -414,6 +422,10 @@ mymonitor.on('message', function(state) { $('#mover-button').prop('disabled',true); $('#mover-text').html("_(Disabled)_ - _(Mover is running)_."); + + + $('#mover-button').prop('disabled',true); + $('#mover-text').html("_(Empty)_ _(will immediately invoke the Mover to Empty a disk)_.  onclick=\"$.cookie('one','tab2')\">(_(Schedule)_)"); break; case '3': // btrfs running @@ -422,6 +434,10 @@ mymonitor.on('message', function(state) { $('#mover-button').prop('disabled',true); $('#mover-text').html("_(Disabled)_ -- _(BTRFS operation is running)_"); + + + $('#mover-button').prop('disabled',true); + $('#mover-text').html("_(Empty)_ _(will immediately invoke the Mover to Empty a disk)_.  onclick=\"$.cookie('one','tab2')\">(_(Schedule)_)"); break; } @@ -832,6 +848,13 @@ endswitch; + +
+ + +
+
+
diff --git a/emhttp/plugins/dynamix/MoverSettings.page b/emhttp/plugins/dynamix/MoverSettings.page index e6576111a4..e67f46f949 100755 --- a/emhttp/plugins/dynamix/MoverSettings.page +++ b/emhttp/plugins/dynamix/MoverSettings.page @@ -18,13 +18,15 @@ Tag="calendar-check-o" $mode = ['Disabled','Hourly','Daily','Weekly','Monthly']; $days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; $setup = true; +$buttontext = _('Move now'); if (!$pool_devices) { - echo "

"._('No Cache device present')."!

"; - $setup = false; + echo "

"._('No Cache device present only empty function will run')."!

"; + $setup = true; + $buttontext = _('Empty now'); } elseif ($var['shareUser']=='-') { echo "

"._('User shares not enabled')."!

"; $setup = false; -} +} if (empty($var['shareMoverSchedule'])) { $cron = explode(' ', "* * * * *"); $move = 0; @@ -32,7 +34,7 @@ if (empty($var['shareMoverSchedule'])) { $cron = explode(' ', $var['shareMoverSchedule']); $move = $cron[2]!='*' ? 4 : ($cron[4]!='*' ? 3 : (substr($cron[1],0,1)!='*' ? 2 : 1)); } -$showMoverButton = $setup && $pool_devices; +$showMoverButton = $setup; $moverRunning = file_exists('/var/run/mover.pid'); ?> - diff --git a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php index 2b5425128e..3df738c90d 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/Helpers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/Helpers.php @@ -32,6 +32,66 @@ function xml_decode($string) { return strval(html_entity_decode($string, ENT_XML1, 'UTF-8')); } +function extraParamsWithQuotedValuesMasked($extraParams) { + return preg_replace('/"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"|\'[^\']*\'/', '""', $extraParams); +} + +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; + } + $parts[$i] = $callback($part); + } + return implode('', $parts); +} + +function extractMacAddressParam($extraParams) { + 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) { + if (!is_string($extraParams) || $extraParams === '') { + return ''; + } + $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) { + return is_string($extraParams) && preg_match('/(?:^|\s)--net(?:work)?(?:=|\s+)[^\s\'"]+/', extraParamsWithQuotedValuesMasked($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 generateTSwebui($url, $serve, $webUI) { if (!isset($webUI)) { return ''; @@ -75,6 +135,9 @@ function postToXML($post, $setOwnership=false) { $xml->Network = xml_encode($post['contNetwork']); } $xml->MyIP = xml_encode($post['contMyIP']); + $extraNetwork = hasNetworkParam($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'; $xml->Support = xml_encode($post['contSupport']); @@ -85,7 +148,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 +212,9 @@ function xmlToVar($xml) { $out['Registry'] = xml_decode($xml->Registry); $out['Network'] = xml_decode($xml->Network); $out['MyIP'] = xml_decode($xml->MyIP ?? ''); + $extraParams = xml_decode($xml->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); $out['Support'] = xml_decode($xml->Support); @@ -159,7 +225,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); @@ -325,13 +391,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 = hasNetworkParam($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,':') !== false ? '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,':') !== false ? '--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..a420587985 100755 --- a/etc/rc.d/rc.docker +++ b/etc/rc.d/rc.docker @@ -245,9 +245,16 @@ 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= container_exist "$CONTAINER" || return 0 @@ -263,23 +270,49 @@ 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 + 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 + 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 + + 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 @@ -347,20 +380,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 51e1124d58a1ce9170a73234f109505a0b00de33 Mon Sep 17 00:00:00 2001 From: tom mortensen Date: Tue, 21 Apr 2026 22:26:35 -0700 Subject: [PATCH 19/26] fix(docker): show container MAC addresses on 7.2 - Purpose: backport the Docker MAC address display fix from master to 7.2. - Before: the Docker container list only showed IP details, so assigned MAC addresses were not visible in the UI. - Problem: users could not confirm fixed or runtime MAC assignments from the container list. - Change: include MAC address data from Docker inspect output and render it with the container IP details. - Implementation: pass endpoint MAC addresses through DockerClient and DockerContainers, then hide empty values for stopped containers. --- .../dynamix.docker.manager/DockerContainers.page | 4 ++-- .../dynamix.docker.manager/include/DockerClient.php | 12 ++++++++++-- .../include/DockerContainers.php | 6 ++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/emhttp/plugins/dynamix.docker.manager/DockerContainers.page b/emhttp/plugins/dynamix.docker.manager/DockerContainers.page index 8cf6e687ca..053c6d9585 100755 --- a/emhttp/plugins/dynamix.docker.manager/DockerContainers.page +++ b/emhttp/plugins/dynamix.docker.manager/DockerContainers.page @@ -37,7 +37,7 @@ $cpus = cpu_list(); _(Application)_ _(Version)_ _(Network)_ - _(Container IP)_ + _(Container IP)_ / _(MAC)_ _(Container Port)_ _(LAN IP:Port)_ _(Volume Mappings)_ (_(App to Host)_) @@ -46,7 +46,7 @@ $cpus = cpu_list(); _(Uptime)_ - + diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php index e9a3c53038..88f5e958ee 100755 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php @@ -1016,15 +1016,23 @@ public function getDockerContainers() { $ports = &$info['Config']['ExposedPorts']; } foreach($ct['NetworkSettings']['Networks'] as $netName => $netVals) { + $networkDetails = $info['NetworkSettings']['Networks'][$netName] ?? $netVals; $i = $c['NetworkMode']=='host' ? $host : $netVals['IPAddress']; - $c['Networks'][$netName] = [ 'IPAddress' => $i ]; + $c['Networks'][$netName] = [ + 'IPAddress' => $i, + 'MacAddress' => $networkDetails['MacAddress'] ?? '' + ]; if ( isset($driver[$netName]) && ($driver[$netName]=='ipvlan' || $driver[$netName]=='macvlan') ) { if (!isset($c['Ports']['vlan'])) $c['Ports']['vlan'] = []; $c['Ports']['vlan']["$i"] = $i; } } + $networkDetails = $info['NetworkSettings']['Networks'][$c['NetworkMode']] ?? $ct['NetworkSettings']['Networks'][$c['NetworkMode']] ?? []; $ip = $c['NetworkMode']=='host' ? $host : $ct['NetworkSettings']['Networks'][$c['NetworkMode']]['IPAddress'] ?? null; - $c['Networks'][$c['NetworkMode']] = [ 'IPAddress' => $ip ]; + $c['Networks'][$c['NetworkMode']] = [ + 'IPAddress' => $ip, + 'MacAddress' => $networkDetails['MacAddress'] ?? '' + ]; $ports = (isset($ports) && is_array($ports)) ? $ports : []; foreach ($ports as $port => $value) { if (!isset($info['HostConfig']['PortBindings'][$port])) { diff --git a/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php b/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php index 3f7e0e7d6d..8a14f607cb 100644 --- a/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php +++ b/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php @@ -28,7 +28,7 @@ $autostart_file = $dockerManPaths['autostart-file']; if (!$containers && !$images) { - echo ""._('No Docker containers installed').""; + echo ""._('No Docker containers installed').""; return; } @@ -154,7 +154,9 @@ function my_lang_log($text) { } foreach($ct['Networks'] as $netName => $netVals) { $networks[] = $netName; - $network_ips[] = $running ? $netVals['IPAddress'] : null; + $network_ip = $running ? htmlspecialchars((string)$netVals['IPAddress']) : ''; + $network_mac = $running ? htmlspecialchars((string)($netVals['MacAddress'] ?? '')) : ''; + $network_ips[] = $network_mac ? "$network_ip
$network_mac
" : $network_ip; if (isset($ct['Networks']['host'])) { $ports_external[] = sprintf('%s', $netVals['IPAddress']); $ports_internal[0] = sprintf('%s', 'all'); From aaf524fa90dedc1d03bd71836aa645b3609f388d Mon Sep 17 00:00:00 2001 From: Tom Mortensen Date: Tue, 12 May 2026 15:25:04 -0700 Subject: [PATCH 20/26] fix: misc. security mitigations --- .../dynamix.plugin.manager/scripts/language | 70 ++++++++++++++-- .../dynamix.vm.manager/VMMachines.page | 7 +- emhttp/plugins/dynamix/include/FileUpload.php | 84 ++++++++++++++++--- .../plugins/dynamix/include/ToggleState.php | 2 +- 4 files changed, 143 insertions(+), 20 deletions(-) diff --git a/emhttp/plugins/dynamix.plugin.manager/scripts/language b/emhttp/plugins/dynamix.plugin.manager/scripts/language index 0515669096..a448ff0366 100755 --- a/emhttp/plugins/dynamix.plugin.manager/scripts/language +++ b/emhttp/plugins/dynamix.plugin.manager/scripts/language @@ -86,6 +86,26 @@ function run($command) { return pclose($run); } +function remove_tree(string $dir): bool { + if (!is_dir($dir)) return false; + $items = scandir($dir); + if ($items === false) return false; + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $entry = "$dir/$item"; + if (is_dir($entry) && !is_link($entry)) { + if (!remove_tree($entry)) return false; + } elseif (!@unlink($entry)) { + return false; + } + } + return @rmdir($dir); +} + +function valid_language_pack_name($name): bool { + return is_string($name) && $name !== '' && preg_match('/^[A-Za-z0-9._$-]+$/', $name); +} + // Run hooked scripts before correct execution of "method" // method = install, update, remove, check // hook programs receives three parameters: type=language and method and language-name @@ -159,7 +179,11 @@ function language($method, $xml_file, &$error) { switch ($method) { case 'install': $url = $xml->LanguageURL; - $name = $xml->LanguagePack; + $name = (string)$xml->LanguagePack; + if (!valid_language_pack_name($name)) { + $error = "invalid language pack"; + return false; + } $save = "$boot/dynamix/lang-$name.zip"; if (!file_exists($save)) { if ($url) { @@ -173,22 +197,54 @@ function language($method, $xml_file, &$error) { } } $path = "$docroot/languages/$name"; - exec("mkdir -p $path"); + if (!is_dir($path) && !@mkdir($path, 0777, true)) { + $error = "failed to create language directory"; + return false; + } @unlink("$docroot/webGui/javascript/translate.$name.js"); - foreach (glob("$path/*.dot",GLOB_NOSORT) as $dot_file) unlink($dot_file); - exec("unzip -qqLjo -d $path $save", $dummy, $err); + foreach (['*.dot', '*.txt', '*.json'] as $pattern) { + foreach (glob("$path/$pattern", GLOB_NOSORT) as $lang_file) @unlink($lang_file); + } + $err = 0; + $zip = new ZipArchive(); + if ($zip->open($save) === true) { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false) continue; + if (substr($entry, -1) === '/') continue; + $content = $zip->getFromIndex($i); + if ($content === false) { + $err = 2; + break; + } + $entryName = strtolower(basename($entry)); + if ($entryName === '') continue; + if (!preg_match('/\.(dot|txt|json)$/', $entryName)) continue; + if (file_put_contents("$path/$entryName", $content) === false) { + $err = 2; + break; + } + } + $zip->close(); + } else { + $err = 2; + } if ($err > 1) { @unlink($save); - exec("rm -rf $path"); + remove_tree($path); $error = "unzip failed. Error code $err"; return false; } return true; case 'remove': - $name = $xml->LanguagePack; + $name = (string)$xml->LanguagePack; if ($name) { + if (!valid_language_pack_name($name)) { + $error = "invalid language pack"; + return false; + } $path = "$docroot/languages/$name"; - exec("rm -rf $path"); + if (is_dir($path)) remove_tree($path); @unlink("$docroot/webGui/javascript/translate.$name.js"); @unlink("$boot/lang-$name.xml"); @unlink("$plugins/lang-$name.xml"); diff --git a/emhttp/plugins/dynamix.vm.manager/VMMachines.page b/emhttp/plugins/dynamix.vm.manager/VMMachines.page index b6da578d33..e29bcb6a95 100755 --- a/emhttp/plugins/dynamix.vm.manager/VMMachines.page +++ b/emhttp/plugins/dynamix.vm.manager/VMMachines.page @@ -496,7 +496,12 @@ function loadlist() { $(function() { - $('#countdown').html(""); + $('#countdown').empty().append( + $('', { + class: '', + text: + }) + ); $('#btnAddVM').click(function AddVMEvent(){$('.tab>input#tab2').click();}); $.removeCookie('lockbutton'); diff --git a/emhttp/plugins/dynamix/include/FileUpload.php b/emhttp/plugins/dynamix/include/FileUpload.php index 27aca0503a..80ff432075 100644 --- a/emhttp/plugins/dynamix/include/FileUpload.php +++ b/emhttp/plugins/dynamix/include/FileUpload.php @@ -21,6 +21,28 @@ $safeexts = ['.png']; $result = false; +function in_safe_path(string $path, string $base): bool { + $path = rtrim($path, '/').'/'; + $base = rtrim($base, '/').'/'; + return strpos($path, $base) === 0; +} + +function remove_tree(string $dir): bool { + if (!is_dir($dir)) return false; + $items = scandir($dir); + if ($items === false) return false; + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $entry = "$dir/$item"; + if (is_dir($entry) && !is_link($entry)) { + if (!remove_tree($entry)) return false; + } elseif (!@unlink($entry)) { + return false; + } + } + return @rmdir($dir); +} + require_once "$docroot/webGui/include/Helpers.php"; switch ($_POST['cmd'] ?? 'load') { @@ -47,20 +69,37 @@ case 'save': // move uploaded file ($verifiedPNG) to final destination $verifiedPNG = "$temp/".basename($file); - $path = $_POST['path']; + $path = $_POST['path'] ?? ''; + $outputRaw = $_POST['output'] ?? ''; + $output = basename($outputRaw); + $outputExt = strtolower(substr($output, -4)); + $isValidFilename = $output !== '' && $output === $outputRaw && preg_match('/^[A-Za-z0-9._$-]+$/', $output); foreach ($safepaths as $safepath) { - if (strpos(dirname("$path/{$_POST['output']}"),$safepath)===0 && in_array(substr(basename($_POST['output']),-4),$safeexts)) { - exec("mkdir -p ".escapeshellarg(realpath($path))); - $result = @rename($verifiedPNG, "$path/{$_POST['output']}"); + $safeBase = realpath($safepath); + $targetDir = realpath($path); + if (!$targetDir && $safeBase) { + $parentDir = realpath(dirname($path)); + if ($parentDir && in_safe_path($parentDir, $safeBase) && @mkdir($path, 0777, true)) { + $targetDir = realpath($path); + } + } + if ($targetDir && $safeBase && in_safe_path($targetDir, $safeBase) && $isValidFilename && in_array($outputExt, $safeexts, true)) { + if (is_dir($targetDir)) { + $result = @rename($verifiedPNG, "$targetDir/$output"); + } break; } } break; case 'delete': - $path = $_POST['path']; + $path = $_POST['path'] ?? ''; + $file = basename($file); + $targetFile = realpath("$path/$file"); + $targetExt = $targetFile ? strtolower(substr($targetFile, -4)) : ''; foreach ($safepaths as $safepath) { - if (strpos(realpath("$path/$file"), $safepath) === 0 && in_array(substr(realpath("$path/$file"), -4), $safeexts)) { - exec("rm -f ".escapeshellarg(realpath("$path/$file"))); + $safeBase = realpath($safepath); + if ($targetFile && $safeBase && in_safe_path($targetFile, $safeBase) && in_array($targetExt, $safeexts, true)) { + @unlink($targetFile); $result = true; break; } @@ -70,14 +109,37 @@ $file = basename($file); $path = "$docroot/languages/$file"; $save = "/tmp/lang-$file.zip"; - exec("mkdir -p $path"); + if (!is_dir($path) && !@mkdir($path, 0777, true)) break; if ($result = file_put_contents($save,base64_decode(preg_replace('/^data:.*;base64,/','',$_POST['filedata'])))) { @unlink("$docroot/webGui/javascript/translate.$file.js"); foreach (glob("$path/*.dot",GLOB_NOSORT) as $dot_file) unlink($dot_file); - exec("unzip -qqjLo -d $path $save", $dummy, $err); + $err = 0; + $zip = new ZipArchive(); + if ($zip->open($save) === true) { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false) continue; + if (substr($entry, -1) === '/') continue; + $content = $zip->getFromIndex($i); + if ($content === false) { + $err = 2; + break; + } + $name = strtolower(basename($entry)); + if ($name === '') continue; + if (!preg_match('/\.(dot|txt|json)$/', $name)) continue; + if (file_put_contents("$path/$name", $content) === false) { + $err = 2; + break; + } + } + $zip->close(); + } else { + $err = 2; + } @unlink($save); if ($err > 1) { - exec("rm -rf $path"); + remove_tree($path); $result = false; break; } @@ -101,7 +163,7 @@ $file = basename($file); $path = "$docroot/languages/$file"; if ($result = is_dir($path)) { - exec("rm -rf $path"); + $result = remove_tree($path); @unlink("$docroot/webGui/javascript/translate.$file.js"); @unlink("$boot/lang-$file.xml"); @unlink("$plugins/lang-$file.xml"); diff --git a/emhttp/plugins/dynamix/include/ToggleState.php b/emhttp/plugins/dynamix/include/ToggleState.php index 63e4e87dcd..d940e75169 100644 --- a/emhttp/plugins/dynamix/include/ToggleState.php +++ b/emhttp/plugins/dynamix/include/ToggleState.php @@ -19,7 +19,7 @@ $action = $_POST['action']??''; function emcmd($cmd) { - exec("emcmd '$cmd'"); + exec("emcmd ".escapeshellarg($cmd)); } switch ($device) { case 'New': From da7ba5db2674b461affd44fea8a17db452a878db Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Mon, 11 May 2026 18:13:40 -0400 Subject: [PATCH 21/26] fix(docker): use configured gateways for VLAN networks - Purpose: keep Docker custom networks on VLAN and secondary interfaces from losing their configured gateway during automatic network recreation. - Before: rc.docker only read the gateway from a default route on the interface, so VLANs without an interface-specific default route created Docker networks without --gateway. - Why that was a problem: Docker could claim the first subnet address as the macvlan/ipvlan gateway, colliding with real VLAN gateways such as 192.168.10.1 and breaking DHCP or static-IP containers. - What the new change accomplishes: automatic Docker network creation now falls back to the configured IPv4 or IPv6 gateway stored in network.ini when no live default route exists. - How it works: configured_gateway maps br/bond network names back to their eth network.ini section, resolves VLAN IDs to their indexed entries, and returns the matching GATEWAY or GATEWAY6 value before network create arguments are assembled. (cherry picked from commit d56c259d7e4431c97042d5f67d5740f443ee81bb) --- etc/rc.d/rc.docker | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/etc/rc.d/rc.docker b/etc/rc.d/rc.docker index a420587985..b3213734e4 100755 --- a/etc/rc.d/rc.docker +++ b/etc/rc.d/rc.docker @@ -192,6 +192,51 @@ network(){ docker network ls --filter driver="$1" --format='{{.Name}}' 2>/dev/null | grep -P "^[a-z]+$2(\$|\.)" | tr '\n' ' ' } +configured_gateway(){ + local NETWORK=$1 + local KEY=$2 + local CFG=${NETWORK_CFG:-/boot/config/network.cfg} + [[ -s $CFG ]] || return + + ( + declare -A VLANID USE_DHCP IPADDR NETMASK GATEWAY METRIC USE_DHCP6 IPADDR6 NETMASK6 GATEWAY6 METRIC6 PRIVACY6 DESCRIPTION PROTOCOL + local BASE=${NETWORK%%.*} + local VLAN= + local IFACE ETH VALUE + local CANDIDATE + local -a CANDIDATES + local i j + + [[ $NETWORK == *.* ]] && VLAN=${NETWORK#*.} + . <(fromdos <"$CFG") + + for ((i=0; i<${SYSNICS:-1}; i++)); do + IFACE=${IFNAME[$i]:-eth$i} + ETH=${IFACE/#br/eth} + ETH=${ETH/#bond/eth} + CANDIDATES=("$IFACE" "$ETH" "${BRNAME[$i]}" "${BONDNAME[$i]}") + if [[ $i -eq 0 ]]; then + [[ ${BRIDGING:-} == yes ]] && CANDIDATES+=("br0") + [[ ${BONDING:-} == yes ]] && CANDIDATES+=("bond0") + fi + for CANDIDATE in "${CANDIDATES[@]}"; do + [[ -n $CANDIDATE && $CANDIDATE == "$BASE" ]] || continue + if [[ -z $VLAN ]]; then + [[ $KEY == GATEWAY6 ]] && VALUE=${GATEWAY6[$i]} || VALUE=${GATEWAY[$i]} + [[ -n $VALUE ]] && printf '%s\n' "$VALUE" + exit + fi + for ((j=1; j<${VLANS[$i]:-0}; j++)); do + [[ ${VLANID[$i,$j]} == "$VLAN" ]] || continue + [[ $KEY == GATEWAY6 ]] && VALUE=${GATEWAY6[$i,$j]} || VALUE=${GATEWAY[$i,$j]} + [[ -n $VALUE ]] && printf '%s\n' "$VALUE" + exit + done + done + done + ) +} + # Is container running? container_running(){ local CONTAINER @@ -453,6 +498,7 @@ docker_network_start(){ if [[ -n $IPV4 ]]; then SUBNET=$(ip -4 route show dev $NETWORK | sort | awk -v ORS=" " '$1 !~ /^default/ {print $1}' | sed 's/ $//') GATEWAY=$(ip -4 route show to default dev $NETWORK | awk '{print $3;exit}') + [[ -n $GATEWAY ]] || GATEWAY=$(configured_gateway "$NETWORK" GATEWAY) SERVER=${IPV4%/*} DHCP=${NETWORK/./_} DHCP=DOCKER_DHCP_${DHCP^^} @@ -464,6 +510,7 @@ docker_network_start(){ if [[ -n $IPV6 ]]; then SUBNET6=$(ip -6 route show dev $NETWORK | sort | awk -v ORS=" " '$1 !~ /^(default|fe80)/ {print $1}' | sed 's/ $//') GATEWAY6=$(ip -6 route show to default dev $NETWORK | awk '{print $3;exit}') + [[ -n $GATEWAY6 ]] || GATEWAY6=$(configured_gateway "$NETWORK" GATEWAY6) fi else # add user defined networks From ea15629fce9d4c55ee66f1a3026dc65e6b1b4163 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 21 Jan 2026 12:46:34 -0500 Subject: [PATCH 22/26] fix: normalize discord agent newlines Purpose: - Backport the Discord notification newline normalization from 7.3 to 7.2. - Keep Pujit Mehrotra credited as the original author of the fix. Before: - Discord status report payloads kept literal \n, \r\n, and \r sequences in DESCRIPTION and CONTENT values. Why that was a problem: - Multi-line Discord reports rendered escaped newline text instead of real line breaks. What the new change accomplishes: - Converts literal newline escape sequences before the Discord payload is generated. - Adds a regression test that verifies generated Discord field text contains real line breaks. How it works: - Normalizes DESCRIPTION and CONTENT shell variables before link handling and payload construction. - Extracts the Discord agent CDATA into a temporary script and stubs curl/date for deterministic test assertions. Original PR: https://github.com/unraid/webgui/pull/2527 (cherry picked from commit 309ae17179486ffccac3af4df8513de035e28cbb) --- emhttp/plugins/dynamix/agents/Discord.xml | 12 ++++ .../agents/tests/discord_newline_test.sh | 72 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 emhttp/plugins/dynamix/agents/tests/discord_newline_test.sh diff --git a/emhttp/plugins/dynamix/agents/Discord.xml b/emhttp/plugins/dynamix/agents/Discord.xml index f086531fcc..1fc39b16cd 100644 --- a/emhttp/plugins/dynamix/agents/Discord.xml +++ b/emhttp/plugins/dynamix/agents/Discord.xml @@ -50,6 +50,18 @@ LINK="${LINK:-}" HOSTNAME="${HOSTNAME:-$(hostname)}" TIMESTAMP="${TIMESTAMP:-$(date +%s)}" +# Convert literal \n sequences into real line breaks for reports. +if [[ -n "${DESCRIPTION}" ]]; then + DESCRIPTION=${DESCRIPTION//\\r\\n/$'\n'} + DESCRIPTION=${DESCRIPTION//\\n/$'\n'} + DESCRIPTION=${DESCRIPTION//\\r/$'\n'} +fi +if [[ -n "${CONTENT}" ]]; then + CONTENT=${CONTENT//\\r\\n/$'\n'} + CONTENT=${CONTENT//\\n/$'\n'} + CONTENT=${CONTENT//\\r/$'\n'} +fi + # ensure link has a host if [[ -n "${LINK}" ]] && [[ ${LINK} != http* ]]; then if [[ -r /usr/local/emhttp/state/nginx.ini ]]; then diff --git a/emhttp/plugins/dynamix/agents/tests/discord_newline_test.sh b/emhttp/plugins/dynamix/agents/tests/discord_newline_test.sh new file mode 100644 index 0000000000..3fa4cce347 --- /dev/null +++ b/emhttp/plugins/dynamix/agents/tests/discord_newline_test.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +agent_xml="${script_dir}/../Discord.xml" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +stub_dir="${tmp_dir}/bin" +payload="${tmp_dir}/payload.json" +script_path="${tmp_dir}/Discord.sh" + +mkdir -p "$stub_dir" + +cat > "${stub_dir}/curl" <<'EOF' +#!/bin/bash +out="${DISCORD_TEST_OUT:-/tmp/discord_payload.json}" +for ((i=1; i<=$#; i++)); do + if [[ "${!i}" == "--data-binary" ]]; then + j=$((i+1)) + printf '%s' "${!j}" > "$out" + exit 0 + fi +done +exit 0 +EOF +chmod +x "${stub_dir}/curl" + +cat > "${stub_dir}/date" <<'EOF' +#!/bin/bash +if [[ "$*" == *"-d "* ]]; then + printf '%s\n' "1970-01-01T00:00:00.000Z" + exit 0 +fi +exec /bin/date "$@" +EOF +chmod +x "${stub_dir}/date" + +awk ' + index($0, "") { in_block=0; next } + in_block { print } +' "$agent_xml" | sed 's/{0}//' > "$script_path" +chmod +x "$script_path" + +run_case() { + local desc="$1" + local content="$2" + local expected="$3" + + rm -f "$payload" + DISCORD_TEST_OUT="$payload" \ + WEBH_URL="http://example.invalid" \ + DESCRIPTION="$desc" \ + CONTENT="$content" \ + PATH="$stub_dir:$PATH" \ + bash "$script_path" >/dev/null + + actual="$(jq -r '.embeds[0].fields[0].value' "$payload")" + if [[ "$actual" != "$expected" ]]; then + echo "Discord newline test failed." >&2 + printf 'Expected:\n%s\n' "$expected" >&2 + printf 'Actual:\n%s\n' "$actual" >&2 + exit 1 + fi +} + +run_case "Line1\\nLine2" "Line3\\nLine4" $'Line1\nLine2\n\nLine3\nLine4' +run_case "Line1\\nLine2" "" $'Line1\nLine2' + +echo "OK" From 1fb0869450a0b28d901c0f47080d7ab8f2a5ebf6 Mon Sep 17 00:00:00 2001 From: Joly0 <13993216+Joly0@users.noreply.github.com> Date: Thu, 14 May 2026 15:50:54 +0200 Subject: [PATCH 23/26] Fix dropdown z-index Fixes clicking on a docker container when page has many containers the dropdown menu will be behind the footer and even outside the visible area because the scrollbar doesnt extend as far. --- emhttp/plugins/dynamix.docker.manager/javascript/docker.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js index ad46fa3850..8e7a31f171 100644 --- a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js +++ b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js @@ -51,6 +51,8 @@ function addDockerContainerContext(container, image, template, started, paused, } context.destroy('#'+id); context.attach('#'+id, opts); + $('#dropdown-'+id).css('z-index', 10001) + .append('
  • '); } function addDockerImageContext(image, imageTag) { var opts = []; From 19db0e3841d2c6d8b125871168e211521cc1e584 Mon Sep 17 00:00:00 2001 From: Joly0 <13993216+Joly0@users.noreply.github.com> Date: Thu, 14 May 2026 16:17:12 +0200 Subject: [PATCH 24/26] Update dropdown spacer element in docker.js --- emhttp/plugins/dynamix.docker.manager/javascript/docker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js index 8e7a31f171..fd8599c337 100644 --- a/emhttp/plugins/dynamix.docker.manager/javascript/docker.js +++ b/emhttp/plugins/dynamix.docker.manager/javascript/docker.js @@ -52,7 +52,7 @@ function addDockerContainerContext(container, image, template, started, paused, context.destroy('#'+id); context.attach('#'+id, opts); $('#dropdown-'+id).css('z-index', 10001) - .append('
  • '); + .append(''); } function addDockerImageContext(image, imageTag) { var opts = []; From de7bf2f84443e032dbfa66573593a92b3bb6cd52 Mon Sep 17 00:00:00 2001 From: Tom Mortensen Date: Thu, 14 May 2026 10:44:48 -0700 Subject: [PATCH 25/26] Move unRAIDSserver plugin to webGUI repo. --- emhttp/plugins/unRAIDServer/EULA.page | 21 + emhttp/plugins/unRAIDServer/README.md | 3 + emhttp/plugins/unRAIDServer/icons/eula.png | Bin 0 -> 3154 bytes emhttp/plugins/unRAIDServer/icons/license.png | Bin 0 -> 580 bytes .../unRAIDServer/images/unRAIDServer.png | Bin 0 -> 1474 bytes emhttp/plugins/unRAIDServer/unRAIDServer.plg | 420 ++++++++++++++++++ 6 files changed, 444 insertions(+) create mode 100644 emhttp/plugins/unRAIDServer/EULA.page create mode 100644 emhttp/plugins/unRAIDServer/README.md create mode 100644 emhttp/plugins/unRAIDServer/icons/eula.png create mode 100644 emhttp/plugins/unRAIDServer/icons/license.png create mode 100644 emhttp/plugins/unRAIDServer/images/unRAIDServer.png create mode 100644 emhttp/plugins/unRAIDServer/unRAIDServer.plg diff --git a/emhttp/plugins/unRAIDServer/EULA.page b/emhttp/plugins/unRAIDServer/EULA.page new file mode 100644 index 0000000000..71668ad473 --- /dev/null +++ b/emhttp/plugins/unRAIDServer/EULA.page @@ -0,0 +1,21 @@ +Menu="About" +Title="EULA" +Icon="icon-eula" +Tag="file-text-o" +--- + + + diff --git a/emhttp/plugins/unRAIDServer/README.md b/emhttp/plugins/unRAIDServer/README.md new file mode 100644 index 0000000000..0692574c3b --- /dev/null +++ b/emhttp/plugins/unRAIDServer/README.md @@ -0,0 +1,3 @@ +**Unraid OS** + +Unraid OS by [Lime Technology, Inc.](https://lime-technology.com). diff --git a/emhttp/plugins/unRAIDServer/icons/eula.png b/emhttp/plugins/unRAIDServer/icons/eula.png new file mode 100644 index 0000000000000000000000000000000000000000..09c5757ed6cb657cc94b5a899c3bb2b49e73f562 GIT binary patch literal 3154 zcmV-Y46XBtP)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=00004XF*Lt006O$eEU(80000WV@Og>004R=004l4008;_004mL004C` z008P>0026e000+nl3&F}0001`P)t-s|NsA^qobsxq@|^$rlzK+r>Cf>sHv%`va++Z zw6wLgwYIjmx3{>sxVgExxw^W#y1Ki&ySux)yu-u8#l^+O#>UCX$;->j*x1FVn0?d|RE?(XmJ@9^;O@$vEU^78ZZ^Yrxe z_4W1k_V)Mp_xSku`T6lq& z<^3b8kN^Mxs7XXYR2Ufr!AoiaK@Nb{ST1~glrUD1V6(pT-YyF zp#nh&k1D?a@U7x-+l=S?W_apSk>=g~Id8AJ4h8GuU^t8kD*9#QFh&wo>F>H(;&lI5 zr-ErWWoDbVHx%;qv}!Gl#}SoWd`4=q8lch=NQ6S(e&@XWOqq!ya@x;r+RrT$g?vBv s-cEgHqKZYoig;O<2tw!}2q8TF0fi<>k$e0fQ~&?~07*qoM6N<$f}K1MPyhe` literal 0 HcmV?d00001 diff --git a/emhttp/plugins/unRAIDServer/icons/license.png b/emhttp/plugins/unRAIDServer/icons/license.png new file mode 100644 index 0000000000000000000000000000000000000000..d6e06fae5ab1b92ceb7de8d7f6c8f97fee9aad63 GIT binary patch literal 580 zcmV-K0=xZ*P)q82M8u1LaLCUNudTC*Sp=> zoxE@M?l{mUSY+96=i|HYeecbiInP>)>pVWL`6#S=HLkUP1!fsSf7XVI`5ZJsBo9~%M6BmtSS0Dk%{f*=MGGQNz^zse7Ee5O`JHBV!=`}gqc4`UNf2rA zfKtwddF$XdUamaC)|b%^v&tg~F6&QI#0hcYL23{MY;1fkqk8`rn)_w!ejVcsO>BTS zwLOPfY?Sd}{0~0tmGElz6zvuwH-MAk0{2;+`t87#=@UGiK1SFaN3HU$NBW}r(=p$> zb;|8%L>GwNfOZ>ph-?C^JUr{k?gw{e!VG~!rBPFANrVI9RU&=aE_ompeRQbxZ~;=W`)SWW)_CcmbqIQEb>P; zU_e^EgV)%;`~2AqO#4c=d$I)_$N5?);C=XM=gE?NtXLZ~nV+bElJ2s*Pf8}w-i#dzA3Scss z?u0mtrLV8=4H(RTy$)Cpu-R-sU=u(X4*-mei~<2EFnjjw5MUc%Y;3#}2n9?`Om+i% zfW3qzQ`6r8Gc&UoARagb{0W$wo2LM&KpJ3SVQ~WZ3$V1bJOikKD}>Jl3#=E~Tm!BH z1;7p97ElD(+S(FTDg`P4JG(`8c6MAY_W@7?JOb?P?Q4O0V9Amt4L}p{0(c3u0n3-K z=mg#aT|hU`12{N1d;&fL{lEY)1PlXTfUm$ez{$z!J1_!_0u#U_Fa=BluCA_bZf@@G z?j9Z-6EArgMxyBgM)wF zYY-9=vUTfLm;LP3`#CPL43~I4j|669WTYsS9Tykpm!U70%l$JA<%bduA3l8a=uw42 zk(`p0nwlz88>gkE$<$WIj~|aZ@1#d06o`Gxkqh9aoR zunsm6)a>C!t50z!c3mK~YRPrYLCzO;c_^ + + + + + + + + + + + + + +]> + + + + +# Version &version; &date; + +## Changes + +Refer to [Release Notes](¬es;) + +## Linux kernel + +* version &kernel; + + + + + + +rm /tmp/&name;.sh +# cleanup possibly failed previous download/install attempt +rm -rf /tmp/&name;* +mkdir /tmp/&name; +# check if this is unRAID-5 +source /etc/unraid-version +if [[ "${version:0:2}" == "5." ]]; then + # prevent endless install loop + rm -rf /boot/plugins/&name;.plg + rm -rf /boot/config/plugins/&name;.plg + # check if 64-bit capable CPU + if ! grep -q " lm" /proc/cpuinfo ; then + echo "CPU is not 64-bit capable" + exit 1 + fi + # Wait until network is ready by pinging google - thanks bonienl! + ip=8.8.4.4 + timer=30 + while [[ $timer -gt 0 ]]; do + if [[ -n $(route -n|awk '/^0.0.0.0/{print $2}') && $(ping -qnc1 $ip|awk '/received/{print $4}') -eq 1 ]]; then + break + fi + ((timer--)) + sleep 1 + done + if [[ $timer -eq 0 ]]; then + echo "No network communication !!!" + exit 1 + fi + # unRAID-5 needs infozip + if [ ! -f /boot/extra/&infozip; ]; then + echo "Downloading &infozip; package" + mkdir -p /boot/extra + wget http://slackware.cs.utah.edu/pub/slackware/slackware-13.1/slackware/a/&infozip; -O /boot/extra/&infozip; + upgradepkg --install-new /boot/extra/&infozip; + fi + # download the release + if ! wget --no-check-certificate &zip; -O /tmp/&name;.zip ; then + echo "&zip; download error $?" + exit 1 + fi + if ! wget --no-check-certificate &md5; -O /tmp/&name;.md5 ; then + echo "&md5; download error $?" + exit 1 + fi +fi + + + + + + +version="&version;" +/dev/null) +if [[ -n "$REISER_DEVICES" ]]; then + echo "***" + echo "*** ReiserFS filesystem(s) detected:" + echo "$REISER_DEVICES" + echo "*** Upgrading to this Unraid version is blocked while ReiserFS is in use." + echo "*** Please migrate all ReiserFS disks to a supported filesystem and retry." + echo "***" + exit 1 +fi +exit 0 +]]> + + + + + + +NEW_UNRAID_VERSION="&version;" +NEW_API="&api;" +/dev/null; then + echo "Checking for a newer version of Unraid Connect" + # Get the currently installed version of the Connect plugin + INSTALLED_VERSION=$($PLUGIN version "$INSTALLED_PLUGIN_FILE" 2>/dev/null) + + # Check for latest version (downloads to /tmp/plugins/$PLG and outputs version) + # Extract version from output (version is on its own line, filter out hook messages) + LATEST_VERSION=$($PLUGIN check "$PLG" 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' | head -n1) + + # Compare versions using PHP's version_compare (returns -1 if latest < installed, 0 if equal, 1 if latest > installed) + if [[ -n "$INSTALLED_VERSION" && -n "$LATEST_VERSION" ]]; then + if [[ $(php -r "echo version_compare('$LATEST_VERSION', '$INSTALLED_VERSION');") -gt 0 ]]; then + # Latest version of Connect is newer, download files without installing + # The 'download' method expects the 'check' method to have put the updated plugin in /tmp/plugins/ + # The 'download' method downloads files but skips Run commands, and stages the plugin in /boot/config/plugins-nextboot/ + echo "Downloading newer version of Unraid Connect" + if ! $PLUGIN download "$PLG" "$NEW_UNRAID_VERSION" 2>/dev/null; then + echo "Warning: Failed to download newer version of Unraid Connect" + elif [[ -f "$NEXTBOOT_PLUGIN_FILE" ]]; then + API_PLUGIN_FILE="$NEXTBOOT_PLUGIN_FILE" + fi + fi + fi + fi + + # If a newer Connect plugin was already staged by a previous run, prefer that staged file + # for the API check even when this execution path did not just download it. + if [[ "$API_PLUGIN_FILE" == "$INSTALLED_PLUGIN_FILE" && -f "$NEXTBOOT_PLUGIN_FILE" ]]; then + NEXTBOOT_VERSION=$($PLUGIN version "$NEXTBOOT_PLUGIN_FILE" 2>/dev/null) + INSTALLED_VERSION=${INSTALLED_VERSION:-$($PLUGIN version "$INSTALLED_PLUGIN_FILE" 2>/dev/null)} + if [[ -n "$NEXTBOOT_VERSION" && ( -z "$INSTALLED_VERSION" || $(php -r "echo version_compare('$NEXTBOOT_VERSION', '$INSTALLED_VERSION');") -gt 0 ) ]]; then + API_PLUGIN_FILE="$NEXTBOOT_PLUGIN_FILE" + fi + fi + + # Get the API version from the Unraid Connect plugin that will be used after reboot. + INSTALLED_API=$(grep -o 'ENTITY api_version "[^"]*"' "$API_PLUGIN_FILE" | sed 's/ENTITY api_version "//;s/"//') + [[ -z "$INSTALLED_API" ]] && exit 0 + + # Ensure the API version for this new version of Unraid is defined + [[ -z "$NEW_API" ]] && exit 0 + + # Strip build strings (everything after +) from both versions before comparison + INSTALLED_API=${INSTALLED_API%+*} + NEW_API=${NEW_API%+*} + + # Compare API versions - if installed plugin has older API than the new unRAID version, require update + if [[ $(php -r "echo version_compare('$INSTALLED_API', '$NEW_API');") -lt 0 ]]; then + echo "***" + echo "*** Please update the Unraid Connect plugin before upgrading Unraid!" + echo "***" + exit 1 + fi + +fi + +exit 0 +]]> + + + + + +&zip; + + +&md5; + + + + +rm /tmp/&name;.sh +# check download and extract +sum1=$(/usr/bin/md5sum /tmp/&name;.zip) +sum2=$(cat /tmp/&name;.md5) +if [[ "${sum1:0:32}" != "${sum2:0:32}" ]]; then + echo "wrong md5" + exit 1 +fi +# check if enough free space on flash +have=$(df -B1 /boot | awk 'END {print $4}') +need=$(unzip -l /tmp/&name;.zip | awk 'END {print $1}') +source /etc/unraid-version +if [[ "${version:0:2}" == "5." ]]; then + # to permit another upgrade + need=$(($need * 2)) +fi +if [[ $need -gt $have ]]; then + echo "boot device shows $have free but upgrade needs $need" + exit 1 +fi +# detect if Plugin-Update-Helper for third party driver plugins is running and update it +plg_update_helper=$(ps aux | grep -E "inotifywait -q /boot/changes.txt -e move_self,delete_self" | grep -v "grep -E inotifywait" | awk '{print $2}') +if [ ! -z "${plg_update_helper}" ]; then + echo "Plugin-Update-Helper for 3rd party driver plugins found, updating..." + kill ${plg_update_helper} 2>/dev/null + wget -q -T 5 -O /usr/bin/plugin_update_helper "https://raw.githubusercontent.com/ich777/unraid-plugin_update_helper/master/plugin_update_helper" + chmod +x /usr/bin/plugin_update_helper 2>/dev/null + echo "/usr/bin/plugin_update_helper" | at now > /dev/null 2>&1 +fi +# move release files to flash +echo +echo "writing flash device - please wait..." +mkdir -p /boot/&name; +rm -rf /boot/&name;/* +files=&files; +unzip -d /boot/&name; /tmp/&name;.zip ${files//,/ } || exit 1 +# ensure writes to USB flash boot device have completed and bz*.sha256 matches +sync -f /boot ; echo 3 > /proc/sys/vm/drop_caches +for sha in /boot/&name;/*.sha256; do + file=${sha%.sha256} + echo "checking sha256 on ${file}" + sha256expect=$(cat "${sha}") + sha256actual=$(/usr/bin/sha256sum "${file}") + if [[ "${sha256actual:0:64}" != "${sha256expect:0:64}" ]]; then + echo "*** bad sha256 on ${file}" + badsha256="yes" + fi +done +if [[ -v badsha256 ]]; then + echo "***" + echo "*** The upgrade failed, but no changes were made to your configuration." + echo "*** Your USB Flash is likely failing." + echo "***" + exit 1 +fi +# preserve previous version +source /etc/unraid-version +if [[ "${version:0:2}" == "5." ]]; then + mkdir -p /boot/unRAID5 + rm -rf /boot/unRAID5/* + # preserve all files in root of flash except ldlinux.sys needed to boot + find /boot -maxdepth 1 -type f -not -name ldlinux.sys -exec mv {} /boot/unRAID5 \; + # preserve a few directories + mv /boot/extra /boot/unRAID5 &> /dev/null + mv /boot/packages /boot/unRAID5 &> /dev/null + mv /boot/plugins /boot/unRAID5 &> /dev/null + mkdir /boot/unRAID5/config + mv /boot/config/plugins /boot/unRAID5/config &> /dev/null + # grab a fresh 'go' file + mv /boot/config/go /boot/unRAID5/config + unzip -d /boot /tmp/&name;.zip config/go || exit 1 + # ensure key file is in the 'config' directory + cp /boot/unRAID5/*.key /boot/config &> /dev/null +else + mkdir -p /boot/previous + # check for earlier upgrade without reboot + if ! /sbin/mount | grep -q "/boot/previous/bz" ; then + rm -rf /boot/previous/* + mv /boot/{&files;} /boot/previous &> /dev/null + fi +fi +# capture the current hardware list +/sbin/lspci -nnmm > /boot/previous/hardware +# move new version files into place +mv /boot/&name;/{&files;} /boot +rm -r /boot/&name; +# Unraid-6/7 specific +if [[ "${version:0:2}" == "6." || "${version:0:2}" == "7." ]]; then + # replace the readme file + echo "**REBOOT REQUIRED!**" > /usr/local/emhttp/plugins/&name;/README.md + # update pre-6.12.2 wireguard configuration files + if [[ $(php -r "echo version_compare('$version', '6.12.2');") -lt 0 ]]; then + for wg in /boot/config/wireguard/*.conf; do + sed -ri "s/^(Post(Up|Down)=logger -t wireguard .*(started|stopped)).*/\1';\/usr\/local\/emhttp\/webGui\/scripts\/update_services/" $wg + done + fi + # when upgrading any version prior to 6.10 + if [[ $(php -r "echo version_compare('$version', '6.10');") -lt 0 ]]; then + if [[ -f /boot/config/ssl/certs/certificate_bundle.pem ]]; then + if grep -q 'USE_SSL="no"' /boot/config/ident.cfg &> /dev/null ; then + rm /boot/config/ssl/certs/certificate_bundle.pem + else + sed -i 's|USE_SSL="auto"|USE_SSL="yes"|g' /boot/config/ident.cfg + fi + else + sed -i 's|USE_SSL="auto"|USE_SSL="no"|g' /boot/config/ident.cfg + fi + fi +fi +# if Unraid-6.3 ensure GUI Safe Mode syslinux option exists +if [[ "${version:0:3}" == "6.3" ]]; then + if ! grep -q 'initrd=/bzroot,/bzroot-gui unraidsafemode' /boot/syslinux/syslinux.cfg &> /dev/null ; then + sed -i 's|label Memtest86+|label unRAID OS GUI Safe Mode (no plugins)\r\n kernel /bzimage\r\n append initrd=/bzroot,/bzroot-gui unraidsafemode\r\nlabel Memtest86+|g' /boot/syslinux/syslinux.cfg &> /dev/null + fi +fi +# when upgrading any version prior to 6.2 +if [[ "${version:0:3}" < "6.2" ]]; then + if ! grep -q '/bzroot-gui' /boot/syslinux/syslinux.cfg &> /dev/null ; then + sed -i 's|menu title Lime Technology\r|menu title Lime Technology, Inc.\r|g' /boot/syslinux/syslinux.cfg &> /dev/null + sed -i 's|label unRAID OS Safe Mode (no plugins)|label unRAID OS GUI Mode\r\n kernel /bzimage\r\n append initrd=/bzroot,/bzroot-gui\r\nlabel unRAID OS Safe Mode (no plugins, no GUI)|g' /boot/syslinux/syslinux.cfg &> /dev/null + fi +fi +# when upgrading any version prior to 6.1 +if [[ "${version:0:3}" < "6.1" ]]; then + if ! grep -q 'shareDisk' /boot/config/share.cfg &> /dev/null ; then + echo 'shareDisk="yes"' >> /boot/config/share.cfg + fi +fi +# when upgrading from 6.0.x +if [[ "${version:0:3}" == "6.0" ]]; then + sed -i 's|dynamix.docker.manager/dockerupdate.php|dynamix.docker.manager/scripts/dockerupdate.php|g' /boot/config/plugins/dynamix/docker-update.cron &> /dev/null + sed -i 's|sbin/monitor|emhttp/plugins/dynamix/scripts/monitor|g' /boot/config/plugins/dynamix/monitor.cron &> /dev/null + sed -i 's|/root/mdcmd|/usr/local/sbin/mdcmd|g' /boot/config/plugins/dynamix/parity-check.cron &> /dev/null + sed -i 's|sbin/plugincheck|emhttp/plugins/dynamix.plugin.manager/scripts/plugincheck|g' /boot/config/plugins/dynamix/plugin-check.cron &> /dev/null + sed -i 's|sbin/statuscheck|emhttp/plugins/dynamix/scripts/statuscheck|g' /boot/config/plugins/dynamix/status-check.cron &> /dev/null +fi +# if template-repos does not exist +if [[ ! -e /boot/config/plugins/dockerMan/template-repos ]]; then + mkdir -p /boot/config/plugins/dockerMan + echo "https://github.com/limetech/docker-templates" > /boot/config/plugins/dockerMan/template-repos +fi +# if EFI or EFI- directory does not exist on vfat file system +if [[ ! -e /boot/efi && ! -e /boot/efi- ]]; then + unzip -d /boot /tmp/&name;.zip EFI/* || exit 1 + sed -i 's|default /syslinux/menu.c32|default menu.c32|g' /boot/syslinux/syslinux.cfg &> /dev/null +fi +# if metric appended to GATEWAY get rid of it +if [[ -f /boot/config/network.cfg ]]; then + sed -ri 's|^(GATEWAY.+)#[0-9]+|\1|' /boot/config/network.cfg +fi +# if legacy dynamix.plg file exists, delete it +rm -f /boot/config/plugins/dynamix.plg +# download any patches for this version before rebooting +if [[ -x /usr/local/emhttp/plugins/unraid.patch/scripts/patch.php ]]; then + /usr/local/emhttp/plugins/unraid.patch/scripts/patch.php check &version; +fi +# ensure writes to USB flash boot device have completed +sync -f /boot +if [ -z "${plg_update_helper}" ]; then + echo "Update successful - PLEASE REBOOT YOUR SERVER" +else + echo "Third party plugins found - PLEASE CHECK YOUR UNRAID NOTIFICATIONS AND WAIT FOR THE MESSAGE THAT IT IS SAFE TO REBOOT!" +fi + + + + + + +rm /tmp/&name;.sh +# unRAID-5 doesn't support 'remove' method, so we're done +source /etc/unraid-version +if [[ "${version:0:2}" == "5." ]]; then + exit 0 +fi +if [[ -d /boot/previous ]]; then + # restore previous Unraid-6 release + rm -f /boot/previous/hardware + mv /boot/previous/* /boot + rmdir /boot/previous + echo "**REBOOT REQUIRED!**" > /usr/local/emhttp/plugins/&name;/README.md +elif [[ -d /boot/unRAID5 ]]; then + # restore previous unRAID-5 release + rm -rf /boot/extra + mv /boot/unRAID5/extra /boot &> /dev/null + rm -rf /boot/plugins + mv /boot/unRAID5/plugins /boot &> /dev/null + rm -rf /boot/packages + mv /boot/unRAID5/packages /boot &> /dev/null + rm -rf /boot/config/plugins + mv /boot/unRAID5/config/plugins /boot/config &> /dev/null + mv /boot/unRAID5/config/go /boot/config + rmdir /boot/unRAID5/config + mv /boot/unRAID5/* /boot + rmdir /boot/unRAID5 +else + echo "Cannot remove, no previous version" + exit 1 +fi +echo "syncing..." +sync +echo "Remove successful - PLEASE REBOOT YOUR SERVER" + + + + From 6b017263463942718450aa124b4828d35416dd33 Mon Sep 17 00:00:00 2001 From: SimonFair <39065407+SimonFair@users.noreply.github.com> Date: Thu, 28 May 2026 12:22:43 +0100 Subject: [PATCH 26/26] Update monitor --- emhttp/plugins/dynamix/scripts/monitor | 202 +++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/emhttp/plugins/dynamix/scripts/monitor b/emhttp/plugins/dynamix/scripts/monitor index 01c25eb86a..1e56ca7634 100755 --- a/emhttp/plugins/dynamix/scripts/monitor +++ b/emhttp/plugins/dynamix/scripts/monitor @@ -42,6 +42,184 @@ $pools = pools_filter($disks); $errors = []; $top = 120; +function panelcontrol_monitor_has_show_flag() { + return true; +} + +function panelcontrol_monitor_state_rank($state) { + switch ((string)$state) { + case 'alert': return 5; + case 'warning': return 4; + case 'normal-blink': return 3; + case 'normal': return 2; + case 'off': return 1; + default: return 0; + } +} + +function panelcontrol_monitor_push_event(&$events, $event, $scope, $target, $state, $reason, $details=[]) { + $events[] = [ + 'event' => (string)$event, + 'scope' => (string)$scope, + 'target' => (string)$target, + 'state' => (string)$state, + 'reason' => (string)$reason, + 'details' => is_array($details) ? $details : [], + ]; +} + +function panelcontrol_monitor_build_payload($var, $disks, $devs, $saved, $display, $server, $high1, $high2, $top) { + $events = []; + + foreach ((array)$disks as $disk) { + $name = _var($disk,'name'); + if ($name === 'flash' || substr(_var($disk,'status'),-3) === '_NP') { + continue; + } + + $diskName = no_tilde((string)$name); + $diskDevice = strtolower(trim((string)_var($disk,'device',''))); + $diskId = trim((string)_var($disk,'id','')); + $temp = _var($disk,'temp','*'); + $spundownRaw = _var($disk,'spundown',''); + $spundownValue = strtolower(trim((string)$spundownRaw)); + $status = strtolower((string)_var($disk,'status','')); + $isNoDeviceStatus = strpos($status, 'np') !== false; + $isDisabledStatus = strpos($status, 'dsbl') !== false || strpos($status, 'disabled') !== false; + if (($diskDevice === '' && $diskId === '') || $isNoDeviceStatus || $isDisabledStatus) { + continue; + } + $target = $diskDevice !== '' ? $diskDevice : $diskId; + $color = strtolower((string)strtok(_var($disk,'color'),'-')); + $eventsBeforeDisk = count($events); + $isSpunDown = in_array($spundownValue, ['1', 'true', 'yes', 'on'], true) + || strpos($status, 'sby') !== false + || strpos($status, 'standby') !== false; + $identity = [ + 'disk_name' => $diskName, + 'disk_device' => $diskDevice, + 'disk_id' => $diskId, + 'disk_temp' => is_numeric($temp) ? (int)$temp : null, + 'disk_spundown' => $spundownRaw, + ]; + + if ($color === 'red') { + panelcontrol_monitor_push_event($events, 'monitor.storage.offline', 'disk', $target, 'alert', 'disk-error-state', [ + 'status' => $status, + ] + $identity); + } elseif ($color === 'yellow') { + panelcontrol_monitor_push_event($events, 'monitor.storage.activity', 'disk', $target, 'normal-blink', 'disk-rebuild-or-sync', [ + 'status' => $status, + ] + $identity); + } + + if ($isSpunDown) { + panelcontrol_monitor_push_event($events, 'monitor.storage.spindown', 'disk', $target, 'normal', 'disk-standby', [ + 'status' => $status, + ] + $identity); + } + + if (!$isSpunDown) { + if (is_numeric($temp)) { + panelcontrol_monitor_push_event($events, 'monitor.system.temperature', 'disk', $target, 'normal', 'temperature-sample', [ + 'temp' => (int)$temp, + ] + $identity); + } + + [$hotNVME,$maxNVME] = _var($disk,'transport')=='nvme' ? get_nvme_info(_var($disk,'device'),'temp') : [-1,-1]; + $hot = _var($disk,'hotTemp',-1)>=0 ? $disk['hotTemp'] : ($hotNVME>=0 ? $hotNVME : (_var($disk,'rotational',1)==0 && $display['hotssd']>=0 ? $display['hotssd'] : $display['hot'])); + $max = _var($disk,'maxTemp',-1)>=0 ? $disk['maxTemp'] : ($maxNVME>=0 ? $maxNVME : (_var($disk,'rotational',1)==0 && $display['maxssd']>=0 ? $display['maxssd'] : $display['max'])); + $tempState = exceed($temp,$max,$top) ? 'alert' : (exceed($temp,$hot,$top) ? 'warning' : ''); + if ($tempState !== '') { + panelcontrol_monitor_push_event($events, 'monitor.system.temperature', 'disk', $target, $tempState, 'temperature-threshold', [ + 'temp' => (int)$temp, + 'warning' => (int)$hot, + 'critical' => (int)$max, + ] + $identity); + } + } + + $numErrors = (int)_var($disk,'numErrors',0); + if ($numErrors > 0) { + panelcontrol_monitor_push_event($events, 'monitor.storage.health', 'disk', $target, 'alert', 'read-errors', [ + 'errors' => $numErrors, + ] + $identity); + } + + if (count($events) === $eventsBeforeDisk) { + panelcontrol_monitor_push_event($events, 'monitor.storage.health', 'disk', $target, 'normal', 'disk-present', [ + 'status' => $status, + ] + $identity); + } + } + + foreach ((array)$devs as $dev) { + $name = _var($dev,'name','no-name'); + $target = 'device:' . no_tilde((string)$name); + $temp = _var($dev,'temp','*'); + if (!is_numeric($temp)) { + continue; + } + + $tempInt = (int)$temp; + $hot = (int)_var($display,'hot',0); + $max = (int)_var($display,'max',0); + $tempState = exceed($tempInt,$max,$top) ? 'alert' : (exceed($tempInt,$hot,$top) ? 'warning' : ''); + if ($tempState !== '') { + panelcontrol_monitor_push_event($events, 'monitor.system.temperature', 'device', $target, $tempState, 'temperature-threshold', [ + 'temp' => $tempInt, + 'warning' => $hot, + 'critical' => $max, + ]); + } + } + + $targets = []; + $counts = ['off' => 0, 'normal' => 0, 'normal-blink' => 0, 'warning' => 0, 'alert' => 0]; + foreach ($events as $event) { + $target = (string)$event['target']; + $state = (string)$event['state']; + if ($target === '' || $state === '') { + continue; + } + if (!isset($targets[$target]) || panelcontrol_monitor_state_rank($state) > panelcontrol_monitor_state_rank($targets[$target])) { + $targets[$target] = $state; + } + } + + foreach ($targets as $state) { + if (isset($counts[$state])) { + $counts[$state]++; + } + } + + $payloadSavedState = is_array($saved) ? $saved : []; + if (isset($payloadSavedState['used'])) { + unset($payloadSavedState['used']); + } + + return [ + 'schema' => 'panelcontrol.monitor-payload.v1', + 'source' => 'unraid.monitor.copy', + 'generatedAt' => gmdate(DATE_RFC3339), + 'server' => (string)$server, + 'thresholds' => [ + 'docker_critical' => (int)$high1, + 'docker_warning' => (int)$high2, + ], + 'saved_state' => $payloadSavedState, + 'events' => $events, + 'targets' => $targets, + 'summary' => [ + 'event_count' => count($events), + 'targets_count' => count($targets), + 'state_counts' => $counts, + ], + ]; +} + +$panelcontrolShow = panelcontrol_monitor_has_show_flag(); + function check_temp(&$disk,$text,$info) { global $notify,$saved,$server,$display,$top; $name = _var($disk,'name'); @@ -387,5 +565,29 @@ if ($saved) { delete_file($ram,$rom); } } + +if ($panelcontrolShow) { + $payload = panelcontrol_monitor_build_payload($var, $disks, $devs, $saved, $display, $server, $high1, $high2, $top); + $payloadPath = '/usr/local/emhttp/state/panelcontrol-monitor-payload.json'; + $payloadJsonRaw = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($payloadJsonRaw === false) { + my_logger('Failed to encode panelcontrol monitor payload: '.json_last_error_msg(), 'webgui'); + } else { + $payloadJson = $payloadJsonRaw . "\n"; + $existingPayloadJson = @file_get_contents($payloadPath); + if ($existingPayloadJson !== $payloadJson) { + $tmpPath = $payloadPath . '.tmp'; + if (@file_put_contents($tmpPath, $payloadJson, LOCK_EX) !== false) { + if (!@rename($tmpPath, $payloadPath)) { + @file_put_contents($payloadPath, $payloadJson, LOCK_EX); + @unlink($tmpPath); + } + } else { + @file_put_contents($payloadPath, $payloadJson, LOCK_EX); + } + } + } +} + exit(0); ?>