A watchdog threshold was reachable only from the bottom of a list of a hundred and twenty on another tab.
198 lines
11 KiB
PHP
198 lines
11 KiB
PHP
<?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';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
// Section-scoped read. A page that owns a subject rather than a script — the AI tab, and
|
|
// partnership before it — wants the sections whose header names that subject, across every
|
|
// conf file it is allowed to see. Same fields, same shape, same write path back; only the
|
|
// question of "which fields" differs, so it is a mode here rather than a second endpoint
|
|
// with its own copy of the allowlist and the master push.
|
|
$match = trim($_GET['sections'] ?? '');
|
|
if ($match !== '') {
|
|
// Whole word, case-insensitive. A plain substring is far too loose on these headers —
|
|
// "ai" alone also selects Maintenance, Containers, Failover and Arr Failed/Stalled
|
|
// Recovery, which is nine wrong sections out of twenty-one and every one of them looks
|
|
// deliberate once it is on the page.
|
|
//
|
|
// preg_quote first: the needle arrives from a query string, so it is matched as a literal
|
|
// with boundaries around it rather than as a pattern a caller could widen to everything.
|
|
// "*" is every section this host has. Deliberately not the same as an empty needle, which
|
|
// would match everything by accident rather than on purpose.
|
|
// Several words, pipe-separated, each still matched whole and still quoted as a literal.
|
|
// One word was not enough: the Arr sections are named Arr Sync, Lidarr, Sonarr and
|
|
// Radarr, and no single stem catches those without also catching "Array Start", which is
|
|
// the Unraid disk array and nothing to do with them.
|
|
$all = ($match === '*');
|
|
$re = $all ? '' : '/\b(' . implode('|', array_map(
|
|
fn($w) => preg_quote(trim($w), '/'),
|
|
array_filter(explode('|', $match), fn($w) => trim($w) !== ''))) . ')\b/i';
|
|
$out = [];
|
|
foreach (vv_get_conf_files() as $f) {
|
|
foreach (vv_conf_all_groups($f) as $g) {
|
|
$name = (string) ($g['subsection'] ?? '');
|
|
// Excluded everywhere, not only from the catch-all. A section kept out of forms
|
|
// because a text box is the wrong control for it does not become the right
|
|
// control because a different page asked.
|
|
if (isset(VV_UI_SECTION_EXCLUDE[$name])) continue;
|
|
if ($all || preg_match($re, $name)) $out[] = $g;
|
|
}
|
|
}
|
|
echo json_encode(['ok' => true, 'groups' => $out]);
|
|
exit;
|
|
}
|
|
|
|
$id = trim($_GET['id'] ?? '');
|
|
if (!$id || str_contains($id, '..')) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
|
exit;
|
|
}
|
|
$groups = vv_conf_fields_for_script($id);
|
|
echo json_encode(['ok' => true, 'groups' => $groups]);
|
|
exit;
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$id = trim($_POST['id'] ?? '');
|
|
$rawJson = $_POST['changes'] ?? '[]';
|
|
|
|
// Optional. It labels which script's form was open and is used nowhere in the write — every
|
|
// change already names its own file and key, and those are what is validated below. A
|
|
// section-scoped save has no script to name, and inventing one so this check would pass
|
|
// would be a guard that only ever guarded against itself.
|
|
|
|
$changes = json_decode($rawJson, true);
|
|
if (!is_array($changes)) { echo json_encode(['ok' => false, 'error' => 'Invalid changes']); exit; }
|
|
|
|
$allowed = vv_get_conf_files();
|
|
foreach ($changes as $c) {
|
|
if (empty($c['file']) || !in_array($c['file'], $allowed, true)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Unauthorized file: ' . ($c['file'] ?? '')]);
|
|
exit;
|
|
}
|
|
if (empty($c['key']) || !preg_match('/^[A-Z_][A-Z0-9_]*$/', $c['key'])) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid key: ' . ($c['key'] ?? '')]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Declared before it is passed. It is a by-reference array parameter, and an undefined
|
|
// variable arrives there as null — which under PHP 8 is a TypeError thrown before a single
|
|
// byte is written, so every save through this endpoint died with a 500 and the page saw an
|
|
// unparseable response rather than a refusal it could report. The callers that pass no
|
|
// second argument were never affected, which is why it survived: this is the only one.
|
|
$rejected = [];
|
|
$results = vv_conf_write_changes($changes, $rejected);
|
|
|
|
// Propagate master.conf to partner hosts when the owner edits it (mirrors rawconf.php).
|
|
$push = [];
|
|
if (($results['master.conf'] ?? false) === true) {
|
|
$push = vv_push_master_conf();
|
|
vv_push_setup_state();
|
|
}
|
|
|
|
// A refused change never reaches a file, so it leaves no false in $results — a save whose
|
|
// only change was refused used to answer ok:true and show the operator their old value back
|
|
// with no explanation. Refusals are failures here and they are named: the whole point of the
|
|
// path guard is that someone learns their edit would have widened a delete target.
|
|
echo json_encode(['ok' => !in_array(false, $results, true) && !$rejected,
|
|
'files' => $results,
|
|
'rejected' => $rejected,
|
|
'error' => $rejected
|
|
? 'Refused: ' . implode(', ',
|
|
array_map(fn($r) => $r['key'] . ' (' . $r['reason'] . ')', $rejected))
|
|
: null,
|
|
'push' => $push]);
|
|
exit;
|
|
}
|
|
|
|
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|