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] = 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	// Local LAN IP via routing table — static-cached per request.
361	// Previously duplicated in include/docker_folders.php and inline in include/docker.php.
362	function vv_local_ip(): string {
363	    static $ip = null;
364	    if ($ip !== null) return $ip;
365	    $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)}'") ?? '');
366	    return $ip;
367	}
368

<?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] = 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'];
}

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


yes lets build it, that was the first acually pushed wizard, had a lot of changes but, i dont know what happened we had a rough week


Good. I have everything I need. Here's what I'm building:

- **`api/setup.php`** — add `detect` (OS + boot transport + recommended storage mode) and `populate` (runs conf_populate.sh) actions
- **`api/checklist.php`** — new, returns live state for each checklist item
- **`pages/setup.php`** — redesign: detection banner auto-loads on render, single identity form, auto-run populate on save, guide + checklist as step 2
- **`STORAGE_MODE_INTERNAL`** auto-set when creating host.conf from template based on detected transport

Starting with the API extensions:


1	<?php
2	header('Content-Type: application/json');
3	require_once dirname(__DIR__) . '/include/config.php';
4
5	if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
6	    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
7	    exit;
8	}
9
10	$action = trim($_POST['action'] ?? 'save');
11
12	// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

$action = trim($_POST['action'] ?? 'save');

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────


Edit

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

$action = trim($_POST['action'] ?? 'save');

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
    ? trim($_GET['action'] ?? '')
    : trim($_POST['action'] ?? 'save');

// ── GET: detect environment ────────────────────────────────────────────────────────────────────
if ($action === 'detect') {
    $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') ?: ''))
        : 'unknown';

    $isUsb = ($transport === 'usb');

    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);

    echo json_encode([
        'ok'           => true,
        'hostname'     => vv_get_hostname(),
        'unraid_ver'   => $vm[1] ?? 'unknown',
        'transport'    => $transport,
        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
        'mode'         => $isUsb ? 'flash' : 'internal',
        'scripts_dir'  => SCRIPTS_DIR,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {
    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
    $lines = array_values(array_filter(array_map('trim', $out)));
    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}


The file /boot/config/plugins/varaverk/Plugin/unraid/api/setup.php has been updated successfully. (file state is current in your context — no need to Read it back)

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
    ? trim($_GET['action'] ?? '')
    : trim($_POST['action'] ?? 'save');

// ── GET: detect environment ────────────────────────────────────────────────────────────────────
if ($action === 'detect') {
    $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') ?: ''))
        : 'unknown';

    $isUsb = ($transport === 'usb');

    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);

    echo json_encode([
        'ok'           => true,
        'hostname'     => vv_get_hostname(),
        'unraid_ver'   => $vm[1] ?? 'unknown',
        'transport'    => $transport,
        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
        'mode'         => $isUsb ? 'flash' : 'internal',
        'scripts_dir'  => SCRIPTS_DIR,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {
    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
    $lines = array_values(array_filter(array_map('trim', $out)));
    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

$action = trim($_POST['action'] ?? 'save');

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
if ($action === 'pull') {
    $host1Hostname = trim($_POST['host1_hostname'] ?? '');
    $mySlot        = trim($_POST['my_slot'] ?? 'host2');
    $myHostname    = trim($_POST['my_hostname'] ?? '');

    if (!$host1Hostname) {
        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
        exit;
    }
    if (!preg_match('/^host\d+$/', $mySlot)) {
        echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
        exit;
    }

    $hostId    = strtoupper($mySlot);
    $hostIdLow = strtolower($mySlot);

    // Derive SSH key path from this server's hostname
    $sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
    $sshKey   = '/root/.ssh/' . $sshOwner . '_rsync_automation';

    if (!file_exists($sshKey)) {
        echo json_encode(['ok' => false, 'error' =>
            "SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
        exit;
    }

    // Resolve HOST1 Tailscale IP
    $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
    if (!$ip) {
        echo json_encode(['ok' => false, 'error' =>
            "Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
        exit;
    }

    // Get HOST1's SCRIPTS_DIR from their varaverk.cfg
    $sshBase   = 'ssh -i ' . escapeshellarg($sshKey)
               . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
    $remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
    preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
    $remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';

    // SCP master.conf from HOST1
    $localMaster = CONF_DIR . '/master.conf';
    $src  = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
    $cmd  = 'scp -i ' . escapeshellarg($sshKey)
          . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
          . ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
    exec($cmd, $out, $rc);
    if ($rc !== 0) {
        echo json_encode(['ok' => false, 'error' =>
            'SCP failed: ' . implode('; ', $out) .
            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
        exit;
    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $hostname   = $myHostname ?: vv_get_hostname();
            $sshKeyPath = $sshKey;
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKeyPath . '"', $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;
}

// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
$host1      = trim($_POST['host1']      ?? '');
$host2      = trim($_POST['host2']      ?? '');
$mySlot     = trim($_POST['my_slot']    ?? 'host1');
$myHostname = trim($_POST['my_hostname'] ?? '');

if (empty($host1)) {
    echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
    exit;
}
if (!preg_match('/^host\d+$/', $mySlot)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
    exit;
}

// Write HOST1 / HOST2 into master.conf
$master = vv_read_conf_raw('master.conf');
if ($master === '') {
    echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
    exit;
}

$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);

$slotNum = (int) preg_replace('/\D/', '', $mySlot);
if ($slotNum > 2 && !empty($myHostname)) {
    $hostKey = 'HOST' . $slotNum;
    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
    } else {
        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
            '${1}"' . addslashes($myHostname) . '"', $master);
    }
}

if (!vv_write_conf_raw('master.conf', $master)) {
    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
    exit;
}

// Create host*.conf from template
$hostId    = strtoupper($mySlot);
$hostIdLow = strtolower($mySlot);
$confFile  = $hostIdLow . '.conf';

if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }
    }
}

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

echo json_encode([
    'ok'       => true,
    'host_id'  => $hostId,
    'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);

+$action = ($_SERVER['REQUEST_METHOD'] === 'GET')

+    : trim($_POST['action'] ?? 'save');

+// ── GET: detect environment ────────────────────────────────────────────────────────────────────

+    $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');

+        ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')

+        ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))

+    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);

+        'hostname'     => vv_get_hostname(),

+        'unraid_ver'   => $vm[1] ?? 'unknown',

+        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',

+        'mode'         => $isUsb ? 'flash' : 'internal',

+// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────

+    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';

+        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);

+    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);

+    $lines = array_values(array_filter(array_map('trim', $out)));

+    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);

-$action = trim($_POST['action'] ?? 'save');


Now wire `STORAGE_MODE_INTERNAL` into the host.conf creation in both the `save` and `pull` actions. First read the current save action's template section:


155	$slotNum = (int) preg_replace('/\D/', '', $mySlot);
156	if ($slotNum > 2 && !empty($myHostname)) {
157	    $hostKey = 'HOST' . $slotNum;
158	    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
159	        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
160	            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
161	    } else {
162	        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
163	            '${1}"' . addslashes($myHostname) . '"', $master);
164	    }
165	}
166
167	if (!vv_write_conf_raw('master.conf', $master)) {
168	    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
169	    exit;
170	}
171
172	// Create host*.conf from template
173	$hostId    = strtoupper($mySlot);
174	$hostIdLow = strtolower($mySlot);
175	$confFile  = $hostIdLow . '.conf';
176
177	if (!file_exists(CONF_DIR . '/' . $confFile)) {
178	    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
179	    if ($template) {
180	        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
181	        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
182	        $conf = str_replace('HOSTN', $hostId,    $template);
183	        $conf = str_replace('hostn', $hostIdLow, $conf);
184	        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
185	            '${1}"' . $sshKeyPath . '"', $conf);
186	        if (!vv_write_conf_raw($confFile, $conf)) {
187	            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
188	            exit;
189	        }

$slotNum = (int) preg_replace('/\D/', '', $mySlot);
if ($slotNum > 2 && !empty($myHostname)) {
    $hostKey = 'HOST' . $slotNum;
    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
    } else {
        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
            '${1}"' . addslashes($myHostname) . '"', $master);
    }
}

if (!vv_write_conf_raw('master.conf', $master)) {
    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
    exit;
}

// Create host*.conf from template
$hostId    = strtoupper($mySlot);
$hostIdLow = strtolower($mySlot);
$confFile  = $hostIdLow . '.conf';

if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }


if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }

if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';

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

        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
            '${1}' . $storageInternal, $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }


if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';

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

        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
            '${1}' . $storageInternal, $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
    ? trim($_GET['action'] ?? '')
    : trim($_POST['action'] ?? 'save');

// ── GET: detect environment ────────────────────────────────────────────────────────────────────
if ($action === 'detect') {
    $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') ?: ''))
        : 'unknown';

    $isUsb = ($transport === 'usb');

    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);

    echo json_encode([
        'ok'           => true,
        'hostname'     => vv_get_hostname(),
        'unraid_ver'   => $vm[1] ?? 'unknown',
        'transport'    => $transport,
        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
        'mode'         => $isUsb ? 'flash' : 'internal',
        'scripts_dir'  => SCRIPTS_DIR,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {
    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
    $lines = array_values(array_filter(array_map('trim', $out)));
    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
if ($action === 'pull') {
    $host1Hostname = trim($_POST['host1_hostname'] ?? '');
    $mySlot        = trim($_POST['my_slot'] ?? 'host2');
    $myHostname    = trim($_POST['my_hostname'] ?? '');

    if (!$host1Hostname) {
        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
        exit;
    }
    if (!preg_match('/^host\d+$/', $mySlot)) {
        echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
        exit;
    }

    $hostId    = strtoupper($mySlot);
    $hostIdLow = strtolower($mySlot);

    // Derive SSH key path from this server's hostname
    $sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
    $sshKey   = '/root/.ssh/' . $sshOwner . '_rsync_automation';

    if (!file_exists($sshKey)) {
        echo json_encode(['ok' => false, 'error' =>
            "SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
        exit;
    }

    // Resolve HOST1 Tailscale IP
    $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
    if (!$ip) {
        echo json_encode(['ok' => false, 'error' =>
            "Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
        exit;
    }

    // Get HOST1's SCRIPTS_DIR from their varaverk.cfg
    $sshBase   = 'ssh -i ' . escapeshellarg($sshKey)
               . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
    $remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
    preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
    $remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';

    // SCP master.conf from HOST1
    $localMaster = CONF_DIR . '/master.conf';
    $src  = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
    $cmd  = 'scp -i ' . escapeshellarg($sshKey)
          . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
          . ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
    exec($cmd, $out, $rc);
    if ($rc !== 0) {
        echo json_encode(['ok' => false, 'error' =>
            'SCP failed: ' . implode('; ', $out) .
            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
        exit;
    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $hostname   = $myHostname ?: vv_get_hostname();
            $sshKeyPath = $sshKey;
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKeyPath . '"', $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;
}

// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
$host1      = trim($_POST['host1']      ?? '');
$host2      = trim($_POST['host2']      ?? '');
$mySlot     = trim($_POST['my_slot']    ?? 'host1');
$myHostname = trim($_POST['my_hostname'] ?? '');

if (empty($host1)) {
    echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
    exit;
}
if (!preg_match('/^host\d+$/', $mySlot)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
    exit;
}

// Write HOST1 / HOST2 into master.conf
$master = vv_read_conf_raw('master.conf');
if ($master === '') {
    echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
    exit;
}

$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);

$slotNum = (int) preg_replace('/\D/', '', $mySlot);
if ($slotNum > 2 && !empty($myHostname)) {
    $hostKey = 'HOST' . $slotNum;
    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
    } else {
        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
            '${1}"' . addslashes($myHostname) . '"', $master);
    }
}

if (!vv_write_conf_raw('master.conf', $master)) {
    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
    exit;
}

// Create host*.conf from template
$hostId    = strtoupper($mySlot);
$hostIdLow = strtolower($mySlot);
$confFile  = $hostIdLow . '.conf';

if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }
    }
}

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

echo json_encode([
    'ok'       => true,
    'host_id'  => $hostId,
    'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);

+        // 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';

+        $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',

+            '${1}' . $storageInternal, $conf);


Now do the same for the `pull` action's host.conf creation:


108	    }
109
110	    // Create host conf from template if it doesn't exist
111	    $confFile = $hostIdLow . '.conf';
112	    if (!file_exists(CONF_DIR . '/' . $confFile)) {
113	        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
114	        if ($template) {
115	            $hostname   = $myHostname ?: vv_get_hostname();
116	            $sshKeyPath = $sshKey;
117	            $conf = str_replace('HOSTN', $hostId,    $template);
118	            $conf = str_replace('hostn', $hostIdLow, $conf);
119	            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
120	                '${1}"' . $sshKeyPath . '"', $conf);
121	            vv_write_conf_raw($confFile, $conf);
122	        }
123	    }
124
125	    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
126	                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
127	    exit;
128	}
129
130	// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
131	$host1      = trim($_POST['host1']      ?? '');
132	$host2      = trim($_POST['host2']      ?? '');

    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $hostname   = $myHostname ?: vv_get_hostname();
            $sshKeyPath = $sshKey;
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKeyPath . '"', $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;
}

// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
$host1      = trim($_POST['host1']      ?? '');
$host2      = trim($_POST['host2']      ?? '');


    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $hostname   = $myHostname ?: vv_get_hostname();
            $sshKeyPath = $sshKey;
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKeyPath . '"', $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKey . '"', $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
                '${1}' . $storageInternal2, $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }


    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKey . '"', $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
                '${1}' . $storageInternal2, $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
    ? trim($_GET['action'] ?? '')
    : trim($_POST['action'] ?? 'save');

// ── GET: detect environment ────────────────────────────────────────────────────────────────────
if ($action === 'detect') {
    $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') ?: ''))
        : 'unknown';

    $isUsb = ($transport === 'usb');

    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);

    echo json_encode([
        'ok'           => true,
        'hostname'     => vv_get_hostname(),
        'unraid_ver'   => $vm[1] ?? 'unknown',
        'transport'    => $transport,
        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
        'mode'         => $isUsb ? 'flash' : 'internal',
        'scripts_dir'  => SCRIPTS_DIR,
    ]);
    exit;
}

// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
if ($action === 'populate') {
    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
    $lines = array_values(array_filter(array_map('trim', $out)));
    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
    exit;
}

// ── HOST2 pull: pull master.conf from HOST1 via SSH ──────────────────────────────────────────
if ($action === 'pull') {
    $host1Hostname = trim($_POST['host1_hostname'] ?? '');
    $mySlot        = trim($_POST['my_slot'] ?? 'host2');
    $myHostname    = trim($_POST['my_hostname'] ?? '');

    if (!$host1Hostname) {
        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname required']);
        exit;
    }
    if (!preg_match('/^host\d+$/', $mySlot)) {
        echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
        exit;
    }

    $hostId    = strtoupper($mySlot);
    $hostIdLow = strtolower($mySlot);

    // Derive SSH key path from this server's hostname
    $sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
    $sshKey   = '/root/.ssh/' . $sshOwner . '_rsync_automation';

    if (!file_exists($sshKey)) {
        echo json_encode(['ok' => false, 'error' =>
            "SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
        exit;
    }

    // Resolve HOST1 Tailscale IP
    $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
    if (!$ip) {
        echo json_encode(['ok' => false, 'error' =>
            "Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
        exit;
    }

    // Get HOST1's SCRIPTS_DIR from their varaverk.cfg
    $sshBase   = 'ssh -i ' . escapeshellarg($sshKey)
               . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
    $remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
    preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
    $remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';

    // SCP master.conf from HOST1
    $localMaster = CONF_DIR . '/master.conf';
    $src  = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
    $cmd  = 'scp -i ' . escapeshellarg($sshKey)
          . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
          . ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
    exec($cmd, $out, $rc);
    if ($rc !== 0) {
        echo json_encode(['ok' => false, 'error' =>
            'SCP failed: ' . implode('; ', $out) .
            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
        exit;
    }

    // Create host conf from template if it doesn't exist
    $confFile = $hostIdLow . '.conf';
    if (!file_exists(CONF_DIR . '/' . $confFile)) {
        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
        if ($template) {
            $hostname   = $myHostname ?: vv_get_hostname();
            $sshKeyPath = $sshKey;
            $conf = str_replace('HOSTN', $hostId,    $template);
            $conf = str_replace('hostn', $hostIdLow, $conf);
            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
                '${1}"' . $sshKeyPath . '"', $conf);
            vv_write_conf_raw($confFile, $conf);
        }
    }

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
    exit;
}

// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
$host1      = trim($_POST['host1']      ?? '');
$host2      = trim($_POST['host2']      ?? '');
$mySlot     = trim($_POST['my_slot']    ?? 'host1');
$myHostname = trim($_POST['my_hostname'] ?? '');

if (empty($host1)) {
    echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
    exit;
}
if (!preg_match('/^host\d+$/', $mySlot)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
    exit;
}

// Write HOST1 / HOST2 into master.conf
$master = vv_read_conf_raw('master.conf');
if ($master === '') {
    echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
    exit;
}

$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);

$slotNum = (int) preg_replace('/\D/', '', $mySlot);
if ($slotNum > 2 && !empty($myHostname)) {
    $hostKey = 'HOST' . $slotNum;
    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
    } else {
        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
            '${1}"' . addslashes($myHostname) . '"', $master);
    }
}

if (!vv_write_conf_raw('master.conf', $master)) {
    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
    exit;
}

// Create host*.conf from template
$hostId    = strtoupper($mySlot);
$hostIdLow = strtolower($mySlot);
$confFile  = $hostIdLow . '.conf';

if (!file_exists(CONF_DIR . '/' . $confFile)) {
    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
    if ($template) {
        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';

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

        $conf = str_replace('HOSTN', $hostId,    $template);
        $conf = str_replace('hostn', $hostIdLow, $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
            '${1}"' . $sshKeyPath . '"', $conf);
        $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
            '${1}' . $storageInternal, $conf);
        if (!vv_write_conf_raw($confFile, $conf)) {
            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
            exit;
        }
    }
}

// Write setup state file — lets partner servers know HOST1 is configured
vv_setup_state_write(['host1_hostname' => $host1]);

echo json_encode([
    'ok'       => true,
    'host_id'  => $hostId,
    'redirect' => '?tab=scheduler&vv_setup=master.conf',
]);

-            $hostname   = $myHostname ?: vv_get_hostname();

+            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');

+            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';

+            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';

+            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';

+            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',

+                '${1}' . $storageInternal2, $conf);


Now create `api/checklist.php`:


Write

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$hostId   = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$master   = vv_read_conf_raw('master.conf');
$confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';

$items = [];

// ── Identity ──────────────────────────────────────────────────────────────────
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
$host1 = trim($m1[1] ?? '');
$items[] = [
    'id'     => 'identity',
    'label'  => 'Server identity',
    'ok'     => !empty($host1),
    'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
];

// ── Host conf ─────────────────────────────────────────────────────────────────
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
$items[] = [
    'id'     => 'host_conf',
    'label'  => 'Host configuration',
    'ok'     => $confExists,
    'detail' => $confExists
        ? "$hostId.conf present"
        : ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
];

// ── Unraid API key ─────────────────────────────────────────────────────────────
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
$items[] = [
    'id'     => 'api_key',
    'label'  => 'Unraid API key',
    'ok'     => !empty($apiKey),
    'detail' => $apiKey ? 'Key present' : 'Not set',
    'action' => $apiKey ? null : 'create_key',
];

// ── SSH key ────────────────────────────────────────────────────────────────────
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
$sshOk   = $sshPath && file_exists($sshPath);
$items[] = [
    'id'     => 'ssh_key',
    'label'  => 'SSH key',
    'ok'     => $sshOk,
    'detail' => $sshOk
        ? basename($sshPath)
        : ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
    'action' => $sshOk ? null : 'ssh_setup',
];

// ── Auto-populate (any service key or container detected) ──────────────────────
$populated = false;
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
    if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
        $populated = true;
        break;
    }
}
$items[] = [
    'id'     => 'populated',
    'label'  => 'Auto-populate',
    'ok'     => $populated,
    'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
    'action' => $populated ? null : 'run_populate',
];

// ── Partnership (only if a partner is configured) ──────────────────────────────
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
$host2 = trim($m2[1] ?? '');
if (!empty($host2)) {
    $state  = vv_setup_state_read();
    $p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
    $p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
    $items[] = [
        'id'     => 'partnership',
        'label'  => 'Partnership',
        'ok'     => $p1done && $p2done,
        'detail' => ($p1done && $p2done)
            ? "Active with $host2"
            : ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
        'action' => (!$p1done) ? 'onboard' : null,
    ];
}

$allOk = !in_array(false, array_column($items, 'ok'), true);

echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);


File created successfully at: /boot/config/plugins/varaverk/Plugin/unraid/api/checklist.php (file state is current in your context — no need to Read it back)

<?php
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

$hostId   = vv_detect_host();
$hostIdUp = strtoupper($hostId);
$master   = vv_read_conf_raw('master.conf');
$confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';

$items = [];

// ── Identity ──────────────────────────────────────────────────────────────────
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
$host1 = trim($m1[1] ?? '');
$items[] = [
    'id'     => 'identity',
    'label'  => 'Server identity',
    'ok'     => !empty($host1),
    'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
];

// ── Host conf ─────────────────────────────────────────────────────────────────
$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
$items[] = [
    'id'     => 'host_conf',
    'label'  => 'Host configuration',
    'ok'     => $confExists,
    'detail' => $confExists
        ? "$hostId.conf present"
        : ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
];

// ── Unraid API key ─────────────────────────────────────────────────────────────
$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
$items[] = [
    'id'     => 'api_key',
    'label'  => 'Unraid API key',
    'ok'     => !empty($apiKey),
    'detail' => $apiKey ? 'Key present' : 'Not set',
    'action' => $apiKey ? null : 'create_key',
];

// ── SSH key ────────────────────────────────────────────────────────────────────
$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
$sshOk   = $sshPath && file_exists($sshPath);
$items[] = [
    'id'     => 'ssh_key',
    'label'  => 'SSH key',
    'ok'     => $sshOk,
    'detail' => $sshOk
        ? basename($sshPath)
        : ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
    'action' => $sshOk ? null : 'ssh_setup',
];

// ── Auto-populate (any service key or container detected) ──────────────────────
$populated = false;
foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
    if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
        $populated = true;
        break;
    }
}
$items[] = [
    'id'     => 'populated',
    'label'  => 'Auto-populate',
    'ok'     => $populated,
    'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
    'action' => $populated ? null : 'run_populate',
];

// ── Partnership (only if a partner is configured) ──────────────────────────────
preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
$host2 = trim($m2[1] ?? '');
if (!empty($host2)) {
    $state  = vv_setup_state_read();
    $p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
    $p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
    $items[] = [
        'id'     => 'partnership',
        'label'  => 'Partnership',
        'ok'     => $p1done && $p2done,
        'detail' => ($p1done && $p2done)
            ? "Active with $host2"
            : ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
        'action' => (!$p1done) ? 'onboard' : null,
    ];
}

$allOk = !in_array(false, array_column($items, 'ok'), true);

echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);


Now rewrite `pages/setup.php` as the full multi-step wizard:


<?php
// First-run setup wizard.
// Step 1: Auto-detect environment + server identity form.
// Step 2: Auto-populate + guide + checklist.
// Handles three scenarios:
//   standard   — blank master.conf, this is HOST1
//   host2-pull — state file pushed by HOST1, pull master.conf via SSH
//   conf-only  — master.conf already here, just create local host.conf

$detectedHostname = vv_get_hostname();
$setupState       = vv_setup_state_read();
$host1FromState   = $setupState['host1_hostname'] ?? '';

$_master     = vv_read_conf_raw('master.conf');
preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $_master, $_h1m);
$masterHost1 = trim($_h1m[1] ?? '');

$isHost2Flow    = !empty($host1FromState) && empty($masterHost1);
$myHostId       = vv_detect_host();
$confMissing    = $myHostId !== 'unknown' && !file_exists(CONF_DIR . '/' . $myHostId . '.conf');
$isConfOnlyFlow = !empty($masterHost1) && $confMissing;
?>
<link rel="stylesheet" href="/plugins/varaverk/css/varaverk.css">

⬡ Varaverk <?php
    if ($isConfOnlyFlow)  echo '— ' . strtoupper($myHostId) . ' Setup';
    elseif ($isHost2Flow) echo '— Partner Setup';
    else                  echo '— First Run';
  ?>

<?php
    if ($isConfOnlyFlow)  echo 'master.conf received. Create your local configuration to continue.';
    elseif ($isHost2Flow) echo 'HOST1 is configured. Pull their settings to connect.';
    else                  echo 'Set up your server before the plugin can start.';
  ?>

Detecting environment…

    <?php if ($isConfOnlyFlow): ?>

HOST1: <?= htmlspecialchars($masterHost1) ?>

This server: <?= htmlspecialchars($detectedHostname) ?> → <?= htmlspecialchars(strtoupper($myHostId)) ?>

Creating: <?= htmlspecialchars($myHostId) ?>.conf

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoConfOnly()">Create <?= htmlspecialchars($myHostId) ?>.conf and continue →</button>

    <?php elseif ($isHost2Flow): ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly

        Your slot
        <select id="vv-slot">
          <option value="host2">HOST2</option>
          <option value="host3">HOST3</option>
          <option value="host4">HOST4</option>
        </select>

        HOST1 detected: <?= htmlspecialchars($host1FromState) ?>

        Requires SSH keys to be exchanged — run Partnership/ssh_setup.sh first if keys aren't set up.

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoPull()">Pull configuration from HOST1 →</button>

    <?php else: ?>

        This server's hostname
        <input type="text" id="vv-hostname" value="<?= htmlspecialchars($detectedHostname) ?>" autocomplete="off" spellcheck="false">

Must match Unraid Settings → Identification exactly (case-sensitive)

      Server role

          Primary
HOST1 · first server

          Partner
HOST2+ · joining primary

          Partner's hostname (optional — can fill in later)
          <input type="text" id="vv-partner-hostname" value="" placeholder="unRAID-PartnerServer" autocomplete="off" spellcheck="false">

          Primary server's hostname required
          <input type="text" id="vv-primary-hostname" value="" placeholder="unRAID-PrimaryServer" autocomplete="off" spellcheck="false">

          Your slot
          <select id="vv-partner-slot">
            <option value="host2">HOST2</option>
            <option value="host3">HOST3</option>
            <option value="host4">HOST4</option>
          </select>

      <button class="vv-btn" id="vv-main-btn" onclick="vvDoSave()">Save and continue →</button>
    <?php endif; ?>

Step 2 of 2

        ⟳ Running auto-populate…

      Quick start

Create your Unraid API key below — needed for live monitor stats

Open Scheduler → Edit host.conf and fill in your credentials

            (Emby key, share paths, Discord webhook)

If partnering: run Partnership/partnership_onboard.sh once both servers are ready

Arr keys, container names, and media paths auto-populate on next array start

      <button id="vv-key-btn" onclick="vvCreateKey(this)" class="vv-btn" style="flex:1;background:#1a3a1a;border-color:#2e6b2e;color:#6fcf97;">
        Create API Key
      </button>
      Skip →

Setup checklist

Loading…

        Go to Scheduler →