1	<?php
2	header('Content-Type: application/json');
3	require_once dirname(__DIR__) . '/include/config.php';
4
5	$action = ($_SERVER['REQUEST_METHOD'] === 'GET')
6	    ? trim($_GET['action'] ?? '')
7	    : trim($_POST['action'] ?? 'save');
8
9	// ── GET: detect environment ────────────────────────────────────────────────────────────────────
10	if ($action === 'detect') {
11	    $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
12	    $bootDisk  = $bootPart
13	        ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '')
14	        : '';
15	    $transport = $bootDisk
16	        ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: ''))
17	        : 'unknown';
18
19	    $isUsb = ($transport === 'usb');
20
21	    preg_match('/version="([^"]+)"/', @file_get_contents('/etc/unraid-version') ?: '', $vm);
22
23	    echo json_encode([
24	        'ok'           => true,
25	        'hostname'     => vv_get_hostname(),
26	        'unraid_ver'   => $vm[1] ?? 'unknown',
27	        'transport'    => $transport,
28	        'boot_device'  => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
29	        'mode'         => $isUsb ? 'flash' : 'internal',
30	        'scripts_dir'  => SCRIPTS_DIR,
31	    ]);
32	    exit;
33	}
34
35	// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
36	if ($action === 'ssh_generate') {
37	    $script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
38	    if (!file_exists($script)) {
39	        echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
40	        exit;
41	    }
42	    exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
43	    // Derive pubkey path from hostname
44	    $hostname  = vv_get_hostname();
45	    $shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
46	    $pubPath   = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
47	    $pubKey    = trim(@file_get_contents($pubPath) ?: '');
48	    echo json_encode([
49	        'ok'     => $rc === 0 && !empty($pubKey),
50	        'pubkey' => $pubKey,
51	        'error'  => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
52	    ]);
53	    exit;
54	}
55
56	// ── POST: run conf_populate.sh ─────────────────────────────────────────────────────────────────
57	if ($action === 'populate') {
58	    $script = SCRIPTS_DIR . '/Plugin/unraid/Tools/conf_populate.sh';
59	    if (!file_exists($script)) {
60	        echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
61	        exit;
62	    }
63	    exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
64	    $lines = array_values(array_filter(array_map('trim', $out)));
65	    echo json_encode(['ok' => $rc === 0, 'lines' => array_slice($lines, 0, 20)]);
66	    exit;
67	}
68
69	if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
70	    echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
71	    exit;
72	}
73
74	$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
75
76	// ── Pull master.conf from HOST1 via SSH (wizard or checklist) ────────────────────────────────
77	if ($action === 'pull') {
78	    $mySlot        = trim($_POST['my_slot']        ?? '') ?: strtolower(vv_detect_host());
79	    $myHostname    = trim($_POST['my_hostname']     ?? '') ?: vv_get_hostname();
80	    $host1Hostname = trim($_POST['host1_hostname']  ?? '');
81	    if (!$host1Hostname) {
82	        $masterRaw = vv_read_conf_raw('master.conf');
83	        preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $masterRaw, $_mh);
84	        $host1Hostname = trim($_mh[1] ?? '');
85	    }
86	    if (!$host1Hostname) {
87	        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname not set — fill in master.conf first']);
88	        exit;
89	    }
90	    if (!preg_match('/^host\d+$/', $mySlot)) {
91	        echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
92	        exit;
93	    }
94
95	    $hostId    = strtoupper($mySlot);
96	    $hostIdLow = strtolower($mySlot);
97
98	    // Derive SSH key path from this server's hostname
99	    $sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname ?: vv_get_hostname()));
100	    $sshKey   = '/root/.ssh/' . $sshOwner . '_rsync_automation';
101
102	    if (!file_exists($sshKey)) {
103	        echo json_encode(['ok' => false, 'error' =>
104	            "SSH key not found at $sshKey — run Partnership/ssh_setup.sh first"]);
105	        exit;
106	    }
107
108	    // Resolve HOST1 Tailscale IP
109	    $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($host1Hostname) . ' 2>/dev/null') ?: '');
110	    if (!$ip) {
111	        echo json_encode(['ok' => false, 'error' =>
112	            "Cannot resolve Tailscale IP for $host1Hostname — is Tailscale running on both servers?"]);
113	        exit;
114	    }
115
116	    // Get HOST1's SCRIPTS_DIR from their varaverk.cfg
117	    $sshBase   = 'ssh -i ' . escapeshellarg($sshKey)
118	               . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@' . $ip;
119	    $remoteCfg = trim(shell_exec($sshBase . ' "grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null"') ?: '');
120	    preg_match('/SCRIPTS_DIR\s*=\s*["\']?([^"\']+)["\']?/', $remoteCfg, $sm);
121	    $remoteConf = rtrim($sm[1] ?? '/boot/config/plugins/varaverk', '/') . '/Configurations';
122
123	    // SCP master.conf from HOST1
124	    $localMaster = CONF_DIR . '/master.conf';
125	    $src  = escapeshellarg('root@' . $ip . ':' . $remoteConf . '/master.conf');
126	    $cmd  = 'scp -i ' . escapeshellarg($sshKey)
127	          . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
128	          . ' ' . $src . ' ' . escapeshellarg($localMaster) . ' 2>&1';
129	    exec($cmd, $out, $rc);
130	    if ($rc !== 0) {
131	        echo json_encode(['ok' => false, 'error' =>
132	            'SCP failed: ' . implode('; ', $out) .
133	            ' — ensure your SSH key is authorised on HOST1 (run Partnership/ssh_setup.sh)']);
134	        exit;
135	    }
136
137	    // Create host conf from template if it doesn't exist
138	    $confFile = $hostIdLow . '.conf';
139	    if (!file_exists(CONF_DIR . '/' . $confFile)) {
140	        $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
141	        if ($template) {
142	            $bootPart2  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
143	            $bootDisk2  = $bootPart2 ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart2) . ' 2>/dev/null') ?: '') : '';
144	            $transport2 = $bootDisk2 ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk2) . ' 2>/dev/null') ?: '')) : '';
145	            $storageInternal2 = ($transport2 !== 'usb') ? 'true' : 'false';
146	            $conf = str_replace('HOSTN', $hostId,    $template);
147	            $conf = str_replace('hostn', $hostIdLow, $conf);
148	            $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
149	                '${1}"' . $sshKey . '"', $conf);
150	            $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
151	                '${1}' . $storageInternal2, $conf);
152	            vv_write_conf_raw($confFile, $conf);
153	        }
154	    }
155
156	    if (file_exists($sshScript)) {
157	        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
158	    }
159	    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
160
161	    $state = vv_setup_state_read();
162	    $state['master_conf_pulled'] = 'true';
163	    vv_setup_state_write($state);
164
165	    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
166	                      'api_key'  => $apiKeyResult,
167	                      'redirect' => '?tab=scheduler&vv_setup=' . $confFile]);
168	    exit;
169	}
170
171	// ── Default action: save (HOST1 first-run wizard) ────────────────────────────────────────────
172	$host1      = trim($_POST['host1']      ?? '');
173	$host2      = trim($_POST['host2']      ?? '');
174	$mySlot     = trim($_POST['my_slot']    ?? 'host1');
175	$myHostname = trim($_POST['my_hostname'] ?? '');
176
177	if (empty($host1)) {
178	    echo json_encode(['ok' => false, 'error' => 'HOST1 hostname is required']);
179	    exit;
180	}
181	if (!preg_match('/^host\d+$/', $mySlot)) {
182	    echo json_encode(['ok' => false, 'error' => 'Invalid slot']);
183	    exit;
184	}
185
186	// Write HOST1 / HOST2 into master.conf
187	$master = vv_read_conf_raw('master.conf');
188	if ($master === '') {
189	    echo json_encode(['ok' => false, 'error' => 'master.conf not found — check SCRIPTS_DIR in varaverk.cfg']);
190	    exit;
191	}
192
193	$master = preg_replace('/^(\s*HOST1\s*=\s*).*$/m', '${1}"' . addslashes($host1) . '"', $master);
194	$master = preg_replace('/^(\s*HOST2\s*=\s*).*$/m', '${1}"' . addslashes($host2) . '"', $master);
195
196	$slotNum = (int) preg_replace('/\D/', '', $mySlot);
197	if ($slotNum > 2 && !empty($myHostname)) {
198	    $hostKey = 'HOST' . $slotNum;
199	    if (!preg_match('/^\s*' . $hostKey . '\s*=/m', $master)) {
200	        $master = preg_replace('/^(\s*HOST2\s*=.*$)/m',
201	            '$1' . "\n    {$hostKey}=\"" . addslashes($myHostname) . '"', $master);
202	    } else {
203	        $master = preg_replace('/^(\s*' . $hostKey . '\s*=\s*).*$/m',
204	            '${1}"' . addslashes($myHostname) . '"', $master);
205	    }
206	}
207
208	if (!vv_write_conf_raw('master.conf', $master)) {
209	    echo json_encode(['ok' => false, 'error' => 'Failed to write master.conf']);
210	    exit;
211	}
212
213	// Create host*.conf from template
214	$hostId    = strtoupper($mySlot);
215	$hostIdLow = strtolower($mySlot);
216	$confFile  = $hostIdLow . '.conf';
217
218	if (!file_exists(CONF_DIR . '/' . $confFile)) {
219	    $template = @file_get_contents(CONF_DIR . '/host.conf.template') ?: '';
220	    if ($template) {
221	        $sshOwner   = strtolower(preg_replace('/^unraid-/i', '', $myHostname));
222	        $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation';
223
224	        // Auto-detect storage mode from boot device transport
225	        $bootPart  = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: '');
226	        $bootDisk  = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : '';
227	        $transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : '';
228	        $storageInternal = ($transport !== 'usb') ? 'true' : 'false';
229
230	        $conf = str_replace('HOSTN', $hostId,    $template);
231	        $conf = str_replace('hostn', $hostIdLow, $conf);
232	        $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m',
233	            '${1}"' . $sshKeyPath . '"', $conf);
234	        $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m',
235	            '${1}' . $storageInternal, $conf);
236	        if (!vv_write_conf_raw($confFile, $conf)) {
237	            echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]);
238	            exit;
239	        }
240	    }
241	}
242
243	// Write setup state file — lets partner servers know HOST1 is configured
244	vv_setup_state_write(['host1_hostname' => $host1]);
245
246	// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
247	if (file_exists($sshScript)) {
248	    exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
249	}
250
251	// Auto-create Unraid API key and write into the fresh conf
252	$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
253
254	echo json_encode([
255	    'ok'       => true,
256	    'host_id'  => $hostId,
257	    'api_key'  => $apiKeyResult,
258	    'redirect' => '?tab=scheduler&vv_setup=master.conf',
259	]);
260

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

// ── GET/POST: generate local SSH keypair ──────────────────────────────────────────────────────
if ($action === 'ssh_generate') {
    $script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';
    if (!file_exists($script)) {
        echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
        exit;
    }
    exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
    // Derive pubkey path from hostname
    $hostname  = vv_get_hostname();
    $shortName = strtolower(preg_replace('/^unraid-/i', '', $hostname));
    $pubPath   = '/root/.ssh/' . $shortName . '_rsync_automation.pub';
    $pubKey    = trim(@file_get_contents($pubPath) ?: '');
    echo json_encode([
        'ok'     => $rc === 0 && !empty($pubKey),
        'pubkey' => $pubKey,
        'error'  => ($rc !== 0) ? implode(' ', array_slice(array_filter(array_map('trim', $out)), -3)) : null,
    ]);
    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;
}

$sshScript = SCRIPTS_DIR . '/Partnership/ssh_setup.sh';

// ── Pull master.conf from HOST1 via SSH (wizard or checklist) ────────────────────────────────
if ($action === 'pull') {
    $mySlot        = trim($_POST['my_slot']        ?? '') ?: strtolower(vv_detect_host());
    $myHostname    = trim($_POST['my_hostname']     ?? '') ?: vv_get_hostname();
    $host1Hostname = trim($_POST['host1_hostname']  ?? '');
    if (!$host1Hostname) {
        $masterRaw = vv_read_conf_raw('master.conf');
        preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $masterRaw, $_mh);
        $host1Hostname = trim($_mh[1] ?? '');
    }
    if (!$host1Hostname) {
        echo json_encode(['ok' => false, 'error' => 'HOST1 hostname not set — fill in master.conf first']);
        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) {
            $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);
        }
    }

    if (file_exists($sshScript)) {
        exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
    }
    $apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

    $state = vv_setup_state_read();
    $state['master_conf_pulled'] = 'true';
    vv_setup_state_write($state);

    echo json_encode(['ok' => true, 'host_id' => $hostId, 'conf_file' => $confFile,
                      'api_key'  => $apiKeyResult,
                      '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]);

// Auto-generate SSH keypair (local only — remote copy happens during onboarding)
if (file_exists($sshScript)) {
    exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
}

// Auto-create Unraid API key and write into the fresh conf
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);

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


1	<?php
2	header('Content-Type: application/json');
3	require_once dirname(__DIR__) . '/include/config.php';
4
5	$hostId   = vv_detect_host();
6	$hostIdUp = strtoupper($hostId);
7	$master   = vv_read_conf_raw('master.conf');
8	$confRaw  = ($hostId !== 'unknown') ? vv_read_conf_raw($hostId . '.conf') : '';
9
10	$items = [];
11
12	// ── Identity ──────────────────────────────────────────────────────────────────
13	preg_match('/^\s*HOST1\s*=\s*"([^"]*)"/m', $master, $m1);
14	$host1 = trim($m1[1] ?? '');
15	$items[] = [
16	    'id'     => 'identity',
17	    'label'  => 'Server identity',
18	    'ok'     => !empty($host1),
19	    'detail' => $host1 ? "HOST1: $host1" : 'HOST1 blank in master.conf',
20	];
21
22	// ── Host conf ─────────────────────────────────────────────────────────────────
23	$confExists = $hostId !== 'unknown' && file_exists(CONF_DIR . '/' . $hostId . '.conf');
24	$items[] = [
25	    'id'     => 'host_conf',
26	    'label'  => 'Host configuration',
27	    'ok'     => $confExists,
28	    'detail' => $confExists
29	        ? "$hostId.conf present"
30	        : ($hostId === 'unknown' ? 'Server not yet identified' : "$hostId.conf missing"),
31	];
32
33	// ── Unraid API key ─────────────────────────────────────────────────────────────
34	$apiKey = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_UNRAID_API_KEY'));
35	$items[] = [
36	    'id'     => 'api_key',
37	    'label'  => 'Unraid API key',
38	    'ok'     => !empty($apiKey),
39	    'detail' => $apiKey ? 'Key present' : 'Not set',
40	    'action' => $apiKey ? null : 'create_key',
41	];
42
43	// ── SSH key ────────────────────────────────────────────────────────────────────
44	$sshPath = trim(vv_parse_conf_scalar($confRaw, $hostIdUp . '_SSH_KEY'));
45	$sshOk   = $sshPath && file_exists($sshPath);
46	$items[] = [
47	    'id'     => 'ssh_key',
48	    'label'  => 'SSH key',
49	    'ok'     => $sshOk,
50	    'detail' => $sshOk
51	        ? basename($sshPath)
52	        : ($sshPath ? "Path set but file missing: $sshPath" : 'No key path in host.conf'),
53	    'action' => $sshOk ? null : 'ssh_setup',
54	];
55
56	// ── Auto-populate (any service key or container detected) ──────────────────────
57	$populated = false;
58	foreach (['_RADARR_API_KEY','_SONARR_API_KEY','_LIDARR_API_KEY','_EMBY_CONTAINER','_JELLYFIN_CONTAINER'] as $f) {
59	    if (trim(vv_parse_conf_scalar($confRaw, $hostIdUp . $f)) !== '') {
60	        $populated = true;
61	        break;
62	    }
63	}
64	$items[] = [
65	    'id'     => 'populated',
66	    'label'  => 'Auto-populate',
67	    'ok'     => $populated,
68	    'detail' => $populated ? 'Services detected in host.conf' : 'No services detected yet',
69	    'action' => $populated ? null : 'run_populate',
70	];
71
72	// ── master.conf pull (partner servers only) ───────────────────────────────────────────────────
73	if ($hostId !== 'host1' && $hostId !== 'unknown') {
74	    $state   = vv_setup_state_read();
75	    $pulled  = !empty($state['master_conf_pulled']);
76	    $items[] = [
77	        'id'     => 'master_conf',
78	        'label'  => 'master.conf',
79	        'ok'     => $pulled,
80	        'detail' => $pulled
81	            ? 'Synced from HOST1'
82	            : ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf'),
83	        'action' => (!$pulled && $host1) ? 'pull_master' : null,
84	    ];
85	}
86
87	// ── Partnership (only if a partner is configured) ──────────────────────────────
88	preg_match('/^\s*HOST2\s*=\s*"([^"]*)"/m', $master, $m2);
89	$host2 = trim($m2[1] ?? '');
90	if (!empty($host2)) {
91	    $state  = vv_setup_state_read();
92	    $p1done = !empty($state['HOST2_PHASE1_DONE']) || !empty($state['host2_phase1_done']);
93	    $p2done = !empty($state['HOST2_PHASE2_DONE']) || !empty($state['host2_phase2_done']);
94	    $items[] = [
95	        'id'     => 'partnership',
96	        'label'  => 'Partnership',
97	        'ok'     => $p1done && $p2done,
98	        'detail' => ($p1done && $p2done)
99	            ? "Active with $host2"
100	            : ($p1done ? "Phase 1 done — waiting for HOST2 to complete" : "Not started — run partnership_onboard.sh"),
101	        'action' => (!$p1done) ? 'onboard' : null,
102	    ];
103	}
104
105	$allOk = !in_array(false, array_column($items, 'ok'), true);
106
107	echo json_encode(['ok' => true, 'complete' => $allOk, 'host_id' => $hostId, 'items' => $items]);
108

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

// ── master.conf pull (partner servers only) ───────────────────────────────────────────────────
if ($hostId !== 'host1' && $hostId !== 'unknown') {
    $state   = vv_setup_state_read();
    $pulled  = !empty($state['master_conf_pulled']);
    $items[] = [
        'id'     => 'master_conf',
        'label'  => 'master.conf',
        'ok'     => $pulled,
        'detail' => $pulled
            ? 'Synced from HOST1'
            : ($host1 ? "Not yet pulled from $host1" : 'HOST1 hostname not set in master.conf'),
        'action' => (!$pulled && $host1) ? 'pull_master' : null,
    ];
}

// ── 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]);


lets double check our onboarding setup, and first launch setup... we may test it out on the second server and start setting it up


Review onboarding and first launch setup


Let me check the config.php include and the host.conf.template more carefully.


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


Bash

find /boot/config/plugins/varaverk/Plugin/unraid/Tools -type f | sort 2>/dev/null; find /boot/config/plugins/varaverk/Tools -name "conf_populate*" 2>/dev/null

Find conf_populate.sh


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
51	#   conf_populate.sh --no-push    Skip pushing to partners after update
52	#
53	# ==============================================================================================
54
55	SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
56	source "$SCRIPT_DIR/../../../load_config.sh"
57	SCRIPTS_ROOT="$SCRIPTS_DIR"
58
59	OVERWRITE=false
60	NO_PUSH=false
61	FILTERED_ARGS=()
62	for arg in "$@"; do
63	    case "$arg" in
64	        --overwrite) OVERWRITE=true ;;
65	        --no-push)   NO_PUSH=true ;;
66	        *)           FILTERED_ARGS+=("$arg") ;;
67	    esac
68	done
69	parse_args "${FILTERED_ARGS[@]}"
70	detect_hosts
71
72	[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
73
74	CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
75	[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }
76
77	log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"
78
79	UPDATED=0
80	SKIPPED=0
81
82	echo ""
83	echo "━━━ $ICON_GEAR Conf Auto-Populate — $MY_ID ($LOCAL_SERVER_NAME) ━━━"
84	echo ""
85	[[ "$DRY_RUN"   == true ]] && warn "DRY RUN — no changes will be made"
86	[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"
87
88	# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
89	_set_conf_var() {
90	    local var_name="$1" value="$2" label="$3"
91	    [[ -z "$value" ]] && return
92
93	    local current
94	    current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)
95
96	    if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
97	        log "$label: already set (${current:0:8}…) — skipping"
98	        (( SKIPPED++ ))
99	        return
100	    fi
101
102	    if [[ "$DRY_RUN" == true ]]; then
103	        warn "DRY RUN — would set $var_name = ${value:0:8}…"
104	        return
105	    fi
106
107	    if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
108	        sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
109	    else
110	        printf '\n    %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
111	    fi
112	    info "$label: set ✅"
113	    (( UPDATED++ ))
114	}
115
116	# ── Helper: find arr config dir via docker volume mount ───────────────────────
117	_arr_config_dir() {
118	    local pattern="$1"
119	    local container_name
120	    container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
121	    [[ -z "$container_name" ]] && return 1
122
123	    local config_path
124	    config_path=$(docker inspect "$container_name" 2>/dev/null | \
125	        jq -r '.[0].Mounts[]? | select(.Destination == "/config") | .Source' 2>/dev/null | head -1)
126	    [[ -z "$config_path" ]] && config_path="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}/${container_name}"
127
128	    [[ -d "$config_path" ]] && echo "$config_path" || return 1
129	}
130
131	# ── Helper: read XML tag value ────────────────────────────────────────────────
132	_xml_val() {
133	    local file="$1" tag="$2"
134	    grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
135	}
136
137	# ── Helper: get host-side port for a container's internal port ────────────────
138	_docker_host_port() {
139	    local container="$1" container_port="$2"
140	    docker inspect "$container" 2>/dev/null | \
141	        jq -r --arg p "${container_port}/tcp" \
142	        '.[0].NetworkSettings.Ports[$p]?[0].HostPort // empty' 2>/dev/null | head -1
143	}
144
145	# ── Helper: get host path for a container destination mount ──────────────────
146	_docker_volume_host() {
147	    local container="$1" dest="$2"
148	    docker inspect "$container" 2>/dev/null | \
149	        jq -r --arg d "$dest" \
150	        '.[0].Mounts[]? | select(.Destination == $d) | .Source' 2>/dev/null | head -1
151	}
152
153	# ==============================================================================================
154	# ── Owner short name ──────────────────────────────────────────────────────────────────────────
155	# ==============================================================================================
156
157	owner=$(echo "$LOCAL_SERVER_NAME" | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//i' | tr '[:upper:]' '[:lower:]')
158	_set_conf_var "${MY_ID}_OWNER" "$owner" "Owner short name"
159
160	# ==============================================================================================
161	# ── Arr API keys + URLs + root paths ─────────────────────────────────────────────────────────
162	# ==============================================================================================
163
164	for arr in radarr sonarr lidarr; do
165	    arr_upper="${arr^^}"
166	    config_dir=$(_arr_config_dir "$arr") || {
167	        log "${arr_upper}: no running container found — skipping"
168	        continue
169	    }
170	    config_xml="${config_dir}/config.xml"
171
172	    if [[ ! -f "$config_xml" ]]; then
173	        log "${arr_upper}: config.xml not found at $config_xml — skipping"
174	        continue
175	    fi
176
177	    key=$(_xml_val "$config_xml" "ApiKey")
178	    port=$(_xml_val "$config_xml" "Port")
179	    port="${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
180	    url_base="http://localhost:${port}"
181
182	    _set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key"      "${arr_upper} API key"
183	    _set_conf_var "${MY_ID}_${arr_upper}_URL"     "$url_base" "${arr_upper} URL"
184
185	    if [[ -n "$key" ]]; then
186	        case "$arr" in lidarr) api_ver="v1" ;; *) api_ver="v3" ;; esac
187	        root_json=$(curl -sf --max-time 5 \
188	            -H "X-Api-Key: $key" "${url_base}/api/${api_ver}/rootfolder" 2>/dev/null)
189	        root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)
190
191	        case "$arr" in
192	            radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
193	            sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT"    "$root_path" "Sonarr TV root"    ;;
194	            lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
195	        esac
196	    fi
197	done
198
199	# ==============================================================================================
200	# ── SABnzbd API key + URL ─────────────────────────────────────────────────────────────────────
201	# ==============================================================================================
202
203	sab_dir=$(_arr_config_dir "sabnzbd") && {
204	    sab_ini=$(find "$sab_dir" -maxdepth 2 -name "sabnzbd.ini" 2>/dev/null | head -1)
205	    if [[ -f "$sab_ini" ]]; then
206	        sab_key=$(grep -oP  '(?<=^api_key\s*=\s*)\S+'  "$sab_ini" 2>/dev/null | head -1)
207	        sab_port=$(grep -oP '(?<=^port\s*=\s*)\d+'     "$sab_ini" 2>/dev/null | head -1)
208	        _set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key"                        "SABnzbd API key"
209	        _set_conf_var "${MY_ID}_SABNZBD_URL"     "http://localhost:${sab_port:-8080}" "SABnzbd URL"
210	    fi
211	}
212
213	# ==============================================================================================
214	# ── slskd API key + URL ───────────────────────────────────────────────────────────────────────
215	# ==============================================================================================
216
217	slskd_dir=$(_arr_config_dir "slskd") && {
218	    slskd_yml=$(find "$slskd_dir" -maxdepth 2 \( -name "*.yml" -o -name "*.yaml" \) 2>/dev/null | head -1)
219	    if [[ -f "$slskd_yml" ]]; then
220	        slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+'  "$slskd_yml" 2>/dev/null | head -1)
221	        [[ -z "$slskd_key" ]] && \
222	            slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
223	        slskd_port=$(grep -oP '(?<=port:\s)\d+' "$slskd_yml" 2>/dev/null | head -1)
224	        _set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key"                          "slskd API key"
225	        _set_conf_var "${MY_ID}_SLSKD_URL"     "http://localhost:${slskd_port:-5030}" "slskd URL"
226	    fi
227	}
228
229	# ==============================================================================================
230	# ── qBittorrent URL + credentials ────────────────────────────────────────────────────────────
231	# ==============================================================================================
232
233	qbit_dir=$(_arr_config_dir "qbittorrent") && {
234	    qbit_conf=$(find "$qbit_dir" -maxdepth 3 -name "qBittorrent.conf" 2>/dev/null | head -1)
235	    if [[ -f "$qbit_conf" ]]; then
236	        qbit_port=$(grep -oP '(?<=WebUI\\Port=)\d+'    "$qbit_conf" 2>/dev/null | head -1)
237	        qbit_user=$(grep -oP '(?<=WebUI\\Username=)\S+' "$qbit_conf" 2>/dev/null | head -1)
238	        # Only capture plaintext password — PBKDF2 hashes are not usable
239	        qbit_pass=$(grep -oP '(?<=WebUI\\Password=)[^\r\n]+' "$qbit_conf" 2>/dev/null | \
240	            grep -v '@ByteArray' | head -1)
241	        _set_conf_var "${MY_ID}_QBIT_URL"      "http://localhost:${qbit_port:-8080}" "qBittorrent URL"
242	        _set_conf_var "${MY_ID}_QBIT_USERNAME" "$qbit_user"                          "qBittorrent username"
243	        _set_conf_var "${MY_ID}_QBIT_PASSWORD" "$qbit_pass"                          "qBittorrent password"
244	    fi
245	}
246
247	# ==============================================================================================
248	# ── Media server container names + URLs + transcode path ─────────────────────────────────────
249	# ==============================================================================================
250
251	transcode_dir=""
252
253	for pattern in "emby" "jellyfin"; do
254	    container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
255	    [[ -z "$container" ]] && continue
256
257	    case "$pattern" in
258	        emby)
259	            host_port=$(_docker_host_port "$container" "8096")
260	            _set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container"                              "Emby container name"
261	            _set_conf_var "${MY_ID}_EMBY_URL"       "http://localhost:${host_port:-8096}"     "Emby URL"
262	            ;;
263	        jellyfin)
264	            host_port=$(_docker_host_port "$container" "8096")
265	            _set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container"                          "Jellyfin container name"
266	            _set_conf_var "${MY_ID}_JELLYFIN_URL"       "http://localhost:${host_port:-8095}" "Jellyfin URL"
267	            ;;
268	    esac
269
270	    # Transcode path: first container with a /transcode mount wins
271	    if [[ -z "$transcode_dir" ]]; then
272	        transcode_dir=$(_docker_volume_host "$container" "/transcode")
273	    fi
274	done
275
276	[[ -n "$transcode_dir" ]] && \
277	    _set_conf_var "${MY_ID}_TRANSCODE_SSD" "${transcode_dir%/}/" "Transcode SSD path"
278
279	# ==============================================================================================
280	# ── Network interface ─────────────────────────────────────────────────────────────────────────
281	# ==============================================================================================
282
283	nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
284	_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"
285
286	# ==============================================================================================
287	# ── Summary + push ────────────────────────────────────────────────────────────────────────────
288	# ==============================================================================================
289
290	echo ""
291	echo "━━━━━ $ICON_SUMMARY Conf Populate Summary ━━━━━"
292	echo "  Updated: $UPDATED field(s)"
293	echo "  Skipped: $SKIPPED already set"
294	echo "  Conf:    $CONF_FILE"
295	echo "━━━━━━━━━━━━━━━━━━━━━"
296
297	if [[ "$UPDATED" -gt 0 ]] && [[ "$DRY_RUN" == false ]] && [[ "$NO_PUSH" == false ]]; then
298	    echo ""
299	    info "Pushing updated conf to partners..."
300	    bash "$SCRIPTS_ROOT/System_Essentials/conf_sync.sh" --push-only "${EXTRA_FLAGS[@]}" || true
301	fi
302

#!/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
#   conf_populate.sh --no-push    Skip pushing to partners after update
#
# ==============================================================================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
SCRIPTS_ROOT="$SCRIPTS_DIR"

OVERWRITE=false
NO_PUSH=false
FILTERED_ARGS=()
for arg in "$@"; do
    case "$arg" in
        --overwrite) OVERWRITE=true ;;
        --no-push)   NO_PUSH=true ;;
        *)           FILTERED_ARGS+=("$arg") ;;
    esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts

[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }

CONF_FILE="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
[[ ! -f "$CONF_FILE" ]] && { error "Conf file not found: $CONF_FILE"; exit 1; }

log "$ICON_GEAR Config: conf=${CONF_FILE} overwrite=${OVERWRITE:-false} no-push=${NO_PUSH:-false}"

UPDATED=0
SKIPPED=0

echo ""
echo "━━━ $ICON_GEAR Conf Auto-Populate — $MY_ID ($LOCAL_SERVER_NAME) ━━━"
echo ""
[[ "$DRY_RUN"   == true ]] && warn "DRY RUN — no changes will be made"
[[ "$OVERWRITE" == true ]] && warn "OVERWRITE mode — existing values will be replaced"

# ── Helper: write a var into conf if empty (or --overwrite) ──────────────────
_set_conf_var() {
    local var_name="$1" value="$2" label="$3"
    [[ -z "$value" ]] && return

    local current
    current=$(grep -oP "(?<=^\s*${var_name}=\")[^\"]*" "$CONF_FILE" 2>/dev/null | head -1)

    if [[ -n "$current" ]] && [[ "$OVERWRITE" == false ]]; then
        log "$label: already set (${current:0:8}…) — skipping"
        (( SKIPPED++ ))
        return
    fi

    if [[ "$DRY_RUN" == true ]]; then
        warn "DRY RUN — would set $var_name = ${value:0:8}…"
        return
    fi

    if grep -q "^\s*${var_name}=" "$CONF_FILE"; then
        sed -i "s|^\(\s*${var_name}\s*=\s*\)\"[^\"]*\"|\1\"${value}\"|" "$CONF_FILE"
    else
        printf '\n    %s="%s"\n' "$var_name" "$value" >> "$CONF_FILE"
    fi
    info "$label: set ✅"
    (( UPDATED++ ))
}

# ── Helper: find arr config dir via docker volume mount ───────────────────────
_arr_config_dir() {
    local pattern="$1"
    local container_name
    container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
    [[ -z "$container_name" ]] && return 1

    local config_path
    config_path=$(docker inspect "$container_name" 2>/dev/null | \
        jq -r '.[0].Mounts[]? | select(.Destination == "/config") | .Source' 2>/dev/null | head -1)
    [[ -z "$config_path" ]] && config_path="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}/${container_name}"

    [[ -d "$config_path" ]] && echo "$config_path" || return 1
}

# ── Helper: read XML tag value ────────────────────────────────────────────────
_xml_val() {
    local file="$1" tag="$2"
    grep -oP "(?<=<${tag}>)[^<]+" "$file" 2>/dev/null | head -1
}

# ── Helper: get host-side port for a container's internal port ────────────────
_docker_host_port() {
    local container="$1" container_port="$2"
    docker inspect "$container" 2>/dev/null | \
        jq -r --arg p "${container_port}/tcp" \
        '.[0].NetworkSettings.Ports[$p]?[0].HostPort // empty' 2>/dev/null | head -1
}

# ── Helper: get host path for a container destination mount ──────────────────
_docker_volume_host() {
    local container="$1" dest="$2"
    docker inspect "$container" 2>/dev/null | \
        jq -r --arg d "$dest" \
        '.[0].Mounts[]? | select(.Destination == $d) | .Source' 2>/dev/null | head -1
}

# ==============================================================================================
# ── Owner short name ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================

owner=$(echo "$LOCAL_SERVER_NAME" | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//i' | tr '[:upper:]' '[:lower:]')
_set_conf_var "${MY_ID}_OWNER" "$owner" "Owner short name"

# ==============================================================================================
# ── Arr API keys + URLs + root paths ─────────────────────────────────────────────────────────
# ==============================================================================================

for arr in radarr sonarr lidarr; do
    arr_upper="${arr^^}"
    config_dir=$(_arr_config_dir "$arr") || {
        log "${arr_upper}: no running container found — skipping"
        continue
    }
    config_xml="${config_dir}/config.xml"

    if [[ ! -f "$config_xml" ]]; then
        log "${arr_upper}: config.xml not found at $config_xml — skipping"
        continue
    fi

    key=$(_xml_val "$config_xml" "ApiKey")
    port=$(_xml_val "$config_xml" "Port")
    port="${port:-$(case $arr in radarr) echo 7878;; sonarr) echo 8989;; lidarr) echo 8686;; esac)}"
    url_base="http://localhost:${port}"

    _set_conf_var "${MY_ID}_${arr_upper}_API_KEY" "$key"      "${arr_upper} API key"
    _set_conf_var "${MY_ID}_${arr_upper}_URL"     "$url_base" "${arr_upper} URL"

    if [[ -n "$key" ]]; then
        case "$arr" in lidarr) api_ver="v1" ;; *) api_ver="v3" ;; esac
        root_json=$(curl -sf --max-time 5 \
            -H "X-Api-Key: $key" "${url_base}/api/${api_ver}/rootfolder" 2>/dev/null)
        root_path=$(echo "$root_json" | jq -r '.[0].path // empty' 2>/dev/null)

        case "$arr" in
            radarr) _set_conf_var "${MY_ID}_RADARR_MOVIE_ROOT" "$root_path" "Radarr movie root" ;;
            sonarr) _set_conf_var "${MY_ID}_SONARR_TV_ROOT"    "$root_path" "Sonarr TV root"    ;;
            lidarr) _set_conf_var "${MY_ID}_LIDARR_MUSIC_ROOT" "$root_path" "Lidarr music root" ;;
        esac
    fi
done

# ==============================================================================================
# ── SABnzbd API key + URL ─────────────────────────────────────────────────────────────────────
# ==============================================================================================

sab_dir=$(_arr_config_dir "sabnzbd") && {
    sab_ini=$(find "$sab_dir" -maxdepth 2 -name "sabnzbd.ini" 2>/dev/null | head -1)
    if [[ -f "$sab_ini" ]]; then
        sab_key=$(grep -oP  '(?<=^api_key\s*=\s*)\S+'  "$sab_ini" 2>/dev/null | head -1)
        sab_port=$(grep -oP '(?<=^port\s*=\s*)\d+'     "$sab_ini" 2>/dev/null | head -1)
        _set_conf_var "${MY_ID}_SABNZBD_API_KEY" "$sab_key"                        "SABnzbd API key"
        _set_conf_var "${MY_ID}_SABNZBD_URL"     "http://localhost:${sab_port:-8080}" "SABnzbd URL"
    fi
}

# ==============================================================================================
# ── slskd API key + URL ───────────────────────────────────────────────────────────────────────
# ==============================================================================================

slskd_dir=$(_arr_config_dir "slskd") && {
    slskd_yml=$(find "$slskd_dir" -maxdepth 2 \( -name "*.yml" -o -name "*.yaml" \) 2>/dev/null | head -1)
    if [[ -f "$slskd_yml" ]]; then
        slskd_key=$(grep -oP '(?<=api_key:\s)[\w-]+'  "$slskd_yml" 2>/dev/null | head -1)
        [[ -z "$slskd_key" ]] && \
            slskd_key=$(grep -oP '(?<=apikey:\s)[\w-]+' "$slskd_yml" 2>/dev/null | head -1)
        slskd_port=$(grep -oP '(?<=port:\s)\d+' "$slskd_yml" 2>/dev/null | head -1)
        _set_conf_var "${MY_ID}_SLSKD_API_KEY" "$slskd_key"                          "slskd API key"
        _set_conf_var "${MY_ID}_SLSKD_URL"     "http://localhost:${slskd_port:-5030}" "slskd URL"
    fi
}

# ==============================================================================================
# ── qBittorrent URL + credentials ────────────────────────────────────────────────────────────
# ==============================================================================================

qbit_dir=$(_arr_config_dir "qbittorrent") && {
    qbit_conf=$(find "$qbit_dir" -maxdepth 3 -name "qBittorrent.conf" 2>/dev/null | head -1)
    if [[ -f "$qbit_conf" ]]; then
        qbit_port=$(grep -oP '(?<=WebUI\\Port=)\d+'    "$qbit_conf" 2>/dev/null | head -1)
        qbit_user=$(grep -oP '(?<=WebUI\\Username=)\S+' "$qbit_conf" 2>/dev/null | head -1)
        # Only capture plaintext password — PBKDF2 hashes are not usable
        qbit_pass=$(grep -oP '(?<=WebUI\\Password=)[^\r\n]+' "$qbit_conf" 2>/dev/null | \
            grep -v '@ByteArray' | head -1)
        _set_conf_var "${MY_ID}_QBIT_URL"      "http://localhost:${qbit_port:-8080}" "qBittorrent URL"
        _set_conf_var "${MY_ID}_QBIT_USERNAME" "$qbit_user"                          "qBittorrent username"
        _set_conf_var "${MY_ID}_QBIT_PASSWORD" "$qbit_pass"                          "qBittorrent password"
    fi
}

# ==============================================================================================
# ── Media server container names + URLs + transcode path ─────────────────────────────────────
# ==============================================================================================

transcode_dir=""

for pattern in "emby" "jellyfin"; do
    container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}")
    [[ -z "$container" ]] && continue

    case "$pattern" in
        emby)
            host_port=$(_docker_host_port "$container" "8096")
            _set_conf_var "${MY_ID}_EMBY_CONTAINER" "$container"                              "Emby container name"
            _set_conf_var "${MY_ID}_EMBY_URL"       "http://localhost:${host_port:-8096}"     "Emby URL"
            ;;
        jellyfin)
            host_port=$(_docker_host_port "$container" "8096")
            _set_conf_var "${MY_ID}_JELLYFIN_CONTAINER" "$container"                          "Jellyfin container name"
            _set_conf_var "${MY_ID}_JELLYFIN_URL"       "http://localhost:${host_port:-8095}" "Jellyfin URL"
            ;;
    esac

    # Transcode path: first container with a /transcode mount wins
    if [[ -z "$transcode_dir" ]]; then
        transcode_dir=$(_docker_volume_host "$container" "/transcode")
    fi
done

[[ -n "$transcode_dir" ]] && \
    _set_conf_var "${MY_ID}_TRANSCODE_SSD" "${transcode_dir%/}/" "Transcode SSD path"

# ==============================================================================================
# ── Network interface ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================

nic=$(ip route show default 2>/dev/null | grep -oP '(?<=dev )\S+' | head -1)
_set_conf_var "${MY_ID}_SYS_WATCHDOG_NIC" "$nic" "Default NIC"

# ==============================================================================================
# ── Summary + push ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================

echo ""
echo "━━━━━ $ICON_SUMMARY Conf Populate Summary ━━━━━"
echo "  Updated: $UPDATED field(s)"
echo "  Skipped: $SKIPPED already set"
echo "  Conf:    $CONF_FILE"
echo "━━━━━━━━━━━━━━━━━━━━━"

if [[ "$UPDATED" -gt 0 ]] && [[ "$DRY_RUN" == false ]] && [[ "$NO_PUSH" == false ]]; then
    echo ""
    info "Pushing updated conf to partners..."
    bash "$SCRIPTS_ROOT/System_Essentials/conf_sync.sh" --push-only "${EXTRA_FLAGS[@]}" || true
fi