Files

512 lines
26 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// First-run setup. Detects the environment, generates the SSH keypair, populates the host
// conf from discovered services, writes the initial master.conf and host*.conf, and — on a
// partner server — pulls master.conf down from HOST1.
//
// OPERATIONAL MODEL
// Two entirely different journeys share this file because they write the same files. HOST1
// authors master.conf from the wizard's answers; every other host receives it over SCP and
// never edits it. Which one runs is decided by the action, not by a mode setting.
//
// Ordering is the substance of the save path. master.conf is written first because
// host*.conf is generated from a template that needs the host slot; the SSH keypair is
// generated before the API key because the key path is written into the conf that the API
// key provisioning then reads back. Reordering these silently produces a half-configured
// host.
//
// Storage mode is decided here and acted on later. The response reports whether the chosen
// mode implies a relocation, and the UI hands that to storage.php — this endpoint never
// moves anything itself.
//
// The host conf is only ever created, never overwritten. Every path checks file_exists()
// first, so re-running the wizard on a configured host cannot destroy credentials that were
// filled in afterwards.
//
// DESIGN PRINCIPLES
// Detection is advisory; the wizard's answer wins.
// Boot transport is auto-detected, but an explicit storage_mode parameter overrides it.
// The detection is a good default, not a verdict — someone deliberately choosing appdata
// on an internal boot device has a reason.
//
// The partner path resolves HOST1's layout rather than assuming it.
// Before the SCP, HOST1's own varaverk.cfg is read over SSH to find its SCRIPTS_DIR,
// because the two hosts can legitimately be in different storage modes. Falling back to
// the default path when that read fails is the right degradation — it is correct in the
// common case.
//
// The template is filled by substitution, not by generation.
// HOSTN/hostn placeholders are replaced and two specific keys rewritten. Everything else
// in host.conf.template — comments, structure, ordering, the keys not yet filled in —
// arrives intact, which is what makes the generated conf readable and diffable.
//
// Failures are named at the step that failed.
// Missing SSH key, unresolvable Tailscale name, failed SCP and missing master.conf each
// return their own message with the remedy in it. This is the one screen where the user
// has no context yet, so a generic error is worth nothing.
//
// OPERATIONAL SAFEGUARDS
// The host slot is pattern-matched on both paths.
// ^host\d+$ before it is used to compose a conf filename or upper-cased into variable
// names. That check is what stops 'unknown' — vv_detect_host()'s failure value — from
// producing an unknown.conf.
//
// Every script run is externally time-boxed.
// `timeout` wraps ssh_setup.sh, conf_populate.sh, the remote cfg read and the SCP.
// set_time_limit() does not cover exec() time on Linux, so without this an unreachable
// HOST1 would hold a php-fpm worker open for as long as SSH kept trying. The SCP also
// runs BatchMode=yes so it can never sit waiting for a password.
//
// Missing scripts are reported, not executed.
// file_exists() precedes every exec(), so a partial deploy returns a named error rather
// than a shell failure that looks like a failed setup.
//
// Conf values are escaped before substitution.
// addslashes() on every hostname written into master.conf, so a pasted value containing
// a quote cannot terminate the assignment and corrupt the keys after it.
//
// Conf writes are atomic.
// vv_write_conf_raw() (tmp + rename) for both files, and the master.conf write is
// checked before the host conf is generated from it.
//
// The setup state is read-modify-written.
// vv_setup_state_write() replaces the file wholesale, so the save path reads the
// existing state first. Passing only the one new key would erase the partnership phase
// markers on a host that had already been onboarded and re-ran the wizard.
//
// The host conf is never overwritten.
// Both creation paths are guarded by file_exists(), so re-running setup on a configured
// host is a no-op for that file rather than a credential wipe.
//
// The SCP target is the local conf path, composed here — no part of the request names a
// destination file.
//
// Every action that changes anything is POST only, which is what places them behind
// Unraid's CSRF guard.
// The platform prepend validates the token on every POST and inspects no GET at all.
// Only `detect` remains GET-reachable, and it is a pure read. This endpoint writes
// credentials and runs setup scripts as root — that is its function, and it is why the
// method boundary is the one that matters here.
//
// REQUEST
// GET ?action=detect hostname, Unraid version, boot transport, suggested mode
// POST action=ssh_generate generate the local keypair, return the public key
// (POST, so Unraid's CSRF guard applies)
// POST action=populate run conf_populate.sh --no-push
// POST action=pull partner: SCP master.conf from HOST1, create host conf
// [my_slot] [my_hostname] [host1_hostname]
// POST (action=save, default) HOST1: write master.conf and host conf
// host1 [host2] [my_slot] [my_hostname] [storage_mode=internal|flash]
//
// RESPONSE
// detect {"ok":true,"hostname","unraid_ver","transport","boot_device","mode",
// "scripts_dir"}
// ssh_generate {"ok":bool,"pubkey":"…","error":…}
// populate {"ok":bool,"lines":[…]}
// pull {"ok":true,"host_id","conf_file","api_key":…,"redirect":"…"}
// save {"ok":true,"host_id","api_key":…,"needs_migration":bool,
// "migrate_to":"internal|flash"|null,"redirect":"…"}
// {"ok":false,"error":…} naming the specific step that failed
//
// DEPENDS ON
// include/config.php vv_get_hostname(), vv_detect_host(), vv_read_conf_raw(),
// vv_write_conf_raw(), vv_setup_state_read/_write(),
// vv_auto_create_api_key(), CONF_DIR, DEPLOY_DIR
// Partnership/ssh_setup.sh keypair generation
// Deployment/conf_populate.sh service discovery
// Deployment/host.conf.template source of the generated host conf
// api/storage.php performs the migration this endpoint only recommends
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.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') {
// 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),
]);