Files
Gmer4Lfe 67eabdc17c Route every conf writer through the guarded path
Eleven call sites wrote master.conf with tmp+rename and nothing else — no backup, no
parse check, no audit — including the two toggles the UI uses most and the raw editor
that installs a whole hand-edited file.
2026-08-09 19:07:28 -04:00

206 lines
9.1 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// 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';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']);
exit;
}
$arrayName = trim($_POST['array_name'] ?? '');
$raw = $_POST['scripts'] ?? '';
$decoded = json_decode($raw, true);
if (!$arrayName || !preg_match('/^[A-Z_]+_SCRIPTS$/', $arrayName)) {
echo json_encode(['ok' => false, 'error' => 'Invalid array_name']);
exit;
}
if (!is_array($decoded)) {
echo json_encode(['ok' => false, 'error' => 'Invalid scripts JSON']);
exit;
}
// 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)) {
echo json_encode(['ok' => false, 'error' => 'Invalid script id: ' . $id]);
exit;
}
$order[] = ['id' => $id, 'enabled' => $enabled];
}
$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) {
echo json_encode(['ok' => false, 'error' => 'master.conf not found']);
exit;
}
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
// Captured before array_splice rewrites the block in place — this is what the write compares
// against to prove master.conf did not change while the new order was being assembled.
$origConf = implode('', $lines ?: []);
if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit;
}
// Find the array block and extract original entry lines keyed by script path.
$arrayEsc = preg_quote($arrayName, '/');
$blockStart = null;
$blockEnd = null;
$depth = 0;
$origEntries = []; // path → original trimmed content line (e.g. '"Daily/script.sh --flag"')
foreach ($lines as $i => $line) {
if ($blockStart === null) {
if (preg_match('/^\s*' . $arrayEsc . '\s*=\s*\(/', $line)) {
$blockStart = $i;
$depth = 1;
}
continue;
}
$depth += substr_count($line, '(');
$depth -= substr_count($line, ')');
if ($depth <= 0) {
$blockEnd = $i;
break;
}
// Collect entries (enabled and commented)
if (preg_match('/^\s*(?:#\s*)?"([^"]+)"/', $line, $m)) {
$parts = preg_split('/\s+/', trim($m[1]));
$path = $parts[0] ?? '';
if (substr($path, -3) === '.sh' && !isset($origEntries[$path])) {
// Store the full quoted expression (may include flags after the path)
$origEntries[$path] = '"' . $m[1] . '"';
}
}
}
if ($blockStart === null || $blockEnd === null) {
echo json_encode(['ok' => false, 'error' => "Array $arrayName not found in master.conf"]);
exit;
}
// Build replacement block lines
$newBlockLines = [];
// Preserve the opening line exactly (e.g. "DAILY_SCRIPTS=(")
$newBlockLines[] = $lines[$blockStart];
foreach ($order as $item) {
$id = $item['id'];
$enabled = $item['enabled'];
$entry = $origEntries[$id] ?? '"' . $id . '"';
$prefix = $enabled ? ' ' : ' # ';
$newBlockLines[] = $prefix . $entry . "\n";
}
// Preserve the closing line exactly
$newBlockLines[] = $lines[$blockEnd];
// Replace the original block in $lines
array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlockLines);
// See movescript.php: the block was rebuilt from a copy read before the lock, so the closure
// compares against the current contents and abandons the reorder if anything changed in
// between. Backup, bash -n, read-back and audit come with the shared path.
$ok = vv_conf_edit('master.conf', fn(string $cur): ?string =>
$cur === $origConf ? implode('', $lines) : null, [], [$arrayName]);
if (!$ok) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
// 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]);