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;
@@ -779,7 +783,7 @@ $(function() { if (pwm.label) { display += '(' + pwm.label + ')'; } - select.append(``); + select.append($('`); + select.append($('`); + $(this).append($('
+ +
+

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/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");