with * entries). Deliberately the SAME * schema, not a podman-specific one of our own: it's the one users and * template authors already know, and it means a template exported here * carries over the fields (image, ports, paths, variables, icon, * category, overview) a Docker template would too, even though the two * ecosystems' XML isn't fully interchangeable (this plugin's Config * entries don't have every attribute dockerMan's does, e.g. no GPU/USB/ * device passthrough yet — see docs/ARCHITECTURE.md section 18). * * Stored at $bootDir/templates/.xml — same boot-persistence * reasoning as compose/autostart/networks (see ajax/compose.php). * * Actions (?action=...): * list GET -> [{name, image, icon, category, overview}, ...] * get GET (&name=...) -> full parsed config, for prefilling the * Create Container form ("Use template") * export GET (&name=...) -> {xml: ""} for download * save POST {"name": "...", "image": "...", "icon": "...", "category": "...", * "overview": "...", "webUrl": "...", "networkMode": "...", "privileged": false, * "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]} * import POST {"xml": ""} -> parses + saves as a new template * import_url POST {"url": "https://..."} -> fetches the URL server-side * (see template_fetch_url() for the SSRF protections this * needs, since the URL comes straight from the client) and * imports it the same way as `import` * list_local GET -> [{file, name, image, icon}, ...] from Unraid's own * dockerMan template directories (real existing Docker * templates the user already has — see * local_dockerman_templates_list()), for a "browse local * templates" picker instead of copy-pasting XML by hand * import_local POST {"file": "gitea.xml"} -> imports one by filename * (validated against the same directories list_local * scanned, never an arbitrary path from the client) * apps_search GET (&q=...&page=1&sort=newest|alpha) -> {results: * [{name, image, icon, overview, templateUrl}, ...], * total, page, pageSize, feedUpdatedAt} — searches * Community Applications' own public app feed (the same * catalog CA's own plugin uses, see ca_feed_search()) by * name, so a template doesn't have to already be known-by- * URL or already sitting in dockerMan's local directories * to import it. An empty/omitted q browses the whole feed * instead of searching, ordered by `sort` (default * "newest"); a non-empty q always sorts alphabetically * regardless of `sort` (see ca_feed_search() for why). * Importing a match is just `import_url` again with that * result's templateUrl — no separate import path needed. * remove POST {"name": "..."} */ declare(strict_types=1); /** * The public catalog Unraid's own Community Applications plugin is built * on — one JSON file listing every CA app (name, repository, icon, * overview, and critically TemplateURL, the same kind of link * template_fetch_url() already knows how to fetch). ~20MB and only * updated a few times a day upstream, so it's cached to a temp file * rather than re-downloaded on every search keystroke. */ const CA_FEED_URL = 'https://raw.githubusercontent.com/Squidly271/AppFeed/master/applicationFeed.json'; const CA_FEED_MAX_AGE_SECONDS = 86400; const CA_FEED_PAGE_SIZE = 24; require __DIR__ . '/../include/bootstrap.php'; $templatesDir = $podmanConfig->bootDir . '/templates'; $action = $_GET['action'] ?? ''; switch ($action) { case 'list': podman_json_response(templates_list($templatesDir)); break; case 'get': podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? '')))); break; case 'export': $path = require_template_path($templatesDir, (string) ($_GET['name'] ?? '')); podman_json_response(['xml' => file_get_contents($path)]); break; case 'save': $body = podman_read_json_body(); $name = trim((string) ($body['name'] ?? '')); if (!is_valid_template_name($name)) { podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400); } template_write($templatesDir, $name, $body); podman_json_response(['status' => 'saved', 'name' => $name]); break; case 'import': $body = podman_read_json_body(); $xml = (string) ($body['xml'] ?? ''); if (trim($xml) === '') { podman_json_error('Missing xml in request body', 400); } $name = template_import($templatesDir, $xml); podman_json_response(['status' => 'imported', 'name' => $name]); break; case 'import_url': $body = podman_read_json_body(); $url = trim((string) ($body['url'] ?? '')); if ($url === '') { podman_json_error('Missing url in request body', 400); } $name = template_import($templatesDir, template_fetch_url($url)); podman_json_response(['status' => 'imported', 'name' => $name]); break; case 'list_local': podman_json_response(local_dockerman_templates_list()); break; case 'import_local': $body = podman_read_json_body(); $file = (string) ($body['file'] ?? ''); $name = template_import($templatesDir, local_dockerman_template_read($file)); podman_json_response(['status' => 'imported', 'name' => $name]); break; case 'apps_search': $q = trim((string) ($_GET['q'] ?? '')); if ($q !== '' && mb_strlen($q) < 2) { podman_json_error('Search term must be at least 2 characters', 400); } $page = max(1, (int) ($_GET['page'] ?? 1)); $sort = (string) ($_GET['sort'] ?? 'newest'); podman_json_response(ca_feed_search($q, $page, $sort)); break; case 'remove': $body = podman_read_json_body(); $path = require_template_path($templatesDir, (string) ($body['name'] ?? '')); unlink($path); podman_json_response(['status' => 'removed']); break; default: podman_json_error("Unknown action '{$action}'", 400); } /** * Template names become filenames — restricted to a fixed safe character * set BEFORE ever being used to build a filesystem path, the same * pattern ajax/compose.php's is_valid_project_name() uses for the same * reason. */ function is_valid_template_name(string $name): bool { return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1; } function require_template_path(string $templatesDir, string $name): string { if (!is_valid_template_name($name)) { podman_json_error('Invalid template name', 400); } $path = $templatesDir . '/' . $name . '.xml'; if (!is_file($path)) { podman_json_error("Template '{$name}' not found", 404); } return $path; } /** @return array> */ function templates_list(string $templatesDir): array { if (!is_dir($templatesDir)) { return []; } $out = []; foreach (scandir($templatesDir) ?: [] as $entry) { if (!str_ends_with($entry, '.xml')) { continue; } $name = substr($entry, 0, -4); if (!is_valid_template_name($name)) { continue; } try { $parsed = template_read($templatesDir . '/' . $entry); } catch (\Throwable $e) { continue; // a hand-edited/corrupt file shouldn't break the whole list } $out[] = [ 'name' => $name, 'image' => $parsed['image'], 'icon' => $parsed['icon'], 'category' => $parsed['category'], 'overview' => $parsed['overview'], ]; } usort($out, static fn($a, $b) => strcmp($a['name'], $b['name'])); return $out; } /** * Parses one template XML file into the same shape * ajax/containers.php's build_container_spec() (Create Container form) * consumes, so "Use template" can feed straight into that form/action. * * @return array */ function template_read(string $path): array { $xml = @simplexml_load_file($path); if ($xml === false) { podman_json_error("Could not parse template XML: {$path}", 500); } $ports = []; $volumes = []; $env = []; foreach ($xml->Config as $cfg) { $attrs = $cfg->attributes(); $type = (string) ($attrs['Type'] ?? ''); $value = trim((string) $cfg); $target = (string) ($attrs['Target'] ?? ''); if ($value === '') { continue; } switch ($type) { case 'Port': $ports[] = [ 'hostPort' => (int) $value, 'containerPort' => (int) ($target !== '' ? $target : $value), 'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp', ]; break; case 'Path': $volumes[] = [ 'kind' => 'path', 'source' => $value, 'containerPath' => $target !== '' ? $target : $value, // dockerMan's own convention for a read-only bind — this // plugin's own exports write it the same way (see // template_write() below), and real CA templates use it // too (e.g. a storage-stats sidecar mounting /mnt/user // read-only rather than read-write for no reason). 'readOnly' => strtolower((string) ($attrs['Mode'] ?? 'rw')) === 'ro', ]; break; case 'Variable': $env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value]; break; } } return [ 'image' => (string) $xml->Repository, 'networkMode' => (string) ($xml->Network ?: 'bridge'), 'privileged' => strtolower((string) $xml->Privileged) === 'true', 'icon' => (string) $xml->Icon, 'category' => (string) $xml->Category, 'overview' => (string) $xml->Overview, 'webUrl' => (string) $xml->WebUI, 'ports' => $ports, 'volumes' => $volumes, 'env' => $env, ]; } /** * Writes a template XML file from the Create Container form's field * shapes (same as build_container_spec() in ajax/containers.php takes) * plus template-only metadata (icon/category/overview). Uses DOMDocument * rather than string concatenation so every value is properly escaped — * no risk of a "<" or "&" in an image name/description breaking the XML. * * @param array $body */ function template_write(string $templatesDir, string $name, array $body): void { if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) { podman_json_error("Could not create {$templatesDir}", 500); } $doc = new \DOMDocument('1.0'); $doc->formatOutput = true; $root = $doc->createElement('Container'); $root->setAttribute('version', '2'); $doc->appendChild($root); $append = static function (string $tag, string $value) use ($doc, $root): void { $root->appendChild($doc->createElement($tag, $value)); }; $append('Name', $name); $append('Repository', trim((string) ($body['image'] ?? ''))); $append('Network', (string) ($body['networkMode'] ?? 'bridge')); $append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false'); $append('Overview', (string) ($body['overview'] ?? '')); $append('Category', (string) ($body['category'] ?? '')); $append('Icon', (string) ($body['icon'] ?? '')); $append('WebUI', (string) ($body['webUrl'] ?? '')); foreach (($body['ports'] ?? []) as $row) { $hostPort = (string) ($row['hostPort'] ?? ''); $containerPort = (string) ($row['containerPort'] ?? ''); if ($hostPort === '' || $containerPort === '') { continue; } $cfg = $doc->createElement('Config', $hostPort); $cfg->setAttribute('Name', 'Port ' . $containerPort); $cfg->setAttribute('Target', $containerPort); $cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp')); $cfg->setAttribute('Type', 'Port'); $root->appendChild($cfg); } foreach (($body['volumes'] ?? []) as $row) { $source = (string) ($row['source'] ?? ''); $containerPath = (string) ($row['containerPath'] ?? ''); if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') { continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds } $cfg = $doc->createElement('Config', $source); $cfg->setAttribute('Name', basename($containerPath)); $cfg->setAttribute('Target', $containerPath); $cfg->setAttribute('Mode', ($row['readOnly'] ?? false) ? 'ro' : 'rw'); $cfg->setAttribute('Type', 'Path'); $root->appendChild($cfg); } foreach (($body['env'] ?? []) as $row) { $key = (string) ($row['key'] ?? ''); if ($key === '') { continue; } $cfg = $doc->createElement('Config', (string) ($row['value'] ?? '')); $cfg->setAttribute('Name', $key); $cfg->setAttribute('Target', $key); $cfg->setAttribute('Type', 'Variable'); $root->appendChild($cfg); } if ($doc->save($templatesDir . '/' . $name . '.xml') === false) { podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500); } } /** * Imports a pasted/uploaded template XML (this plugin's own schema, or * a plain Unraid dockerMan template — both use the same / * shape). Validates it parses and has a usable before * writing it under our own templates dir with a sanitized filename. */ function template_import(string $templatesDir, string $xmlText): string { $xml = @simplexml_load_string($xmlText); if ($xml === false || $xml->getName() !== 'Container') { podman_json_error('Not a valid template XML (expected a root element)', 400); } $rawName = trim((string) $xml->Name); $name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName); if (!is_valid_template_name((string) $name)) { podman_json_error('Template XML has no usable ', 400); } if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) { podman_json_error("Could not create {$templatesDir}", 500); } if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) { podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500); } return (string) $name; } /** * Fetches a template XML from a user-supplied URL for "Import from URL". * Only plain http(s) with a hostname that resolves EXCLUSIVELY to public * addresses is allowed — checked with FILTER_FLAG_NO_PRIV_RANGE | * FILTER_FLAG_NO_RES_RANGE, which also covers loopback/link-local, not * just RFC1918. curl is then pinned to that exact validated IP via * CURLOPT_RESOLVE rather than letting it resolve the host itself again: * resolving once here and connecting separately would leave a window for * a DNS answer that changes between the check and the request (DNS * rebinding) to point curl at an internal address anyway. Redirects are * not followed for the same reason — a redirect target needs this same * validation, and silently trusting one would reopen the hole this whole * function exists to close. Response size is capped well above what any * real template XML needs. */ function template_fetch_url(string $url): string { $parts = parse_url($url); $scheme = strtolower((string) ($parts['scheme'] ?? '')); $host = (string) ($parts['host'] ?? ''); if (!in_array($scheme, ['http', 'https'], true) || $host === '') { podman_json_error('URL must be a plain http:// or https:// address', 400); } $port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80)); $ips = []; if (filter_var($host, FILTER_VALIDATE_IP) !== false) { $ips[] = $host; } else { foreach (@dns_get_record($host, DNS_A + DNS_AAAA) ?: [] as $rec) { $ip = $rec['type'] === 'AAAA' ? ($rec['ipv6'] ?? '') : ($rec['ip'] ?? ''); if ($ip !== '') { $ips[] = $ip; } } } if (empty($ips)) { podman_json_error("Could not resolve host '{$host}'", 400); } foreach ($ips as $ip) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { podman_json_error("Refusing to fetch from '{$host}': resolves to a private/reserved address ({$ip})", 400); } } $maxBytes = 512 * 1024; // a template XML is a few KB; this is generous headroom $received = ''; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RESOLVE => ["{$host}:{$port}:{$ips[0]}"], CURLOPT_RETURNTRANSFER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Accept: application/xml, text/xml, */*'], CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$received, $maxBytes) { $received .= $chunk; return strlen($received) > $maxBytes ? 0 : strlen($chunk); }, ]); curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $error = curl_error($ch); curl_close($ch); if ($status >= 300 && $status < 400) { podman_json_error('The URL returned a redirect — fetch its final target URL directly instead (redirects are not followed here, to keep this from becoming a way around the checks above).', 400); } if ($status !== 200) { podman_json_error('Fetching the URL failed (HTTP ' . $status . ($error !== '' ? ": {$error}" : '') . ')', 400); } if (trim($received) === '') { podman_json_error('The URL returned an empty response', 400); } return $received; } /** * Path of the cached CA app feed. Deliberately sys_get_temp_dir(), not * $bootDir — this is a large (~20MB), frequently-refreshed derived * artifact, not source-of-truth config, so it doesn't belong on the flash * drive (see ARCHITECTURE.md 4.1 on what belongs on /boot vs. not) and is * fine to just re-download after a reboot. */ function ca_feed_cache_path(): string { return sys_get_temp_dir() . '/podman-ca-appfeed.json'; } /** * Downloads the CA feed if the cache is missing or older than * CA_FEED_MAX_AGE_SECONDS, returning the path to a usable (possibly * stale) cache file. A failed download falls back to a stale cache * rather than failing the search outright — an out-of-date app list is * still far more useful than none, and the feed realistically doesn't * change meaningfully within a day anyway. */ function ca_feed_ensure_cached(): string { $path = ca_feed_cache_path(); if (is_file($path) && (time() - filemtime($path)) < CA_FEED_MAX_AGE_SECONDS) { return $path; } $tmpPath = $path . '.' . getmypid() . '.tmp'; $fh = fopen($tmpPath, 'wb'); if ($fh === false) { if (is_file($path)) { return $path; } podman_json_error('Could not write app feed cache', 500); } $ch = curl_init(CA_FEED_URL); curl_setopt_array($ch, [ CURLOPT_FILE => $fh, CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, ]); $ok = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); fclose($fh); if (!$ok || $status !== 200 || filesize($tmpPath) < 1000) { @unlink($tmpPath); if (is_file($path)) { return $path; } podman_json_error('Could not download the Community Applications feed', 502); } rename($tmpPath, $path); return $path; } /** * Searches the CA feed by name (and its ExtraSearchTerms, the same field * CA's own search matches against) and returns one page of just enough * per result to render a picker + import it — never the raw feed itself, * which is far too large to ship to the client for what is, per page, a * couple dozen entries. * * An empty query is "browse" mode instead of "search": ordered by * $sort — "newest" (default, by the feed's own FirstSeen timestamp — when * CA's feed first picked the template up) or "alpha". A non-empty query * always sorts alphabetically regardless of $sort: the feed's "downloads" * figure (the only other candidate) turns out to just be the underlying * Docker image's Docker Hub pull count (verified live: dozens of unrelated * templates that all happen to wrap the official nginx/postgres/redis * images share the exact same, enormous number), which would make search * results look ranked by relevance while actually just favoring whichever * match wraps the most-pulled base image. * * @return array{results: array>, total: int, page: int, pageSize: int, feedUpdatedAt: int} */ function ca_feed_search(string $query, int $page = 1, string $sort = 'newest'): array { $path = ca_feed_ensure_cached(); // Parsing ~20MB of JSON into PHP arrays needs more headroom than the // default limit on some setups; scoped to this request only. ini_set('memory_limit', '256M'); $data = json_decode((string) file_get_contents($path), true); $apps = is_array($data) ? ($data['applist'] ?? []) : []; $needle = mb_strtolower($query); $matches = []; foreach ($apps as $app) { $name = (string) ($app['Name'] ?? ''); $templateUrl = (string) ($app['TemplateURL'] ?? ''); if ($name === '' || $templateUrl === '') { continue; } // CA's feed lists real Unraid OS plugins (.plg installers — system // add-ons like GPU drivers, mover tuning, Unraid Connect) alongside // actual Docker app templates, both under the same "applist" — the // giveaway is a Repository ending in ".plg" instead of a Docker // image reference (verified against every one of the 268 entries // tagged with CA's own "Plugins" category — every single one had // exactly this). Podman only runs containers, so these would just // fail nonsensically if "installed" as one. if (str_ends_with(strtolower((string) ($app['Repository'] ?? '')), '.plg')) { continue; } if ($needle !== '') { $haystack = mb_strtolower($name . ' ' . (string) ($app['ExtraSearchTerms'] ?? '')); if (mb_strpos($haystack, $needle) === false) { continue; } } // CA's own overview text uses forum-style bbcode ([b]/[br]/[li]/…) // rather than plain text or HTML — strip the tags for a clean, // short plain-text excerpt instead of showing the raw markup. $overview = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace('/\[[^\]]*\]/', ' ', (string) ($app['Overview'] ?? '')))); if (mb_strlen($overview) > 140) { $overview = mb_substr($overview, 0, 137) . '…'; } $matches[] = [ 'name' => $name, 'image' => (string) ($app['Repository'] ?? ''), 'icon' => (string) ($app['Icon'] ?? ''), 'overview' => $overview, 'templateUrl' => $templateUrl, 'firstSeen' => (int) ($app['FirstSeen'] ?? 0), ]; } if ($needle !== '' || $sort === 'alpha') { usort($matches, static fn($a, $b) => strcasecmp($a['name'], $b['name'])); } else { usort($matches, static fn($a, $b) => $b['firstSeen'] <=> $a['firstSeen']); } $total = count($matches); $pageSize = CA_FEED_PAGE_SIZE; $lastPage = max(1, (int) ceil($total / $pageSize)); $page = min(max(1, $page), $lastPage); $slice = array_slice($matches, ($page - 1) * $pageSize, $pageSize); foreach ($slice as &$m) { unset($m['firstSeen']); } unset($m); return [ 'results' => $slice, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize, 'feedUpdatedAt' => filemtime($path), ]; } /** * Unraid's own Docker Manager plugin stores every template a user has * ever saved/customized under templates-user/, plus a local cache of * Community Applications' own catalog under templates-community/ (may * be empty/absent if CA was never installed) — both in the same * / XML shape this plugin reads. Read-only: this * plugin never writes into dockerMan's own directories. * * @return array */ function dockerman_template_dirs(): array { return array_values(array_filter([ '/boot/config/plugins/dockerMan/templates-user', '/boot/config/plugins/dockerMan/templates-community', ], 'is_dir')); } /** @return array> */ function local_dockerman_templates_list(): array { $out = []; foreach (dockerman_template_dirs() as $dir) { foreach (scandir($dir) ?: [] as $entry) { if (!str_ends_with($entry, '.xml')) { continue; } $xml = @simplexml_load_file($dir . '/' . $entry); if ($xml === false) { continue; // skip anything that doesn't parse rather than failing the whole list } $out[] = [ 'file' => $entry, 'name' => (string) $xml->Name, 'image' => (string) $xml->Repository, 'icon' => (string) $xml->Icon, ]; } } usort($out, static fn($a, $b) => strcmp($a['name'], $b['name'])); return $out; } /** * Reads one local dockerMan template's raw XML by filename. $file comes * straight from client input — reduced to its basename and required to * actually exist in one of dockerman_template_dirs() (the same list * list_local scanned), never treated as an arbitrary path. */ function local_dockerman_template_read(string $file): string { $file = basename($file); if (!str_ends_with($file, '.xml')) { podman_json_error('Invalid template file', 400); } foreach (dockerman_template_dirs() as $dir) { $path = $dir . '/' . $file; if (is_file($path)) { $content = file_get_contents($path); if ($content !== false) { return $content; } } } podman_json_error("Local template '{$file}' not found", 404); }