normalized array of containers for the table view * inspect GET (&id=...) -> raw inspect JSON, for a detail dialog * start POST {"id": "..."} * stop POST {"id": "...", "timeout": 10} * restart POST {"id": "...", "timeout": 10} * remove POST {"id": "...", "force": false} * pause POST {"id": "..."} * unpause POST {"id": "..."} * kill POST {"id": "...", "signal": "SIGKILL"} * rename POST {"id": "...", "name": "..."} * logs GET (&id=...&tail=200) -> plain text * list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle * check_updates GET -> {"": {"updateAvailable": bool, "error": "..."?}} for every image currently in use * create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"", * "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode), * "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}], * "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}], * "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "", * "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"], * "privileged": false, "startAfterCreate": true} */ declare(strict_types=1); require __DIR__ . '/../include/bootstrap.php'; $action = $_GET['action'] ?? ''; switch ($action) { case 'list': podman_json_response(containers_list($client)); break; case 'inspect': $id = (string) ($_GET['id'] ?? ''); if ($id === '') { podman_json_error('Missing id', 400); } podman_json_response($client->inspectContainer($id)); break; case 'logs': $id = (string) ($_GET['id'] ?? ''); if ($id === '') { podman_json_error('Missing id', 400); } $tail = (int) ($_GET['tail'] ?? 200); podman_json_response(['text' => $client->containerLogs($id, $tail)]); break; case 'start': $body = podman_read_json_body(); $client->startContainer(require_id($body)); podman_json_response(['status' => 'started']); break; case 'stop': $body = podman_read_json_body(); $client->stopContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds)); podman_json_response(['status' => 'stopped']); break; case 'restart': $body = podman_read_json_body(); $client->restartContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds)); podman_json_response(['status' => 'restarted']); break; case 'remove': $body = podman_read_json_body(); $client->removeContainer(require_id($body), (bool) ($body['force'] ?? false)); podman_json_response(['status' => 'removed']); break; case 'pause': $body = podman_read_json_body(); $client->pauseContainer(require_id($body)); podman_json_response(['status' => 'paused']); break; case 'unpause': $body = podman_read_json_body(); $client->unpauseContainer(require_id($body)); podman_json_response(['status' => 'unpaused']); break; case 'kill': $body = podman_read_json_body(); $client->killContainer(require_id($body), (string) ($body['signal'] ?? 'SIGKILL')); podman_json_response(['status' => 'killed']); break; case 'rename': $body = podman_read_json_body(); $newName = trim((string) ($body['name'] ?? '')); if ($newName === '') { podman_json_error('Missing name in request body', 400); } $client->renameContainer(require_id($body), $newName); podman_json_response(['status' => 'renamed']); break; case 'list_gpus': podman_json_response(gpu_list()); break; case 'check_updates': podman_json_response(check_image_updates($client)); break; case 'create': $body = podman_read_json_body(); $image = trim((string) ($body['image'] ?? '')); if ($image === '') { podman_json_error('Missing image in request body', 400); } $spec = build_container_spec($image, $body); // Unlike `podman run`, /containers/create does NOT auto-pull a // missing image — it fails outright with a 404 "no such image" // (found by live-testing the Create Container form against a // freshly-typed image reference that wasn't pulled yet). Retry // once after an explicit pull rather than always pulling // up-front, so re-creating with an image the user already has // stays fast and offline-friendly. try { $id = $client->createContainer($spec); } catch (PodmanApiException $e) { if ($e->httpStatus !== 404) { throw $e; } $client->pullImage($image); $id = $client->createContainer($spec); } if ($body['startAfterCreate'] ?? true) { $client->startContainer($id); } podman_json_response(['id' => $id, 'status' => ($body['startAfterCreate'] ?? true) ? 'started' : 'created']); break; default: podman_json_error("Unknown action '{$action}'", 400); } /** * Checks every image currently backing a (non-infra) container against its * origin registry — see RegistryClient for how, and why this isn't a * podman/libpod feature at all. Deduplicated per unique image reference * first (several containers commonly share the same image), so a host * with e.g. five containers all on the same base image only makes one * real registry request for it, not five. * * @return array> keyed by image reference */ function check_image_updates(PodmanClient $client): array { $digestByImageId = []; foreach ($client->listImages() as $img) { $digestByImageId[(string) ($img['Id'] ?? '')] = (string) ($img['Digest'] ?? ''); } $localDigestByRef = []; foreach ($client->listContainers(true) as $c) { if ($c['IsInfra'] ?? false) { continue; } $ref = (string) ($c['Image'] ?? ''); $imageId = (string) ($c['ImageID'] ?? ''); if ($ref === '' || !isset($digestByImageId[$imageId])) { continue; } $localDigestByRef[$ref] = $digestByImageId[$imageId]; } $out = []; foreach ($localDigestByRef as $ref => $localDigest) { $out[$ref] = $localDigest === '' ? ['error' => 'No local digest recorded for this image.'] : RegistryClient::checkForUpdate($ref, $localDigest); } return $out; } /** * Detects AMD/Intel GPUs via /dev/dri + sysfs — NOT via any podman/libpod * API (libpod has no GPU inventory endpoint; this is plain host hardware * detection). NVIDIA is deliberately excluded: it needs the separate * nvidia-container-toolkit runtime, not a plain /dev/dri device passthrough, * so listing it here would offer a checkbox that doesn't actually work. * Verified live: card/render pairs from the same GPU share a "device" * symlink target under /sys/class/drm, which is how they're grouped below; * vendor 0x1002 = AMD, 0x8086 = Intel (PCI SIG IDs). * * @return array> */ function gpu_list(): array { if (!is_dir('/sys/class/drm')) { return []; } $byDevice = []; foreach (scandir('/sys/class/drm') ?: [] as $entry) { if (!preg_match('/^(card\d+|renderD\d+)$/', $entry)) { continue; } $devicePath = "/sys/class/drm/{$entry}/device"; $target = @readlink($devicePath); if ($target === false) { continue; } $vendorFile = "{$devicePath}/vendor"; if (!is_file($vendorFile)) { continue; } $vendorId = trim((string) @file_get_contents($vendorFile)); $byDevice[$target]['vendorId'] ??= $vendorId; $byDevice[$target][str_starts_with($entry, 'card') ? 'card' : 'render'] = "/dev/dri/{$entry}"; } $vendorNames = ['0x1002' => 'AMD', '0x8086' => 'Intel', '0x10de' => 'NVIDIA']; $out = []; foreach ($byDevice as $group) { $vendorId = $group['vendorId'] ?? ''; $vendorName = $vendorNames[$vendorId] ?? $vendorId; // NVIDIA needs the nvidia-container-toolkit runtime, not a plain // /dev/dri passthrough — excluded so the checkbox we offer always // actually works (see function comment). if ($vendorName === 'NVIDIA' || !isset($group['render'])) { continue; } $out[] = [ 'vendor' => $vendorName, 'card' => $group['card'] ?? null, 'render' => $group['render'], ]; } return $out; } /** * Builds a libpod SpecGenerator body (POST /containers/create) from the * WebUI's Create Container form fields. Field names/shapes here * (portmappings, netns, networks, mounts, volumes, restart_policy) were * verified live against a real podman system service — see * PodmanClient::createContainer()'s header comment. * * @param array $body * @return array */ function build_container_spec(string $image, array $body): array { $spec = ['image' => $image]; $name = trim((string) ($body['name'] ?? '')); if ($name !== '') { // Same character set podman itself enforces (define.NameRegex in // libpod) — validated here too so a space/invalid character gets // a clear message instead of podman's raw "running container // create option: names must match ...: invalid argument" (found // live: a template-derived container name with a space in it hit // exactly this). if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) { podman_json_error( "Container name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" . preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.', 400 ); } $spec['name'] = $name; } $env = []; foreach (($body['env'] ?? []) as $row) { $key = trim((string) ($row['key'] ?? '')); if ($key !== '') { $env[$key] = (string) ($row['value'] ?? ''); } } if ($env !== []) { $spec['env'] = $env; } $ports = []; foreach (($body['ports'] ?? []) as $row) { $hostPort = (int) ($row['hostPort'] ?? 0); $containerPort = (int) ($row['containerPort'] ?? 0); if ($hostPort > 0 && $containerPort > 0) { $ports[] = [ 'host_ip' => '', 'host_port' => $hostPort, 'container_port' => $containerPort, 'protocol' => (string) ($row['protocol'] ?? 'tcp'), ]; } } if ($ports !== []) { $spec['portmappings'] = $ports; } $mounts = []; $volumes = []; foreach (($body['volumes'] ?? []) as $row) { $source = trim((string) ($row['source'] ?? '')); $containerPath = trim((string) ($row['containerPath'] ?? '')); if ($source === '' || $containerPath === '') { continue; } if (($row['kind'] ?? 'named') === 'path') { $mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => ['rbind']]; } else { $volumes[] = ['name' => $source, 'dest' => $containerPath]; } } if ($mounts !== []) { $spec['mounts'] = $mounts; } if ($volumes !== []) { $spec['volumes'] = $volumes; } // "bridge"/"host"/"none" are podman's own reserved netns modes; any // other value is an existing custom podman network's name, attached // via the "networks" field instead (verified live: passing a // network name through "networks" attaches it without needing an // explicit netns mode at all). $networkMode = (string) ($body['networkMode'] ?? 'bridge'); if (in_array($networkMode, ['bridge', 'host', 'none'], true)) { $spec['netns'] = ['nsmode' => $networkMode]; } elseif ($networkMode !== '') { // A static IP only makes sense on a custom (typically macvlan) // network — verified live that "networks":{"":{"static_ips": // [...]}} assigns it, same as podman itself does for --ip. Basic // IPv4-shape validation only (not full RFC-correctness) — this // goes straight into a create request against the local podman // socket, not anywhere it could reach untrusted input otherwise. $staticIp = trim((string) ($body['staticIp'] ?? '')); if ($staticIp !== '') { if (preg_match('/^(\d{1,3}\.){3}\d{1,3}$/', $staticIp) !== 1) { podman_json_error("Static IP (\"{$staticIp}\") doesn't look like a valid IPv4 address.", 400); } $spec['networks'] = [$networkMode => ['static_ips' => [$staticIp]]]; } else { $spec['networks'] = [$networkMode => new \stdClass()]; } } if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') { $spec['restart_policy'] = (string) $body['restartPolicy']; } if ($body['privileged'] ?? false) { $spec['privileged'] = true; } $devices = []; foreach (($body['gpuDevices'] ?? []) as $path) { // Only ever pass through paths matching the exact shape gpu_list() // itself reports — the client only ever gets those as checkbox // values, but this is the boundary where a tampered/malicious // request body gets rejected rather than handing arbitrary host // device paths (e.g. "/dev/sda") straight into the container spec. if (is_string($path) && preg_match('#^/dev/dri/(card|renderD)\d+$#', $path) === 1) { $devices[] = ['path' => $path]; } } if ($devices !== []) { $spec['devices'] = $devices; } $pod = trim((string) ($body['pod'] ?? '')); if ($pod !== '') { // "pod" joins an existing pod's shared network namespace — verified // live that it can be sent alongside "netns" above without // conflict (podman just defers to the pod's namespace). $spec['pod'] = $pod; } return $spec; } /** @param array $body */ function require_id(array $body): string { $id = (string) ($body['id'] ?? ''); if ($id === '') { podman_json_error('Missing id in request body', 400); } return $id; } /** * Normalizes libpod's /containers/json entries into exactly what the * Containers table (javascript/containers.js) renders — keeping this * shaping logic server-side means the frontend never has to know libpod's * raw field names/quirks (e.g. Names is an array, State vs Status, etc). * * @return array> */ function containers_list(PodmanClient $client): array { $raw = $client->listContainers(true); $out = []; foreach ($raw as $c) { // Every pod has a hidden "infra" container managing its shared // network namespace — not something a user creates or can // meaningfully stop/remove on its own (found live: it always // shows "running" with no independent lifecycle, so Containers // panel gets a permanently un-removable row once any pod exists; // it already appears as its own row in the Pods panel). See // ajax/pods.php for actual pod lifecycle management. if ($c['IsInfra'] ?? false) { continue; } $names = $c['Names'] ?? []; $name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? '')); $ports = []; foreach (($c['Ports'] ?? []) as $p) { if (isset($p['host_port'], $p['container_port'])) { $ports[] = "{$p['host_port']}:{$p['container_port']}/" . ($p['protocol'] ?? 'tcp'); } elseif (isset($p['container_port'])) { $ports[] = "{$p['container_port']}/" . ($p['protocol'] ?? 'tcp'); } } $startedAt = podman_parse_time($c['StartedAt'] ?? null); $state = strtolower((string) ($c['State'] ?? 'unknown')); // One extra local-socket round trip per running container (~15ms // each, verified live — negligible for a home host's container // count). Best-effort: a container that stops between the list // call above and this one shouldn't blank out the whole table. $cpuPercent = null; $memUsageBytes = null; $memLimitBytes = null; if ($state === 'running') { try { $stats = $client->containerStats((string) ($c['Id'] ?? '')); $cpuPercent = isset($stats['cpu_stats']['cpu']) ? round((float) $stats['cpu_stats']['cpu'], 1) : null; $memUsageBytes = isset($stats['memory_stats']['usage']) ? (int) $stats['memory_stats']['usage'] : null; $memLimitBytes = isset($stats['memory_stats']['limit']) ? (int) $stats['memory_stats']['limit'] : null; } catch (PodmanApiException $e) { // leave stats null } } $out[] = [ 'id' => (string) ($c['Id'] ?? ''), 'shortId' => podman_short_id((string) ($c['Id'] ?? '')), 'name' => $name, 'image' => (string) ($c['Image'] ?? ''), 'state' => $state, 'status' => (string) ($c['Status'] ?? ''), 'health' => $c['Health']['Status'] ?? null, 'ports' => $ports, 'pod' => $c['Pod'] ?? null, 'podName' => $c['PodName'] ?? null, 'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null, 'createdAt' => podman_parse_time($c['Created'] ?? null), 'cpuPercent' => $cpuPercent, 'memUsageBytes' => $memUsageBytes, 'memLimitBytes' => $memLimitBytes, ]; } usort($out, static fn($a, $b) => strcmp($a['name'], $b['name'])); return $out; }