Document the PHP api layer and fix what documenting it exposed

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.
This commit is contained in:
Gmer4Lfe
2026-08-02 10:11:39 -04:00
parent 6a959fb5e4
commit 987313e7dc
55 changed files with 3972 additions and 95 deletions
+128 -4
View File
@@ -1,4 +1,102 @@
<?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';
@@ -89,18 +187,41 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
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'];
if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) continue;
$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);
if (file_put_contents($confPath, implode('', $lines)) === false) $errors[] = 'scripts write failed';
// 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";
}
@@ -114,6 +235,9 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$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";
}