/ HOST_NAME matching, same 15-char NetBIOS truncation fallback. The // two implementations must agree — a page that disagrees with the scripts about which // host it is on is worse than one that cannot tell. // // Conf parsing resolves ${VAR} in two passes. // Bash expands at runtime; PHP reads the file literally. Pass 1 substitutes // ${SCRIPTS_DIR} from the PHP-side constant, pass 2 resolves remaining ${VAR} against // the already-parsed set. Without this, every derived path arrives as a literal string. // // Read-only with respect to behaviour. // This file parses conf and reports; it does not decide policy. Callers own that. // // OPERATIONAL SAFEGUARDS // Ambiguous truncated hostnames are refused, never guessed. // The 15-char fallback accepts a match only when exactly one configured host qualifies. // Two plausible candidates return 'unknown' rather than picking one — a wrong host // identity silently routes local work to a remote node. // // Cache writes are atomic. // vv_cache_write() writes .tmp then rename()s into place, so a concurrent reader sees // either the old payload or the new one, never a half-written file. // // Cache reads are age-gated and fail to null. // Past $maxAge, vv_cache_read() returns null rather than stale data. Callers treat null // as "no cache" and fall back to a live call — a missing cache can never be the reason // a page fails to render. // // Unknown host degrades instead of guessing. // vv_detect_host() returns 'unknown' and vv_conf_vars() then loads master.conf alone. // Shared config still resolves; host-specific values are simply absent. // // EXPORTS // Identity vv_detect_host(), vv_get_hostname(), vv_is_owner(), vv_known_hosts() // Config vv_conf_vars(), vv_read_conf_raw(), vv_write_conf_raw(), vv_get_conf_files() // Parsing vv_parse_conf_scalar(), vv_parse_kv_db(), vv_format_uptime() // Remote vv_resolve_tailscale_ip(), vv_remote_state_cmd(), vv_local_ip() // Setup state vv_setup_state_read/_write(), vv_push_setup_state(), vv_push_master_conf() // Cache vv_cache_read(), vv_cache_write() // Unraid API vv_unraid_api_query(), vv_auto_create_api_key() // // CONFIGURATION // varaverk.cfg SCRIPTS_DIR, CUSTOM_SCRIPTS_DIR // master.conf HOST / HOST_NAME — host identity // host*.conf HOST*_SSH_KEY — used for setup-state and conf push // VV_CACHE_DIR /tmp/vv_cache (tmpfs — RAM speed, cleared on reboot) // ═══════════════════════════════════════════════════════════════════════════════════════════════ define('PLUGIN_CFG', '/boot/config/plugins/varaverk/varaverk.cfg'); $_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: []; define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk'); define('CONF_DIR', SCRIPTS_DIR . '/Configurations'); define('DEPLOY_DIR', SCRIPTS_DIR . '/Deployment'); define('DATA_DIR', SCRIPTS_DIR . '/data'); define('STATE_DIR', SCRIPTS_DIR . '/State_Files'); define('LOG_DIR', '/var/log/varaverk'); // User-authored custom scripts (scheduler page "+ Create Script") — kept outside the git // repo entirely, alongside the User Scripts plugin's own storage. Any *.sh file placed // directly in this folder is auto-detected and listed — it doesn't have to be created // through the page's editor. define('CUSTOM_SCRIPTS_DIR', $_vv_cfg['CUSTOM_SCRIPTS_DIR'] ?? '/boot/config/plugins/user.scripts/Varaverk/Scripts'); unset($_vv_cfg); define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db'); define('VV_CACHE_DIR', '/tmp/vv_cache'); // Read the setup state file into a key=>value array. function vv_setup_state_read(): array { $out = []; foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) { [$k, $v] = explode('=', trim($line), 2) + ['', '']; if ($k !== '') $out[$k] = $v; } return $out; } // Write the setup state file (creates or overwrites). function vv_setup_state_write(array $data): void { $content = ''; foreach ($data as $k => $v) $content .= "$k=$v\n"; file_put_contents(VV_SETUP_STATE_FILE, $content); } // Push the setup state file to all remote hosts via scp. // Reads the remote's varaverk.cfg to find their actual SCRIPTS_DIR (handles appdata mode). function vv_push_setup_state(): void { if (!file_exists(VV_SETUP_STATE_FILE)) return; $myHostId = vv_detect_host(); $vars = vv_conf_vars(); $sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? ''; if (!$sshKey || !file_exists($sshKey)) return; $master = vv_read_conf_raw('master.conf'); preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m); $seen = []; foreach ($m[1] as $i => $hostKey) { $hostId = strtolower($hostKey); if ($hostId === $myHostId || isset($seen[$hostId])) continue; $seen[$hostId] = true; $hostname = trim($m[2][$i]); if (!$hostname) continue; $ip = vv_resolve_tailscale_ip($hostname); if (!$ip) continue; $sshBase = 'ssh -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip; // Get remote SCRIPTS_DIR from varaverk.cfg — handles appdata mode on remote. // Falls back to the default install path if varaverk.cfg is absent (pre-install). $cfgRaw = trim(shell_exec($sshBase . ' "cat /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: ''); $remoteSD = '/boot/config/plugins/varaverk'; foreach (explode("\n", $cfgRaw) as $line) { if (str_starts_with(trim($line), 'SCRIPTS_DIR=')) { $remoteSD = trim(substr(trim($line), strlen('SCRIPTS_DIR=')), '"\''); break; } } $remoteStatePath = $remoteSD . '/State_Files/varaverk_setup.db'; shell_exec($sshBase . ' "mkdir -p ' . escapeshellarg(dirname($remoteStatePath)) . '" 2>/dev/null'); $dest = escapeshellarg('root@' . $ip . ':' . $remoteStatePath); exec('scp -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no' . ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1'); } } // Push master.conf to all remote hosts via scp after a local save. // Returns one result entry per remote found in master.conf. // Silently returns [] on non-owner hosts (no SSH key, no remote access). function vv_push_master_conf(): array { $myHostId = vv_detect_host(); $vars = vv_conf_vars(); $sshKey = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? ''; if (!$sshKey || !file_exists($sshKey)) return []; $localPath = CONF_DIR . '/master.conf'; $master = vv_read_conf_raw('master.conf'); preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m); $results = []; $seen = []; foreach ($m[1] as $i => $hostKey) { $hostId = strtolower($hostKey); if ($hostId === $myHostId || isset($seen[$hostId])) continue; $seen[$hostId] = true; $hostname = trim($m[2][$i]); $ip = vv_resolve_tailscale_ip($hostname); if (!$ip) { $results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found']; continue; } // Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed, // Configurations/ exists, and master.conf is already present. // Any missing piece means the remote isn't ready — skip rather than push blind. // Remote command built as one PHP string and escapeshellarg()'d whole — shell_exec() // adds its own `sh -c` layer locally, so a bare double-quoted string here would let // the $(...)/${...} substitutions expand on HOST1 before ssh ever sees them, instead // of on the remote host. escapeshellarg() keeps it opaque until the remote shell runs it. $sshBase = 'ssh -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip; $remoteCmd = 'cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)' . ' && sd=$(echo "$cfg" | grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\')' . ' && test -d "${sd}/Configurations"' . ' && test -f "${sd}/Configurations/master.conf"' . ' && echo "$sd"'; $probe = trim(shell_exec($sshBase . ' ' . escapeshellarg($remoteCmd)) ?: ''); if ($probe === '') { $results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'plugin not installed, dir missing, or master.conf absent — skipped']; continue; } $remoteConf = rtrim($probe, '/') . '/Configurations'; $dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf'); $cmd = 'scp -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no' . ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1'; exec($cmd, $out, $rc); $results[] = [ 'host' => $hostKey, 'ok' => $rc === 0, 'error' => $rc !== 0 ? implode('; ', $out) : '', ]; } return $results; } function vv_get_hostname(): string { return trim(shell_exec('hostname -s') ?: ''); } // Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages // the mapping so this survives IP changes), falls back to parsing `tailscale status` text. function vv_resolve_tailscale_ip(string $hostname): string { $h = strtolower($hostname); $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: ''); if ($ip) return $ip; // Fallback: unambiguous prefix match against tailscale status (either direction) — handles // Unraid's 15-char NetBIOS hostname truncation vs. a longer name recorded in master.conf. // Only accept the match when exactly one peer could qualify; never guess between multiple // candidates that happen to share a prefix (e.g. server1/server10). $out = shell_exec('tailscale status 2>/dev/null') ?: ''; $matches = []; foreach (explode("\n", $out) as $line) { $cols = preg_split('/\s+/', trim($line)); if (!isset($cols[1])) continue; $peerHost = strtolower(explode('.', $cols[1])[0]); if (str_starts_with($peerHost, $h) || str_starts_with($h, $peerHost)) { $matches[] = $cols[0]; } } return count($matches) === 1 ? $matches[0] : ''; } function vv_detect_host(): string { // Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname. // Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts. $master = vv_read_conf_raw('master.conf'); preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m); $hostname = vv_get_hostname(); foreach ($m[1] as $i => $key) { if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key); } // Unraid truncates the Server Name to 15 chars (NetBIOS limit). If the live hostname // is at that exact limit, the name configured in master.conf (matching what Tailscale // shows for this peer — resolve_tailscale_ip() keys off the same value) may be a longer, // untruncated version. Only accept the match when it's unambiguous — exactly one // configured host may qualify; never guess between multiple prefix candidates. if (strlen($hostname) === 15) { $candidates = []; foreach ($m[1] as $i => $key) { $configured = trim($m[2][$i]); if (strlen($configured) > 15 && stripos($configured, $hostname) === 0) $candidates[] = $key; } if (count($candidates) === 1) return strtolower($candidates[0]); } return 'unknown'; } function vv_is_owner(): bool { return vv_detect_host() === 'host1'; } function vv_read_conf_raw(string $filename): string { $path = CONF_DIR . '/' . $filename; return file_exists($path) ? file_get_contents($path) : ''; } function vv_write_conf_raw(string $filename, string $content): bool { $path = CONF_DIR . '/' . $filename; $tmp = $path . '.vv.tmp'; if (file_put_contents($tmp, $content) === false) return false; return rename($tmp, $path); } function vv_get_conf_files(): array { // Returns conf files this host is allowed to view/edit $host = vv_detect_host(); $files = []; if ($host === 'host1') { // Owner sees master.conf + their own host conf $files[] = 'master.conf'; $files[] = 'host1.conf'; } elseif (preg_match('/^host(\d+)$/', $host)) { // Any other numbered host sees only their own conf $files[] = $host . '.conf'; } else { // Unknown host — show all for dev/debug foreach (glob(CONF_DIR . '/*.conf') as $f) { $files[] = basename($f); } } return $files; } // Parse conf into key=>value map for $VAR substitution in docs function vv_conf_vars(): array { $vars = []; $files = ['master.conf']; $host = vv_detect_host(); if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf'; foreach ($files as $f) { $raw = vv_read_conf_raw($f); // Match: VAR_NAME="value" or VAR_NAME=value (no quotes) preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m); foreach ($m[1] as $i => $key) { $vars[$key] = str_replace('\\$', '$', trim($m[2][$i])); } } // Resolve bash variable references — bash expands ${VAR} at runtime; PHP reads them literally. // Pass 1: ${SCRIPTS_DIR} from the PHP-computed constant (other vars depend on it). // Pass 2: ${VAR} using now-resolved values from within the same conf set. foreach ($vars as $k => &$v) { if (is_string($v)) $v = str_replace('${SCRIPTS_DIR}', SCRIPTS_DIR, $v); } foreach ($vars as $k => &$v) { if (is_string($v) && str_contains($v, '${')) { $v = preg_replace_callback('/\$\{([A-Z0-9_]+)\}/', function ($m) use ($vars) { return $vars[$m[1]] ?? $m[0]; }, $v); } } unset($v); return $vars; } // Query the Unraid GraphQL API for a given host. // For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP. // $apiKey may be passed explicitly (needed when querying a remote host from the local host, // since vv_conf_vars() only loads the current host's conf file). // Returns the decoded 'data' object on success, null on any failure. // Debug log written to /tmp/vv_api_debug.json on failure. function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array { $vars = vv_conf_vars(); $key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? ''); if (!$key) return null; $myHostId = vv_detect_host(); if (strtolower($hostId) === strtolower($myHostId)) { $url = 'http://localhost/graphql'; } else { $hostname = $vars[strtoupper($hostId)] ?? ''; if (!$hostname) return null; $ip = vv_resolve_tailscale_ip($hostname); if (!$ip) return null; $url = "http://{$ip}/graphql"; } $body = json_encode(['query' => $gql]); // Use curl (preferred — doesn't require allow_url_fopen, better error handling). if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"], CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeoutSec, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false, ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); } else { // Fallback to file_get_contents if curl is unavailable. $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => "Content-Type: application/json\r\nx-api-key: {$key}", 'content' => $body, 'timeout' => $timeoutSec, 'ignore_errors' => true, ]]); $resp = @file_get_contents($url, false, $ctx); $httpCode = $resp !== false ? 200 : 0; $curlErr = ''; } if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) { @file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([ 'ts' => time(), 'host' => $hostId, 'url' => $url, 'http_code' => $httpCode, 'curl_err' => $curlErr, 'response' => substr((string)$resp, 0, 800), ], JSON_PRETTY_PRINT)); return null; } $decoded = json_decode((string)$resp, true); // If the API returned GraphQL errors, log them for diagnosis. if (!empty($decoded['errors'])) { @file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([ 'ts' => time(), 'host' => $hostId, 'url' => $url, 'http_code' => $httpCode, 'errors' => $decoded['errors'], 'data' => $decoded['data'] ?? null, ], JSON_PRETTY_PRINT)); } // data key present (even if null means query ran but returned nothing useful). return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null; } // ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ─────────── // Read a cached payload. Returns null if missing or older than $maxAge seconds. function vv_cache_read(string $key, int $maxAge = 90): ?array { $f = VV_CACHE_DIR . '/' . $key . '.json'; if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null; $raw = file_get_contents($f); return $raw ? (json_decode($raw, true) ?: null) : null; } // Write a payload atomically (tmp + rename) so readers never see a partial file. function vv_cache_write(string $key, array $data): void { if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true); $f = VV_CACHE_DIR . '/' . $key . '.json'; $tmp = $f . '.tmp'; file_put_contents($tmp, json_encode($data)); rename($tmp, $f); } // ── Shared utility functions (used across include/ and api/ files) ──────────── // Format seconds into "2d 3h 15m". function vv_format_uptime(int $seconds): string { $d = intdiv($seconds, 86400); $h = intdiv($seconds % 86400, 3600); $m = intdiv($seconds % 3600, 60); return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m"; } // Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value. // Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar / // vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex. function vv_parse_conf_scalar(string $raw, string $key): string { return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m) ? trim($m[1]) : ''; } // Parse a key=value state file (e.g. fallback_state.db, partnership_state.db). // Returns ['key' => 'value', ...]. Lines without '=' are ignored. function vv_parse_kv_db(string $text): array { $out = []; foreach (explode("\n", $text) as $line) { $line = trim($line); if ($line === '' || $line[0] === '#') continue; [$k, $v] = array_pad(explode('=', $line, 2), 2, ''); if ($k !== '') $out[trim($k)] = trim($v); } return $out; } // All configured hosts from master.conf as ['host1' => 'hostname', ...]. // Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts. function vv_known_hosts(): array { $vars = vv_conf_vars(); $hosts = []; foreach ($vars as $k => $v) { if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') { $hosts['host' . $m[1]] = $v; } } ksort($hosts); return $hosts ?: ['host1' => 'HOST1']; } // Create (or overwrite) the Varaverk Unraid API key and write it into host conf. // Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...']. function vv_auto_create_api_key(string $hostId, string $confFile): array { $script = SCRIPTS_DIR . '/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh'; if (!file_exists($script)) { return ['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']; } // set_time_limit() does not cover exec() time on Linux, so the bound has to be external — // otherwise a stalled unraid-api call holds a php-fpm worker open indefinitely. exec('timeout 120 bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc); if ($rc === 124) { return ['ok' => false, 'error' => 'Key renewal timed out after 120s']; } if ($rc !== 0) { $msg = implode(' ', array_filter(array_map('trim', $out))); return ['ok' => false, 'error' => $msg ?: 'Script failed']; } $varName = strtoupper($hostId) . '_UNRAID_API_KEY'; $raw = vv_read_conf_raw($confFile); preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*"([^"]+)"/m', $raw, $m); $key = $m[1] ?? ''; return ['ok' => true, 'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : 'registered']; } // Build a bash command that reads a state file from the REMOTE host's State_Files/. // Reads the remote's varaverk.cfg to resolve their SCRIPTS_DIR (may differ from ours // when the remote is in appdata mode). Falls back to the internal plugin path. function vv_remote_state_cmd(string $filename): string { $fn = basename($filename); return 'sd=$(grep -m1 SCRIPTS_DIR= /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null' . ' | cut -d\'"\' -f2); cat "${sd:-/boot/config/plugins/varaverk}/State_Files/' . $fn . '" 2>/dev/null'; } // Local LAN IP via routing table — static-cached per request. // Previously duplicated in include/docker_folders.php and inline in include/docker.php. function vv_local_ip(): string { static $ip = null; if ($ip !== null) return $ip; $ip = trim(shell_exec("ip route get 8.8.8.8 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++)if(\$i==\"src\")print \$(i+1)}'") ?? ''); return $ip; }