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 */ 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> */ 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; }