Files
Varaverk/Plugin/unraid/api/confform.php
T
Gmer4Lfe f90b23ddd9 Initialise the array the write path collects refusals into
An undefined variable reaching a by-reference array parameter is a TypeError
under PHP 8, so every save through this endpoint died before writing anything.
2026-08-11 19:03:12 -04:00

184 lines
9.7 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.
$re = '/\b' . preg_quote($match, '/') . '\b/i';
$out = [];
foreach (vv_get_conf_files() as $f) {
foreach (vv_conf_all_groups($f) as $g) {
if (preg_match($re, (string) ($g['subsection'] ?? ''))) $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']);