Document the PHP api layer and fix what documenting it exposed

Writing down what each endpoint actually guarantees made the places it
didn't obvious — shell arguments reaching a crontab or a bash -c
unescaped, master.conf written without tmp+rename, and conf edits that
could be saved without ever being parsed.
This commit is contained in:
Gmer4Lfe
2026-08-02 10:11:39 -04:00
parent 6a959fb5e4
commit 987313e7dc
55 changed files with 3972 additions and 95 deletions
+33
View File
@@ -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
+78 -4
View File
@@ -1,9 +1,83 @@
<?php
// API cache writer — runs every minute via Varaverk scheduler.
// Builds monitor + arrs payloads and writes them to /tmp/vv_cache/ so page
// loads can serve instantly from the file instead of making live HTTP calls.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Background cache writer. Builds the full monitor payload and the arrs payload once a
// minute and writes them to the cache, so page loads serve from a file instead of paying
// for collection.
//
// Called by api_cache_writer.sh (bash wrapper required by the scheduler).
// OPERATIONAL MODEL
// This is what makes the monitor and arrs tabs fast. api/monitor.php and api/arrs.php read
// the files this process writes and only fall back to collecting for themselves on a miss.
// The expensive work — GraphQL, docker stats, SSH to partners, HTTP to every arr instance —
// happens here, on a schedule, off the request path.
//
// Runs from cron every minute via api_cache_writer.sh, a bash wrapper the scheduler
// requires. Not reachable over HTTP, and refuses to run if it ever is.
//
// The payload assembled here is deliberately identical to api/monitor.php's. The two are
// maintained together: a field added there and not here is a field that is only ever served
// on a cache miss.
//
// DESIGN PRINCIPLES
// One API round trip for the whole payload.
// vv_api_data() is called once up front and static-cached for the life of the process,
// so the API-first collectors below share a single GraphQL query rather than issuing
// one each.
//
// Writes two caches, not one.
// monitor and arrs have different consumers and different costs, so they are written
// under separate keys and either can be served while the other is stale.
//
// Every payload carries its own timestamp, so consumers can render age rather than
// presenting minute-old numbers as current.
//
// Reports its own duration on stdout.
// The elapsed time goes to the job log, which is the only place a slow collection cycle
// becomes visible — this process has no other output and no failure anyone would see.
//
// 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. Otherwise a browser hitting this path would trigger the full expensive
// collection — including SSH to every partner — outside any cache or rate limit, once
// per request.
//
// Read-only with respect to the system. Every collector observes; none start, stop, or
// change anything. The only writes are the two cache files.
//
// Cache writes are atomic.
// vv_cache_write() writes a .tmp and renames, so a page load landing mid-write reads
// the previous complete payload rather than a truncated one.
//
// Every collector degrades to empty rather than fatal.
// The library suppresses its filesystem reads and redirects stderr on every shell call,
// so absent hardware yields an empty section. On a payload this wide that property is
// what keeps one missing subsystem from failing the whole cycle and leaving both caches
// to expire.
//
// A failed cycle is survivable by design.
// Nothing here clears the previous cache before building the new one. A run that dies
// partway leaves the last good payload in place, and the readers' own age windows —
// 300s for monitor, 300s for arrs — are several cycles wide, so a single missed minute
// is invisible.
//
// OUTPUT
// VV_CACHE_DIR/monitor.json consumed by api/monitor.php
// VV_CACHE_DIR/arrs.json consumed by api/arrs.php
// stdout one timing line, captured into the job log
//
// DEPENDS ON
// include/monitor.php, include/common.php, include/unraid_api.php,
// include/vms.php, include/docker_folders.php, include/arrs.php
// Tools/api_cache_writer.sh the bash wrapper cron actually invokes
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Collecting this payload means GraphQL, docker stats and SSH to every partner. It must never
// be triggerable by an HTTP request.
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$_base = dirname(__DIR__);
require_once $_base . '/include/monitor.php';
+76 -2
View File
@@ -1,6 +1,80 @@
<?php
// Diagnostic endpoint — tests the Unraid GraphQL API and returns raw results.
// Hit from browser: /plugins/varaverk/api/api_test.php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Unraid GraphQL API diagnostic. Reports whether this host has a usable API key, what the
// PHP environment can do, and what the API actually answers — including live schema
// introspection for the types the monitor layer depends on.
//
// OPERATIONAL MODEL
// A hand-run tool, not part of any page. Nothing in the UI links here; it is opened
// directly at /plugins/varaverk/api/api_test.php when the monitor page starts showing
// degraded detail and the question is whether the API, the key, or the parsing is at fault.
//
// It exists because that question was expensive to answer. The Unraid API renamed and
// removed types between 7.2.5 and 7.3 — ArrayParity and ArrayCache stopped being distinct
// types — and the only reliable way to know what the running version exposes is to ask it.
//
// DESIGN PRINCIPLES
// Reports the environment before the result.
// Key presence, curl availability and allow_url_fopen are answered first, because a
// failed probe means something entirely different depending on those three.
//
// Two transports, same query.
// curl when available, a stream context otherwise. The fallback exists so the
// diagnostic still returns something on a PHP build where the probe's failure would
// otherwise be indistinguishable from the API being down.
//
// Returns raw alongside decoded.
// probe_raw carries the first 1000 bytes verbatim. When the response is not JSON — an
// HTML error page, a proxy interception — the decoded field is null and the raw text is
// the only thing that explains why.
//
// Introspects the specific types the monitor layer reads.
// Not a full schema dump. The named type lists are the ones whose field names the
// collectors depend on, so a rename shows up here as a missing field rather than as a
// quietly empty card on the monitor page.
//
// Pretty-printed on purpose. The only consumer is a person reading it in a browser tab.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Every query is a read or an introspection; nothing here mutates array, docker,
// or VM state through the API.
//
// Every request is time-boxed.
// 5s with a 3s connect timeout on the probe, 8s on each introspection call. A hung or
// unreachable API returns a diagnostic rather than becoming one.
//
// The whole probe is skipped without a key, and reports that as the finding.
// key_present is false and probe stays null. The absence of a key is the most common
// answer this tool gives, and it is reported as a result rather than as a failure.
//
// Localhost only. The endpoint is hardcoded to http://localhost/graphql — no part of the
// request selects a target, so this cannot be used to probe another host.
//
// The key is truncated in the response.
// Only the first 8 characters are returned, as key_prefix — enough to confirm which key
// is in use, not enough to use it.
//
// Known exposure: this returns more than a normal endpoint should.
// A key prefix, the cached API debug log, and the live schema are all visible to anyone
// with a WebGUI session. That is acceptable for a diagnostic reachable only by typing
// its URL, but it is the reason it is not linked from any page and should not be
// wrapped in one. See the CSRF note in README-unraid.md for the session-only guard this
// shares with the rest of the api layer.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"host","key_present","key_prefix","curl_available","allow_url_fopen","debug_log",
// "probe":{"url","http_code","curl_err","decoded"},"probe_raw",
// "schema","pool_drives","schema2"}
// The schema, pool_drives and schema2 keys are present only when a key exists.
//
// DEPENDS ON
// include/config.php vv_detect_host(), vv_conf_vars(), VV_CACHE_DIR
// Unraid API http://localhost/graphql
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
+79 -2
View File
@@ -1,7 +1,74 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Arr data endpoint. Serves the arrs tab: local Sonarr/Radarr/Lidarr statistics gathered
// live, partner statistics served from the background cache, plus an on-demand refresh of
// one partner's cache.
//
// OPERATIONAL MODEL
// Two modes on one URL. Without parameters it answers from a 300s cache and only builds a
// fresh payload on a miss. With ?action=refresh_remote&host=hostN it shells out to
// Tools/remote_arr_cache_writer.sh for that one host, returns the newly written node, and
// busts the main cache so the next ordinary poll picks the change up.
//
// DESIGN PRINCIPLES
// The refresh branch runs before the cache read, and exits.
// It is a distinct operation, not a cache-control flag, so it never falls through into
// the normal load path and cannot return a stale payload labelled as refreshed.
//
// Cache first for the ordinary case.
// vv_arrs_all() contacts every configured arr instance. At the tab's poll rate that is
// far too expensive to repeat, so the 300s cache is the default path and the live build
// is the exception.
//
// include/arrs.php is required only on a cache miss.
// A cache hit answers without loading the library at all, which is the difference
// between a poll that costs a file read and one that costs an autoload.
//
// OPERATIONAL SAFEGUARDS
// The host parameter is matched against a pattern, never used as a path.
// ^host\d+$ is enforced before the value goes anywhere. It reaches the script only as
// an escapeshellarg'd --host= value and the cache filename it composes, so neither a
// shell metacharacter nor a traversal sequence can survive the check.
//
// The refresh is externally time-boxed.
// set_time_limit() does not count time spent inside exec() on Linux, so PHP's own limit
// cannot end a hung SSH call — the child is wrapped in `timeout` instead. Exit 124 is
// reported as a timeout rather than a generic failure, so the UI can distinguish an
// unreachable partner from a broken script.
//
// A missing script is reported, not executed.
// The file_exists() check runs before exec(), so a partial deploy returns a named error
// rather than a shell "command not found" surfacing as an empty refresh.
//
// The cache is busted after the write, not before.
// @unlink() of arrs.json follows the script run, so a failed refresh leaves the previous
// good payload in place instead of forcing every subsequent poll onto the live path.
//
// Read-only with respect to the arrs themselves. Statistics come from the databases the
// scripts write; nothing here triggers a scan, cleanup, or import.
//
// REQUEST
// GET cached (300s) full payload
// GET ?action=refresh_remote&host=host<n> 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;
+89
View File
@@ -1,4 +1,93 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Auth stack endpoint. The single URL behind the auth tab, covering all three services it
// manages: Nginx Proxy Manager proxy hosts and certificates, lldap users and groups, and
// Authelia access rules.
//
// OPERATIONAL MODEL
// Read and write share one URL, split on HTTP method. GET serves the five read actions and
// is always safe. POST carries an action naming exactly one library call. Anything that is
// neither GET nor POST is refused with 405 before a parameter is read.
//
// Three services, one endpoint, because they are one subject. The auth stack is HOST1's
// source of truth for identity, and a change in one service usually accompanies a change in
// another — a new lldap user is only useful once an Authelia rule and an NPM host exist for
// it. Splitting them into three endpoints would split one page's work across three files
// with three copies of the same dispatch.
//
// DESIGN PRINCIPLES
// Both dispatches are closed match expressions.
// Every action maps to one named library call, and an unrecognised action falls to a
// default arm that returns an error. No part of the request is ever used to construct a
// function name, so the action list is the complete set of things this endpoint can do.
//
// The endpoint holds no auth logic of its own.
// Token acquisition, API dialects, config parsing and the Authelia container restart
// all live in include/auth.php. This file is dispatch and nothing else, which is what
// keeps the credential handling in one auditable place.
//
// Structured payloads arrive as JSON in a form field.
// Proxy definitions and rule sets are nested, so they are passed as encoded JSON rather
// than flattened into form keys — a rule set does not survive form encoding intact.
//
// OPERATIONAL SAFEGUARDS
// Wrong method is refused with a status code, not just a body, so a mistaken caller fails
// visibly rather than parsing an error object as data.
//
// Every parameter is optional and typed at the call site.
// (int) casts on ids, ?? '' on strings, ?? '0' === '1' on flags. A malformed POST
// reaches the library as zeros and empty strings — which the library rejects — rather
// than raising undefined-index warnings into the JSON body and corrupting the response.
//
// Malformed JSON degrades to an empty structure.
// json_decode with ?: [] on both data and rules. A truncated payload becomes an empty
// set the library refuses, not a partial one it might act on.
//
// Credentials pass through, and are never returned.
// lldap_set_password and lldap_create_user accept a password and hand it straight to
// the library. No action in either dispatch returns a stored credential, and nothing
// here writes one to a log.
//
// The destructive actions are POST-only by construction.
// Delete of a proxy, a user, or a group exists only in the POST match. The GET arm has
// five read actions and no others, so no link or prefetch can reach a delete.
//
// Authelia rule writes are atomic and refuse to create.
// The library writes .vv.tmp and renames, and returns 'Config not found' rather than
// authoring a fresh config — a config it created would carry no rules and a default
// policy, which is an accidental open door.
//
// Known gap: no CSRF token is validated on the POST actions.
// The endpoint is guarded by the Unraid WebGUI session alone, and this is the highest-
// value endpoint in the plugin to reach — it can create a user and open a proxy host.
// Shared with the rest of the api layer; see the CSRF note in README-unraid.md.
//
// REQUEST
// GET ?action=npm_proxies | npm_certs | lldap_users | lldap_groups | authelia_rules
// POST action=npm_create data=<JSON>
// POST action=npm_update id, data=<JSON>
// 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=<JSON>, 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';
+97 -1
View File
@@ -1,5 +1,101 @@
<?php
// Live board data: locks, recent errors, partner reachability.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Attention board. The three things worth knowing without opening a tab: locks nobody is
// holding, scripts whose last run ended badly, and whether the partner is reachable.
//
// OPERATIONAL MODEL
// A dashboard of exceptions, not of state. Everything here is designed to be empty on a
// healthy host — an empty board is the expected reading, which is what makes a non-empty
// one worth looking at.
//
// Each section answers a question no single other endpoint does. Locks come from the
// filesystem, errors from correlating logs against their stat files, reachability from an
// actual ping. They are collected together because they are read together.
//
// DESIGN PRINCIPLES
// Only stale locks are reported.
// A lock whose owning pid is still in /proc is a job legitimately running and is
// skipped. What remains is the set a person might need to clear — which is exactly what
// clearlock.php exists for.
//
// Errors are gated on the stat file, not on the log text.
// A script is only reported when its last recorded run exited warn or error. Scanning
// logs for the word "error" produced two persistent false positives: dry runs, which
// print failures they did not cause, and success summaries containing lines like
// "Failed: 0". The stat file is the run's own verdict, and it is authoritative.
//
// A script with no stat file is skipped entirely.
// No stat file means it never ran through run_job.sh, so there is no verdict to trust
// and no basis for reporting it.
//
// The error line is the last matching one, searched backwards.
// Logs are appended, so the most recent failure is at the end. The search walks up from
// there and stops at the first hit rather than reading forward and reporting the oldest.
//
// There is a fallback when nothing matches.
// A run that failed without printing a recognisable error still reports its last
// non-blank line. A script marked error with no explanation is worse than an imperfect
// one.
//
// The partner is discovered, not configured.
// master.conf is scanned for HOST<n> 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';
+100 -2
View File
@@ -1,4 +1,95 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Certificate status. Serves the cert panel four ways — the cached results of the last
// cert_monitor.sh run, the configured domain list, a live read of NPM's own certificate
// store, and an on-demand re-run of the monitor.
//
// OPERATIONAL MODEL
// Two independent sources of truth, deliberately kept separate. cert_monitor.sh checks the
// domains as they resolve from outside — the certificate a visitor actually receives. NPM's
// API reports what it holds internally. They disagree exactly when something is wrong: a
// renewed cert that was never reloaded, or a proxy host pointing at the wrong certificate.
// Collapsing them into one number would hide the only case worth catching.
//
// The default path serves cert_monitor.sh's cache and never checks anything itself. That
// script runs on the Sunday report schedule; the run action exists for when someone does
// not want to wait for it.
//
// DESIGN PRINCIPLES
// Thresholds come from master.conf, with shipped defaults.
// CERT_WARN_DAYS and CERT_CRIT_DAYS are read per request rather than baked in, so the
// page and the script that notifies agree on what "critical" means.
//
// No cache is answered with the domain list, not with an error.
// Before the first run there is nothing to report, so the configured domains are
// returned marked UNKN. The panel shows what is being watched rather than an empty
// state that looks like nothing is configured.
//
// Already expired sorts with critical, not past it.
// A negative day count is CRIT rather than a separate state, and the sort puts the
// smallest number first — so the most urgent certificate is always at the top.
//
// The run action returns the script's output alongside the fresh data.
// Capped at 30 lines. When a check fails, the reason is in that output and nowhere in
// the structured result.
//
// OPERATIONAL SAFEGUARDS
// The action is chosen from a fixed set, and everything else falls to the cached read.
// No part of the request names a script, a domain, or a file. The one script this
// endpoint can run is a hardcoded path.
//
// The script run is externally time-boxed.
// `timeout 180` wraps it — set_time_limit() does not cover exec() time on Linux, so
// PHP's own limit cannot end a check hung on an unresponsive domain. Exit 124 is
// reported as a timeout rather than a generic failure.
//
// A missing script is reported, not executed.
// file_exists() precedes exec(), so a partial deploy returns a named error rather than
// a shell failure surfacing as an empty result.
//
// Every threshold read has a default.
// ?: 30 and ?: 7 on the regex captures, so a master.conf mid-edit or missing the keys
// still yields coherent statuses instead of comparing every certificate against zero
// and reporting the whole estate critical.
//
// Missing or malformed expiry data is UNKN, never OK.
// A certificate whose expires_on is absent or unparseable yields a null day count and
// an explicit unknown status. Defaulting it to OK would silently drop a certificate out
// of monitoring — the one failure this panel exists to prevent.
//
// Every cache read degrades.
// json_decode with ?: fallbacks throughout, so a truncated cache file yields an empty
// result rather than a fatal.
//
// The NPM path reports its own transport failures.
// vv_npm_req() returns an _err key rather than throwing, and that is checked before the
// response is treated as a certificate list — so an unreachable NPM is reported as such
// instead of rendering as zero certificates.
//
// Accepted: the run action is reachable over GET.
// It re-runs a read-only monitor and writes only its own cache, so repeating it is
// harmless. Guarded by the Unraid WebGUI session; see the CSRF note in README-unraid.md.
//
// REQUEST
// GET cached status, or the configured domains when no cache exists
// GET|POST ?action=npm live certificate list from NPM's API
// GET|POST ?action=domains configured domains and thresholds, no checks run
// GET|POST ?action=run re-run cert_monitor.sh, then return its fresh cache
//
// RESPONSE
// default {"ok":true,"checked_at","host","warn_days","crit_days","domains":[…]}
// npm {"ok":true,"certs":[{id,nice_name,domain_names,provider,expires,days,status}],
// "warn_days","crit_days"}
// domains {"ok":true,"host_id","domains":[…],"warn_days","crit_days"}
// run {"ok":true,"data":…,"output":[…],"rc":int}
// {"ok":false,"error":"NPM request failed"|"cert_monitor.sh not found"|"… timed out …"}
//
// DEPENDS ON
// include/config.php vv_detect_host(), vv_read_conf_raw(), STATE_DIR, SCRIPTS_DIR
// include/auth.php vv_npm_req()
// Monitors/cert_monitor.sh writes STATE_DIR/cert_status.json
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/auth.php';
@@ -81,8 +172,15 @@ if ($action === 'run') {
echo json_encode(['ok' => 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)
+79
View File
@@ -1,4 +1,83 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Setup checklist. Evaluates what this host still needs before it is fully configured —
// identity, conf, API and SSH keys, service discovery, media keys, and partnership state —
// and names the action that fixes each gap.
//
// OPERATIONAL MODEL
// Derived, never stored. Every item is computed from the conf files and the setup state on
// each request, so the checklist cannot go stale or disagree with reality. There is no
// "completed" flag anyone could tick.
//
// The item list is not fixed. Emby and Jellyfin checks appear only once their container is
// configured; the master.conf pull appears only on a partner host; partnership appears only
// once a HOST2 is named. A checklist that listed everything would tell a single-host
// install it was permanently incomplete.
//
// Items carry an action name, not a URL. The page maps create_key, ssh_setup, run_populate,
// pull_master and onboard onto the right endpoint or instruction — so this file describes
// what is wrong, and the UI owns how to fix it.
//
// DESIGN PRINCIPLES
// Each check answers the narrowest useful question.
// The SSH item tests that the configured path exists on disk, not merely that a path is
// set — a path set to a file that was never generated is the actual failure mode, and
// the detail text distinguishes the two cases.
//
// Auto-populate is satisfied by any one service.
// The check passes on the first arr key or media container found. Requiring all of them
// would leave the item permanently red on a host that legitimately runs only some.
//
// Partnership reports its phases separately.
// Phase 1 done with phase 2 outstanding is its own message, because the fix is to go
// finish onboarding on the other host — not to re-run anything here.
//
// State keys are read case-insensitively.
// Both HOST2_PHASE1_DONE and host2_phase1_done are accepted, because the state file has
// been written by both the shell layer and the PHP layer over its life.
//
// complete is derived from the items, not tracked.
// A single strict in_array(false, …) over the item results, so the summary can never
// disagree with the list it summarises.
//
// OPERATIONAL SAFEGUARDS
// Read-only. This endpoint diagnoses and never fixes — every remedy is a separate,
// explicitly invoked action. That separation is what makes it safe to poll.
//
// No input at all. There are no parameters, so there is nothing to validate and no way to
// ask about a host other than this one.
//
// An unidentified host degrades to a report rather than an error.
// vv_detect_host() returning 'unknown' is handled at every use — the host conf is not
// read, and the identity and host_conf items say so explicitly. That is the exact state
// a fresh install is in, and it is the checklist's job to describe it.
//
// Every conf read is defaulted.
// vv_read_conf_raw() returns empty for a missing file and every scalar lookup is
// trimmed with a ?? fallback, so a partial or absent conf yields items marked not-ok
// rather than a fatal that would blank the whole panel.
//
// Key presence is reported, key values never are.
// The API, SSH, Emby and Jellyfin items report only whether a value is set — and for
// SSH, the basename of the path. No credential is returned.
//
// Missing is reported as missing, never as fine.
// Every item defaults to ok:false and is only set true by a positive test. A check that
// cannot run reports the gap it could not rule out.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"ok":true,"complete":bool,"host_id":"host<n>|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';
+52
View File
@@ -1,4 +1,56 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Stale lock removal. Deletes one lock file from /tmp/unraid_locks so a script whose
// previous run died without releasing its lock can be started again from the UI.
//
// OPERATIONAL MODEL
// The manual escape hatch for the one failure the lock discipline cannot resolve on its
// own. Scripts take a lock to prevent concurrent runs; a run killed by OOM, a reboot mid-
// job, or kill -9 leaves the lock behind and every subsequent run refuses to start. This
// endpoint is how a person clears that, and it is deliberately the only way — nothing
// automatically reaps locks, because "the lock is old" and "the job is still running" are
// not distinguishable from the file alone.
//
// DESIGN PRINCIPLES
// Loads nothing.
// This is the only endpoint in the api layer that requires no include. The operation is
// one unlink in one fixed directory; pulling in the config layer to perform it would
// add failure modes to something that must work when other things are broken.
//
// Deletes exactly one lock, never sweeps.
// There is no clear-all. Releasing every lock at once would restart the concurrent runs
// the locks exist to prevent.
//
// OPERATIONAL SAFEGUARDS
// POST only. A GET cannot delete a lock, so a stray link, prefetch, or browser history
// entry cannot release one.
//
// The filename is stripped and then pattern-matched, in that order.
// basename() removes any directory component, and the surviving name must match
// ^[a-zA-Z0-9_\-]+\.lock$ — no dots beyond the extension, no slashes, no traversal. The
// two together mean the composed path cannot leave /tmp/unraid_locks, and the .lock
// suffix means nothing but a lock file is a candidate in the first place.
//
// Deleting an absent lock is success, not an error.
// file_exists() is checked and @unlink() suppressed, so two clicks, or a race with the
// script releasing its own lock, both end with the lock gone and ok:true. The caller
// wants the lock absent; it does not care who removed it.
//
// Scope is one fixed directory, hardcoded here.
// /tmp/unraid_locks is a literal, not a config value, so no conf edit or empty variable
// can redirect this delete somewhere else.
//
// REQUEST
// POST file=<name>.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']);
+60
View File
@@ -1,4 +1,64 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Orchestrator membership toggle. Comments or uncomments one script's entry inside the
// *_SCRIPTS arrays in master.conf, so the scheduler page can take a script out of an
// orchestrator's run list without deleting it.
//
// OPERATIONAL MODEL
// This is not the same switch as the scheduler's enable/disable. Scheduler state controls
// whether a *cron entry* fires; this controls whether an orchestrator *calls a child
// script* during its own run. A script can be disabled here and still run, if another
// orchestrator lists it — unraid_api_key_renew.sh is deliberately in two arrays.
//
// The edit is a comment marker, not a deletion. The line stays in master.conf with its
// arguments and its position intact, so re-enabling restores exactly what was there before
// and a diff shows an intent change rather than a removal.
//
// DESIGN PRINCIPLES
// Only the first match is toggled.
// vv_conf_toggle_script() breaks after the first array entry it matches. A script
// listed in two orchestrators is not silently changed in both by one click.
//
// No match is success, not failure.
// A script that is not in any array has nothing to toggle and the conf is already in
// the requested state. Returning an error there would make the UI report a problem
// where none exists.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before any parameter is read.
//
// The id is pattern-matched and traversal-checked separately.
// ^[a-zA-Z0-9_./\-]+\.sh$ permits the Category/script.sh form the arrays actually use,
// so the slash cannot simply be banned — str_contains($id, '..') is therefore a second,
// explicit check rather than something folded into the pattern.
//
// The id is never used as a path.
// It reaches vv_conf_toggle_script() only as a preg_quote()d needle matched against
// existing lines in master.conf. Nothing is opened, executed, or created from it, so a
// value that slipped past validation still has no file to reach.
//
// The scope of the edit is bounded to array bodies.
// The library tracks whether it is inside a `*_SCRIPTS=(` block and skips every line
// outside one, so a matching string in a comment or an unrelated variable cannot be
// rewritten.
//
// The write is atomic.
// vv_conf_toggle_script() writes through vv_write_conf_raw() (tmp + rename). Every
// script sources master.conf; a truncated write here would be a system-wide outage
// rather than a lost toggle.
//
// REQUEST
// POST id=<Category/script.sh> 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';
+87
View File
@@ -1,4 +1,91 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Structured conf editing. GET returns the configuration fields relevant to one script,
// grouped and typed for rendering as a form; POST writes a set of field changes back to
// whichever conf files they belong to.
//
// OPERATIONAL MODEL
// The counterpart to rawconf.php. That one hands over a text file; this one presents the
// subset of keys a given script actually reads, with their types and current values, so a
// threshold can be changed without opening master.conf and finding it.
//
// Changes are keyed by file, not by form. Each change carries its own target file, because
// one script's settings routinely span master.conf and a host conf — a threshold is shared,
// the credential it applies to is not. A single save therefore writes to several files, and
// reports per-file results.
//
// A master.conf write is followed by a push to every partner, matching rawconf.php. The
// two endpoints edit the same file and must distribute it the same way.
//
// DESIGN PRINCIPLES
// Which fields belong to a script is derived, not configured.
// vv_conf_fields_for_script() resolves them from the script itself, so a new conf
// variable appears in the form as soon as the script reads it — there is no second list
// to keep in step.
//
// Validation is total before any write begins.
// Every change in the set is checked first, and the endpoint exits on the first bad
// one. A partially applied save across multiple conf files is far harder to reason
// about than a rejected one.
//
// ok reflects the whole set.
// ok is false if any file failed, while files carries the per-file detail. A caller
// that checks only ok is correct but coarse; one that wants to know which file failed
// can see it.
//
// OPERATIONAL SAFEGUARDS
// Every change names its own file, and every one is checked against the allowlist.
// in_array(..., true) against vv_get_conf_files() per change — not once for the batch.
// The allowlist is what enforces sparse checkout: HOST2 cannot be handed a change
// targeting host1.conf, because host1.conf is not in its list.
//
// Keys must look like shell variables.
// ^[A-Z_][A-Z0-9_]*$ — no lowercase, no punctuation, no leading digit. The key is used
// to locate and rewrite an assignment in a bash file, so anything that could not be a
// variable name has no legitimate target.
//
// Malformed payloads are refused, not coerced.
// is_array() on the decoded changes, and an explicit missing-id check. A truncated body
// becomes a rejection rather than an empty change set that would report success while
// writing nothing.
//
// The GET path blocks traversal on the id.
// An explicit '..' check before the id reaches the field resolver.
//
// Writes are atomic per file — vv_conf_write_changes() goes through the same tmp + rename
// path as every other conf write, so a script sourcing a conf mid-save sees the old file or
// the new one.
//
// The push only happens after master.conf is confirmed written.
// Guarded on the per-file result being exactly true, so a failed edit cannot distribute
// a stale or partly-written master.conf to partners.
//
// Unknown methods are refused explicitly, so a PUT or DELETE cannot fall through the two
// handled blocks into an empty 200.
//
// Narrower than rawconf.php, but not narrow enough to skip the syntax gate.
// Every write rewrites the value of an existing, named key — no key can be added,
// deleted, or moved. But only the scalar path escapes its value: the array,
// array_single and assoc_array paths splice the caller's text into the file verbatim,
// and the type is chosen by the request. vv_conf_write_changes() therefore runs the
// same `bash -n` check config.php and rawconf.php apply, and a file that does not parse
// is reported as a failed write with the original left intact.
//
// REQUEST
// GET ?id=<Category/name.sh>
// POST id=<Category/name.sh> changes=<JSON array of {file, key, value}>
//
// RESPONSE
// GET {"ok":true,"groups":[…]}
// POST {"ok":bool,"files":{"<conf>":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';
+80
View File
@@ -1,7 +1,69 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Conf save endpoint. Writes the full text of one configuration file back to
// Configurations/, for the settings tab's raw editor.
//
// OPERATIONAL MODEL
// Whole-file replacement, not a patch. The editor sends back everything it was given, so a
// save is a straight overwrite. There is no merge, no per-key update, and no attempt to
// reconcile with a concurrent edit — last writer wins, which is correct for a single-admin
// plugin and far more predictable than a partial merge of a bash file.
//
// DESIGN PRINCIPLES
// Which files exist is decided by the host, never by the request.
// vv_get_conf_files() returns the allowlist for this host — master.conf plus its own
// host conf on HOST1, its own conf alone elsewhere. The filename is checked for exact
// membership in that list. This is what keeps sparse checkout honest: HOST2 cannot be
// asked to write host1.conf, because host1.conf is not in its list.
//
// Raw is the point.
// The structured editor is confform.php. This endpoint exists for the cases that one
// cannot express — new keys, comments, array literals, bulk edits — so it deliberately
// does not parse, reformat, or normalise what it is given.
//
// OPERATIONAL SAFEGUARDS
// POST only. Refused before the allowlist is even consulted, so a link or a prefetch can
// never reach the write path.
//
// Exact allowlist membership, not a pattern.
// in_array() against vv_get_conf_files() — not a regex, not a prefix test, not
// basename(). A filename that is not literally one of the permitted strings is rejected,
// which makes traversal and absolute paths unreachable rather than merely filtered.
//
// The content is syntax-checked before it can replace a working file.
// Conf files are sourced by every script in the system. A stray quote saved here would
// break load_config.sh, and with it every orchestrator, watchdog and fallback path — on
// a machine whose whole purpose is running unattended. `bash -n` on a private temp copy
// is the difference between a rejected save and a silent, total outage, so a file that
// does not parse is refused and the previous version is left untouched.
//
// The temp copy is created with tempnam() and always removed.
// The candidate is never written next to the real conf and never under a predictable
// name, so a failed validation cannot leave a stray file for a script to source.
//
// The real write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf
// during the save reads either the old file or the new one, never a half-written one.
//
// REQUEST
// POST file=<allowed conf name> content=<full file text>
//
// 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']);
+64
View File
@@ -1,4 +1,68 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Unraid API key provisioning. Runs unraid_api_key_renew.sh for this host and reports a
// masked preview of the key that ended up in the conf — the "create key" action on the
// setup and partnership tabs.
//
// OPERATIONAL MODEL
// Provisioning for the local host only. There is no host parameter: the target is whatever
// vv_detect_host() resolves to, because a key can only be created on the machine that owns
// the API it authenticates against. A partner's key is created on the partner.
//
// Repair, not just creation. The underlying script is the same one the array-start and
// 15-minute watchdog runs call, because the registry it writes can be cleared by a service
// restart rather than only by a reboot. Running it against a host that already has a valid
// key is a no-op that re-registers, which is why this endpoint is safe to press twice.
//
// DESIGN PRINCIPLES
// Idempotent by delegation.
// This file contains no key logic at all. Whether a key needs creating, renewing, or
// leaving alone is decided in one place — the shell script — so the UI path and the
// scheduled path can never diverge on that judgement.
//
// The key is never returned.
// Only a masked preview (first 8, last 4) leaves the server. The full value lives in
// host*.conf, which is the only place anything reads it from. There is no workflow that
// needs the key in a browser, so it is not sent to one.
//
// OPERATIONAL SAFEGUARDS
// The host slot is validated before it is used to compose a conf filename.
// vv_detect_host() can return 'unknown' when hostname matching fails, and 'unknown.conf'
// is not a file that should be read or written. ^host\d+$ is enforced first, so a host
// this plugin cannot identify gets a clear error instead of a confusing failure deeper
// in the script.
//
// A missing script is reported, not executed.
// vv_auto_create_api_key() checks file_exists() before exec(), so a partial deploy
// returns a named error rather than a shell failure surfacing as an empty key.
//
// The script run is externally time-boxed.
// `timeout 120` wraps it inside the library — PHP's own limit does not cover exec()
// time on Linux, so a stalled unraid-api call would otherwise hold a php-fpm worker
// open indefinitely. Exit 124 is reported as a timeout, distinctly from a script error.
//
// Never cached.
// Cache-Control: no-store, no-cache. A cached provisioning response would report an old
// key preview after a genuine renewal — the one moment the preview matters.
//
// Known gap: this is a state-changing action served over GET.
// Both callers (pages/setup.php, pages/partnership.php) fetch it, and POST bodies are
// unreliable on this nginx/PHP setup. It is guarded by the Unraid WebGUI session rather
// than by method or token. Documented rather than silently accepted — see the CSRF note
// in README-unraid.md.
//
// REQUEST
// GET, no parameters (the target host is the local host, by construction)
//
// RESPONSE
// {"ok":true,"key_preview":"abcd1234...wxyz"} or "registered" when the key is not readable
// {"ok":false,"error":string} unknown host, missing script, or timeout
//
// DEPENDS ON
// include/config.php vv_detect_host(), vv_auto_create_api_key()
// System_Essentials/unraid_api_key_renew.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
+58
View File
@@ -1,4 +1,62 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Docker folder endpoint. GET returns the full container inventory with folder assignments;
// POST performs one folder operation — create, rename, delete, move a container, or sync
// the folder store against master.conf in either direction.
//
// OPERATIONAL MODEL
// Read and write share one URL, split on HTTP method. GET is the docker tab's poll and is
// always safe. POST carries an `action` naming exactly one library call. Anything that is
// neither GET nor POST is refused with 405 before a parameter is read.
//
// DESIGN PRINCIPLES
// The action list is a closed match expression, not a dispatch table.
// Six named actions map to six library functions. An unrecognised action falls to the
// default arm and returns an error — it cannot resolve to a callable, because no part
// of the request is ever used to build a function name.
//
// Grouping is metadata, never container control.
// This endpoint moves containers between folders in a JSON store. It does not start,
// stop, or recreate anything — that is docker_action.php, deliberately a separate file
// with a separate confirmation path in the UI.
//
// Validation belongs to the library.
// Folder ids and names are trimmed here and checked in include/docker.php, so the same
// rules apply whether a call arrives from this endpoint or from the conf sync.
//
// OPERATIONAL SAFEGUARDS
// Wrong method is refused with a status code, not just a body.
// 405 is set alongside the JSON error so a mistaken caller fails visibly rather than
// parsing an error object as data.
//
// Every parameter is optional and defaults to empty.
// ?? '' on all four inputs means a malformed POST reaches the library as blank strings
// and is rejected there, rather than raising an undefined-index warning into the JSON
// body and corrupting the response.
//
// The folder store is written atomically by the library.
// vv_dk_write_json() writes to .vv.tmp and rename()s, so a delete or move interrupted
// mid-write cannot leave a truncated store — which would scatter every container back
// to ungrouped.
//
// REQUEST
// GET full inventory, no parameters
// POST action=move_container container, folder_id
// POST action=create_folder name
// POST action=rename_folder folder_id, name
// POST action=delete_folder folder_id
// POST action=sync_conf_to_json | sync_json_to_conf no further parameters
//
// RESPONSE
// GET vv_dk_all() verbatim — containers, folders, icons, WebUI links
// POST {"ok":bool,"error":string|null} as returned by the invoked library call
//
// DEPENDS ON
// include/docker.php vv_dk_all(), vv_dk_move_container(), vv_dk_create_folder(),
// vv_dk_rename_folder(), vv_dk_delete_folder(),
// vv_dk_sync_conf_to_json(), vv_dk_sync_json_to_conf()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/docker.php';
+88 -1
View File
@@ -1,4 +1,91 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Container control. Start, stop, restart, tail logs, and pull-and-rebuild one container —
// the action buttons on the docker tab.
//
// OPERATIONAL MODEL
// Four of the five actions are synchronous and answer within the request. pull_rebuild is
// not: a pull can take minutes, so it spawns docker_pull_worker.php detached, returns a job
// id immediately, and the page polls job_status until the worker writes a terminal state.
// The job file in /tmp is the only channel between the two.
//
// Deliberately separate from docker.php. That endpoint moves containers between folders —
// metadata only. This one starts and stops them. Keeping the destructive verbs in their own
// file is what lets the UI put a confirmation in front of exactly these and not the others.
//
// Loads no library at all. Every operation here is a docker CLI call, and pulling in the
// config layer would add failure modes to the endpoint most likely to be used while
// something else is broken.
//
// DESIGN PRINCIPLES
// Prefers Unraid's own rebuild helper over stop/start.
// restart and pull_rebuild both try rebuild_container first, because recreating a
// container correctly means reapplying its full template — ports, mounts, variables. The
// stop/start fallback exists for when that helper is absent and is explicitly the lesser
// option.
//
// The image is resolved from the container, never supplied.
// pull_rebuild reads both the current image id and the configured image reference via
// docker inspect. The request names a container; it cannot name what to pull into it.
//
// Job ids are random, not sequential.
// bin2hex(random_bytes(8)) — a job's status is readable by anyone who can guess its id,
// so the id is not guessable.
//
// Every action reports docker's own output.
// Combined stdout and stderr are returned rather than a generic failure message. When a
// container will not start, the reason is in that text and nowhere else.
//
// OPERATIONAL SAFEGUARDS
// The container name is pattern-matched and then confirmed to exist.
// ^[a-zA-Z0-9_.-]+$ excludes every shell metacharacter, and a `docker ps -a` lookup with
// an anchored filter must return that exact name before any container-scoped action
// runs. The existence check is what stops a valid-looking name from reaching an action
// at all — and it is anchored (^name$) so a prefix cannot select a different container.
//
// Every value interpolated into a shell command is escaped.
// Container name, image, job file, worker path and the rebuild helper all pass through
// escapeshellarg(), on top of the pattern check rather than instead of it.
//
// job_status is reachable without a container name, and is validated separately.
// It returns before the name check, because the container it refers to may have been
// recreated by then. Its id must match ^[0-9a-f]+$, so it cannot escape the job
// directory or name a file outside it.
//
// An unknown job id reports pending, not missing.
// The worker writes its first state after the request returns, so a poll that arrives in
// between must not be told the job does not exist.
//
// The job directory is private and created on demand.
// mkdir 0700 under /tmp — a hardcoded literal, not a config value, so no conf edit can
// redirect these writes. /tmp is tmpfs, so abandoned jobs clear on reboot.
//
// pull_rebuild refuses to start without an image.
// An empty docker inspect result aborts before the worker is spawned, so a container
// whose image cannot be determined is never stopped in pursuit of an update.
//
// Unknown actions fall through to an explicit error, so a typo cannot reach a container.
//
// REQUEST
// POST action=start|stop|restart name=<container>
// POST action=logs name=<container> last 200 lines, timestamped
// POST action=pull_rebuild name=<container> spawns the worker, returns job_id
// POST action=job_status job_id=<hex> poll a pull_rebuild job
//
// RESPONSE
// {"ok":bool,"output":"<docker 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);
+91 -4
View File
@@ -1,7 +1,94 @@
<?php
// Background worker: docker pull → compare image ID → rebuild if updated.
// Called via: php docker_pull_worker.php <name> <jobFile> <oldId> <image> <rebuild>
[$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 <name> <jobFile> <oldId> <image> <rebuild>
//
// 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);
+73 -2
View File
@@ -1,4 +1,69 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Dry-run launcher. Starts one script through run_job.sh with --dry-run, so the scheduler
// page can show what a job would do without letting it do any of it.
//
// OPERATIONAL MODEL
// Identical to run.php but for two differences: --dry-run is added, and there is no
// already-running guard. The missing guard is deliberate — a dry run changes nothing, so
// there is no reason to refuse one while a real run is in progress, and the two write to
// the same log where their output is distinguishable by the dry-run banner.
//
// Fire and forget. The child is nohup'd and detached, and the response returns immediately
// with ok:true meaning "launched", not "finished". Progress is followed through log.php.
//
// DESIGN PRINCIPLES
// Never executes the target script directly.
// Everything goes through run_job.sh, so a dry run gets the same locking, logging, stat
// file and exit handling as a scheduled run. A second execution path would be a second
// set of bugs.
//
// --dry-run is added here, honoured there.
// This endpoint guarantees the flag is passed; whether a given script actually respects
// it is that script's contract. The scripts that accept the flag are the ones the UI
// offers the button for.
//
// OPERATIONAL SAFEGUARDS
// The job id is validated and then confirmed to exist.
// ^[a-zA-Z0-9_./\-]+\.sh$ plus an explicit '..' check — the slash must be permitted for
// Category/name.sh ids, so traversal is caught by its own test rather than by the
// character class. file_exists() then confirms the resolved path is a real script, so a
// well-formed id for a file that is not there fails before anything is spawned.
//
// 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() even though
// none of them can currently carry a metacharacter. 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 dry run cannot consume the
// request's stdin or discard the history of previous runs.
//
// REQUEST
// POST id=<Category/name.sh> [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 </dev/null &');
echo json_encode(['ok' => true]);
+37
View File
@@ -1,4 +1,41 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Fallback data endpoint. One JSON document describing every node's fallback picture —
// state, tier activation, handback strikes, covered container status — for the fallback
// tab's 30s poll.
//
// DESIGN PRINCIPLES
// Thin transport. Every judgement about what a state file means lives in
// include/fallback.php. This file exists to give the browser a URL.
//
// No parameters. There is exactly one answer to "what is the fallback state", so the
// endpoint takes no input and has no branch that can be asked for the wrong thing.
//
// OPERATIONAL SAFEGUARDS
// Read-only by construction.
// There is no code path here that writes a state file, starts a container, or forces a
// tier. Failover and handback are driven by fallback.sh reacting to real reachability;
// a browser request is exactly the wrong way to enter either state.
//
// Unknown is passed through, never smoothed into NORMAL.
// vv_fb_all() reports a missing or unparseable state file as empty rather than healthy.
// This endpoint encodes that verbatim instead of substituting a default, because
// claiming NORMAL for a fallback process that is not running is the worst available lie.
//
// A dark partner degrades the payload, never fails it.
// Remote SSH failures inside vv_fb_all() return empty, so the node this page exists to
// diagnose cannot take the page down with it.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// vv_fb_all() verbatim — per-node state, tiers, strikes and covered container status
//
// DEPENDS ON
// include/fallback.php vv_fb_all()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/fallback.php';
echo json_encode(vv_fb_all());
+69
View File
@@ -1,4 +1,73 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Rsync tier toggle. Flips one *_RSYNC_ENABLED boolean in master.conf and propagates the
// changed file to every partner host — the enable switches on the rsync tab.
//
// OPERATIONAL MODEL
// master.conf is shared, not per-host. A tier flag has to mean the same thing on both sides
// of the partnership or a sync will run from one end and not the other, so the write is
// always followed by a push. The push is a no-op on a non-owner: vv_push_master_conf()
// returns empty when this host has no SSH key, so a partner flipping a flag locally does
// not overwrite the owner's file.
//
// Two flags exist at different levels — RSYNC_ENABLED is the global gate and the tier flags
// (CRITICAL_, INTERMEDIATE_, DAILY_, WEEKLY_, FALLBACK_) sit under it. This endpoint treats
// them identically; the precedence lives in the shell layer.
//
// DESIGN PRINCIPLES
// Toggles existing flags, never creates them.
// vv_conf_flag_set() rewrites a line that already matches NAME=true|false and returns
// false when nothing matched. A typo'd flag name fails loudly rather than appending a
// key no script reads.
//
// Push results are reported, not swallowed.
// The per-host push outcome is returned in the response so the page can show that a
// partner did not receive the change. A flag that is set on one host and not the other
// is exactly the state that produces a one-sided sync.
//
// Setup state is pushed alongside the conf.
// vv_push_setup_state() runs after the conf push so the partner's onboarding view
// reflects the same reality — the two are written together because they are read
// together.
//
// 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 be used to flip an unrelated boolean
// in master.conf. Every other conf edit goes through confform.php or config.php, which
// have their own rules; a general-purpose flag setter would bypass all of them.
//
// Anything other than the literal "1" is treated as false.
// ($_POST['enabled'] ?? '0') === '1' — strict comparison against one value, so a
// missing, malformed, or unexpected parameter disables rather than enables. Failing
// toward off is the safe direction for a flag that starts data movement.
//
// The conf write is atomic.
// vv_conf_flag_set() writes through vv_write_conf_raw() (tmp + rename). Every script
// sources master.conf, so a truncated write would be a system-wide outage.
//
// The push only happens after a confirmed local write.
// Guarded on $ok, so a failed edit cannot distribute a stale or partly-written conf to
// partners.
//
// Related risk, not guarded here: turning RSYNC_ENABLED on for HOST2 onboarding requires
// re-reading the --merge-run / --delete interlock in Rsync/rsync.sh first. That is a
// property of the sync, not of this switch, and this endpoint does not enforce it.
//
// REQUEST
// POST name=<TIER>_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';
+89 -3
View File
@@ -1,7 +1,93 @@
<?php
// Import Script — lets the Scheduler page's "+ Import Script" browser move an existing
// .sh file from anywhere on the server into CUSTOM_SCRIPTS_DIR. This is a MOVE: the
// source is deleted once the copy is verified, so no stale duplicate is left behind.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Script import. Browses the filesystem for .sh files and moves a chosen one into
// CUSTOM_SCRIPTS_DIR, so an existing script can be brought under Varaverk's scheduler
// without retyping it.
//
// OPERATIONAL MODEL
// Two actions on one URL: a GET browser and a POST import. The browser is rooted at / and
// walks one directory at a time, because the scripts people want to import live wherever
// they happened to put them — most often the User Scripts plugin's own folders.
//
// This is a move, not a copy. The source is deleted once the destination is verified, so
// there is exactly one copy afterwards and no chance of editing the wrong one. That is also
// why the verification is so deliberate: a move that half-succeeds destroys the only copy.
//
// DESIGN PRINCIPLES
// Copy, verify, then delete — in that order, always.
// copy() first, then a size comparison and a SHA-256 of both files, and only then the
// unlink. Source and destination are routinely on different filesystems, where rename()
// is not atomic and a partial write is a real outcome rather than a theoretical one.
//
// A failed verification leaves the source untouched.
// The destination is removed and the source is left exactly where it was. Between
// losing the import and losing the script, the import is the acceptable loss.
//
// An undeletable source is a warning, not a failure.
// If the copy verified but the original could not be removed — read-only mount,
// permissions — the import is reported successful with a warning naming the file to
// clean up. The script works from its new home either way, and failing the whole
// operation would leave the user with two copies and an error message.
//
// Directory listings are capped and sorted.
// 300 entries each for directories and .sh files. A browser rooted at / will eventually
// be pointed at something enormous.
//
// OPERATIONAL SAFEGUARDS
// Refuses to import from inside the Varaverk repo.
// Both paths are resolved with realpath() and compared by prefix. Importing a tracked
// file would delete it out from under git with no commit recording it — the next pull
// would either restore it as a phantom or report a deletion nobody made. This check is
// the reason realpath() is used rather than the submitted string: a symlink into the
// repo would otherwise slip past a textual comparison.
//
// Refuses a source already inside CUSTOM_SCRIPTS_DIR.
// Also compared after realpath(). Without it, the move would copy a file onto itself
// and then delete it.
//
// Refuses to overwrite an existing custom script.
// file_exists() on the destination aborts with the conflicting name. A silent overwrite
// here would destroy a script that may already be scheduled and running.
//
// Both paths are validated as absolute with no traversal.
// ^/[^\0]*$ for the browse path and ^/[^\0]*\.sh$ for the import, plus an explicit '..'
// check on each. Null bytes are excluded by the character class, which matters because
// these strings reach both the filesystem and a shell.
//
// Both find invocations escape their argument, and neither takes anything else from the
// request — depth, type and name filters are all literals.
//
// The destination is made executable before it is reported.
// chmod 0755 after verification, so a freshly imported script is immediately runnable
// rather than failing the first time it is scheduled.
//
// Existence is confirmed before work begins — is_dir() for browse, is_file() for import —
// so a bad path returns a named error rather than a warning leaking into the JSON body.
//
// Accepted exposure: the browser can list any directory on the host.
// It returns directory names and .sh filenames only — no file contents, and nothing
// outside those two types. That is the minimum a file picker rooted at / can do, and it
// is guarded by the Unraid WebGUI session; see the CSRF note in README-unraid.md.
//
// REQUEST
// GET ?action=browse&path=/absolute/dir list subdirectories and .sh files
// POST action=import path=/absolute/file.sh move it into CUSTOM_SCRIPTS_DIR
//
// RESPONSE
// browse {"ok":true,"path","dirs":[…],"files":[…],"parent":…}
// import {"ok":true,"id":"Custom/<name>.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';
+66
View File
@@ -1,4 +1,70 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Job log endpoint. Returns the tail of one job's log for the scheduler page's live output
// view, and — on POST with clear=1 — truncates it.
//
// OPERATIONAL MODEL
// Read and clear share one URL because they share one identifier and one validation path.
// The method decides which: GET always reads, POST reads unless clear is set. Splitting
// them would duplicate the id validation, which is the only part with teeth.
//
// The response carries the log's mtime as ts, so the page can tell a log that is still
// growing from one that has stopped without diffing the content it already has.
//
// DESIGN PRINCIPLES
// Tail, never the whole file.
// The last 200 lines. Orchestrator logs are appended to indefinitely and this is polled
// while a job runs, so returning the file would grow the response without bound exactly
// when the page is fetching it most often.
//
// A log that does not exist is a successful empty read.
// ok:true with empty content and ts:0. A job that has never run has no log, and that is
// a normal state on a fresh install — not a condition the page should report as an
// error.
//
// Clearing truncates, never deletes.
// file_put_contents with an empty string keeps the inode, so a running job's open file
// handle keeps writing to the same file. Unlinking it would leave the job appending to
// a file nothing can read.
//
// OPERATIONAL SAFEGUARDS
// The job id is validated identically on both paths.
// ^[a-zA-Z0-9_./\-]+\.sh$ plus an explicit '..' check, applied before the method is
// branched on, so the clear path cannot be reached with an id the read path would have
// rejected. The slash must be permitted for Category/name.sh ids, which is why
// traversal gets its own test rather than being implied by the character class.
//
// The id is never used as a path directly.
// vv_job_log_path() maps it into LOG_DIR and rewrites the .sh suffix to .log, so the
// only files this endpoint can name are job logs — the .sh requirement in the pattern
// is what makes that rewrite total.
//
// Clearing requires POST and an explicit flag.
// A GET cannot truncate a log, and a POST without clear=1 reads like any other request.
// Destroying output needs to be asked for twice, in two different ways.
//
// Clearing an absent log is success, not an error.
// file_exists() is checked first, so clearing a job that has never run reports ok
// rather than failing on a file the caller did not expect to exist anyway.
//
// The tail read degrades to empty.
// file() with a ?: [] fallback, so an unreadable or vanishing log yields an empty view
// instead of a fatal that would blank the page mid-poll.
//
// REQUEST
// GET ?id=<Category/name.sh> last 200 lines
// POST id=<…> same as GET
// POST id=<…> clear=1 truncate the log
//
// RESPONSE
// {"ok":true,"content":"…","ts":<mtime>} 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';
+137 -3
View File
@@ -1,4 +1,129 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Ad-hoc rsync between hosts. Picks a partner, browses both filesystems to choose a source
// and destination, runs a transfer in the background, streams its output, and cancels it —
// the manual sync panel on the rsync tab.
//
// OPERATIONAL MODEL
// Entirely outside the profile system. The scheduled tiers sync configured shares with
// configured profiles; this is for the one-off move that does not belong in a conf file —
// seeding a new host, recovering a share, copying something once. Nothing here is recorded,
// scheduled, or repeated.
//
// Six actions on one URL, in the order the panel uses them: hosts, browse, browse_local,
// run, poll, stop. run returns a token immediately and the transfer continues detached; poll
// reads its log until a sentinel appears; stop kills it.
//
// Progress is a log file in /tmp keyed by token, terminated by a __DONE__ sentinel the
// wrapper appends after rsync exits. That sentinel is what lets poll distinguish "still
// running" from "finished" without inspecting a process — and the pid file is the backstop
// for the case where rsync died without reaching it.
//
// Both filesystems are browsed the same way, one directory at a time. The remote side runs
// the same find over SSH that the local side runs directly, so the two panes behave
// identically.
//
// DESIGN PRINCIPLES
// Source is always local, destination always remote.
// The direction is fixed. A tool that could pull as well as push would need twice the
// path validation and would make "which side am I about to overwrite" a question the
// user has to answer correctly every time.
//
// Trailing slashes are normalised onto both paths.
// rsync's most consequential piece of syntax is whether the source ends in a slash. It
// is appended unconditionally here so the transfer always means "the contents of", never
// "the directory itself nested inside".
//
// One manual sync at a time, host-wide.
// Enforced by scanning for any live pid file, not per token. Two concurrent ad-hoc
// transfers would compete for the same bandwidth the scheduled tiers are also using.
//
// An inconclusive remote check proceeds; a definite failure stops.
// A destination that reports 'missing' is refused. An SSH check that answers neither
// 'ok' nor 'missing' — Tailscale still coming up, for instance — is allowed through,
// because rsync will fail cleanly and visibly in the log if the path really is wrong.
//
// OPERATIONAL SAFEGUARDS
// System paths are refused as both source and destination.
// An explicit blocklist — /, /proc, /sys, /dev, /run, /etc, /bin, /sbin, /usr, /lib,
// /lib64, /boot/EFI, /tmp — compared after trailing-slash normalisation so /etc and
// /etc/ are the same answer. This is the guard against a blank or truncated field
// collapsing into a path that would sync the operating system over the network.
//
// Both paths must be absolute, traversal-free, and free of shell metacharacters.
// Leading slash required, '..' rejected, and null bytes, newlines, backticks and $
// rejected outright — then escapeshellarg() on top.
//
// rsync flags cannot become shell syntax.
// Control characters are rejected and every remaining token is escaped individually.
// The previous blocklist stripped metacharacters but not newlines, and these flags are
// spliced into a `bash -c` script — a newline would have started a second command
// inside it. Escaping each token is right about every character; a blocklist has to be.
//
// The bandwidth limit is cast, not filtered.
// (int) with a max(0, …) floor, so it can only ever be a non-negative number.
//
// The remote user is reduced to a safe character set before it is used.
// preg_replace strips everything outside [a-z0-9_.-], with 'root' as the fallback when
// nothing survives.
//
// The local source is confirmed to exist before anything is launched.
//
// Tokens are random and fixed-length.
// bin2hex(random_bytes(8)) produces the token; poll and stop both reduce the submitted
// value to hex and require exactly 16 characters, so neither can name a file outside
// the /tmp/vv_ms_ namespace. A transfer's log is readable by anyone who can guess its
// token, so the token is not guessable.
//
// Stop escalates and then cleans up.
// SIGTERM, 400ms, SIGKILL — rsync should be given the chance to finish its current file
// and close its connection. The cancellation is then written into the log with the
// sentinel, so a poll already in flight terminates cleanly instead of hanging.
//
// Poll detects a crashed transfer as well as a finished one.
// A missing sentinel with a dead pid is reported done with an explicit note, so a
// transfer killed by OOM or a reboot does not leave the panel polling forever.
//
// SSH is non-interactive and time-boxed.
// BatchMode=yes and ConnectTimeout=10 on the transfer, 8s and 4s on the browse and ping
// calls — nothing here can sit waiting for a password or a dead host.
//
// Log files are removed on the terminal poll, so a completed transfer does not leave its
// output in /tmp indefinitely. /tmp is tmpfs, so an abandoned one clears at reboot anyway.
//
// Accepted exposure: browse can list any directory on either host.
// Directory names only — no file contents, no file names. Guarded by the Unraid WebGUI
// session; see the CSRF note in README-unraid.md.
//
// REQUEST
// GET ?action=hosts partners, Tailscale state, key presence
// GET ?action=browse&host=<slot>&path=/dir remote directory listing over SSH
// GET ?action=browse_local&path=/dir local directory listing
// POST action=run local=/src host=<slot> remote_path=/dst
// [user=root] [bw_limit=<KB/s>] [use_key=0|1] [flags=<rsync flags>]
// GET ?action=poll&token=<hex16> output so far, and whether it finished
// POST action=stop token=<hex16> 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":"<hex16>"}
// 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_<token>.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, '/') . '/');
+34
View File
@@ -1,4 +1,38 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Active media sessions endpoint. Normalised now-playing across every Emby, Jellyfin and
// Plex instance configured for this host, for the monitor page's session panel.
//
// DESIGN PRINCIPLES
// Thin transport. Discovery, per-server API dialects and normalisation all live in
// include/media.php; this file only sets the content type and encodes the result.
//
// No parameters. Which servers to ask is derived from conf, not from the request, so the
// browser cannot point this endpoint at an arbitrary URL.
//
// OPERATIONAL SAFEGUARDS
// Bounded by the library's 3s per-request timeout.
// A wedged media server cannot hold this endpoint open, because every fetch inside
// vv_media_sessions() carries a stream-context timeout. This is the only thing between
// a hung Emby and a poll that never returns.
//
// Failure is an empty list, not an error.
// Unreachable servers, non-JSON bodies and unexpected shapes all resolve to [] inside
// the library. The panel renders empty and the rest of the monitor page is unaffected.
//
// Read-only. Sessions are observed. Nothing here stops a stream, forces a transcode, or
// messages a client.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// vv_media_sessions() verbatim — a flat list of normalised sessions across all servers
//
// DEPENDS ON
// include/media.php vv_media_sessions()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/media.php';
+77
View File
@@ -1,4 +1,81 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Full monitor payload. Every metric the monitor page renders — system, CPU, memory,
// network, GPUs, disks and pools, array, parity, UPS, containers, VMs, transcodes, watchdog
// and rsync summaries, and remote node roll-ups — in one document.
//
// OPERATIONAL MODEL
// Deliberately one large response rather than many small ones. Most of these metrics share
// an underlying source — the Unraid API, /proc, the same emhttp ini files — so collecting
// them together means one pass over each source instead of one per endpoint. The page
// renders from a single consistent snapshot; twenty parallel fetches would render from
// twenty slightly different moments.
//
// Served from a 300s cache written by Tools/api_cache_writer.sh, which runs every minute.
// The page therefore almost never pays for collection — the background writer does. ?live
// forces a fresh build for the refresh button.
//
// DESIGN PRINCIPLES
// The cache check happens before the heavy includes.
// Only include/config.php is loaded to reach vv_cache_read(). monitor.php, vms.php and
// docker_folders.php are required only after a miss, so a cache hit costs one file read
// rather than parsing three libraries.
//
// The API cache is pre-warmed once, on purpose.
// vv_api_data() is called before the payload is assembled so the API-first functions
// below it share a single GraphQL round trip instead of each making their own.
//
// Every field is a named function call, in render order.
// The payload is a flat map of key to collector. Adding a metric is adding a line, and
// nothing in the assembly depends on anything else in it — so one expensive or broken
// collector can be moved or removed without touching the others.
//
// Reports the API's own health alongside the data.
// _api_status travels with the payload, so the page can show that a section degraded to
// its local fallback rather than silently presenting less detail.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Every function here observes; none of them start, stop, or change anything.
//
// Cache miss is distinguished from empty payload.
// vv_cache_read() returns null on a miss, expiry, or unparseable file, and the check is
// an explicit !== null — so a legitimately sparse payload is served from cache instead
// of being mistaken for a miss and forced onto the expensive path on every poll.
//
// Every collector degrades to empty rather than fatal.
// The library suppresses its filesystem reads and redirects stderr on every shell call,
// so absent hardware — no GPU, no UPS, no ZFS, no VMs — yields an empty section and an
// unrendered card. On a payload this wide that property is what keeps one missing
// subsystem from blanking the entire page.
//
// Remote collection degrades per node, so one dark partner costs its own card and nothing
// else.
//
// Known cost: a cache miss on a host with an unreachable partner pays the remote SSH
// timeouts inline. That is why the background writer exists and why the cache window is
// long — the miss path is the exception, not the design.
//
// REQUEST
// GET served from the 300s cache when one is present
// GET ?live bypass the cache and collect everything fresh
//
// RESPONSE
// A flat object of the keys listed in the assembly below, plus _api_status and ts.
//
// DEPENDS ON
// include/config.php vv_cache_read()
// include/common.php raw hardware metrics — system, cpu, mem, net, gpu, disks,
// ups, parity, containers, transcodes, remote roll-ups
// (loaded transitively through include/monitor.php)
// include/monitor.php the "is anything wrong" roll-ups — vv_partner_state(),
// vv_fallback_state(), vv_watchdog_summary(),
// vv_scripts_status(), vv_rsync_status()
// include/unraid_api.php vv_api_data(), vv_api_get_status()
// include/vms.php vv_get_vms()
// include/docker_folders.php vv_get_docker_folders()
// Tools/api_cache_writer.sh writes the cache this endpoint normally serves
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
+71
View File
@@ -1,4 +1,75 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// High-frequency monitor poll. CPU, memory and network only, cheap enough to fetch every
// second — the live-updating subset of what monitor.php returns in full.
//
// OPERATIONAL MODEL
// Split from monitor.php on refresh rate. The fields here move second to second and are
// readable from /proc alone. Everything that needs `docker stats`, a GraphQL call, or an
// SSH round trip stays in the full payload, because those cannot be sampled at this rate.
//
// The expensive memory breakdown is borrowed rather than recomputed. Docker and VM memory,
// top processes and swap come from the last full cache — a 600s window, wide enough that
// the card stays populated even when the background writer falls behind. Stale is the right
// trade here: per-container memory does not move meaningfully between seconds, and paying
// for it would defeat the point of this endpoint.
//
// DESIGN PRINCIPLES
// Reads /proc directly, no shell.
// /proc/meminfo and the ZFS arcstats file are parsed inline. At this poll rate a single
// fork per request would dominate the cost of the endpoint.
//
// Shares the CPU baseline with everything else.
// vv_cpu_per_core() keeps its counters in a shared state file, so this endpoint, the
// header snapshot, and the full monitor payload all report the same number rather than
// three independent samples that visibly disagree.
//
// Free means available, not unused.
// MemAvailable, not MemFree — reclaimable page cache is not memory pressure, and
// reporting MemFree would show a healthy machine as nearly full.
//
// ZFS ARC is broken out of system memory.
// On a host with ZFS cache pools the ARC is most of the "used" figure and is fully
// reclaimable. Folding it into system memory would make every reading alarming and
// none of them actionable.
//
// OPERATIONAL SAFEGUARDS
// Read-only.
//
// Every source read has a fallback.
// file() with a ?: [] fallback, @file() for the arcstats path that does not exist on a
// host without ZFS, and ?? 0 on every extracted key. A missing subsystem contributes
// zero rather than a warning or a fatal — and at one request per second, a fatal here
// would be a page that never stops erroring.
//
// The borrowed fields degrade to zero independently.
// Each ?? default is applied per field, so an absent or expired full cache costs those
// four values and leaves CPU, memory and network — the reason to call this endpoint —
// intact.
//
// Swap has its own fallback path.
// When the cached figures are absent, swap is recomputed from /proc/meminfo rather than
// reported as zero, because zero swap used and unknown swap used look identical in the
// UI and mean very different things.
//
// Derived values are clamped.
// max(0, …) on used and system memory, so the subtraction cannot go negative when the
// cached docker and VM figures were sampled against a different total.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"cpu":{…},"mem":{total_kb,free_kb,used_kb,arc_kb,docker_kb,vm_kb,system_kb,
// swap_total_kb,swap_used_kb,top_procs},"net":{…},"ts":epoch}
//
// DEPENDS ON
// include/config.php vv_cache_read()
// include/common.php vv_cpu_per_core(), vv_network_stats()
// /proc/meminfo, /proc/spl/kstat/zfs/arcstats
// monitor cache written by Tools/api_cache_writer.sh — source of the borrowed fields
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/common.php';
+54 -1
View File
@@ -1,11 +1,64 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Remote node metrics endpoint. The monitor page's partner cards — CPU, memory, storage,
// uptime and container counts for every host other than this one.
//
// OPERATIONAL MODEL
// Split from monitor.php on cost, not on subject. Local metrics are cheap file reads;
// remote metrics are SSH round trips to every partner. Keeping them on separate URLs lets
// the page poll local stats often and remote stats rarely, and lets a dark partner slow
// only its own request. Served from a 1-hour cache by default; ?live bypasses it for the
// page's explicit refresh button.
//
// DESIGN PRINCIPLES
// The cache is the default and the live call is the exception.
// An hour is deliberately long. Partner hardware stats do not move fast enough to
// justify paying SSH latency on every page load, and the refresh button exists for the
// moment someone actually needs current numbers.
//
// Every payload carries its own timestamp.
// ts is written into the cached document, so the page can render the age rather than
// presenting hour-old numbers as current.
//
// The live path writes the cache too.
// A manual refresh benefits every subsequent visitor instead of being discarded.
//
// OPERATIONAL SAFEGUARDS
// Cache miss is distinguished from empty payload.
// vv_cache_read() returns null on a miss, expiry, or unparseable file, and the check is
// an explicit !== null. A legitimately empty result — the single-host case, where there
// are no remote hosts at all — is served from cache rather than being mistaken for a
// miss and forced onto the SSH path on every single poll.
//
// Read-only over SSH. The remote commands are stat collection only; nothing is started,
// stopped, or written on a partner.
//
// Unreachable partners degrade per node inside vv_remote_hosts_stats(), so one dark host
// cannot empty the other cards.
//
// HTTP caching is disabled even though the payload is cached server-side.
// The two are not the same lever. The server-side cache has an age the page can see and
// a bypass it can trigger; a browser or proxy cache has neither, and would defeat ?live
// entirely.
//
// REQUEST
// GET served from the 3600s cache when one is present
// GET ?live bypass the cache, collect fresh, and rewrite it
//
// RESPONSE
// {"remote_hosts":{…},"ts":epoch}
//
// DEPENDS ON
// include/monitor.php vv_remote_hosts_stats(), vv_cache_read(), vv_cache_write()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store');
require_once dirname(__DIR__) . '/include/monitor.php';
if (!isset($_GET['live'])) {
$cached = vv_cache_read('monitor_remote', 3600);
if ($cached) { echo json_encode($cached); exit; }
if ($cached !== null) { echo json_encode($cached); exit; }
}
$data = ['remote_hosts' => vv_remote_hosts_stats(), 'ts' => time()];
+85 -4
View File
@@ -1,6 +1,84 @@
<?php
// Move a script between *_SCRIPTS arrays in master.conf.
// POST: script (rel path), to_array (var name, or '' to remove from all arrays).
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Moves a script between orchestrators. Removes its line from whichever *_SCRIPTS array in
// master.conf currently holds it and inserts it into the named one — or into none, which
// removes it from every orchestrator.
//
// OPERATIONAL MODEL
// Line surgery on master.conf, not a re-serialisation. The file is read as lines, one line
// is relocated, and the rest is written back byte for byte. master.conf is hand-maintained
// and full of comments, grouping and deliberate ordering that no round trip through a
// parser would preserve.
//
// Two passes, in order: remove first, then insert. Doing it in one pass would need to know
// whether the target array comes before or after the source, and getting that wrong would
// either duplicate the entry or drop it.
//
// Position within the target array is the end, immediately before its closing paren.
// Orchestrators run their arrays in order, so appending is the only placement that does not
// silently reorder someone else's work.
//
// DESIGN PRINCIPLES
// Move is remove-plus-insert, and remove alone is a valid operation.
// An empty to_array performs only the removal pass, which is how a script is taken out
// of every orchestrator. That is a distinct intent from conf_toggle.php's commenting
// out — this removes the line, that disables it in place.
//
// Indentation is normalised on re-insertion.
// The moved line is rewritten as two spaces and the quoted path, so a script does not
// carry its old array's formatting into its new one.
//
// A move that finds nothing to move still succeeds.
// The removal pass is best-effort; only a missing *target* is an error. A script that
// was in no array is simply added to the one requested.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before any parameter is read.
//
// Both inputs are pattern-matched, and neither is used as a path.
// The script must match ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check, and the
// array name ^[A-Z_]+_SCRIPTS$. Both then reach the matcher only through preg_quote(),
// so they are needles matched against existing lines — nothing is opened or executed
// from either.
//
// The scope of both passes is bounded to array bodies.
// Each pass tracks whether it is inside a *_SCRIPTS=( block and ignores every line
// outside one, so a matching string in a comment or an unrelated variable is never
// moved or displaced.
//
// A missing target array aborts before the write.
// If the insert pass never finds the target, the endpoint returns an error and writes
// nothing — the removal is discarded with it. Without that check a typo'd array name
// would silently delete the script from the orchestrator it was in.
//
// master.conf is confirmed present and readable before either pass.
// Both file_exists() and the file() result are checked, so a missing or unreadable conf
// returns a named error rather than writing a file built from an empty line list.
//
// The write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s. Every script sources master.conf,
// so a truncated write here would be a system-wide outage rather than a lost move.
//
// The push happens only after a confirmed write, and its result is returned.
// A partner that did not receive the move is exactly the state that makes one host run
// a script the other does not, so the per-host outcome is reported rather than
// discarded.
//
// REQUEST
// POST script=<Category/name.sh> to_array=<NAME>_SCRIPTS
// POST script=<Category/name.sh> 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]);
+36
View File
@@ -1,4 +1,40 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Partnership data endpoint. The whole partnership picture in one document — known hosts,
// reachability, SSH trust, conf-sync state and the shared-service inventory — for the
// partnership tab's poll.
//
// DESIGN PRINCIPLES
// Thin transport. Host enumeration, SSH probing and trust evaluation live in
// include/partnership.php; this file only sets the content type and encodes.
//
// No parameters. The partnership is defined by conf, not by the request, so there is
// nothing for a caller to select and nothing to validate.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here installs a key, edits a conf, or changes a partner's state.
// Every mutating partnership operation is a separate endpoint (partnership_settings.php,
// setup.php) so that a page poll can never alter trust.
//
// An unreachable partner is a reported condition, not a failure.
// vv_partnership_all() returns each host with its own reachability result, so a dark
// node renders as unreachable while the rest of the page stays accurate. The tab has to
// stay useful precisely when a partner is down.
//
// Probes are time-boxed inside the library, so this endpoint cannot outlast them.
// vv_pt_ssh() runs with ConnectTimeout (4s default) and BatchMode=yes, so a partner
// that is powered off costs that timeout and can never sit waiting for a password.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// vv_partnership_all() verbatim — {"config":…,"nodes":…,"sync":…,"ts":epoch}
//
// DEPENDS ON
// include/partnership.php vv_partnership_all()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/partnership.php';
echo json_encode(vv_partnership_all());
+48 -2
View File
@@ -1,6 +1,52 @@
<?php
// Connectivity test — SSH echo to a partner host with round-trip latency.
// GET ?id=HOST2 (GET avoids the bodyless-POST issue on this nginx setup).
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Partner connectivity test. Runs one SSH `echo ok` against a named host slot and reports
// success plus round-trip latency — the "Test" button on the partnership tab.
//
// OPERATIONAL MODEL
// Deliberately a GET, despite being an action. php -S and this nginx setup drop POST
// bodies on Unraid PHP 8.4, so a POST would arrive with no id at all. The operation is
// safe to repeat and changes nothing, which is what makes GET acceptable here rather than
// merely convenient.
//
// DESIGN PRINCIPLES
// Tests the real path, not a substitute for it.
// The probe is SSH over the Tailscale IP with the same key every partnership operation
// uses. An ICMP ping would answer a question nobody is asking — what matters is whether
// this host can actually drive the partner.
//
// Distinguishes why it failed.
// vv_pt_ping() returns separate errors for an unknown slot, a missing key, and an
// unresolvable Tailscale name, because those need three different fixes.
//
// OPERATIONAL SAFEGUARDS
// The host id is pattern-matched before it is used.
// ^host\d+$ (case-insensitive) is enforced here, and the value is then only used as a
// conf-key lookup — it never reaches a path or a shell. The remote command is the fixed
// literal `echo ok`; nothing from the request composes it.
//
// The probe is time-boxed at 8 seconds.
// vv_pt_ping() passes an explicit timeout to vv_pt_ssh(), which also sets BatchMode=yes
// so it can never sit waiting for a password. A powered-off partner costs 8s, not a
// stuck request.
//
// Read-only on the partner. `echo ok` is the entire remote payload.
//
// Never cached.
// Cache-Control: no-store, no-cache — a connectivity test served from cache is worse
// than no test, because it reports a partner reachable after it has gone dark.
//
// REQUEST
// GET ?id=host<n> (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';
+57 -4
View File
@@ -1,8 +1,61 @@
<?php
// Partnership-related settings for this host — the Partnership section of each accessible conf.
// HOST1 (owner): master.conf PARTNERSHIP + host1.conf Partnership. Other hosts: their own.
// Uses vv_conf_all_groups() (handles master.conf's sandwiched major header) then filters
// to partnership sections. Writes go through confform.php (which pushes master.conf to partners).
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Partnership settings reader. Returns the partnership-related field groups from every conf
// file this host is allowed to see, so the partnership tab can render them as a form.
//
// OPERATIONAL MODEL
// Read-only, and deliberately so. Saves from this form go to confform.php, which already
// knows how to write a field change and push master.conf to partners. Duplicating that here
// would mean two write paths for the same keys, and only one of them would push.
//
// Which files are read is decided by the host, not the request. On HOST1 that is
// master.conf's PARTNERSHIP section plus host1.conf's; on any other host, its own conf
// alone — the same split sparse checkout enforces at git level.
//
// DESIGN PRINCIPLES
// Filters full group parsing rather than pattern-matching the file.
// vv_conf_all_groups() is used and then filtered by subsection, because it already
// handles master.conf's sandwiched major header — the structure a naive section grep
// gets wrong.
//
// Matches the section name case-insensitively and by substring.
// stripos, not equality, because the section is spelled PARTNERSHIP in master.conf and
// Partnership in the host confs. Requiring an exact match would silently return one
// file's groups and not the other's.
//
// Files with no partnership section are omitted entirely.
// The response lists only files that contributed, so the page renders one panel per
// real section rather than an empty panel per readable file.
//
// OPERATIONAL SAFEGUARDS
// Read-only. No parameters, no writes, nothing to validate — there is no input to this
// endpoint at all, which is what makes the file list unforgeable.
//
// The file list comes from vv_get_conf_files(), the same allowlist every conf endpoint uses,
// so this cannot expose a partner's conf even though it enumerates rather than being told
// what to read.
//
// Never cached.
// Cache-Control: no-store, no-cache — these are the values a user is actively editing,
// and a cached read would show them their own change reverting.
//
// Credential fields are returned as they appear in the conf.
// The partnership sections carry SSH key paths and API keys, and this endpoint returns
// them for editing. That is the same exposure the raw conf editor has, over the same
// WebGUI session; see the CSRF note in README-unraid.md.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"ok":true,"files":[{"file":"<conf>","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';
+91 -1
View File
@@ -1,5 +1,76 @@
<?php
// Raw conf read/write — respects per-host file visibility from vv_get_conf_files().
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Raw conf read and write. Serves the full text of one configuration file and saves it
// back, and — when the file is master.conf — distributes the result to every partner host.
//
// OPERATIONAL MODEL
// Read and write on one URL, split on method, sharing one allowlist. GET also returns the
// allowlist itself, so the editor can populate its file picker from the same authority that
// will later authorise the save. The two can therefore never disagree about what this host
// is permitted to edit.
//
// master.conf is shared; host*.conf is not. A master.conf save is followed by a push to
// every partner, because a threshold or toggle that differs between hosts produces
// behaviour neither side expects. A host conf is local by definition and is never pushed.
//
// DESIGN PRINCIPLES
// The allowlist encodes sparse checkout.
// vv_get_conf_files() returns master.conf plus its own host conf on HOST1, and its own
// conf alone elsewhere. That is the same split git enforces at checkout — HOST2 has no
// host1.conf to read, and this endpoint will not name one either.
//
// master.conf defaults on read, nothing defaults on write.
// GET with no file returns master.conf, because that is what the editor opens to. POST
// has no default: a save must name its target explicitly.
//
// Push results travel with the response.
// Per-host outcomes are returned rather than logged, so the page can show that a partner
// did not receive the change instead of leaving the two hosts quietly divergent.
//
// OPERATIONAL SAFEGUARDS
// Exact allowlist membership with strict comparison, on both paths.
// in_array(..., true) against vv_get_conf_files() — not a pattern, not basename(). A
// filename that is not literally one of the permitted strings is rejected, which makes
// traversal and absolute paths unreachable rather than merely filtered. The redundant
// '..' check is kept as a second, explicit statement of intent.
//
// The content is syntax-checked before it can replace a working file.
// Conf files are sourced by every script in the system, and master.conf is pushed from
// here to every partner — so a stray quote saved through this endpoint would not just
// break this host's orchestrators, watchdogs and fallback, it would distribute that
// break across the mesh. `bash -n` on a private temp copy is checked first, and a file
// that does not parse is refused with the previous version left untouched.
//
// The temp copy is created with tempnam() and always removed, so a rejected save cannot
// leave a stray file beside the real conf for a script to source.
//
// The real write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf during
// the save reads either the old file or the new one, never a half-written one.
//
// The push only happens after a confirmed write.
// Guarded on $written, so a failed save cannot distribute a stale or partly-written
// master.conf. The push is also a no-op on a host with no SSH key, which is what stops a
// partner from overwriting the owner's file.
//
// Unknown methods are refused explicitly, so a PUT or DELETE cannot fall through the two
// handled blocks into an empty 200.
//
// REQUEST
// GET ?file=<allowed conf name> defaults to master.conf
// POST file=<allowed conf name> content=<full file text>
//
// 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') {
+57 -1
View File
@@ -1,5 +1,61 @@
<?php
// Read-only endpoint: return full content of any script in SCRIPTS_DIR.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Script and document reader. Returns the full text of one .sh or .md file inside
// SCRIPTS_DIR — the source view behind the scheduler page's script viewer and the docs tab.
//
// DESIGN PRINCIPLES
// Two extensions, one endpoint.
// Scripts and their READMEs are read the same way because they are read for the same
// reason — someone wants to see what a job actually does. Splitting them would mean two
// endpoints with identical validation.
//
// Returns text, never renders it.
// Markdown arrives as source. Rendering is the browser's job, and doing it here would
// make this endpoint an HTML producer with an HTML producer's escaping problems.
//
// Relative ids only.
// The id is a path relative to SCRIPTS_DIR, so the caller never learns or supplies the
// installation root. That is also what makes the same id valid in internal and appdata
// storage modes.
//
// OPERATIONAL SAFEGUARDS
// Read-only. There is no write counterpart in this file; edits go through movescript.php
// and import_script.php, which have their own validation.
//
// Traversal is blocked before the path is composed.
// str_contains($id, '..') is checked explicitly, and the pattern
// ^[A-Za-z0-9_.\-/]+\.(sh|md)$ excludes null bytes, backslashes, spaces, and every
// shell metacharacter. The slash has to be permitted because ids are Category/name.sh,
// so the '..' check carries the traversal guarantee on its own rather than being
// implied by the character class.
//
// The extension allowlist is the real access boundary.
// Only .sh and .md can be named at all, which is what keeps Configurations/*.conf —
// the files holding every credential in the system — outside this endpoint's reach. Any
// future extension added here has to be checked against that first.
//
// A missing file is reported, not opened.
// file_exists() precedes file_get_contents(), so a bad id returns a named error rather
// than a PHP warning leaking the absolute path into the JSON body.
//
// Known limit: symlinks inside SCRIPTS_DIR are followed.
// Containment is enforced on the id, not on the resolved path, so a symlink placed
// inside the tree pointing outside it would be read. Not tightened with a realpath
// check, because the plugin's own boot model installs SCRIPTS_DIR as a symlink and a
// naive containment test would break it. The tree is git-managed; a rogue symlink in it
// is a repo compromise, which is a larger problem than this endpoint.
//
// REQUEST
// GET ?id=<Category/name.sh|Category/README-name.md>
//
// RESPONSE
// {"ok":true,"content":"<full file text>"}
// {"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';
+53
View File
@@ -1,4 +1,57 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Recent run history. The last 24 finished job runs across every orchestrator and script,
// newest first, with status and duration — the activity feed on the scheduler page.
//
// OPERATIONAL MODEL
// There is no run database. Each job writes a .json stat file beside its log, and this
// endpoint reconstructs history by walking LOG_DIR recursively and sorting what it finds by
// start time. That keeps the stat file the single source of truth for a job's outcome —
// the same file status.php reads for live state — instead of maintaining a second record
// that could disagree with it.
//
// DESIGN PRINCIPLES
// Finished runs only.
// status === 'running' is skipped, because a run in progress has no duration and
// belongs to status.php's live indicators, not to history. The two endpoints partition
// the same files rather than overlapping.
//
// Capped at 24 after sorting, not before.
// Every stat file is read and ranked before the slice, so the newest 24 are genuinely
// the newest — filesystem iteration order says nothing about run time.
//
// Duration is derived, never stored.
// end - start is computed here, so a stat file that recorded a start but never got to
// write an end still yields a usable row rather than being discarded.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here deletes a stat file, truncates a log, or re-runs a job.
//
// The whole walk is wrapped in a try/catch.
// RecursiveDirectoryIterator throws when LOG_DIR does not exist or a subdirectory is
// unreadable — on a fresh install that is the normal state, not an error. The catch
// yields an empty run list so the page renders "no runs yet" instead of a 500.
//
// Every individual file read is independently suppressed and validated.
// @file_get_contents, @json_decode, then an is_array plus required-key check. A stat
// file caught mid-write, truncated, or left over from an older schema is skipped —
// one bad file cannot cost the other 23 rows.
//
// Negative durations are clamped.
// max(0, end - start) — a stat file whose clock went backwards across an NTP step
// reports 0s rather than a negative duration the UI would have to special-case.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"ok":true,"runs":[{"id","label","status","start","dur"}, …]} newest first, max 24
//
// DEPENDS ON
// include/config.php LOG_DIR
// LOG_DIR/**/*.json stat files written by run_job.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
+93 -8
View File
@@ -1,8 +1,85 @@
<?php
// Rewrite a *_SCRIPTS array in master.conf with a new script order.
// POST: array_name (e.g. "DAILY_SCRIPTS"), scripts (JSON: [{"id":"rel/path.sh","enabled":true}, ...])
// Preserves original entry lines (including inline flags/args) where possible.
// Scripts absent from the new list are dropped; new scripts are added as fresh entries.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Rewrites one *_SCRIPTS array in master.conf with a new order and a new enabled/disabled
// state per entry — the drag-to-reorder on the scheduler page's orchestrator view.
//
// OPERATIONAL MODEL
// Order is behaviour, not presentation. An orchestrator runs its array top to bottom, so
// moving an entry changes when that script runs relative to the others — which is why this
// writes to master.conf rather than to a UI preference.
//
// The array block is located, its entries harvested, and the whole block replaced. Only the
// lines between the opening and closing parens are touched; everything else in master.conf
// is written back byte for byte, because the file is hand-maintained and full of comments
// and grouping no round trip through a parser would preserve.
//
// Disabled entries stay in the file, commented. Enabled state is expressed by the presence
// or absence of a leading '# ', the same convention conf_toggle.php uses.
//
// DESIGN PRINCIPLES
// Original entry text is preserved, not regenerated.
// Entries are harvested from the existing block keyed by script path, and reused
// verbatim when the same path appears in the new order. That is what keeps inline flags
// and arguments — "Media/cleanup.sh --deep" — through a reorder. Only a script that was
// not previously in the array is written fresh, as a bare quoted path.
//
// The opening and closing lines are preserved exactly.
// Both are carried over untouched rather than rebuilt, so a trailing comment on the
// array declaration survives.
//
// Absent means removed.
// A script in the file but not in the submitted order is dropped from the array. The
// page always submits the complete list, so absence is an instruction, not an omission.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before any parameter is read.
//
// The array name is pattern-matched and only ever used as a needle.
// ^[A-Z_]+_SCRIPTS$, then preg_quote()d and matched against existing lines. Nothing is
// opened or executed from it.
//
// Every entry is validated, and an invalid one fails the request rather than being skipped.
// ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check per entry. Skipping would be
// actively dangerous here: the array is rebuilt from the validated list alone, so a
// silently dropped entry is a script silently removed from its orchestrator. Validation
// completes before anything is written.
//
// A missing array aborts before the write.
// Both the block start and end must be found, otherwise the endpoint returns a named
// error and writes nothing. Without that, a typo'd array name would splice a block into
// an undefined position.
//
// master.conf is confirmed present and readable before either pass.
//
// The write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s. Every script sources master.conf, so
// a truncated write here would be a system-wide outage rather than a lost reorder.
//
// The push happens only after a confirmed write, and its result is returned — a partner
// that did not receive the new order runs these scripts in a different sequence.
//
// Known limit: block detection counts parens textually.
// depth is tracked with substr_count over '(' and ')', 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, and the alternative is a bash parser — but a
// future entry with a paren in its arguments would be the thing that broke this.
//
// REQUEST
// POST array_name=<NAME>_SCRIPTS
// scripts=<JSON array of {"id":"Category/name.sh","enabled":bool}>
//
// 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]);
+86
View File
@@ -1,4 +1,90 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Rsync tab data. Tier enablement, the currently running sync, last-run results, 30 days of
// bandwidth history, the per-window script and share lists, and the tuning settings — plus
// a separate action serving the live sync log.
//
// OPERATIONAL MODEL
// Two responses on one URL. The default is the tab's full state. ?action=rsync_log is the
// log viewer, split out because it polls far faster than the rest and returns a payload
// nothing else needs.
//
// The log action has two sources and prefers the live one. An active sync is identified by
// a lock file whose pid is still in /proc, and its in-progress log is read directly. With
// no active sync, the most recently modified .last.log is served instead — so the panel
// shows the run that is happening, or failing that the run that just happened, without the
// caller having to know which.
//
// Window definitions are a table, not a set of branches. Each tier names the master.conf
// script array and the host.conf shares array it draws from, so adding a tier is a row.
// monthly is deliberately half-populated — monthly_maintenance.sh does ZFS scrub and SMART
// tests and has no rsync section, so no shares variable exists for it. fallback has neither:
// it is driven entirely by fallback.sh.
//
// DESIGN PRINCIPLES
// Liveness is proven by the process table, not by the lock file's existence.
// A lock whose pid is gone is a crashed run, and treating it as active would show a
// stale log as a sync in progress forever.
//
// Toggles default to on when the key is absent.
// !== 'false' rather than === 'true', so a conf that predates a flag behaves as it did
// before the flag existed. That is the correct default for a tier that was previously
// unconditional.
//
// History is filtered by date, not by line count.
// The bandwidth log is append-only and unbounded; a 30-day cutoff keeps the response
// proportional to the window the page renders rather than to the file's age.
//
// Elapsed time comes from the lock's mtime.
// The lock is touched when the run starts, so its age is the run's age without the
// script having to report progress.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here starts, stops, or reconfigures a sync — this endpoint reports on
// rsync.sh, and the interlocks that make a sync safe live there. In particular, the
// --merge-run / --delete ordering is rsync.sh's to enforce; nothing in this payload implies
// it has been satisfied.
//
// The lock and log scan is bounded to a hardcoded directory.
// glob over /tmp/unraid_locks — a literal, not a config value.
//
// Every read degrades to empty.
// @file_get_contents, file() with ?: [] fallbacks, and file_exists() before each read.
// A lock caught mid-write or a log removed between the glob and the read yields an empty
// line list rather than a fatal.
//
// Malformed history lines are skipped, not repaired.
// A count check before the fields are used, and an isset() on the optional bytes column,
// so a truncated or older-format row is dropped instead of producing a row of nulls.
//
// Output is bounded and stripped.
// Last 200 lines, with ANSI escapes removed. rsync logs are long and coloured, and the
// escapes would otherwise reach the page as control codes.
//
// Every setting has a default.
// ?? on all four tuning values and on the bandwidth warning threshold, so a conf missing
// a key renders a usable panel rather than zeros that read as "no limit configured".
//
// The profile name is derived from the lock's own contents, with the filename as fallback,
// so a lock written by an older format still identifies its run.
//
// REQUEST
// GET full rsync tab state
// GET ?action=rsync_log live sync log, or the most recent completed one
//
// RESPONSE
// default {"enabled","windows","active","last_sync","bw_history":[…],"bw_warn_gb",
// "win_arrays":{<window>:{"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';
+81
View File
@@ -1,4 +1,85 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Rsync profile CRUD. Lists, creates, updates and deletes the named transfer profiles that
// control how a given share is synced — rsync options, bandwidth cap, retry behaviour,
// container stop/start lists and exclusions.
//
// OPERATIONAL MODEL
// A profile is not stored as a record. It is one key spread across nine parallel
// `declare -A PROFILE_*` associative arrays in master.conf — PROFILE_RSYNC_OPTS[name],
// PROFILE_BW_LIMIT[name], and so on. That layout exists because the shell layer reads each
// setting independently, and this endpoint's whole job is to present it as a record anyway:
// read all nine, pivot by profile name, and on save write the same key back into each.
//
// Every write therefore touches nine array declarations at once, submitted as a single
// change set so they land together. A profile that existed in some arrays and not others
// would read back with silently missing settings.
//
// DESIGN PRINCIPLES
// Create and update are the same operation.
// Save sets the named key in every array whether or not it was already there. There is
// no separate create path, and therefore no way for the two to diverge on what a
// complete profile looks like.
//
// Delete only touches arrays that actually contain the profile.
// array_key_exists() is checked per array, and an empty change set is reported as
// "Profile not found" rather than as a successful no-op.
//
// Values are quoted only when they need to be.
// _rp_build_assoc() emits a bare value unless it is empty or contains whitespace or a
// shell metacharacter. That keeps master.conf readable by hand, which is the reason the
// whole config layer is bash rather than JSON.
//
// Missing arrays are skipped, not created.
// A PROFILE_* declaration absent from master.conf is passed over. This endpoint edits
// the schema that exists; adding a new setting is a conf template change, not a runtime
// one.
//
// OPERATIONAL SAFEGUARDS
// The profile name is constrained to characters that cannot break the array.
// ^[a-zA-Z0-9_\-]+$ on both save and delete — no spaces, quotes, brackets or shell
// metacharacters. The name becomes an associative-array subscript, so anything outside
// that set could terminate the key or the declaration.
//
// Values are escaped on the way in, and the result is parsed before it lands.
// Embedded quotes are backslash-escaped by _rp_build_assoc(), and
// vv_conf_write_changes() then runs `bash -n` over the rewritten file. The escaping
// handles the expected case; the syntax gate is what catches the unexpected one — and
// it matters here because assoc_array values are spliced in verbatim rather than
// through the scalar path's escaping.
//
// Writes go through the shared conf writer, so they are atomic — tmp + rename — and a
// script sourcing master.conf mid-save sees the old file or the new one.
//
// The push happens only after every array write succeeded.
// Guarded on the combined result, so a partially failed change set is not distributed
// to partners. Profiles are shared configuration; a partner holding a different
// definition of a profile would sync the same share differently.
//
// Save and delete are POST-only; only list is reachable by GET.
//
// Unknown actions fall through to an explicit error rather than an empty 200.
//
// REQUEST
// GET|POST ?action=list all profiles, pivoted into records
// POST action=save name=<profile> 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=<profile>
//
// RESPONSE
// list {"ok":true,"profiles":{"<name>":{"<field>":"<value>", …}, …}}
// 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';
+79 -4
View File
@@ -1,8 +1,76 @@
<?php
// Save rsync standalone config (location + cron) for a specific rsync tier.
// POST: flag_name (e.g. "DAILY_RSYNC_ENABLED"), orch_id, location, cron
// Stored in schedule.json under "__rsync_{FLAG_NAME}".
// Triggers a cron rebuild so the standalone entry takes effect immediately.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Standalone rsync scheduling. Records a location and cron expression for one rsync tier so
// that tier can run on its own schedule when its orchestrator is disabled.
//
// OPERATIONAL MODEL
// Rsync normally runs as a step inside an orchestrator — critical, intermediate, daily,
// weekly. This exists for the case where someone wants that tier's sync without the rest of
// the orchestrator's work. vv_cron_rebuild() emits the standalone entry only when the
// orchestrator is disabled and both a location and a cron are configured, so the two can
// never both fire: enabling the orchestrator silently takes precedence.
//
// Stored in schedule.json under __rsync_<FLAG_NAME>, 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=<TIER>_RSYNC_ENABLED orch_id=<Category/name.sh>
// 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();
+128 -4
View File
@@ -1,4 +1,102 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Sync window editor. Lists the available scripts and rsync profiles, and saves which
// scripts and which shares belong to one maintenance window — critical, intermediate, daily
// or weekly.
//
// OPERATIONAL MODEL
// One save writes to two files, because a window is defined in two places. The script list
// is a *_MAINTENANCE_SCRIPTS array in master.conf, shared across the partnership; the share
// list is a <HOST>_*_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=<JSON [{"id","enabled"}]> shares=<JSON [{"path","profile"}]>
// Either list may be omitted; only the ones supplied are written.
//
// RESPONSE
// list_scripts {"ok":true,"groups":{"<folder>":[{"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";
}
+76 -2
View File
@@ -1,4 +1,72 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Manual job launcher. Starts one script through run_job.sh, marked --manual, from the
// scheduler page's run button.
//
// OPERATIONAL MODEL
// Fire and forget. The child is nohup'd, detached from the request, and its output appended
// to the job's own log. ok:true means "launched", not "succeeded" — the outcome arrives in
// the stat file, and the page follows it through status.php and log.php.
//
// A manual run is the same run the cron would do. Same runner, same flags, same log, same
// lock — only --manual differs, so that the stat file records who started it.
//
// DESIGN PRINCIPLES
// Never executes the target script directly.
// run_job.sh is the single execution path, so locking, logging, the stat file and exit
// handling are identical whether a job was started by cron or by a person. A second
// path would be a second set of bugs, and they would only show up under manual runs.
//
// Refuses a concurrent run, rather than relying on the script's own lock.
// The scripts do lock and would exit on their own, but they would do it silently in a
// log nobody has open yet. Checking here turns that into an immediate, visible
// "Already running" with an already_running flag the UI can act on.
//
// OPERATIONAL SAFEGUARDS
// The job id is validated and then confirmed to exist.
// ^[a-zA-Z0-9_./\-]+\.sh$ plus an explicit '..' check — the slash must be permitted for
// Category/name.sh ids, so traversal is caught by its own test rather than by the
// character class. file_exists() then confirms the resolved path is a real script.
//
// The running check is cross-checked against the process table.
// A stat file saying 'running' is only believed while /proc/<pid> 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=<Category/name.sh> [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 </dev/null &');
echo json_encode(['ok' => true]);
+60 -2
View File
@@ -1,6 +1,64 @@
<?php
// Save custom-script folder assignments to schedule.json (__folders key).
// POST: folders (JSON-encoded object: {"FolderName": ["Custom/script.sh", ...]})
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Script folder assignments. Saves the grouping of scripts into named folders on the
// scheduler page, stored in schedule.json under the __folders key.
//
// OPERATIONAL MODEL
// Whole-map replacement, not a patch. The page sends the complete folder structure and it
// replaces __folders entirely, so a removed folder disappears by absence. Reconciling
// individual moves would need a change log the UI does not have and cannot produce from a
// drag.
//
// Stored alongside the schedule rather than in a file of its own, under a key prefixed with
// __ to keep it out of the job namespace — the same convention __rsync_* uses. Everything
// that iterates the schedule as jobs skips these keys by prefix.
//
// DESIGN PRINCIPLES
// Presentation only. Folder membership changes nothing about whether or when a script runs;
// vv_cron_rebuild() never reads __folders, which is why this endpoint does not trigger one.
//
// Invalid entries are dropped, not rejected.
// A bad folder name or an unrecognised script id is skipped and the rest of the map is
// saved. Failing the whole request would lose a full reorganisation over one stale
// entry — and the entries most likely to be stale are scripts deleted since the page
// loaded.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before the payload is read.
//
// The payload must decode to an array.
// is_array() on the decoded JSON — a truncated or non-JSON body is refused outright
// rather than writing an empty map, which would silently erase every folder.
//
// Every script id is validated exactly as the run paths validate it.
// ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check, applied per entry. These ids are
// stored and later handed back to the page as job references, so a value that could not
// be run has no business being persisted next to ones that can.
//
// Folder names are length-capped and type-coerced.
// Cast to string, trimmed, empty rejected, and capped at 80 characters — a JSON object
// with numeric or absurdly long keys cannot bloat schedule.json, which is read on every
// scheduler page load and every cron rebuild.
//
// Non-array folder contents are skipped.
// is_array() per folder before iterating, so a malformed value cannot raise a warning
// into the JSON response.
//
// The rest of the schedule is preserved.
// The file is loaded, one key replaced, and the whole structure written back — job
// entries and __rsync_* keys are carried through untouched.
//
// REQUEST
// POST folders=<JSON object: {"<folder name>": ["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';
+94 -9
View File
@@ -1,4 +1,77 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Schedule writer. Saves one job's enabled state, cron expression and log flag — or a whole
// set of them in one call — and rebuilds varaverk.cron from the result.
//
// OPERATIONAL MODEL
// schedule.json is the source of truth; varaverk.cron is generated from it. Nothing edits
// the crontab directly, which is why the generated file carries a "managed by plugin, do
// not edit manually" banner. Every save here ends in a vv_cron_rebuild() inside the library,
// so the two can never drift.
//
// Two paths, same effect. The single-entry path is what the UI's toggles use; the batch
// path exists so a bulk edit is one load/write/rebuild cycle rather than N of them. The
// batch path is currently unreferenced by the UI.
//
// The two paths differ in how they treat a bad cron, deliberately. A single save rejects it
// and tells the user; a batch save blanks that one field and continues, because failing an
// entire bulk edit over one malformed row would lose every other change in it.
//
// DESIGN PRINCIPLES
// Event triggers are stored here but never reach cron.
// array_start and array_stop are accepted as cron values and recorded in schedule.json,
// but vv_cron_rebuild() skips them — they fire from the array event hook instead. One
// schedule holds both kinds of trigger so the page has a single list to render.
//
// Enablement is decided here, precedence in the library.
// A child script whose orchestrator is enabled gets no independent cron line, and that
// suppression lives in vv_cron_rebuild(). This endpoint records intent; the library
// resolves what that intent means against the rest of the schedule.
//
// OPERATIONAL SAFEGUARDS
// The job id and the cron expression are both crontab injection surfaces, and are validated
// as such.
// vv_cron_rebuild() interpolates both into a generated crontab line inside double
// quotes. The id must match ^[A-Za-z0-9_./\-]+\.sh$ with no '..', and the cron
// expression must consist only of [0-9A-Za-z*,\-/ ] — neither can carry a quote, a
// shell metacharacter, or a newline. Previously the id was checked only for emptiness,
// and the field-count pattern used \s, which matches newline: a value of "* * * *\nX"
// satisfied it and would have appended a second, attacker-chosen line to root's crontab.
// The character-class check is what carries that guarantee now; the field count is a
// usability check on top of it.
//
// Both paths share one validator.
// vv_sched_id_valid() and vv_sched_cron_valid() are called from the single and batch
// paths alike, so the bulk path cannot become the loose one — which is exactly how the
// id check came to be missing from it before.
//
// POST only, checked before any parameter is read.
//
// Anything other than the literal "1" is false.
// Both flags compare strictly against '1', so a missing or malformed parameter disables
// rather than enables. The single-entry path previously used a (bool) cast, under which
// the string "false" evaluates to true.
//
// Malformed batch JSON degrades to an empty set.
// json_decode with a ?: [] fallback, so a truncated payload writes nothing rather than
// rebuilding the crontab from garbage.
//
// REQUEST
// POST id=<Category/name.sh> enabled=0|1 cron=<5 fields|array_start|array_stop|empty>
// log_enabled=0|1
// POST batch=<JSON array of {id, enabled, cron, log_enabled}>
//
// 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;
}
+85 -2
View File
@@ -1,4 +1,78 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Custom script CRUD. Reads, saves and deletes the user-authored scripts behind the
// scheduler page's "+ Create Script" editor.
//
// OPERATIONAL MODEL
// Custom scripts live in CUSTOM_SCRIPTS_DIR, outside the git repo entirely — alongside the
// User Scripts plugin's own storage. That is what keeps them out of the pushed repository
// and lets them survive a git pull that rewrites everything under SCRIPTS_DIR. Any .sh file
// dropped into that folder by hand is picked up too; it does not have to be created here.
//
// The Custom/ prefix is a namespace, not a directory under SCRIPTS_DIR. Ids are
// Custom/<name>.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/<name>.sh read (empty content when absent)
// POST name=<name> content=<script text> save or overwrite (action defaults to save)
// POST name=<name> action=delete delete script and schedule entry
//
// RESPONSE
// {"ok":true,"content":"…"} read
// {"ok":true,"id":"Custom/<name>.sh"} save
// {"ok":true} delete
// {"ok":false,"error":"Invalid id"|"Name must be letters, numbers, _ or - only"
// |"Script not found"|"Failed to write script"|"Method not allowed"}
//
// DEPENDS ON
// include/scheduler.php vv_schedule_load(), vv_schedule_save(), vv_cron_rebuild()
// include/config.php CUSTOM_SCRIPTS_DIR
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
@@ -42,11 +116,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$dir = CUSTOM_SCRIPTS_DIR;
if (!is_dir($dir)) mkdir($dir, 0755, true);
if (file_put_contents($path, $content) === false) {
// tmp + chmod + rename — an enabled script can be launched by cron at any moment, and a
// half-written or not-yet-executable file would run as a truncated script.
$tmp = $path . '.vv.tmp';
if (file_put_contents($tmp, $content) === false) {
@unlink($tmp);
echo json_encode(['ok' => false, 'error' => 'Failed to write script']);
exit;
}
chmod($tmp, 0755);
if (!rename($tmp, $path)) {
@unlink($tmp);
echo json_encode(['ok' => false, 'error' => 'Failed to write script']);
exit;
}
chmod($path, 0755);
// Ensure schedule.json has an entry so the script appears in the job list
$schedule = vv_schedule_load();
+67
View File
@@ -1,4 +1,71 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Contextual help for one script. Returns its header block plus any documentation sections
// about it found in the README and Manual files — the info panel on the scheduler page.
//
// OPERATIONAL MODEL
// Documentation is assembled at request time, not indexed. Two sources are combined: the
// script's own header comment, which is authoritative because it lives next to the code,
// and matching sections from the markdown docs, which give the surrounding context the
// header deliberately leaves out.
//
// Four documents are searched per request — the top-level README.md and Manual.md, plus the
// module-level README-<Dir>.md and Manual-<Dir>.md for the script's own folder. That mirrors
// how the docs are actually organised: general behaviour at the top, specifics per folder.
//
// DESIGN PRINCIPLES
// Matches headings by slug, with a first-word fallback.
// The script name is normalised — underscores and hyphens to spaces, lowercased — and a
// heading matches if it contains the whole slug or, failing that, the first word when
// that word is longer than three characters. The length floor is what stops a script
// beginning with "arr" or "sync" from matching every section in the file.
//
// Intro sections are never matched.
// The matcher returns false for the intro, because the opening prose of a README
// mentions many scripts and would otherwise match nearly all of them.
//
// Every section carries its source label.
// The panel shows where each block came from, so a reader can tell the module manual
// from the top-level README rather than seeing one undifferentiated wall of text.
//
// Works for documents as well as scripts.
// A non-.sh id yields no header and only the doc sections, so the same endpoint serves
// the docs tab's own entries.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here writes, executes, or schedules anything.
//
// Traversal is blocked before any path is composed.
// An explicit '..' check plus ^[A-Za-z0-9_./\-]+$. The slash must be permitted because
// ids are Category/name.sh, so the '..' test carries the traversal guarantee on its own.
//
// The filesystem is only touched for ids that name a script.
// The header read is guarded by both the .sh suffix test and file_exists(), so a
// well-formed id for a file that is not there returns an empty header rather than a
// warning that would leak the absolute path into the JSON body.
//
// Every document is existence-checked before it is searched, and the module-level path is
// additionally guarded against a dirname of '.' — an id with no directory component would
// otherwise compose README-..md and search a file that cannot exist.
//
// Missing documentation is a normal outcome.
// No header and no matching sections yields ok:true with empty values. A script nobody
// has written about yet is not an error, and reporting it as one would put a failure in
// the panel for most custom scripts.
//
// REQUEST
// GET ?id=<Category/name.sh> or any documented id
//
// RESPONSE
// {"ok":true,"name":"…","header":"…","sections":[{"source":"…","body":"…"}, …]}
// {"ok":false,"error":"Invalid id"}
//
// DEPENDS ON
// include/scheduler.php vv_script_header_clean()
// include/docs.php vv_readme_section() (loaded transitively)
// SCRIPTS_DIR README.md, Manual.md, README-<Dir>.md, Manual-<Dir>.md
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
+90 -2
View File
@@ -1,7 +1,73 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Relocates the plugin. Points SCRIPTS_DIR in varaverk.cfg at a different Varaverk checkout
// — the manual equivalent of what the storage-migration flow does automatically.
//
// OPERATIONAL MODEL
// varaverk.cfg is the one file whose location is fixed. Everything else the plugin reads —
// scripts, Configurations/, Deployment/, State_Files/, data/ — is resolved relative to the
// SCRIPTS_DIR it names, which is what makes internal-vs-appdata storage mode possible at
// all. Rewriting that one value moves the entire plugin.
//
// Currently unreferenced by the UI. The settings tab drives relocation through the storage
// migration flow, which also copies the data. This endpoint changes the pointer alone, and
// is kept as the recovery path for a cfg that points somewhere that no longer exists.
//
// DESIGN PRINCIPLES
// Changes the pointer, never moves the data.
// There is deliberately no copy step. Migration is a separate, longer operation with
// its own confirmation; conflating the two would make a one-field edit capable of
// deleting a directory.
//
// Merges into the existing cfg rather than authoring it.
// varaverk.cfg carries GITEA_CONTAINER, GITEA_REPO_PATH, GITEA_SSH_KEY, SSH_PORT and
// CUSTOM_SCRIPTS_DIR alongside SCRIPTS_DIR. Only the key being changed is touched.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked before anything is read.
//
// The target must actually be a Varaverk checkout.
// is_dir() alone is not enough — it would happily accept /mnt/user or /tmp and leave
// every subsequent page resolving conf and script paths under a directory that contains
// none of them, with no single obvious symptom. common.sh and load_config.sh must both
// be present, because those are the two files every script in the repo sources.
//
// Unrelated keys survive the write.
// The previous version emitted a file containing SCRIPTS_DIR and nothing else, silently
// discarding the Gitea coordinates that git_pull_execute.sh needs — so a relocation
// would have broken both the daily pull and the UI pull button, and only at the next
// scheduled run. The cfg is now read, one key replaced, and all keys written back.
//
// The write is atomic.
// tmp + rename, so a page render or a script reading varaverk.cfg mid-save sees the old
// file or the new one. A truncated varaverk.cfg would leave SCRIPTS_DIR undefined and
// every path in the plugin falling back to the compiled-in default.
//
// Values are escaped for the ini quoting parse_ini_file() expects.
// addslashes() on every value written, not just the new one, so a path containing a
// quote cannot terminate the string early and corrupt the keys that follow it.
//
// REQUEST
// POST scripts_dir=<absolute path to a Varaverk checkout>
//
// RESPONSE
// {"ok":true,"error":null}
// {"ok":false,"error":"POST only"|"scripts_dir is required"|"Directory does not exist"
// |"Not a Varaverk checkout — common.sh and load_config.sh not found"
// |"Failed to write cfg file"}
//
// DEPENDS ON
// include/config.php PLUGIN_CFG
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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;
}
$scriptsDir = trim($_POST['scripts_dir'] ?? '');
if (!$scriptsDir) {
@@ -14,11 +80,33 @@ if (!is_dir($scriptsDir)) {
exit;
}
// Pointing SCRIPTS_DIR at an arbitrary directory leaves every conf and script path in the
// plugin resolving under something that holds none of them. These two files are sourced by
// every script in the repo, so their presence is what makes a directory a checkout.
if (!file_exists($scriptsDir . '/common.sh') || !file_exists($scriptsDir . '/load_config.sh')) {
echo json_encode([
'ok' => false,
'error' => 'Not a Varaverk checkout — common.sh and load_config.sh not found',
]);
exit;
}
$cfgFile = PLUGIN_CFG;
$cfgDir = dirname($cfgFile);
if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true);
$content = 'SCRIPTS_DIR="' . addslashes($scriptsDir) . '"' . "\n";
$ok = file_put_contents($cfgFile, $content) !== false;
// Merge, do not author. varaverk.cfg also carries the Gitea coordinates git_pull_execute.sh
// needs — rewriting the file with SCRIPTS_DIR alone would silently break the daily pull.
$cfg = @parse_ini_file($cfgFile) ?: [];
$cfg['SCRIPTS_DIR'] = $scriptsDir;
$content = '';
foreach ($cfg as $k => $v) {
$content .= $k . '="' . addslashes((string)$v) . '"' . "\n";
}
$tmp = $cfgFile . '.vv.tmp';
$ok = file_put_contents($tmp, $content) !== false && rename($tmp, $cfgFile);
if (!$ok) @unlink($tmp);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write cfg file']);
+130 -9
View File
@@ -1,4 +1,119 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// First-run setup. Detects the environment, generates the SSH keypair, populates the host
// conf from discovered services, writes the initial master.conf and host*.conf, and — on a
// partner server — pulls master.conf down from HOST1.
//
// OPERATIONAL MODEL
// Two entirely different journeys share this file because they write the same files. HOST1
// authors master.conf from the wizard's answers; every other host receives it over SCP and
// never edits it. Which one runs is decided by the action, not by a mode setting.
//
// Ordering is the substance of the save path. master.conf is written first because
// host*.conf is generated from a template that needs the host slot; the SSH keypair is
// generated before the API key because the key path is written into the conf that the API
// key provisioning then reads back. Reordering these silently produces a half-configured
// host.
//
// Storage mode is decided here and acted on later. The response reports whether the chosen
// mode implies a relocation, and the UI hands that to storage.php — this endpoint never
// moves anything itself.
//
// The host conf is only ever created, never overwritten. Every path checks file_exists()
// first, so re-running the wizard on a configured host cannot destroy credentials that were
// filled in afterwards.
//
// DESIGN PRINCIPLES
// Detection is advisory; the wizard's answer wins.
// Boot transport is auto-detected, but an explicit storage_mode parameter overrides it.
// The detection is a good default, not a verdict — someone deliberately choosing appdata
// on an internal boot device has a reason.
//
// The partner path resolves HOST1's layout rather than assuming it.
// Before the SCP, HOST1's own varaverk.cfg is read over SSH to find its SCRIPTS_DIR,
// because the two hosts can legitimately be in different storage modes. Falling back to
// the default path when that read fails is the right degradation — it is correct in the
// common case.
//
// The template is filled by substitution, not by generation.
// HOSTN/hostn placeholders are replaced and two specific keys rewritten. Everything else
// in host.conf.template — comments, structure, ordering, the keys not yet filled in —
// arrives intact, which is what makes the generated conf readable and diffable.
//
// Failures are named at the step that failed.
// Missing SSH key, unresolvable Tailscale name, failed SCP and missing master.conf each
// return their own message with the remedy in it. This is the one screen where the user
// has no context yet, so a generic error is worth nothing.
//
// OPERATIONAL SAFEGUARDS
// The host slot is pattern-matched on both paths.
// ^host\d+$ before it is used to compose a conf filename or upper-cased into variable
// names. That check is what stops 'unknown' — vv_detect_host()'s failure value — from
// producing an unknown.conf.
//
// Every script run is externally time-boxed.
// `timeout` wraps ssh_setup.sh, conf_populate.sh, the remote cfg read and the SCP.
// set_time_limit() does not cover exec() time on Linux, so without this an unreachable
// HOST1 would hold a php-fpm worker open for as long as SSH kept trying. The SCP also
// runs BatchMode=yes so it can never sit waiting for a password.
//
// Missing scripts are reported, not executed.
// file_exists() precedes every exec(), so a partial deploy returns a named error rather
// than a shell failure that looks like a failed setup.
//
// Conf values are escaped before substitution.
// addslashes() on every hostname written into master.conf, so a pasted value containing
// a quote cannot terminate the assignment and corrupt the keys after it.
//
// Conf writes are atomic.
// vv_write_conf_raw() (tmp + rename) for both files, and the master.conf write is
// checked before the host conf is generated from it.
//
// The setup state is read-modify-written.
// vv_setup_state_write() replaces the file wholesale, so the save path reads the
// existing state first. Passing only the one new key would erase the partnership phase
// markers on a host that had already been onboarded and re-ran the wizard.
//
// The host conf is never overwritten.
// Both creation paths are guarded by file_exists(), so re-running setup on a configured
// host is a no-op for that file rather than a credential wipe.
//
// The SCP target is the local conf path, composed here — no part of the request names a
// destination file.
//
// Accepted: this endpoint writes credentials and runs setup scripts as root.
// It is the setup wizard; that is its function. It is guarded by the Unraid WebGUI
// session; see the CSRF note in README-unraid.md.
//
// REQUEST
// GET ?action=detect hostname, Unraid version, boot transport, suggested mode
// GET|POST action=ssh_generate generate the local keypair, return the public key
// POST action=populate run conf_populate.sh --no-push
// POST action=pull partner: SCP master.conf from HOST1, create host conf
// [my_slot] [my_hostname] [host1_hostname]
// POST (action=save, default) HOST1: write master.conf and host conf
// host1 [host2] [my_slot] [my_hostname] [storage_mode=internal|flash]
//
// RESPONSE
// detect {"ok":true,"hostname","unraid_ver","transport","boot_device","mode",
// "scripts_dir"}
// ssh_generate {"ok":bool,"pubkey":"…","error":…}
// populate {"ok":bool,"lines":[…]}
// pull {"ok":true,"host_id","conf_file","api_key":…,"redirect":"…"}
// save {"ok":true,"host_id","api_key":…,"needs_migration":bool,
// "migrate_to":"internal|flash"|null,"redirect":"…"}
// {"ok":false,"error":…} naming the specific step that failed
//
// DEPENDS ON
// include/config.php vv_get_hostname(), vv_detect_host(), vv_read_conf_raw(),
// vv_write_conf_raw(), vv_setup_state_read/_write(),
// vv_auto_create_api_key(), CONF_DIR, DEPLOY_DIR
// Partnership/ssh_setup.sh keypair generation
// Deployment/conf_populate.sh service discovery
// Deployment/host.conf.template source of the generated host conf
// api/storage.php performs the migration this endpoint only recommends
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
@@ -39,7 +154,9 @@ if ($action === 'ssh_generate') {
echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
exit;
}
exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
// set_time_limit() does not cover exec() time on Linux — every script run below is
// wrapped in `timeout` so a stalled child cannot hold a php-fpm worker open.
exec('timeout 120 bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
// Derive pubkey path from hostname
$hostname = vv_get_hostname();
$shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
@@ -60,7 +177,7 @@ if ($action === 'populate') {
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
exit;
}
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
exec('timeout 180 bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
$lines = array_values(array_filter(array_map('trim', $out)));
echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
exit;
@@ -116,15 +233,15 @@ if ($action === 'pull') {
// Get HOST1's SCRIPTS_DIR from their varaverk.cfg
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
$remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
$remoteCfg = trim(shell_exec('timeout 30 ' . $sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
$remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
// SCP master.conf from HOST1
$localMaster = CONF_DIR . '/master.conf';
$src = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
$cmd = 'scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
$cmd = 'timeout 60 scp -i ' . escapeshellarg($sshKey)
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o BatchMode=yes'
. ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
exec($cmd, $out, $rc);
if ($rc !== 0) {
@@ -154,7 +271,7 @@ if ($action === 'pull') {
}
if (file_exists($sshScript)) {
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
exec('timeout 120 bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
}
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
@@ -247,12 +364,16 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) {
}
}
// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);
// Write setup state file — lets partner servers know HOST1 is configured.
// Read-modify-write: vv_setup_state_write() replaces the file wholesale, and re-running the
// wizard must not erase onboarding progress recorded by the partnership phases.
$state = vv_setup_state_read();
$state['host1_hostname'] = $host1;
vv_setup_state_write($state);
// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
if (file_exists($sshScript)) {
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
exec('timeout 120 bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
}
// Auto-create Unraid API key and write into the fresh conf
+68
View File
@@ -1,4 +1,72 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Header snapshot. The small always-visible summary carried across every tab — CPU, RAM,
// fallback state, partner peers, and active stream and transcode counts.
//
// OPERATIONAL MODEL
// This is the most frequently requested endpoint in the plugin: it is polled from whichever
// tab happens to be open, continuously, for as long as the page is open. Everything about
// it is shaped by that. Each field is the cheapest available answer to its question, not
// the most complete one — monitor.php exists for the complete one.
//
// DESIGN PRINCIPLES
// Shares the CPU baseline rather than sampling its own.
// vv_cpu_per_core() keeps its counters in /tmp/vv_cpu_stat.json, and reading through it
// means the header and the monitor page report the same number. Two independent
// samplers would drift apart and produce a visible disagreement between the header and
// the page directly below it.
//
// Fallback state is read from the file, never derived.
// A direct read of fallback_state.db with no exec and no SSH. The authoritative answer
// is the one fallback.sh wrote; recomputing it here would be both slower and capable of
// disagreeing with the process that actually controls failover.
//
// Media counts are cached 30 seconds, everything else is live.
// Streams are the only field that costs HTTP round trips to another service. Caching
// just that keeps the poll cheap without making CPU or RAM stale, and 30s is well under
// the time it takes anyone to notice a stream started.
//
// Peers exclude self.
// is_me is filtered out here rather than in the UI, so every consumer of this payload
// gets the same definition of "peers" and none of them can forget to apply it.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here starts, stops, or changes anything.
//
// The fallback read degrades to a named unknown.
// @file_get_contents with a ?: '' fallback, and the parsed state defaults to 'UNKNOWN'.
// A missing or unreadable state file shows UNKNOWN in the header rather than NORMAL —
// reporting healthy for a fallback process that is not running is the one error this
// field must never make.
//
// Division is guarded.
// ram_total_mb > 0 is checked before the percentage, so a failed meminfo read yields 0
// rather than a division by zero that would fatal on every tab at once.
//
// Every count is cast on the way out.
// (int) on the cached stream and transcode counts with ?? 0 defaults, so a cache file
// written by an older schema cannot put a null or a string into the header payload.
//
// Media failures are already contained upstream — vv_media_sessions() time-boxes each
// request at 3s and returns an empty session list on any failure, so an unreachable Emby
// costs this poll nothing beyond that timeout, once every 30 seconds.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"cpu_pct","ram_pct","ram_used_mb","ram_total_mb","fallback","partner_enabled",
// "peers":[…],"stream_count","transcode_count"}
//
// DEPENDS ON
// include/common.php vv_cpu_per_core(), vv_system_resources()
// (loaded transitively through include/monitor.php)
// include/monitor.php vv_partner_state()
// include/config.php vv_parse_kv_db(), vv_cache_read(), vv_cache_write(), STATE_DIR
// include/media.php vv_media_sessions()
// STATE_DIR fallback_state.db — written by Fallback/fallback.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/monitor.php';
require_once dirname(__DIR__) . '/include/media.php';
+53 -5
View File
@@ -1,12 +1,60 @@
<?php
// Returns current run status for all scheduled jobs.
// Used by the scheduler page to light up running indicators without user interaction.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Job status endpoint. The current run status of every scheduled job, keyed by job id, so
// the scheduler page can light up running indicators on a poll without the user opening
// anything.
//
// OPERATIONAL MODEL
// Status is not stored centrally — each job writes its own .json stat file beside its log,
// and this endpoint reads the schedule to know which ids exist, then collects one file per
// id. A job with no stat file has never run and is omitted entirely, which is how the page
// tells "never run" apart from "ran and finished".
//
// DESIGN PRINCIPLES
// The recorded status is cross-checked against the process table.
// A stat file saying "running" is only believed while /proc/<pid> still exists. A
// runner killed mid-job — OOM, reboot, kill -9 — never gets to write its own failure,
// so trusting the file alone would leave the page showing a spinner forever.
//
// Reports status only.
// No timestamps, no output, no exit codes. This is polled frequently and exists to
// drive indicator state; the detail views fetch what they need from log.php.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here starts, stops, or reaps a job.
//
// Every file read degrades to an empty object.
// @file_get_contents with a ?: '{}' fallback and a ?: [] after json_decode, so a stat
// file that is unreadable or caught mid-write yields status 'unknown' rather than a
// fatal that would blank every indicator on the page.
//
// A dead runner is reported as error, never as still running.
// This is the safe direction to be wrong in: a job wrongly shown as finished prompts
// someone to look, whereas one wrongly shown as running is silently ignored forever.
//
// Known limit: PID reuse.
// /proc/<pid> existing does not prove it is still *this* job's process. On a host that
// has churned through the pid space a recycled pid could hold a dead job in 'running'.
// Accepted rather than fixed — the alternative is a start-time comparison against
// /proc/<pid>/stat, which is more machinery than an indicator light justifies.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"<job id>":"running"|"success"|"error"|"unknown", …}
// Jobs that have never run are absent from the object entirely.
//
// DEPENDS ON
// include/scheduler.php vv_schedule_load(), vv_job_stat_path()
// LOG_DIR <job>.json stat files written by run_job.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
$schedule = vv_schedule_load();
$result = [];
foreach ($schedule as $id => $entry) {
$result = [];
foreach (array_keys(vv_schedule_load()) as $id) {
$statFile = vv_job_stat_path($id);
if (!file_exists($statFile)) continue;
$stat = json_decode(@file_get_contents($statFile) ?: '{}', true) ?: [];
+84
View File
@@ -1,4 +1,88 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Job killer. Terminates a running job, clears the lock files it left behind, and corrects
// its stat file — the stop button on the scheduler page.
//
// OPERATIONAL MODEL
// Escalating termination, then cleanup. SIGTERM to the whole process group, up to three
// seconds to exit, SIGKILL if it did not. The group is the target rather than the pid
// because a Varaverk script is mostly other processes — rsync, ssh, curl, docker — and
// killing only the parent would orphan every one of them still holding the resources the
// next run needs.
//
// The stat file is the job's own record, and a killed job never gets to correct it. This
// endpoint does that on its behalf, which is what stops the scheduler page from showing a
// spinner for a job that no longer exists.
//
// DESIGN PRINCIPLES
// Kills the group, falls back to the tree.
// `ps -o pgid=` resolves the process group; when that fails the fallback is pkill -P
// plus the pid itself. Two strategies, because a job whose runner already exited can
// leave children whose group id is no longer discoverable from the recorded pid.
//
// Waits before escalating.
// Six 500ms checks between TERM and KILL. Scripts have cleanup handlers — releasing
// locks, finishing a write, unmounting — and killing immediately would skip exactly the
// work that makes the next run safe.
//
// Reports honestly when the kill failed.
// ok mirrors whether the process is actually gone. A process in uninterruptible sleep
// survives SIGKILL, and the response says so rather than claiming success and leaving
// the user to discover the job is still running.
//
// OPERATIONAL SAFEGUARDS
// The job id is validated, and the pid is an integer before it reaches a shell.
// ^[a-zA-Z0-9_./\-]+\.sh$ with an explicit '..' check on the id, and (int) casts on
// every pid — the recorded one and each lock's owner — before interpolation. The signal
// commands are the only shell in this file and none of them can carry anything but a
// number.
//
// pid < 2 is refused.
// A zero, missing, or malformed pid in the stat file would make `kill -TERM -0` signal
// the caller's own process group — the web server. Rejecting anything below 2 also
// excludes init.
//
// A job that is not running is a no-op success.
// Both the missing-stat-file and status-not-running paths exit before any signal is
// sent, so pressing stop twice cannot kill an unrelated process that has since been
// assigned the recorded pid.
//
// The pid is only cleared from the stat file if the process is confirmed gone.
// D-state processes survive SIGKILL. Clearing the pid there would lose the only handle
// anyone has on a process that is still holding locks, and would report the job stopped
// while it continues to run.
//
// Lock clearing is bounded to the lock directory and to dead owners.
// glob over /tmp/unraid_locks/*.lock — a hardcoded literal, not a config value — and a
// lock is removed only when its recorded owner is this pid or is no longer in /proc. A
// lock held by a live, unrelated process is never touched.
//
// Deliberate side effect: stale locks from other jobs are reaped too.
// The dead-owner test is not scoped to this job, so one stop clears every abandoned
// lock on the host. That is intentional — a lock whose owner does not exist is by
// definition stale, and leaving it to be found later means a future run refuses to
// start for no reason.
//
// The name-based fallback covers pid rotation.
// A lock file named after the script is removed regardless of its recorded owner,
// because a rotated pid can make a genuinely stale lock look live.
//
// REQUEST
// POST id=<Category/name.sh>
//
// RESPONSE
// {"ok":true,"killed":true,"locks":["…"],"error":null}
// {"ok":true,"msg":"Not running"}
// {"ok":false,"killed":false,"locks":[…],
// "error":"Process still alive after SIGKILL (D-state) — lock may persist"}
// {"ok":false,"error":"Invalid id"|"No stat file — script may not be running"
// |"No valid PID in stat file"}
//
// DEPENDS ON
// include/scheduler.php vv_job_stat_path()
// /tmp/unraid_locks lock files written by common.sh's locking helper
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
+120 -5
View File
@@ -1,6 +1,111 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Storage mode and API key management. Reports where the plugin currently lives and what
// kind of device it boots from, migrates it between internal and flash layouts, records the
// detected mode into conf, and reports or provisions Unraid API keys across all hosts.
//
// OPERATIONAL MODEL
// Storage mode exists because /boot is not the same thing on every Unraid host. On a real
// USB flash device, write wear is a genuine constraint and the plugin belongs in appdata; on
// a host that boots from an internal SSD or a ZFS pool it does not, and living under
// /boot/config/plugins is simpler. The mode is therefore detected from the boot device's
// transport rather than assumed.
//
// Detection walks from the mountpoint down to physical media, and handles two shapes. A
// block-device /boot resolves through lsblk's parent-name to the whole disk. A ZFS /boot
// resolves through zpool to a backing vdev first, skipping the mirror/raidz/spare topology
// rows, and then to its whole disk. Only then is the transport read.
//
// Migration is delegated entirely to storage_migrate.sh. It copies data, rewrites
// varaverk.cfg and master.conf, and removes the old location — far too much to do inside a
// web request, and it must be resumable and testable on its own.
//
// DESIGN PRINCIPLES
// Reports detected and current separately, and never reconciles them itself.
// current_mode is where the plugin actually is; detected is where the hardware suggests
// it should be. A host can legitimately sit in either state, so the status action
// presents both and lets a person decide.
//
// A third mode, custom, is a first-class answer.
// A SCRIPTS_DIR matching neither known path is reported as custom rather than being
// forced into one of the two. Someone who deliberately relocated the plugin should not
// see the UI claim it is somewhere it is not.
//
// API key status is reported for every host, from conf.
// Partner keys live in the partner's own conf section, so a partner's key can be
// reported present without contacting it. Only the local host's key is validated
// against the API, because only the local host has one to call.
//
// Keys are previewed, never returned.
// First 8 and last 4 characters — enough to confirm which key is in use, not enough to
// use it.
//
// OPERATIONAL SAFEGUARDS
// The migration target is an exact allowlist with strict comparison.
// Only 'internal' and 'flash' reach the script, as an escapeshellarg'd --to= value. No
// part of the request names a path, so migration cannot be pointed anywhere else.
//
// Every shell argument is escaped, including ones derived from other shell calls.
// The device names discovered during detection are fed back into lsblk through
// escapeshellarg(), so a device name containing anything unexpected cannot compose a
// command.
//
// Every detection step degrades to 'unknown' rather than guessing.
// A failed findmnt, lsblk, or zpool returns unknown at that step and stops. Guessing
// here would mean recommending a migration based on a device that was never identified.
//
// Both script runs are externally time-boxed.
// `timeout` wraps each — set_time_limit() does not cover exec() time on Linux, so PHP's
// own limit cannot end a stalled migration or a key renewal hung on an unreachable
// partner. Exit 124 is reported as a timeout, distinctly from a script failure.
//
// Missing scripts are reported, not executed.
// file_exists() precedes both exec() calls, so a partial deploy returns a named error
// rather than a shell failure that would look like a failed migration.
//
// Migration and detection are POST-only. status and api_status are the only GET-reachable
// actions, and both are pure reads.
//
// Handle with care: the detect action writes HOST<n>_STORAGE_MODE_INTERNAL.
// That value decides where the plugin's data lives, and on HOST1 it is deliberately
// pinned. Running detect on a host whose boot device reports an unexpected transport
// would rewrite it. The action exists for onboarding a new host, where nothing has been
// decided yet — it is not a repair tool for an established one.
//
// The conf write goes through the structured writer, so it is atomic and syntax-checked —
// which is also why include/confform.php must be loaded here. It was previously absent and
// the detect action fataled on an undefined function.
//
// REQUEST
// GET|POST ?action=status current mode, detected mode, boot device, conf value
// GET|POST ?action=api_status per-host API key presence and local API health
// POST action=migrate to=internal|flash
// POST action=detect write the detected mode into this host's conf
// POST action=setup_apikeys run unraid_api_key_renew.sh
//
// RESPONSE
// status {"ok":true,"current_mode","current_dir","internal_dir","flash_dir",
// "transport","detected","boot_disk","conf_key","conf_val","array_started"}
// api_status {"ok":true,"my_id","key_name","hosts":[…],"fallbacks":…}
// migrate {"ok":bool,"exit":int,"output":"…"}
// detect {"ok":bool,"detected":"true|false","transport":"…"}
// setup_apikeys {"ok":bool,"exit":int,"output":"…"}
// {"ok":false,"error":"Invalid target: …"|"storage_migrate.sh not found"
// |"unraid_api_key_renew.sh not found"|"… timed out …"|"Unknown action"}
//
// DEPENDS ON
// include/config.php SCRIPTS_DIR, vv_detect_host(), vv_conf_vars()
// include/confform.php vv_conf_write_changes()
// include/unraid_api.php vv_api_get_status() (loaded only by api_status)
// Tools/storage_migrate.sh
// System_Essentials/unraid_api_key_renew.sh
// findmnt, lsblk, zpool
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
// vv_conf_write_changes() lives here — the detect action fatals without it.
require_once dirname(__DIR__) . '/include/confform.php';
$action = $_GET['action'] ?? $_POST['action'] ?? '';
@@ -58,7 +163,7 @@ if ($action === 'status') {
'flash_dir' => $flashDir,
'transport' => $transport,
'detected' => $detected,
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
'boot_disk' => $bootDisk ?: 'unknown',
'conf_key' => $confKey,
'conf_val' => $confVal,
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
@@ -80,10 +185,15 @@ if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
set_time_limit(300);
// set_time_limit() does not cover exec() time on Linux, so the bound is external.
set_time_limit(630);
$output = [];
$exit = 0;
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
exec('timeout 600 bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
if ($exit === 124) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh timed out after 600s', 'output' => implode("\n", $output)]);
exit;
}
echo json_encode([
'ok' => $exit === 0,
@@ -160,9 +270,14 @@ if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
set_time_limit(60);
// set_time_limit() does not cover exec() time on Linux — this script reaches every
// partner over SSH, so the bound has to be external.
set_time_limit(150);
$output = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' 2>&1', $output, $exit);
exec('timeout 120 bash ' . escapeshellarg($script) . ' 2>&1', $output, $exit);
if ($exit === 124) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh timed out after 120s']); exit;
}
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
exit;
}
+69
View File
@@ -1,4 +1,73 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Host power control. Stops the array, shuts the machine down, or reboots it. The three
// most destructive operations the plugin can perform, deliberately isolated in one small
// file rather than folded into a general-purpose action endpoint.
//
// OPERATIONAL MODEL
// The command is detached and the response returns immediately. A shutdown kills the web
// server that is serving this request, so waiting on the child would mean the browser sees
// a connection reset rather than a result. Backgrounding is what lets the UI acknowledge
// the action before the host stops answering.
//
// ok:true therefore means "accepted and dispatched", not "completed". Nothing can report
// the completion of an operation that ends the process reporting it.
//
// DESIGN PRINCIPLES
// Three fixed commands, chosen by name.
// The action string selects among three compiled-in literals through a match. No part
// of the request is ever interpolated into the command, so there is no injection
// surface to protect — the request cannot express a command that is not one of these
// three.
//
// Reads its body as JSON, not as a form.
// php://input is parsed directly, because POST bodies are unreliable on this Unraid
// PHP setup and reading the raw stream sidesteps the form parser entirely.
//
// No scheduling, no delay, no cancel.
// There is nothing to cancel because there is no window in which to cancel it. A delayed
// shutdown with a cancel path would need state, and state that can be wrong about
// whether a host is about to power off is worse than no state.
//
// OPERATIONAL SAFEGUARDS
// POST only, checked first.
// A GET cannot power off the host, so no link, prefetch, bookmark, or browser history
// entry can reach these commands.
//
// Strict allowlist with strict comparison.
// in_array($action, ['stop','shutdown','restart'], true) — the third argument matters.
// Without it PHP's loose comparison would accept values that are not these strings, and
// the match below would then have no arm for them.
//
// The match has no default arm, and that is safe only because of the check above.
// An unmatched action would raise \UnhandledMatchError, which is a hard failure rather
// than a wrong command — but it is unreachable, because the allowlist already rejected
// everything that is not one of the three. The two guards are deliberately redundant.
//
// The audit line is written before the command is dispatched, not after.
// Ordering is the whole point: after a shutdown there is no "after". Timestamp, action
// and originating IP land in actions.log with LOCK_EX while the machine is still
// running, so a power event always has a record of who asked for it.
//
// The audit write is best-effort and never blocks the action.
// @ and FILE_APPEND — a full or read-only flash must not be able to prevent a shutdown.
// Losing the log line is the lesser failure; the log is gitignored and local by design.
//
// REQUEST
// POST {"action":"stop"} stop the array (mdcmd stop)
// POST {"action":"shutdown"} power off the host
// POST {"action":"restart"} reboot the host
//
// RESPONSE
// {"ok":true} accepted and dispatched — not completed
// {"ok":false,"error":"POST only"|"invalid action"}
//
// DEPENDS ON
// include/config.php SCRIPTS_DIR (audit log location)
// /usr/local/sbin/mdcmd array control
// /sbin/shutdown host power control
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
+43
View File
@@ -1,4 +1,47 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Watchdog data endpoint. Every watchdog's current state for every node in one document —
// resource, docker, system, storage, network and stability — together with the thresholds
// each one is judging against, for the watchdog tab's poll.
//
// DESIGN PRINCIPLES
// Thin transport. State-file parsing and threshold resolution live in
// include/watchdog.php; this file only sets the content type and encodes.
//
// Thresholds ship with the state, not separately.
// vv_wd_all() resolves every threshold from master.conf into the same payload as the
// counters they apply to. A strike count means nothing without the limit it is counted
// against, so the page never has to fetch the two independently and risk mismatching
// them across a conf edit.
//
// No parameters. Which watchdogs exist is fixed by the codebase, not by the request.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here clears a strike, lifts a skip-list entry, restarts a container,
// or cancels a pending reboot. The watchdogs own their own state; this endpoint reports it.
//
// Absent counters are reported quiet, not alarming.
// A watchdog that has not yet written state reads as zero rather than unknown, so a
// fresh boot does not light the page up with false strikes.
//
// Thresholds fall back to the shipped defaults.
// Every vv_wd_scalar() lookup has a ?: default, so a master.conf that is mid-edit or
// missing a key still yields a coherent payload instead of comparing counters against
// zero and declaring everything critical.
//
// Remote collection degrades per node — one unreachable partner drops that node's card and
// leaves the local host and every other partner intact.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// vv_wd_all() verbatim — per-node watchdog state plus the resolved threshold set
//
// DEPENDS ON
// include/watchdog.php vv_wd_all()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/watchdog.php';
echo json_encode(vv_wd_all());
+92 -2
View File
@@ -1,6 +1,96 @@
<?php
// Receives Sonarr/Radarr/Lidarr Download events. Fires upgrade_webhook_handler.sh
// in the background and returns 200 immediately — arr does not wait on the push.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Arr download webhook. Receives Sonarr, Radarr and Lidarr Download events and fires
// upgrade_webhook_handler.sh against the affected folder so the partner learns about the
// new file immediately rather than at the next scheduled sync.
//
// OPERATIONAL MODEL
// Accept, dispatch, return. The handler is backgrounded and the response goes out at once,
// because an arr that is made to wait on a cross-host push will time the webhook out and
// log it as a failure — and it retries on a schedule that would compound the problem.
//
// 200 therefore means accepted, not propagated. The handler's own log is the record of what
// happened; this endpoint cannot report it and does not pretend to.
//
// The reason for immediacy is search suppression: until the partner knows a file exists, it
// will keep searching for it. The scheduled sync would close that gap eventually; the
// webhook closes it in seconds.
//
// DESIGN PRINCIPLES
// The arr type is inferred from the payload's shape, not from a parameter.
// series.path means Sonarr, movie.folderPath means Radarr, artist.path means Lidarr.
// Each app names its own field, so the structure identifies the sender without a query
// string the user could get wrong when configuring three separate applications.
//
// Test events are answered with instructions.
// Sonarr's "Test" button gets a message telling the user to configure On Download,
// because a bare success there is exactly what leads to a webhook that is connected and
// wired to nothing.
//
// Every non-Download event is acknowledged and skipped.
// ok:true with the event name, never an error. An arr that receives an error status
// retries and eventually disables the webhook, so events this endpoint does not care
// about have to be accepted rather than rejected.
//
// Fires on all Download events, not just upgrades.
// New grabs and upgrades both need propagating; distinguishing them would suppress
// exactly the first-time grabs the partner is most likely to duplicate.
//
// The kill switch is read per request.
// DOWNLOAD_WEBHOOK_ENABLED is checked from conf on every call, so turning it off takes
// effect without touching the arr configuration — and the response says it was skipped
// rather than silently doing nothing.
//
// OPERATIONAL SAFEGUARDS
// POST only, with a real status code.
// 405 for the wrong method, 400 for unparseable or unrecognised bodies. Status codes
// matter more here than elsewhere in this layer: the caller is a machine that changes
// its retry behaviour based on them.
//
// The path is validated and then escaped.
// Must be absolute, no '..', no null bytes or newlines — then passed through
// escapeshellarg() into the handler invocation. The arr type is likewise escaped even
// though it is one of three literals this file chose itself.
//
// Both the empty-path and unrecognised-structure cases are handled explicitly.
// A payload with a recognised key holding an empty value is rejected separately from
// one whose structure is unknown, because those are different misconfigurations.
//
// A missing handler is reported as a server error, not swallowed.
// 500 with a named error, so a partial deploy is visible in the arr's own webhook log
// rather than appearing to succeed forever.
//
// Output is appended to a dedicated log with stdin detached, so a backgrounded handler
// cannot hold the request's file descriptors open.
//
// Known gap: this endpoint authenticates nothing.
// master.conf carries WEBHOOK_SECRET, and the standalone Node listener on WEBHOOK_PORT
// validates it — this WebGUI-hosted path does not. Anyone who can reach the URL can
// make it run the handler against any absolute path that passes validation. Injection
// is not the risk (the path is escaped); triggering work is. Left as-is deliberately
// rather than fixed in passing: adding a secret check here would break whichever arr
// instances are currently configured against this URL, and that is a change to make
// with the arr configs open, not as part of a documentation pass. See also the CSRF
// note in README-unraid.md.
//
// REQUEST
// POST <arr webhook JSON body>
// eventType=Test → connection acknowledgement
// eventType=Download → dispatches the handler
// any other eventType → acknowledged and skipped
//
// RESPONSE
// {"ok":true,"arr":"sonarr|radarr|lidarr","path":"…"} dispatched
// {"ok":true,"message":"Webhook connected — …"} Test
// {"ok":true,"skipped":"<event>|DOWNLOAD_WEBHOOK_ENABLED=false"}
// {"ok":false,"error":…} with 405 / 400 / 500 as appropriate
//
// DEPENDS ON
// include/config.php vv_conf_vars(), SCRIPTS_DIR
// Media/upgrade_webhook_handler.sh the backgrounded handler
// master.conf DOWNLOAD_WEBHOOK_ENABLED
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
+17 -1
View File
@@ -282,7 +282,23 @@ function vv_conf_write_changes(array $changes): array {
) ?? $raw;
}
}
$results[$file] = vv_write_conf_raw($file, $raw);
// Only the scalar path escapes its value; the array, array_single and assoc_array
// paths splice the caller's text into the file verbatim, and the type comes from the
// request. Every script sources these files, so the result is parsed before it is
// allowed to replace a working conf.
$results[$file] = vv_conf_syntax_ok($raw) && vv_write_conf_raw($file, $raw);
}
return $results;
}
// bash -n against a private temp copy. Returns true when the content parses as a sourceable
// conf, false otherwise — never writes anything itself.
function vv_conf_syntax_ok(string $content): bool {
$tmp = tempnam(sys_get_temp_dir(), 'vvconf');
if ($tmp === false) return true; // cannot check — do not block the write
file_put_contents($tmp, $content);
$out = []; $rc = 0;
exec('bash -n ' . escapeshellarg($tmp) . ' 2>&1', $out, $rc);
@unlink($tmp);
return $rc === 0;
}
+6 -1
View File
@@ -476,7 +476,12 @@ function vv_auto_create_api_key(string $hostId, string $confFile): array {
if (!file_exists($script)) {
return ['ok' => false, 'error' => 'unraid_api_key_renew.sh not found'];
}
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 —
// otherwise a stalled unraid-api call holds a php-fpm worker open indefinitely.
exec('timeout 120 bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
if ($rc === 124) {
return ['ok' => false, 'error' => 'Key renewal timed out after 120s'];
}
if ($rc !== 0) {
$msg = implode(' ', array_filter(array_map('trim', $out)));
return ['ok' => false, 'error' => $msg ?: 'Script failed'];
+6 -2
View File
@@ -579,7 +579,9 @@ function vv_conf_flag_set(string $name, bool $value): bool {
$content, -1, $count
);
if (!$count) return false;
return file_put_contents($confPath, $new) !== false;
// tmp+rename — every script sources master.conf, so a truncated write here is a
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', $new);
}
// Comment or uncomment a script's line in the first master.conf array that contains it.
@@ -607,7 +609,9 @@ function vv_conf_toggle_script(string $rel, bool $enable): bool {
}
unset($line);
if (!$changed) return true;
return file_put_contents($confPath, implode('', $lines)) !== false;
// tmp+rename — every script sources master.conf, so a truncated write here is a
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', implode('', $lines));
}
// Parse an orchestrator script to find which child scripts it calls.