Create Container form: - GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA excluded since it needs a different runtime) - device paths strictly validated server-side against the host's own detected list. - Macvlan network support: selecting a macvlan network reveals a static IP field and hides port mappings (meaningless once the container has its own LAN address), matching Unraid Docker Manager's "Custom: br0" behavior. Networks panel gained a matching macvlan network-creation flow, with the parent-interface dropdown read from Unraid's own network.cfg so it lists exactly what Docker Manager itself offers. Containers panel: - Edit: reopens the create form pre-filled from the container's current config (image/ports/volumes/env/network/restart policy/GPU/static IP); saving stops+removes the old container and recreates it under the same settings, since podman/Docker have no in-place "modify" API for most of this. - Update: same stop/remove/recreate flow, but pulls the current image first. "Check for Updates" compares each in-use image's local digest against its origin registry (Docker Hub/GHCR/self-hosted registries all verified live) with no podman-side feature backing it - implemented via the registry's own HTTP API. A small log-modal shows progress for both actions instead of a silent wait. - Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted real image pulls/container creates mid-request; bumped to 600s (nginx already allows up to 640s for this plugin's requests). Images panel: - "Prune unused" (removes every image with zero containers referencing it, not just dangling ones - confirmation copy says so explicitly since this is more aggressive than it sounds) and per-image "Tag". Also several real UI bugs found via live screenshots: unused-image prune having no visible effect until reloaded, table action-button columns drifting row to row (a bare "display:flex" on a <td> was fighting the table layout algorithm), Templates category badges dumping raw multi-tag strings from real Unraid templates, and low-contrast search/filter controls that were nearly invisible against the card background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
6.2 KiB
PHP
174 lines
6.2 KiB
PHP
<?php
|
|
/**
|
|
* ajax/networks.php
|
|
*
|
|
* Backs the Networks panel. See docs/ARCHITECTURE.md section 8 for why
|
|
* networks created here are netavark-backed and deliberately isolated
|
|
* from Docker's own docker0/custom-network space.
|
|
*
|
|
* Actions (?action=...):
|
|
* list GET -> normalized network list with subnet/gateway/usage
|
|
* list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent
|
|
* create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
|
|
* "gateway": "...", "parentInterface": "br0"}
|
|
* remove POST {"name": "...", "force": false}
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__ . '/../include/bootstrap.php';
|
|
|
|
$action = $_GET['action'] ?? '';
|
|
|
|
switch ($action) {
|
|
case 'list':
|
|
podman_json_response(networks_list($client));
|
|
break;
|
|
|
|
case 'list_parent_interfaces':
|
|
podman_json_response(macvlan_parent_interfaces());
|
|
break;
|
|
|
|
case 'create':
|
|
$body = podman_read_json_body();
|
|
$name = (string) ($body['name'] ?? '');
|
|
if ($name === '') {
|
|
podman_json_error('Missing name in request body', 400);
|
|
}
|
|
$driver = (string) ($body['driver'] ?? 'bridge');
|
|
|
|
$parentInterface = null;
|
|
if ($driver === 'macvlan') {
|
|
$parentInterface = (string) ($body['parentInterface'] ?? '');
|
|
// Only ever accept an interface this same host reported via
|
|
// macvlan_parent_interfaces() — the boundary preventing a
|
|
// tampered request from asking podman to attach to an
|
|
// arbitrary/unexpected interface name.
|
|
$known = array_column(macvlan_parent_interfaces(), 'interface');
|
|
if (!in_array($parentInterface, $known, true)) {
|
|
podman_json_error('Unknown parent interface — refresh the page and try again.', 400);
|
|
}
|
|
if (!isset($body['subnet']) || (string) $body['subnet'] === '') {
|
|
podman_json_error('Subnet is required for a macvlan network.', 400);
|
|
}
|
|
}
|
|
|
|
podman_json_response($client->createNetwork(
|
|
$name,
|
|
$driver,
|
|
isset($body['subnet']) ? (string) $body['subnet'] : null,
|
|
isset($body['gateway']) ? (string) $body['gateway'] : null,
|
|
$parentInterface
|
|
));
|
|
break;
|
|
|
|
case 'remove':
|
|
$body = podman_read_json_body();
|
|
$name = (string) ($body['name'] ?? '');
|
|
if ($name === '') {
|
|
podman_json_error('Missing name in request body', 400);
|
|
}
|
|
// podman0, the default bridge, refuses removal API-side — no
|
|
// special-casing needed here, PodmanApiException surfaces podman's
|
|
// own rejection message as-is.
|
|
$client->removeNetwork($name, (bool) ($body['force'] ?? false));
|
|
podman_json_response(['status' => 'removed']);
|
|
break;
|
|
|
|
default:
|
|
podman_json_error("Unknown action '{$action}'", 400);
|
|
}
|
|
|
|
/**
|
|
* Reads Unraid's own /boot/config/network.cfg (BRNAME[i]/VLANID[i,j]/
|
|
* DESCRIPTION[i,j]) to list the same host bridge + VLAN interfaces
|
|
* Unraid's own Docker Manager offers as "Custom: br0" / "Custom: br0.3
|
|
* (VPN)" network types — reusing Unraid's own config instead of guessing
|
|
* from raw `ip link` output, so the list always matches what Docker
|
|
* Manager shows for the same host. Verified live: this host's
|
|
* network.cfg has BRNAME[0]="br0" and VLANID[0,1]="3"/DESCRIPTION[0,1]=
|
|
* "VPN", producing "br0" and "br0.3 (VPN)" — matching the interface
|
|
* names shown in that other plugin's own network-type dropdown exactly.
|
|
* Each candidate is confirmed to actually exist in /sys/class/net before
|
|
* being offered, in case network.cfg mentions an interface that isn't
|
|
* currently up.
|
|
*
|
|
* @return array<int,array{interface:string,label:string}>
|
|
*/
|
|
function macvlan_parent_interfaces(): array
|
|
{
|
|
$cfgFile = '/boot/config/network.cfg';
|
|
if (!is_file($cfgFile)) {
|
|
return [];
|
|
}
|
|
|
|
$cfg = [];
|
|
foreach (file($cfgFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
|
if (preg_match('/^([A-Z0-9_]+)\[(\d+)(?:,(\d+))?\]="([^"]*)"$/', $line, $m) !== 1) {
|
|
continue;
|
|
}
|
|
[, $key, $i, $j, $value] = $m + [3 => ''];
|
|
$i = (int) $i;
|
|
if ($j === '') {
|
|
$cfg[$key][$i] = $value;
|
|
} else {
|
|
$cfg[$key][$i][(int) $j] = $value;
|
|
}
|
|
}
|
|
|
|
$out = [];
|
|
foreach (($cfg['BRNAME'] ?? []) as $i => $brname) {
|
|
if (!is_string($brname) || $brname === '' || !is_dir("/sys/class/net/{$brname}")) {
|
|
continue;
|
|
}
|
|
$out[] = ['interface' => $brname, 'label' => $brname];
|
|
foreach (($cfg['VLANID'][$i] ?? []) as $j => $vlanId) {
|
|
$iface = "{$brname}.{$vlanId}";
|
|
if (!is_dir("/sys/class/net/{$iface}")) {
|
|
continue;
|
|
}
|
|
$desc = $cfg['DESCRIPTION'][$i][$j] ?? '';
|
|
$out[] = ['interface' => $iface, 'label' => $iface . ($desc !== '' ? " ({$desc})" : '')];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/** @return array<int,array<string,mixed>> */
|
|
function networks_list(PodmanClient $client): array
|
|
{
|
|
$raw = $client->listNetworks();
|
|
|
|
$usageCounts = [];
|
|
foreach ($client->listContainers(true) as $c) {
|
|
$nets = $c['Networks'] ?? [];
|
|
if (is_array($nets)) {
|
|
foreach ($nets as $netName) {
|
|
if (is_string($netName)) {
|
|
$usageCounts[$netName] = ($usageCounts[$netName] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($raw as $n) {
|
|
$name = (string) ($n['name'] ?? '');
|
|
$subnets = $n['subnets'] ?? [];
|
|
$subnet = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['subnet'] ?? '') : '';
|
|
$gateway = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['gateway'] ?? '') : '';
|
|
|
|
$out[] = [
|
|
'name' => $name,
|
|
'driver' => (string) ($n['driver'] ?? 'bridge'),
|
|
'subnet' => $subnet,
|
|
'gateway' => $gateway,
|
|
'isDefault' => $name === 'podman',
|
|
'containers' => $usageCounts[$name] ?? 0,
|
|
];
|
|
}
|
|
|
|
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
|
return $out;
|
|
}
|