/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') { // POST only. This generates a keypair, and Unraid's CSRF prepend validates POSTs while // ignoring GETs entirely — over GET it would run with no token check at all. if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $script = SCRIPTS_DIR . '/Partnership/ssh_setup.sh'; if (!file_exists($script)) { echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']); exit; } // set_time_limit() does not cover exec() time on Linux — every script run below is // wrapped in `timeout` so a stalled child cannot hold a php-fpm worker open. exec('timeout 120 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; } // Where setup hands the operator next, by role. // // Both exits used to be hardcoded to the Scheduler. For HOST1 that is defensible — it has just // written a conf and Scheduler is where you would tune it. For a partner it is a dead end: a // mirror that finishes setup has exactly one job left, installing its key and joining, and that // lives on the Partnership tab. The role was already known here and simply not consulted. function vv_setup_redirect(string $mySlot, string $confFile = 'master.conf'): string { return strtolower($mySlot) === 'host1' ? '?tab=scheduler&vv_setup=' . $confFile : '?tab=partnership&vv_setup=' . $confFile; } // ── POST: run conf_populate.sh ───────────────────────────────────────────────────────────────── if ($action === 'populate') { $script = DEPLOY_DIR . '/conf_populate.sh'; if (!file_exists($script)) { echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']); exit; } exec('timeout 180 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; } // ── POST: defer / undefer a non-blocking checklist item ─────────────────────────────────────── // Records the operator's "not now" so the checklist can reach complete without the item being // green. Only the ids api/checklist.php marks deferrable are accepted — a blocking item cannot be // dismissed, because dismissing it would report a mesh as ready when it cannot function. if ($action === 'defer' || $action === 'undefer') { // POST only, and not merely by convention: $action is taken from GET too, and Unraid's CSRF // guard checks POST alone. A GET-reachable mutation here would let any page the operator // visits silently dismiss a checklist item. if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['ok' => false, 'error' => 'Method not allowed']); exit; } $allowed = ['api_key', 'populated', 'emby_key', 'jellyfin_key']; $item = trim($_POST['item'] ?? ''); if (!in_array($item, $allowed, true)) { echo json_encode(['ok' => false, 'error' => 'Not a deferrable item: ' . $item]); exit; } $state = vv_setup_state_read(); $key = 'DEFER_' . strtoupper($item); if ($action === 'defer') { $state[$key] = (string)time(); // when, not just whether — a stale decision shows its age } else { unset($state[$key]); } if (!vv_setup_state_write($state)) { echo json_encode(['ok' => false, 'error' => 'Failed to write setup state']); exit; } echo json_encode(['ok' => true, 'item' => $item, 'deferred' => $action === 'defer']); 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's Tailscale IP through the shared resolver, not a bare `tailscale ip -4`. // // master.conf records the OS hostname (unRAID-Gmer4Lfe). Tailscale knows the same machine by // its own name, and when the two nodes are in different tailnets linked by node sharing, a // shared peer is only addressable by its full name — `unraid-gmer4lfe.tonkinese-monster.ts.net` // resolves while both `unRAID-Gmer4Lfe` and `unraid-gmer4lfe` fall through to public DNS and // fail. A bare lookup therefore reported "is Tailscale running on both servers?" on a mesh // where Tailscale was running perfectly on both. // // vv_resolve_tailscale_ip() already handles this: it tries the direct lookup, then falls back // to an unambiguous prefix match against `tailscale status` with the domain stripped, and // refuses to guess when more than one peer could qualify. $ip = vv_resolve_tailscale_ip($host1Hostname); if (!$ip) { echo json_encode(['ok' => false, 'error' => "Cannot resolve a Tailscale address for $host1Hostname. Check `tailscale status` on " . "this server — the name in master.conf must match a peer there, or be an unambiguous " . "prefix of one."]); 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('timeout 30 ' . $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 = 'timeout 60 scp -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o BatchMode=yes' . ' ' . $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(DEPLOY_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); // allowCreate: this is first-run setup, so the host conf does not exist yet. There // is no prior content to back up, and a candidate that fails bash -n is removed // rather than restored. vv_conf_edit($confFile, fn(): string => $conf, [], ["{$hostId}_SSH_KEY"], true); } } if (file_exists($sshScript)) { exec('timeout 120 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' => vv_setup_redirect($hostId, $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'); // Captured before the substitutions below — the write compares against it so a master.conf that // changed while setup was being filled in is not silently overwritten. $origMaster = $master; 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_conf_edit('master.conf', fn(string $cur): ?string => $cur === $origMaster ? $master : null, [], ['HOST1', 'HOST2'])) { 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'; // Storage mode: use wizard selection, fall back to auto-detect from boot transport $smParam = trim($_POST['storage_mode'] ?? ''); if ($smParam === 'flash') { $storageInternal = 'false'; } elseif ($smParam === 'internal') { $storageInternal = 'true'; } else { $bootPart = trim(shell_exec('findmnt -n -o SOURCE /boot 2>/dev/null') ?: ''); $bootDisk = $bootPart ? trim(shell_exec('lsblk -no pkname ' . escapeshellarg($bootPart) . ' 2>/dev/null') ?: '') : ''; $transport = $bootDisk ? strtolower(trim(shell_exec('lsblk -dno TRAN /dev/' . escapeshellarg($bootDisk) . ' 2>/dev/null') ?: '')) : ''; $storageInternal = ($transport !== 'usb') ? 'true' : 'false'; } if (!file_exists(CONF_DIR . '/' . $confFile)) { $template = @file_get_contents(DEPLOY_DIR . '/host.conf.template') ?: ''; if ($template) { $sshOwner = strtolower(preg_replace('/^unraid-/i', '', $myHostname)); $sshKeyPath = '/root/.ssh/' . $sshOwner . '_rsync_automation'; $conf = str_replace('HOSTN', $hostId, $template); $conf = str_replace('hostn', $hostIdLow, $conf); $conf = preg_replace('/^(\s*' . $hostId . '_SSH_KEY\s*=\s*)""/m', '${1}"' . $sshKeyPath . '"', $conf); $conf = preg_replace('/^(\s*' . $hostId . '_STORAGE_MODE_INTERNAL\s*=\s*)\S+/m', '${1}' . $storageInternal, $conf); // allowCreate — see the sibling write above; this is the same first-run create. if (!vv_conf_edit($confFile, fn(): string => $conf, [], ["{$hostId}_SSH_KEY"], true)) { echo json_encode(['ok' => false, 'error' => "Failed to write $confFile"]); exit; } } } // ── Adopt the owner's custom docker networks ───────────────────────────────────────────────── // Sent by the wizard, which read them out of the owner's host conf that onboard Phase 1 cached // into the RAM conf dir. This is the one setup value a fresh mirror has no way to know: the // template ships NETWORK_CONNECT_NETWORKS with its only entry commented out, and the owner then // deploys containers here onto a network named in the *owner's* templates. An empty list is what // left twelve containers created against a network that did not exist. // // Outside the create block above, so it applies to a conf that already exists — the wizard is // re-runnable and a mirror rebuilt against an existing host conf needs this just as much. // // Merged, never replaced: anything already listed here was put there deliberately. $netsRaw = trim((string)($_POST['networks'] ?? '')); if ($netsRaw !== '' && $hostIdLow !== 'host1') { // Written into a file that bash sources, so the name is validated rather than trusted. // Docker's own charset for a network name is a superset of this; anything outside it is // far more likely to be an injection attempt than a real network. $nets = array_values(array_filter( array_map('trim', explode(',', $netsRaw)), fn($n) => $n !== '' && preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/', $n) && !preg_match('/^(bridge|host|none|br\d)/i', $n) )); if ($nets) { $netVar = $hostId . '_NETWORK_CONNECT_NETWORKS'; vv_conf_edit($confFile, function (string $cur) use ($netVar, $nets): ?string { $existing = vv_parse_conf_list($cur, $netVar); $merged = $existing; foreach ($nets as $n) { if (!in_array($n, $merged, true)) $merged[] = $n; } if ($merged === $existing) return null; // already adopted — no write, no audit noise $body = ''; foreach ($merged as $n) $body .= ' "' . $n . '"' . "\n"; $new = preg_replace( '/^(\s*' . preg_quote($netVar, '/') . '\s*=\s*\()(.*?)(^\s*\))/ms', "\$1\n" . $body . '$3', $cur, 1, $count); return ($count === 1 && $new !== null) ? $new : null; }, [], [$netVar]); } } // Write setup state file — lets partner servers know HOST1 is configured. // Read-modify-write: vv_setup_state_write() replaces the file wholesale, and re-running the // wizard must not erase onboarding progress recorded by the partnership phases. $state = vv_setup_state_read(); $state['host1_hostname'] = $host1; vv_setup_state_write($state); // Auto-generate SSH keypair (local only — remote copy happens during onboarding) if (file_exists($sshScript)) { exec('timeout 120 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); $targetDir = ($storageInternal === 'true') ? '/boot/config/plugins/varaverk' : '/mnt/user/appdata/Varaverk'; $needsMigration = (defined('SCRIPTS_DIR') && SCRIPTS_DIR !== $targetDir); echo json_encode([ 'ok' => true, 'host_id' => $hostId, 'api_key' => $apiKeyResult, 'needs_migration'=> $needsMigration, 'migrate_to' => $needsMigration ? ($storageInternal === 'true' ? 'internal' : 'flash') : null, 'redirect' => vv_setup_redirect($mySlot), ]);