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
+27 -22
View File
@@ -51,6 +51,7 @@
// Reads/writes HOST*_DOCKER_FOLDER_MAP in host*.conf (used by onboard scripts)
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';
define('VV_DOCKER_JSON', SCRIPTS_DIR . '/docker_folders.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;
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();
$myId = strtoupper($currentHost);
$raw = vv_read_conf_raw($currentHost . '.conf');
$map = vv_dk_read_conf_map($raw, $myId);
foreach ($map as &$v) {
if ($v === $oldName) $v = $newName;
}
unset($v);
$updated = vv_dk_write_conf_map($raw, $myId, $map);
vv_write_conf_raw($currentHost . '.conf', $updated);
vv_conf_edit($currentHost . '.conf', function (string $raw) use ($myId, $oldName, $newName): string {
$map = vv_dk_read_conf_map($raw, $myId);
foreach ($map as &$v) {
if ($v === $oldName) $v = $newName;
}
unset($v);
return vv_dk_write_conf_map($raw, $myId, $map);
}, [], ["{$myId}_DOCKER_FOLDER_MAP"]);
return ['ok' => true];
}
@@ -367,14 +370,14 @@ function vv_dk_delete_folder(string $folderId): array {
unset($data[$folderId]);
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();
$myId = strtoupper($currentHost);
$raw = vv_read_conf_raw($currentHost . '.conf');
$map = vv_dk_read_conf_map($raw, $myId);
$map = array_filter($map, fn($v) => $v !== $folderName);
$updated = vv_dk_write_conf_map($raw, $myId, $map);
vv_write_conf_raw($currentHost . '.conf', $updated);
vv_conf_edit($currentHost . '.conf', function (string $raw) use ($myId, $folderName): string {
$map = vv_dk_read_conf_map($raw, $myId);
$map = array_filter($map, fn($v) => $v !== $folderName);
return vv_dk_write_conf_map($raw, $myId, $map);
}, [], ["{$myId}_DOCKER_FOLDER_MAP"]);
return ['ok' => true];
}
@@ -421,22 +424,24 @@ function vv_dk_sync_json_to_conf(): array {
$data = vv_dk_read_json();
$currentHost = vv_detect_host();
$myId = strtoupper($currentHost);
$raw = vv_read_conf_raw($currentHost . '.conf');
_vv_dk_sync_conf_from_json($data, $raw, $currentHost, $myId);
_vv_dk_sync_conf_from_json($data, $currentHost, $myId);
return ['ok' => true];
}
// 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 {
// Internal: rebuild conf map from current json state and write it.
// 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 (!$id) $id = strtoupper($host);
if (!$raw) $raw = vv_read_conf_raw($host . '.conf');
$map = [];
foreach ($data as $f) {
$name = $f['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.
// 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 {
$confPath = CONF_DIR . '/master.conf';
$content = file_get_contents($confPath);
if ($content === false) return false;
$val = $value ? 'true' : 'false';
$new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}',
$content, -1, $count
);
if (!$count) return false;
// tmp+rename — every script sources master.conf, so a truncated write here is a
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', $new);
if (!vv_conf_key_valid($name)) return false;
$val = $value ? 'true' : 'false';
return vv_conf_edit('master.conf', function (string $content) use ($name, $val): ?string {
$new = preg_replace(
'/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
'${1}' . $val . '${3}',
$content, -1, $count
);
// A name that matches no true/false line is a caller error, not an already-correct
// state — unlike the membership toggle below, where absence genuinely means nothing
// to do. Returning null keeps the write from happening and logs reason=no-match.
return $count ? $new : null;
}, [$name => $val]);
}
// 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 {
$confPath = CONF_DIR . '/master.conf';
$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) return false;
$changed = false;
$inArray = false;
$relEsc = preg_quote($rel, '/');
foreach ($lines as &$line) {
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) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
$isCommented = (bool)preg_match('/^\s*#/', $line);
if ($enable && $isCommented) {
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$changed = true;
} elseif (!$enable && !$isCommented) {
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$changed = true;
return vv_conf_edit('master.conf', function (string $content) use ($rel, $enable): ?string {
$lines = preg_split('/(?<=\n)/', $content) ?: [];
$changed = false;
$inArray = false;
$relEsc = preg_quote($rel, '/');
foreach ($lines as &$line) {
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) continue;
if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
$isCommented = (bool)preg_match('/^\s*#/', $line);
if ($enable && $isCommented) {
$line = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
$changed = true;
} elseif (!$enable && !$isCommented) {
$line = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
$changed = true;
}
break;
}
break;
}
unset($line);
if (!$changed) return true;
// tmp+rename — every script sources master.conf, so a truncated write here is a
// system-wide outage, not a lost toggle.
return vv_write_conf_raw('master.conf', implode('', $lines));
unset($line);
// A script in no array has nothing to toggle and the conf already reads the way the
// caller asked. Returning the content unchanged reports success without a write.
return $changed ? implode('', $lines) : $content;
}, [], [$rel]);
}
// Parse an orchestrator script to find which child scripts it calls.