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:
Gmer4Lfe
2026-08-02 10:11:39 -04:00
parent 6a959fb5e4
commit 987313e7dc
55 changed files with 3972 additions and 95 deletions
+130 -9
View File
@@ -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