diff --git a/Plugin/unraid/README-unraid.md b/Plugin/unraid/README-unraid.md index b6a484b..57d2f2c 100644 --- a/Plugin/unraid/README-unraid.md +++ b/Plugin/unraid/README-unraid.md @@ -112,6 +112,39 @@ be the reason a page fails to load. --- +## ━━━ WHAT GUARDS THE API LAYER ━━━ + +Every endpoint under `api/` is protected by exactly one thing: **the Unraid WebGUI session.** +Anything that can reach `/plugins/varaverk/api/*.php` with a valid session can do everything +this plugin can do — stop the array, power off the host, write `master.conf` and push it to +every partner, create an lldap user, write and schedule a root-run script. + +That is the same trust level as the rest of the WebGUI, and it is the intended model. It is +written down here because two things about it are easy to assume and wrong. + +**No endpoint validates a CSRF token.** `pages/partnership.php` and `pages/scheduler.php` send +Unraid's `csrf_token` with their POSTs, which reads like the token is checked somewhere. It is +not — no file in `api/` looks at it. A request that arrives with a logged-in session cookie is +honoured whatever caused the browser to send it. Adding validation is a worthwhile hardening +pass, but it is a real change: every caller has to send the token before any endpoint requires +it, or the UI breaks silently on whichever page was missed. Do it deliberately, in one pass, +with the pages open — not opportunistically while touching one endpoint. + +**`api/webhook.php` is the exception that authenticates nothing at all.** It exists to receive +Sonarr/Radarr/Lidarr download events, which arrive from a container rather than a browser. +`master.conf` carries a `WEBHOOK_SECRET` and the standalone Node listener on `WEBHOOK_PORT` +validates it — this WebGUI-hosted path does not. Injection is not the risk (the path is +validated and escaped); triggering work is. Closing it means adding a secret check here **and** +updating the webhook URL in each arr's settings, in that order. + +Two further endpoints are worth knowing about because they read wider than the rest: +`api/api_test.php` returns an API key prefix and the live GraphQL schema, and +`api/import_script.php`'s browse action and `api/manual_sync.php`'s browse actions list +directory names anywhere on either host. All three are read-only and none return file contents, +but they are the ones to look at first if the session boundary ever moves. + +--- + ## ━━━ ARRAY LIFECYCLE HOOKS ━━━ `event/` plugs Varaverk into Unraid's own array lifecycle. These are how the ecosystem starts diff --git a/Plugin/unraid/Tools/api_cache_writer.php b/Plugin/unraid/Tools/api_cache_writer.php index 1827856..e579cb2 100644 --- a/Plugin/unraid/Tools/api_cache_writer.php +++ b/Plugin/unraid/Tools/api_cache_writer.php @@ -1,9 +1,83 @@ re-run the partner cache writer for one host +// +// RESPONSE +// normal vv_arrs_all() verbatim — local node live, remote nodes cached with cache_age +// refresh {"ok":bool,"node":object|null,"output":string} +// errors {"ok":false,"error":string} — invalid host, missing script, or timeout +// +// DEPENDS ON +// include/config.php vv_cache_read(), VV_CACHE_DIR +// include/arrs.php vv_arrs_all() (loaded only on a cache miss) +// Tools/remote_arr_cache_writer.sh (refresh branch only) +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +// Bound on the partner refresh child process. set_time_limit() does not cover exec() time +// on Linux, so this is enforced by `timeout`, not by PHP. +define('VV_ARRS_REFRESH_TIMEOUT', 120); + // ── Manual remote refresh — runs remote_arr_cache_writer for one host ───────── $_action = trim($_GET['action'] ?? ''); if ($_action === 'refresh_remote') { @@ -13,9 +80,19 @@ if ($_action === 'refresh_remote') { if (!file_exists($script)) { echo json_encode(['ok' => false, 'error' => 'remote_arr_cache_writer.sh not found']); exit; } - set_time_limit(30); + set_time_limit(VV_ARRS_REFRESH_TIMEOUT + 30); $out = []; $exit = 0; - exec('bash ' . escapeshellarg($script) . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit); + exec('timeout ' . VV_ARRS_REFRESH_TIMEOUT . ' bash ' . escapeshellarg($script) + . ' --host=' . escapeshellarg(strtoupper($host)) . ' 2>&1', $out, $exit); + + if ($exit === 124) { + echo json_encode([ + 'ok' => false, + 'error' => 'Refresh timed out after ' . VV_ARRS_REFRESH_TIMEOUT . 's', + 'output' => implode("\n", $out), + ]); + exit; + } $cacheFile = VV_CACHE_DIR . '/arrs_remote_' . $host . '.json'; $node = null; diff --git a/Plugin/unraid/api/auth.php b/Plugin/unraid/api/auth.php index 2dc6023..7895b3b 100644 --- a/Plugin/unraid/api/auth.php +++ b/Plugin/unraid/api/auth.php @@ -1,4 +1,93 @@ +// POST action=npm_update id, data= +// POST action=npm_delete id +// POST action=npm_toggle id, enabled=0|1 +// POST action=lldap_create_user uid, email, display_name, password +// POST action=lldap_update_user uid, email, display_name +// POST action=lldap_delete_user uid +// POST action=lldap_set_password uid, password +// POST action=lldap_create_group name +// POST action=lldap_delete_group id +// POST action=lldap_add_to_group uid, gid +// POST action=lldap_remove_from_group uid, gid +// POST action=authelia_save rules=, default_policy +// +// RESPONSE +// Whatever the invoked library call returns — ['ok' => bool] with a payload or an error, +// or an _err key on a failed remote call. npm_certs is wrapped as {"ok":true,"certs":[…]}. +// {"ok":false,"error":"Unknown action: …"} for anything outside the lists above. +// +// DEPENDS ON +// include/auth.php vv_npm_*(), vv_lldap_*(), vv_authelia_read_rules(), +// vv_authelia_write_rules() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/auth.php'; diff --git a/Plugin/unraid/api/board.php b/Plugin/unraid/api/board.php index 855ae87..cd67585 100644 --- a/Plugin/unraid/api/board.php +++ b/Plugin/unraid/api/board.php @@ -1,5 +1,101 @@ entries and the first non-self one is used, so this +// works for any number of hosts without a second list to maintain. +// +// Tailscale resolves the address, never local DNS. +// vv_resolve_tailscale_ip() mirrors common.sh, so the board tests the same path the +// rest of the system uses and survives the partner's IP changing. +// +// OPERATIONAL SAFEGUARDS +// Read-only. Nothing here clears a lock, truncates a log, or restarts anything — the board +// reports; clearlock.php and stop.php act. +// +// The lock scan is bounded to a hardcoded directory. +// glob over /tmp/unraid_locks/*.lock — a literal, not a config value, so no conf edit +// can point this scan somewhere else. +// +// The log walk is wrapped in a try/catch. +// RecursiveDirectoryIterator throws when LOG_DIR is absent or a subdirectory is +// unreadable — the normal state on a fresh install. The catch yields an empty error +// list rather than a 500. +// +// Every file read is independently suppressed and defaulted. +// @file_get_contents, @file, and ?: fallbacks throughout. One unreadable log or stat +// file costs its own row, not the response. +// +// The scan window and the output are both bounded. +// Logs older than 7 days are skipped, only the last 200 lines of each are read, each +// line is truncated at 220 characters, and the result is capped at 20 errors. This runs +// against a directory that grows without limit. +// +// ANSI escapes are stripped before matching and before returning. +// Logs are written with colour. Without stripping, the patterns would miss coloured +// error markers and the JSON would carry terminal control codes into the page. +// +// The ping target is escaped, and time-boxed by ping itself. +// escapeshellarg() on a value that came from conf, and -c1 -W2 so an unreachable +// partner costs two seconds. The result is cached 30s so the board's poll does not ping +// on every request. +// +// Reachability falls back to the hostname when Tailscale cannot resolve. +// Better to test something and report the result than to report nothing because the +// preferred resolution path failed. +// +// REQUEST +// GET, no parameters +// +// RESPONSE +// {"ok":true, +// "locks":[{"name","file","age"}], stale locks only — empty is healthy +// "errors":[{"script","line","ts"}], newest first, max 20 +// "partner":{"host","reachable","latency"}} null when no partner is configured +// +// DEPENDS ON +// include/config.php LOG_DIR, vv_conf_vars(), vv_get_hostname(), +// vv_resolve_tailscale_ip(), vv_cache_read(), vv_cache_write() +// /tmp/unraid_locks lock files written by common.sh's locking helper +// LOG_DIR/** .log files and their .json stat files, written by run_job.sh +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; diff --git a/Plugin/unraid/api/cert.php b/Plugin/unraid/api/cert.php index 3bb1c78..9fdb4bf 100644 --- a/Plugin/unraid/api/cert.php +++ b/Plugin/unraid/api/cert.php @@ -1,4 +1,95 @@ false, 'error' => 'cert_monitor.sh not found']); exit; } - set_time_limit(180); - exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc); + // set_time_limit() does not cover exec() time on Linux, so the bound has to be external — + // cert_monitor.sh reaches out to every configured domain and one unreachable host would + // otherwise hold a php-fpm worker open indefinitely. + set_time_limit(210); + exec('timeout 180 bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc); + if ($rc === 124) { + echo json_encode(['ok' => false, 'error' => 'cert_monitor.sh timed out after 180s']); + exit; + } // Read freshly written cache $data = file_exists($cacheFile) ? (json_decode(file_get_contents($cacheFile), true) ?: null) diff --git a/Plugin/unraid/api/checklist.php b/Plugin/unraid/api/checklist.php index 18ae98f..5c3d4f9 100644 --- a/Plugin/unraid/api/checklist.php +++ b/Plugin/unraid/api/checklist.php @@ -1,4 +1,83 @@ |unknown", +// "items":[{"id","label","ok","detail","action"?}, …]} +// action is present and non-null only when there is a remedy the UI can invoke. +// +// DEPENDS ON +// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_parse_conf_scalar(), +// vv_setup_state_read(), CONF_DIR +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; diff --git a/Plugin/unraid/api/clearlock.php b/Plugin/unraid/api/clearlock.php index 8e3154f..7041376 100644 --- a/Plugin/unraid/api/clearlock.php +++ b/Plugin/unraid/api/clearlock.php @@ -1,4 +1,56 @@ .lock +// +// RESPONSE +// {"ok":true} lock removed, or already absent +// {"ok":false,"error":string} wrong method or a filename that failed validation +// +// DEPENDS ON +// nothing — /tmp/unraid_locks is written by the shell layer's locking helper +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'POST only']); diff --git a/Plugin/unraid/api/conf_toggle.php b/Plugin/unraid/api/conf_toggle.php index b9779d9..562813d 100644 --- a/Plugin/unraid/api/conf_toggle.php +++ b/Plugin/unraid/api/conf_toggle.php @@ -1,4 +1,64 @@ enabled=0|1 +// +// RESPONSE +// {"ok":true,"error":null} +// {"ok":false,"error":"POST only"|"Invalid id"|"Failed to write master.conf"} +// +// DEPENDS ON +// include/scheduler.php vv_conf_toggle_script() → vv_write_conf_raw() +// Configurations/master.conf the *_SCRIPTS arrays +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; diff --git a/Plugin/unraid/api/confform.php b/Plugin/unraid/api/confform.php index 630f13a..8321294 100644 --- a/Plugin/unraid/api/confform.php +++ b/Plugin/unraid/api/confform.php @@ -1,4 +1,91 @@ +// POST id= changes= +// +// RESPONSE +// GET {"ok":true,"groups":[…]} +// POST {"ok":bool,"files":{"":bool, …},"push":[{"host","ok","ready","error"}, …]} +// {"ok":false,"error":"Invalid id"|"Missing id"|"Invalid changes" +// |"Unauthorized file: …"|"Invalid key: …"|"Method not allowed"} +// +// DEPENDS ON +// include/confform.php vv_conf_fields_for_script(), vv_conf_write_changes() +// include/config.php vv_get_conf_files(), vv_push_master_conf(), vv_push_setup_state() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; require_once dirname(__DIR__) . '/include/confform.php'; diff --git a/Plugin/unraid/api/config.php b/Plugin/unraid/api/config.php index 8bc8587..41f8829 100644 --- a/Plugin/unraid/api/config.php +++ b/Plugin/unraid/api/config.php @@ -1,7 +1,69 @@ content= +// +// RESPONSE +// {"ok":true,"error":null} +// {"ok":false,"error":"POST only"|"File not permitted"|"Syntax error: …"|"Failed to write file"} +// +// DEPENDS ON +// include/config.php vv_get_conf_files(), vv_write_conf_raw(), CONF_DIR +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['ok' => false, 'error' => 'POST only']); + exit; +} + $file = trim($_POST['file'] ?? ''); $content = $_POST['content'] ?? ''; @@ -12,5 +74,23 @@ if (!$file || !in_array($file, $allowed)) { exit; } +// Every script sources these. A syntax error here takes the whole system down, so the +// candidate is parsed before it is allowed to replace a working file. +$check = tempnam(sys_get_temp_dir(), 'vvconf'); +if ($check !== false) { + file_put_contents($check, $content); + $out = []; $rc = 0; + exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc); + @unlink($check); + if ($rc !== 0) { + $msg = implode(' ', array_filter(array_map('trim', $out))); + echo json_encode([ + 'ok' => false, + 'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'), + ]); + exit; + } +} + $ok = vv_write_conf_raw($file, $content); echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write file']); diff --git a/Plugin/unraid/api/create_api_key.php b/Plugin/unraid/api/create_api_key.php index d072338..af5b47a 100644 --- a/Plugin/unraid/api/create_api_key.php +++ b/Plugin/unraid/api/create_api_key.php @@ -1,4 +1,68 @@ +// POST action=logs name= last 200 lines, timestamped +// POST action=pull_rebuild name= spawns the worker, returns job_id +// POST action=job_status job_id= poll a pull_rebuild job +// +// RESPONSE +// {"ok":bool,"output":""} start / stop / restart +// {"ok":true,"logs":"…"} logs +// {"ok":true,"status":"started","job_id":"…"} pull_rebuild +// {"ok":true,"status":"pending"} or the worker's job file verbatim +// {"ok":false,"error":"invalid name"|"invalid job_id"|"Container not found" +// |"Could not determine image"|"Unknown action"} +// +// DEPENDS ON +// api/docker_pull_worker.php detached worker for the pull_rebuild path +// docker CLI ps, logs, start, stop, inspect +// dynamix.docker.manager rebuild_container, when present +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); define('VV_JOB_DIR', '/tmp/varaverk_dk_jobs'); @@ -48,7 +135,7 @@ if ($action === 'start' || $action === 'stop') { if ($action === 'restart') { $rebuild = '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container'; if (is_executable($rebuild)) { - exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc); + exec(escapeshellarg($rebuild) . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc); } else { exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1); exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2); diff --git a/Plugin/unraid/api/docker_pull_worker.php b/Plugin/unraid/api/docker_pull_worker.php index 787ab37..ddc7d84 100644 --- a/Plugin/unraid/api/docker_pull_worker.php +++ b/Plugin/unraid/api/docker_pull_worker.php @@ -1,7 +1,94 @@ -[$name, $jobFile, $oldId, $image, $rebuild] = array_slice($argv, 1, 5); +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +// PURPOSE +// Container update worker. Pulls one image, decides whether it actually changed, and +// recreates the container if it did — reporting progress through a job file the docker tab +// polls. +// +// OPERATIONAL MODEL +// Not an HTTP endpoint. This runs as a detached CLI process, spawned by docker_action.php, +// because a pull can take minutes and no web request should be held open for it. It lives +// under api/ because it is part of that endpoint's implementation, not because it is +// reachable over HTTP — and it refuses to run if it ever is. +// +// Progress is a file, not a return value. The parent request returns immediately with a job +// id; this process writes the current state to that file as it goes, and the page polls it. +// The file is the only channel between the two. +// +// Called as: php docker_pull_worker.php +// +// DESIGN PRINCIPLES +// Compares image ids, not pull output. +// `docker pull` reports success whether or not anything changed. The image id before +// and after is the only reliable signal, and it is what decides whether the container +// is disturbed at all — an up-to-date container is never restarted. +// +// Prefers Unraid's own rebuild path. +// When a rebuild helper is supplied and executable it is used, because recreating a +// container correctly means reapplying its full template — ports, mounts, variables. +// The stop/start fallback exists for the case where that helper is unavailable, and is +// explicitly the lesser option: it picks up a new image only if the container was +// already configured to be recreated on start. +// +// Every exit writes a terminal state. +// Both outcomes end with a job-file write, so the poller always converges. A worker that +// died without writing would leave the page spinning indefinitely. +// +// OPERATIONAL SAFEGUARDS +// Refuses to run under a web server. +// PHP_SAPI is checked first and a non-CLI invocation is answered with a 404 and no +// output. Without that, requesting this file over HTTP would evaluate it with no $argv +// at all — and it is a script whose entire job is to stop and restart containers. +// +// Required arguments are checked before anything runs. +// Missing name, job file, or image exits non-zero before the first docker call, so a +// malformed spawn cannot pull or restart anything. +// +// Every value interpolated into a shell command is escaped. +// Image, container name and the rebuild helper path all go through escapeshellarg(), +// even though they originate from docker_action.php rather than from a request. The +// escaping is what stays correct if that caller ever changes. +// +// The rebuild helper is confirmed executable before it is invoked. +// is_executable() gates it, so a missing or non-executable helper falls back to +// stop/start rather than failing the update with a shell error. +// +// Failure is reported as failure. +// A non-zero rebuild status writes ok:false with an explicit message. The image has +// already been pulled at that point, so silently reporting success would leave a +// container running an old image that the page claims was updated. +// +// Docker output is captured, never echoed. +// Every call redirects stderr and the output is discarded or kept locally. This process +// has no stdout consumer; writing to it would only risk corrupting the job file if the +// two were ever pointed at the same place. +// +// ARGUMENTS +// 1 name container name +// 2 jobFile path the progress JSON is written to +// 3 oldId image id before the pull, for the changed/unchanged comparison +// 4 image image reference to pull +// 5 rebuild optional path to Unraid's container rebuild helper +// +// JOB FILE STATES +// {"ok":true,"status":"done","updated":false,"message":"Already up to date"} +// {"ok":true,"status":"rebuilding"} +// {"ok":true,"status":"done","updated":true,"message":"Updated and rebuilt"} +// {"ok":false,"status":"done","error":"Rebuild failed after pull"} +// +// DEPENDS ON +// api/docker_action.php spawns this worker and creates the job file path +// docker CLI pull, image inspect, stop, start +// ═══════════════════════════════════════════════════════════════════════════════════════════════ + +// This file stops and starts containers. It is a CLI worker and must never be reachable as a +// web request — over HTTP there is no $argv, and every argument below would be undefined. +if (PHP_SAPI !== 'cli') { + http_response_code(404); + exit(1); +} + +[$name, $jobFile, $oldId, $image, $rebuild] = array_slice($argv, 1, 5) + array_fill(0, 5, ''); if (!$name || !$jobFile || !$image) exit(1); @@ -21,7 +108,7 @@ if ($oldId && $newId && $oldId === $newId) { jw($jobFile, ['ok' => true, 'status' => 'rebuilding']); if ($rebuild && is_executable($rebuild)) { - exec($rebuild . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc); + exec(escapeshellarg($rebuild) . ' ' . escapeshellarg($name) . ' 2>&1', $out, $rc); } else { exec('docker stop ' . escapeshellarg($name) . ' 2>&1', $o1, $rc1); exec('docker start ' . escapeshellarg($name) . ' 2>&1', $o2, $rc2); diff --git a/Plugin/unraid/api/dryrun.php b/Plugin/unraid/api/dryrun.php index eb37c0d..551cd7f 100644 --- a/Plugin/unraid/api/dryrun.php +++ b/Plugin/unraid/api/dryrun.php @@ -1,4 +1,69 @@ > to the job's own log with stdin from /dev/null, so a dry run cannot consume the +// request's stdin or discard the history of previous runs. +// +// REQUEST +// POST id= [location=/absolute/path] [extra_args=…] +// +// RESPONSE +// {"ok":true} launched — not completed +// {"ok":false,"error":"Invalid id"|"Script not found: …"|"Invalid location" +// |"Invalid extra_args"} +// +// DEPENDS ON +// include/scheduler.php vv_job_log_path(), vv_job_flags() +// run_job.sh the single execution path for every job +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; @@ -25,8 +90,11 @@ if ($location && (!str_starts_with($location, '/') || str_contains($location, '. exit; } +// Control characters are rejected outright — a newline would end the command line and start +// a second one. Everything that survives is split on whitespace and escaped per token, so +// the shell never parses any of it as syntax. $extra_args = trim($_POST['extra_args'] ?? ''); -if ($extra_args && preg_match('/[;&|`$<>\\\\"\']/', $extra_args)) { +if ($extra_args !== '' && preg_match('/[\x00-\x1f\x7f]/', $extra_args)) { echo json_encode(['ok' => false, 'error' => 'Invalid extra_args']); exit; } @@ -34,7 +102,10 @@ if ($extra_args && preg_match('/[;&|`$<>\\\\"\']/', $extra_args)) { $runner = dirname(__DIR__) . '/run_job.sh'; $flags = vv_job_flags($id); $locArg = $location ? ' ' . escapeshellarg('--location=' . $location) : ''; -$extraStr = $extra_args ? ' ' . $extra_args : ''; +$extraStr = ''; +foreach (preg_split('/\s+/', $extra_args, -1, PREG_SPLIT_NO_EMPTY) as $tok) { + $extraStr .= ' ' . escapeshellarg($tok); +} exec('nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ' --dry-run' . ($flags ? " $flags" : '') . ' --manual' . $locArg . $extraStr . ' >> ' . escapeshellarg($logFile) . ' 2>&1 true]); diff --git a/Plugin/unraid/api/fallback.php b/Plugin/unraid/api/fallback.php index aeeea10..144e3a9 100644 --- a/Plugin/unraid/api/fallback.php +++ b/Plugin/unraid/api/fallback.php @@ -1,4 +1,41 @@ _RSYNC_ENABLED enabled=0|1 +// +// RESPONSE +// {"ok":true,"error":null,"push":[{"host","ok","ready","error"}, …]} +// {"ok":false,"error":"POST only"|"Invalid flag name"|"Failed to write master.conf", +// "push":[]} +// +// DEPENDS ON +// include/scheduler.php vv_conf_flag_set() → vv_write_conf_raw() +// include/config.php vv_push_master_conf(), vv_push_setup_state() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; diff --git a/Plugin/unraid/api/import_script.php b/Plugin/unraid/api/import_script.php index 1abf97f..ae75837 100644 --- a/Plugin/unraid/api/import_script.php +++ b/Plugin/unraid/api/import_script.php @@ -1,7 +1,93 @@ .sh"} optionally with a "warning" +// {"ok":false,"error":"Invalid path"|"Not a directory: …"|"Invalid script path" +// |"Not found: …"|"Could not resolve path" +// |"Refusing to import from inside the Varaverk repo …" +// |"Already in Custom Scripts."|"A script named \"…\" already exists …" +// |"Copy failed"|"Copy verification failed — source left untouched" +// |"Invalid request"} +// +// DEPENDS ON +// include/config.php SCRIPTS_DIR, CUSTOM_SCRIPTS_DIR +// api/script.php manages the scripts once they are here +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/config.php'; diff --git a/Plugin/unraid/api/log.php b/Plugin/unraid/api/log.php index f5b0415..5c7cc4e 100644 --- a/Plugin/unraid/api/log.php +++ b/Plugin/unraid/api/log.php @@ -1,4 +1,70 @@ last 200 lines +// POST id=<…> same as GET +// POST id=<…> clear=1 truncate the log +// +// RESPONSE +// {"ok":true,"content":"…","ts":} read — ts is 0 when the log does not exist +// {"ok":true} clear +// {"ok":false,"error":"Invalid job id"} +// +// DEPENDS ON +// include/scheduler.php vv_job_log_path() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; diff --git a/Plugin/unraid/api/manual_sync.php b/Plugin/unraid/api/manual_sync.php index cfcf878..eb965b8 100644 --- a/Plugin/unraid/api/manual_sync.php +++ b/Plugin/unraid/api/manual_sync.php @@ -1,4 +1,129 @@ &path=/dir remote directory listing over SSH +// GET ?action=browse_local&path=/dir local directory listing +// POST action=run local=/src host= remote_path=/dst +// [user=root] [bw_limit=] [use_key=0|1] [flags=] +// GET ?action=poll&token= output so far, and whether it finished +// POST action=stop token= cancel a running transfer +// +// RESPONSE +// hosts {"ok":true,"hosts":[{slot,id,hostname,online,ip}],"has_key":bool,"ssh_key":"…"} +// browse {"ok":true,"path","dirs":[…],"parent":…} max 200 entries +// run {"ok":true,"token":""} +// poll {"ok":true,"output":"…","done":bool,"started":bool} +// stop {"ok":true} +// {"ok":false,"error":"Invalid path"|"Missing: …"|"Refusing to sync from system path: …" +// |"Local source does not exist: …"|"Another manual sync is already +// running — stop it first."|"Remote destination does not exist: …" +// |"SSH connection failed to …"|"Invalid flags"|"Invalid token" +// |"Unknown action"} +// +// DEPENDS ON +// include/config.php vv_detect_host(), vv_read_conf_raw(), vv_conf_vars(), +// vv_resolve_tailscale_ip() +// include/arrs.php vv_arr_known_hosts(), vv_arr_scalar() +// include/partnership.php vv_pt_ts_peers(), vv_pt_ssh() +// /tmp/vv_ms_.log|.pid per-transfer state +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/config.php'; @@ -104,8 +229,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') { $user = preg_replace('/[^a-z0-9_.-]/i', '', trim($_POST['user'] ?? 'root')) ?: 'root'; $bwLimit = max(0, (int)($_POST['bw_limit'] ?? 0)); $useKey = ($_POST['use_key'] ?? '1') !== '0'; - $rawFlags = trim($_POST['flags'] ?? ''); - $flags = $rawFlags !== '' ? preg_replace('/[`$!|&;><(){}\[\]\\\\]/', '', $rawFlags) : '-av --stats'; + // Control characters are rejected outright — a newline would start a second command + // inside the bash -c script this is spliced into. What survives is split on whitespace + // and escaped per token below, so the shell never parses any of it as syntax. + $rawFlags = trim($_POST['flags'] ?? ''); + if ($rawFlags !== '' && preg_match('/[\x00-\x1f\x7f]/', $rawFlags)) { + echo json_encode(['ok' => false, 'error' => 'Invalid flags']); exit; + } + $flagList = $rawFlags !== '' + ? preg_split('/\s+/', $rawFlags, -1, PREG_SPLIT_NO_EMPTY) + : ['-av', '--stats']; foreach (['local' => $local, 'host' => $slot, 'remote_path' => $remotePath] as $f => $v) { if (!$v) { echo json_encode(['ok' => false, 'error' => 'Missing: ' . $f]); exit; } @@ -170,7 +303,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'run') { } else { $sshOpts = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10'; } - if ($bwLimit) $flags .= ' --bwlimit=' . (int)$bwLimit; + if ($bwLimit) $flagList[] = '--bwlimit=' . (int)$bwLimit; + $flags = implode(' ', array_map('escapeshellarg', $flagList)); $src = escapeshellarg(rtrim($local, '/') . '/'); $dst = escapeshellarg($user . '@' . $t['ip'] . ':' . rtrim($remotePath, '/') . '/'); diff --git a/Plugin/unraid/api/media.php b/Plugin/unraid/api/media.php index 2fcd580..ed5cff1 100644 --- a/Plugin/unraid/api/media.php +++ b/Plugin/unraid/api/media.php @@ -1,4 +1,38 @@ vv_remote_hosts_stats(), 'ts' => time()]; diff --git a/Plugin/unraid/api/movescript.php b/Plugin/unraid/api/movescript.php index 1c9fc60..463f6ad 100644 --- a/Plugin/unraid/api/movescript.php +++ b/Plugin/unraid/api/movescript.php @@ -1,6 +1,84 @@ to_array=_SCRIPTS +// POST script= to_array= remove from all arrays +// +// RESPONSE +// {"ok":true,"push":[{"host","ok","ready","error"}, …]} +// {"ok":false,"error":"POST only"|"Invalid script"|"Invalid array name" +// |"master.conf not found"|"Could not read master.conf" +// |"Target array \"…\" not found in master.conf"|"Write failed"} +// +// DEPENDS ON +// include/config.php CONF_DIR, vv_write_conf_raw(), vv_push_master_conf(), +// vv_push_setup_state() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; @@ -75,5 +153,8 @@ if (!vv_write_conf_raw('master.conf', implode('', $newLines))) { exit; } -vv_push_master_conf(); -echo json_encode(['ok' => true]); +// Reported rather than discarded — a partner that did not receive the move is exactly the +// state that makes one host run a script the other does not. Mirrors rawconf/confform. +$push = vv_push_master_conf(); +vv_push_setup_state(); +echo json_encode(['ok' => true, 'push' => $push]); diff --git a/Plugin/unraid/api/partnership.php b/Plugin/unraid/api/partnership.php index af5c925..f99fef3 100644 --- a/Plugin/unraid/api/partnership.php +++ b/Plugin/unraid/api/partnership.php @@ -1,4 +1,40 @@ (case-insensitive; the slot name, not the hostname) +// +// RESPONSE +// {"ok":true,"ms":int,…} on success +// {"ok":false,"error":string} invalid id, no key, unresolvable, or no reply +// +// DEPENDS ON +// include/partnership.php vv_pt_ping() → vv_pt_ssh(), vv_resolve_tailscale_ip() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/partnership.php'; diff --git a/Plugin/unraid/api/partnership_settings.php b/Plugin/unraid/api/partnership_settings.php index e480084..29eb59f 100644 --- a/Plugin/unraid/api/partnership_settings.php +++ b/Plugin/unraid/api/partnership_settings.php @@ -1,8 +1,61 @@ ","groups":[…]}, …]} +// +// DEPENDS ON +// include/confform.php vv_conf_all_groups() +// include/config.php vv_get_conf_files() +// api/confform.php the write path for these same fields +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/confform.php'; diff --git a/Plugin/unraid/api/rawconf.php b/Plugin/unraid/api/rawconf.php index 6bc3f62..44a11fc 100644 --- a/Plugin/unraid/api/rawconf.php +++ b/Plugin/unraid/api/rawconf.php @@ -1,5 +1,76 @@ defaults to master.conf +// POST file= content= +// +// RESPONSE +// GET {"ok":true,"content":"…","file":"…","allowed":["…"]} +// POST {"ok":true,"push":[{"host","ok","ready","error"}, …]} +// push is empty for host confs and on hosts with no partners +// {"ok":false,"error":"Not allowed"|"Syntax error: …"|"Method not allowed"} +// +// DEPENDS ON +// include/config.php vv_get_conf_files(), vv_read_conf_raw(), vv_write_conf_raw(), +// vv_push_master_conf(), vv_push_setup_state() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; @@ -22,6 +93,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo json_encode(['ok' => false, 'error' => 'Not allowed']); exit; } + // Every script sources these, and master.conf is pushed to every partner from here — a + // syntax error saved through this endpoint would propagate the outage across the mesh. + $check = tempnam(sys_get_temp_dir(), 'vvconf'); + if ($check !== false) { + file_put_contents($check, $content); + $out = []; $rc = 0; + exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc); + @unlink($check); + if ($rc !== 0) { + $msg = implode(' ', array_filter(array_map('trim', $out))); + echo json_encode([ + 'ok' => false, + 'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'), + 'push' => [], + ]); + exit; + } + } + $written = vv_write_conf_raw($file, $content); $push = []; if ($written && $file === 'master.conf') { diff --git a/Plugin/unraid/api/readscript.php b/Plugin/unraid/api/readscript.php index 93c4976..3bdd9cc 100644 --- a/Plugin/unraid/api/readscript.php +++ b/Plugin/unraid/api/readscript.php @@ -1,5 +1,61 @@ +// +// RESPONSE +// {"ok":true,"content":""} +// {"ok":false,"error":"Invalid id"|"Not found"} +// +// DEPENDS ON +// include/config.php SCRIPTS_DIR +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; diff --git a/Plugin/unraid/api/recent.php b/Plugin/unraid/api/recent.php index 674909b..bf43e03 100644 --- a/Plugin/unraid/api/recent.php +++ b/Plugin/unraid/api/recent.php @@ -1,4 +1,57 @@ _SCRIPTS +// scripts= +// +// RESPONSE +// {"ok":true,"push":[{"host","ok","ready","error"}, …]} +// {"ok":false,"error":"POST only"|"Invalid array_name"|"Invalid scripts JSON" +// |"Invalid script id: …"|"master.conf not found" +// |"Could not read master.conf"|"Array … not found in master.conf" +// |"Write failed"} +// +// DEPENDS ON +// include/config.php CONF_DIR, vv_write_conf_raw(), vv_push_master_conf(), +// vv_push_setup_state() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; @@ -24,12 +101,17 @@ if (!is_array($decoded)) { exit; } -// Validate each entry +// Validate every entry before anything is written. A rejected entry cannot be skipped here: +// this endpoint rewrites the array from $order alone, so a silently dropped entry is a script +// silently removed from its orchestrator. $order = []; foreach ($decoded as $item) { $id = trim((string)($item['id'] ?? '')); $enabled = (bool)($item['enabled'] ?? true); - if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) continue; + if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) { + echo json_encode(['ok' => false, 'error' => 'Invalid script id: ' . $id]); + exit; + } $order[] = ['id' => $id, 'enabled' => $enabled]; } @@ -106,5 +188,8 @@ if (!vv_write_conf_raw('master.conf', implode('', $lines))) { exit; } -vv_push_master_conf(); -echo json_encode(['ok' => true]); +// Reported rather than discarded — a partner that did not receive the new order is a partner +// running these scripts in a different sequence. Mirrors rawconf/confform/movescript. +$push = vv_push_master_conf(); +vv_push_setup_state(); +echo json_encode(['ok' => true, 'push' => $push]); diff --git a/Plugin/unraid/api/rsync.php b/Plugin/unraid/api/rsync.php index 206a5e7..34f94e6 100644 --- a/Plugin/unraid/api/rsync.php +++ b/Plugin/unraid/api/rsync.php @@ -1,4 +1,90 @@ :{"scripts":[…],"shares":[…]}},"settings":{…},"ts"} +// log {"ok":true,"live":bool,"profile","elapsed","lines":[…]} +// +// DEPENDS ON +// include/monitor.php vv_rsync_status() +// include/config.php vv_conf_vars(), vv_read_conf_raw(), vv_detect_host(), +// vv_parse_bash_array(), DATA_DIR +// /tmp/unraid_locks rsync_*.lock, rsync_*.log, rsync_*.last.log — written by rsync.sh +// DATA_DIR bandwidth_history.db +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/monitor.php'; diff --git a/Plugin/unraid/api/rsync_profiles.php b/Plugin/unraid/api/rsync_profiles.php index 7616a5c..77ba589 100644 --- a/Plugin/unraid/api/rsync_profiles.php +++ b/Plugin/unraid/api/rsync_profiles.php @@ -1,4 +1,85 @@ rsync_opts, bw_limit, retry_count, sleep, +// critical_containers, delayed_containers, container_delay, +// exclude_dirs, remote_restart (all optional, default empty) +// POST action=delete name= +// +// RESPONSE +// list {"ok":true,"profiles":{"":{"":"", …}, …}} +// save {"ok":bool,"results":{"master.conf":bool}} +// delete {"ok":bool} +// {"ok":false,"error":"Invalid profile name — …"|"No profile arrays found in master.conf" +// |"Profile not found"|"Unknown action"} +// +// DEPENDS ON +// include/confform.php vv_conf_write_changes() +// include/config.php vv_read_conf_raw(), vv_push_master_conf(), vv_push_setup_state() +// Rsync/rsync.sh consumer of every PROFILE_* array this writes +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/config.php'; diff --git a/Plugin/unraid/api/rsync_standalone.php b/Plugin/unraid/api/rsync_standalone.php index 1338b2e..3a8936d 100644 --- a/Plugin/unraid/api/rsync_standalone.php +++ b/Plugin/unraid/api/rsync_standalone.php @@ -1,8 +1,76 @@ , a namespace deliberately outside the +// job id space. Everything that iterates the schedule as jobs skips keys with that prefix. +// +// The cron is rebuilt immediately on save, so the change takes effect without waiting for +// another event to regenerate varaverk.cron. +// +// DESIGN PRINCIPLES +// Keyed by flag, not by orchestrator. +// The tier flag is the stable identity — CRITICAL_RSYNC_ENABLED means the same thing +// regardless of which orchestrator currently carries that tier. orch_id is stored +// alongside it purely so the rebuild can check whether that orchestrator is enabled. +// +// Configuration only. Nothing here starts a sync; it records when one should start. +// +// Empty fields are permitted and mean "not configured". +// location and cron may both be blank, which is how a standalone entry is cleared — +// vv_cron_rebuild() requires both to be present before it emits anything. +// +// OPERATIONAL SAFEGUARDS +// POST only, checked before any parameter is read. +// +// The flag name is constrained to the rsync namespace. +// ^[A-Z_]+_RSYNC_ENABLED$ — this endpoint cannot create a schedule key for anything +// else, so the __rsync_ namespace stays exactly as wide as the tiers it was built for. +// +// The cron expression is validated as a crontab injection surface. +// vv_cron_rebuild() interpolates it directly into a generated crontab line. Only +// [0-9A-Za-z*,\-/ ] is permitted, and then five whitespace-separated fields are +// required. The character class is what carries the guarantee: the field-count pattern +// uses \s, which matches newline, so on its own it would accept a value carrying a +// second, caller-chosen crontab entry. It was previously not validated at all. +// +// The orchestrator id is validated even though it is only ever used as a lookup key. +// ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check, so a value that could not name a +// real script cannot be stored as though it does. +// +// The location must be absolute and clean. +// Leading slash required, '..' rejected, control characters rejected. It reaches the +// crontab as an escapeshellarg'd --location= token, so validation and escaping are both +// in place. +// +// The rest of the schedule is preserved — the file is loaded, one key replaced, and the +// whole structure written back. +// +// The cron rebuild only runs after a confirmed write, so a failed save cannot regenerate +// the crontab from a schedule that was not persisted. +// +// REQUEST +// POST flag_name=_RSYNC_ENABLED orch_id= +// location=/absolute/path cron=<5 fields> +// +// RESPONSE +// {"ok":true} +// {"ok":false,"error":"POST only"|"Invalid flag_name"|"Invalid orch_id"|"Invalid location" +// |"Invalid cron expression"|"Write failed"} +// +// DEPENDS ON +// include/scheduler.php vv_schedule_load(), vv_schedule_save(), vv_cron_rebuild() +// Rsync/rsync.sh the script the generated cron entry invokes +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/scheduler.php'; @@ -29,6 +97,13 @@ if ($location && (!str_starts_with($location, '/') || str_contains($location, '. echo json_encode(['ok' => false, 'error' => 'Invalid location']); exit; } +// vv_cron_rebuild() interpolates this straight into a crontab line. Restrict to cron-safe +// characters first — a field-count check alone would accept a value carrying a newline and +// therefore a second, caller-chosen crontab entry. +if ($cron && (!preg_match('/^[0-9A-Za-z*,\-\/ ]+$/', $cron) || !preg_match('/^(\S+ +){4}\S+$/', $cron))) { + echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']); + exit; +} $key = '__rsync_' . $flagName; $schedule = vv_schedule_load(); diff --git a/Plugin/unraid/api/rsync_win_arrays.php b/Plugin/unraid/api/rsync_win_arrays.php index ecd9138..54c0a9a 100644 --- a/Plugin/unraid/api/rsync_win_arrays.php +++ b/Plugin/unraid/api/rsync_win_arrays.php @@ -1,4 +1,102 @@ _*_SYNC_SHARES array in this host's own conf, because what a host syncs +// is local to it. The three-file model puts them there, and this endpoint respects that +// split rather than flattening it. +// +// The two halves are written independently and their failures reported separately. A shares +// write that fails does not roll back a successful scripts write — they are different files +// with different consumers, and a partial success is more useful than an all-or-nothing +// that leaves both stale. +// +// Scripts are edited by block surgery, shares by the structured conf writer. The script +// array carries inline arguments and comment-disabled entries that must survive a round +// trip; the shares array is a flat list this endpoint fully owns. +// +// DESIGN PRINCIPLES +// The window name is a key into a fixed map, never a composed variable. +// Four windows, each naming its two conf variables. An unrecognised window is rejected +// before anything is read, so no part of the request can name a conf variable directly. +// +// The script library excludes what cannot be scheduled here. +// Plugin, .git, Orchestrators, Custom, Configurations, Deployment, State_Files, data and +// the archive folders are filtered out, and root-level scripts are excluded by requiring +// at least one directory component. Orchestrators are excluded specifically because +// putting one inside another window's array is how a run becomes recursive. +// +// Original entry lines are preserved through a save. +// Existing entries are harvested keyed by script path and reused verbatim, so inline +// flags survive. Only a newly added script is written as a bare quoted path. +// +// Disabled entries stay in the file, commented — the same convention conf_toggle.php and +// reorderarray.php use. +// +// A share's profile is optional and encoded inline as path|profile, matching what rsync.sh +// parses. No profile means the default. +// +// OPERATIONAL SAFEGUARDS +// Every script id is validated, and an invalid one fails the request rather than being +// skipped. +// The block is regenerated from the submitted list alone, so a silently dropped entry is +// a script silently removed from its window. Validation completes before the block is +// rebuilt. +// +// Share paths must be absolute with no traversal, and profile names are constrained. +// ^[A-Za-z0-9_\-]+$ on the profile, because it is spliced into a quoted conf array +// element where a quote would terminate the string and a paren would close the array. +// +// A missing script array aborts that half of the save. +// Both block boundaries must be found, otherwise an error is recorded and nothing is +// written — without it, a splice would land at an undefined position. +// +// Both writes are atomic. +// The scripts half goes through vv_write_conf_raw() (tmp + rename) and the shares half +// through vv_conf_write_changes(), which does the same. Every script sources master.conf; +// a truncated write here would be a system-wide outage rather than a lost edit. +// +// The shares write is syntax-checked before it lands. +// vv_conf_write_changes() runs `bash -n` on the result, so a share path that would not +// parse is reported as a failed write with the original conf intact. +// +// master.conf is pushed to partners after a confirmed write. +// It is a shared file; leaving one host's window definition ahead of the other's is what +// makes the two run different work. Mirrors reorderarray, movescript and rawconf. +// +// The script scan is wrapped in a try/catch, so an unreadable subdirectory yields a partial +// library rather than a 500. +// +// Known limit: block detection counts parens textually. +// depth is tracked with substr_count, which does not know about quotes or comments. An +// entry whose arguments contained an unbalanced paren would end the block early. No +// current entry does — but a future one would be the thing that broke this. +// +// REQUEST +// GET|POST ?action=list_scripts .sh files grouped by folder, schedulable ones only +// GET|POST ?action=list_profiles rsync profile names declared in master.conf +// POST action=save win_key=critical|intermediate|daily|weekly +// scripts= shares= +// Either list may be omitted; only the ones supplied are written. +// +// RESPONSE +// list_scripts {"ok":true,"groups":{"":[{"id","label"}, …]}} +// list_profiles {"ok":true,"profiles":["…"]} +// save {"ok":bool,"errors":[…]} +// {"ok":false,"error":"Invalid window"|"Invalid script id: …"|"Unknown action"} +// +// DEPENDS ON +// include/config.php SCRIPTS_DIR, CONF_DIR, vv_detect_host(), vv_read_conf_raw(), +// vv_write_conf_raw(), vv_push_master_conf(), vv_push_setup_state() +// include/confform.php vv_conf_write_changes() +// Rsync/rsync.sh consumer of the shares arrays this writes +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/confform.php'; @@ -89,18 +187,41 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') { } if ($blockStart !== null && $blockEnd !== null) { + // Validate the whole list before rebuilding. The block is regenerated from this + // loop alone, so skipping an invalid entry would silently drop that script from + // the orchestrator rather than reporting a bad request. + $bad = null; + foreach ($scripts as $item) { + $id = trim((string)($item['id'] ?? '')); + if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) { + $bad = $id; + break; + } + } + if ($bad !== null) { + echo json_encode(['ok' => false, 'error' => 'Invalid script id: ' . $bad]); + exit; + } + $newBlock = [$lines[$blockStart]]; foreach ($scripts as $item) { - $id = trim((string)($item['id'] ?? '')); - $enabled = !isset($item['enabled']) || (bool)$item['enabled']; - if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) continue; + $id = trim((string)$item['id']); + $enabled = !isset($item['enabled']) || (bool)$item['enabled']; $entry = $origLines[$id] ?? '"' . $id . '"'; $prefix = $enabled ? ' ' : ' #'; $newBlock[] = $prefix . $entry . "\n"; } $newBlock[] = $lines[$blockEnd]; array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlock); - if (file_put_contents($confPath, implode('', $lines)) === false) $errors[] = 'scripts write failed'; + // tmp+rename — every script sources master.conf, so a truncated write here is a + // system-wide outage, not a lost edit. + if (!vv_write_conf_raw('master.conf', implode('', $lines))) { + $errors[] = 'scripts write failed'; + } else { + // master.conf is shared — mirrors reorderarray/movescript/rawconf. + vv_push_master_conf(); + vv_push_setup_state(); + } } else { $errors[] = "Array $scriptsVar not found in master.conf"; } @@ -114,6 +235,9 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') { $path = trim((string)($item['path'] ?? '')); $profile = trim((string)($item['profile'] ?? '')); if (!$path || str_contains($path, '..') || !str_starts_with($path, '/')) continue; + // The profile name is spliced into a quoted conf array element; anything outside + // this set could terminate the string or the array. + if ($profile !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $profile)) continue; $val = $profile ? "{$path}|{$profile}" : $path; $inner .= ' "' . $val . '"' . "\n"; } diff --git a/Plugin/unraid/api/run.php b/Plugin/unraid/api/run.php index d2025da..a0d898d 100644 --- a/Plugin/unraid/api/run.php +++ b/Plugin/unraid/api/run.php @@ -1,4 +1,72 @@ exists. Without that, +// a job whose runner was OOM-killed — the exact case where someone is trying to start +// it again — would be permanently unstartable from the UI. +// +// The location argument must be absolute and clean. +// Leading slash required, '..' rejected, control characters rejected — then passed as a +// single escapeshellarg'd --location= token. +// +// Extra arguments cannot become shell syntax. +// Control characters are rejected, then the string is split on whitespace and each +// token escaped individually. The previous blocklist of metacharacters missed newlines, +// which would have terminated the command line and started a second one — a blocklist +// has to be right about every character, whereas escaping each token is right about all +// of them. +// +// Every interpolated value is escaped, including the ones that are already validated. +// Runner path, id, script path and log path all go through escapeshellarg(). Validation +// and escaping guard different things, and the escaping is what stays correct if the +// validation is ever loosened. +// +// Output is appended, never truncated. +// >> to the job's own log with stdin from /dev/null, so a manual run cannot consume the +// request's stdin or discard the history of previous runs. +// +// REQUEST +// POST id= [location=/absolute/path] [extra_args=…] +// +// RESPONSE +// {"ok":true} launched — not completed +// {"ok":false,"already_running":true,"error":"Already running"} +// {"ok":false,"error":"Invalid id"|"Script not found: …"|"Invalid location" +// |"Invalid extra_args"} +// +// DEPENDS ON +// include/scheduler.php vv_job_log_path(), vv_job_stat_path(), vv_job_flags() +// run_job.sh the single execution path for every job +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; @@ -25,8 +93,11 @@ if ($location && (!str_starts_with($location, '/') || str_contains($location, '. exit; } +// Control characters are rejected outright — a newline would end the command line and start +// a second one. Everything that survives is split on whitespace and escaped per token below, +// so the shell never parses any of it as syntax. $extra_args = trim($_POST['extra_args'] ?? ''); -if ($extra_args && preg_match('/[;&|`$<>\\\\"\']/', $extra_args)) { +if ($extra_args !== '' && preg_match('/[\x00-\x1f\x7f]/', $extra_args)) { echo json_encode(['ok' => false, 'error' => 'Invalid extra_args']); exit; } @@ -46,7 +117,10 @@ if (file_exists($statFile)) { $runner = dirname(__DIR__) . '/run_job.sh'; $flags = vv_job_flags($id); $locArg = $location ? ' ' . escapeshellarg('--location=' . $location) : ''; -$extraStr = $extra_args ? ' ' . $extra_args : ''; +$extraStr = ''; +foreach (preg_split('/\s+/', $extra_args, -1, PREG_SPLIT_NO_EMPTY) as $tok) { + $extraStr .= ' ' . escapeshellarg($tok); +} exec('nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ($flags ? " $flags" : '') . ' --manual' . $locArg . $extraStr . ' >> ' . escapeshellarg($logFile) . ' 2>&1 true]); diff --git a/Plugin/unraid/api/savefolders.php b/Plugin/unraid/api/savefolders.php index 7481b00..ad89e6c 100644 --- a/Plugin/unraid/api/savefolders.php +++ b/Plugin/unraid/api/savefolders.php @@ -1,6 +1,64 @@ ": ["Category/script.sh", …], …}> +// +// RESPONSE +// {"ok":true} +// {"ok":false,"error":"POST only"|"Invalid JSON"|"Write failed"} +// +// DEPENDS ON +// include/scheduler.php vv_schedule_load(), vv_schedule_save() +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/scheduler.php'; diff --git a/Plugin/unraid/api/scheduler.php b/Plugin/unraid/api/scheduler.php index 34312b7..9b212b6 100644 --- a/Plugin/unraid/api/scheduler.php +++ b/Plugin/unraid/api/scheduler.php @@ -1,4 +1,77 @@ enabled=0|1 cron=<5 fields|array_start|array_stop|empty> +// log_enabled=0|1 +// POST batch= +// +// RESPONSE +// {"ok":true,"error":null} +// {"ok":false,"error":"POST only"|"Invalid id"|"Invalid cron expression" +// |"Failed to write schedule"} +// +// DEPENDS ON +// include/scheduler.php vv_schedule_update(), vv_schedule_update_batch(), +// vv_cron_rebuild() +// varaverk.cron generated output — never edited directly +// ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/scheduler.php'; @@ -7,6 +80,20 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +// Both values below are interpolated into the generated crontab by vv_cron_rebuild(), so +// neither may contain a quote, a shell metacharacter, or a newline. +function vv_sched_id_valid(string $id): bool { + return $id !== '' && !str_contains($id, '..') && (bool)preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id); +} + +// Five whitespace-separated fields of cron-safe characters only. \s matches newline, so the +// field-count pattern alone would accept a value carrying a second crontab line. +function vv_sched_cron_valid(string $cron): bool { + if (in_array($cron, ['array_start', 'array_stop'], true)) return true; + if (!preg_match('/^[0-9A-Za-z*,\-\/ ]+$/', $cron)) return false; + return (bool)preg_match('/^(\S+ +){4}\S+$/', $cron); +} + // Batch save — all entries in one load/write/rebuild cycle if (!empty($_POST['batch'])) { $entries = json_decode($_POST['batch'], true) ?: []; @@ -14,9 +101,8 @@ if (!empty($_POST['batch'])) { foreach ($entries as $e) { $id = trim($e['id'] ?? ''); $cron = trim($e['cron'] ?? ''); - if (!$id) continue; - if ($cron && !in_array($cron, ['array_start', 'array_stop'], true) - && !preg_match('/^(\S+\s+){4}\S+$/', $cron)) $cron = ''; + if (!vv_sched_id_valid($id)) continue; + if ($cron && !vv_sched_cron_valid($cron)) $cron = ''; $clean[] = [ 'id' => $id, 'enabled' => ($e['enabled'] ?? '0') === '1', @@ -30,18 +116,17 @@ if (!empty($_POST['batch'])) { } $id = trim($_POST['id'] ?? ''); -$enabled = (bool)($_POST['enabled'] ?? false); +$enabled = ($_POST['enabled'] ?? '0') === '1'; $cron = trim($_POST['cron'] ?? ''); $log_enabled = ($_POST['log_enabled'] ?? '0') === '1'; -if (!$id) { - echo json_encode(['ok' => false, 'error' => 'Missing id']); +if (!vv_sched_id_valid($id)) { + echo json_encode(['ok' => false, 'error' => 'Invalid id']); exit; } -// Basic cron validation — 5 fields, or known @event trigger, or empty -if ($cron && !in_array($cron, ['array_start', 'array_stop'], true) - && !preg_match('/^(\S+\s+){4}\S+$/', $cron)) { +// 5 cron fields, or a known event trigger, or empty +if ($cron && !vv_sched_cron_valid($cron)) { echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']); exit; } diff --git a/Plugin/unraid/api/script.php b/Plugin/unraid/api/script.php index 5386cb0..8f99904 100644 --- a/Plugin/unraid/api/script.php +++ b/Plugin/unraid/api/script.php @@ -1,4 +1,78 @@ .sh everywhere in the scheduler, and vv_cron_rebuild() is the other half of +// the convention — it maps that prefix onto CUSTOM_SCRIPTS_DIR when generating cron lines. +// +// Saving also registers. A new script gets a disabled, unscheduled entry in schedule.json so +// it appears in the job list immediately; the user then schedules it through scheduler.php. +// +// DESIGN PRINCIPLES +// Names, not paths, on the write side. +// POST takes a bare name and composes the id and the path from it. There is no way to +// express a subdirectory, so the flat namespace is a property of the interface rather +// than something validation has to enforce afterwards. +// +// Delete removes the script and its schedule entry together. +// Unlink, drop the schedule key, save, rebuild the cron. Leaving a schedule entry for a +// script that no longer exists would put a cron line in place for a missing file. +// +// Reading a script that does not exist returns empty content, not an error. +// The editor opens the same way for a new script and an existing one. +// +// OPERATIONAL SAFEGUARDS +// The read id is constrained to the Custom namespace. +// ^Custom/[a-zA-Z0-9_\-]+\.sh$ plus a '..' check — no dots in the name, no nested path, +// no other prefix. This endpoint cannot be used to read a repo script; that is +// readscript.php, which has its own extension allowlist. +// +// The write name excludes every path character. +// ^[a-zA-Z0-9_\-]+$ — no slash, no dot, no traversal sequence can be expressed, so the +// composed path is always a direct child of CUSTOM_SCRIPTS_DIR. The name pattern is +// stricter than the read pattern because it is what constructs the filename. +// +// The script write is atomic, and executable before it is visible. +// tmp + chmod 0755 + rename. An enabled custom script can be launched by cron at any +// moment; writing in place would let it fire against a truncated file, and chmod after +// the write would let it fire against a non-executable one. +// +// Delete confirms existence first, so a repeated delete reports a clear "Script not found" +// rather than silently rebuilding the cron for nothing. +// +// Unknown methods are refused explicitly at the end, so a PUT or DELETE cannot fall through +// the two handled blocks into an empty 200. +// +// Accepted by design: this endpoint writes an executable root-run script from a browser. +// That is the entire feature, and it is why it is confined to a directory outside the +// repo with a flat namespace and a strict name pattern. It is guarded by the Unraid +// WebGUI session; see the CSRF note in README-unraid.md. +// +// REQUEST +// GET ?id=Custom/.sh read (empty content when absent) +// POST name= content=