Files
Varaverk/Plugin/unraid/include/config.php
T
Gmer4Lfe be306cf86c Let one gate decide every AI surface, so switching AI off actually removes all of it
The Tools card adopted the AI scripts on the host check alone, and api/ai.php only
tested AI_ENABLED in front of ask, so a disabled subsystem still had rows to run and
an endpoint that answered.
2026-08-07 09:49:31 -04:00

545 lines
25 KiB
PHP

<?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_is_ai_host(),
// 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';
}
// The AI subsystem is HOST1-only: it is the node with the GPU, the Ollama process and the
// index. Deliberately a separate predicate from vv_is_owner() even though both resolve to
// host1 today — one says "auth source of truth", this one says "AI runs here", and the day
// either moves, conflating them would move the other by accident.
//
// Returns false for 'unknown', so a host that cannot identify itself never shows the tab.
function vv_is_ai_host(): bool {
return vv_detect_host() === 'host1';
}
// Whether the UI may offer anything AI at all: the right host, with the master switch on. Every
// AI surface asks this one question — the AI tab, the assistant dock on the Scheduler, and the
// two AI rows on the Tools card — so AI off means AI gone, not gone from most places.
//
// It lives here rather than in include/ai.php because the pages that need it do not all load
// that file; the Scheduler loads only config.php, and a gate that silently answers false where
// its definition is missing is worse than no gate. Reads AI_ENABLED directly for the same
// reason. Fail-closed on anything but the literal "true", matching the conf's own contract.
function vv_ai_ui_on(): bool {
return vv_is_ai_host()
&& strtolower(trim(vv_conf_vars()['AI_ENABLED'] ?? 'false')) === 'true';
}
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';
// Guarantee the trailing newline. A textarea does not supply one, so saving through the raw
// editor left master.conf ending mid-line — and appending is a real pattern here
// (conf_upgrade, and the array writers below), so the next `>>` would have joined itself
// onto the last setting instead of starting a line. Appended, never stripped: a deliberate
// run of blank lines at the end of a conf is the author's business.
if ($content !== '' && !str_ends_with($content, "\n")) $content .= "\n";
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;
}