<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Root of the PHP layer. Locates the Varaverk installation, parses master.conf plus this
//   host's own host*.conf into a flat array, and provides host identity, remote resolution,
//   and the tmpfs payload cache. Every other include/ file requires this one.
//
// DESIGN PRINCIPLES
//   varaverk.cfg is the single source of truth for location.
//       SCRIPTS_DIR is read from it; CONF_DIR, DATA_DIR, STATE_DIR and DEPLOY_DIR are all
//       derived. Storage-mode migration rewrites that one value and every path follows.
//
//   Host identity mirrors bash detect_hosts() exactly.
//       Same HOST<n> / HOST<n>_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<n> / HOST<n>_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;
}
