to_array=_SCRIPTS // POST script= 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'; require_once dirname(__DIR__) . '/include/confform.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); // Captured before anything is rebuilt from it — this is what the write compares against to // prove master.conf did not change while the move was being worked out. $origConf = implode('', $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; } // The array was rebuilt from a copy read before the lock was taken. Handing vv_conf_edit() a // closure that compares against the current contents turns that into an optimistic check: if // anything changed master.conf in between, the move is abandoned rather than written over the // top of it. Everything else — backup, bash -n, read-back, audit — comes with the shared path. $ok = vv_conf_edit('master.conf', fn(string $cur): ?string => $cur === $origConf ? implode('', $newLines) : null, [], [$script]); if (!$ok) { 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]);