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.
161 lines
7.4 KiB
PHP
161 lines
7.4 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// 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';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
echo json_encode(['ok' => false, 'error' => 'POST only']);
|
|
exit;
|
|
}
|
|
|
|
$script = trim($_POST['script'] ?? '');
|
|
$toArray = trim($_POST['to_array'] ?? '');
|
|
|
|
if (!$script || str_contains($script, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $script)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid script']);
|
|
exit;
|
|
}
|
|
if ($toArray && !preg_match('/^[A-Z_]+_SCRIPTS$/', $toArray)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid array name']);
|
|
exit;
|
|
}
|
|
|
|
$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);
|
|
if (!$lines) {
|
|
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
|
|
exit;
|
|
}
|
|
|
|
$scriptEsc = preg_quote($script, '/');
|
|
$removedLine = null;
|
|
$inArray = false;
|
|
|
|
// Step 1: find and remove the script line from whatever array it is currently in.
|
|
$newLines = [];
|
|
foreach ($lines as $line) {
|
|
if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
|
|
if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
|
|
if ($inArray && preg_match('/^\s*(?:#\s*)?"' . $scriptEsc . '(?:\s[^"]*)?"/', $line)) {
|
|
$removedLine = ' "' . $script . '"' . "\n"; // normalise indentation when re-inserting
|
|
continue; // drop from current location
|
|
}
|
|
$newLines[] = $line;
|
|
}
|
|
|
|
// Step 2: insert into target array (if specified).
|
|
if ($toArray) {
|
|
$resultLines = [];
|
|
$inTarget = false;
|
|
$inserted = false;
|
|
foreach ($newLines as $line) {
|
|
if (preg_match('/^\s*' . preg_quote($toArray, '/') . '\s*=\s*\(/', $line)) $inTarget = true;
|
|
if ($inTarget && !$inserted && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) {
|
|
$resultLines[] = $removedLine ?? (' "' . $script . '"' . "\n");
|
|
$inTarget = false;
|
|
$inserted = true;
|
|
}
|
|
$resultLines[] = $line;
|
|
}
|
|
if (!$inserted) {
|
|
echo json_encode(['ok' => false, 'error' => 'Target array "' . $toArray . '" not found in master.conf']);
|
|
exit;
|
|
}
|
|
$newLines = $resultLines;
|
|
}
|
|
|
|
if (!vv_write_conf_raw('master.conf', implode('', $newLines))) {
|
|
echo json_encode(['ok' => false, 'error' => 'Write failed']);
|
|
exit;
|
|
}
|
|
|
|
// 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]);
|