Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing

- Package #9-11: catatonit (pod infra init), nftables (netavark firewall
  backend), docker-compose (external compose provider for `podman compose`)
  — all vendored prebuilt binaries, versions.env pinned, propagated through
  build-packages.sh/release.sh/podman.plg/verify+update-packages.sh.
- Fix WebUI: every POST action was silently failing (empty response body)
  because Unraid's own CSRF protection was never satisfied — app.js now
  sends the page's csrf_token as X-CSRF-Token.
- Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but
  /images/pull actually streams newline-delimited JSON — every successful
  pull was throwing "Expected a JSON object/array response".
- Fix WebUI: compose.php's up/down status detection had the same
  single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was
  corrupting the parse.
- Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a
  redeployed JS/CSS fix isn't served stale from browser cache.
- Add a reusable modal dialog (app.js openFormModal) replacing
  prompt()/alert() for New Volume/Network/Pull Image.
- Add host-path (bind-mount) support when creating a named volume.
- Add Create Container (image, name, network mode incl. custom networks,
  ports, volumes, env, restart policy, privileged, start-after-create),
  auto-pulling the image on first use since /containers/create doesn't.

All fixes verified live against a real podman system service and, where
reachable, via the actual WebUI over the real socket — not just unit-level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 11:51:17 +00:00
co-authored by Claude Sonnet 5
parent 5944ddf722
commit 5b47b4cc0a
33 changed files with 1075 additions and 87 deletions
+77 -4
View File
@@ -101,6 +101,23 @@ final class PodmanClient
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
}
/**
* POST /containers/create — takes a libpod SpecGenerator body. Field
* names/shapes below (image, name, command, env, portmappings,
* netns, networks, mounts, volumes, restart_policy, privileged) were
* verified live against a real podman system service, not assumed
* from docs — see ajax/containers.php's create action, which builds
* this array from the WebUI's Create Container form.
*
* @param array<string,mixed> $spec
* @return string the new container's ID
*/
public function createContainer(array $spec): string
{
$result = $this->request('POST', '/containers/create', [], false, $spec);
return (string) ($result['Id'] ?? '');
}
public function startContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
@@ -220,10 +237,54 @@ final class PodmanClient
return $this->request('GET', '/images/json');
}
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
/**
* POST /images/pull — pulls (or updates) an image by reference, e.g.
* "docker.io/library/postgres:16".
*
* Unlike virtually every other libpod endpoint, a successful pull's
* response body is NOT one JSON document — it's newline-delimited
* JSON, one progress object per line (verified live:
* `{"status":"pulling","stream":"..."}` repeated, then a final
* `{"status":"success","images":[...],"id":"..."}` line). Feeding
* that whole blob through the normal single-document request() here
* made json_decode() fail on every successful pull with "Expected a
* JSON object/array response from /images/pull" — found by
* live-testing a real pull through the WebUI's Images panel, not
* from reading libpod's docs. An error that happens before any
* image data is found (e.g. unknown reference) is unaffected: libpod
* sends that as a normal single-JSON-object 4xx response, which
* requestRaw()/request()'s existing status>=400 handling already
* covers correctly.
*/
public function pullImage(string $reference): array
{
return $this->request('POST', '/images/pull', ['reference' => $reference]);
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
$last = null;
foreach (explode("\n", trim($raw)) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (!is_array($decoded)) {
continue;
}
// A mid-stream error (pull started, then failed — e.g. the
// connection dropped partway through a layer) is reported as
// an {"error": "..."} line rather than an HTTP error status,
// since headers/status are already committed by the time
// libpod knows the pull failed.
if (isset($decoded['error'])) {
throw new PodmanApiException((string) $decoded['error'], 502);
}
$last = $decoded;
}
if ($last === null) {
throw new PodmanApiException('Expected a JSON object/array response from /images/pull');
}
return $last;
}
public function removeImage(string $id, bool $force = false): void
@@ -240,9 +301,21 @@ final class PodmanClient
return $this->request('GET', '/volumes/json');
}
public function createVolume(string $name, string $driver = 'local'): array
/**
* $hostPath, if given, binds the volume directly to an existing host
* directory instead of a podman-managed one — the local driver's
* `type=none,o=bind,device=<path>` option trio (same mechanism
* `podman volume create --opt type=none --opt o=bind --opt device=...`
* uses on the CLI). Verified live: a container mounting such a volume
* reads/writes the host path directly, not an internal copy.
*/
public function createVolume(string $name, string $driver = 'local', ?string $hostPath = null): array
{
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
$body = ['Name' => $name, 'Driver' => $driver];
if ($hostPath !== null && $hostPath !== '') {
$body['Options'] = ['type' => 'none', 'device' => $hostPath, 'o' => 'bind'];
}
return $this->request('POST', '/volumes/create', [], false, $body);
}
public function removeVolume(string $name, bool $force = false): void