Files
Varaverk/Plugin/unraid/api/storage.php
T
Gmer4Lfe 987313e7dc 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.
2026-08-02 10:11:39 -04:00

286 lines
14 KiB
PHP

<?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'] ?? '';
// ── Boot device detection ─────────────────────────────────────────────────────
function vv_storage_detect_transport(): string {
$src = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
if (!$src) return 'unknown';
if (str_starts_with($src, '/dev/')) {
// Block device partition — walk up to whole disk
$disk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($src) . " 2>/dev/null") ?: '');
if (!$disk) return 'unknown';
} else {
// ZFS dataset (pool/dataset) — find a backing device via zpool
$pool = explode('/', $src)[0];
$dev = trim(shell_exec("zpool list -vHp " . escapeshellarg($pool) . " 2>/dev/null | awk 'NR>1 && \$1~/^[a-z]/ && \$1!~/mirror|raidz|spare/{print \$1; exit}'") ?: '');
if (!$dev) return 'unknown';
// Strip partition suffix to get the whole-disk name
$disk = trim(shell_exec("lsblk -no pkname /dev/" . escapeshellarg($dev) . " 2>/dev/null") ?: $dev);
}
return strtolower(trim(shell_exec("lsblk -dno TRAN /dev/" . escapeshellarg($disk) . " 2>/dev/null") ?: 'unknown'));
}
// ── Current mode status ───────────────────────────────────────────────────────
if ($action === 'status') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'flash' : 'internal';
$currentDir = SCRIPTS_DIR;
$internalDir = '/boot/config/plugins/varaverk';
$flashDir = '/mnt/user/appdata/Varaverk';
$currentMode = ($currentDir === $internalDir) ? 'internal'
: ($currentDir === $flashDir ? 'flash' : 'custom');
$myHost = vv_detect_host();
$vars = vv_conf_vars();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confVal = $vars[$confKey] ?? null;
// Boot device name for display
$bootSrc = trim(shell_exec("findmnt -n -o SOURCE /boot 2>/dev/null") ?: '');
$bootDisk = '';
if (str_starts_with($bootSrc, '/dev/')) {
$bootDisk = trim(shell_exec("lsblk -no pkname " . escapeshellarg($bootSrc) . " 2>/dev/null") ?: '');
$bootDisk = $bootDisk ? '/dev/' . $bootDisk : '';
} elseif ($bootSrc) {
$bootDisk = $bootSrc; // ZFS: show "flash/boot"
}
echo json_encode([
'ok' => true,
'current_mode' => $currentMode,
'current_dir' => $currentDir,
'internal_dir' => $internalDir,
'flash_dir' => $flashDir,
'transport' => $transport,
'detected' => $detected,
'boot_disk' => $bootDisk ?: 'unknown',
'conf_key' => $confKey,
'conf_val' => $confVal,
'array_started'=> is_dir('/mnt/user') && count(scandir('/mnt/user')) > 2,
]);
exit;
}
// ── Run migration ─────────────────────────────────────────────────────────────
if ($action === 'migrate' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$to = trim($_POST['to'] ?? '');
if (!in_array($to, ['internal', 'flash'], true)) {
echo json_encode(['ok' => false, 'error' => 'Invalid target: must be internal or flash']);
exit;
}
$script = dirname(__DIR__) . '/Tools/storage_migrate.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'storage_migrate.sh not found']);
exit;
}
// set_time_limit() does not cover exec() time on Linux, so the bound is external.
set_time_limit(630);
$output = [];
$exit = 0;
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,
'exit' => $exit,
'output' => implode("\n", $output),
]);
exit;
}
// ── Auto-detect and write to conf ─────────────────────────────────────────────
if ($action === 'detect' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$transport = vv_storage_detect_transport();
$detected = ($transport === 'usb') ? 'false' : 'true';
$myHost = vv_detect_host();
$confKey = strtoupper($myHost) . '_STORAGE_MODE_INTERNAL';
$confFile = $myHost . '.conf';
$results = vv_conf_write_changes([[
'file' => $confFile,
'key' => $confKey,
'value' => $detected,
'type' => 'scalar',
]]);
$ok = !in_array(false, $results, true);
echo json_encode(['ok' => $ok, 'detected' => $detected, 'transport' => $transport]);
exit;
}
// ── Unraid API key status ─────────────────────────────────────────────────────
if ($action === 'api_status') {
require_once dirname(__DIR__) . '/include/unraid_api.php';
$localStatus = vv_api_get_status();
$vars = vv_conf_vars();
$myHost = vv_detect_host();
$myId = strtoupper($myHost);
$hn = trim((string)shell_exec("hostname -s 2>/dev/null | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//'")) ?: 'Varaverk';
$localKeyName = 'Varaverk ' . $hn;
$hosts = [];
foreach ($vars as $k => $v) {
if (!preg_match('/^HOST(\d+)$/', $k, $m) || !$v) continue;
$id = 'HOST' . $m[1];
$keyVar = $id . '_UNRAID_API_KEY';
$key = $vars[$keyVar] ?? '';
$isLocal = ($id === $myId);
$hosts[] = [
'host_id' => $id,
'hostname' => $v,
'is_local' => $isLocal,
'key_var' => $keyVar,
'key_present' => !empty($key),
'key_preview' => $key ? substr($key, 0, 8) . '...' . substr($key, -4) : null,
'api_ok' => $isLocal
? (!$localStatus['key_missing'] && $localStatus['available'])
: !empty($key),
];
}
usort($hosts, fn($a, $b) => strcmp($a['host_id'], $b['host_id']));
echo json_encode([
'ok' => true,
'my_id' => $myId,
'key_name' => $localKeyName,
'hosts' => $hosts,
'fallbacks' => $localStatus['fallbacks'],
]);
exit;
}
// ── Setup/renew API keys (local + all partners via SSH) ───────────────────────
if ($action === 'setup_apikeys' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$script = SCRIPTS_DIR . '/Plugin/unraid/System_Essentials/unraid_api_key_renew.sh';
if (!file_exists($script)) {
echo json_encode(['ok' => false, 'error' => 'unraid_api_key_renew.sh not found']); exit;
}
// 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('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;
}
echo json_encode(['ok' => false, 'error' => 'Unknown action']);