Eleven call sites wrote master.conf with tmp+rename and nothing else — no backup, no parse check, no audit — including the two toggles the UI uses most and the raw editor that installs a whole hand-edited file.
413 lines
21 KiB
PHP
413 lines
21 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;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
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('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' => '?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');
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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' => '?tab=scheduler&vv_setup=master.conf',
|
|
]);
|