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
+120 -5
View File
@@ -1,6 +1,111 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Storage mode and API key management. Reports where the plugin currently lives and what
// kind of device it boots from, migrates it between internal and flash layouts, records the
// detected mode into conf, and reports or provisions Unraid API keys across all hosts.
//
// OPERATIONAL MODEL
// Storage mode exists because /boot is not the same thing on every Unraid host. On a real
// USB flash device, write wear is a genuine constraint and the plugin belongs in appdata; on
// a host that boots from an internal SSD or a ZFS pool it does not, and living under
// /boot/config/plugins is simpler. The mode is therefore detected from the boot device's
// transport rather than assumed.
//
// Detection walks from the mountpoint down to physical media, and handles two shapes. A
// block-device /boot resolves through lsblk's parent-name to the whole disk. A ZFS /boot
// resolves through zpool to a backing vdev first, skipping the mirror/raidz/spare topology
// rows, and then to its whole disk. Only then is the transport read.
//
// Migration is delegated entirely to storage_migrate.sh. It copies data, rewrites
// varaverk.cfg and master.conf, and removes the old location — far too much to do inside a
// web request, and it must be resumable and testable on its own.
//
// DESIGN PRINCIPLES
// Reports detected and current separately, and never reconciles them itself.
// current_mode is where the plugin actually is; detected is where the hardware suggests
// it should be. A host can legitimately sit in either state, so the status action
// presents both and lets a person decide.
//
// A third mode, custom, is a first-class answer.
// A SCRIPTS_DIR matching neither known path is reported as custom rather than being
// forced into one of the two. Someone who deliberately relocated the plugin should not
// see the UI claim it is somewhere it is not.
//
// API key status is reported for every host, from conf.
// Partner keys live in the partner's own conf section, so a partner's key can be
// reported present without contacting it. Only the local host's key is validated
// against the API, because only the local host has one to call.
//
// Keys are previewed, never returned.
// First 8 and last 4 characters — enough to confirm which key is in use, not enough to
// use it.
//
// OPERATIONAL SAFEGUARDS
// The migration target is an exact allowlist with strict comparison.
// Only 'internal' and 'flash' reach the script, as an escapeshellarg'd --to= value. No
// part of the request names a path, so migration cannot be pointed anywhere else.
//
// Every shell argument is escaped, including ones derived from other shell calls.
// The device names discovered during detection are fed back into lsblk through
// escapeshellarg(), so a device name containing anything unexpected cannot compose a
// command.
//
// Every detection step degrades to 'unknown' rather than guessing.
// A failed findmnt, lsblk, or zpool returns unknown at that step and stops. Guessing
// here would mean recommending a migration based on a device that was never identified.
//
// Both script runs are externally time-boxed.
// `timeout` wraps each — set_time_limit() does not cover exec() time on Linux, so PHP's
// own limit cannot end a stalled migration or a key renewal hung on an unreachable
// partner. Exit 124 is reported as a timeout, distinctly from a script failure.
//
// Missing scripts are reported, not executed.
// file_exists() precedes both exec() calls, so a partial deploy returns a named error
// rather than a shell failure that would look like a failed migration.
//
// Migration and detection are POST-only. status and api_status are the only GET-reachable
// actions, and both are pure reads.
//
// Handle with care: the detect action writes HOST<n>_STORAGE_MODE_INTERNAL.
// That value decides where the plugin's data lives, and on HOST1 it is deliberately
// pinned. Running detect on a host whose boot device reports an unexpected transport
// would rewrite it. The action exists for onboarding a new host, where nothing has been
// decided yet — it is not a repair tool for an established one.
//
// The conf write goes through the structured writer, so it is atomic and syntax-checked —
// which is also why include/confform.php must be loaded here. It was previously absent and
// the detect action fataled on an undefined function.
//
// REQUEST
// GET|POST ?action=status current mode, detected mode, boot device, conf value
// GET|POST ?action=api_status per-host API key presence and local API health
// POST action=migrate to=internal|flash
// POST action=detect write the detected mode into this host's conf
// POST action=setup_apikeys run unraid_api_key_renew.sh
//
// RESPONSE
// status {"ok":true,"current_mode","current_dir","internal_dir","flash_dir",
// "transport","detected","boot_disk","conf_key","conf_val","array_started"}
// api_status {"ok":true,"my_id","key_name","hosts":[…],"fallbacks":…}
// migrate {"ok":bool,"exit":int,"output":"…"}
// detect {"ok":bool,"detected":"true|false","transport":"…"}
// setup_apikeys {"ok":bool,"exit":int,"output":"…"}
// {"ok":false,"error":"Invalid target: …"|"storage_migrate.sh not found"
// |"unraid_api_key_renew.sh not found"|"… timed out …"|"Unknown action"}
//
// DEPENDS ON
// include/config.php SCRIPTS_DIR, vv_detect_host(), vv_conf_vars()
// include/confform.php vv_conf_write_changes()
// include/unraid_api.php vv_api_get_status() (loaded only by api_status)
// Tools/storage_migrate.sh
// System_Essentials/unraid_api_key_renew.sh
// findmnt, lsblk, zpool
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
// vv_conf_write_changes() lives here — the detect action fatals without it.
require_once dirname(__DIR__) . '/include/confform.php';
$action = $_GET['action'] ?? $_POST['action'] ?? '';
@@ -58,7 +163,7 @@ if ($action === 'status') {
'flash_dir' => $flashDir,
'transport' => $transport,
'detected' => $detected,
'boot_disk' => $bootDisk ? '/dev/' . $bootDisk : 'unknown',
'boot_disk' => $bootDisk ?: 'unknown',
'conf_key' => $confKey,
'conf_val' => $confVal,
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
@@ -80,10 +185,15 @@ if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
set_time_limit(300);
// set_time_limit() does not cover exec() time on Linux, so the bound is external.
set_time_limit(630);
$output = [];
$exit = 0;
exec('bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
exec('timeout 600 bash ' . escapeshellarg($script) . ' --to=' . escapeshellarg($to) . ' 2>&1', $output, $exit);
if ($exit === 124) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh timed out after 600s', 'output' => implode("\n", $output)]);
exit;
}
echo json_encode([
'ok' => $exit === 0,
@@ -160,9 +270,14 @@ if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
set_time_limit(60);
// set_time_limit() does not cover exec() time on Linux — this script reaches every
// partner over SSH, so the bound has to be external.
set_time_limit(150);
$output = []; $exit = 0;
exec('bash ' . escapeshellarg($script) . ' 2>&1', $output, $exit);
exec('timeout 120 bash ' . escapeshellarg($script) . ' 2>&1', $output, $exit);
if ($exit === 124) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh timed out after 120s']); exit;
}
echo json_encode(['ok' => $exit === 0, 'exit' => $exit, 'output' => implode("\n", $output)]);
exit;
}