From dd09653e5b6bfe72962ed8ba60ab3410ead1aa9f Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 17:07:44 +0000 Subject: [PATCH 1/3] fanctrlplus2: run custom sensors from flash without an execute bit The Unraid flash drive is FAT32 and cannot hold an execute bit, so scripts in sensors.d were never offered or read. Both the settings page and the control loop now run a fresh private copy of each readable script from runtime storage, keeping its shebang, the 5-second timeout and the output contract. Fixes #9 --- .../plugins/fanctrlplus2/include/Common.php | 19 ++++-- .../fanctrlplus2/scripts/aux_sensors.sh | 14 ++-- .../fanctrlplus2/scripts/custom_sensor.sh | 41 ++++++++++++ tests/aux_sensor_read_test.sh | 65 +++++++++++++++++-- tests/custom_sensor_test.php | 42 +++++++++--- 5 files changed, 158 insertions(+), 23 deletions(-) create mode 100755 src/usr/local/emhttp/plugins/fanctrlplus2/scripts/custom_sensor.sh diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php index 9628252..0392c57 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/Common.php @@ -447,9 +447,10 @@ function fcp_clamp_temp(int $temp): ?int { } // ===== User-supplied sensor scripts ===== -// Any executable dropped in the sensors.d directory is a sensor. The contract -// is deliberately small: print the temperature in Celsius and nothing else, or -// print nothing and exit non-zero, which disables it for that round only. +// Any readable file dropped in the sensors.d directory is a sensor, executable +// or not: the directory sits on flash that cannot hold an execute bit. The +// contract is deliberately small: print the temperature in Celsius and nothing +// else, or print nothing and exit non-zero, which disables it for that round only. const FCP_CUSTOM_SENSOR_DIR = '/boot/config/plugins/fanctrlplus2/sensors.d'; const FCP_CUSTOM_SENSOR_TIMEOUT = 5; @@ -469,16 +470,22 @@ function detect_custom_temps(string $dir = FCP_CUSTOM_SENSOR_DIR): array { $result = []; $scripts = glob("$dir/*") ?: []; sort($scripts); + $runner = dirname(__DIR__) . '/scripts/custom_sensor.sh'; foreach ($scripts as $script) { $name = basename($script); - if (!is_file($script) || !is_executable($script)) continue; - if ($name[0] === '.') continue; + // Same names the control loop can read back: no hidden files, no + // characters that would not survive the sensor list. + if (!preg_match('/^[A-Za-z0-9_-][A-Za-z0-9._-]*$/', $name)) continue; + if (!is_file($script) || !is_readable($script)) continue; + // The flash drive holding the directory cannot carry an execute bit, so + // the runner executes a private runtime copy and enforces the timeout. $output = []; $status = 0; exec( - 'timeout ' . FCP_CUSTOM_SENSOR_TIMEOUT . ' ' . escapeshellarg($script) . ' 2>/dev/null', + 'bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($script) . ' ' + . FCP_CUSTOM_SENSOR_TIMEOUT . ' 2>/dev/null', $output, $status ); diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh index b6ad487..487f5ae 100755 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/aux_sensors.sh @@ -6,11 +6,13 @@ # shellcheck disable=SC2034 -# Drop-in directory for user-supplied sensor scripts. Each executable there is -# one sensor: it prints the temperature in Celsius and nothing else, or prints -# nothing and exits non-zero, which disables it for that round only. +# Drop-in directory for user-supplied sensor scripts. Each readable file there +# is one sensor, executable or not (the flash drive it lives on cannot hold an +# execute bit): it prints the temperature in Celsius and nothing else, or +# prints nothing and exits non-zero, which disables it for that round only. fcp_custom_sensor_dir="${fcp_custom_sensor_dir:-/boot/config/plugins/fanctrlplus2/sensors.d}" fcp_custom_sensor_timeout="${fcp_custom_sensor_timeout:-5}" +fcp_custom_sensor_runner="${fcp_custom_sensor_runner:-$(dirname "${BASH_SOURCE[0]}")/custom_sensor.sh}" # ===== Temperature readings ===== # Every reading the plugin evaluates, whatever produced it, is pulled into the @@ -133,8 +135,10 @@ aux_read_sensor() { # is refused outright. [[ "$sensor" =~ ^custom:([A-Za-z0-9._-]+)$ ]] || return 0 script="$fcp_custom_sensor_dir/${BASH_REMATCH[1]}" - [[ "${BASH_REMATCH[1]}" == .* || ! -x "$script" ]] && return 0 - temp=$(timeout "$fcp_custom_sensor_timeout" "$script" 2>/dev/null) || return 0 + [[ "${BASH_REMATCH[1]}" == .* ]] && return 0 + # The flash drive holding the directory cannot carry an execute bit, so + # the runner executes a private runtime copy; it also enforces the timeout. + temp=$(bash "$fcp_custom_sensor_runner" "$script" "$fcp_custom_sensor_timeout") || return 0 # The contract is the temperature and nothing else, so output that is not # a number comes from a broken script rather than a sensor. A fractional # reading is truncated to whole degrees. diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/custom_sensor.sh b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/custom_sensor.sh new file mode 100755 index 0000000..a314007 --- /dev/null +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/custom_sensor.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Run one user-supplied sensor script and relay its output and exit status. +# Shared by the PHP sensor discovery and the control loop, so both read a +# sensor identically. +# +# custom_sensor.sh SCRIPT [TIMEOUT_SECONDS] +# +# The sensors.d directory lives on the Unraid flash drive, which is FAT32 and +# can never carry an execute bit, so SCRIPT is only required to be a readable +# file. It is copied afresh into a private directory on runtime storage, made +# executable there and run from the copy, which keeps its shebang line working +# and means an edit on flash applies to the very next reading. The copy never +# outlives the run, however it ends. Nothing is ever chmod'ed or run on flash. + +set -u + +script="${1:-}" +limit="${2:-5}" +# An immediate child of the sticky runtime directory avoids trusting the +# separately managed /var/tmp/fanctrlplus2 parent for executable payloads. +base="${FCP_CUSTOM_SENSOR_RUNTIME_DIR:-/var/tmp/fanctrlplus2-sensor-run-$EUID}" + +[[ -f "$script" && -r "$script" ]] || exit 1 +[[ "$limit" =~ ^[0-9]+(\.[0-9]+)?$ && "$limit" =~ [1-9] ]] || exit 1 + +umask 077 +mkdir -p -m 700 -- "$base" 2>/dev/null || exit 1 +# Only a private directory of ours may hold something about to be executed. +[[ -d "$base" && ! -L "$base" && -O "$base" ]] || exit 1 +permissions=$(stat -c %a -- "$base" 2>/dev/null) || exit 1 +(( (8#$permissions & 0022) == 0 )) || exit 1 + +work=$(mktemp -d -- "$base/run.XXXXXX" 2>/dev/null) || exit 1 +trap 'rm -rf -- "$work"' EXIT +trap 'exit 143' HUP INT TERM + +cp -- "$script" "$work/sensor" 2>/dev/null || exit 1 +chmod 700 -- "$work/sensor" || exit 1 + +# -k: a script that ignores the polite signal is still gone shortly after. +timeout -k 1 "$limit" "$work/sensor" 2>/dev/null "$tmp/sensors.d/$name" - chmod +x "$tmp/sensors.d/$name" + chmod 600 "$tmp/sensors.d/$name" } write_sensor ambient <<'STUB' @@ -277,9 +278,65 @@ STUB expect_equal "" "$(aux_read_sensor "custom:slow")" \ "A script that hangs must be abandoned for the round." -printf '#!/bin/bash\necho 50\n' > "$tmp/sensors.d/not_executable" -expect_equal "" "$(aux_read_sensor "custom:not_executable")" \ - "A file that is not executable must be skipped." +# Nothing a reading leaves behind may stay on disk: not after a success, a +# failing script or the timeout that killed a hung one. +expect_equal "" "$(ls -A "$FCP_CUSTOM_SENSOR_RUNTIME_DIR")" \ + "No runtime copy of a sensor script may outlive its reading." + +# Flash storage cannot carry an execute bit, so a plain 0600 file is the norm. +# Every sensor above was written that way; pin it so the fixtures stay honest. +expect_equal "600" "$(stat -c %a "$tmp/sensors.d/ambient")" \ + "The fixtures must model flash storage: readable but never executable." + +# The script's own interpreter line is honoured, not replaced with bash. +cat > "$tmp/interp" <<'STUB' +#!/bin/bash +sed -n 2p "$1" +STUB +chmod +x "$tmp/interp" +printf '#!%s\n58\n' "$tmp/interp" > "$tmp/sensors.d/interpreted" +chmod 600 "$tmp/sensors.d/interpreted" +expect_equal "58" "$(aux_read_sensor "custom:interpreted")" \ + "The interpreter named by the shebang must run the script." + +# The file on flash is the source of truth: an edit applies to the next +# reading even when size and modification time are unchanged. +printf '#!/bin/bash\necho 21\n' > "$tmp/sensors.d/edited" +chmod 600 "$tmp/sensors.d/edited" +touch -d '2020-01-01' "$tmp/sensors.d/edited" +expect_equal "21" "$(aux_read_sensor "custom:edited")" "The first version must be read." +printf '#!/bin/bash\necho 22\n' > "$tmp/sensors.d/edited" +touch -d '2020-01-01' "$tmp/sensors.d/edited" +expect_equal "22" "$(aux_read_sensor "custom:edited")" \ + "An edited script must be read afresh, never from a stale copy." + +# A script that cannot be read, or that is not a plain file, is no sensor. +mkdir "$tmp/sensors.d/adir" +expect_equal "" "$(aux_read_sensor "custom:adir")" "A directory is not a sensor." +printf '#!/bin/bash\necho 50\n' > "$tmp/sensors.d/.hidden" +expect_equal "" "$(aux_read_sensor "custom:.hidden")" "A hidden name is refused." +expect_equal "" "$(aux_read_sensor "custom:..")" "The parent directory is refused." +expect_equal "" "$(ls -A "$FCP_CUSTOM_SENSOR_RUNTIME_DIR")" \ + "Refused and failed readings must leave no runtime copy either." + +# Executable copies cannot live in writable/shared or symlinked runtime bases. +runtime="$FCP_CUSTOM_SENSOR_RUNTIME_DIR" +mkdir "$tmp/shared-runtime" +chmod 777 "$tmp/shared-runtime" +export FCP_CUSTOM_SENSOR_RUNTIME_DIR="$tmp/shared-runtime" +expect_equal "" "$(aux_read_sensor "custom:ambient")" "A writable shared runtime directory must be refused." +ln -s "$runtime" "$tmp/runtime-link" +export FCP_CUSTOM_SENSOR_RUNTIME_DIR="$tmp/runtime-link" +expect_equal "" "$(aux_read_sensor "custom:ambient")" "A symlinked runtime directory must be refused." +export FCP_CUSTOM_SENSOR_RUNTIME_DIR="$runtime" + +write_sensor stubborn <<'STUB' +#!/bin/bash +trap '' TERM +while :; do sleep 1; done +STUB +expect_equal "" "$(aux_read_sensor "custom:stubborn")" "A script ignoring TERM must still be killed and yield no reading." +expect_equal "" "$(ls -A "$runtime")" "A hard timeout must also remove its private copy." expect_equal "" "$(aux_read_sensor "custom:absent")" \ "A name with no script behind it must yield no reading." diff --git a/tests/custom_sensor_test.php b/tests/custom_sensor_test.php index 5bfd08e..8fd92d4 100644 --- a/tests/custom_sensor_test.php +++ b/tests/custom_sensor_test.php @@ -1,6 +1,8 @@ 'custom:ambient', 'label' => 'ambient (24°C)', 'chip' => 'Custom Sensors', 'idx' => 0], - ['path' => 'custom:nic', 'label' => 'nic (71°C)', 'chip' => 'Custom Sensors', 'idx' => 1], + ['path' => 'custom:interpreted', 'label' => 'interpreted (58°C)', 'chip' => 'Custom Sensors', 'idx' => 1], + ['path' => 'custom:nic', 'label' => 'nic (71°C)', 'chip' => 'Custom Sensors', 'idx' => 2], + ['path' => 'custom:plain', 'label' => 'plain (50°C)', 'chip' => 'Custom Sensors', 'idx' => 3], ], $found, - 'Only the executables honouring the contract are offered, in name order.' + 'Every readable script honouring the contract is offered, executable or not, in name order.' ); +expect_equal([], glob("$runDir/*") ?: [], + 'Discovery must not leave runtime copies of the scripts behind.'); + +file_put_contents("$dir/ambient", "#!/bin/sh\necho 31\n"); +touch("$dir/ambient", 1577836800); +$edited = array_column(detect_custom_temps($dir), 'label', 'path'); +expect_equal('ambient (31°C)', $edited['custom:ambient'] ?? null, + 'An edited script must be read afresh.'); expect_equal([], detect_custom_temps("$dir/nowhere"), 'A missing drop-in directory must simply mean no custom sensors.'); -array_map('unlink', glob("$dir/*")); +foreach (array_merge(glob("$dir/*") ?: [], glob("$dir/.hidden") ?: []) as $f) is_dir($f) ? rmdir($f) : unlink($f); rmdir($dir); +@rmdir($runDir); +@unlink("$dir-interp"); if ($failures) { fwrite(STDERR, implode("\n\n", $failures) . "\n"); From 3d526389eda5e6d8fc06bcdec949bf387671e11f Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 17:07:45 +0000 Subject: [PATCH 2/3] fanctrlplus2: export and restore saved configuration Add Export backup and Restore backup to General Settings. A backup is a JSON file holding the saved fan configurations, PWM labels, dashboard switches and fan order. Restore checks every file is plain settings before replacing anything, because the fan configurations are sourced by bash. It rolls back if replacement fails and keeps fan control running or stopped as it was. Custom sensor scripts and unrelated files are left alone. Refs #11 --- README.md | 38 ++- .../plugins/fanctrlplus2/fanctrlplus2.page | 92 +++++++- .../fanctrlplus2/include/ConfigBackup.php | 218 ++++++++++++++++++ .../fanctrlplus2/include/FanctrlLogic.php | 64 +++++ .../include/update.fanctrlplus2.php | 11 + tests/config_backup_test.php | 155 +++++++++++++ 6 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php create mode 100644 tests/config_backup_test.php diff --git a/README.md b/README.md index 00e62a9..8c2379b 100644 --- a/README.md +++ b/README.md @@ -59,15 +59,15 @@ Do not reinstall or run upstream FanCtrl Plus while FanCtrl Plus 2 is installed. ## Custom temperature sensors -Any sensor the plugin does not know about can be added as a script. Drop an -executable file in: +Any sensor the plugin does not know about can be added as a readable script with +a valid shebang. Place the file in: ```text /boot/config/plugins/fanctrlplus2/sensors.d/ ``` -Each executable in that directory becomes one auxiliary sensor, named after the -file, selectable per fan alongside the built-in sources. The contract is: +Each readable script in that directory becomes one auxiliary sensor, named +after the file, selectable per fan alongside the built-in sources. The contract is: - print the temperature in degrees Celsius and nothing else (a fractional reading is truncated to whole degrees); @@ -79,10 +79,9 @@ driven by its other sources, and the script is tried again on the next round. Errors are reported by the exit status, never by a sentinel reading: a script that prints `0` has reported 0 °C. -Readings are clamped to 0–200 °C, as they are for every other source, so a -value outside that range drives the fan at the corresponding end of its curve -rather than being discarded. A script that reads 0 °C when the settings page -is opened is taken for a dead sensor and is not offered in the list. +Readings below −100 °C or above 300 °C are rejected as implausible; valid negative +readings are floored at 0 °C. A script that reads 0 °C when the settings page is +opened is taken for an unpopulated sensor and is not offered in the list. ```sh #!/bin/bash @@ -92,7 +91,28 @@ temp=$(some-tool --read-ambient 2>/dev/null) || exit 1 echo "$temp" ``` -Remember to make the file executable (`chmod +x`). +The flash drive does not need executable permissions. The plugin makes a private +executable copy on runtime storage, preserving the shebang and using the file's +current contents on each read. The copy is removed after the reading finishes. +Use absolute paths for helper files: `$0` refers to the runtime copy, not the +original flash-resident script. + +## Configuration backup and restore + +Use **General Settings → Export backup** to download saved fan configurations, +PWM labels, dashboard switches, and fan ordering as a JSON file. Unsaved edits, +custom sensor scripts, runtime history, and logs are not included. + +**Restore backup** asks for confirmation before replacing saved settings. The +entire file is validated before changes are made, and unrelated files such as +`sensors.d/` remain untouched. Fan control retains its prior running or stopped +state; a failed replacement restores the previous managed configuration files. +Reloading after a successful restore shows the restored settings. + +Store backups somewhere other than the Unraid flash drive. Back up custom sensor +scripts separately. Restored USB identities are resolved by the existing device +migration at service startup; a missing or ambiguous recorded controller is not +assigned to another device. ## Fan identity and USB port changes diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page index 4cf11db..bd4919b 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page @@ -59,6 +59,8 @@ HTML;
@@ -1353,6 +1357,76 @@ $(function() { enabled: checked ? 1 : 0 }); } + + const configBackupUrl = '/plugins/fanctrlplus2/include/FanctrlLogic.php'; + function setConfigBackupBusy(busy) { + $('#fcp-config-export, #fcp-config-import').prop('disabled', busy); + } + function configBackupError(xhr) { + $('#fcp-config-backup-status').text( + xhr.responseJSON && xhr.responseJSON.message + ? xhr.responseJSON.message : 'The configuration request failed.' + ); + } + + $(document).on('click', '#fcp-config-export', function () { + setConfigBackupBusy(true); + $('#fcp-config-backup-status').text('Preparing backup…'); + $.ajax({url: configBackupUrl, data: {op: 'exportconfig'}, dataType: 'json'}) + .done(function (snapshot) { + const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null, 2)], {type: 'application/json'})); + const link = document.createElement('a'); + link.href = url; + link.download = 'fanctrlplus2-config-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + $('#fcp-config-backup-status').text('Backup downloaded. Only saved settings are included.'); + }).fail(configBackupError).always(() => setConfigBackupBusy(false)); + }); + + $(document).on('click', '#fcp-config-import', function () { + document.getElementById('fcp-config-import-file').click(); + }); + + $(document).on('change', '#fcp-config-import-file', async function () { + const file = this.files[0]; + this.value = ''; + if (!file) return; + if (file.size > 2 * 1024 * 1024) { + $('#fcp-config-backup-status').text('The backup exceeds the 2 MiB limit.'); + return; + } + const confirmation = await Swal.fire({ + title: 'Restore saved configuration?', + text: 'This replaces saved fan settings, labels and ordering. Unsaved edits will be discarded. Custom sensor scripts stay unchanged, and the running or stopped state is preserved.', + icon: 'warning', + showCancelButton: true, + focusCancel: true, + confirmButtonText: 'Restore', + cancelButtonText: 'Cancel' + }); + if (!confirmation.isConfirmed) return; + setConfigBackupBusy(true); + $('#fcp-config-backup-status').text('Restoring configuration…'); + try { + const backup = await file.text(); + JSON.parse(backup); + $.ajax({url: configBackupUrl, type: 'POST', data: {op: 'importconfig', backup: backup}, dataType: 'json'}) + .done(function (response) { + if (response.status !== 'ok') { + $('#fcp-config-backup-status').text(response.message || 'Restore failed.'); + return; + } + $('#fcp-config-backup-status').text('Configuration restored. Reloading…'); + window.location.reload(); + }).fail(configBackupError).always(() => setConfigBackupBusy(false)); + } catch (error) { + $('#fcp-config-backup-status').text('The selected file is not a readable JSON backup.'); + setConfigBackupBusy(false); + } + }); $(document).on('click', '.add-disk-group-btn', function () { const $btn = $(this); @@ -1720,7 +1794,7 @@ $(function() {

Auxiliary Sensor Settings: Works the same way as Disk and CPU Temperature Settings, for anything that is neither: chipset, VRM, network card, RAID controller, GPU.
Include Sensor(s) lists the non-CPU, non-NVMe hwmon sensors, and adds LSI/MegaRAID controllers (storcli), NVIDIA GPUs (nvidia-smi) and Mellanox network cards (mget_temp) when those tools are installed. The highest temperature among the selected sensors drives this fan.
- Custom sensors: anything else can be added as a script. Any executable placed in /boot/config/plugins/fanctrlplus2/sensors.d/ becomes a selectable sensor named after the file. It must print the temperature in degrees Celsius and nothing else, and on any error print nothing and exit with a non-zero status. A script that errors, prints something else, or takes longer than 5 seconds is skipped for that round of checks only, and tried again on the next one. Readings from every source, this one included, are clamped to 0–200 °C, so a value outside that range drives the fan at the corresponding end of its curve instead of being discarded. + Custom sensors: add a readable script with a valid shebang to /boot/config/plugins/fanctrlplus2/sensors.d/. It becomes a selectable sensor named after the file. No executable bit is needed on the flash drive: the plugin runs a private executable copy on runtime storage using the script’s own interpreter. The script must print only a temperature in degrees Celsius, or exit non-zero on error. Errors, invalid output and scripts taking longer than 5 seconds are skipped for that round only. Outputs outside the plausible −100–300 °C range are skipped; valid negative readings are floored at 0 °C.

Chart: Click the Chart button in a fan block to preview the fan speed curve based on your current settings. @@ -1963,6 +2037,18 @@ $(function() { While disabled, no fan-speed history is collected.

+ +
+

Configuration backup

+

Download saved fan settings, PWM labels, dashboard switches and ordering. + Restoring replaces those settings. Custom sensor scripts are not included.

+
+ + + +
+

+
diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php new file mode 100644 index 0000000..6f2712e --- /dev/null +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php @@ -0,0 +1,218 @@ + FCP_BACKUP_MAX_FILES) throw new RuntimeException('Too many configuration files to back up.'); + $json = json_encode(['format'=>'fanctrlplus2-config', 'version'=>1, 'files'=>(object)$files], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false || strlen($json) + 1 > FCP_BACKUP_MAX_BYTES) throw new RuntimeException('Configuration cannot be encoded within the backup size limit.'); + fcp_validate_config_backup($json); + return $json."\n"; + } finally { flock($lock, LOCK_UN); fclose($lock); } +} + +function fcp_backup_sys_path(string $path, bool $pwm): bool { + if ($path === '') return true; + if (preg_match('#(?:^|/)\.\.?(?:/|$)#', $path)) return false; + $end = $pwm ? 'pwm\d+' : '(?:temp\d+_input|temp)'; + return preg_match('#^/sys/(?:devices|class/(?:hwmon|thermal))/[A-Za-z0-9_.:/-]+/'.$end.'$#D', $path) === 1; +} + +function fcp_backup_binding(string $value): bool { + if (strpos($value, 'usb:') !== 0) return fcp_backup_sys_path($value, true); + $hex = substr($value, 4); + if ($hex === '' || strlen($hex) > 8192 || strlen($hex) % 2 || !ctype_xdigit($hex)) return false; + $parts = json_decode(hex2bin($hex), true); + return is_array($parts) && array_keys($parts) === [0,1,2,3] + && is_string($parts[0]) && preg_match('/^[0-9a-f]{4}$/D', $parts[0]) + && is_string($parts[1]) && preg_match('/^[0-9a-f]{4}$/D', $parts[1]) + && is_string($parts[2]) && $parts[2] !== '' && strlen($parts[2]) <= 1024 + && is_string($parts[3]) && preg_match('#^/[A-Za-z0-9_.:/*-]+/pwm\d+$#D', $parts[3]); +} + +// Decode only literal shell assignments. Unlike an INI parser, this rejects +// unescaped substitutions and command tails before any file can be sourced. +function fcp_backup_assignments(string $text): array { + if (preg_match('/[\x00-\x08\x0B-\x1F\x7F]/', $text)) throw new InvalidArgumentException('Configuration contains control characters.'); + $values = []; + foreach (explode("\n", $text) as $line) { + $line = trim($line, " \t"); + if ($line === '' || $line[0] === '#') continue; + if (!preg_match('/^([a-z][a-z0-9_]*)=(.*)$/D', $line, $m) || array_key_exists($m[1], $values)) throw new InvalidArgumentException('Invalid configuration assignment.'); + $encoded = $m[2]; $value = ''; + if (strlen($encoded) >= 2 && $encoded[0] === '"' && substr($encoded, -1) === '"') { + $inside = substr($encoded, 1, -1); + for ($i=0, $length=strlen($inside); $i<$length; $i++) { + $c = $inside[$i]; + if ($c === '\\') { + if (++$i >= $length || strpos('\\"$`', $inside[$i]) === false) throw new InvalidArgumentException('Invalid configuration escape.'); + $value .= $inside[$i]; + } else { + if (strpos('"$`', $c) !== false || ord($c) < 32) throw new InvalidArgumentException('Configuration must contain literal values only.'); + $value .= $c; + } + } + } elseif (preg_match('#^[A-Za-z0-9_./:,+-]*$#D', $encoded)) { + $value = $encoded; + } else { throw new InvalidArgumentException('Invalid configuration value.'); } + $values[$m[1]] = $value; + } + return $values; +} + +function fcp_validate_fan_config(string $text): array { + $values = fcp_backup_assignments($text); + $boolean = ['service','syslog','log_enable','cpu_enable','aux_enable']; + $numeric = ['pwm','max','idle','idle_percent','low','high','interval','interval_sec','disk_group_count','cpu_min_temp','cpu_max_temp','aux_min_temp','aux_max_temp']; + $string = ['custom','label','controller','controller_identity','fan','disks','cpu_sensor','aux_sensor']; + foreach ($values as $key=>$value) { + $group = preg_match('/^disk_group_(\d+)_(name|disks|low|high)$/D', $key, $m); + if ($group && (strlen($m[1]) > 3 || (int)$m[1] > 255)) throw new InvalidArgumentException('Too many disk groups.'); + if (in_array($key, $boolean, true)) { + if ($value !== '0' && $value !== '1') throw new InvalidArgumentException('Invalid configuration switch.'); + } elseif (in_array($key, $numeric, true) || ($group && in_array($m[2], ['low','high'], true))) { + if ($value === '' && in_array($key, ['cpu_min_temp','cpu_max_temp','aux_min_temp','aux_max_temp'], true)) continue; + if (!preg_match('/^(?:0|-?[1-9]\d{0,8})$/D', $value)) throw new InvalidArgumentException('Invalid numeric configuration value.'); + $number = (int)$value; + if (in_array($key, ['pwm','max','idle'], true) && ($number < 0 || $number > 255)) throw new InvalidArgumentException('PWM value is outside 0–255.'); + if (in_array($key, ['interval','interval_sec'], true) && $number <= 0) throw new InvalidArgumentException('Interval must be positive.'); + if ($key === 'disk_group_count' && ($number < 0 || $number > 256)) throw new InvalidArgumentException('Invalid disk group count.'); + if ($key === 'idle_percent' && ($number < 0 || $number > 100)) throw new InvalidArgumentException('Invalid idle percentage.'); + } elseif (!in_array($key, $string, true) && !$group) { + throw new InvalidArgumentException('Unsupported configuration key.'); + } + if ($key === 'controller' && !fcp_backup_sys_path($value, true)) throw new InvalidArgumentException('Invalid PWM controller path.'); + if ($key === 'controller_identity' && !fcp_backup_binding($value)) throw new InvalidArgumentException('Invalid controller identity.'); + if ($key === 'cpu_sensor' && !fcp_backup_sys_path($value, false)) throw new InvalidArgumentException('Invalid CPU sensor path.'); + if ($key === 'fan' && $value !== '') { + $pwmPath = preg_replace('#/fan(\d+)_input$#', '/pwm$1', $value); + if ($pwmPath === $value || !fcp_backup_sys_path($pwmPath, true)) throw new InvalidArgumentException('Invalid legacy fan input.'); + } + if ($key === 'aux_sensor') { + foreach ($value === '' ? [] : explode(',', $value) as $sensor) { + if (fcp_backup_sys_path($sensor, false)) continue; + if (!preg_match('/^(?:custom:[A-Za-z0-9_-][A-Za-z0-9._-]*|storcli:c\d+:roc|nvidia:gpu\d+|mlx:[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F])$/D', $sensor)) throw new InvalidArgumentException('Invalid auxiliary sensor.'); + } + } + if ($key === 'disks' || ($group && $m[2] === 'disks')) { + if ($value !== '' && !preg_match('/^[A-Za-z0-9_.:#=@+-]+(?:,[A-Za-z0-9_.:#=@+-]+)*$/D', $value)) throw new InvalidArgumentException('Invalid disk identifier.'); + } + } + if (!isset($values['custom']) || !preg_match('/^[A-Za-z0-9_-]+$/D', $values['custom'])) throw new InvalidArgumentException('A saved fan must have a valid name.'); + return $values; +} + +function fcp_validate_config_backup(string $json): array { + if (strlen($json) > FCP_BACKUP_MAX_BYTES) throw new InvalidArgumentException('The backup exceeds the 2 MiB limit.'); + $doc = json_decode($json); + if (!is_object($doc) || ($doc->format ?? null) !== 'fanctrlplus2-config' || ($doc->version ?? null) !== 1 || !isset($doc->files) || !is_object($doc->files)) throw new InvalidArgumentException('Invalid or unsupported backup format.'); + $files = get_object_vars($doc->files); + if (count($files) > FCP_BACKUP_MAX_FILES) throw new InvalidArgumentException('Too many backup files.'); + $names = []; $filenames = []; + foreach ($files as $name=>$text) { + if (!is_string($text) || (!in_array($name, ['pwm_labels.cfg','order.cfg'], true) && (!fcp_backup_fan_name($name) || preg_match('/^fanctrlplus2_temp_\d+\.cfg$/D', $name)))) throw new InvalidArgumentException('Unsupported backup filename or content.'); + // The Unraid boot filesystem is case-insensitive. + $folded = strtolower($name); + if (isset($filenames[$folded])) throw new InvalidArgumentException('Backup filenames collide on the boot filesystem.'); + $filenames[$folded] = true; + if (fcp_backup_fan_name($name)) { + $fan = fcp_validate_fan_config($text); + if (isset($names[$fan['custom']])) throw new InvalidArgumentException('Duplicate fan name in backup.'); + $names[$fan['custom']] = true; + } elseif ($name === 'order.cfg') { + foreach (fcp_backup_assignments($text) as $key=>$target) { + if (!preg_match('/^(?:order\d+|col\d+_\d+|left\d+|right\d+)$/D', $key) || !fcp_backup_fan_name($target)) throw new InvalidArgumentException('Invalid fan ordering.'); + } + } else { + foreach (explode("\n", $text) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') continue; + $pair = explode('=', $line, 2); + if (count($pair) !== 2) throw new InvalidArgumentException('Invalid PWM label entry.'); + if (preg_match('/^__FCP_[A-Z0-9_]+__$/D', $pair[0])) { + if ($pair[1] !== '0' && $pair[1] !== '1') throw new InvalidArgumentException('Invalid dashboard flag.'); + } elseif ($pair[0] === '' || !fcp_backup_binding($pair[0])) { throw new InvalidArgumentException('Invalid PWM label identity.'); } + } + } + } + ksort($files); + return $files; +} + +// Caller callbacks stop/restart control only when it was running. They run +// while the same lock used by every HTTP settings writer remains held. +function fcp_restore_config(string $dir, string $json, ?callable $before = null, ?callable $after = null): int { + $files = fcp_validate_config_backup($json); + $lock = fcp_config_lock($dir); + $stage = null; $moved = []; $installed = []; $stopped = false; $retain = false; + try { + $old = fcp_config_files($dir, true); + $stage = "$dir/.restore-".bin2hex(random_bytes(8)); + if (!mkdir($stage, 0700) || !mkdir("$stage/new", 0700) || !mkdir("$stage/old", 0700)) throw new RuntimeException('Could not stage the restoration.'); + foreach ($files as $name=>$text) { + if (file_put_contents("$stage/new/$name", $text, LOCK_EX) !== strlen($text)) throw new RuntimeException('Could not stage a configuration file.'); + } + if ($before !== null) { $stopped = true; $before(); } + foreach ($old as $name=>$text) { + if (!rename("$dir/$name", "$stage/old/$name")) throw new RuntimeException('Could not preserve the previous configuration.'); + $moved[] = $name; + } + foreach ($files as $name=>$text) { + if (!rename("$stage/new/$name", "$dir/$name")) throw new RuntimeException('Could not install the restored configuration.'); + $installed[] = $name; + } + if ($after !== null) $after(); + return count($files); + } catch (Throwable $error) { + foreach ($installed as $name) { + if (file_exists("$dir/$name") && !@unlink("$dir/$name")) $retain = true; + } + foreach ($moved as $name) { + if (!@rename("$stage/old/$name", "$dir/$name")) $retain = true; + } + if ($stopped && $after !== null) { try { $after(); } catch (Throwable $restartError) { /* Files remain recovered; report the original failure. */ } } + if ($retain) throw new RuntimeException('Restore failed; previous files remain in '.$stage.'/old.', 0, $error); + throw $error; + } finally { + if ($stage !== null && !$retain) { + foreach (glob("$stage/*/*") ?: [] as $path) @unlink($path); + @rmdir("$stage/new"); @rmdir("$stage/old"); @rmdir($stage); + } + flock($lock, LOCK_UN); fclose($lock); + } +} diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php index 0363fce..dfcbde9 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/FanctrlLogic.php @@ -27,12 +27,22 @@ require_once "$docroot/plugins/$plugin/include/Common.php"; require_once "/usr/local/emhttp/plugins/fanctrlplus2/include/OrderManager.php"; +require_once __DIR__.'/ConfigBackup.php'; header('Content-Type: application/json'); header('Cache-Control: no-store, max-age=0'); $op = $_GET['op'] ?? $_POST['op'] ?? ''; +// Serialize restore/export with every HTTP writer of these configuration files. +if (in_array($op, ['savelabel','newtemp','delete','setsyslog','saveorder','start','stop','fcp_airflow_toggle','fcp_history_toggle'], true)) { + try { $config_lock = fcp_config_lock($cfg_dir); } + catch (Throwable $error) { http_response_code(500); json_response(['status'=>'error','message'=>$error->getMessage()]); } + register_shutdown_function(function () use ($config_lock) { + if (is_resource($config_lock)) { flock($config_lock, LOCK_UN); fclose($config_lock); } + }); +} + if ($op === 'refresh_single' && !empty($_GET['custom'])) { $custom = escapeshellarg($_GET['custom']); shell_exec("/usr/local/emhttp/plugins/fanctrlplus2/scripts/fanctrlplus2_refresh_single.sh $custom > /dev/null 2>&1 &"); @@ -59,6 +69,60 @@ function scan_dir($dir) { $op = $_GET['op'] ?? $_POST['op'] ?? ''; switch ($op) { + + case 'exportconfig': + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + header('Allow: GET'); http_response_code(405); + json_response(['status'=>'error','message'=>'Use GET to export a configuration.']); + } + try { + $backup = fcp_export_config($cfg_dir); + header('Content-Disposition: attachment; filename="fanctrlplus2-config-'.date('Ymd-His').'.json"'); + echo $backup; + exit; + } catch (Throwable $error) { + http_response_code(500); json_response(['status'=>'error','message'=>$error->getMessage()]); + } + break; + + case 'importconfig': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Allow: POST'); http_response_code(405); + json_response(['status'=>'error','message'=>'Use POST to restore a configuration.']); + } + if (!isset($_POST['backup']) || !is_string($_POST['backup'])) { + http_response_code(400); json_response(['status'=>'error','message'=>'A backup JSON document is required.']); + } + $service = function (string $action): string { + $output = []; $status = 0; + exec('bash /etc/rc.d/rc.fanctrlplus2 '.escapeshellarg($action).' 2>/dev/null', $output, $status); + if ($status !== 0) throw new RuntimeException('Fan control service is unavailable.'); + return trim(implode("\n", $output)); + }; + $was_running = false; $had_stop_marker = false; + try { + $files = fcp_restore_config($cfg_dir, $_POST['backup'], + function () use ($service, &$was_running, &$had_stop_marker) { + $had_stop_marker = is_file('/var/run/fanctrlplus2.user_stopped'); + $state = $service('status'); + if (!in_array($state, ['running','stopped'], true)) throw new RuntimeException('Could not determine fan control status.'); + $was_running = $state === 'running'; + if ($was_running) $service('stop'); + // Pause automatic starts only for the duration of the replacement. + elseif (@file_put_contents('/var/run/fanctrlplus2.user_stopped', '') === false) throw new RuntimeException('Could not retain the stopped state.'); + }, + function () use ($service, &$was_running, &$had_stop_marker) { + if ($was_running) $service('start'); + elseif (!$had_stop_marker && is_file('/var/run/fanctrlplus2.user_stopped') && !@unlink('/var/run/fanctrlplus2.user_stopped')) throw new RuntimeException('Could not restore automatic startup state.'); + } + ); + json_response(['status'=>'ok','message'=>'Configuration restored','files'=>$files]); + } catch (InvalidArgumentException $error) { + http_response_code(400); json_response(['status'=>'error','message'=>$error->getMessage()]); + } catch (Throwable $error) { + http_response_code(500); json_response(['status'=>'error','message'=>$error->getMessage()]); + } + break; case 'identify': $pwm = $_GET['pwm'] ?? ''; diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php index d98dc95..7d17924 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/update.fanctrlplus2.php @@ -25,10 +25,21 @@ $docroot = $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp'; require_once "$docroot/plugins/$plugin/include/Common.php"; +require_once __DIR__.'/ConfigBackup.php'; if (!is_dir($cfgpath)) { mkdir($cfgpath, 0777, true); } +try { $config_lock = fcp_config_lock($cfgpath); } +catch (Throwable $error) { + http_response_code(500); + ob_clean(); + echo json_encode(['status'=>'error','message'=>$error->getMessage()]); + exit; +} +register_shutdown_function(function () use ($config_lock) { + if (is_resource($config_lock)) { flock($config_lock, LOCK_UN); fclose($config_lock); } +}); header('Content-Type: application/json'); diff --git a/tests/config_backup_test.php b/tests/config_backup_test.php new file mode 100644 index 0000000..5a6ea6f --- /dev/null +++ b/tests/config_backup_test.php @@ -0,0 +1,155 @@ +$fan, 'pwm_labels.cfg'=>$labels, 'order.cfg'=>"order0=\"fanctrlplus2_JBOD.cfg\"\n"]; +foreach ($files as $name=>$text) file_put_contents("$root/$name", $text); +mkdir("$root/sensors.d"); +file_put_contents("$root/sensors.d/ambient", "#!/bin/sh\necho 37\n"); +file_put_contents("$root/notes.txt", 'keep'); +file_put_contents("$root/fanctrlplus2_temp_0.cfg", 'unsaved'); +$backup = fcp_export_config($root); +$doc = json_decode($backup, true); +$check($doc['format'] === 'fanctrlplus2-config' && $doc['version'] === 1, 'Snapshot must identify its format/version.'); +$exported = $doc['files']; ksort($exported); $expected=$files; ksort($expected); +$check($exported === $expected, 'Export must preserve saved labels, groups and ordering, excluding scripts and unsaved data.'); +file_put_contents("$root/fanctrlplus2_JBOD.cfg", str_replace('pwm="102"', 'pwm="120"', $fan)); +file_put_contents("$root/fanctrlplus2_extra.cfg", str_replace('JBOD','extra',$fan)); +fcp_restore_config($root, $backup); +foreach ($files as $name=>$text) $check(file_get_contents("$root/$name") === $text, "Restore must preserve $name exactly, including escaping and serial identity."); +$check(!file_exists("$root/fanctrlplus2_extra.cfg"), 'Snapshot replacement must remove fan configurations absent from the backup.'); +$check(!file_exists("$root/fanctrlplus2_temp_0.cfg"), 'Restore must discard unsaved temporary fan blocks.'); +$check(file_get_contents("$root/notes.txt") === 'keep' && file_get_contents("$root/sensors.d/ambient") === "#!/bin/sh\necho 37\n", 'Restore must leave unrelated files and sensor code untouched.'); + +$invalid = []; +$encode = function ($map, $version=1) { return json_encode(['format'=>'fanctrlplus2-config','version'=>$version,'files'=>(object)$map]); }; +$invalid[] = $encode($files, 2); +$invalid[] = '{broken'; +$invalid[] = $encode(['../../escape.cfg'=>$fan]); +$invalid[] = $encode(['sensors.d/evil'=>$fan]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>$fan."PATH=\"/tmp\"\n"]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>str_replace('pwm="102"', 'pwm="array[\$(touch /tmp/fcp_backup_pwn)]"', $fan)]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>str_replace('/sys/devices/platform/nct6775.656/hwmon/hwmon5/pwm1','/tmp/pwm1',$fan)]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>str_replace('/sys/devices/platform/','/sys/devices/../platform/',$fan)]); +$invalid[] = $encode(['order.cfg'=>"order0=\"../../escape.cfg\"\n"]); +$invalid[] = $encode(['pwm_labels.cfg'=>"/tmp/pwm1=bad\n"]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>$fan, 'fanctrlplus2_jbod.cfg'=>str_replace('JBOD','jbod',$fan)]); +$invalid[] = $encode(['fanctrlplus2_one.cfg'=>$fan, 'fanctrlplus2_two.cfg'=>$fan]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>$fan."pwm=\"120\"\n"]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>str_replace('pwm="102"', 'pwm="09"', $fan)]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>str_replace('label="JBOD"', 'label="$(touch /tmp/fcp_backup_pwn)"', $fan)]); +$invalid[] = $encode(['fanctrlplus2_JBOD.cfg'=>str_replace('label="JBOD"', 'label="`touch /tmp/fcp_backup_pwn`"', $fan)]); +$invalid[] = json_encode(['format'=>'fanctrlplus2-config','version'=>1,'files'=>(object)[],'padding'=>str_repeat('x',FCP_BACKUP_MAX_BYTES)]); +foreach ($invalid as $payload) { + $before=fcp_export_config($root); + try { fcp_restore_config($root, $payload); $check(false, 'An unsafe/unsupported snapshot was accepted.'); } + catch (InvalidArgumentException $e) { } + $check(fcp_export_config($root) === $before, 'Rejected imports must not change any configuration.'); +} + +// Bash does not treat control-byte-prefixed '#' as a comment. Exercise the +// consumer as well as validation: even a regressed restore must not execute it. +foreach (["\r", "\x0b"] as $control) { + foreach (['fanctrlplus2_JBOD.cfg', 'order.cfg'] as $name) { + $marker="$root/command-ran"; + $injected=$files; + $injected[$name] .= $control.'# ; touch '.escapeshellarg($marker)."\n"; + $rejected=false; + try { fcp_restore_config($root,$encode($injected)); } + catch (InvalidArgumentException $e) { $rejected=true; } + exec('bash -c '.escapeshellarg('source '.escapeshellarg("$root/$name")).' 2>/dev/null'); + $check($rejected && !file_exists($marker), 'Control-prefixed comments must be rejected without executing shell code.'); + if (file_exists($marker)) unlink($marker); + fcp_restore_config($root,$backup); + } +} + +// Safe absent order references are normal after deleting or adding a block. +$oldOrder=$files['order.cfg']; +file_put_contents("$root/order.cfg",$oldOrder."order1=\"fanctrlplus2_temp_0.cfg\"\norder2=\"fanctrlplus2_gone.cfg\"\n"); +$withMissing=fcp_export_config($root); +try { fcp_restore_config($root,$withMissing); } +catch (InvalidArgumentException $e) { $check(false,'An exported order with stale/temporary references must restore.'); } +fcp_restore_config($root,$backup); +$legacy=$files; +$legacy['fanctrlplus2_JBOD.cfg'] .= "idle_percent=\"10\"\nfan=\"/sys/devices/platform/nct6775.656/hwmon/hwmon5/fan1_input\"\n"; +$legacy['fanctrlplus2_JBOD.cfg'] = str_replace('ata_TEST_DISK','ata_TEST#=@DISK',$legacy['fanctrlplus2_JBOD.cfg']); +fcp_restore_config($root,$encode($legacy)); +$check(fcp_validate_config_backup(fcp_export_config($root))['fanctrlplus2_JBOD.cfg'] === $legacy['fanctrlplus2_JBOD.cfg'], 'Legacy idle settings and valid udev identifiers must round-trip.'); +fcp_restore_config($root,$backup); + +// A restart failure after replacement must roll back the real files, not +// discard the sole copy of the previous working settings. +$modified=$files; $modified['fanctrlplus2_JBOD.cfg']=str_replace('pwm="102"','pwm="130"',$fan); +$before=fcp_export_config($root); +try { fcp_restore_config($root, $encode($modified), null, function () { throw new RuntimeException('restart failed'); }); $check(false, 'A failed restart must be reported.'); } +catch (RuntimeException $e) { } +$check(fcp_export_config($root) === $before, 'A post-replacement failure must restore the old configuration files.'); +$check((glob("$root/.restore-*") ?: []) === [], 'Successful rollback must not leave staged copies behind.'); + +// A pre-existing symlink is never followed or replaced by an import. +$outside="$root-outside"; file_put_contents($outside,'untouched'); +unlink("$root/pwm_labels.cfg"); symlink($outside,"$root/pwm_labels.cfg"); +try { fcp_restore_config($root,$backup); $check(false,'Import must refuse existing managed symlinks.'); } +catch (RuntimeException $e) { } +$check(file_get_contents($outside)==='untouched' && is_link("$root/pwm_labels.cfg"), 'Rejected symlink targets must remain untouched.'); +unlink("$root/pwm_labels.cfg"); unlink($outside); +fcp_restore_config($root,$encode([])); +$check((glob("$root/fanctrlplus2_*.cfg") ?: []) === [], 'An explicitly empty snapshot must restore an empty managed configuration.'); +$check(file_get_contents("$root/notes.txt")==='keep', 'Empty restoration must retain unrelated files.'); + +// Long-lived service children must not retain their parent's configuration +// lock. Synchronize after exec rather than relying on a sleep or timing. +$lock = fcp_config_lock($root); +$child = proc_open(['/bin/sh','-c','printf "ready\\n"; read reply'], [0=>['pipe','r'],1=>['pipe','w'],2=>['file','/dev/null','w']], $pipes); +if (!is_resource($child)) throw new RuntimeException('Could not start lock regression child.'); +fgets($pipes[1]); +$contender = fopen("$root/.config.lock", 'c'); +$entered = flock($contender, LOCK_EX | LOCK_NB); +$check(!$entered, 'A concurrent configuration writer must not bypass the held lock.'); +if ($entered) flock($contender, LOCK_UN); +fclose($contender); +fclose($lock); +$probe = fopen("$root/.config.lock", 'c'); +$acquired = flock($probe, LOCK_EX | LOCK_NB); +$check($acquired, 'An execed service child must not keep the configuration locked after the request ends.'); +if ($acquired) flock($probe, LOCK_UN); +fclose($probe); +fwrite($pipes[0], "\n"); fclose($pipes[0]); fclose($pipes[1]); proc_close($child); +exec('rm -rf '.escapeshellarg($root)); +if ($failures) { fwrite(STDERR,implode("\n",$failures)."\n"); exit(1); } +echo "configuration backup tests passed\n"; From 845543b5156f3084ed02d4932e95682065e8858b Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 17:25:08 +0000 Subject: [PATCH 3/3] fanctrlplus2: address review of backup/restore The array monitor now decides and starts fan control under the settings lock, so it can no longer start loops while a restore swaps files. That replaces the temporary stop marker restore used to set. PWM dropdown options are built as DOM nodes, so labels show markup characters as text. Tabs inside quoted values are accepted, since the save form can write them and bash reads them literally. A failed rollback keeps the original error in its message. --- .../plugins/fanctrlplus2/fanctrlplus2.page | 9 +++++---- .../fanctrlplus2/include/ConfigBackup.php | 5 +++-- .../fanctrlplus2/include/FanctrlLogic.php | 14 +++++--------- .../fanctrlplus2/scripts/array_monitor.sh | 18 +++++++++++++----- tests/config_backup_test.php | 4 ++-- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page index bd4919b..4b4a76c 100644 --- a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page +++ b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page @@ -783,7 +783,7 @@ $(function() { if (pwm.label) { display += '(' + pwm.label + ')'; } - select.append(``); + select.append($('`); + select.append($('`); + $(this).append($('