377 lines
21 KiB
PHP
377 lines
21 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
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'],
|
|
// ── 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;
|
|
}
|
|
|
|
// 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'])) $byFile[$c['file']][] = $c;
|
|
}
|
|
|
|
$results = [];
|
|
foreach ($byFile as $file => $fileChanges) {
|
|
$raw = vv_read_conf_raw($file);
|
|
if ($raw === '') { $results[$file] = false; continue; }
|
|
|
|
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',
|
|
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;
|
|
}
|
|
}
|
|
// Only the scalar path escapes its value; the array, array_single and assoc_array
|
|
// paths splice the caller's text into the file verbatim, and the type comes from the
|
|
// request. Every script sources these files, so the result is parsed before it is
|
|
// allowed to replace a working conf.
|
|
$results[$file] = vv_conf_syntax_ok($raw) && vv_write_conf_raw($file, $raw);
|
|
}
|
|
return $results;
|
|
}
|
|
|
|
// 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 {
|
|
$tmp = tempnam(sys_get_temp_dir(), 'vvconf');
|
|
if ($tmp === false) return true; // cannot check — do not block the write
|
|
file_put_contents($tmp, $content);
|
|
$out = []; $rc = 0;
|
|
exec('bash -n ' . escapeshellarg($tmp) . ' 2>&1', $out, $rc);
|
|
@unlink($tmp);
|
|
return $rc === 0;
|
|
}
|