Nothing in Varaverk fires when a job ends, so this picks up run records that completed since the last pass rather than adding a hook to forty scripts. Ahead of stability in the cycle on purpose: a wrong port is not fixed by rebooting the machine.
676 lines
36 KiB
PHP
676 lines
36 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;
|
|
}
|
|
|
|
// 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);
|
|
$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];
|
|
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];
|
|
}
|
|
}
|
|
|
|
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.
|
|
function vv_conf_key_is_secret(string $key): bool {
|
|
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);
|
|
}
|
|
|
|
// 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}
|
|
function vv_conf_write_changes(array $changes): array {
|
|
$byFile = [];
|
|
foreach ($changes as $c) {
|
|
if (empty($c['file']) || empty($c['key'])) continue;
|
|
if (!vv_conf_key_valid($c['key'])) {
|
|
vv_conf_audit((string) $c['file'], (string) $c['key'], 'rejected', 'reason=malformed-key');
|
|
continue;
|
|
}
|
|
if (!vv_conf_value_safe((string) ($c['value'] ?? ''))) {
|
|
vv_conf_audit((string) $c['file'], (string) $c['key'], 'rejected', 'reason=command-substitution');
|
|
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');
|
|
}
|