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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user