Route every conf writer through the guarded path

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.
This commit is contained in:
Gmer4Lfe
2026-08-09 19:07:28 -04:00
parent d9f917ecef
commit 67eabdc17c
10 changed files with 174 additions and 121 deletions
+12 -5
View File
@@ -43,10 +43,17 @@
// outside one, so a matching string in a comment or an unrelated variable cannot be
// rewritten.
//
// The write is atomic.
// vv_conf_toggle_script() writes through vv_write_conf_raw() (tmp + rename). Every
// script sources master.conf; a truncated write here would be a system-wide outage
// rather than a lost toggle.
// The write is atomic, backed up, verified and logged.
// vv_conf_toggle_script() goes through vv_conf_edit(), the one guarded conf write path:
// an exclusive lock, a timestamped copy into CONF_BACKUP_DIR, bash -n on the candidate,
// tmp + rename to install it, then the installed file is sourced to prove it still loads.
// There is no single key to read back for a commented array member, so a clean source is
// the whole assertion. Every script sources master.conf; a truncated or unparseable write
// here would be a system-wide outage rather than a lost toggle.
//
// A script in no array writes nothing at all.
// The rewrite returns the contents unchanged, which reports success without taking a
// backup or touching the file. "Already in the requested state" is not a write.
//
// REQUEST
// POST id=<Category/script.sh> enabled=0|1
@@ -56,7 +63,7 @@
// {"ok":false,"error":"POST only"|"Invalid id"|"Failed to write master.conf"}
//
// DEPENDS ON
// include/scheduler.php vv_conf_toggle_script() → vv_write_conf_raw()
// include/scheduler.php vv_conf_toggle_script() → vv_conf_edit() → vv_write_conf_raw()
// Configurations/master.conf the *_SCRIPTS arrays
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
+21 -23
View File
@@ -38,13 +38,17 @@
// is the difference between a rejected save and a silent, total outage, so a file that
// does not parse is refused and the previous version is left untouched.
//
// The temp copy is created with tempnam() and always removed.
// The candidate is never written next to the real conf and never under a predictable
// name, so a failed validation cannot leave a stray file for a script to source.
// Checked here via vv_conf_syntax_error() only so the editor can show bash's own
// complaint with a line number. vv_conf_edit() checks again before installing; this one
// is for the message, not the decision.
//
// The real write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf
// during the save reads either the old file or the new one, never a half-written one.
// The write goes through the one guarded conf path.
// vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR,
// re-checks the syntax, installs via .vv.tmp + rename() so a concurrent reader sees the
// old file or the new one but never a half-written one, then sources the installed file
// to prove it still loads and restores the backup if it does not. The whole-file nature
// of this endpoint is why that matters most here: there is no key to verify, so a clean
// source is the only assertion available.
//
// REQUEST
// POST file=<allowed conf name> content=<full file text>
@@ -54,10 +58,12 @@
// {"ok":false,"error":"POST only"|"File not permitted"|"Syntax error: …"|"Failed to write file"}
//
// DEPENDS ON
// include/config.php vv_get_conf_files(), vv_write_conf_raw(), CONF_DIR
// include/config.php vv_get_conf_files(), CONF_DIR
// include/confform.php vv_conf_syntax_error(), vv_conf_edit()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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']);
@@ -75,22 +81,14 @@ if (!$file || !in_array($file, $allowed)) {
}
// Every script sources these. A syntax error here takes the whole system down, so the
// candidate is parsed before it is allowed to replace a working file.
$check = tempnam(sys_get_temp_dir(), 'vvconf');
if ($check !== false) {
file_put_contents($check, $content);
$out = []; $rc = 0;
exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc);
@unlink($check);
if ($rc !== 0) {
$msg = implode(' ', array_filter(array_map('trim', $out)));
echo json_encode([
'ok' => false,
'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'),
]);
exit;
}
// candidate is parsed before it is allowed to replace a working file. Checked here as well as
// inside vv_conf_edit() so the editor can show bash's own complaint; the write path only knows
// whether to proceed, not what to tell the person typing.
$syntax = vv_conf_syntax_error($content, $file);
if ($syntax !== null) {
echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax]);
exit;
}
$ok = vv_write_conf_raw($file, $content);
$ok = vv_conf_edit($file, fn() => $content, [], ['whole-file']);
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'Failed to write file']);
+7 -4
View File
@@ -48,9 +48,12 @@
// missing, malformed, or unexpected parameter disables rather than enables. Failing
// toward off is the safe direction for a flag that starts data movement.
//
// The conf write is atomic.
// vv_conf_flag_set() writes through vv_write_conf_raw() (tmp + rename). Every script
// sources master.conf, so a truncated write would be a system-wide outage.
// The conf write is atomic, backed up, verified and logged.
// vv_conf_flag_set() goes through vv_conf_edit(), the one guarded conf write path: an
// exclusive lock, a timestamped copy into CONF_BACKUP_DIR, bash -n on the candidate,
// tmp + rename to install it, then the file is sourced and the flag read back — a value
// that does not come back as asked restores the backup. Every script sources master.conf,
// so a truncated or unparseable write would be a system-wide outage.
//
// The push only happens after a confirmed local write.
// Guarded on $ok, so a failed edit cannot distribute a stale or partly-written conf to
@@ -69,7 +72,7 @@
// "push":[]}
//
// DEPENDS ON
// include/scheduler.php vv_conf_flag_set() → vv_write_conf_raw()
// include/scheduler.php vv_conf_flag_set() → vv_conf_edit() → vv_write_conf_raw()
// include/config.php vv_push_master_conf(), vv_push_setup_state()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
+12 -1
View File
@@ -81,6 +81,7 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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']);
@@ -106,6 +107,9 @@ if (!file_exists($confPath)) {
}
$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;
@@ -148,7 +152,14 @@ if ($toArray) {
$newLines = $resultLines;
}
if (!vv_write_conf_raw('master.conf', implode('', $newLines))) {
// 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;
}
+18 -21
View File
@@ -45,9 +45,13 @@
// The temp copy is created with tempnam() and always removed, so a rejected save cannot
// leave a stray file beside the real conf for a script to source.
//
// The real write is atomic.
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf during
// the save reads either the old file or the new one, never a half-written one.
// The write goes through the one guarded conf path.
// vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR,
// re-checks the syntax, installs via .vv.tmp + rename() so a concurrent reader sees the
// old file or the new one but never a half-written one, then sources the installed file
// to prove it still loads and restores the backup if it does not. That backup matters
// more here than anywhere else: this endpoint replaces a whole hand-edited file, and the
// confs are gitignored, so before it existed a bad paste had nothing to go back to.
//
// The push only happens after a confirmed write.
// Guarded on $written, so a failed save cannot distribute a stale or partly-written
@@ -68,11 +72,13 @@
// {"ok":false,"error":"Not allowed"|"Syntax error: …"|"Method not allowed"}
//
// DEPENDS ON
// include/config.php vv_get_conf_files(), vv_read_conf_raw(), vv_write_conf_raw(),
// vv_push_master_conf(), vv_push_setup_state()
// include/config.php vv_get_conf_files(), vv_read_conf_raw(),
// vv_push_master_conf(), vv_push_setup_state()
// include/confform.php vv_conf_syntax_error(), vv_conf_edit()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
$allowed = vv_get_conf_files();
@@ -95,24 +101,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
// Every script sources these, and master.conf is pushed to every partner from here — a
// syntax error saved through this endpoint would propagate the outage across the mesh.
$check = tempnam(sys_get_temp_dir(), 'vvconf');
if ($check !== false) {
file_put_contents($check, $content);
$out = []; $rc = 0;
exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc);
@unlink($check);
if ($rc !== 0) {
$msg = implode(' ', array_filter(array_map('trim', $out)));
echo json_encode([
'ok' => false,
'error' => 'Syntax error: ' . str_replace($check, $file, $msg ?: 'conf does not parse'),
'push' => [],
]);
exit;
}
// Checked here as well as inside vv_conf_edit() so the editor can show bash's own complaint;
// the write path only knows whether to proceed, not what to tell the person typing.
$syntax = vv_conf_syntax_error($content, $file);
if ($syntax !== null) {
echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax, 'push' => []]);
exit;
}
$written = vv_write_conf_raw($file, $content);
$written = vv_conf_edit($file, fn() => $content, [], ['whole-file']);
$push = [];
if ($written && $file === 'master.conf') {
$push = vv_push_master_conf();
+11 -1
View File
@@ -82,6 +82,7 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════
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']);
@@ -122,6 +123,9 @@ if (!file_exists($confPath)) {
}
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
// Captured before array_splice rewrites the block in place — this is what the write compares
// against to prove master.conf did not change while the new order was being assembled.
$origConf = implode('', $lines ?: []);
if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit;
@@ -183,7 +187,13 @@ $newBlockLines[] = $lines[$blockEnd];
// Replace the original block in $lines
array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlockLines);
if (!vv_write_conf_raw('master.conf', implode('', $lines))) {
// See movescript.php: the block was rebuilt from a copy read before the lock, so the closure
// compares against the current contents and abandons the reorder if anything changed in
// between. Backup, bash -n, read-back and audit come with the shared path.
$ok = vv_conf_edit('master.conf', fn(string $cur): ?string =>
$cur === $origConf ? implode('', $lines) : null, [], [$arrayName]);
if (!$ok) {
echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit;
}
+10 -3
View File
@@ -167,6 +167,9 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
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;
@@ -213,9 +216,13 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
$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))) {
// 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.
+12 -3
View File
@@ -120,6 +120,7 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
? trim($_GET['action'] ?? '')
@@ -277,7 +278,10 @@ if ($action === 'pull') {
'${1}"' . $sshKey . '"', $conf);
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
'${1}' . $storageInternal2, $conf);
vv_write_conf_raw($confFile, $conf);
// allowCreate: this is first-run setup, so the host conf does not exist yet. There
// is no prior content to back up, and a candidate that fails bash -n is removed
// rather than restored.
vv_conf_edit($confFile, fn(): string => $conf, [], ["{$hostId}_SSH_KEY"], true);
}
}
@@ -313,6 +317,9 @@ if (!preg_match('/^host\d+$/', $mySlot)) {
// Write HOST1 / HOST2 into master.conf
$master = vv_read_conf_raw('master.conf');
// Captured before the substitutions below — the write compares against it so a master.conf that
// changed while setup was being filled in is not silently overwritten.
$origMaster = $master;
if ($master === '') {
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
exit;
@@ -333,7 +340,8 @@ if ($slotNum > 2 && !empty($myHostname)) {
}
}
if (!vv_write_conf_raw('master.conf', $master)) {
if (!vv_conf_edit('master.conf', fn(string $cur): ?string => $cur === $origMaster ? $master : null,
[], ['HOST1', 'HOST2'])) {
echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
exit;
}
@@ -368,7 +376,8 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) {
'${1}"' . $sshKeyPath . '"', $conf);
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
'${1}' . $storageInternal, $conf);
if (!vv_write_conf_raw($confFile, $conf)) {
// allowCreate — see the sibling write above; this is the same first-run create.
if (!vv_conf_edit($confFile, fn(): string => $conf, [], ["{$hostId}_SSH_KEY"], true)) {
echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
exit;
}