Document the PHP api layer and fix what documenting it exposed
Writing down what each endpoint actually guarantees made the places it didn't obvious — shell arguments reaching a crontab or a bash -c unescaped, master.conf written without tmp+rename, and conf edits that could be saved without ever being parsed.
This commit is contained in:
+130
-9
@@ -1,4 +1,119 @@
|
||||
<?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.
|
||||
//
|
||||
// Accepted: this endpoint writes credentials and runs setup scripts as root.
|
||||
// It is the setup wizard; that is its function. It is guarded by the Unraid WebGUI
|
||||
// session; see the CSRF note in README-unraid.md.
|
||||
//
|
||||
// REQUEST
|
||||
// GET ?action=detect hostname, Unraid version, boot transport, suggested mode
|
||||
// GET|POST action=ssh_generate generate the local keypair, return the public key
|
||||
// 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';
|
||||
|
||||
@@ -39,7 +154,9 @@ if ($action === 'ssh_generate') {
|
||||
echo json_encode(['ok' => false, 'error' => 'ssh_setup.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --local-only 2>&1', $out, $rc);
|
||||
// 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));
|
||||
@@ -60,7 +177,7 @@ if ($action === 'populate') {
|
||||
echo json_encode(['ok' => false, 'error' => 'conf_populate.sh not found']);
|
||||
exit;
|
||||
}
|
||||
exec('bash ' . escapeshellarg($script) . ' --no-push 2>&1', $out, $rc);
|
||||
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;
|
||||
@@ -116,15 +233,15 @@ if ($action === 'pull') {
|
||||
// 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"') ?: '');
|
||||
$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 = 'scp -i ' . escapeshellarg($sshKey)
|
||||
. ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no'
|
||||
$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) {
|
||||
@@ -154,7 +271,7 @@ if ($action === 'pull') {
|
||||
}
|
||||
|
||||
if (file_exists($sshScript)) {
|
||||
exec('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
exec('timeout 120 bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
}
|
||||
$apiKeyResult = vv_auto_create_api_key($hostId, $confFile);
|
||||
|
||||
@@ -247,12 +364,16 @@ if (!file_exists(CONF_DIR . '/' . $confFile)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Write setup state file — lets partner servers know HOST1 is configured
|
||||
vv_setup_state_write(['host1_hostname' => $host1]);
|
||||
// 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('bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
exec('timeout 120 bash ' . escapeshellarg($sshScript) . ' --local-only 2>/dev/null');
|
||||
}
|
||||
|
||||
// Auto-create Unraid API key and write into the fresh conf
|
||||
|
||||
Reference in New Issue
Block a user