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..4b4a76c 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/fanctrlplus2.page
@@ -59,6 +59,8 @@ HTML;
+
+
+
Configuration backup
+
Download saved fan settings, PWM labels, dashboard switches and ordering.
+ Restoring replaces those settings. Custom sensor scripts are not included.
+
+ Export backup
+ Restore backup
+
+
+
+
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/include/ConfigBackup.php b/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php
new file mode 100644
index 0000000..823ce64
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/include/ConfigBackup.php
@@ -0,0 +1,219 @@
+ 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 {
+ // Tabs are literal inside double quotes; the form can save them.
+ if (strpos('"$`', $c) !== false || (ord($c) < 32 && $c !== "\t")) 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 ('.$error->getMessage().'); 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..afb2865 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,56 @@ 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));
+ };
+ // The array monitor decides and starts under the same lock, so it cannot
+ // start fan control while this restore is swapping files.
+ $was_running = false;
+ try {
+ $files = fcp_restore_config($cfg_dir, $_POST['backup'],
+ function () use ($service, &$was_running) {
+ $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');
+ },
+ function () use ($service, &$was_running) { if ($was_running) $service('start'); }
+ );
+ 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/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/array_monitor.sh b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/array_monitor.sh
index e989bde..2360543 100644
--- a/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/array_monitor.sh
+++ b/src/usr/local/emhttp/plugins/fanctrlplus2/scripts/array_monitor.sh
@@ -5,6 +5,9 @@ LOG="/var/log/fanctrlplus2_array_watch.log"
CHECK_INTERVAL=10
rc_script="/etc/rc.d/rc.${plugin}"
pidfile="/var/run/fanctrlplus2.user_stopped"
+# Shared with the settings page; a configuration restore holds it while it
+# swaps files.
+config_lock="/boot/config/plugins/${plugin}/.config.lock"
last_md_state=""
last_fanctrl_state=0
@@ -27,17 +30,22 @@ log "Array monitor started"
while true; do
current_md_state=$(grep -oP 'mdState=\K\w+' < <(/usr/local/sbin/mdcmd status 2>/dev/null) || echo "")
- fanctrl_running=0
- is_fanctrl_running && fanctrl_running=1
if [[ "$current_md_state" != "$last_md_state" ]]; then
log "Array state changed: $last_md_state → $current_md_state"
last_md_state="$current_md_state"
fi
- if [[ "$current_md_state" == "STARTED" && "$fanctrl_running" -eq 0 && ! -f "$pidfile" ]]; then
- log "FanCtrlPlus not running after array start → launching"
- "$rc_script" start
+ if [[ "$current_md_state" == "STARTED" ]]; then
+ # Decide and start under the settings lock. fd 9 is closed for rc so the
+ # fan loops it spawns do not keep the lock held.
+ (
+ flock 9 || exit 0
+ if ! is_fanctrl_running && [[ ! -f "$pidfile" ]]; then
+ log "FanCtrlPlus not running after array start → launching"
+ "$rc_script" start 9>&-
+ fi
+ ) 9>"$config_lock"
fi
sleep "$CHECK_INTERVAL"
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/config_backup_test.php b/tests/config_backup_test.php
new file mode 100644
index 0000000..f14658c
--- /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','Top '],['ata_TEST#=@DISK',"Top\t"],$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, tabs in group names 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";
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");