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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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, '/') . '/');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) ?: [];
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user