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 // outside one, so a matching string in a comment or an unrelated variable cannot be
// rewritten. // rewritten.
// //
// The write is atomic. // The write is atomic, backed up, verified and logged.
// vv_conf_toggle_script() writes through vv_write_conf_raw() (tmp + rename). Every // vv_conf_toggle_script() goes through vv_conf_edit(), the one guarded conf write path:
// script sources master.conf; a truncated write here would be a system-wide outage // an exclusive lock, a timestamped copy into CONF_BACKUP_DIR, bash -n on the candidate,
// rather than a lost toggle. // 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 // REQUEST
// POST id=<Category/script.sh> enabled=0|1 // POST id=<Category/script.sh> enabled=0|1
@@ -56,7 +63,7 @@
// {"ok":false,"error":"POST only"|"Invalid id"|"Failed to write master.conf"} // {"ok":false,"error":"POST only"|"Invalid id"|"Failed to write master.conf"}
// //
// DEPENDS ON // 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 // Configurations/master.conf the *_SCRIPTS arrays
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json'); 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 // 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. // does not parse is refused and the previous version is left untouched.
// //
// The temp copy is created with tempnam() and always removed. // Checked here via vv_conf_syntax_error() only so the editor can show bash's own
// The candidate is never written next to the real conf and never under a predictable // complaint with a line number. vv_conf_edit() checks again before installing; this one
// name, so a failed validation cannot leave a stray file for a script to source. // is for the message, not the decision.
// //
// The real write is atomic. // The write goes through the one guarded conf path.
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf // vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR,
// during the save reads either the old file or the new one, never a half-written one. // 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 // REQUEST
// POST file=<allowed conf name> content=<full file text> // 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"} // {"ok":false,"error":"POST only"|"File not permitted"|"Syntax error: …"|"Failed to write file"}
// //
// DEPENDS ON // 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'); header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']); 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 // 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. // candidate is parsed before it is allowed to replace a working file. Checked here as well as
$check = tempnam(sys_get_temp_dir(), 'vvconf'); // inside vv_conf_edit() so the editor can show bash's own complaint; the write path only knows
if ($check !== false) { // whether to proceed, not what to tell the person typing.
file_put_contents($check, $content); $syntax = vv_conf_syntax_error($content, $file);
$out = []; $rc = 0; if ($syntax !== null) {
exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc); echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax]);
@unlink($check); exit;
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;
}
} }
$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']); 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 // missing, malformed, or unexpected parameter disables rather than enables. Failing
// toward off is the safe direction for a flag that starts data movement. // toward off is the safe direction for a flag that starts data movement.
// //
// The conf write is atomic. // The conf write is atomic, backed up, verified and logged.
// vv_conf_flag_set() writes through vv_write_conf_raw() (tmp + rename). Every script // vv_conf_flag_set() goes through vv_conf_edit(), the one guarded conf write path: an
// sources master.conf, so a truncated write would be a system-wide outage. // 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. // 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 // Guarded on $ok, so a failed edit cannot distribute a stale or partly-written conf to
@@ -69,7 +72,7 @@
// "push":[]} // "push":[]}
// //
// DEPENDS ON // 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() // include/config.php vv_push_master_conf(), vv_push_setup_state()
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json'); header('Content-Type: application/json');
+12 -1
View File
@@ -81,6 +81,7 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']); echo json_encode(['ok' => false, 'error' => 'POST only']);
@@ -106,6 +107,9 @@ if (!file_exists($confPath)) {
} }
$lines = file($confPath, FILE_KEEP_BLANK_LINES); $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) { if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']); echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit; exit;
@@ -148,7 +152,14 @@ if ($toArray) {
$newLines = $resultLines; $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']); echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit; exit;
} }
+18 -21
View File
@@ -45,9 +45,13 @@
// The temp copy is created with tempnam() and always removed, so a rejected save cannot // 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. // leave a stray file beside the real conf for a script to source.
// //
// The real write is atomic. // The write goes through the one guarded conf path.
// vv_write_conf_raw() writes .vv.tmp and rename()s, so a script sourcing the conf during // vv_conf_edit() takes an exclusive lock, copies the previous file into CONF_BACKUP_DIR,
// the save reads either the old file or the new one, never a half-written one. // 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. // The push only happens after a confirmed write.
// Guarded on $written, so a failed save cannot distribute a stale or partly-written // 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"} // {"ok":false,"error":"Not allowed"|"Syntax error: …"|"Method not allowed"}
// //
// DEPENDS ON // DEPENDS ON
// include/config.php vv_get_conf_files(), vv_read_conf_raw(), vv_write_conf_raw(), // include/config.php vv_get_conf_files(), vv_read_conf_raw(),
// vv_push_master_conf(), vv_push_setup_state() // vv_push_master_conf(), vv_push_setup_state()
// include/confform.php vv_conf_syntax_error(), vv_conf_edit()
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
$allowed = vv_get_conf_files(); $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 // 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. // syntax error saved through this endpoint would propagate the outage across the mesh.
$check = tempnam(sys_get_temp_dir(), 'vvconf'); // Checked here as well as inside vv_conf_edit() so the editor can show bash's own complaint;
if ($check !== false) { // the write path only knows whether to proceed, not what to tell the person typing.
file_put_contents($check, $content); $syntax = vv_conf_syntax_error($content, $file);
$out = []; $rc = 0; if ($syntax !== null) {
exec('bash -n ' . escapeshellarg($check) . ' 2>&1', $out, $rc); echo json_encode(['ok' => false, 'error' => 'Syntax error: ' . $syntax, 'push' => []]);
@unlink($check); exit;
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;
}
} }
$written = vv_write_conf_raw($file, $content); $written = vv_conf_edit($file, fn() => $content, [], ['whole-file']);
$push = []; $push = [];
if ($written && $file === 'master.conf') { if ($written && $file === 'master.conf') {
$push = vv_push_master_conf(); $push = vv_push_master_conf();
+11 -1
View File
@@ -82,6 +82,7 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'POST only']); echo json_encode(['ok' => false, 'error' => 'POST only']);
@@ -122,6 +123,9 @@ if (!file_exists($confPath)) {
} }
$lines = file($confPath, FILE_KEEP_BLANK_LINES); $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) { if (!$lines) {
echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']); echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
exit; exit;
@@ -183,7 +187,13 @@ $newBlockLines[] = $lines[$blockEnd];
// Replace the original block in $lines // Replace the original block in $lines
array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlockLines); 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']); echo json_encode(['ok' => false, 'error' => 'Write failed']);
exit; exit;
} }
+10 -3
View File
@@ -167,6 +167,9 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (is_array($scripts)) { if (is_array($scripts)) {
$confPath = CONF_DIR . '/master.conf'; $confPath = CONF_DIR . '/master.conf';
$lines = file($confPath, FILE_KEEP_BLANK_LINES) ?: []; $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, '/'); $esc = preg_quote($scriptsVar, '/');
$blockStart = $blockEnd = null; $blockStart = $blockEnd = null;
$depth = 0; $depth = 0;
@@ -213,9 +216,13 @@ if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$newBlock[] = $lines[$blockEnd]; $newBlock[] = $lines[$blockEnd];
array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlock); array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlock);
// tmp+rename — every script sources master.conf, so a truncated write here is a // Shared guarded path: lock, backup, bash -n, atomic install, read-back, audit. The
// system-wide outage, not a lost edit. // closure compares against the current contents first, so a master.conf that changed
if (!vv_write_conf_raw('master.conf', implode('', $lines))) { // 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'; $errors[] = 'scripts write failed';
} else { } else {
// master.conf is shared — mirrors reorderarray/movescript/rawconf. // master.conf is shared — mirrors reorderarray/movescript/rawconf.
+12 -3
View File
@@ -120,6 +120,7 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php'; require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
$action = ($_SERVER['REQUEST_METHOD'] === 'GET') $action = ($_SERVER['REQUEST_METHOD'] === 'GET')
? trim($_GET['action'] ?? '') ? trim($_GET['action'] ?? '')
@@ -277,7 +278,10 @@ if ($action === 'pull') {
'${1}"' . $sshKey . '"', $conf); '${1}"' . $sshKey . '"', $conf);
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m', $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
'${1}' . $storageInternal2, $conf); '${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 // Write HOST1 / HOST2 into master.conf
$master = vv_read_conf_raw('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 === '') { if ($master === '') {
echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']); echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
exit; 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']); echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
exit; exit;
} }
@@ -368,7 +376,8 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) {
'${1}"' . $sshKeyPath . '"', $conf); '${1}"' . $sshKeyPath . '"', $conf);
$conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m', $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
'${1}' . $storageInternal, $conf); '${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"]); echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
exit; exit;
} }
+27 -22
View File
@@ -51,6 +51,7 @@
// Reads/writes HOST*_DOCKER_FOLDER_MAP in host*.conf (used by onboard scripts) // Reads/writes HOST*_DOCKER_FOLDER_MAP in host*.conf (used by onboard scripts)
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';
define('VV_DOCKER_JSON', SCRIPTS_DIR . '/docker_folders.json'); define('VV_DOCKER_JSON', SCRIPTS_DIR . '/docker_folders.json');
define('VV_FV3_JSON', '/boot/config/plugins/folder.view3/docker.json'); define('VV_FV3_JSON', '/boot/config/plugins/folder.view3/docker.json');
@@ -344,17 +345,19 @@ function vv_dk_rename_folder(string $folderId, string $newName): array {
$data[$folderId]['name'] = $newName; $data[$folderId]['name'] = $newName;
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed']; if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
// Update conf: replace old folder name with new name in the map // Update conf: replace old folder name with new name in the map.
// The rebuild is a pure function of the current contents, so it runs inside vv_conf_edit()'s
// lock rather than against a copy read beforehand — there is no window to lose an edit in.
$currentHost = vv_detect_host(); $currentHost = vv_detect_host();
$myId = strtoupper($currentHost); $myId = strtoupper($currentHost);
$raw = vv_read_conf_raw($currentHost . '.conf'); vv_conf_edit($currentHost . '.conf', function (string $raw) use ($myId, $oldName, $newName): string {
$map = vv_dk_read_conf_map($raw, $myId); $map = vv_dk_read_conf_map($raw, $myId);
foreach ($map as &$v) { foreach ($map as &$v) {
if ($v === $oldName) $v = $newName; if ($v === $oldName) $v = $newName;
} }
unset($v); unset($v);
$updated = vv_dk_write_conf_map($raw, $myId, $map); return vv_dk_write_conf_map($raw, $myId, $map);
vv_write_conf_raw($currentHost . '.conf', $updated); }, [], ["{$myId}_DOCKER_FOLDER_MAP"]);
return ['ok' => true]; return ['ok' => true];
} }
@@ -367,14 +370,14 @@ function vv_dk_delete_folder(string $folderId): array {
unset($data[$folderId]); unset($data[$folderId]);
if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed']; if (!vv_dk_write_json($data)) return ['ok' => false, 'error' => 'JSON write failed'];
// Remove from conf map // Remove from conf map — rebuilt inside the lock, see vv_dk_rename_folder() above.
$currentHost = vv_detect_host(); $currentHost = vv_detect_host();
$myId = strtoupper($currentHost); $myId = strtoupper($currentHost);
$raw = vv_read_conf_raw($currentHost . '.conf'); vv_conf_edit($currentHost . '.conf', function (string $raw) use ($myId, $folderName): string {
$map = vv_dk_read_conf_map($raw, $myId); $map = vv_dk_read_conf_map($raw, $myId);
$map = array_filter($map, fn($v) => $v !== $folderName); $map = array_filter($map, fn($v) => $v !== $folderName);
$updated = vv_dk_write_conf_map($raw, $myId, $map); return vv_dk_write_conf_map($raw, $myId, $map);
vv_write_conf_raw($currentHost . '.conf', $updated); }, [], ["{$myId}_DOCKER_FOLDER_MAP"]);
return ['ok' => true]; return ['ok' => true];
} }
@@ -421,22 +424,24 @@ function vv_dk_sync_json_to_conf(): array {
$data = vv_dk_read_json(); $data = vv_dk_read_json();
$currentHost = vv_detect_host(); $currentHost = vv_detect_host();
$myId = strtoupper($currentHost); $myId = strtoupper($currentHost);
$raw = vv_read_conf_raw($currentHost . '.conf'); _vv_dk_sync_conf_from_json($data, $currentHost, $myId);
_vv_dk_sync_conf_from_json($data, $raw, $currentHost, $myId);
return ['ok' => true]; return ['ok' => true];
} }
// Internal: rebuild conf map from current json state and write it // Internal: rebuild conf map from current json state and write it.
function _vv_dk_sync_conf_from_json(array $data, string $raw = '', string $host = '', string $id = ''): void { // The contents to splice into are read inside vv_conf_edit()'s lock. The caller used to be able
// to hand in a copy it had already read; that was only ever an optimisation, and passing a stale
// copy would have written the rest of the conf back as it looked before the lock was taken.
function _vv_dk_sync_conf_from_json(array $data, string $host = '', string $id = ''): void {
if (!$host) $host = vv_detect_host(); if (!$host) $host = vv_detect_host();
if (!$id) $id = strtoupper($host); if (!$id) $id = strtoupper($host);
if (!$raw) $raw = vv_read_conf_raw($host . '.conf');
$map = []; $map = [];
foreach ($data as $f) { foreach ($data as $f) {
$name = $f['name'] ?? ''; $name = $f['name'] ?? '';
foreach ($f['containers'] ?? [] as $c) $map[$c] = $name; foreach ($f['containers'] ?? [] as $c) $map[$c] = $name;
} }
$updated = vv_dk_write_conf_map($raw, $id, $map);
vv_write_conf_raw($host . '.conf', $updated); vv_conf_edit($host . '.conf', fn(string $raw): string => vv_dk_write_conf_map($raw, $id, $map),
[], ["{$id}_DOCKER_FOLDER_MAP"]);
} }
+44 -38
View File
@@ -598,50 +598,56 @@ function vv_conf_flag_value(string $name): bool {
} }
// Write a boolean flag value to master.conf. // Write a boolean flag value to master.conf.
// Goes through vv_conf_edit() for the lock, the pre-write backup, the syntax check and the audit
// line. This used to call vv_write_conf_raw() directly, which gave it tmp+rename atomicity and
// nothing else — no backup, and no check that the file still sourced afterwards.
function vv_conf_flag_set(string $name, bool $value): bool { function vv_conf_flag_set(string $name, bool $value): bool {
$confPath = CONF_DIR . '/master.conf'; if (!vv_conf_key_valid($name)) return false;
$content = file_get_contents($confPath); $val = $value ? 'true' : 'false';
if ($content === false) return false;
$val = $value ? 'true' : 'false'; return vv_conf_edit('master.conf', function (string $content) use ($name, $val): ?string {
$new = preg_replace( $new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m', '/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}', '${1}' . $val . '${3}',
$content, -1, $count $content, -1, $count
); );
if (!$count) return false; // A name that matches no true/false line is a caller error, not an already-correct
// tmp+rename — every script sources master.conf, so a truncated write here is a // state — unlike the membership toggle below, where absence genuinely means nothing
// system-wide outage, not a lost toggle. // to do. Returning null keeps the write from happening and logs reason=no-match.
return vv_write_conf_raw('master.conf', $new); return $count ? $new : null;
}, [$name => $val]);
} }
// Comment or uncomment a script's line in the first master.conf array that contains it. // Comment or uncomment a script's line in the first master.conf array that contains it.
// Goes through vv_conf_edit() for the lock, the pre-write backup, the syntax check and the audit
// line — see vv_conf_flag_set() above for what that replaced. There is no key to verify here,
// so the audit subject is the script id and a clean source is the whole assertion.
function vv_conf_toggle_script(string $rel, bool $enable): bool { function vv_conf_toggle_script(string $rel, bool $enable): bool {
$confPath = CONF_DIR . '/master.conf'; return vv_conf_edit('master.conf', function (string $content) use ($rel, $enable): ?string {
$lines = file($confPath, FILE_KEEP_BLANK_LINES); $lines = preg_split('/(?<=\n)/', $content) ?: [];
if (!$lines) return false; $changed = false;
$changed = false; $inArray = false;
$inArray = false; $relEsc = preg_quote($rel, '/');
$relEsc = preg_quote($rel, '/'); foreach ($lines as &$line) {
foreach ($lines as &$line) { if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
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*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false; if (!$inArray) continue;
if (!$inArray) continue; if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue; $isCommented = (bool)preg_match('/^\s*#/', $line);
$isCommented = (bool)preg_match('/^\s*#/', $line); if ($enable && $isCommented) {
if ($enable && $isCommented) { $line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line); $changed = true;
$changed = true; } elseif (!$enable && !$isCommented) {
} elseif (!$enable && !$isCommented) { $line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line); $changed = true;
$changed = true; }
break;
} }
break; unset($line);
} // A script in no array has nothing to toggle and the conf already reads the way the
unset($line); // caller asked. Returning the content unchanged reports success without a write.
if (!$changed) return true; return $changed ? implode('', $lines) : $content;
// tmp+rename — every script sources master.conf, so a truncated write here is a }, [], [$rel]);
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', implode('', $lines));
} }
// Parse an orchestrator script to find which child scripts it calls. // Parse an orchestrator script to find which child scripts it calls.