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.
265 lines
14 KiB
PHP
265 lines
14 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Sync window editor. Lists the available scripts and rsync profiles, and saves which
|
|
// scripts and which shares belong to one maintenance window — critical, intermediate, daily
|
|
// or weekly.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// One save writes to two files, because a window is defined in two places. The script list
|
|
// is a *_MAINTENANCE_SCRIPTS array in master.conf, shared across the partnership; the share
|
|
// list is a <HOST>_*_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=<JSON [{"id","enabled"}]> shares=<JSON [{"path","profile"}]>
|
|
// Either list may be omitted; only the ones supplied are written.
|
|
//
|
|
// RESPONSE
|
|
// list_scripts {"ok":true,"groups":{"<folder>":[{"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) ?: [];
|
|
// Captured before array_splice rewrites the block in place — the write compares against
|
|
// it to prove master.conf did not change while the new block was being assembled.
|
|
$origConf = implode('', $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);
|
|
// Shared guarded path: lock, backup, bash -n, atomic install, read-back, audit. The
|
|
// closure compares against the current contents first, so a master.conf that changed
|
|
// while this block was being assembled abandons the write instead of clobbering it.
|
|
$wrote = vv_conf_edit('master.conf', fn(string $cur): ?string =>
|
|
$cur === $origConf ? implode('', $lines) : null, [], [$scriptsVar]);
|
|
|
|
if (!$wrote) {
|
|
$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']);
|