1	<?php
2	// Config file parser and writer.
3	// Reads master.conf and the appropriate host*.conf based on running host.
4
5	define('PLUGIN_CFG',  '/boot/config/plugins/varaverk/varaverk.cfg');
6
7	$_vv_cfg = @parse_ini_file(PLUGIN_CFG) ?: [];
8	define('SCRIPTS_DIR', $_vv_cfg['SCRIPTS_DIR'] ?? '/boot/config/plugins/varaverk');
9	define('CONF_DIR',    SCRIPTS_DIR . '/Configurations');
10	define('DATA_DIR',    SCRIPTS_DIR . '/data');
11	define('STATE_DIR',   SCRIPTS_DIR . '/State_Files');
12	define('LOG_DIR',     '/var/log/varaverk');
13	unset($_vv_cfg);
14
15	define('VV_SETUP_STATE_FILE', STATE_DIR . '/varaverk_setup.db');
16	define('VV_CACHE_DIR',        '/tmp/vv_cache');
17
18	// Read the setup state file into a key=>value array.
19	function vv_setup_state_read(): array {
20	    $out = [];
21	    foreach (file(VV_SETUP_STATE_FILE) ?: [] as $line) {
22	        [$k, $v] = explode('=', trim($line), 2) + ['', ''];
23	        if ($k !== '') $out[$k] = $v;
24	    }
25	    return $out;
26	}
27
28	// Write the setup state file (creates or overwrites).
29	function vv_setup_state_write(array $data): void {
30	    $content = '';
31	    foreach ($data as $k => $v) $content .= "$k=$v\n";
32	    file_put_contents(VV_SETUP_STATE_FILE, $content);
33	}
34
35	// Push the setup state file to all remote hosts via scp.
36	// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
37	// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
38	function vv_push_setup_state(): void {
39	    if (!file_exists(VV_SETUP_STATE_FILE)) return;
40	    $myHostId = vv_detect_host();
41	    $vars     = vv_conf_vars();
42	    $sshKey   = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
43	    if (!$sshKey || !file_exists($sshKey)) return;
44
45	    $master = vv_read_conf_raw('master.conf');
46	    preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
47	    $seen = [];
48	    foreach ($m[1] as $i => $hostKey) {
49	        $hostId = strtolower($hostKey);
50	        if ($hostId === $myHostId || isset($seen[$hostId])) continue;
51	        $seen[$hostId] = true;
52	        $hostname = trim($m[2][$i]);
53	        if (!$hostname) continue;
54	        $ip = vv_resolve_tailscale_ip($hostname);
55	        if (!$ip) continue;
56	        $sshBase = 'ssh -i ' . escapeshellarg($sshKey)
57	                 . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
58	        // Ensure the target dir exists (it always should on Unraid, but be safe)
59	        shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
60	        $dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
61	        exec('scp -i ' . escapeshellarg($sshKey)
62	            . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
63	            . ' ' . escapeshellarg(VV_SETUP_STATE_FILE) . ' ' . $dest . ' 2>&1');
64	    }
65	}
66
67	// Push master.conf to all remote hosts via scp after a local save.
68	// Returns one result entry per remote found in master.conf.
69	// Silently returns [] on non-owner hosts (no SSH key, no remote access).
70	function vv_push_master_conf(): array {
71	    $myHostId = vv_detect_host();
72	    $vars     = vv_conf_vars();
73	    $sshKey   = $vars[strtoupper($myHostId) . '_SSH_KEY'] ?? '';
74	    if (!$sshKey || !file_exists($sshKey)) return [];
75
76	    $localPath = CONF_DIR . '/master.conf';
77	    $master    = vv_read_conf_raw('master.conf');
78	    preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
79
80	    $results = [];
81	    $seen    = [];
82	    foreach ($m[1] as $i => $hostKey) {
83	        $hostId = strtolower($hostKey);
84	        if ($hostId === $myHostId || isset($seen[$hostId])) continue;
85	        $seen[$hostId] = true;
86
87	        $hostname = trim($m[2][$i]);
88	        $ip = vv_resolve_tailscale_ip($hostname);
89	        if (!$ip) {
90	            $results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false, 'error' => 'Tailscale IP not found'];
91	            continue;
92	        }
93
94	        // Single SSH call: get remote SCRIPTS_DIR and verify plugin is installed,
95	        // Configurations/ exists, and master.conf is already present.
96	        // Any missing piece means the remote isn't ready — skip rather than push blind.
97	        $sshBase  = 'ssh -i ' . escapeshellarg($sshKey)
98	                  . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
99	        $probe    = trim(shell_exec(
100	            $sshBase . ' "cfg=$(grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null)'
101	            . ' && sd=$(echo \"$cfg\" | grep -oP \'(?<=SCRIPTS_DIR=\")[^\"]+\')'
102	            . ' && test -d \"${sd}/Configurations\"'
103	            . ' && test -f \"${sd}/Configurations/master.conf\"'
104	            . ' && echo \"$sd\""'
105	        ) ?: '');
106
107	        if ($probe === '') {
108	            $results[] = ['host' => $hostKey, 'ok' => false, 'ready' => false,
109	                          'error' => 'plugin not installed, dir missing, or master.conf absent — skipped'];
110	            continue;
111	        }
112
113	        $remoteConf = rtrim($probe, '/') . '/Configurations';
114	        $dest = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
115	        $cmd  = 'scp -i ' . escapeshellarg($sshKey)
116	              . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
117	              . ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1';
118	        exec($cmd, $out, $rc);
119	        $results[] = [
120	            'host'  => $hostKey,
121	            'ok'    => $rc === 0,
122	            'error' => $rc !== 0 ? implode('; ', $out) : '',
123	        ];
124	    }
125	    return $results;
126	}
127
128	function vv_get_hostname(): string {
129	    return trim(shell_exec('hostname -s') ?: '');
130	}
131
132	// Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages
133	// the mapping so this survives IP changes), falls back to parsing `tailscale status` text.
134	function vv_resolve_tailscale_ip(string $hostname): string {
135	    $h  = strtolower($hostname);
136	    $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: '');
137	    if ($ip) return $ip;
138	    $out = shell_exec('tailscale status 2>/dev/null') ?: '';
139	    foreach (explode("\n", $out) as $line) {
140	        $cols = preg_split('/\s+/', trim($line));
141	        if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
142	    }
143	    return '';
144	}
145
146	function vv_detect_host(): string {
147	    // Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname.
148	    // Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts.
149	    $master = vv_read_conf_raw('master.conf');
150	    preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m);
151	    $hostname = vv_get_hostname();
152	    foreach ($m[1] as $i => $key) {
153	        if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key);
154	    }
155	    return 'unknown';
156	}
157
158	function vv_is_owner(): bool {
159	    return vv_detect_host() === 'host1';
160	}
161
162	function vv_read_conf_raw(string $filename): string {
163	    $path = CONF_DIR . '/' . $filename;
164	    return file_exists($path) ? file_get_contents($path) : '';
165	}
166
167	function vv_write_conf_raw(string $filename, string $content): bool {
168	    $path = CONF_DIR . '/' . $filename;
169	    $tmp  = $path . '.vv.tmp';
170	    if (file_put_contents($tmp, $content) === false) return false;
171	    return rename($tmp, $path);
172	}
173
174	function vv_get_conf_files(): array {
175	    // Returns conf files this host is allowed to view/edit
176	    $host = vv_detect_host();
177	    $files = [];
178	    if ($host === 'host1') {
179	        // Owner sees master.conf + their own host conf
180	        $files[] = 'master.conf';
181	        $files[] = 'host1.conf';
182	    } elseif (preg_match('/^host(\d+)$/', $host)) {
183	        // Any other numbered host sees only their own conf
184	        $files[] = $host . '.conf';
185	    } else {
186	        // Unknown host — show all for dev/debug
187	        foreach (glob(CONF_DIR . '/*.conf') as $f) {
188	            $files[] = basename($f);
189	        }
190	    }
191	    return $files;
192	}
193
194	// Parse conf into key=>value map for $VAR substitution in docs
195	function vv_conf_vars(): array {
196	    $vars = [];
197	    $files = ['master.conf'];
198	    $host  = vv_detect_host();
199	    if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf';
200
201	    foreach ($files as $f) {
202	        $raw = vv_read_conf_raw($f);
203	        // Match: VAR_NAME="value" or VAR_NAME=value (no quotes)
204	        preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
205	        foreach ($m[1] as $i => $key) {
206	            $vars[$key] = str_replace('\\$', '$', trim($m[2][$i]));
207	        }
208	    }
209	    return $vars;
210	}
211
212	// Query the Unraid GraphQL API for a given host.
213	// For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP.
214	// $apiKey may be passed explicitly (needed when querying a remote host from the local host,
215	// since vv_conf_vars() only loads the current host's conf file).
216	// Returns the decoded 'data' object on success, null on any failure.
217	// Debug log written to /tmp/vv_api_debug.json on failure.
218	function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array {
219	    $vars = vv_conf_vars();
220	    $key  = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '');
221	    if (!$key) return null;
222
223	    $myHostId = vv_detect_host();
224	    if (strtolower($hostId) === strtolower($myHostId)) {
225	        $url = 'http://localhost/graphql';
226	    } else {
227	        $hostname = $vars[strtoupper($hostId)] ?? '';
228	        if (!$hostname) return null;
229	        $ip = vv_resolve_tailscale_ip($hostname);
230	        if (!$ip) return null;
231	        $url = "http://{$ip}/graphql";
232	    }
233
234	    $body = json_encode(['query' => $gql]);
235
236	    // Use curl (preferred — doesn't require allow_url_fopen, better error handling).
237	    if (function_exists('curl_init')) {
238	        $ch = curl_init($url);
239	        curl_setopt_array($ch, [
240	            CURLOPT_POST           => true,
241	            CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "x-api-key: {$key}"],
242	            CURLOPT_POSTFIELDS     => $body,
243	            CURLOPT_RETURNTRANSFER => true,
244	            CURLOPT_TIMEOUT        => $timeoutSec,
245	            CURLOPT_CONNECTTIMEOUT => 3,
246	            CURLOPT_FOLLOWLOCATION => false,
247	        ]);
248	        $resp     = curl_exec($ch);
249	        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
250	        $curlErr  = curl_error($ch);
251	        curl_close($ch);
252	    } else {
253	        // Fallback to file_get_contents if curl is unavailable.
254	        $ctx  = stream_context_create(['http' => [
255	            'method'        => 'POST',
256	            'header'        => "Content-Type: application/json\r\nx-api-key: {$key}",
257	            'content'       => $body,
258	            'timeout'       => $timeoutSec,
259	            'ignore_errors' => true,
260	        ]]);
261	        $resp     = @file_get_contents($url, false, $ctx);
262	        $httpCode = $resp !== false ? 200 : 0;
263	        $curlErr  = '';
264	    }
265
266	    if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) {
267	        @file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
268	            'ts'        => time(),
269	            'host'      => $hostId,
270	            'url'       => $url,
271	            'http_code' => $httpCode,
272	            'curl_err'  => $curlErr,
273	            'response'  => substr((string)$resp, 0, 800),
274	        ], JSON_PRETTY_PRINT));
275	        return null;
276	    }
277
278	    $decoded = json_decode((string)$resp, true);
279
280	    // If the API returned GraphQL errors, log them for diagnosis.
281	    if (!empty($decoded['errors'])) {
282	        @file_put_contents(VV_CACHE_DIR . '/vv_api_debug.json', json_encode([
283	            'ts'        => time(),
284	            'host'      => $hostId,
285	            'url'       => $url,
286	            'http_code' => $httpCode,
287	            'errors'    => $decoded['errors'],
288	            'data'      => $decoded['data'] ?? null,
289	        ], JSON_PRETTY_PRINT));
290	    }
291
292	    // data key present (even if null means query ran but returned nothing useful).
293	    return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null;
294	}
295
296	// ── File-based API cache (/tmp/vv_cache — tmpfs, cleared on reboot) ───────────
297
298	// Read a cached payload. Returns null if missing or older than $maxAge seconds.
299	function vv_cache_read(string $key, int $maxAge = 90): ?array {
300	    $f = VV_CACHE_DIR . '/' . $key . '.json';
301	    if (!file_exists($f) || (time() - filemtime($f)) > $maxAge) return null;
302	    $raw = file_get_contents($f);
303	    return $raw ? (json_decode($raw, true) ?: null) : null;
304	}
305
306	// Write a payload atomically (tmp + rename) so readers never see a partial file.
307	function vv_cache_write(string $key, array $data): void {
308	    if (!is_dir(VV_CACHE_DIR)) @mkdir(VV_CACHE_DIR, 0755, true);
309	    $f   = VV_CACHE_DIR . '/' . $key . '.json';
310	    $tmp = $f . '.tmp';
311	    file_put_contents($tmp, json_encode($data));
312	    rename($tmp, $f);
313	}
314
315	// ── Shared utility functions (used across include/ and api/ files) ────────────
316
317	// Format seconds into "2d 3h 15m".
318	function vv_format_uptime(int $seconds): string {
319	    $d = intdiv($seconds, 86400);
320	    $h = intdiv($seconds % 86400, 3600);
321	    $m = intdiv($seconds % 3600, 60);
322	    return ($d ? "{$d}d " : '') . ($h ? "{$h}h " : '') . "{$m}m";
323	}
324
325	// Parse a scalar value from raw conf text. Matches KEY="value" or KEY=value.
326	// Identical logic was previously duplicated as vv_arr_scalar / vv_wd_scalar /
327	// vv_fb_scalar / vv_media_conf_scalar — all reduce to this one regex.
328	function vv_parse_conf_scalar(string $raw, string $key): string {
329	    return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?([^"\n]*)"?/m', $raw, $m)
330	        ? trim($m[1]) : '';
331	}
332
333	// Parse a key=value state file (e.g. fallback_state.db, partnership_state.db).
334	// Returns ['key' => 'value', ...]. Lines without '=' are ignored.
335	function vv_parse_kv_db(string $text): array {
336	    $out = [];
337	    foreach (explode("\n", $text) as $line) {
338	        $line = trim($line);
339	        if ($line === '' || $line[0] === '#') continue;
340	        [$k, $v] = array_pad(explode('=', $line, 2), 2, '');
341	        if ($k !== '') $out[trim($k)] = trim($v);
342	    }
343	    return $out;
344	}
345
346	// All configured hosts from master.conf as ['host1' => 'hostname', ...].
347	// Canonical version — previously duplicated as vv_arr_known_hosts / vv_fb_known_hosts.
348	function vv_known_hosts(): array {
349	    $vars  = vv_conf_vars();
350	    $hosts = [];
351	    foreach ($vars as $k => $v) {
352	        if (preg_match('/^HOST(\d+)$/', $k, $m) && $v !== '') {
353	            $hosts['host' . $m[1]] = $v;
354	        }
355	    }
356	    ksort($hosts);
357	    return $hosts ?: ['host1' => 'HOST1'];
358	}
359
360	// Create (or overwrite) the Varaverk Unraid API key and write it into host conf.
361	// Returns ['ok'=>true,'key_preview'=>'...'] or ['ok'=>false,'error'=>'...'].
362	function vv_auto_create_api_key(string $hostId, string $confFile): array {
363	    $script = SCRIPTS_DIR . '/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh';
364	    if (!file_exists($script)) {
365	        return ['ok' => false, 'error' => 'unraid_api_key_renew.sh not found'];
366	    }
367	    exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
368	    if ($rc !== 0) {
369	        $msg = implode(' ', array_filter(array_map('trim', $out)));
370	        return ['ok' => false, 'error' => $msg ?: 'Script failed'];
371	    }
372	    $varName = strtoupper($hostId) . '_UNRAID_API_KEY';
373	    $raw     = vv_read_conf_raw($confFile);
374	    preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*"([^"]+)"/m', $raw, $m);
375	    $key = $m[1] ?? '';
376	    return ['ok' => true, 'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : 'registered'];
377	}
378
379	// Local LAN IP via routing table — static-cached per request.
380	// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
381	function vv_local_ip(): string {
382	    static $ip = null;
383	    if ($ip !== null) return $ip;
384	    $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)}'") ?? '');
385	    return $ip;
386	}
387

<?php
// Config file parser and writer.
// Reads master.conf and the appropriate host*.conf based on running host.

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('DATA_DIR',    SCRIPTS_DIR . '/data');
define('STATE_DIR',   SCRIPTS_DIR . '/State_Files');
define('LOG_DIR',     '/var/log/varaverk');
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.
// Unlike master.conf push, this does NOT require the plugin to be installed on the remote —
// it only needs SSH to be reachable, and pushes to /boot/config/ (always available).
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;
        // Ensure the target dir exists (it always should on Unraid, but be safe)
        shell_exec($sshBase . ' "mkdir -p /boot/config" 2>/dev/null');
        $dest = escapeshellarg('root@' . $ip . ':/boot/config/varaverk_setup.db');
        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.
        $sshBase  = 'ssh -i ' . escapeshellarg($sshKey)
                  . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
        $probe    = trim(shell_exec(
            $sshBase . ' "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\""'
        ) ?: '');

        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;
    $out = shell_exec('tailscale status 2>/dev/null') ?: '';
    foreach (explode("\n", $out) as $line) {
        $cols = preg_split('/\s+/', trim($line));
        if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0];
    }
    return '';
}

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);
    }
    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]));
        }
    }
    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'];
    }
    exec('bash ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
    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'];
}

// 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;
}


so a missunderstanding last night from what i asked to do caused some issues that have gitea push new templates to replace my .confs. all should vbe good now. but lets double check that i fixed the is…


Configure boot partition USB installation process


Clean. Let me now check the remaining pieces of the initial run flow — `conf_upgrade.sh` (to understand its role) and `conf_populate.sh` (called from the wizard step 2).


1	#!/bin/bash
2	# ==============================================================================================
3	# ============================= conf_upgrade.sh ================================================
4	# ==============================================================================================
5	#
6	# PURPOSE
7	# ─────────────────────────────────────────────────────────────────────────────
8	# Merges a new conf template into an existing user conf while preserving every
9	# value the user has already set. Run manually when the conf schema changes
10	# between versions — adds new keys, removes deprecated ones, and keeps the
11	# structure of the new template exactly.
12	#
13	#   Keys in template only  → ADDED   (placeholder/default — user fills in once)
14	#   Keys in target only    → REMOVED (deprecated in new version)
15	#   Keys in both           → KEPT    (target's value always wins, template ignored)
16	#   Comments / blank lines → always from template (structure follows new version)
17	#
18	# Supports all conf variable patterns: simple scalars, indexed arrays, and
19	# associative arrays (declare -A).
20	#
21	# ==============================================================================================
22	# OPERATIONAL SAFEGUARDS
23	# ==============================================================================================
24	#
25	# Dry-run Mode
26	#   --dry-run prints the full change report (ADDED / REMOVED / KEPT) then exits
27	#   without writing anything. Always preview before applying to production confs.
28	#
29	# Backup Option
30	#   --backup writes a .bak copy of the target before overwriting. Use when
31	#   applying to a conf that has never been upgraded before.
32	#
33	# File Existence Guards
34	#   Both --template and --target are validated before any parsing begins.
35	#   Missing files abort immediately with a clear error.
36	#
37	# Atomic Write
38	#   Merged output is written to a tempfile first, then copied to the target.
39	#   A partial write cannot corrupt the original.
40	#
41	# ==============================================================================================
42	# CONFIGURATION
43	# ==============================================================================================
44	#
45	# No conf vars. All inputs are CLI flags.
46	#
47	#   --template <file>   New version conf file (source of structure and defaults)
48	#   --target   <file>   Existing user conf (source of real values — always preserved)
49	#   --dry-run           Show what would change without writing
50	#   --backup            Write a .bak copy of target before modifying
51	#
52	# ==============================================================================================
53	# RUNTIME MODES
54	# ==============================================================================================
55	#
56	# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf --dry-run
57	#     Preview what would be added, removed, and kept — no changes written.
58	#
59	# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf --backup
60	#     Apply the upgrade, writing a .bak first.

#!/bin/bash
# ==============================================================================================
# ============================= conf_upgrade.sh ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Merges a new conf template into an existing user conf while preserving every
# value the user has already set. Run manually when the conf schema changes
# between versions — adds new keys, removes deprecated ones, and keeps the
# structure of the new template exactly.
#
#   Keys in template only  → ADDED   (placeholder/default — user fills in once)
#   Keys in target only    → REMOVED (deprecated in new version)
#   Keys in both           → KEPT    (target's value always wins, template ignored)
#   Comments / blank lines → always from template (structure follows new version)
#
# Supports all conf variable patterns: simple scalars, indexed arrays, and
# associative arrays (declare -A).
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Dry-run Mode
#   --dry-run prints the full change report (ADDED / REMOVED / KEPT) then exits
#   without writing anything. Always preview before applying to production confs.
#
# Backup Option
#   --backup writes a .bak copy of the target before overwriting. Use when
#   applying to a conf that has never been upgraded before.
#
# File Existence Guards
#   Both --template and --target are validated before any parsing begins.
#   Missing files abort immediately with a clear error.
#
# Atomic Write
#   Merged output is written to a tempfile first, then copied to the target.
#   A partial write cannot corrupt the original.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# No conf vars. All inputs are CLI flags.
#
#   --template <file>   New version conf file (source of structure and defaults)
#   --target   <file>   Existing user conf (source of real values — always preserved)
#   --dry-run           Show what would change without writing
#   --backup            Write a .bak copy of target before modifying
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf --dry-run
#     Preview what would be added, removed, and kept — no changes written.
#
# conf_upgrade.sh --template Configurations/master.conf.template --target Configurations/master.conf --backup
#     Apply the upgrade, writing a .bak first.


1	#!/bin/bash
2	# ==============================================================================================
3	# ============================= Conf Auto-Populate =============================================
4	# ==============================================================================================
5	#
6	# PURPOSE
7	# ─────────────────────────────────────────────────────────────────────────────
8	# Reads credentials and settings from locally running services and writes
9	# them into the local host conf. Safe to run multiple times — only populates
10	# EMPTY fields, never overwrites existing values unless --overwrite is passed.
11	#
12	# After populating, pushes the updated conf to all partners via conf_sync.sh
13	# so they have the fresh keys in their /tmp/.vv/ cache immediately.
14	#
15	# ==============================================================================================
16	# AUTO-DETECTED FIELDS
17	# ==============================================================================================
18	#
19	#   HOSTN_OWNER               from hostname (strip unRAID- prefix, lowercase)
20	#   HOSTN_RADARR_API_KEY      from Radarr config.xml (found via docker volume mount)
21	#   HOSTN_RADARR_URL          from Radarr config.xml port
22	#   HOSTN_SONARR_API_KEY      from Sonarr config.xml
23	#   HOSTN_SONARR_URL          from Sonarr config.xml port
24	#   HOSTN_LIDARR_API_KEY      from Lidarr config.xml
25	#   HOSTN_LIDARR_URL          from Lidarr config.xml port
26	#   HOSTN_RADARR_MOVIE_ROOT   from Radarr rootFolder API
27	#   HOSTN_SONARR_TV_ROOT      from Sonarr rootFolder API
28	#   HOSTN_LIDARR_MUSIC_ROOT   from Lidarr rootFolder API
29	#   HOSTN_SABNZBD_API_KEY     from sabnzbd.ini
30	#   HOSTN_SABNZBD_URL         from sabnzbd.ini port
31	#   HOSTN_SLSKD_API_KEY       from slskd config.yml
32	#   HOSTN_SLSKD_URL           from slskd config.yml port
33	#   HOSTN_QBIT_URL            from qBittorrent.conf WebUI port
34	#   HOSTN_QBIT_USERNAME       from qBittorrent.conf WebUI username
35	#   HOSTN_QBIT_PASSWORD       from qBittorrent.conf WebUI password (plaintext only)
36	#   HOSTN_EMBY_CONTAINER      fuzzy match from docker ps
37	#   HOSTN_EMBY_URL            from docker port binding
38	#   HOSTN_JELLYFIN_CONTAINER  fuzzy match from docker ps
39	#   HOSTN_JELLYFIN_URL        from docker port binding
40	#   HOSTN_TRANSCODE_SSD       from Emby/Jellyfin container /transcode volume mount
41	#   HOSTN_SYS_WATCHDOG_NIC   from ip route default gateway interface
42	#
43	# ==============================================================================================
44	# RUNTIME MODES
45	# ==============================================================================================
46	#
47	#   conf_populate.sh              Populate empty fields only
48	#   conf_populate.sh --overwrite  Overwrite all detected fields (re-sync after arr key rotation)
49	#   conf_populate.sh --dry-run    Show what would be written, no changes
50	#   conf_populate.sh --log        Verbose output

#!/bin/bash
# ==============================================================================================
# ============================= Conf Auto-Populate =============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Reads credentials and settings from locally running services and writes
# them into the local host conf. Safe to run multiple times — only populates
# EMPTY fields, never overwrites existing values unless --overwrite is passed.
#
# After populating, pushes the updated conf to all partners via conf_sync.sh
# so they have the fresh keys in their /tmp/.vv/ cache immediately.
#
# ==============================================================================================
# AUTO-DETECTED FIELDS
# ==============================================================================================
#
#   HOSTN_OWNER               from hostname (strip unRAID- prefix, lowercase)
#   HOSTN_RADARR_API_KEY      from Radarr config.xml (found via docker volume mount)
#   HOSTN_RADARR_URL          from Radarr config.xml port
#   HOSTN_SONARR_API_KEY      from Sonarr config.xml
#   HOSTN_SONARR_URL          from Sonarr config.xml port
#   HOSTN_LIDARR_API_KEY      from Lidarr config.xml
#   HOSTN_LIDARR_URL          from Lidarr config.xml port
#   HOSTN_RADARR_MOVIE_ROOT   from Radarr rootFolder API
#   HOSTN_SONARR_TV_ROOT      from Sonarr rootFolder API
#   HOSTN_LIDARR_MUSIC_ROOT   from Lidarr rootFolder API
#   HOSTN_SABNZBD_API_KEY     from sabnzbd.ini
#   HOSTN_SABNZBD_URL         from sabnzbd.ini port
#   HOSTN_SLSKD_API_KEY       from slskd config.yml
#   HOSTN_SLSKD_URL           from slskd config.yml port
#   HOSTN_QBIT_URL            from qBittorrent.conf WebUI port
#   HOSTN_QBIT_USERNAME       from qBittorrent.conf WebUI username
#   HOSTN_QBIT_PASSWORD       from qBittorrent.conf WebUI password (plaintext only)
#   HOSTN_EMBY_CONTAINER      fuzzy match from docker ps
#   HOSTN_EMBY_URL            from docker port binding
#   HOSTN_JELLYFIN_CONTAINER  fuzzy match from docker ps
#   HOSTN_JELLYFIN_URL        from docker port binding
#   HOSTN_TRANSCODE_SSD       from Emby/Jellyfin container /transcode volume mount
#   HOSTN_SYS_WATCHDOG_NIC   from ip route default gateway interface
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
#   conf_populate.sh              Populate empty fields only
#   conf_populate.sh --overwrite  Overwrite all detected fields (re-sync after arr key rotation)
#   conf_populate.sh --dry-run    Show what would be written, no changes
#   conf_populate.sh --log        Verbose output


Everything checks out. Here's the full trace of the initial run path:

**Templates — location check:**
- Only exist in `Configurations/` — `find` confirmed no copies in `Deployment/`
- `Deployment/` contains only `deploy.sh` + `conf_upgrade.sh` (dev→prod sync tools, not part of install)

**Initial run flow, step by step:**

| Step | What happens | Template/conf path |
|------|-------------|-------------------|
| `.plg` install | Seeds `varaverk.cfg` with `SCRIPTS_DIR="/boot/config/plugins/varaverk"`, clones repo, then seeds `master.conf` if absent | `$CFG_DIR/Configurations/master.conf.template` ✅ |
| Every boot | Symlinks `/usr/local/emhttp/plugins/varaverk` → Plugin/unraid | no conf involved |
| User opens plugin → wizard | `api/setup.php?action=detect` → hostname, boot transport, storage mode pre-selected | — |
| Wizard save | Creates `host1.conf` from template; writes hostnames into master.conf; SSH keygen; API key | `CONF_DIR . '/host.conf.template'` where `CONF_DIR = SCRIPTS_DIR/Configurations` ✅ |
| Storage migration (USB) | `storage_migrate.sh --to=flash` — uses `CONF_DIR` from `load_config.sh` | `SCRIPTS_DIR/Configurations` ✅ |
| Wizard step 2 | `conf_populate.sh` reads running containers → fills empty conf fields; reads/writes via `load_config.sh` | `Configurations/` ✅ |

**loader chain:**
- `load_config.sh` → `CONF_DIR="${SCRIPTS_DIR}/Configurations"` → sources `Configurations/master.conf` then `Configurations/host*.conf` ✅
- `config.php` → reads `varaverk.cfg` for `SCRIPTS_DIR` → `define('CONF_DIR', SCRIPTS_DIR . '/Configurations')` ✅

No references to `Deployment/` anywhere in the install or runtime path. The fix held — everything resolves to `Configurations/` from the very first `.plg` install step through wizard completion.


ok, now gitea will not push a new template at 1am?


Good question. Let me check what's tracked in the repo vs what's gitignored, and what the daily git pull actually does.


Bash

cat /boot/config/plugins/varaverk/.gitignore 2>/dev/null || echo "(no .gitignore)"

Check gitignore


git -C /boot/config/plugins/varaverk ls-files Configurations/ 2>/dev/null

List what git is tracking in Configurations/


# ── Personal configuration — never push to public repo ────────────────────────
# These contain API keys, passwords, SSH paths, personal hostnames.
# Templates (*.template) are safe and remain tracked.
Configurations/host*.conf
Configurations/master.conf
Configurations/*.bak
.claude
.vscode

# ── Runtime state, data, logs ─────────────────────────────────────────────────
data/
State_Files/
*.log
*.lock

# ── Plugin runtime state (per-host, never synced) ────────────────────────────
schedule.json
docker_folders.json
*.db

# ── Plugin config files (co-located with scripts on flash) ───────────────────
varaverk.cfg
varaverk.cron
varaverk-*.txz

# ── Build artifacts ───────────────────────────────────────────────────────────
# .txz packages are attached to GitHub releases, not committed to the repo.
Plugin/dist/

# ── OS / editor ───────────────────────────────────────────────────────────────
.DS_Store
*.swp
*~
.vscode/


Configurations/host.conf.template
Configurations/master.conf.template


You're safe. The `.gitignore` explicitly excludes `Configurations/host*.conf` and `Configurations/master.conf` — only the `.template` files are tracked. Git pull at 1am will update templates (if they changed) but will never touch your live confs.


i woke up to varaverk broken this morning and that change last night..... gitea didnt line up with my host1.conf and master.conf, so it rm them and added a fresh template... wich broke a running system


Let me look at what the daily git pull actually executes — a plain `git pull` won't touch gitignored files, but `git reset --hard` or `git checkout` will override anything tracked in the remote.


Read git pull script


#!/bin/bash
# ==============================================================================================
# ================================= Git Pull & Execute =========================================
# ==============================================================================================
# Pulls the latest scripts from the Gitea repository via SSH.
# Lives at the repo root — sources load_config.sh from the same directory.
#
# ── WHAT THIS SCRIPT DOES ─────────────────────────────────────────────────────────────────────
# 1. Detects which server it's running on via detect_hosts() (MY_ID)
# 2. Configures sparse checkout to exclude other servers' credential files
#    Each server only pulls its own host*.conf — never sees peer credentials
# 3. Pulls or clones latest scripts from Gitea
# 4. Sets executable permissions on all .sh files
#
# ── SPARSE CHECKOUT ───────────────────────────────────────────────────────────────────────────
# Sparse checkout ensures each server only receives its own host conf:
#   HOST1 pulls:  master.conf + host1.conf + all scripts
#   HOST1 skips:  host2.conf, host3.conf etc.
#   HOST2 pulls:  master.conf + host2.conf + all scripts
#   HOST2 skips:  host1.conf, host3.conf etc.
#
# Adding a new server:
#   Create host3.conf in the repo
#   All existing servers automatically exclude it on next pull
#   New server gets only its own conf ✅
#
# ── GITEA LOCATION DETECTION ──────────────────────────────────────────────────────────────────
# Detects where Gitea is running at runtime — works through fallback:
#   Gitea local  → connects via local IP
#   Gitea remote → connects via Tailscale IP
#   Both fail    → falls back to GITEA_DOMAIN if configured
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
#   GITEA_CONTAINER  — Docker container name for Gitea
#   GITEA_REPO_PATH  — repo path on Gitea (e.g. Varaverk/varaverk.git)
#   GITEA_DOMAIN     — public domain fallback (optional)
#   TARGET_DIR       — local path to clone/pull into
#   GITEA_SSH_KEY    — SSH key for Gitea authentication
#   SSH_PORT         — Gitea SSH port (often 221 or 222)
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
#   git_pull_execute.sh              — normal pull
#   git_pull_execute.sh --dry-run    — preview without making changes
#   git_pull_execute.sh --log        — verbose output
#   git_pull_execute.sh --status     — show config and exit
# ==============================================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Root level script — load_config.sh is in the same directory
source "$SCRIPT_DIR/load_config.sh"

parse_args "$@"

# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
    error "Must be run as root"
    exit 1
fi
acquire_lock

if ! command -v docker &>/dev/null; then
    error "Docker command not found"
    exit 1
fi

# detect_hosts() sets MY_ID — needed for sparse checkout configuration
detect_hosts

# ==============================================================================================
# ━━━ Locate Gitea ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CONTAINERS Locate Gitea ━━━"

if docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
    # Gitea is running on this server — use local IP
    GITEA_IP=$(hostname -I | awk '{print $1}')
    log "Gitea running locally — connecting via $GITEA_IP"
else
    # Gitea not running locally — find it on the remote server via Tailscale
    log "Gitea not running locally — checking remote server"
    GITEA_IP=$(resolve_tailscale_ip "${REMOTE_SERVER_NAME}")
    if [[ -n "$GITEA_IP" ]]; then
        echo "  Gitea on $REMOTE_SERVER_NAME — connecting via Tailscale $GITEA_IP"
    elif [[ -n "${GITEA_DOMAIN:-}" ]]; then
        warn "Tailscale resolution failed — falling back to $GITEA_DOMAIN"
        GITEA_IP="$GITEA_DOMAIN"
    else
        error "Cannot find Gitea — local: not running, Tailscale: failed, domain: not configured"
        notify "Git pull failed on $(hostname) — cannot locate Gitea container" "Git Sync" "alert"
        exit 1
    fi
fi

REPO_SSH="git@${GITEA_IP}:${GITEA_REPO_PATH}"

require_var REPO_SSH
require_var TARGET_DIR
require_var GITEA_SSH_KEY
require_var SSH_PORT

# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
    echo ""
    echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
    echo "$ICON_NET  Repo:        $REPO_SSH"
    echo "$ICON_GEAR Target:      $TARGET_DIR"
    echo "$ICON_GEAR SSH Key:     $GITEA_SSH_KEY"
    echo "$ICON_GEAR SSH Port:    $SSH_PORT"
    echo "$ICON_HOST My ID:       $MY_ID ($LOCAL_SERVER_NAME)"
    echo "$ICON_HOST Remote ID:   $REMOTE_ID ($REMOTE_SERVER_NAME)"
    echo "$ICON_NOTIFY Notify:    unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
    echo "$ICON_GEAR Dry Run:     $DRY_RUN"
    echo "━━━━━━━━━━━━━━━━━━━━━━━"
    exit 0
fi

[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"

# ==============================================================================================
# ━━━ Sparse Checkout Configuration ━━━
# ==============================================================================================
# Build the list of host*.conf files that belong to OTHER servers.
# This server pulls everything EXCEPT those files.
# MY_ID is set by detect_hosts() — e.g. "HOST1"

configure_sparse_checkout() {
    local repo_dir="$1"

    log "Configuring sparse checkout for $MY_ID..."

    # Enable sparse checkout
    git -C "$repo_dir" config core.sparseCheckout true 2>/dev/null

    # Build exclusion list — all host*.conf files except MY_ID's
    local sparse_file="$repo_dir/.git/info/sparse-checkout"
    mkdir -p "$(dirname "$sparse_file")"

    # Start with: pull everything
    echo "/*" > "$sparse_file"

    # Exclude each other server's conf file
    # Find all host*.conf files present in the repo
    local excluded=0
    for conf_file in "$repo_dir"/host*.conf; do
        [[ -f "$conf_file" ]] || continue
        local conf_name
        conf_name=$(basename "$conf_file")

        # Determine which HOST ID owns this conf by grepping its hostname var
        # Pattern: HOST1="unRAID-..." or HOST2="unRAID-..."
        local conf_host_id
        conf_host_id=$(grep -m1 -oP '^\s+HOST[0-9]+(?==)' "$conf_file" 2>/dev/null | tr -d ' ')

        if [[ -z "$conf_host_id" ]]; then
            log "Cannot determine HOST ID for $conf_name — including in pull (safe default)"
            continue
        fi

        if [[ "$conf_host_id" != "$MY_ID" ]]; then
            echo "!$conf_name" >> "$sparse_file"
            log "Sparse checkout: excluding $conf_name (belongs to $conf_host_id)"
            ((excluded++))
        else
            log "Sparse checkout: including $conf_name (belongs to $MY_ID — this server)"
        fi
    done

    if [[ "$excluded" -gt 0 ]]; then
        echo "  Sparse checkout: excluding $excluded peer conf file(s) — credentials protected"
    else
        log "Sparse checkout: no peer conf files to exclude (single server or first run)"
    fi
}

# ==============================================================================================
# ━━━ Git Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Git Sync ━━━"
echo "$ICON_NET  Repo:    $REPO_SSH"
echo "$ICON_GEAR Target:  $TARGET_DIR"
echo ""

START=$(date +%s)
SYNC_SUCCESS=false

if [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would sync $REPO_SSH → $TARGET_DIR"
    warn "DRY RUN — would configure sparse checkout for $MY_ID"
    warn "DRY RUN — would exclude peer host*.conf files"
    SYNC_SUCCESS=true
else
    mkdir -p "$TARGET_DIR"
    git config --global --add safe.directory "$TARGET_DIR"
    cd "$TARGET_DIR" || { error "Cannot cd into $TARGET_DIR"; exit 1; }

    if [[ -d ".git" ]]; then
        # ── Existing repository ──────────────────────────────────────────────
        echo "  Existing repository — updating"

        # Configure sparse checkout BEFORE pull
        # Uses conf files already present from last pull to determine exclusions
        configure_sparse_checkout "$TARGET_DIR"

        echo "  Pulling latest changes..."
        if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" git pull --ff-only; then
            echo "  Git pull successful"
            SYNC_SUCCESS=true
        else
            # ff-only fails when local commits or tracked changes exist that can't
            # fast-forward. Fail loudly — never silently destroy local work.
            error "Git pull failed — local changes conflict with remote (will not force-reset)"
            notify "Git pull failed on $(hostname) — local changes conflict, manual resolve needed" "Git Sync" "alert"
            exit 1
        fi

    else
        # ── Fresh clone ──────────────────────────────────────────────────────
        echo "  No repository found — cloning"

        # Clone first — need the repo to exist before configuring sparse checkout
        if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" git clone "$REPO_SSH" .; then
            echo "  Clone successful"

            # Configure sparse checkout after clone
            # Now all host*.conf files are present — can detect exclusions
            configure_sparse_checkout "$TARGET_DIR"

            # Apply sparse checkout — removes excluded files from working tree
            echo "  Applying sparse checkout..."
            git read-tree -mu HEAD
            echo "  Sparse checkout applied — peer credentials removed from working tree"

            SYNC_SUCCESS=true
        else
            error "Clone failed"
            notify "Git clone failed on $(hostname) — check Gitea connectivity" "Git Sync" "alert"
            exit 1
        fi
    fi

    # ── Permissions ──────────────────────────────────────────────────────────
    echo ""
    echo "━━━ $ICON_GEAR Permissions ━━━"
    log "Setting executable permissions on all .sh files..."
    find "$TARGET_DIR" -type f -name "*.sh" -exec chmod +x {} \;
    echo "  Permissions set on .sh files"

    # ── Flash mode: sync Plugin/ to /boot/ so the webUI picks up updates ─────
    # In flash mode SCRIPTS_DIR is in appdata — Plugin/ lives in the repo there
    # but Unraid serves PHP from /boot/. Sync after every pull to keep them in step.
    _BOOT_DIR="/boot/config/plugins/varaverk"
    if [[ "$TARGET_DIR" != "$_BOOT_DIR" ]]; then
        echo ""
        echo "━━━ $ICON_SYNC Flash mode: sync Plugin/ → /boot/ ━━━"
        if rsync -a --delete "$TARGET_DIR/Plugin/" "$_BOOT_DIR/Plugin/" 2>/dev/null; then
            echo "  Plugin/ synced to /boot/ ✅"
        else
            warn "Plugin/ sync to /boot/ failed — webUI may be stale until next pull"
        fi
    fi
fi

END=$(date +%s)

# ==============================================================================================
# ━━━ Conf Upgrade ━━━
# ==============================================================================================
# Merges new conf structure into the live conf files after every pull.
# New keys → added with template defaults (user fills in once).
# Removed keys → dropped. Existing values → always preserved.
# Silent when already up to date — no overhead on unchanged pulls.

echo ""
echo "━━━ $ICON_GEAR Conf Upgrade ━━━"

UPGRADE_SCRIPT="$TARGET_DIR/Deployment/conf_upgrade.sh"
CONF_DIR="$TARGET_DIR/Configurations"

if [[ ! -f "$UPGRADE_SCRIPT" ]]; then
    log "conf_upgrade.sh not found — skipping (pre-deployment-folder repo)"
elif [[ "$DRY_RUN" == true ]]; then
    warn "DRY RUN — would upgrade master.conf and ${MY_ID,,}.conf"
elif [[ "$SYNC_SUCCESS" == true ]]; then
    _DRY=""

    # master.conf
    if [[ -f "$CONF_DIR/master.conf.template" && -f "$CONF_DIR/master.conf" ]]; then
        bash "$UPGRADE_SCRIPT" \
            --template "$CONF_DIR/master.conf.template" \
            --target   "$CONF_DIR/master.conf" \
            --backup   $_DRY
    else
        warn "master.conf.template or master.conf not found — skipping"
    fi

    # This server's host conf only — sparse checkout ensures we have it
    HOST_CONF="$CONF_DIR/${MY_ID,,}.conf"
    if [[ -f "$CONF_DIR/host.conf.template" && -f "$HOST_CONF" ]]; then
        # Template uses HOSTN_ as generic prefix. Substitute MY_ID before merging
        # so keys match the target (HOST1_* or HOST2_*) and real values are preserved.
        TMPL_RESOLVED=$(mktemp)
        sed "s/HOSTN_/${MY_ID}_/g; s/REMOTE_ID/${REMOTE_ID}/g" "$CONF_DIR/host.conf.template" > "$TMPL_RESOLVED"
        bash "$UPGRADE_SCRIPT" \
            --template "$TMPL_RESOLVED" \
            --target   "$HOST_CONF" \
            --backup   $_DRY
        rm -f "$TMPL_RESOLVED"
    else
        warn "${MY_ID,,}.conf or host.conf.template not found — skipping"
    fi
fi

# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY GIT SYNC SUMMARY ━━━━━"
echo "$ICON_NET  Repo:     $REPO_SSH"
echo "$ICON_GEAR Target:   $TARGET_DIR"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_LOCK Excluded: peer host*.conf files"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
if [[ "$DRY_RUN" == true ]]; then
    echo "$ICON_WARN Status:   DRY RUN — no changes made"
elif [[ "$SYNC_SUCCESS" == true ]]; then
    echo "$ICON_DONE Status:   $ICON_SUCCESS DONE"
    notify "Repository synced successfully on $(hostname)" "Git Sync" "normal"
else
    echo "$ICON_ERROR Status:  $ICON_ERROR FAILED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"


grep -n "git\|pull" /boot/config/plugins/varaverk/Orchestrators/daily_sync_maintenance.sh | head -30

Check how daily maintenance calls git pull


10:#     git_pull_execute.sh              — pull latest scripts first, always
31:#   git pull first        — maintenance runs on latest code, not yesterday's
145:# ── Split maintenance scripts: git pull runs pre-sync, rest run post-sync ─────────────────────
151:    if [[ "$script_name" == "git_pull_execute.sh" ]]; then
198:# ━━━ Pre-sync — git pull ━━━


i woke up to varaverk broken this morning and that change last night..... gitea didnt line up with my host1.conf and master.conf, so it rm them and added a fresh template... wich broke a running syste…


Found it. Look at the "Conf Upgrade" section at the bottom of `git_pull_execute.sh` — after every pull it runs `conf_upgrade.sh` using `host.conf.template` as the structure source. If Gitea's template is missing keys that exist in your live conf, `conf_upgrade.sh` removes them ("Keys in target only → REMOVED"). That's what killed it.

Let me check the current git state to see what's committed and what Gitea actually has.


git -C /boot/config/plugins/varaverk ls-files Configurations/ && echo "---" && git -C /boot/config/plugins/varaverk status --short && echo "---" && git -C /boot/config/plugins/varaverk log --oneline -8

Check tracked files, status, and recent commits


Configurations/host.conf.template
Configurations/master.conf.template
---
 M Configurations/host.conf.template
 M Plugin/unraid/api/setup.php
 M Plugin/unraid/pages/setup.php
 M Tools/claude_startup.sh
?? Configurations/host1.conf.template.bak2
?? claude-bin/
?? claude-data/
---
60ff658 Fix conf_upgrade template naming bugs that caused false ADDED/REMOVED on every nightly pull
1bc76f8 Align host template to unified section structure
f036fbf Sync master.conf.template schema to match live master.conf
1f766fe Sync host.conf.template schema to match host1.conf
f10f09e Bug fixes, arr cleanup API-driven scan, shared JS formatters
3937d5e Consolidate conf templates into Configurations/ — remove stale Deployment/conf_templates/
129e8ae Fix Emby HTTP 500 on PlayedItems — convert DatePlayed to yyyyMMddHHmmss
ca0d17e All rsync flags re-enabled — HOST2 rebuild complete


The situation is clear. `host.conf.template` has our AUTH STACK changes uncommitted and unpushed. When conf_upgrade.sh runs tonight with Gitea's old template (which has no AUTH STACK section), it will see `HOST1_NPM_URL`, `HOST1_NPM_USER`, etc. as "keys in target only → REMOVED" and strip them from `host1.conf`. Same problem, different keys.

The other uncommitted files (`setup.php`, `pages/setup.php`, `claude_startup.sh`) are scripts only — conf_upgrade.sh won't touch those. But the template mismatch will break the conf.

Also, what's that `.bak2` file — that's unexpected:


ls -la /boot/config/plugins/varaverk/Configurations/*.bak* 2>/dev/null && echo "---" && git -C /boot/config/plugins/varaverk diff Configurations/host.conf.template | head -60

Check bak files and what changed in template


-rw-rw-rw- 1 root root 42131 Jun 13 01:00 /boot/config/plugins/varaverk/Configurations/host1.conf.bak
-rw-r--r-- 1 root root 23870 Jun 13 10:00 /boot/config/plugins/varaverk/Configurations/host1.conf.template.bak2
-rw-rw-rw- 1 root root 92902 Jun  7 01:00 /boot/config/plugins/varaverk/Configurations/master.conf.bak
---
diff --git a/Configurations/host.conf.template b/Configurations/host.conf.template
index 82e3c04..2842f46 100644
--- a/Configurations/host.conf.template
+++ b/Configurations/host.conf.template
@@ -503,3 +503,27 @@
     HOSTN_SYS_WATCHDOG_CHECK_NETWORK=true
     HOSTN_SYS_WATCHDOG_CHECK_SSHD=true
     HOSTN_SYS_WATCHDOG_CHECK_RUNAWAY=false
+
+# ==============================================================================================
+# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
+# ==============================================================================================
+# Credentials for the Varaverk Auth Stack page (NPM, lldap, Authelia).
+
+# ━━━ NginxProxyManager ━━━
+# Admin API runs on 7818 (not 81 — 81 is the partnership WebUI port).
+    HOSTN_NPM_URL="http://localhost:7818"
+    HOSTN_NPM_USER=""                           # NPM admin email
+    HOSTN_NPM_PASS=""                           # NPM admin password
+
+# ━━━ lldap ━━━
+    HOSTN_LLDAP_URL="http://localhost:17170"
+    HOSTN_LLDAP_USER="admin"                    # lldap admin username
+    HOSTN_LLDAP_PASS=""                         # lldap admin password
+
+# ━━━ Authelia ━━━
+    HOSTN_AUTHELIA_CONFIG="/mnt/user/appdata/Authelia/configuration.yml"
+    HOSTN_AUTHELIA_CONTAINER="Authelia"
+
+# ==============================================================================================
+# ──────────────────────── End Of HOSTn Variables ──────────────────────────────────────────────
+# ==============================================================================================


`host1.conf.bak` at 01:00 confirms conf_upgrade.sh ran and backed up before the damage. `host1.conf.template.bak2` is junk — a leftover from whatever file accidentally appeared called `host1.conf.template` during last night's chaos.

**The risk tonight:** conf_upgrade.sh will run again with Gitea's old template, see the 24 AUTH STACK lines in `host1.conf` as "keys in target only → REMOVED", and strip them all out again.

Everything that needs to be committed and pushed before 1am:

- `Configurations/host.conf.template` — the AUTH STACK section (the one that actually protects host1.conf tonight)
- `Plugin/unraid/api/setup.php` — storage mode wizard fix
- `Plugin/unraid/pages/setup.php` — storage mode wizard fix
- `Tools/claude_startup.sh` — whatever changed there

And `Configurations/host1.conf.template.bak2` should be deleted — it's untracked noise that will confuse things.

Want me to commit all of that and push to Gitea?


push it , no coauthor unless i specify


git -C /boot/config/plugins/varaverk diff Tools/claude_startup.sh

Check what changed in claude_startup.sh


diff --git a/Tools/claude_startup.sh b/Tools/claude_startup.sh
index 42cafaa..17c1d51 100755
--- a/Tools/claude_startup.sh
+++ b/Tools/claude_startup.sh
@@ -5,18 +5,27 @@
 #
 # PURPOSE
 # ─────────────────────────────────────────────────────────────────────────────
-# Restores Claude Code's persistent data after an Unraid reboot and launches Claude.
-# Unraid's root filesystem lives in RAM — /root/.claude and /root/.local are wiped on
-# every reboot. This script symlinks both directories back to persistent appdata storage
-# before launching Claude, so memory, sessions, and settings survive across reboots.
+# Restores Claude Code's persistent data after an Unraid reboot.
+# /root is RAM — wiped on every boot. This script re-creates symlinks so
+# Claude's memory, sessions, settings, and binary survive across reboots.
 #
-# On first run with no existing persistent data, migrates from the current live locations:
-#   /root/.claude              → PERSIST_DIR/.claude            (memory, sessions, settings)
-#   /root/.local/share/claude  → PERSIST_DIR/local/share/claude (installed binaries)
-# Subsequent runs skip the migration and only create the symlinks.
+# Storage mode is read from the host conf file:
 #
-# Standalone script — no common.sh dependency. Safe to run directly from terminal
-# or from array_started.sh.
+#   HOST*_STORAGE_MODE_INTERNAL=true   → Internal (boot) mode
+#       .claude data  → /boot/config/claude
+#       binary        → /boot/config/claude-bin
+#       No array dependency — runs even before array mounts.
+#
+#   HOST*_STORAGE_MODE_INTERNAL=false  → Appdata mode
+#       .claude data  → /mnt/user/appdata/claude-code/.claude
+#       binary        → /mnt/user/appdata/claude-code/local/share/claude
+#       Requires array to be mounted.
+#
+# On first run in either mode, migrates any existing live data to persistent
+# storage. Subsequent runs only re-create the symlinks.
+#
+# Standalone script — no common.sh dependency. Safe to run directly from
+# terminal or from array_started.sh.
 #
 # ==============================================================================================
 # RUNTIME MODES
@@ -30,14 +39,9 @@
 #
 # ==============================================================================================

-PERSIST_DIR="/mnt/user/appdata/claude-code"
-CLAUDE_DATA="$PERSIST_DIR/.claude"
-CLAUDE_BIN="$PERSIST_DIR/local/share/claude"
-
 LAUNCH=false
 [[ "$1" == "--launch" ]] && LAUNCH=true

-# Standalone — no common.sh dependency
 _log()  { echo "  ✅  $*"; }
 _warn() { echo "  ⚠   $*"; }
 _err()  { echo "  ❌  $*" >&2; }
@@ -46,15 +50,48 @@ echo ""
 echo "━━━ Claude Code Startup ━━━"
 echo ""

-# ── Array must be mounted ─────────────────────────────────────────────────────────────────────
-if ! mountpoint -q /mnt/user 2>/dev/null; then
-    _err "Array not mounted — /mnt/user not available"
-    exit 1
+# ── Detect storage mode ───────────────────────────────────────────────────────────────────────
+CONF_DIR="/boot/config/plugins/varaverk/Configurations"
+STORAGE_INTERNAL=false
+for _conf in "$CONF_DIR"/host*.conf; do
+    [[ -f "$_conf" ]] || continue
+    if grep -q "_STORAGE_MODE_INTERNAL=true" "$_conf" 2>/dev/null; then
+        STORAGE_INTERNAL=true
+        break
+    fi
+done
+
+if [[ "$STORAGE_INTERNAL" == true ]]; then
+    CLAUDE_DATA="/boot/config/plugins/varaverk/claude-data"
+    CLAUDE_BIN="/boot/config/plugins/varaverk/claude-bin"
+    _log "Storage mode: internal boot"
+else
+    PERSIST_DIR="/mnt/user/appdata/claude-code"
+    CLAUDE_DATA="$PERSIST_DIR/.claude"
+    CLAUDE_BIN="$PERSIST_DIR/local/share/claude"
+    _log "Storage mode: appdata"
+fi
+
+# ── Array check (appdata mode only) ──────────────────────────────────────────────────────────
+if [[ "$STORAGE_INTERNAL" == false ]]; then
+    if ! mountpoint -q /mnt/user 2>/dev/null; then
+        _err "Array not mounted — /mnt/user not available (required for appdata mode)"
+        exit 1
+    fi
 fi

 # ── Create persistent dirs ────────────────────────────────────────────────────────────────────
 mkdir -p "$CLAUDE_DATA" "$CLAUDE_BIN"

+# ── Migrate from old /boot/config/claude location (internal mode only) ───────────────────────
+if [[ "$STORAGE_INTERNAL" == true && -d /boot/config/claude && "$CLAUDE_DATA" != "/boot/config/claude" ]]; then
+    if [[ -z "$(ls -A "$CLAUDE_DATA" 2>/dev/null)" ]]; then
+        _warn "Migrating old /boot/config/claude → $CLAUDE_DATA"
+        cp -a /boot/config/claude/. "$CLAUDE_DATA/"
+        _log "Migrated .claude data from old location"
+    fi
+fi
+
 # ── Migrate .claude on first run ──────────────────────────────────────────────────────────────
 if [[ ! -L /root/.claude && -d /root/.claude ]]; then
     _warn "First run — migrating /root/.claude → $CLAUDE_DATA"
@@ -75,8 +112,6 @@ if [[ ! -L /root/.local/share/claude && -d /root/.local/share/claude ]]; then
 fi

 # ── Create symlinks ───────────────────────────────────────────────────────────────────────────
-# Remove any real directories first — ln -sfn silently creates inside a dir instead of
-# replacing it, which produces a circular symlink on subsequent runs after migration.
 mkdir -p /root/.local/share /root/.local/bin

 [[ -d /root/.claude && ! -L /root/.claude ]] && rm -rf /root/.claude
@@ -88,8 +123,6 @@ ln -sfn "$CLAUDE_BIN" /root/.local/share/claude
 _log "claude binary  → $CLAUDE_BIN"

 # ── Symlink CLAUDE.md ─────────────────────────────────────────────────────────────────────────
-# Lives on /boot so it survives reboots without appdata. Symlinked into /root so Claude
-# picks it up automatically from the working directory on every session.
 CLAUDE_MD="/boot/config/plugins/varaverk/CLAUDE.md"
 if [[ -f "$CLAUDE_MD" ]]; then
     ln -sfn "$CLAUDE_MD" /root/CLAUDE.md
@@ -111,7 +144,6 @@ _log "claude v$LATEST ready"

 echo ""

-# ── Setup-only mode (used by array_started.sh or other callers) ─────────────────────────────────
 if [[ "$LAUNCH" == false ]]; then
     _log "Setup complete — run 'claude' to start"
     echo ""


Delete junk bak2 file


(Bash completed with no output)


git -C /boot/config/plugins/varaverk diff Plugin/unraid/api/setup.php | grep "^[+-]" | grep -v "^---\|^+++" | head -40

Quick summary of setup.php changes


+// Storage mode: use wizard selection, fall back to auto-detect from boot transport
+$smParam = trim($_POST['storage_mode'] ?? '');
+if ($smParam === 'flash') {
+    $storageInternal = 'false';
+} elseif ($smParam === 'internal') {
+    $storageInternal = 'true';
+} else {
+    $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
+    $bootDisk  = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
+    $transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
+    $storageInternal = ($transport !== 'usb') ? 'true' : 'false';
+}
+
-        // Auto-detect storage mode from boot device transport
-        $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
-        $bootDisk  = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
-        $transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
-        $storageInternal = ($transport !== 'usb') ? 'true' : 'false';
-
+$targetDir      = ($storageInternal === 'true') ? '/boot/config/plugins/varaverk' : '/mnt/user/appdata/Varaverk';
+$needsMigration = (defined('SCRIPTS_DIR') && SCRIPTS_DIR !== $targetDir);
+
-    'ok'       => true,
-    'host_id'  => $hostId,
-    'api_key'  => $apiKeyResult,
-    'redirect' => '?tab=scheduler&vv_setup=master.conf',
+    'ok'             => true,
+    'host_id'        => $hostId,
+    'api_key'        => $apiKeyResult,
+    'needs_migration'=> $needsMigration,
+    'migrate_to'     => $needsMigration ? ($storageInternal === 'true' ? 'internal' : 'flash') : null,
+    'redirect'       => '?tab=scheduler&vv_setup=master.conf',


Good, everything checks out. Committing all four files with one focused message per logical change, then pushing.


git -C /boot/config/plugins/varaverk add Configurations/host.conf.template Plugin/unraid/api/setup.php Plugin/unraid/pages/setup.php Tools/claude_startup.sh

Stage changed files


git -C /boot/config/plugins/varaverk commit -m "$(cat <<'EOF'
Add auth stack conf vars and storage mode selection to setup wizard

host.conf.template: add AUTH STACK section (NPM/lldap/Authelia) so
conf_upgrade.sh stops stripping those keys from host*.conf at 1am.

setup wizard: storage mode selector auto-detects USB vs NVMe at first
run; user can override; triggers storage_migrate.sh when mode differs
from current SCRIPTS_DIR location.

claude_startup.sh: reads HOST*_STORAGE_MODE_INTERNAL to pick internal
or appdata path rather than always requiring array to be mounted.
EOF
)"

Commit all staged changes


[main ba1259c] Add auth stack conf vars and storage mode selection to setup wizard
 4 files changed, 157 insertions(+), 42 deletions(-)


GIT_SSH_COMMAND="ssh -i $(grep -oP '(?<=GITEA_SSH_KEY=")[^"]+' /boot/config/plugins/varaverk/varaverk.cfg) -p $(grep -oP '(?<=SSH_PORT=")[^"]+' /boot/config/plugins/varaverk/varaverk.cfg) -o StrictHostKeyChecking=no" git -C /boot/config/plugins/varaverk push origin main 2>&1

Push to Gitea


remote: . Processing 1 references
remote: Processed 1 references in total
To ssh://192.168.50.2:221/FailedProxy/Varaverk.git
   1bc76f8..ba1259c  main -> main


Pushed. Tonight's 1am run will pull `ba1259c`, conf_upgrade.sh will see the AUTH STACK section in the template, and it will correctly treat those keys as already-present → preserved, not removed.


now make sure host2.conf has the same auth stack section


grep -n "AUTH STACK\|NPM_URL\|NPM_USER\|NPM_PASS\|LLDAP\|AUTHELIA" /boot/config/plugins/varaverk/Configurations/host2.conf

Check host2 auth stack section


643:# ── AUTH STACK ────────────────────────────────────────────────────────────────────────────────
650:    HOST2_NPM_URL="http://localhost:7818"
651:    HOST2_NPM_USER=""                           # NPM admin email
652:    HOST2_NPM_PASS=""                           # NPM admin password
655:    HOST2_LLDAP_URL="http://localhost:17170"
656:    HOST2_LLDAP_USER="admin"                    # lldap admin username
657:    HOST2_LLDAP_PASS=""                         # lldap admin password
660:    HOST2_AUTHELIA_CONFIG="/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml"
661:    HOST2_AUTHELIA_CONTAINER="Authelia"


host2.conf already has it — we added it earlier and fixed the NPM port from 81 to 7818. The only difference from host1 is the Authelia config path (`appdata-Fallback` vs `appdata`), which is correct since HOST2 uses the fallback critical-data location.

It's good.


Storage mode wizard and auth stack conf vars are done and pushed to Gitea. Next time you're back, fill in HOST1_NPM_USER, HOST1_NPM_PASS, and HOST1_LLDAP_PASS with real credentials. (disable recaps in /config)


ok lets make sure the daily git pull won't break anything else


Best way to verify is to run conf_upgrade.sh in dry-run mode exactly as the nightly job does — that shows precisely what it would add or remove tonight.


CONF_DIR="/boot/config/plugins/varaverk/Configurations"
UPGRADE="/boot/config/plugins/varaverk/Deployment/conf_upgrade.sh"

echo "=== master.conf ==="
bash "$UPGRADE" --dry-run \
    --template "$CONF_DIR/master.conf.template" \
    --target   "$CONF_DIR/master.conf"

echo ""
echo "=== host1.conf ==="
TMPL=$(mktemp)
sed "s/HOSTN_/HOST1_/g" "$CONF_DIR/host.conf.template" > "$TMPL"
bash "$UPGRADE" --dry-run \
    --template "$TMPL" \
    --target   "$CONF_DIR/host1.conf"
rm -f "$TMPL"

Dry-run conf_upgrade against live confs


=== master.conf ===

── conf_upgrade: master.conf ──────────────────────────────────────────
  REMOVED (deprecated — no longer in this version):
    - CONF_SYNC_ENABLED
  KEPT    235 existing vars — your values preserved
────────────────────────────────────────────────────────────────────────

(dry-run — no changes written)

=== host1.conf ===

── conf_upgrade: host1.conf ──────────────────────────────────────────
  ADDED   (new — fill in your values where needed):
    + FALLBACK_HOST1_COVERS_REMOTE_ID_TIER1
    + FALLBACK_HOST1_COVERS_REMOTE_ID_TIER2
    + FALLBACK_HOST1_COVERS_REMOTE_ID_TIER3
    + FALLBACK_HOST1_COVERS_REMOTE_ID_TIER4
  REMOVED (deprecated — no longer in this version):
    - FALLBACK_HOST1_COVERS_HOST2_TIER1
    - FALLBACK_HOST1_COVERS_HOST2_TIER2
    - FALLBACK_HOST1_COVERS_HOST2_TIER3
    - FALLBACK_HOST1_COVERS_HOST2_TIER4
  KEPT    120 existing vars — your values preserved
────────────────────────────────────────────────────────────────────────

(dry-run — no changes written)


Two findings, but I need to re-run the host1.conf check properly — the nightly job also substitutes `REMOTE_ID` in the template, and I missed that half of the sed. Let me fix that and check what REMOTE_ID resolves to for HOST1.


grep -m1 "^    MY_ID\|^MY_ID\|REMOTE_ID\|HOST2\s*=" /boot/config/plugins/varaverk/Configurations/master.conf | head -5

Find REMOTE_ID value for HOST1


# detect_hosts() in common.sh matches the local hostname against these to set MY_ID / REMOTE_ID.


grep "REMOTE_ID\|COVERS_REMOTE\|COVERS_HOST" /boot/config/plugins/varaverk/Configurations/host.conf.template | head -10

Check template fallback var names


# Replace REMOTE_ID below with the actual remote host ID (HOST1, HOST2, etc.)
    FALLBACK_HOSTN_COVERS_REMOTE_ID_TIER1=(
    FALLBACK_HOSTN_COVERS_REMOTE_ID_TIER2=(
    FALLBACK_HOSTN_COVERS_REMOTE_ID_TIER3=(
    FALLBACK_HOSTN_COVERS_REMOTE_ID_TIER4=(


CONF_DIR="/boot/config/plugins/varaverk/Configurations"
UPGRADE="/boot/config/plugins/varaverk/Deployment/conf_upgrade.sh"

# Replicate exact nightly substitution: HOSTN_ → HOST1_, REMOTE_ID → HOST2
echo "=== host1.conf (with full substitution) ==="
TMPL=$(mktemp)
sed "s/HOSTN_/HOST1_/g; s/REMOTE_ID/HOST2/g" "$CONF_DIR/host.conf.template" > "$TMPL"
bash "$UPGRADE" --dry-run --template "$TMPL" --target "$CONF_DIR/host1.conf"
rm -f "$TMPL"

Dry-run host1.conf with correct REMOTE_ID substitution


=== host1.conf (with full substitution) ===

── conf_upgrade: host1.conf ──────────────────────────────────────────
  KEPT    124 existing vars — your values preserved
  Already up to date — no changes needed.
────────────────────────────────────────────────────────────────────────


host1.conf is clean. One real issue remains — `CONF_SYNC_ENABLED` is in master.conf but not in the template, so it gets removed at 1am. Need to check if anything actually uses it.


grep -rn "CONF_SYNC_ENABLED" /boot/config/plugins/varaverk/ --include="*.sh" --include="*.php" 2>/dev/null | grep -v ".git"

Find all references to CONF_SYNC_ENABLED


/boot/config/plugins/varaverk/System_Essentials/conf_sync.sh:52:if [[ "${CONF_SYNC_ENABLED:-true}" == false ]]; then
/boot/config/plugins/varaverk/System_Essentials/conf_sync.sh:53:    log "CONF_SYNC_ENABLED=false — skipping"


It's used — `conf_sync.sh` checks it to know whether to run. Removing it would mean it can only be disabled by someone who already knows the variable name exists. It belongs in master.conf.template. Let me find where it sits in the live conf and add it to the right place in the template.


grep -n "CONF_SYNC_ENABLED\|conf_sync\|CONF_SYNC" /boot/config/plugins/varaverk/Configurations/master.conf | head -10

Find CONF_SYNC_ENABLED context in master.conf


138:    CONF_SYNC_ENABLED=false             # HOST2 rebuild in progress — re-enable when HOST2 is back online
290:        "System_Essentials/conf_sync.sh"              # pull partner confs + push own conf into /tmp/.vv/ RAM cache


sed -n '130,150p' /boot/config/plugins/varaverk/Configurations/master.conf

Read surrounding context in master.conf


# "warn"  — log warning and continue (safe for minor/patch differences)
# "abort" — refuse to continue (strict — ensures both sides always match)
    UNRAID_VERSION_MISMATCH_ACTION="warn"

# ── Arr Sync ──
# arr_sync.sh syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
# Runs before rsync — all nodes agree on tracked library before files are transferred.
# Remote API keys are read live from each node's config.xml via SSH — never stored here.
    CONF_SYNC_ENABLED=false             # HOST2 rebuild in progress — re-enable when HOST2 is back online

    ARR_SYNC_ENABLED=true
    ARR_SYNC_BLOCKLIST="${DATA_DIR}/arr_sync_blocklist.tsv"
    ARR_SYNC_CONNECT_TIMEOUT=10     # seconds — SSH connect timeout per node
    ARR_SYNC_API_TIMEOUT=60         # seconds — curl timeout for library fetches
    DOCKER_APPDATA_BASE="/mnt/user/appdata"
    ARR_SYNC_LIDARR_PORT=8686
    ARR_SYNC_SONARR_PORT=8989
    ARR_SYNC_RADARR_PORT=7878

# ── Remote Docker Daemon ──
# Strike system for remote Docker daemon health checks.


grep -n "UNRAID_VERSION_MISMATCH\|ARR_SYNC_ENABLED\|Arr Sync\|conf_sync" /boot/config/plugins/varaverk/Configurations/master.conf.template | head -10

Find the right insertion point in master.conf.template


129:    UNRAID_VERSION_MISMATCH_ACTION="warn"
131:# ── Arr Sync ──
135:    ARR_SYNC_ENABLED=true
349:    # It is controlled by ARR_SYNC_ENABLED (see Arr Sync section above).


128	# "abort" — refuse to continue (strict — ensures both sides always match)
129	    UNRAID_VERSION_MISMATCH_ACTION="warn"
130
131	# ── Arr Sync ──
132	# arr_sync.sh syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
133	# Runs before rsync — all nodes agree on tracked library before files are transferred.
134	# Remote API keys are read live from each node's config.xml via SSH — never stored here.
135	    ARR_SYNC_ENABLED=true
136	    ARR_SYNC_BLOCKLIST="${DATA_DIR}/arr_sync_blocklist.tsv"
137	    ARR_SYNC_CONNECT_TIMEOUT=10     # seconds — SSH connect timeout per node
138	    ARR_SYNC_API_TIMEOUT=60         # seconds — curl timeout for library fetches
139	    DOCKER_APPDATA_BASE="/mnt/user/appdata"

# "abort" — refuse to continue (strict — ensures both sides always match)
    UNRAID_VERSION_MISMATCH_ACTION="warn"

# ── Arr Sync ──
# arr_sync.sh syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
# Runs before rsync — all nodes agree on tracked library before files are transferred.
# Remote API keys are read live from each node's config.xml via SSH — never stored here.
    ARR_SYNC_ENABLED=true
    ARR_SYNC_BLOCKLIST="${DATA_DIR}/arr_sync_blocklist.tsv"
    ARR_SYNC_CONNECT_TIMEOUT=10     # seconds — SSH connect timeout per node
    ARR_SYNC_API_TIMEOUT=60         # seconds — curl timeout for library fetches
    DOCKER_APPDATA_BASE="/mnt/user/appdata"


Edit

# ── Arr Sync ──
# arr_sync.sh syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
# Runs before rsync — all nodes agree on tracked library before files are transferred.
# Remote API keys are read live from each node's config.xml via SSH — never stored here.
    ARR_SYNC_ENABLED=true

# ── Conf Sync ──
# conf_sync.sh pulls partner host confs into /tmp/.vv/ RAM cache on array start and after
# any conf save — makes partner vars (HOST2_*, etc.) available without touching git.
    CONF_SYNC_ENABLED=true

# ── Arr Sync ──
# arr_sync.sh syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
# Runs before rsync — all nodes agree on tracked library before files are transferred.
# Remote API keys are read live from each node's config.xml via SSH — never stored here.
    ARR_SYNC_ENABLED=true


The file /boot/config/plugins/varaverk/Configurations/master.conf.template has been updated successfully. (file state is current in your context — no need to Read it back)

# ── Conf Sync ──
# conf_sync.sh pulls partner host confs into /tmp/.vv/ RAM cache on array start and after
# any conf save — makes partner vars (HOST2_*, etc.) available without touching git.
    CONF_SYNC_ENABLED=true

# ── Arr Sync ──
# arr_sync.sh syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
# Runs before rsync — all nodes agree on tracked library before files are transferred.
# Remote API keys are read live from each node's config.xml via SSH — never stored here.
    ARR_SYNC_ENABLED=true

# "abort" — refuse to continue (strict — ensures both sides always match)

+# conf_sync.sh pulls partner host confs into /tmp/.vv/ RAM cache on array start and after

+# any conf save — makes partner vars (HOST2_*, etc.) available without touching git.


Now verify the dry-run is clean on both:


Bash

CONF_DIR="/boot/config/plugins/varaverk/Configurations"
UPGRADE="/boot/config/plugins/varaverk/Deployment/conf_upgrade.sh"

echo "=== master.conf ==="
bash "$UPGRADE" --dry-run \
    --template "$CONF_DIR/master.conf.template" \
    --target   "$CONF_DIR/master.conf"

echo ""
echo "=== host1.conf ==="
TMPL=$(mktemp)
sed "s/HOSTN_/HOST1_/g; s/REMOTE_ID/HOST2/g" "$CONF_DIR/host.conf.template" > "$TMPL"
bash "$UPGRADE" --dry-run --template "$TMPL" --target "$CONF_DIR/host1.conf"
rm -f "$TMPL"

Final dry-run after fixing CONF_SYNC_ENABLED


=== master.conf ===

── conf_upgrade: master.conf ──────────────────────────────────────────
  KEPT    236 existing vars — your values preserved
  Already up to date — no changes needed.
────────────────────────────────────────────────────────────────────────

=== host1.conf ===

── conf_upgrade: host1.conf ──────────────────────────────────────────
  KEPT    124 existing vars — your values preserved
  Already up to date — no changes needed.
────────────────────────────────────────────────────────────────────────


Both clean. Commit and push the template fix:


git -C /boot/config/plugins/varaverk add Configurations/master.conf.template && git -C /boot/config/plugins/varaverk commit -m "Add CONF_SYNC_ENABLED to master.conf.template so nightly upgrade doesn't strip it"

Commit master.conf.template fix


[main fd1c4c7] Add CONF_SYNC_ENABLED to master.conf.template so nightly upgrade doesn't strip it
 1 file changed, 5 insertions(+)


GIT_SSH_COMMAND="ssh -i $(grep -oP '(?<=GITEA_SSH_KEY=")[^"]+' /boot/config/plugins/varaverk/varaverk.cfg) -p $(grep -oP '(?<=SSH_PORT=")[^"]+' /boot/config/plugins/varaverk/varaverk.cfg) -o StrictHostKeyChecking=no" git -C /boot/config/plugins/varaverk push origin main 2>&1

Push to Gitea


remote: . Processing 1 references
remote: Processed 1 references in total
To ssh://192.168.50.2:221/FailedProxy/Varaverk.git
   ba1259c..fd1c4c7  main -> main


Both confs are now safe. The dry-run confirmed "Already up to date — no changes needed" on both — meaning tonight's 1am pull will land, conf_upgrade.sh will run, and nothing gets added, removed, or touched.


push it


Already done — that last push included it. Gitea is at `fd1c4c7`.