_*_SYNC_SHARES array in this host's own conf, because what a host syncs // is local to it. The three-file model puts them there, and this endpoint respects that // split rather than flattening it. // // The two halves are written independently and their failures reported separately. A shares // write that fails does not roll back a successful scripts write — they are different files // with different consumers, and a partial success is more useful than an all-or-nothing // that leaves both stale. // // Scripts are edited by block surgery, shares by the structured conf writer. The script // array carries inline arguments and comment-disabled entries that must survive a round // trip; the shares array is a flat list this endpoint fully owns. // // DESIGN PRINCIPLES // The window name is a key into a fixed map, never a composed variable. // Four windows, each naming its two conf variables. An unrecognised window is rejected // before anything is read, so no part of the request can name a conf variable directly. // // The script library excludes what cannot be scheduled here. // Plugin, .git, Orchestrators, Custom, Configurations, Deployment, State_Files, data and // the archive folders are filtered out, and root-level scripts are excluded by requiring // at least one directory component. Orchestrators are excluded specifically because // putting one inside another window's array is how a run becomes recursive. // // Original entry lines are preserved through a save. // Existing entries are harvested keyed by script path and reused verbatim, so inline // flags survive. Only a newly added script is written as a bare quoted path. // // Disabled entries stay in the file, commented — the same convention conf_toggle.php and // reorderarray.php use. // // A share's profile is optional and encoded inline as path|profile, matching what rsync.sh // parses. No profile means the default. // // OPERATIONAL SAFEGUARDS // Every script id is validated, and an invalid one fails the request rather than being // skipped. // The block is regenerated from the submitted list alone, so a silently dropped entry is // a script silently removed from its window. Validation completes before the block is // rebuilt. // // Share paths must be absolute with no traversal, and profile names are constrained. // ^[A-Za-z0-9_\-]+$ on the profile, because it is spliced into a quoted conf array // element where a quote would terminate the string and a paren would close the array. // // A missing script array aborts that half of the save. // Both block boundaries must be found, otherwise an error is recorded and nothing is // written — without it, a splice would land at an undefined position. // // Both writes are atomic. // The scripts half goes through vv_write_conf_raw() (tmp + rename) and the shares half // through vv_conf_write_changes(), which does the same. Every script sources master.conf; // a truncated write here would be a system-wide outage rather than a lost edit. // // The shares write is syntax-checked before it lands. // vv_conf_write_changes() runs `bash -n` on the result, so a share path that would not // parse is reported as a failed write with the original conf intact. // // master.conf is pushed to partners after a confirmed write. // It is a shared file; leaving one host's window definition ahead of the other's is what // makes the two run different work. Mirrors reorderarray, movescript and rawconf. // // The script scan is wrapped in a try/catch, so an unreadable subdirectory yields a partial // library rather than a 500. // // Known limit: block detection counts parens textually. // depth is tracked with substr_count, 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 — but a future one would be the thing that broke this. // // REQUEST // GET|POST ?action=list_scripts .sh files grouped by folder, schedulable ones only // GET|POST ?action=list_profiles rsync profile names declared in master.conf // POST action=save win_key=critical|intermediate|daily|weekly // scripts= shares= // Either list may be omitted; only the ones supplied are written. // // RESPONSE // list_scripts {"ok":true,"groups":{"":[{"id","label"}, …]}} // list_profiles {"ok":true,"profiles":["…"]} // save {"ok":bool,"errors":[…]} // {"ok":false,"error":"Invalid window"|"Invalid script id: …"|"Unknown action"} // // DEPENDS ON // include/config.php SCRIPTS_DIR, CONF_DIR, vv_detect_host(), vv_read_conf_raw(), // vv_write_conf_raw(), vv_push_master_conf(), vv_push_setup_state() // include/confform.php vv_conf_write_changes() // Rsync/rsync.sh consumer of the shares arrays this writes // ═══════════════════════════════════════════════════════════════════════════════════════════════ header('Content-Type: application/json'); require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/confform.php'; require_once dirname(__DIR__) . '/include/common.php'; $action = $_GET['action'] ?? $_POST['action'] ?? ''; // ── Script library — all .sh files grouped by folder ───────────────────────── if ($action === 'list_scripts') { $base = rtrim(SCRIPTS_DIR, '/') . '/'; $exclude = ['Plugin', '.git', 'Orchestrators', 'Custom', 'Configurations', 'Deployment', 'State_Files', 'data', 'Old_Arch_Still_Works', 'Kernel']; $groups = []; try { $ri = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(SCRIPTS_DIR, RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($ri as $rf) { if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue; $rel = ltrim(str_replace($base, '', $rf->getPathname()), '/'); $parts = explode('/', $rel); if (count($parts) < 2 || in_array($parts[0], $exclude)) continue; $folder = $parts[0]; $label = str_replace('_', ' ', basename($rel, '.sh')); $groups[$folder][] = ['id' => $rel, 'label' => $label]; } } catch (Exception $e) {} ksort($groups); foreach ($groups as &$g) usort($g, fn($a, $b) => strcmp($a['id'], $b['id'])); echo json_encode(['ok' => true, 'groups' => $groups]); exit; } // ── Profile names from master.conf ──────────────────────────────────────────── if ($action === 'list_profiles') { $raw = vv_read_conf_raw('master.conf'); preg_match_all('/^\s*PROFILE_([A-Z0-9_]+)_RSYNC_OPTS\s*=/m', $raw, $m); $profiles = array_values(array_unique( array_map(fn($n) => strtolower(str_replace('_', '-', $n)), $m[1] ?? []) )); echo json_encode(['ok' => true, 'profiles' => $profiles]); exit; } // ── Save scripts + shares for a window ─────────────────────────────────────── if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') { $winKey = trim($_POST['win_key'] ?? ''); $scripts = json_decode($_POST['scripts'] ?? 'null', true); $shares = json_decode($_POST['shares'] ?? 'null', true); $winMap = [ 'critical' => ['CRITICAL_MAINTENANCE_SCRIPTS', 'CRITICAL_SYNC_SHARES'], 'intermediate' => ['INTERMEDIATE_MAINTENANCE_SCRIPTS', 'INTERMEDIATE_SYNC_SHARES'], 'daily' => ['DAILY_MAINTENANCE_SCRIPTS', 'DAILY_SYNC_SHARES'], 'weekly' => ['WEEKLY_MAINTENANCE_SCRIPTS', 'WEEKLY_SYNC_SHARES'], ]; if (!isset($winMap[$winKey])) { echo json_encode(['ok' => false, 'error' => 'Invalid window']); exit; } [$scriptsVar, $sharesBase] = $winMap[$winKey]; $myId = strtoupper(vv_detect_host()); $sharesVar = "{$myId}_{$sharesBase}"; $errors = []; // ── Scripts → master.conf ───────────────────────────────────────────────── if (is_array($scripts)) { $confPath = CONF_DIR . '/master.conf'; $lines = file($confPath, FILE_KEEP_BLANK_LINES) ?: []; $esc = preg_quote($scriptsVar, '/'); $blockStart = $blockEnd = null; $depth = 0; $origLines = []; foreach ($lines as $i => $line) { if ($blockStart === null) { if (preg_match('/^\s*' . $esc . '\s*=\s*\(/', $line)) { $blockStart = $i; $depth = 1; } continue; } $depth += substr_count($line, '(') - substr_count($line, ')'); if ($depth <= 0) { $blockEnd = $i; break; } if (preg_match('/^\s*(?:#\s*)?"([^"]+)"/', $line, $m)) { $path = explode(' ', trim($m[1]))[0]; if (str_ends_with($path, '.sh') && !isset($origLines[$path])) $origLines[$path] = '"' . $m[1] . '"'; } } if ($blockStart !== null && $blockEnd !== null) { // Validate the whole list before rebuilding. The block is regenerated from this // loop alone, so skipping an invalid entry would silently drop that script from // the orchestrator rather than reporting a bad request. $bad = null; foreach ($scripts as $item) { $id = trim((string)($item['id'] ?? '')); if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) { $bad = $id; break; } } if ($bad !== null) { echo json_encode(['ok' => false, 'error' => 'Invalid script id: ' . $bad]); exit; } $newBlock = [$lines[$blockStart]]; foreach ($scripts as $item) { $id = trim((string)$item['id']); $enabled = !isset($item['enabled']) || (bool)$item['enabled']; $entry = $origLines[$id] ?? '"' . $id . '"'; $prefix = $enabled ? ' ' : ' #'; $newBlock[] = $prefix . $entry . "\n"; } $newBlock[] = $lines[$blockEnd]; array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlock); // tmp+rename — every script sources master.conf, so a truncated write here is a // system-wide outage, not a lost edit. if (!vv_write_conf_raw('master.conf', implode('', $lines))) { $errors[] = 'scripts write failed'; } else { // master.conf is shared — mirrors reorderarray/movescript/rawconf. vv_push_master_conf(); vv_push_setup_state(); } } else { $errors[] = "Array $scriptsVar not found in master.conf"; } } // ── Shares → host*.conf ─────────────────────────────────────────────────── if (is_array($shares)) { $confFile = strtolower($myId) . '.conf'; $inner = ''; foreach ($shares as $item) { $path = trim((string)($item['path'] ?? '')); $profile = trim((string)($item['profile'] ?? '')); if (!$path || str_contains($path, '..') || !str_starts_with($path, '/')) continue; // The profile name is spliced into a quoted conf array element; anything outside // this set could terminate the string or the array. if ($profile !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $profile)) continue; $val = $profile ? "{$path}|{$profile}" : $path; $inner .= ' "' . $val . '"' . "\n"; } $results = vv_conf_write_changes([[ 'file' => $confFile, 'key' => $sharesVar, 'value' => rtrim($inner), 'type' => 'array', ]]); if (in_array(false, $results, true)) $errors[] = 'shares write failed'; } echo json_encode(['ok' => empty($errors), 'errors' => $errors]); exit; } echo json_encode(['ok' => false, 'error' => 'Unknown action']);