. 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= — 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. // Which page shows conf sections by subject rather than by script, and the route a person would // be told to follow to reach it. Declared once because two things need it and they must agree: // the page itself builds its ?sections= query from this, and Tools/ui_map_build.php turns it into // navigation instructions the assistant can give. A route written down in only one of those two // places is a route that goes stale the first time a card moves. // // `match` is the whole-word needle matched against section headers — see api/confform.php. const VV_UI_SECTION_SURFACES = [ [ 'match' => 'ai', 'tab' => 'AI', 'route' => 'AI tab → Settings → Configuration', ], // Everything that is not structurally excluded. The catch-all exists so no ordinary setting // is reachable only by editing a file — a settings page whose answer to a third of the conf // is "open it over SSH" is not a settings page. [ 'match' => '*', 'tab' => 'Settings', 'route' => 'Settings tab → All settings', ], [ 'match' => 'partnership', 'tab' => 'Partnership', // Array fields only — the card is one collapsible block per list, because these are // container lists dozens of lines long and a flat form would be unreadable. 'route' => 'Partnership tab → Array Settings', ], ]; // Sections deliberately kept out of every settings surface, and why. Not a security boundary — // vv_conf_edit() still guards the write and the raw editor can still reach them. This is about // what belongs in a form: these hold the machine's identity and the roots everything else is // derived from, and a text box next to a Save button is the wrong shape for a value that decides // whether the server recognises itself on next boot. // // The reason travels with the exclusion because the UI map prints it. "No route" invites someone // to add one; "no route, because changing this moves the entire data tree" does not. const VV_UI_SECTION_EXCLUDE = [ 'HOST IDENTITIES' => 'HOST1 and HOST2 are what detect_hosts() matches the local hostname against. Editing one ' . 'here would change which machine this believes it is.', 'SHARED HOST CONFIGURATION' => 'DATA_DIR is the single on-disk root — move it and the whole tree follows, including the ' . 'state the running scripts are holding open.', 'Cache Roots' => 'The tmpfs half. These must never point at flash, and nothing in a form conveys that.', 'State Files' => 'Paths to live state databases, derived from STATE_DIR. Repointing one orphans the state ' . 'a watchdog is mid-way through writing.', 'GIT / REPO' => 'Where the plugin updates itself from. Wrong here means the next pull is the last one.', ]; // Pages that edit named keys rather than whole sections — a purpose-built control for one // setting, not a form over a conf section. Only the route is declared: the keys themselves are // read out of the page source by Tools/ui_map_build.php, so a control added or removed changes // the map without anyone remembering to update a list. // // A page missing from here is not broken; its settings simply appear as conf-only in the map, // which is the honest answer until someone gives it a route. // `also` lists keys the page assembles at runtime, which no amount of reading the source will // reveal — settings.php writes HOST1_DISCORD_WEBHOOK through a PHP variable holding the host // slot. Written with HOSTN, substituted per machine, the same convention conf_upgrade uses. The // generator checks each one exists and complains if it does not, so a stale entry is loud rather // than a route to a control that was removed. const VV_UI_PAGE_ROUTES = [ 'settings.php' => ['route' => 'Settings tab', 'also' => ['HOSTN_DISCORD_WEBHOOK', 'HOSTN_STORAGE_MODE_INTERNAL']], 'fallback.php' => ['route' => 'Fallback tab'], 'rsync.php' => ['route' => 'Rsync tab'], 'arrs.php' => ['route' => 'Arrs tab'], 'watchdog.php' => ['route' => 'Watchdog tab'], 'docker.php' => ['route' => 'Docker tab'], ]; // 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 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='); 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'); }