Several scripts delete inside a conf path — the orphan cleaner runs rm -rf under a download dir and rsync runs --delete against a destination — so /mnt/user/Movies becoming /mnt/user is the edit that turns a cleanup into a sweep. Depth cannot be the test, because /tv and /movies are real container-internal values here; direction can. Clearing a path, making it relative and '..' segments go with it, and autofix additionally requires a proposed path to exist, since every probe it has is a network probe and proves nothing about a directory. Refusals now reach the caller: a save whose only change was refused answered ok with no explanation.
150 lines
7.6 KiB
PHP
150 lines
7.6 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') {
|
|
$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'] ?? '[]';
|
|
|
|
if (!$id) { echo json_encode(['ok' => false, 'error' => 'Missing id']); exit; }
|
|
|
|
$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;
|
|
}
|
|
}
|
|
|
|
$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']);
|