[{device, sizeBytes, sizeFormatted, model, hasData}, ...] * format POST {"device": "/dev/sdc"} -> {"mountPath": "/mnt/disks/podman-storage"} */ declare(strict_types=1); require __DIR__ . '/../include/bootstrap.php'; const MOUNT_PATH = '/mnt/disks/podman-storage'; const MANAGED_DISK_CFG = '/boot/config/plugins/podman/managed-disk.cfg'; $action = $_GET['action'] ?? ''; switch ($action) { case 'list_candidates': podman_json_response(list_candidate_disks()); break; case 'format': $body = podman_read_json_body(); $device = (string) ($body['device'] ?? ''); podman_json_response(format_disk($device)); break; default: podman_json_error("Unknown action '{$action}'", 400); } /** * Runs a command and returns [exitCode, stdout+stderr combined]. Every * caller here passes a fixed argv array (never a shell string built from * request input), so there is no injection surface even before the * device-path validation in format_disk() below. * * @param array $argv * @return array{0:int,1:string} */ function run(array $argv, int $timeoutSeconds = 30): array { $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $process = proc_open($argv, $descriptors, $pipes); if (!is_resource($process)) { return [127, "could not start {$argv[0]}"]; } stream_set_timeout($pipes[1], $timeoutSeconds); $out = (stream_get_contents($pipes[1]) ?: '') . (stream_get_contents($pipes[2]) ?: ''); fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($process); return [$code, trim($out)]; } /** * Every whole disk (not partition) libpod's host isn't already using — * mounted anywhere (itself or any partition), part of Unraid's own * array/cache pool (cross-checked against /var/local/emhttp/disks.ini, * the same state file Unraid's own array management writes — the boot * flash device is covered by this same check, since Unraid lists it there * too), or the disk backing the currently-booted root/flash filesystem. * * @return array> */ function list_candidate_disks(): array { // Nested partitions come back automatically as a "children" array in // -J's JSON tree — CHILDREN is not a real -o column (lsblk itself // rejects it: "unknown column: CHILDREN"; verified live against a // real host's lsblk). LABEL is included specifically to catch the // Unraid boot flash drive, which FAT-labels itself "UNRAID" — see the // safety note below for why that check exists at all. [$code, $out] = run(['lsblk', '-J', '-b', '-p', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,LABEL'], 10); if ($code !== 0) { podman_json_error('Could not list block devices: ' . $out, 500); } $tree = json_decode($out, true); if (!is_array($tree) || !isset($tree['blockdevices'])) { podman_json_error('Unexpected lsblk output', 500); } $arrayDevices = unraid_array_device_names(); $candidates = []; foreach ($tree['blockdevices'] as $dev) { if (($dev['type'] ?? '') !== 'disk') { continue; } $name = (string) ($dev['name'] ?? ''); $baseName = basename($name); if (in_array($baseName, $arrayDevices, true)) { continue; } // zram devices report TYPE "disk" too (RAM-backed, not persistent // storage — pointless and misleading to offer for this) and an // empty card-reader slot with no card inserted reports as a real // "disk" at 0 bytes; both are excluded outright rather than left // for the size-based hasData check below. if (str_starts_with($baseName, 'zram') || (int) ($dev['size'] ?? 0) <= 0) { continue; } if (device_or_children_mounted($dev)) { continue; } if (device_or_children_labeled_unraid($dev)) { continue; } // Deliberately excludes (not just warns about) ANY disk that // already has a filesystem, partition, or other signature on it // or any of its partitions — not just ones lsblk reports as // currently mounted. Found live: a real host's cache pool disks // showed up here as "safe" with only a soft warning, because // they're ZFS pool members (fstype "zfs_member"/partition // present) rather than plain mounts, so the earlier // mounted-only check missed them entirely — the same blind spot // that also let the Unraid boot USB stick's own partition // through (FAT, not reported as "mounted" by lsblk either). A // disk being reused for podman storage must be genuinely blank; // asking a user to wipe it themselves first is a small price for // this being impossible to get wrong. if (!empty($dev['fstype']) || !empty($dev['children'])) { continue; } $candidates[] = [ 'device' => $name, 'sizeBytes' => (int) ($dev['size'] ?? 0), 'sizeFormatted' => podman_format_bytes((int) ($dev['size'] ?? 0)), 'model' => trim((string) ($dev['model'] ?? '')) ?: null, ]; } return $candidates; } function device_or_children_mounted(array $dev): bool { if (!empty($dev['mountpoint'])) { return true; } foreach ($dev['children'] ?? [] as $child) { if (device_or_children_mounted($child)) { return true; } } return false; } /** * Catches the Unraid boot flash drive specifically: it FAT-labels itself * "UNRAID" (verified live: `blkid` on a real host's boot partition shows * LABEL_FATBOOT="UNRAID" LABEL="UNRAID") and — on at least one real, * modern Unraid setup — /boot is actually backed by a ZFS dataset * ("flash/boot"), not a direct mount of that partition, so it does NOT * show up as "mounted" via lsblk's own MOUNTPOINT column at all. This * label check is a second, independent layer specifically because that * gap meant the boot drive briefly passed every other check here during * development — never rely on a single signal for something this * destructive. */ function device_or_children_labeled_unraid(array $dev): bool { if (strtoupper(trim((string) ($dev['label'] ?? ''))) === 'UNRAID') { return true; } foreach ($dev['children'] ?? [] as $child) { if (device_or_children_labeled_unraid($child)) { return true; } } return false; } /** * @return array bare device names ("sda", "nvme0n1", ...) Unraid * itself has assigned to the array or a cache pool, read from the same * /var/local/emhttp/disks.ini Unraid's own array management writes — * NOT parsed/guessed, so this stays correct across whatever array * layout a given host actually has. */ function unraid_array_device_names(): array { $path = '/var/local/emhttp/disks.ini'; if (!is_readable($path)) { return []; } $ini = @parse_ini_file($path, true); if (!is_array($ini)) { return []; } $names = []; foreach ($ini as $section) { if (is_array($section) && !empty($section['device'])) { $names[] = basename((string) $section['device']); } } return $names; } /** @return array */ function format_disk(string $device): array { if (!preg_match('#^/dev/(sd[a-z]+|nvme\d+n\d+|vd[a-z]+)$#', $device)) { podman_json_error('Invalid or unsupported device path', 400); } $allowed = array_column(list_candidate_disks(), 'device'); if (!in_array($device, $allowed, true)) { podman_json_error("{$device} is not a currently-eligible disk (already in use, part of the array, or not found) — refusing to format it.", 400); } $partDevice = preg_match('#nvme\d+n\d+$#', $device) ? "{$device}p1" : "{$device}1"; [$code, $out] = run(['wipefs', '-a', $device], 30); if ($code !== 0) { podman_json_error("wipefs failed: {$out}", 500); } [$code, $out] = run(['parted', '-s', $device, 'mklabel', 'gpt', 'mkpart', 'primary', '1MiB', '100%'], 30); if ($code !== 0) { podman_json_error("parted failed: {$out}", 500); } run(['partprobe', $device], 10); // Partition device nodes can take a moment to appear after partprobe. for ($i = 0; $i < 20 && !file_exists($partDevice); $i++) { usleep(250000); } if (!file_exists($partDevice)) { podman_json_error("Partition {$partDevice} did not appear after partitioning {$device}", 500); } [$code, $out] = run(['mkfs.xfs', '-f', '-n', 'ftype=1', '-L', 'podmanstorage', $partDevice], 60); if ($code !== 0) { podman_json_error("mkfs.xfs failed: {$out}", 500); } [$code, $uuid] = run(['blkid', '-s', 'UUID', '-o', 'value', $partDevice], 10); $uuid = trim($uuid); if ($code !== 0 || $uuid === '') { podman_json_error('Could not determine filesystem UUID after formatting', 500); } if (!is_dir(MOUNT_PATH) && !mkdir(MOUNT_PATH, 0755, true) && !is_dir(MOUNT_PATH)) { podman_json_error('Could not create ' . MOUNT_PATH, 500); } [$code, $out] = run(['mount', 'UUID=' . $uuid, MOUNT_PATH], 15); if ($code !== 0) { podman_json_error("mount failed: {$out}", 500); } // Persisted so plugin/sbin/podman-mount-managed-disk.sh (called from // the disks_mounted event hook, before rc.podman start) can remount // this same disk by UUID on every future boot — nothing else on the // system knows about this disk, since it's deliberately outside // Unraid's own array/cache pool management. $cfgDir = dirname(MANAGED_DISK_CFG); if (!is_dir($cfgDir) && !mkdir($cfgDir, 0755, true) && !is_dir($cfgDir)) { podman_json_error('Could not create ' . $cfgDir, 500); } $cfgContent = "# Written by the unraid-podman WebUI (ajax/disks.php) — do not edit by hand.\n" . 'UUID="' . $uuid . "\"\n" . 'MOUNTPOINT="' . MOUNT_PATH . "\"\n"; if (file_put_contents(MANAGED_DISK_CFG, $cfgContent, LOCK_EX) === false) { podman_json_error('Formatted and mounted, but could not persist ' . MANAGED_DISK_CFG . ' for future boots', 500); } return ['mountPath' => MOUNT_PATH]; }