The comments above a setting already state its choices, units and bounds, so the form can read them instead of asking for the file to be annotated first.
892 lines
48 KiB
PHP
892 lines
48 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Maps each script to the conf subsections that configure it, parses those fields into
|
|
// form definitions, and writes edits back to the conf file. This is what lets the
|
|
// scheduler page edit a script's settings without the user opening master.conf.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Section headers in the conf are the schema.
|
|
// The map keys off the literal `# ━━━ Name ━━━` and `# ── Name ──` headers already in
|
|
// the conf files. Documentation structure and form structure are the same thing, so a
|
|
// new setting placed under an existing header appears in the UI with no code change.
|
|
//
|
|
// Edits are surgical, never a rewrite.
|
|
// Only the changed lines are replaced. Comments, ordering, spacing and every unrelated
|
|
// value survive untouched — these files are hand-maintained and heavily commented, and
|
|
// a regenerating writer would destroy that.
|
|
//
|
|
// Structure only; values are not validated.
|
|
// Consistent with conf_upgrade.sh, this reconciles shape and leaves correctness to the
|
|
// consuming script. Shape is enforced — the file must parse, source, and read back the
|
|
// value that was asked for. Whether 300 is a sensible timeout is still not this file's
|
|
// question.
|
|
//
|
|
// One guarded write path, not one set of guards per caller.
|
|
// vv_conf_edit() owns the lock, the backup, the validation and the audit line, and every
|
|
// conf write in the plugin goes through it — the Settings form, both raw editors, the
|
|
// flag toggles, orchestrator membership, script moves and reorders, the rsync window
|
|
// arrays, the docker folder map and first-run setup. vv_write_conf_raw() has exactly one
|
|
// caller left, inside vv_conf_edit() itself.
|
|
//
|
|
// A caller that reaches past it gets tmp + rename and nothing else: no backup, no bash -n,
|
|
// no read-back, no audit line. That is how this started — seven files each with their own
|
|
// partial idea of what a safe conf write was, two of them carrying a copy-pasted bash -n
|
|
// block that failed open.
|
|
//
|
|
// The rewrite happens inside the lock, or it proves nothing moved.
|
|
// $mutate receives the current contents, so a caller that can rebuild from them has no
|
|
// window at all. Callers that must assemble the result first — the ones driven by a form
|
|
// payload — compare against what they read and return null if it no longer matches,
|
|
// which abandons the write rather than reverting a concurrent edit.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// An unmatched section yields no fields rather than a wrong write.
|
|
// If the named subsection is not found the field list comes back empty and nothing is
|
|
// written. Guessing at a target line in a conf file is how an unrelated setting gets
|
|
// overwritten.
|
|
//
|
|
// Writes are confined to the parsed line range.
|
|
// Each field carries the exact line it came from, so a write cannot land outside the
|
|
// subsection it was read from.
|
|
//
|
|
// A value is shell source code, so command substitution is refused outright.
|
|
// Scalars are written inside double quotes and array values are spliced in verbatim, so
|
|
// $(...) or a backtick in a value runs as root in every script that sources the conf. No
|
|
// conf in this repo uses either, so rejecting them costs nothing. $VAR and ${VAR} stay
|
|
// legal — AI_DATA_DIR="${DATA_DIR}/ai" is the established idiom, and a reference resolves
|
|
// to a value where a substitution runs a program.
|
|
//
|
|
// Nothing is written without a backup in hand.
|
|
// The previous contents are copied to CONF_BACKUP_DIR/<file>.<stamp> first, and a backup
|
|
// that cannot be taken cancels the write. The confs are gitignored, so that directory is
|
|
// the entire recovery story — there is no history to revert to.
|
|
//
|
|
// Validated, then verified, then rolled back on failure.
|
|
// bash -n proves the candidate parses; sourcing the installed file and reading the keys
|
|
// back proves the values survived quoting. A scalar that does not read back as the value
|
|
// requested restores the backup. A conf that parses cleanly and holds the wrong string is
|
|
// the failure a syntax check cannot see.
|
|
//
|
|
// Held under an exclusive lock for the whole read-modify-write.
|
|
// Two concurrent savers would otherwise read the same original, and the second rename
|
|
// would discard the first one's change without either reporting a failure.
|
|
//
|
|
// Every outcome is logged, and secrets are logged by name only.
|
|
// LOG_DIR/conf_changes.log records applied, rejected, failed and rolled-back alike. A
|
|
// credential-shaped key logs value=<redacted> — the log proves a change happened, it is
|
|
// not a second copy of the secret.
|
|
//
|
|
// EXPORTS
|
|
// vv_conf_has_sections() does this script have an editable conf section
|
|
// vv_conf_parse_subsection() fields within one named subsection
|
|
// vv_conf_all_groups() every mapped group
|
|
// vv_conf_fields_for_script() form definition for one script
|
|
// vv_conf_write_changes() apply edits back to the conf file
|
|
//
|
|
// CONFIGURATION
|
|
// CONF_DIR master.conf and host*.conf are the read and write targets
|
|
// CONF_BACKUP_DIR DATA_DIR/Backups/Confs — pre-write copies, 0700
|
|
// LOG_DIR conf_changes.log is written here
|
|
// CONF_BACKUP_RETAIN backups kept per conf file (default 30)
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
// confform.php — script→conf-section mapping, field parsing, and write-back.
|
|
|
|
// Map: script relative id → subsection names (must match # ━━━ Name ━━━ or # ── Name ── headers).
|
|
const VV_SCRIPT_CONF_SECTIONS = [
|
|
// Orchestrators
|
|
'Orchestrators/array_started.sh' => ['Array Start'],
|
|
'Orchestrators/array_stopping.sh' => ['Array Stop'],
|
|
'Orchestrators/watchdog_orchestrator.sh' => ['Watchdog Orchestrator', 'System Watchdog'],
|
|
'Orchestrators/critical_sync_maintenance.sh' => ['Critical Sync Maintenance', 'Critical Sync Shares'],
|
|
'Orchestrators/intermediate_sync_maintenance.sh' => ['Intermediate Sync Maintenance', 'Intermediate Sync Shares'],
|
|
'Orchestrators/daily_sync_maintenance.sh' => ['Daily Sync Maintenance', 'Daily Sync Shares'],
|
|
'Orchestrators/weekly_sync_maintenance.sh' => ['Weekly Sync Maintenance', 'Weekly Sync Shares'],
|
|
'Orchestrators/monthly_maintenance.sh' => ['Monthly Maintenance'],
|
|
'Orchestrators/transcode_management.sh' => ['Transcode Management', 'Transcode Manager', 'Transcode Server Array'],
|
|
// Docker Essentials
|
|
'Docker_Essentials/docker_daily_restart.sh' => ['Docker Daily Restart'],
|
|
'Docker_Essentials/docker_weekly_restart.sh' => ['Docker Weekly Restart'],
|
|
'Docker_Essentials/docker_network_connect.sh' => ['Docker Network Connect'],
|
|
'Docker_Essentials/downloaders_reset.sh' => ['Downloaders Reset', 'Downloaders'],
|
|
// Watchdogs
|
|
'Watchdogs/docker_watchdog.sh' => ['Docker Watchdog'],
|
|
'Watchdogs/resource_watchdog.sh' => ['Pressure Thresholds', 'Downloaders', 'SABnzbd Throttle', 'qBittorrent Throttle'],
|
|
'Watchdogs/System/network_watchdog.sh' => ['Network Watchdog'],
|
|
'Plugin/unraid/Watchdogs/System/webgui_watchdog.sh' => ['WebGUI Watchdog'],
|
|
// Media
|
|
'Media/media_cleaner.sh' => ['Media Cleaner'],
|
|
'Media/media_shares_permissions.sh' => ['Media Permissions'],
|
|
// Arrs Stack
|
|
'Arrs_Stack/radarr_cleanup.sh' => ['Arr Cleanup', 'Radarr'],
|
|
'Arrs_Stack/lidarr_cleanup.sh' => ['Arr Cleanup', 'Lidarr'],
|
|
'Arrs_Stack/sonarr_cleanup.sh' => ['Arr Cleanup', 'Sonarr'],
|
|
// Monitors
|
|
'Monitors/cert_monitor.sh' => ['Certificate Monitor'],
|
|
'Monitors/backup_verify.sh' => ['Backup Verify'],
|
|
'Monitors/smart_health.sh' => ['SMART Health'],
|
|
'Monitors/bandwidth_monitor.sh' => ['Bandwidth Monitor'],
|
|
'Monitors/emby_session_report.sh' => ['Emby Session Report', 'Emby'],
|
|
'Monitors/zfs_memory_snapshot.sh' => ['ZFS Report', 'ZFS Memory Snapshot'],
|
|
'Monitors/weekly_health_digest.sh' => ['Health Digest', 'Certificate Monitor'],
|
|
'Monitors/system_tuning_monitor.sh' => ['System Tuning Monitor'],
|
|
// ── Added after auditing the conf against this map ────────────────────────────────────────
|
|
// 99 named subsections existed; 35 were reachable. The rest were configured and documented in
|
|
// the script headers, but the Info & Settings view rendered no Config block at all — which
|
|
// reads as "this script has no settings" rather than "nobody mapped it". Only sections that
|
|
// unambiguously belong to one script are listed: a wrong mapping puts someone else's settings
|
|
// under a script and is worse than the gap it closes.
|
|
'Arrs_Stack/arr_download_orphan_cleaner.sh' => ['Download Orphan Cleaner (arr_download_orphan_cleaner.sh)'],
|
|
'Arrs_Stack/radarr_classification_scan.sh' => ['Arr Content Classification (radarr/sonarr_classification_scan.sh)', 'Radarr'],
|
|
'Arrs_Stack/sonarr_classification_scan.sh' => ['Arr Content Classification (radarr/sonarr_classification_scan.sh)', 'Sonarr'],
|
|
'Arrs_Stack/arr_full_rescan.sh' => ['Arr Full Library Rescan'],
|
|
'Arrs_Stack/arr_corruption_scan.sh' => ['Corruption Scan'],
|
|
'Arrs_Stack/arrs_failed_stalled_recovery.sh' => ['Arr Failed/Stalled Recovery', 'Arr Recovery Toggles'],
|
|
'Media/play_state_sync.sh' => ['Play State Sync', 'Play State Sync — Handback'],
|
|
'Orchestrators/sunday_morning_coffee_report.sh' => ['Sunday Morning Coffee Report'],
|
|
'Fallback/fallback_test.sh' => ['Failover Test'],
|
|
'Rsync/rsync.sh' => ['Rsync Enable/Disable', 'Rsync Defaults',
|
|
'Rsync Profile System', 'Rsync Merge Auto-Promote'],
|
|
'System_Essentials/server_reboot.sh' => ['Reboot', 'Emby'],
|
|
'System_Essentials/clear_logs.sh' => ['Clear Logs'],
|
|
'System_Essentials/inotify_tuning.sh' => ['inotify Tuning'],
|
|
'System_Essentials/docker_syslog_filter.sh' => ['Syslog Filter'],
|
|
'Plugin/unraid/System_Essentials/mover_stop.sh' => ['Mover'],
|
|
'Plugin/unraid/System_Essentials/php_fpm_max_children.sh' => ['PHP-FPM'],
|
|
'Watchdogs/stability_watchdog.sh' => ['Strike and Reboot Loop Settings',
|
|
'RAM Reboot Threshold', 'OOM Bypass Settings'],
|
|
'AI/ai_index.sh' => ['AI Retrieval Index', 'AI Master Switch', 'Ollama'],
|
|
'AI/ai_query.sh' => ['AI Retrieval Index', 'AI Master Switch', 'Ollama'],
|
|
'AI/ai_token_sync.sh' => ['AI Feature Toggles'],
|
|
'Plugin/unraid/Tools/ai_repair_sweep.sh' => ['AI Repair', 'AI Repair Findings',
|
|
'AI Master Switch'],
|
|
// ── Shared host sections ──────────────────────────────────────────────────────────────────
|
|
// A script's settings are not only the ones named after it. Anything talking to Lidarr reads
|
|
// the host's Lidarr block; anything reading playback reads Emby. Those blocks are where the
|
|
// URL and API key actually live, so a script that could not work without them was showing a
|
|
// Config form that omitted the very settings most likely to be wrong.
|
|
//
|
|
// Several scripts pointing at one section is intended, not duplication — the arr cleanups
|
|
// already shared 'Arr Cleanup' this way. Writes are confined to the parsed line range, so a
|
|
// section edited from two places still edits the same lines.
|
|
'Arrs_Stack/lidarr_missing_art.sh' => ['Lidarr'],
|
|
'Arrs_Stack/lidarr_release_fixer.sh' => ['Lidarr'],
|
|
'Arrs_Stack/lidarr_duplicate_artist_cleanup.sh' => ['Lidarr'],
|
|
'Arrs_Stack/playback_aware_lidarr_discovery.sh' => ['Lidarr', 'Emby'],
|
|
'Arrs_Stack/playback_aware_radarr_discovery.sh' => ['Radarr', 'Emby'],
|
|
'Arrs_Stack/playback_aware_sonarr_discovery.sh' => ['Sonarr', 'Emby'],
|
|
'Arrs_Stack/radarr_tmdb_removed.sh' => ['Radarr'],
|
|
'Arrs_Stack/sonarr_tvdb_removed.sh' => ['Sonarr'],
|
|
'Tools/emby_to_lidarr_sync.sh' => ['Emby', 'Lidarr'],
|
|
'Tools/emby_to_radarr_sync.sh' => ['Emby', 'Radarr'],
|
|
'Tools/emby_to_sonarr_sync.sh' => ['Emby', 'Sonarr'],
|
|
'Tools/emby_database_repair.sh' => ['Emby'],
|
|
'Tools/bulk_permissions_repair.sh' => ['Media Permissions'],
|
|
'Tools/trailer_folder_migration.sh' => ['Media Permissions'],
|
|
'Transcodes/transcode_manager.sh' => ['Transcode Manager', 'Transcode Server Array'],
|
|
// fallback.sh owns the whole failover surface: which tiers it covers, how long it waits, what
|
|
// it does to DDNS, and what it hands back. All four sections are its configuration.
|
|
'Fallback/fallback.sh' => ['Fallback Tiers — What HOSTN Wants Covered When Down',
|
|
'Tier Delays — HOSTN Outage Timers',
|
|
'Internet Loss', 'DDNS',
|
|
'Play State Sync — Handback'],
|
|
];
|
|
|
|
function vv_conf_has_sections(string $id): bool {
|
|
return !empty(VV_SCRIPT_CONF_SECTIONS[$id] ?? []);
|
|
}
|
|
|
|
// Parse fields from a named subsection in raw conf content.
|
|
// Headers accepted: # ━━━ Name ━━━ OR # ── Name ── (any mix of ━ ─ chars).
|
|
// Returns array of field defs, or null if subsection not found.
|
|
function vv_conf_parse_subsection(string $raw, string $subName, string $filename): ?array {
|
|
$lines = explode("\n", $raw);
|
|
$n = count($lines);
|
|
// Host slot normalised on both sides. Section titles carry the host they belong to — the
|
|
// template ships "Tier Delays — HOSTN Outage Timers" and conf_upgrade substitutes it to HOST1
|
|
// or HOST2 per machine — so a literal name in the map can only ever match one host, and on
|
|
// the other it silently finds nothing. Folding HOST<n> back to HOSTN makes one entry correct
|
|
// everywhere, which matters because this map is shared code and the conf files are not.
|
|
$slot = fn(string $s): string
|
|
=> preg_replace('/\bhost\d+\b/', 'hostn', mb_strtolower(trim(preg_replace('/\s+/', ' ', $s))));
|
|
$needle = $slot($subName);
|
|
$start = -1;
|
|
|
|
// /u is load-bearing, not tidiness. Without it the character class matches BYTES, and an
|
|
// em-dash (E2 80 94) is built entirely from bytes that also appear in ━ and ─. So a name
|
|
// containing one terminated early: "Tier Delays — HOSTN Outage Timers" captured as "Tier
|
|
// Delays", matched nothing, and every section with a dash in its title was unreachable —
|
|
// silently, because an unfound section is defined to return no fields.
|
|
for ($i = 0; $i < $n; $i++) {
|
|
if (!preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $lines[$i], $m)) continue;
|
|
if ($slot($m[1]) === $needle) { $start = $i + 1; break; }
|
|
}
|
|
if ($start === -1) return null;
|
|
|
|
// End at next section/subsection line (3+ consecutive divider chars after #)
|
|
$end = $n;
|
|
for ($i = $start; $i < $n; $i++) {
|
|
if (preg_match('/^#\s*[━─═=]{3,}/', $lines[$i])) { $end = $i; break; }
|
|
}
|
|
|
|
return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null;
|
|
}
|
|
|
|
// Which control a field should be drawn with. Derived, never stored — the conf file stays the
|
|
// only schema, so this reads what is already written there rather than asking for the file to be
|
|
// annotated first. A key added tomorrow gets the right control with no code change and no markup.
|
|
//
|
|
// The order matters: an explicit statement in the comment beats a guess from the value, because
|
|
// the guess cannot tell "false" the boolean from "false" the string a script compares against.
|
|
//
|
|
// Nothing here validates. Consistent with the rest of this file, it decides shape and leaves
|
|
// correctness to the consuming script — a number field with min/max is a courtesy to whoever is
|
|
// typing, not a promise that the value is sensible.
|
|
function _vv_conf_widget(string $key, string $value, string $type, string $desc, array $descLines): array {
|
|
// Arrays get the block editor whatever they contain. One value per line is already the right
|
|
// control for a list, and there is no second guess worth making.
|
|
if ($type !== 'scalar') return ['widget' => 'lines'];
|
|
|
|
// Secrets never render in the clear, whatever their value looks like. Reuses the same rule
|
|
// the audit log redacts by, so a key cannot be a secret in the log and plain text in a form.
|
|
if (vv_conf_key_is_secret($key)) return ['widget' => 'secret'];
|
|
|
|
// The convention already in the confs: each choice on its own comment line, quoted, then a
|
|
// dash and what it does. It was written as documentation and happens to be a complete enum
|
|
// declaration, which is exactly the property this file is built around.
|
|
//
|
|
// # "warn" — log warning and continue
|
|
// # "abort" — refuse to continue (strict)
|
|
// UNRAID_VERSION_MISMATCH_ACTION="warn"
|
|
$choices = [];
|
|
foreach ($descLines as $line) {
|
|
if (preg_match('/^"([^"]+)"\s*(?:[—–-]\s*(.*))?$/u', trim($line), $m)) {
|
|
$choices[] = ['value' => $m[1], 'hint' => trim($m[2] ?? '')];
|
|
}
|
|
}
|
|
// A single quoted line is a quotation, not a choice list.
|
|
if (count($choices) > 1) {
|
|
// A value outside its own choice list is a misconfiguration, and dropping it to make the
|
|
// list tidy would change the setting the moment the form saved. It is offered as a choice
|
|
// instead, so the dropdown shows what is actually set and can also correct it.
|
|
$have = array_column($choices, 'value');
|
|
if (!in_array($value, $have, true)) {
|
|
array_unshift($choices, ['value' => $value, 'hint' => 'current value — not one of the documented options']);
|
|
}
|
|
return ['widget' => 'enum', 'choices' => $choices];
|
|
}
|
|
|
|
// The other convention already in the confs: the choices listed inline after the value, either
|
|
// as the whole comment or after a colon.
|
|
//
|
|
// DIGEST_PROFILE="weekly" # always | smart | weekly
|
|
// SONARR_DISCOVERY_MONITOR_MODE="all" # monitor mode on add: all | future | first | none
|
|
//
|
|
// Anchored to the end of the comment and required to contain the current value. The pattern is
|
|
// loose enough to match ordinary prose containing a pipe, and "the value is one of these" is
|
|
// the only signal that separates a choice list from a sentence.
|
|
if (preg_match('/(?:^|:\s*)([A-Za-z0-9_.\-]+(?:\s*\|\s*[A-Za-z0-9_.\-]+)+)\s*$/', trim($desc), $m)) {
|
|
$opts = array_map('trim', explode('|', $m[1]));
|
|
if (count($opts) > 1 && in_array($value, $opts, true)) {
|
|
return ['widget' => 'enum',
|
|
'choices' => array_map(fn($o) => ['value' => $o, 'hint' => ''], $opts)];
|
|
}
|
|
}
|
|
|
|
// The escape hatch, for a setting whose options are not already spelled out above it. Written
|
|
// to read as English so adding one does not turn the conf into a config language.
|
|
if (preg_match('/\bone of:\s*([^.;#]+)/i', $desc, $m)) {
|
|
$opts = array_values(array_filter(array_map('trim', preg_split('/\s*[,|]\s*/', $m[1])), 'strlen'));
|
|
if (count($opts) > 1) {
|
|
return ['widget' => 'enum',
|
|
'choices' => array_map(fn($o) => ['value' => trim($o, '"\''), 'hint' => ''], $opts)];
|
|
}
|
|
}
|
|
|
|
$v = strtolower(trim($value));
|
|
if ($v === 'true' || $v === 'false') return ['widget' => 'bool'];
|
|
|
|
// A file mode is digits and is not a number. A spinner invites arrowing 755 to 756, and a
|
|
// number input normalises a leading zero away — so 0755 would save as 755, which is the same
|
|
// mode today and a different one the day anything reads it as octal. Kept as plain text.
|
|
if (preg_match('/(CHMOD|_MODE|PERM|UMASK)/', $key) && preg_match('/^0?[0-7]{3}$/', trim($value))) {
|
|
return ['widget' => 'text'];
|
|
}
|
|
|
|
if (preg_match('/^-?\d+$/', trim($value))) {
|
|
$out = ['widget' => 'int'];
|
|
// "clamped to 1-50", "between 1 and 50". Already written in several confs, so the range
|
|
// that documents the setting also bounds the field.
|
|
if (preg_match('/(?:clamped to|between)\s*(-?\d+)\s*(?:-|to|and)\s*(-?\d+)/i', $desc, $r)) {
|
|
$out['min'] = (int) $r[1];
|
|
$out['max'] = (int) $r[2];
|
|
}
|
|
// The house style opens an inline comment with the unit — "# seconds — probe when
|
|
// resolving which node has Ollama" — so the unit is already there to be shown beside
|
|
// the box instead of buried in the description.
|
|
if (preg_match('/^\s*(seconds?|minutes?|hours?|days?|weeks?|months?|percent|%|[KMGT]B|'
|
|
. 'chars?|characters?|tokens?|conversations?|entries|items|lines|runs|'
|
|
. 'attempts|retries|consecutive)\b/i', $desc, $u)) {
|
|
$out['unit'] = strtolower($u[1]);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// A path gets a wider box and no monospace surprises. ${VAR}/... is the established idiom.
|
|
if (str_starts_with($value, '/') || str_contains($value, '${')) return ['widget' => 'path'];
|
|
|
|
return ['widget' => 'text'];
|
|
}
|
|
|
|
// Parse all config fields between two line indices. Shared by vv_conf_parse_subsection()
|
|
// (per-script editor) and vv_conf_all_groups() (full settings view).
|
|
function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array {
|
|
$fields = [];
|
|
$pendingDesc = [];
|
|
|
|
for ($i = $start; $i < $end; $i++) {
|
|
$line = rtrim($lines[$i]);
|
|
|
|
if ($line === '' || $line === '#') { $pendingDesc = []; continue; }
|
|
|
|
// Pure comment line
|
|
if (preg_match('/^#\s*(.*)$/', $line, $cm)) {
|
|
$inner = trim($cm[1]);
|
|
if ($inner !== '' && !preg_match('/^[━─═=\-\s]+$/', $inner)) {
|
|
$pendingDesc[] = $inner;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$desc = implode(' ', $pendingDesc);
|
|
// Kept as lines as well as joined. The enum convention lives in the line breaks — one
|
|
// quoted choice per comment line — and joining them destroys the only thing that
|
|
// distinguishes a choice list from a paragraph that happens to contain quotes.
|
|
$descLines = $pendingDesc;
|
|
$pendingDesc = [];
|
|
|
|
// declare -A KEY=(
|
|
if (preg_match('/^(\s*)declare\s+-A\s+([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
|
|
$indent = $m[1]; $key = $m[2];
|
|
$blockLines = [];
|
|
$j = $i + 1;
|
|
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
|
|
$blockLines[] = rtrim($lines[$j]);
|
|
$j++;
|
|
}
|
|
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
|
|
'type' => 'assoc_array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
|
|
$i = $j;
|
|
continue;
|
|
}
|
|
|
|
// KEY=( (array)
|
|
if (preg_match('/^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*\(/', $line, $m)) {
|
|
$indent = $m[1]; $key = $m[2];
|
|
// Single-line: KEY=( ... )
|
|
if (preg_match('/^[^(]*\(([^)]*)\)/', $line, $sm)) {
|
|
$fields[] = ['key' => $key, 'value' => $sm[1],
|
|
'type' => 'array_single', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
|
|
continue;
|
|
}
|
|
// Multi-line
|
|
$blockLines = [];
|
|
$j = $i + 1;
|
|
while ($j < $end && !preg_match('/^\s*\)\s*$/', $lines[$j])) {
|
|
$blockLines[] = rtrim($lines[$j]);
|
|
$j++;
|
|
}
|
|
$fields[] = ['key' => $key, 'value' => implode("\n", $blockLines),
|
|
'type' => 'array', 'desc' => $desc, 'file' => $filename, 'indent' => $indent];
|
|
$i = $j;
|
|
continue;
|
|
}
|
|
|
|
// Scalar: KEY="value" or KEY=value
|
|
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
|
|
$fields[] = ['key' => $m[1], 'value' => $m[2],
|
|
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
|
|
'_lines' => $descLines];
|
|
continue;
|
|
}
|
|
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
|
|
$val = trim($m[2]);
|
|
if ($val === '') continue;
|
|
$fields[] = ['key' => $m[1], 'value' => $val,
|
|
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
|
|
'_lines' => $descLines];
|
|
}
|
|
}
|
|
|
|
// Widget is added in one pass rather than at each of the five construction sites above, so
|
|
// every field type goes through the same rules and a new type cannot quietly skip them.
|
|
foreach ($fields as &$f) {
|
|
$f += _vv_conf_widget((string) $f['key'], (string) $f['value'], (string) $f['type'],
|
|
(string) $f['desc'], (array) ($f['_lines'] ?? []));
|
|
// Internal to this pass. Left in, it would ride out to the browser on every field as a
|
|
// second, subtly different copy of the description.
|
|
unset($f['_lines']);
|
|
}
|
|
unset($f);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
// Return ALL config groups (every named section + its fields) for a conf file.
|
|
// Enumerates header lines (# ━━━ Name ━━━ or # ── Name ──); each group runs from its
|
|
// header to the next named header so major sections (sandwiched in ===) capture their
|
|
// settings too. Empty groups (divider-only headers) are dropped.
|
|
function vv_conf_all_groups(string $filename): array {
|
|
$raw = vv_read_conf_raw($filename);
|
|
if ($raw === '') return [];
|
|
$lines = explode("\n", $raw);
|
|
$n = count($lines);
|
|
|
|
$headers = [];
|
|
for ($i = 0; $i < $n; $i++) {
|
|
// /u for the same reason as the locator above: without it a name containing an em-dash is
|
|
// truncated at the dash, and the full-settings view labels the group with half its title.
|
|
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $lines[$i], $m)) {
|
|
$headers[] = ['name' => trim(preg_replace('/\s+/', ' ', $m[1])), 'line' => $i];
|
|
}
|
|
}
|
|
|
|
$groups = [];
|
|
foreach ($headers as $idx => $h) {
|
|
$start = $h['line'] + 1;
|
|
$end = $headers[$idx + 1]['line'] ?? $n;
|
|
$fields = _vv_conf_parse_field_range($lines, $start, $end, $filename);
|
|
if ($fields) {
|
|
$groups[] = ['subsection' => $h['name'], 'file' => $filename, 'fields' => $fields];
|
|
}
|
|
}
|
|
return $groups;
|
|
}
|
|
|
|
// Return all conf groups (subsection + fields) for a script on the current host.
|
|
function vv_conf_fields_for_script(string $id): array {
|
|
$sectionNames = VV_SCRIPT_CONF_SECTIONS[$id] ?? [];
|
|
if (!$sectionNames) return [];
|
|
|
|
$groups = [];
|
|
foreach ($sectionNames as $name) {
|
|
foreach (vv_get_conf_files() as $filename) {
|
|
$fields = vv_conf_parse_subsection(vv_read_conf_raw($filename), $name, $filename);
|
|
if ($fields !== null) {
|
|
$groups[] = ['subsection' => $name, 'file' => $filename, 'fields' => $fields];
|
|
}
|
|
}
|
|
}
|
|
return $groups;
|
|
}
|
|
|
|
// A conf key must be a plain shell identifier. Every downstream use — the replacement regex,
|
|
// the source-verification subshell, the audit line — treats the key as trusted text, so it is
|
|
// validated once here rather than escaped differently in three places.
|
|
function vv_conf_key_valid(string $key): bool {
|
|
return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key);
|
|
}
|
|
|
|
// Credential-shaped keys are logged by name only. The audit log is the record that a change
|
|
// happened, not a second copy of the secret that changed. Also decides which fields render
|
|
// masked, so the same key cannot be redacted in the log and legible in a form.
|
|
//
|
|
// The match is deliberately loose — the second alternative is unanchored, so anything merely
|
|
// CONTAINING "token" or "pass" counts. Over-redacting a setting costs a little readability;
|
|
// under-redacting one puts a credential in a log file, so the bias is correct and stays.
|
|
//
|
|
// The exception exists because one family trips it on a word that means something else entirely:
|
|
// AI_TOKEN_* is LLM token accounting — a path, a row cap, a switch — and nothing there is a
|
|
// credential. Named as a literal prefix rather than by relaxing the rule, so this cannot widen
|
|
// into "token sometimes means tokens" and quietly expose a real one.
|
|
function vv_conf_key_is_secret(string $key): bool {
|
|
if (str_starts_with($key, 'AI_TOKEN_')) return false;
|
|
return (bool) preg_match('/(PASS|PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|_KEY)$|(PASS|PASSWORD|SECRET|TOKEN|APIKEY)/i', $key);
|
|
}
|
|
|
|
// Scalar values are written inside double quotes and array values are spliced in verbatim, so
|
|
// a value is shell source code the moment any script reads the conf. Command substitution in a
|
|
// value therefore executes on every load — in every script, as root. No conf in this repo uses
|
|
// it, so rejecting it costs nothing and closes the path.
|
|
//
|
|
// $VAR and ${VAR} stay legal on purpose: AI_DATA_DIR="${DATA_DIR}/ai" is the established idiom
|
|
// here, and a reference resolves to a value where a substitution runs a program.
|
|
function vv_conf_value_safe(string $value): bool {
|
|
return !preg_match('/\$\(|`|<\(|>\(/', $value);
|
|
}
|
|
|
|
// ── Path values ──────────────────────────────────────────────────────────────────────────────
|
|
// A conf path is not just a string: several scripts delete inside one. arr_download_orphan_cleaner
|
|
// runs rm -rf on entries under a download dir, rsync.sh runs --delete against a profile's
|
|
// destination, and the transcode ramdisk tears down its own mount point. Those are correct
|
|
// against /mnt/user/Movies and catastrophic against /mnt/user, and nothing between the form and
|
|
// the file could previously tell those apart — bash -n proves the file parses, and the read-back
|
|
// proves the value arrived, but both are perfectly happy with a value that will erase a share.
|
|
//
|
|
// Depth is not the test. /tv, /movies and "/kids movies" are all real values in host1.conf: they
|
|
// are paths inside the arr containers, and a rule requiring two segments would refuse the conf
|
|
// this host already runs. What separates a safe path edit from a dangerous one is not how deep
|
|
// the new value is but which direction it moved — widening a path to its own ancestor is the
|
|
// edit that turns a targeted cleanup into a sweep, and it is meaningful at every depth.
|
|
//
|
|
// Scalars only. Arrays are spliced in verbatim by a different branch of the writer, and the arr
|
|
// root-folder maps that live in them deserve their own pass; what matters here is that every
|
|
// value the repair subsystem writes is a scalar, so its whole surface is covered.
|
|
//
|
|
// Returns null when the write is allowed, or the reason it is not.
|
|
function vv_conf_path_write_ok(string $key, string $new, ?string $old): ?string {
|
|
$isPath = fn(?string $v): bool => $v !== null && $v !== '' && $v[0] === '/';
|
|
|
|
// A ${VAR} reference is the established idiom for composing paths here and cannot be
|
|
// resolved statically, so it is left to the read-back the way it always was.
|
|
if ($new !== '' && $new[0] === '$') return null;
|
|
|
|
// Not a path write at all unless one side of it is a path.
|
|
if (!$isPath($new) && !$isPath($old)) return null;
|
|
|
|
// A newline inside double quotes is legal bash, so this is one of the few malformations
|
|
// bash -n cannot catch. A path carrying one arrives at rsync or rm as two arguments.
|
|
if (preg_match('/[\r\n\x00]/', $new)) return 'newline-in-path';
|
|
|
|
// Blanking a path is the classic: rm -rf "$DIR"/* with an empty DIR is rm -rf /*. The raw
|
|
// conf editor is the way to empty one deliberately — this writer is the automated path.
|
|
if ($isPath($old) && $new === '') return 'path-cleared';
|
|
|
|
// Was a path, is now something that resolves against whatever directory the script happens
|
|
// to be in. There is no safe answer to "delete inside Movies" when Movies is relative.
|
|
if ($isPath($old) && !$isPath($new)) return 'path-became-relative';
|
|
|
|
if (!$isPath($new)) return null;
|
|
|
|
// '..' as a whole segment. Not a substring match — a share legitimately named "..old" is not
|
|
// traversal, and a rule that cannot tell them apart teaches people to work around it.
|
|
foreach (explode('/', $new) as $seg) {
|
|
if ($seg === '..') return 'path-traversal';
|
|
}
|
|
|
|
$norm = fn(string $p): string => rtrim(preg_replace('#/+#', '/', $p), '/');
|
|
$n = $norm($new);
|
|
if ($n === '') return 'path-is-root';
|
|
|
|
// The rule this whole function exists for. An ancestor is matched at the segment boundary so
|
|
// /mnt/user/Movies2 is not read as a parent of /mnt/user/Movies.
|
|
if ($isPath($old)) {
|
|
$o = $norm((string)$old);
|
|
if ($o !== $n && str_starts_with($o . '/', $n . '/')) return 'path-broadened';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Under DATA_DIR, not beside the confs. data/ is the one on-disk root and is gitignored whole,
|
|
// so a backup here cannot become a tracked file the way Configurations/*.bak did. 0700 because
|
|
// these are verbatim copies of files holding every credential on the host.
|
|
function vv_conf_backup_dir(): string {
|
|
if (!is_dir(CONF_BACKUP_DIR)) @mkdir(CONF_BACKUP_DIR, 0700, true);
|
|
return CONF_BACKUP_DIR;
|
|
}
|
|
|
|
// Copy the current conf aside before it is touched. Returns the backup path, or null when no
|
|
// backup could be taken — which the caller treats as a reason not to write, because the whole
|
|
// recovery story for these files is this directory.
|
|
function vv_conf_backup(string $filename): ?string {
|
|
$src = CONF_DIR . '/' . $filename;
|
|
if (!is_file($src)) return null;
|
|
|
|
$dest = vv_conf_backup_dir() . '/' . $filename . '.' . date('Y-m-d\THis');
|
|
// Same second, second change: keep both rather than silently overwrite the older one.
|
|
if (file_exists($dest)) {
|
|
$n = 1;
|
|
while (file_exists($dest . '.' . $n)) $n++;
|
|
$dest .= '.' . $n;
|
|
}
|
|
if (!@copy($src, $dest)) return null;
|
|
@chmod($dest, 0600);
|
|
|
|
vv_conf_prune_backups($filename);
|
|
return $dest;
|
|
}
|
|
|
|
// Retain the newest CONF_BACKUP_RETAIN backups per conf file. Pruned by filename, which sorts
|
|
// chronologically because the stamp is ISO-8601 — no stat() per candidate.
|
|
function vv_conf_prune_backups(string $filename): void {
|
|
$keep = (int) (vv_conf_vars()['CONF_BACKUP_RETAIN'] ?? 30);
|
|
if ($keep < 1) $keep = 30;
|
|
|
|
$found = glob(vv_conf_backup_dir() . '/' . $filename . '.*') ?: [];
|
|
if (count($found) <= $keep) return;
|
|
|
|
sort($found);
|
|
foreach (array_slice($found, 0, count($found) - $keep) as $old) @unlink($old);
|
|
}
|
|
|
|
// Source a candidate conf in a subshell and read back the keys that were just written.
|
|
// bash -n proves the file parses; this proves the values survived quoting and arrived as
|
|
// intended. Returns null when the file could not be sourced at all.
|
|
function vv_conf_read_back(string $path, array $keys): ?array {
|
|
$script = 'source ' . escapeshellarg($path) . ' >/dev/null 2>&1 || exit 90; ';
|
|
foreach ($keys as $k) {
|
|
if (!vv_conf_key_valid($k)) continue;
|
|
$script .= 'printf "%s\t%s\n" ' . escapeshellarg($k) . ' "${' . $k . '-}"; ';
|
|
}
|
|
|
|
$out = []; $rc = 0;
|
|
exec('bash -c ' . escapeshellarg($script) . ' 2>/dev/null', $out, $rc);
|
|
if ($rc !== 0) return null;
|
|
|
|
$vals = [];
|
|
foreach ($out as $line) {
|
|
$parts = explode("\t", $line, 2);
|
|
if (count($parts) === 2) $vals[$parts[0]] = $parts[1];
|
|
}
|
|
return $vals;
|
|
}
|
|
|
|
// One line per conf change, best-effort and never able to block the write itself.
|
|
function vv_conf_audit(string $file, string $key, string $outcome, string $detail = ''): void {
|
|
$line = date('Y-m-d H:i:s')
|
|
. " file={$file} key={$key} outcome={$outcome}"
|
|
. ($detail !== '' ? " {$detail}" : '')
|
|
. ' ip=' . ($_SERVER['REMOTE_ADDR'] ?? 'cli')
|
|
. "\n";
|
|
@file_put_contents(LOG_DIR . '/conf_changes.log', $line, FILE_APPEND | LOCK_EX);
|
|
}
|
|
|
|
// Write a batch of field changes back to their respective conf files.
|
|
// Each change: {file, key, value, type}
|
|
//
|
|
// $rejected collects what was refused before any file was touched. A rejected change is dropped
|
|
// from the batch, so a request whose only change is refused produces no results at all — and
|
|
// "no results" and "nothing went wrong" were indistinguishable to a caller checking for false.
|
|
// The settings form reported a save that had silently not happened. Callers that care pass this
|
|
// and say so; the two that write assoc arrays are unaffected and do not.
|
|
function vv_conf_write_changes(array $changes, array &$rejected = []): array {
|
|
$rejected = [];
|
|
$refuse = function (array $c, string $reason) use (&$rejected): void {
|
|
vv_conf_audit((string) $c['file'], (string) $c['key'], 'rejected', 'reason=' . $reason);
|
|
$rejected[] = ['file' => (string) $c['file'], 'key' => (string) $c['key'], 'reason' => $reason];
|
|
};
|
|
|
|
$byFile = [];
|
|
foreach ($changes as $c) {
|
|
if (empty($c['file']) || empty($c['key'])) continue;
|
|
if (!vv_conf_key_valid($c['key'])) {
|
|
$refuse($c, 'malformed-key');
|
|
continue;
|
|
}
|
|
if (!vv_conf_value_safe((string) ($c['value'] ?? ''))) {
|
|
$refuse($c, 'command-substitution');
|
|
continue;
|
|
}
|
|
// Compared against the value the conf holds now, which is what makes "broadened" a
|
|
// question this can answer at all. Scalars only — see vv_conf_path_write_ok().
|
|
if (($c['type'] ?? 'scalar') === 'scalar') {
|
|
$bad = vv_conf_path_write_ok((string) $c['key'], (string) ($c['value'] ?? ''),
|
|
vv_conf_vars()[$c['key']] ?? null);
|
|
if ($bad !== null) { $refuse($c, $bad); continue; }
|
|
}
|
|
$byFile[$c['file']][] = $c;
|
|
}
|
|
|
|
$results = [];
|
|
foreach ($byFile as $file => $fileChanges) {
|
|
$results[$file] = vv_conf_write_file($file, $fileChanges);
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
// Read-modify-write for one conf file. The surgical replacement lives here; the guards that
|
|
// make installing it safe live in vv_conf_install(), which every conf writer shares.
|
|
function vv_conf_write_file(string $file, array $fileChanges): bool {
|
|
return vv_conf_edit($file, function (string $raw) use ($fileChanges): ?string {
|
|
foreach ($fileChanges as $c) {
|
|
$qKey = preg_quote($c['key'], '/');
|
|
$value = $c['value'];
|
|
$type = $c['type'] ?? 'scalar';
|
|
|
|
if ($type === 'scalar') {
|
|
$raw = preg_replace_callback(
|
|
'/^(\s*' . $qKey . '\s*=\s*)("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^#\n]*?)(\s*(?:#[^\n]*)?)$/m',
|
|
// Backslashes first. Escaping quotes first meant the backslash just inserted
|
|
// was itself doubled on the next pass — " became \\" — which closed the
|
|
// string early and stored a truncated value that still parsed cleanly.
|
|
fn($m) => $m[1] . '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"' . $m[3],
|
|
$raw
|
|
) ?? $raw;
|
|
|
|
} elseif ($type === 'array_single') {
|
|
$raw = preg_replace_callback(
|
|
'/^(\s*' . $qKey . '\s*=\s*\()([^)]*)(\)(?:\s*(?:#[^\n]*)?)?)$/m',
|
|
fn($m) => $m[1] . $value . $m[3],
|
|
$raw
|
|
) ?? $raw;
|
|
|
|
} elseif ($type === 'array') {
|
|
$raw = preg_replace_callback(
|
|
'/^(\s*)(' . $qKey . '\s*=\s*\()[^)]*\)/ms',
|
|
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
|
|
$raw
|
|
) ?? $raw;
|
|
|
|
} elseif ($type === 'assoc_array') {
|
|
$raw = preg_replace_callback(
|
|
'/^(\s*)(declare\s+-A\s+' . $qKey . '\s*=\s*\()[^)]*\)/ms',
|
|
fn($m) => $m[1] . $m[2] . "\n" . $value . "\n" . $m[1] . ")",
|
|
$raw
|
|
) ?? $raw;
|
|
}
|
|
}
|
|
|
|
return $raw;
|
|
|
|
// Scalars are verified because their intended value is known exactly. The array types splice
|
|
// caller-supplied text whose sourced form is legitimately not equal to what was written, so
|
|
// for those a clean source is the whole assertion.
|
|
}, vv_conf_expected_scalars($fileChanges), array_column($fileChanges, 'key'));
|
|
}
|
|
|
|
// The intended end state for the keys that can be checked against one.
|
|
function vv_conf_expected_scalars(array $fileChanges): array {
|
|
$expect = [];
|
|
foreach ($fileChanges as $c) {
|
|
if (($c['type'] ?? 'scalar') === 'scalar') $expect[$c['key']] = (string) $c['value'];
|
|
}
|
|
return $expect;
|
|
}
|
|
|
|
// The one guarded way to install a changed conf. $mutate receives the current contents and
|
|
// returns the rewritten ones, or null to abort without touching anything. Every conf writer in
|
|
// the plugin goes through here, so the lock, the backup, the validation and the audit trail are
|
|
// written once and cannot be forgotten by a new caller.
|
|
//
|
|
// $expect key => intended value, verified by sourcing the installed file
|
|
// $subjects names for the audit line, when there is no key to assert (a toggled member)
|
|
// $allowCreate write a conf that does not exist yet. Off by default: for every caller except
|
|
// first-run setup, a missing target means the filename is wrong, and creating it
|
|
// would leave a stray conf that shadows nothing and is sourced by nobody.
|
|
function vv_conf_edit(string $file, callable $mutate, array $expect = [], array $subjects = [],
|
|
bool $allowCreate = false): bool {
|
|
$path = CONF_DIR . '/' . $file;
|
|
$subjects = $subjects ?: (array_keys($expect) ?: ['-']);
|
|
|
|
$audit = function (string $outcome, string $detail = '') use ($file, $subjects): void {
|
|
foreach ($subjects as $s) vv_conf_audit($file, (string) $s, $outcome, $detail);
|
|
};
|
|
|
|
// Held across the whole read-modify-write. Two concurrent savers would otherwise each read
|
|
// the same original, and the second rename would silently discard the first one's change.
|
|
$lockFh = @fopen(CONF_DIR . '/.conf-write.lock', 'c');
|
|
if ($lockFh === false || !flock($lockFh, LOCK_EX)) {
|
|
if ($lockFh) fclose($lockFh);
|
|
$audit('failed', 'reason=lock');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// A conf that does not exist yet is a create, not an edit — first-run setup writes
|
|
// host*.conf before there is anything to read. There is no prior content to lose, so
|
|
// there is nothing to back up, and undoing a failed create means removing the file.
|
|
$exists = is_file($path);
|
|
if (!$exists && !$allowCreate) { $audit('failed', 'reason=unreadable'); return false; }
|
|
$raw = $exists ? vv_read_conf_raw($file) : '';
|
|
if ($exists && $raw === '') { $audit('failed', 'reason=unreadable'); return false; }
|
|
|
|
$before = $exists ? (vv_conf_read_back($path, $subjects) ?? []) : [];
|
|
|
|
$new = $mutate($raw);
|
|
if ($new === null) { $audit('failed', 'reason=no-match'); return false; }
|
|
if ($exists && $new === $raw) { $audit('no-change'); return true; }
|
|
|
|
$backup = null;
|
|
if ($exists) {
|
|
$backup = vv_conf_backup($file);
|
|
if ($backup === null) {
|
|
// No recovery path for this write means the write does not happen. These files
|
|
// are gitignored, so a backup not taken cannot be reconstructed afterwards.
|
|
$audit('failed', 'reason=backup');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$undo = function () use ($backup, $path): void {
|
|
if ($backup !== null) @copy($backup, $path); else @unlink($path);
|
|
};
|
|
|
|
if (!vv_conf_syntax_ok($new)) { $audit('rejected', 'reason=syntax'); return false; }
|
|
if (!vv_write_conf_raw($file, $new)) { $audit('failed', 'reason=write'); return false; }
|
|
|
|
// bash -n proved the candidate parses. This proves the installed file still sources and
|
|
// that each value arrived intact — a quoting bug produces a file that parses perfectly
|
|
// and holds the wrong string, which is the failure the syntax check cannot see.
|
|
$after = vv_conf_read_back($path, $subjects);
|
|
if ($after === null) {
|
|
$undo();
|
|
$audit('rolled-back', 'reason=source-failed');
|
|
return false;
|
|
}
|
|
|
|
foreach ($expect as $key => $want) {
|
|
if (($after[$key] ?? null) !== $want) {
|
|
$undo();
|
|
vv_conf_audit($file, $key, 'rolled-back', 'reason=value-mismatch');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
foreach ($subjects as $s) {
|
|
$s = (string) $s;
|
|
// A subject that is not a conf key — a script id, or a marker for a whole-file save
|
|
// — has no value to read back, so there is no before and after to report.
|
|
if (!vv_conf_key_valid($s)) { vv_conf_audit($file, $s, 'applied'); continue; }
|
|
if (vv_conf_key_is_secret($s)) { vv_conf_audit($file, $s, 'applied', 'value=<redacted>'); continue; }
|
|
vv_conf_audit($file, $s, 'applied',
|
|
'from=' . vv_conf_audit_val($before[$s] ?? '') . ' to=' . vv_conf_audit_val($after[$s] ?? ''));
|
|
}
|
|
|
|
return true;
|
|
|
|
} finally {
|
|
flock($lockFh, LOCK_UN);
|
|
fclose($lockFh);
|
|
}
|
|
}
|
|
|
|
// Audit values are single-line and bounded. A conf value can be a 40-line array; the log is a
|
|
// record of what changed, and an unbounded splat of it makes the log unreadable at the moment
|
|
// it is actually needed.
|
|
function vv_conf_audit_val(string $v): string {
|
|
$v = preg_replace('/\s+/', ' ', trim($v));
|
|
if (strlen($v) > 120) $v = substr($v, 0, 117) . '...';
|
|
return '"' . $v . '"';
|
|
}
|
|
|
|
// bash -n against a private temp copy. Returns true when the content parses as a sourceable
|
|
// conf, false otherwise — never writes anything itself.
|
|
function vv_conf_syntax_ok(string $content): bool {
|
|
return vv_conf_syntax_error($content) === null;
|
|
}
|
|
|
|
// The same check, with bash's own complaint when it fails — the raw editors show it to whoever
|
|
// is typing, where "conf does not parse" alone would mean hunting the line by hand. $label
|
|
// replaces the temp path in the message so the reader sees their own filename.
|
|
//
|
|
// Fails closed. This used to pass when the temp file could not be created, which was defensible
|
|
// while every write came from a human clicking Save on a form. The assistant writes through here
|
|
// too, so an unverified conf is not installed — a refused write is recoverable, a conf that no
|
|
// script can source is a system-wide outage.
|
|
function vv_conf_syntax_error(string $content, string $label = 'conf'): ?string {
|
|
$tmp = tempnam(sys_get_temp_dir(), 'vvconf');
|
|
if ($tmp === false) return 'cannot verify: no writable temp directory';
|
|
|
|
file_put_contents($tmp, $content);
|
|
$out = []; $rc = 0;
|
|
exec('bash -n ' . escapeshellarg($tmp) . ' 2>&1', $out, $rc);
|
|
@unlink($tmp);
|
|
if ($rc === 0) return null;
|
|
|
|
$msg = implode(' ', array_filter(array_map('trim', $out)));
|
|
return str_replace($tmp, $label, $msg ?: 'conf does not parse');
|
|
}
|