This is an automated email from the ASF dual-hosted git repository.

weizhouapache pushed a commit to branch proxmox-sdn
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git

commit 0c7c7098d6109e4ee1575e9e17ed4832b8381a35
Author: Wei Zhou <[email protected]>
AuthorDate: Wed Apr 29 22:26:04 2026 +0200

    Network extension: Proxmox SDN
---
 Proxmox-SDN/README.md      |  656 +++++++++++++++
 Proxmox-SDN/proxmox-sdn.sh | 1956 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 2612 insertions(+)

diff --git a/Proxmox-SDN/README.md b/Proxmox-SDN/README.md
new file mode 100644
index 0000000..b1c3cc1
--- /dev/null
+++ b/Proxmox-SDN/README.md
@@ -0,0 +1,656 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+# Proxmox SDN Network Extension for Apache CloudStack
+
+This directory contains the **Proxmox SDN** `NetworkOrchestrator` extension —
+a CloudStack plugin that delegates network lifecycle operations to the
+[Proxmox VE SDN](https://pve.proxmox.com/pve-docs/chapter-pvesdn.html) API.
+
+The extension maps CloudStack network concepts (isolated networks, VPCs,
+VLANs, source NAT, static NAT, port forwarding, firewall rules) onto
+Proxmox SDN primitives (zones, VNets, subnets) and manages
+NAT/firewall rules on the Proxmox gateway node via iptables.
+
+This extension works alongside the **Proxmox.sh** VM orchestrator
+extension: proxmox-sdn.sh creates VLAN networks (VNets) on Proxmox SDN,
+while Proxmox.sh creates VMs and attaches NICs to those VNet bridges.
+VM metadata (userdata, passwords, SSH keys, network config) is delivered
+via a cloud-init ISO attached to the VM.
+
+The extension is implemented as a shell script executed by
+`NetworkExtensionElement` — **no separate plugin JAR is required**.
+
+---
+
+## Table of Contents
+
+1. [Architecture](#architecture)
+2. [Proxmox SDN Concepts](#proxmox-sdn-concepts)
+3. [CloudStack ↔ Proxmox SDN Mapping](#cloudstack--proxmox-sdn-mapping)
+4. [Directory Contents](#directory-contents)
+5. [Prerequisites](#prerequisites)
+6. [Installation](#installation)
+7. [Step-by-step API Setup](#step-by-step-api-setup)
+   - [1. Create the Extension](#1-create-the-extension)
+   - [2. Register with Physical Network](#2-register-with-physical-network)
+   - [3. Create Network Offering](#3-create-network-offering)
+   - [4. Create an Isolated Network](#4-create-an-isolated-network)
+   - [5. VPC Setup](#5-vpc-setup)
+   - [6. Cleanup](#6-cleanup)
+8. [Extension Details Reference](#extension-details-reference)
+9. [Supported Commands](#supported-commands)
+10. [Custom Actions](#custom-actions)
+11. [Cloud-Init Integration](#cloud-init-integration)
+12. [Integration with Proxmox VM 
Extension](#integration-with-proxmox-vm-extension)
+13. [Proxmox SDN Zone Types](#proxmox-sdn-zone-types)
+14. [Limitations and Future Work](#limitations-and-future-work)
+
+---
+
+## Architecture
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│  CloudStack Management Server                                │
+│                                                              │
+│  NetworkExtensionElement.java                                │
+│      │ executes (path resolved from Extension record)        │
+│      ▼                                                       │
+│  /usr/share/cloudstack-management/extensions/<ext-name>/     │
+│      proxmox-sdn.sh                                          │
+│          │                                                   │
+│          │ 1. Proxmox REST API (HTTPS :8006)                 │
+│          │    → /api2/json/cluster/sdn/zones                 │
+│          │    → /api2/json/cluster/sdn/vnets                 │
+│          │    → /api2/json/cluster/sdn/vnets/<vnet>/subnets  │
+│          │    → PUT /api2/json/cluster/sdn  (apply changes)  │
+│          │                                                   │
+│          │ 2. SSH to gateway node (for iptables NAT/FW)      │
+│          ▼                                                   │
+└──────────┬───────────────────────────────────────────────────┘
+           │
+           ▼
+┌──────────────────────────────────────────────────────────────┐
+│  Proxmox VE Cluster                                          │
+│                                                              │
+│  SDN Layer (managed via API)                                 │
+│  ┌────────────────────────────────────────────────────┐      │
+│  │  Zone: cszone (type: vlan, bridge: vmbr0)          │      │
+│  │                                                    │      │
+│  │  ┌──────────────────────────────────────────┐      │      │
+│  │  │  VNet: cs42 (tag: 100)                   │      │      │
+│  │  │    Subnet: 10.0.1.0/24                   │      │      │
+│  │  │      Gateway: 10.0.1.1                   │      │      │
+│  │  │      SNAT: enabled                       │      │      │
+│  │  └──────────────────────────────────────────┘      │      │
+│  │                                                    │      │
+│  │  ┌──────────────────────────────────────────┐      │      │
+│  │  │  VNet: cs55 (tag: 200)                   │      │      │
+│  │  │    Subnet: 10.0.2.0/24                   │      │      │
+│  │  │      Gateway: 10.0.2.1                   │      │      │
+│  │  └──────────────────────────────────────────┘      │      │
+│  └────────────────────────────────────────────────────┘      │
+│                                                              │
+│  NAT/Firewall (iptables on gateway node)                     │
+│  ┌────────────────────────────────────────────────────┐      │
+│  │  Chain CS_PVE_SDN_42_PR (nat PREROUTING)           │      │
+│  │    DNAT rules for static NAT / port forwarding     │      │
+│  │                                                    │      │
+│  │  Chain CS_PVE_SDN_42_POST (nat POSTROUTING)        │      │
+│  │    SNAT rules for source NAT                       │      │
+│  │                                                    │      │
+│  │  Chain CS_PVE_SDN_FWD_42 (filter FORWARD)          │      │
+│  │    Forwarding rules for guest traffic              │      │
+│  └────────────────────────────────────────────────────┘      │
+└──────────────────────────────────────────────────────────────┘
+```
+
+**Key design principles:**
+
+* The `proxmox-sdn.sh` script runs entirely on the **management server**.
+  Network topology (zones, VNets, subnets) is managed via the Proxmox
+  REST API — no SSH is needed for SDN operations.
+
+* NAT and firewall rules are managed via iptables on a designated
+  **gateway node** in the Proxmox cluster, accessed via SSH or the
+  Proxmox API execute endpoint.
+
+* Proxmox SDN handles VLAN trunking and DHCP (via dnsmasq on nodes)
+  natively.
+
+* VMs connect to CloudStack networks via Proxmox VNet bridges that
+  are automatically created on all cluster nodes when SDN changes
+  are applied.
+
+---
+
+## Proxmox SDN Concepts
+
+Refer to the [Proxmox SDN 
documentation](https://pve.proxmox.com/pve-docs/chapter-pvesdn.html)
+for full details.
+
+### Zones
+
+A **Zone** defines a transport domain — the method by which VNets
+are isolated from each other:
+
+| Zone Type | Isolation Method | Use Case |
+|-----------|-----------------|----------|
+| **Simple** | No isolation (all VNets share the same bridge) | Testing, flat 
networks |
+| **VLAN** | 802.1Q VLAN tags on a physical bridge | Traditional L2 isolation |
+| **QinQ** | Stacked VLANs (802.1ad) | Service provider environments |
+
+### VNets
+
+A **VNet** is a virtual network within a zone. Each VNet:
+- Has a unique name (max 8 characters in Proxmox)
+- Has a tag (VLAN ID for VLAN zones)
+- Creates a Linux bridge on each cluster node (e.g., `vnet0`)
+- VMs connect to VNets by assigning their NICs to the VNet bridge
+
+### Subnets
+
+A **Subnet** within a VNet defines:
+- IP address range (CIDR)
+- Gateway IP
+- SNAT flag (for source NAT on the nodes)
+- DHCP range and DNS server
+- IPAM integration (PVE, NetBox, phpIPAM)
+
+
+---
+
+## CloudStack ↔ Proxmox SDN Mapping
+
+| CloudStack Concept | Proxmox SDN Concept | Notes |
+|-------------------|---------------------|-------|
+| Isolated network | VNet in a VLAN zone | One VNet per CS network |
+| VLAN tag | VNet tag | Direct mapping |
+| Network gateway | Subnet gateway | Proxmox applies it on nodes |
+| Network CIDR | Subnet CIDR | — |
+| Source NAT | Subnet SNAT + iptables on GW node | Proxmox subnet SNAT + 
custom rules |
+| VPC | Per-VPC VLAN zone | One VLAN zone per VPC |
+| VPC tier | VNet within VPC zone | — |
+| Static NAT | iptables DNAT on GW node | — |
+| Port forwarding | iptables DNAT on GW node | — |
+| Firewall rules | iptables on GW node | — |
+| DHCP | Proxmox SDN subnet DHCP | dnsmasq on nodes |
+| VM metadata | Cloud-init ISO (cidata) | Attached via Proxmox API |
+
+---
+
+## Directory Contents
+
+| File | Purpose |
+|------|---------|
+| `proxmox-sdn.sh` | Main extension script (runs on management server) |
+| `README.md` | This documentation |
+
+---
+
+## Prerequisites
+
+### Proxmox VE Cluster
+
+* **Proxmox VE 7.x or 8.x** with SDN enabled
+  - SDN is available in Proxmox VE 7.0+ (Technology Preview) and
+    fully supported in Proxmox VE 8.1+
+  - Ensure `libpve-network-perl` is installed:
+    ```bash
+    apt install libpve-network-perl
+    ```
+
+* **API Token** with SDN permissions:
+  ```bash
+  # On a Proxmox node, create an API token:
+  pveum user token add root@pam cloudstack --privsep 0
+  # Or with specific permissions:
+  pveum role add CloudStackSDN -privs "SDN.Allocate SDN.Audit SDN.Use 
Sys.Modify Sys.Audit"
+  pveum acl modify / --roles CloudStackSDN --users root@pam --tokens 
root@pam!cloudstack
+  ```
+
+* **SDN enabled** in the datacenter configuration:
+  ```bash
+  # Verify SDN is configured:
+  pvesh get /cluster/sdn
+  ```
+
+* **Physical bridge** configured (e.g., `vmbr0`) that carries VLAN
+  trunks (for VLAN zones) or has IP connectivity between nodes
+  (for VXLAN/EVPN zones).
+
+### CloudStack Management Server
+
+* `curl` — for Proxmox API calls
+* `jq` — for JSON parsing (recommended; fallback to grep-based parsing)
+* `bash` 4+ — for associative arrays and advanced string handling
+* SSH access to the gateway node (for iptables management)
+
+---
+
+## Installation
+
+### Management Server
+
+During package installation, `proxmox-sdn.sh` is deployed to:
+```
+/usr/share/cloudstack-management/extensions/<extension-name>/proxmox-sdn.sh
+```
+
+In **developer mode** the extensions directory defaults to `extensions/`
+relative to the repo root, so `extensions/proxmox-sdn/proxmox-sdn.sh`
+is found automatically.
+
+---
+
+## Step-by-step API Setup
+
+### 1. Create the Extension
+
+```bash
+cmk createExtension \
+    name=proxmox-sdn \
+    type=NetworkOrchestrator \
+    path=proxmox-sdn \
+    "details[0].key=network.services" \
+    "details[0].value=SourceNat,StaticNat,PortForwarding,Firewall,Gateway" \
+    "details[1].key=network.service.capabilities" \
+    
"details[1].value={\"SourceNat\":{\"SupportedSourceNatTypes\":\"peraccount\",\"RedundantRouter\":\"false\"},\"Firewall\":{\"TrafficStatistics\":\"per
 public ip\"}}"
+```
+
+For a full-featured extension including DHCP:
+
+```bash
+cmk createExtension \
+    name=proxmox-sdn-full \
+    type=NetworkOrchestrator \
+    path=proxmox-sdn \
+    "details[0].key=network.services" \
+    
"details[0].value=SourceNat,StaticNat,PortForwarding,Firewall,Gateway,Dhcp,Dns" 
\
+    "details[1].key=network.service.capabilities" \
+    
"details[1].value={\"SourceNat\":{\"SupportedSourceNatTypes\":\"peraccount\",\"RedundantRouter\":\"false\"},\"Firewall\":{\"TrafficStatistics\":\"per
 public ip\"}}"
+```
+
+Verify:
+```bash
+cmk listExtensions name=proxmox-sdn
+```
+
+### 2. Register with Physical Network
+
+```bash
+cmk registerExtension \
+    id=<extension-uuid> \
+    resourcetype=PhysicalNetwork \
+    resourceid=<phys-net-uuid>
+```
+
+Set Proxmox connection details:
+
+```bash
+cmk updateRegisteredExtension \
+    extensionid=<extension-uuid> \
+    resourcetype=PhysicalNetwork \
+    resourceid=<phys-net-uuid> \
+    "details[0].key=url"                      
"details[0].value=proxmox1.example.com" \
+    "details[1].key=user"                     "details[1].value=root@pam" \
+    "details[2].key=token"                    "details[2].value=cloudstack" \
+    "details[3].key=secret"                   
"details[3].value=<api-token-secret>" \
+    "details[4].key=node"                     "details[4].value=pve1" \
+    "details[5].key=verify_tls_certificate"   "details[5].value=false" \
+    "details[6].key=sdn.zone.type"            "details[6].value=vlan" \
+    "details[7].key=sdn.zone.bridge"          "details[7].value=vmbr0" \
+    "details[8].key=public.bridge"            "details[8].value=vmbr0" \
+    "details[9].key=gateway.node"             "details[9].value=pve1"
+```
+
+For SSH-based iptables management on the gateway node, add SSH credentials:
+
+```bash
+cmk updateRegisteredExtension \
+    extensionid=<extension-uuid> \
+    resourcetype=PhysicalNetwork \
+    resourceid=<phys-net-uuid> \
+    "details[0].key=ssh.key"   "details[0].value=$(cat ~/.ssh/id_rsa)" \
+    "details[1].key=ssh.user"  "details[1].value=root"
+```
+
+Verify the NSP was created:
+```bash
+cmk listNetworkServiceProviders physicalnetworkid=<phys-net-uuid>
+# → "proxmox-sdn" should appear as Enabled
+```
+
+### 3. Create Network Offering
+
+```bash
+cmk createNetworkOffering \
+    name="Proxmox SDN Isolated" \
+    displaytext="Isolated network via Proxmox SDN" \
+    guestiptype=Isolated \
+    traffictype=GUEST \
+    supportedservices="SourceNat,StaticNat,PortForwarding,Firewall,Gateway" \
+    "serviceProviderList[0].service=SourceNat"      
"serviceProviderList[0].provider=proxmox-sdn" \
+    "serviceProviderList[1].service=StaticNat"      
"serviceProviderList[1].provider=proxmox-sdn" \
+    "serviceProviderList[2].service=PortForwarding" 
"serviceProviderList[2].provider=proxmox-sdn" \
+    "serviceProviderList[3].service=Firewall"       
"serviceProviderList[3].provider=proxmox-sdn" \
+    "serviceProviderList[4].service=Gateway"        
"serviceProviderList[4].provider=proxmox-sdn" \
+    "serviceCapabilityList[0].service=SourceNat" \
+    "serviceCapabilityList[0].capabilitytype=SupportedSourceNatTypes" \
+    "serviceCapabilityList[0].capabilityvalue=peraccount"
+
+cmk updateNetworkOffering id=<offering-uuid> state=Enabled
+```
+
+### 4. Create an Isolated Network
+
+```bash
+cmk createNetwork \
+    name=my-proxmox-network \
+    displaytext="My Proxmox SDN network" \
+    networkofferingid=<offering-uuid> \
+    zoneid=<zone-uuid>
+```
+
+When a VM is deployed, CloudStack triggers `implement-network`, which:
+1. Creates a VLAN zone `cszone` (if not already present) in Proxmox SDN
+2. Creates a VNet `cs<networkId>` with the assigned VLAN tag
+3. Creates a subnet with gateway and CIDR
+4. Applies SDN changes (`PUT /cluster/sdn`)
+5. Sets up iptables NAT chains on the gateway node
+
+### 5. VPC Setup
+
+For VPC networks, create a VPC offering and VPC:
+
+```bash
+# Create VPC offering
+cmk createVPCOffering \
+    name="Proxmox SDN VPC" \
+    displaytext="VPC via Proxmox SDN" \
+    supportedservices="SourceNat,StaticNat,PortForwarding,Firewall,NetworkACL" 
\
+    "serviceProviderList[0].service=SourceNat"      
"serviceProviderList[0].provider=proxmox-sdn" \
+    "serviceProviderList[1].service=StaticNat"      
"serviceProviderList[1].provider=proxmox-sdn" \
+    "serviceProviderList[2].service=PortForwarding" 
"serviceProviderList[2].provider=proxmox-sdn" \
+    "serviceProviderList[3].service=Firewall"       
"serviceProviderList[3].provider=proxmox-sdn" \
+    "serviceProviderList[4].service=NetworkACL"     
"serviceProviderList[4].provider=proxmox-sdn"
+
+# Create VPC
+cmk createVPC \
+    name=my-vpc \
+    displaytext="My Proxmox SDN VPC" \
+    vpcofferingid=<vpc-offering-uuid> \
+    zoneid=<zone-uuid> \
+    cidr=10.0.0.0/16
+
+# Create VPC tier network offering and tiers...
+```
+
+Each VPC gets its own Proxmox SDN VLAN zone. VPC tier
+networks are VNets within the VPC zone.
+
+### 6. Cleanup
+
+```bash
+# Delete network
+cmk deleteNetwork id=<network-uuid>
+
+# Unregister and delete extension
+cmk unregisterExtension id=<ext-uuid> resourcetype=PhysicalNetwork 
resourceid=<phys-net-uuid>
+cmk deleteExtension id=<ext-uuid>
+```
+
+---
+
+## Extension Details Reference
+
+### Physical Network Extension Details
+
+Set via `updateRegisteredExtension`:
+
+| Key | Required | Default | Description |
+|-----|----------|---------|-------------|
+| `url` | **Yes** | — | Proxmox API hostname (e.g., `proxmox1.example.com`) |
+| `user` | **Yes** | — | API token user (e.g., `root@pam`) |
+| `token` | **Yes** | — | API token name (e.g., `cloudstack`) |
+| `secret` | **Yes** | — | API token secret |
+| `node` | **Yes** | — | Default Proxmox node name |
+| `verify_tls_certificate` | No | `true` | Set to `false` to skip TLS 
verification |
+| `sdn.zone` | No | `cszone` | SDN zone name for isolated networks |
+| `sdn.zone.type` | No | `vlan` | Zone type: `simple`, `vlan`, `qinq` |
+| `sdn.zone.bridge` | No | `vmbr0` | Physical bridge for the SDN zone |
+| `sdn.service.vlan` | No | — | Service VLAN tag (for QinQ zones) |
+| `gateway.node` | No | same as `node` | Node acting as NAT gateway |
+| `gateway.ip` | No | same as `url` | IP address of the gateway node (for SSH) 
|
+| `public.bridge` | No | `vmbr0` | Public network bridge on gateway node |
+| `ssh.user` | No | `root` | SSH user for gateway node |
+| `ssh.key` | No | — | SSH private key (PEM) for gateway node |
+| `cloudinit.storage` | No | `local` | Proxmox storage for cloud-init ISOs |
+
+### Per-Network Extension Details
+
+Stored in `network_details` by `ensure-network-device`, forwarded as
+`--network-extension-details`:
+
+| Key | Description |
+|-----|-------------|
+| `zone` | Proxmox SDN zone name |
+| `vnet` | Proxmox SDN VNet name |
+| `node` | Gateway node name |
+| `vpc_id` | VPC ID (for VPC tier networks) |
+
+---
+
+## Supported Commands
+
+### Network Lifecycle
+
+| Command | Description |
+|---------|-------------|
+| `ensure-network-device` | Validate Proxmox API connectivity, return 
zone/VNet/node details |
+| `implement-network` | Create VNet + Subnet in Proxmox SDN, set up iptables 
chains |
+| `shutdown-network` | Remove subnet and VNet, clean up iptables chains |
+| `destroy-network` | Permanently remove VNet and all state |
+
+### VPC Lifecycle
+
+| Command | Description |
+|---------|-------------|
+| `implement-vpc` | Create SDN zone for VPC, set up VPC-level SNAT |
+| `shutdown-vpc` | Remove VPC zone, clean up VPC SNAT |
+| `destroy-vpc` | Permanently remove VPC zone and state |
+| `update-vpc-source-nat-ip` | Update VPC-level SNAT to a new public IP |
+
+### IP Management
+
+| Command | Description |
+|---------|-------------|
+| `assign-ip` | Assign public IP to gateway node, configure SNAT |
+| `release-ip` | Release public IP, remove SNAT/DNAT rules |
+| `add-static-nat` | Configure 1:1 DNAT + SNAT on gateway node |
+| `delete-static-nat` | Remove 1:1 NAT rules |
+| `add-port-forward` | Configure port-based DNAT on gateway node |
+| `delete-port-forward` | Remove port forwarding rules |
+
+### Firewall
+
+| Command | Description |
+|---------|-------------|
+| `apply-fw-rules` | Apply firewall rules (egress/ingress) via iptables |
+| `apply-network-acl` | Apply VPC Network ACL rules via iptables |
+
+### DHCP/DNS/Metadata
+
+These commands are handled by Proxmox SDN natively or delegated to
+cloud-init:
+
+| Command | Behavior |
+|---------|----------|
+| `config-dhcp-subnet` | Updates Proxmox SDN subnet DHCP settings |
+| `add-dhcp-entry` | No-op (Proxmox SDN IPAM handles this) |
+| `config-dns-subnet` | Updates Proxmox SDN subnet DNS settings |
+| `save-vm-data` | Creates cloud-init ISO with metadata and attaches to VM |
+| `save-userdata` | Creates/updates cloud-init ISO with userdata |
+| `save-password` | Adds password to cloud-init ISO |
+| `save-sshkey` | Adds SSH key to cloud-init ISO |
+| `apply-lb-rules` | No-op (LB not natively supported) |
+
+---
+
+## Custom Actions
+
+| Action | Description |
+|--------|-------------|
+| `dump-config` | Dump Proxmox SDN zones, VNets, and local state |
+| `apply-sdn` | Manually apply pending SDN configuration changes |
+
+Example:
+```bash
+cmk addCustomAction extensionid=<ext-uuid> name=dump-config 
resourcetype=Network
+cmk runNetworkCustomAction networkid=<network-uuid> actionid=<action-uuid>
+```
+
+---
+
+## Cloud-Init Integration
+
+VM metadata (userdata, passwords, SSH keys, network configuration) is
+delivered via a **cloud-init ISO** (with the `cidata` volume label)
+created by the extension and attached to the VM via the Proxmox API.
+
+When CloudStack calls `save-vm-data`, `save-userdata`, `save-password`,
+or `save-sshkey`, the extension:
+
+1. Creates a temporary directory with three files:
+   - **meta-data** — instance ID and local hostname
+   - **network-config** — NoCloud v2 format with static IP, gateway,
+     and DNS from the network details
+   - **user-data** — CloudStack userdata, password, and/or SSH keys
+     merged into a `#cloud-config` document
+
+2. Generates an ISO image using `genisoimage` (or `mkisofs`/`xorrisofs`)
+   with the `cidata` volume label
+
+3. Uploads the ISO to Proxmox storage (configurable via
+   `cloudinit.storage`, default: `local`)
+
+4. Attaches the ISO to the VM as `ide2` (CD-ROM drive) via the
+   Proxmox API
+
+The VM's Proxmox VMID is resolved from the `--vmid` argument, the
+`VM_DATA_FILE` JSON, or a local IP→VMID mapping maintained by the
+extension.
+
+---
+
+## Integration with Proxmox VM Extension
+
+This SDN extension is designed to work alongside the **Proxmox.sh**
+VM orchestrator extension (`extensions/Proxmox/proxmox.sh`).
+
+**Workflow:**
+
+1. **Network creation** — CloudStack calls `proxmox-sdn.sh implement-network`,
+   which creates a VNet (e.g., `cs42`) in Proxmox SDN with the assigned
+   VLAN tag. The VNet name becomes a Linux bridge on every cluster node.
+
+2. **VM creation** — CloudStack calls `proxmox.sh create`, which creates
+   a QEMU VM and attaches NICs to the VNet bridge using the VLAN tag
+   and MAC address from CloudStack.
+
+3. **Metadata delivery** — CloudStack calls `proxmox-sdn.sh save-vm-data`
+   (and related commands), which creates a cloud-init ISO with network
+   config, userdata, password, and SSH keys, and attaches it to the VM.
+
+The VNet name returned by `ensure-network-device` in the extension
+details JSON (`"vnet": "cs42"`) is the bridge name that the VM
+orchestrator uses as `network_bridge`.
+
+---
+
+## Proxmox SDN Zone Types
+
+### VLAN Zone (Recommended for isolated networks)
+
+Best for traditional L2 isolation. Requires 802.1Q VLAN trunk on the
+physical bridge.
+
+```
+details: sdn.zone.type=vlan, sdn.zone.bridge=vmbr0
+```
+
+Each CloudStack network gets a VNet with the VLAN tag assigned by
+CloudStack. The Proxmox VNet creates a VLAN-aware bridge on each
+cluster node.
+
+### Simple Zone (For testing)
+
+No isolation — all VNets share the same bridge. Useful for testing.
+
+```
+details: sdn.zone.type=simple
+```
+
+### QinQ Zone (For service providers)
+
+Stacked VLANs (802.1ad). Each VNet uses a customer VLAN inside a
+service VLAN.
+
+```
+details: sdn.zone.type=qinq, sdn.zone.bridge=vmbr0, sdn.service.vlan=100
+```
+
+---
+
+## Limitations and Future Work
+
+### Current Limitations
+
+1. **DHCP**: Proxmox SDN provides DHCP via dnsmasq on nodes, but
+   per-MAC static reservations must be configured through Proxmox
+   IPAM (PVE built-in, NetBox, or phpIPAM). The extension currently
+   delegates DHCP to Proxmox SDN's native mechanisms.
+
+2. **Load Balancing**: Not natively supported by Proxmox SDN. A
+   future version could deploy HAProxy on the gateway node.
+
+3. **VNet Name Length**: Proxmox limits VNet names to 8 characters.
+   The extension generates short names (e.g., `cs42`, `vt5n42`) but
+   this limits the maximum network/VPC IDs that can be represented
+   without collision.
+
+4. **Multi-node Gateway**: Currently, a single gateway node handles
+   all NAT/firewall rules. A future version could distribute rules
+   across multiple nodes for HA.
+
+5. **Cloud-init ISO tools**: The management server must have
+   `genisoimage`, `mkisofs`, or `xorrisofs` installed to create
+   cloud-init ISOs.
+
+### Future Enhancements
+
+- **Proxmox IPAM Integration**: Register VM MAC→IP mappings in the
+  Proxmox IPAM database for static DHCP reservations.
+- **Proxmox Firewall API**: Use the Proxmox cluster/node firewall
+  API instead of raw iptables for better integration.
+- **HA Gateway**: Distribute NAT/firewall rules across multiple
+  Proxmox nodes with VRRP or similar.
+- **IPv6 Support**: Extend subnet configuration with IPv6 CIDR
+  and dual-stack support.
+
diff --git a/Proxmox-SDN/proxmox-sdn.sh b/Proxmox-SDN/proxmox-sdn.sh
new file mode 100755
index 0000000..af918a2
--- /dev/null
+++ b/Proxmox-SDN/proxmox-sdn.sh
@@ -0,0 +1,1956 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+##############################################################################
+# proxmox-sdn.sh  (Proxmox SDN Network Extension for Apache CloudStack)
+#
+# Runs on the CloudStack management server.
+# Delegates network lifecycle operations to the Proxmox VE SDN API.
+#
+# Proxmox SDN concepts (https://pve.proxmox.com/pve-docs/chapter-pvesdn.html):
+#
+#   Zone    — A transport domain (Simple, VLAN, QinQ).
+#             Each zone defines how traffic is isolated between VNets.
+#
+#   VNet    — A virtual network within a zone. VMs connect to VNets via
+#             bridges. Each VNet has a tag (VLAN ID for VLAN zones).
+#
+#   Subnet  — An IP subnet within a VNet. Optionally provides DHCP via
+#             dnsmasq and a gateway (SNAT) on the nodes.
+#
+# Mapping from CloudStack → Proxmox SDN:
+#
+#   CloudStack isolated network  →  Proxmox VNet (in a VLAN/Simple zone)
+#                                   with a subnet for gateway/CIDR
+#   CloudStack VPC               →  Proxmox VLAN zone (per-VPC)
+#   CloudStack VPC tier          →  Proxmox VNet within the VPC zone
+#   CloudStack VLAN tag          →  VNet tag
+#   CloudStack gateway/CIDR      →  Proxmox subnet gateway/CIDR
+#   CloudStack source NAT        →  Proxmox subnet SNAT flag
+#   CloudStack DHCP              →  Proxmox subnet DHCP range
+#   CloudStack firewall          →  Proxmox cluster firewall rules
+#   CloudStack static NAT        →  Proxmox firewall DNAT rules on nodes
+#   CloudStack port forwarding   →  Proxmox firewall DNAT rules on nodes
+#
+# Integration with Proxmox VM Orchestrator (Proxmox.sh):
+#   This SDN extension creates VNet bridges in Proxmox.  The VNet name
+#   returned by ensure-network-device (e.g. "cs42") is the bridge that
+#   the Proxmox.sh VM orchestrator uses as network_bridge when attaching
+#   VM NICs.  VM metadata (userdata, password, SSH key, network config)
+#   is delivered via a small cloud-init ISO attached to the VM.
+#
+# ---- CLI arguments injected by NetworkExtensionElement ----
+#
+#   --physical-network-extension-details <json>
+#       JSON object with all extension_resource_map_details.  Required keys:
+#         url                     – Proxmox API host (e.g. 
proxmox1.example.com)
+#         user                    – API token user (e.g. root@pam)
+#         token                   – API token name (e.g. cloudstack)
+#         secret                  – API token secret
+#         node                    – Default Proxmox node name
+#       Optional keys:
+#         verify_tls_certificate  – "true" or "false" (default: "true")
+#         sdn.zone               – SDN zone name (default: auto-generated)
+#         sdn.zone.type          – Zone type: simple, vlan, qinq
+#                                  (default: vlan)
+#         sdn.bridge.prefix      – Bridge name prefix (default: vnet)
+#         gateway.node           – Node acting as NAT gateway (default: same 
as node)
+#         public.bridge          – Public network bridge on gateway node
+#                                  (default: vmbr0)
+#         guest.bridge           – Override guest bridge (default: auto from 
VNet)
+#         cloudinit.storage      – Proxmox storage for cloud-init ISOs
+#                                  (default: local)
+#
+#   --network-extension-details <json>
+#       Per-network opaque JSON blob (from network_details key ext.details).
+#       '{}' on the first ensure-network-device call.
+#       Keys managed by this script:
+#         zone      – Proxmox SDN zone name
+#         vnet      – Proxmox SDN VNet name
+#         subnet    – Proxmox SDN subnet CIDR
+#         node      – Gateway node name
+#
+# Exit codes:
+#   0  – success
+#   1  – usage / configuration error
+#   2  – API connection / authentication error
+#   3  – API command returned non-zero / unexpected result
+##############################################################################
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Resolve paths for logging
+# ---------------------------------------------------------------------------
+_SELF="$(readlink -f "$0" 2>/dev/null \
+         || realpath "$0" 2>/dev/null \
+         || echo "$0")"
+_SCRIPT_BASENAME="$(basename "${_SELF}" .sh)"
+_EXT_DIR_NAME="$(basename "$(dirname "${_SELF}")")"
+
+LOG_FILE="/var/log/cloudstack/extensions/${_EXT_DIR_NAME}.log"
+mkdir -p "$(dirname "${LOG_FILE}")" 2>/dev/null || true
+
+STATE_DIR="/var/lib/cloudstack/${_EXT_DIR_NAME}"
+mkdir -p "${STATE_DIR}" 2>/dev/null || true
+
+# ---------------------------------------------------------------------------
+# Logging
+# ---------------------------------------------------------------------------
+
+log() {
+    local ts
+    ts=$(date '+%Y-%m-%d %H:%M:%S')
+    printf '[%s] %s\n' "${ts}" "$*" >> "${LOG_FILE}" 2>/dev/null || true
+}
+
+die() {
+    local _msg="$1"
+    local _code="${2:-1}"
+    log "ERROR: ${_msg}"
+    echo "ERROR: ${_msg}" >&2
+    exit "${_code}"
+}
+
+# ---------------------------------------------------------------------------
+# JSON helpers
+# ---------------------------------------------------------------------------
+
+# json_get <json> <key> → unquoted string value or empty
+json_get() {
+    local _json="$1" _key="$2"
+    if command -v jq >/dev/null 2>&1; then
+        printf '%s' "${_json}" | jq -r ".\"${_key}\" // empty" 2>/dev/null || 
true
+    else
+        printf '%s' "${_json}" | grep -o "\"${_key}\":\"[^\"]*\"" | cut -d'"' 
-f4 || true
+    fi
+}
+
+# json_get_nested <json> <key1> <key2> → for nested objects
+json_get_nested() {
+    local _json="$1" _k1="$2" _k2="$3"
+    if command -v jq >/dev/null 2>&1; then
+        printf '%s' "${_json}" | jq -r ".\"${_k1}\".\"${_k2}\" // empty" 
2>/dev/null || true
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# Validate input and parse command
+# ---------------------------------------------------------------------------
+
+if [ $# -lt 1 ]; then
+    die "Usage: proxmox-sdn.sh <command> [arguments...]" 1
+fi
+
+COMMAND="$1"
+shift
+
+# ---------------------------------------------------------------------------
+# Parse CLI arguments
+# ---------------------------------------------------------------------------
+
+PHYS_DETAILS="{}"
+EXTENSION_DETAILS="{}"
+NETWORK_ID=""
+CURRENT_DETAILS="{}"
+VPC_ID=""
+VLAN=""
+GATEWAY=""
+CIDR=""
+PUBLIC_IP=""
+PRIVATE_IP=""
+PUBLIC_PORT=""
+PRIVATE_PORT=""
+PROTOCOL=""
+SOURCE_NAT="false"
+PUBLIC_GATEWAY=""
+PUBLIC_CIDR=""
+PUBLIC_VLAN=""
+ZONE_ID=""
+EXTENSION_IP=""
+MAC=""
+HOSTNAME=""
+DNS_SERVER=""
+DEFAULT_NIC="true"
+DOMAIN=""
+VM_DATA_FILE=""
+FW_RULES_FILE=""
+ACL_RULES_FILE=""
+RESTORE_DATA_FILE=""
+VM_IP=""
+ACTION_NAME=""
+ACTION_PARAMS="{}"
+NIC_ID=""
+USERDATA=""
+VM_PASSWORD=""
+VM_SSHKEY=""
+HYPERVISOR_HOSTNAME=""
+VM_DATA=""
+PROXMOX_VMID=""
+FORWARD_ARGS=()
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        --physical-network-extension-details)
+            PHYS_DETAILS="${2:-{}}"
+            shift 2 ;;
+        --network-extension-details)
+            EXTENSION_DETAILS="${2:-{}}"
+            shift 2 ;;
+        --network-id)
+            NETWORK_ID="${2:-}"
+            shift 2 ;;
+        --vpc-id)
+            VPC_ID="${2:-}"
+            shift 2 ;;
+        --current-details)
+            CURRENT_DETAILS="${2:-{}}"
+            shift 2 ;;
+        --vlan)
+            VLAN="${2:-}"
+            shift 2 ;;
+        --gateway)
+            GATEWAY="${2:-}"
+            shift 2 ;;
+        --cidr)
+            CIDR="${2:-}"
+            shift 2 ;;
+        --public-ip)
+            PUBLIC_IP="${2:-}"
+            shift 2 ;;
+        --private-ip)
+            PRIVATE_IP="${2:-}"
+            shift 2 ;;
+        --public-port)
+            PUBLIC_PORT="${2:-}"
+            shift 2 ;;
+        --private-port)
+            PRIVATE_PORT="${2:-}"
+            shift 2 ;;
+        --protocol)
+            PROTOCOL="${2:-}"
+            shift 2 ;;
+        --source-nat)
+            SOURCE_NAT="${2:-false}"
+            shift 2 ;;
+        --public-gateway)
+            PUBLIC_GATEWAY="${2:-}"
+            shift 2 ;;
+        --public-cidr)
+            PUBLIC_CIDR="${2:-}"
+            shift 2 ;;
+        --public-vlan)
+            PUBLIC_VLAN="${2:-}"
+            shift 2 ;;
+        --zone-id)
+            ZONE_ID="${2:-}"
+            shift 2 ;;
+        --extension-ip)
+            EXTENSION_IP="${2:-}"
+            shift 2 ;;
+        --mac)
+            MAC="${2:-}"
+            shift 2 ;;
+        --hostname)
+            HOSTNAME="${2:-}"
+            shift 2 ;;
+        --dns)
+            DNS_SERVER="${2:-}"
+            shift 2 ;;
+        --default-nic)
+            DEFAULT_NIC="${2:-true}"
+            shift 2 ;;
+        --domain)
+            DOMAIN="${2:-}"
+            shift 2 ;;
+        --ip)
+            VM_IP="${2:-}"
+            shift 2 ;;
+        --vm-data-file)
+            VM_DATA_FILE="${2:-}"
+            shift 2 ;;
+        --fw-rules-file)
+            FW_RULES_FILE="${2:-}"
+            shift 2 ;;
+        --acl-rules-file)
+            ACL_RULES_FILE="${2:-}"
+            shift 2 ;;
+        --restore-data-file)
+            RESTORE_DATA_FILE="${2:-}"
+            shift 2 ;;
+        --action)
+            ACTION_NAME="${2:-}"
+            shift 2 ;;
+        --action-params)
+            ACTION_PARAMS="${2:-{}}"
+            shift 2 ;;
+        --lb-rules)
+            # LB rules JSON (not used, but accept the argument)
+            shift 2 ;;
+        --options)
+            # DHCP options JSON (not used, but accept the argument)
+            shift 2 ;;
+        --nic-id)
+            NIC_ID="${2:-}"
+            shift 2 ;;
+        --userdata)
+            USERDATA="${2:-}"
+            shift 2 ;;
+        --password)
+            VM_PASSWORD="${2:-}"
+            shift 2 ;;
+        --sshkey)
+            VM_SSHKEY="${2:-}"
+            shift 2 ;;
+        --hypervisor-hostname)
+            HYPERVISOR_HOSTNAME="${2:-}"
+            shift 2 ;;
+        --vm-data)
+            VM_DATA="${2:-}"
+            shift 2 ;;
+        --vmid)
+            PROXMOX_VMID="${2:-}"
+            shift 2 ;;
+        *)
+            FORWARD_ARGS+=("$1")
+            shift ;;
+    esac
+done
+
+# ---------------------------------------------------------------------------
+# Read Proxmox connection details from physical-network extension details
+# ---------------------------------------------------------------------------
+
+PVE_URL=$(json_get "${PHYS_DETAILS}" "url")
+PVE_USER=$(json_get "${PHYS_DETAILS}" "user")
+PVE_TOKEN=$(json_get "${PHYS_DETAILS}" "token")
+PVE_SECRET=$(json_get "${PHYS_DETAILS}" "secret")
+PVE_NODE=$(json_get "${PHYS_DETAILS}" "node")
+PVE_VERIFY_TLS=$(json_get "${PHYS_DETAILS}" "verify_tls_certificate")
+PVE_VERIFY_TLS="${PVE_VERIFY_TLS:-true}"
+
+SDN_ZONE_NAME=$(json_get "${PHYS_DETAILS}" "sdn.zone")
+SDN_ZONE_TYPE=$(json_get "${PHYS_DETAILS}" "sdn.zone.type")
+SDN_ZONE_TYPE="${SDN_ZONE_TYPE:-vlan}"
+SDN_BRIDGE_PREFIX=$(json_get "${PHYS_DETAILS}" "sdn.bridge.prefix")
+SDN_BRIDGE_PREFIX="${SDN_BRIDGE_PREFIX:-vnet}"
+
+GW_NODE=$(json_get "${PHYS_DETAILS}" "gateway.node")
+GW_NODE="${GW_NODE:-${PVE_NODE}}"
+PUB_BRIDGE=$(json_get "${PHYS_DETAILS}" "public.bridge")
+PUB_BRIDGE="${PUB_BRIDGE:-vmbr0}"
+
+# VLAN zone bridge (physical bridge that carries VLAN trunks)
+ZONE_BRIDGE=$(json_get "${PHYS_DETAILS}" "sdn.zone.bridge")
+ZONE_BRIDGE="${ZONE_BRIDGE:-vmbr0}"
+
+# Storage for cloud-init ISOs (must be an ISO-capable storage, e.g. "local")
+CLOUDINIT_STORAGE=$(json_get "${PHYS_DETAILS}" "cloudinit.storage")
+CLOUDINIT_STORAGE="${CLOUDINIT_STORAGE:-local}"
+
+# ---------------------------------------------------------------------------
+# Proxmox API helpers (reuse patterns from extensions/Proxmox/proxmox.sh)
+# ---------------------------------------------------------------------------
+
+call_proxmox_api() {
+    local method="$1"
+    local path="$2"
+    local data="${3:-}"
+
+    local curl_opts=(
+        -s
+        -w '\n%{http_code}'
+        -X "${method}"
+        -H "Authorization: PVEAPIToken=${PVE_USER}!${PVE_TOKEN}=${PVE_SECRET}"
+    )
+
+    if [ "${PVE_VERIFY_TLS}" = "false" ]; then
+        curl_opts+=(-k)
+    fi
+
+    if [ -n "${data}" ]; then
+        curl_opts+=(-d "${data}")
+    fi
+
+    local full_url="https://${PVE_URL}:8006/api2/json${path}";
+    local response
+    response=$(curl "${curl_opts[@]}" "${full_url}" 2>&1) || {
+        log "API call failed: ${method} ${path} — curl error"
+        echo ""
+        return 1
+    }
+
+    # Split response body and HTTP status code
+    local body http_code
+    http_code=$(echo "${response}" | tail -1)
+    body=$(echo "${response}" | sed '$d')
+
+    if [ "${http_code}" -ge 200 ] && [ "${http_code}" -lt 300 ] 2>/dev/null; 
then
+        echo "${body}"
+        return 0
+    elif [ "${http_code}" = "500" ] 2>/dev/null; then
+        # 500 may indicate the resource already exists — check error message
+        log "API ${method} ${path} returned HTTP ${http_code}: ${body}"
+        echo "${body}"
+        return 1
+    else
+        log "API ${method} ${path} returned HTTP ${http_code}: ${body}"
+        echo "${body}"
+        return 1
+    fi
+}
+
+# api_get <path> → JSON body (exits on failure)
+api_get() {
+    call_proxmox_api GET "$1"
+}
+
+# api_post <path> [data] → JSON body (exits on failure)
+api_post() {
+    call_proxmox_api POST "$1" "${2:-}"
+}
+
+# api_put <path> [data] → JSON body
+api_put() {
+    call_proxmox_api PUT "$1" "${2:-}"
+}
+
+# api_delete <path> → JSON body
+api_delete() {
+    call_proxmox_api DELETE "$1"
+}
+
+# apply_sdn_changes — Apply pending SDN configuration changes
+apply_sdn_changes() {
+    log "Applying SDN configuration changes..."
+    local resp
+    resp=$(api_put "/cluster/sdn") || {
+        log "WARNING: Failed to apply SDN changes (may require manual 'pvesh 
set /cluster/sdn')"
+        return 0
+    }
+    log "SDN changes applied successfully"
+    return 0
+}
+
+# ---------------------------------------------------------------------------
+# VNet naming helpers
+#
+# Proxmox VNet names are limited to 8 characters (alphanumeric + dash).
+# We generate deterministic short names from CloudStack network/VPC IDs.
+#
+# Isolated network:  cs<networkId>     e.g. cs42
+# VPC tier:          vt<vpcId>x<netId> e.g. vt5x42  (truncated to 8 chars)
+# VPC zone:          csz<vpcId>        e.g. csz5
+# ---------------------------------------------------------------------------
+
+vnet_name() {
+    local net_id="$1"
+    local vpc_id="${2:-}"
+    if [ -n "${vpc_id}" ]; then
+        local name="vt${vpc_id}n${net_id}"
+    else
+        local name="cs${net_id}"
+    fi
+    # Proxmox VNet names: max 8 chars, alphanumeric + dash, lowercase
+    name=$(echo "${name}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-')
+    echo "${name:0:8}"
+}
+
+# Zone name for a VPC (per-VPC VLAN zone)
+vpc_zone_name() {
+    local vpc_id="$1"
+    local name="csz${vpc_id}"
+    name=$(echo "${name}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')
+    echo "${name:0:8}"
+}
+
+# Zone name for isolated networks (shared VLAN zone)
+isolated_zone_name() {
+    if [ -n "${SDN_ZONE_NAME}" ]; then
+        echo "${SDN_ZONE_NAME}"
+    else
+        echo "cszone"
+    fi
+}
+
+# Subnet ID in Proxmox format: <vnet>-<cidr with / replaced by ->
+subnet_id() {
+    local vnet="$1"
+    local cidr="$2"
+    # Proxmox subnet format: <zone>-<cidr>  where cidr uses - instead of /
+    local cidr_safe
+    cidr_safe=$(echo "${cidr}" | tr '/' '-')
+    echo "${vnet}-${cidr_safe}"
+}
+
+# ---------------------------------------------------------------------------
+# SDN resource management helpers
+# ---------------------------------------------------------------------------
+
+# Ensure an SDN zone exists. Create it if not present.
+ensure_sdn_zone() {
+    local zone_name="$1"
+    local zone_type="${2:-vlan}"
+    local bridge="${3:-${ZONE_BRIDGE}}"
+
+    log "ensure_sdn_zone: name=${zone_name} type=${zone_type} bridge=${bridge}"
+
+    # Check if zone already exists
+    local resp
+    resp=$(api_get "/cluster/sdn/zones/${zone_name}" 2>/dev/null) && {
+        log "SDN zone '${zone_name}' already exists"
+        return 0
+    }
+
+    # Create the zone
+    local data="zone=${zone_name}&type=${zone_type}"
+
+    case "${zone_type}" in
+        vlan)
+            data+="&bridge=${bridge}"
+            ;;
+        simple)
+            # Simple zones don't need a bridge or additional params
+            ;;
+        qinq)
+            data+="&bridge=${bridge}"
+            local service_vlan
+            service_vlan=$(json_get "${PHYS_DETAILS}" "sdn.service.vlan")
+            [ -n "${service_vlan}" ] && data+="&tag=${service_vlan}"
+            ;;
+    esac
+
+    log "Creating SDN zone: ${data}"
+    resp=$(api_post "/cluster/sdn/zones" "${data}") || {
+        # Check if it failed because the zone already exists
+        if echo "${resp}" | grep -qi "already exists"; then
+            log "SDN zone '${zone_name}' already exists (race condition)"
+            return 0
+        fi
+        log "WARNING: Failed to create SDN zone '${zone_name}': ${resp}"
+        return 1
+    }
+
+    log "SDN zone '${zone_name}' created successfully"
+    return 0
+}
+
+# Create a VNet in the specified zone
+create_vnet() {
+    local vnet_name="$1"
+    local zone_name="$2"
+    local tag="${3:-}"
+    local alias="${4:-}"
+
+    log "create_vnet: vnet=${vnet_name} zone=${zone_name} tag=${tag}"
+
+    # Check if VNet already exists
+    local resp
+    resp=$(api_get "/cluster/sdn/vnets/${vnet_name}" 2>/dev/null) && {
+        log "VNet '${vnet_name}' already exists"
+        return 0
+    }
+
+    local data="vnet=${vnet_name}&zone=${zone_name}"
+    [ -n "${tag}" ] && data+="&tag=${tag}"
+    [ -n "${alias}" ] && data+="&alias=${alias}"
+
+    resp=$(api_post "/cluster/sdn/vnets" "${data}") || {
+        if echo "${resp}" | grep -qi "already exists"; then
+            log "VNet '${vnet_name}' already exists (race condition)"
+            return 0
+        fi
+        log "WARNING: Failed to create VNet '${vnet_name}': ${resp}"
+        return 1
+    }
+
+    log "VNet '${vnet_name}' created successfully"
+    return 0
+}
+
+# Delete a VNet
+delete_vnet() {
+    local vnet_name="$1"
+
+    log "delete_vnet: vnet=${vnet_name}"
+
+    api_delete "/cluster/sdn/vnets/${vnet_name}" || {
+        log "WARNING: Failed to delete VNet '${vnet_name}' (may not exist)"
+        return 0
+    }
+
+    log "VNet '${vnet_name}' deleted successfully"
+    return 0
+}
+
+# Create a subnet within a VNet
+create_subnet() {
+    local vnet_name="$1"
+    local cidr="$2"
+    local gateway="${3:-}"
+    local snat="${4:-0}"
+    local dhcp_enable="${5:-0}"
+    local dns="${6:-}"
+
+    log "create_subnet: vnet=${vnet_name} cidr=${cidr} gw=${gateway} 
snat=${snat} dhcp=${dhcp_enable}"
+
+    local subnet_cidr="${cidr}"
+    local data="subnet=${subnet_cidr}&type=subnet"
+
+    [ -n "${gateway}" ] && data+="&gateway=${gateway}"
+
+    # SNAT: Proxmox subnet supports snat flag (boolean)
+    if [ "${snat}" = "1" ] || [ "${snat}" = "true" ]; then
+        data+="&snat=1"
+    fi
+
+    # DHCP: Proxmox can enable DHCP on the subnet
+    if [ "${dhcp_enable}" = "1" ] || [ "${dhcp_enable}" = "true" ]; then
+        # Proxmox SDN DHCP is enabled by specifying dhcp-range
+        local net_addr prefix
+        net_addr=$(echo "${cidr}" | cut -d'/' -f1)
+        prefix=$(echo "${cidr}" | cut -d'/' -f2)
+        # Calculate a reasonable DHCP range
+        # Use the gateway as excluded, start from .2 or next available
+        local dhcp_start dhcp_end
+        dhcp_start=$(json_get "${PHYS_DETAILS}" "sdn.dhcp.start")
+        dhcp_end=$(json_get "${PHYS_DETAILS}" "sdn.dhcp.end")
+        if [ -n "${dhcp_start}" ] && [ -n "${dhcp_end}" ]; then
+            
data+="&dhcp-range=start-address=${dhcp_start},end-address=${dhcp_end}"
+        fi
+    fi
+
+    [ -n "${dns}" ] && data+="&dnszoneprefix=${dns}"
+
+    local resp
+    resp=$(api_post "/cluster/sdn/vnets/${vnet_name}/subnets" "${data}") || {
+        if echo "${resp}" | grep -qi "already exists"; then
+            log "Subnet '${cidr}' already exists on VNet '${vnet_name}'"
+            # Update existing subnet
+            local sub_id
+            sub_id=$(subnet_id "${vnet_name}" "${cidr}")
+            local update_data=""
+            [ -n "${gateway}" ] && update_data+="gateway=${gateway}"
+            if [ "${snat}" = "1" ] || [ "${snat}" = "true" ]; then
+                [ -n "${update_data}" ] && update_data+="&"
+                update_data+="snat=1"
+            fi
+            if [ -n "${update_data}" ]; then
+                api_put "/cluster/sdn/vnets/${vnet_name}/subnets/${sub_id}" 
"${update_data}" || true
+            fi
+            return 0
+        fi
+        log "WARNING: Failed to create subnet '${cidr}' on VNet 
'${vnet_name}': ${resp}"
+        return 1
+    }
+
+    log "Subnet '${cidr}' created on VNet '${vnet_name}'"
+    return 0
+}
+
+# Delete a subnet from a VNet
+delete_subnet() {
+    local vnet_name="$1"
+    local cidr="$2"
+
+    local sub_id
+    sub_id=$(subnet_id "${vnet_name}" "${cidr}")
+    log "delete_subnet: vnet=${vnet_name} subnet=${sub_id}"
+
+    api_delete "/cluster/sdn/vnets/${vnet_name}/subnets/${sub_id}" || {
+        log "WARNING: Failed to delete subnet '${sub_id}' (may not exist)"
+        return 0
+    }
+
+    log "Subnet '${sub_id}' deleted from VNet '${vnet_name}'"
+    return 0
+}
+
+# Delete an SDN zone
+delete_sdn_zone() {
+    local zone_name="$1"
+
+    log "delete_sdn_zone: zone=${zone_name}"
+
+    api_delete "/cluster/sdn/zones/${zone_name}" || {
+        log "WARNING: Failed to delete SDN zone '${zone_name}' (may not exist 
or have VNets)"
+        return 0
+    }
+
+    log "SDN zone '${zone_name}' deleted"
+    return 0
+}
+
+# ---------------------------------------------------------------------------
+# Proxmox firewall helpers (for NAT/firewall rules on nodes)
+#
+# Proxmox SDN subnets can handle SNAT natively when the snat flag is set.
+# For DNAT (static NAT, port forwarding), and for fine-grained firewall
+# control, we use iptables on the gateway node via the Proxmox API's
+# node exec endpoint or SSH.
+# ---------------------------------------------------------------------------
+
+# Execute a command on a Proxmox node via the API
+# Uses /nodes/{node}/execute (if available) or falls back to SSH
+node_exec() {
+    local node="$1"
+    local cmd="$2"
+
+    log "node_exec: node=${node} cmd=${cmd}"
+
+    # Try the Proxmox API execute endpoint first (PVE 8.x+)
+    local resp
+    resp=$(api_post "/nodes/${node}/execute" "command=$(urlencode "${cmd}")") 
2>/dev/null && {
+        echo "${resp}" | jq -r '.data // empty' 2>/dev/null || echo "${resp}"
+        return 0
+    }
+
+    # Fallback: SSH to the node
+    local node_ip
+    node_ip=$(json_get "${PHYS_DETAILS}" "gateway.ip")
+    if [ -z "${node_ip}" ]; then
+        node_ip="${PVE_URL}"
+    fi
+
+    local ssh_user
+    ssh_user=$(json_get "${PHYS_DETAILS}" "ssh.user")
+    ssh_user="${ssh_user:-root}"
+
+    local ssh_key
+    ssh_key=$(json_get "${PHYS_DETAILS}" "ssh.key")
+
+    local ssh_opts=(
+        -o StrictHostKeyChecking=no
+        -o UserKnownHostsFile=/dev/null
+        -o LogLevel=ERROR
+        -o ConnectTimeout=10
+    )
+
+    if [ -n "${ssh_key}" ]; then
+        local keyfile
+        keyfile=$(mktemp /tmp/.cs-pvesdn-key-XXXXXX)
+        chmod 600 "${keyfile}"
+        printf '%s\n' "${ssh_key}" > "${keyfile}"
+        ssh_opts+=(-i "${keyfile}" -o IdentitiesOnly=yes -o BatchMode=yes)
+        ssh "${ssh_opts[@]}" "${ssh_user}@${node_ip}" "${cmd}"
+        local rc=$?
+        rm -f "${keyfile}"
+        return ${rc}
+    else
+        ssh "${ssh_opts[@]}" "${ssh_user}@${node_ip}" "${cmd}" 2>/dev/null
+        return $?
+    fi
+}
+
+urlencode() {
+    if command -v python3 >/dev/null 2>&1; then
+        python3 -c "import urllib.parse; print(urllib.parse.quote('''$1'''))"
+    else
+        echo "$1"
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# NAT rule management on the gateway node
+#
+# We manage SNAT/DNAT/firewall rules via iptables on the gateway node.
+# Rules are placed in dedicated chains to avoid conflicts with Proxmox's
+# own firewall.
+#
+# Chain naming:
+#   CS_PVE_SDN_<networkId>_PR    – PREROUTING DNAT
+#   CS_PVE_SDN_<networkId>_POST  – POSTROUTING SNAT
+#   CS_PVE_SDN_FWD_<networkId>   – FORWARD filter
+# ---------------------------------------------------------------------------
+
+CHAIN_PREFIX="CS_PVE_SDN"
+
+nat_chain_pr()   { echo "${CHAIN_PREFIX}_${1}_PR"; }
+nat_chain_post() { echo "${CHAIN_PREFIX}_${1}_POST"; }
+filter_chain()   { echo "${CHAIN_PREFIX}_FWD_${1}"; }
+fw_chain()       { echo "${CHAIN_PREFIX}_FW_${1}"; }
+
+# Ensure iptables chain exists and is jumped to from parent
+ensure_iptables_chain() {
+    local node="$1" table="$2" chain="$3" parent="$4"
+
+    node_exec "${node}" "iptables -t ${table} -n -L ${chain} >/dev/null 2>&1 
|| iptables -t ${table} -N ${chain}" || true
+    node_exec "${node}" "iptables -t ${table} -C ${parent} -j ${chain} 
2>/dev/null || iptables -t ${table} -I ${parent} 1 -j ${chain}" || true
+}
+
+# Setup per-network iptables chains on the gateway node
+setup_network_chains() {
+    local node="$1" net_id="$2"
+
+    local pr_chain post_chain fwd_chain
+    pr_chain=$(nat_chain_pr "${net_id}")
+    post_chain=$(nat_chain_post "${net_id}")
+    fwd_chain=$(filter_chain "${net_id}")
+
+    ensure_iptables_chain "${node}" nat "${pr_chain}" PREROUTING
+    ensure_iptables_chain "${node}" nat "${post_chain}" POSTROUTING
+    ensure_iptables_chain "${node}" filter "${fwd_chain}" FORWARD
+
+    log "setup_network_chains: created chains for network ${net_id} on ${node}"
+}
+
+# Remove per-network iptables chains from the gateway node
+cleanup_network_chains() {
+    local node="$1" net_id="$2"
+
+    local pr_chain post_chain fwd_chain
+    pr_chain=$(nat_chain_pr "${net_id}")
+    post_chain=$(nat_chain_post "${net_id}")
+    fwd_chain=$(filter_chain "${net_id}")
+
+    # Remove jumps
+    node_exec "${node}" "iptables -t nat -D PREROUTING -j ${pr_chain} 
2>/dev/null; true" || true
+    node_exec "${node}" "iptables -t nat -D POSTROUTING -j ${post_chain} 
2>/dev/null; true" || true
+    node_exec "${node}" "iptables -t filter -D FORWARD -j ${fwd_chain} 
2>/dev/null; true" || true
+
+    # Flush and delete chains
+    node_exec "${node}" "iptables -t nat -F ${pr_chain} 2>/dev/null; iptables 
-t nat -X ${pr_chain} 2>/dev/null; true" || true
+    node_exec "${node}" "iptables -t nat -F ${post_chain} 2>/dev/null; 
iptables -t nat -X ${post_chain} 2>/dev/null; true" || true
+    node_exec "${node}" "iptables -t filter -F ${fwd_chain} 2>/dev/null; 
iptables -t filter -X ${fwd_chain} 2>/dev/null; true" || true
+
+    log "cleanup_network_chains: removed chains for network ${net_id} on 
${node}"
+}
+
+# ---------------------------------------------------------------------------
+# State management
+# ---------------------------------------------------------------------------
+
+_net_state_dir() { echo "${STATE_DIR}/network-${NETWORK_ID}"; }
+_vpc_state_dir() {
+    if [ -n "${VPC_ID}" ]; then
+        echo "${STATE_DIR}/vpc-${VPC_ID}"
+    else
+        echo "${STATE_DIR}/network-${NETWORK_ID}"
+    fi
+}
+
+save_state() {
+    local dir="$1" key="$2" value="$3"
+    mkdir -p "${dir}"
+    echo "${value}" > "${dir}/${key}"
+}
+
+read_state() {
+    local dir="$1" key="$2" default="${3:-}"
+    if [ -f "${dir}/${key}" ]; then
+        cat "${dir}/${key}"
+    else
+        echo "${default}"
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# Load persisted state
+# ---------------------------------------------------------------------------
+
+_load_state() {
+    local nsd; nsd=$(_net_state_dir)
+    local vsd; vsd=$(_vpc_state_dir)
+
+    [ -z "${VLAN}" ] && VLAN=$(read_state "${nsd}" vlan "")
+    [ -z "${CIDR}" ] && CIDR=$(read_state "${nsd}" cidr "")
+    [ -z "${GATEWAY}" ] && GATEWAY=$(read_state "${nsd}" gateway "")
+
+    # Load VNet and zone names from state
+    STORED_VNET=$(read_state "${nsd}" vnet "")
+    STORED_ZONE=$(read_state "${vsd}" zone "")
+}
+
+##############################################################################
+# Command: ensure-network-device
+#
+# Validate Proxmox SDN API connectivity and return the network device details.
+# For isolated networks: ensure the shared VLAN zone exists.
+# For VPC networks: the zone is created by implement-vpc.
+##############################################################################
+
+if [ "${COMMAND}" = "ensure-network-device" ]; then
+    [ -z "${NETWORK_ID}" ] && [ -z "${VPC_ID}" ] && \
+        die "ensure-network-device: missing --network-id or --vpc-id" 1
+
+    # Validate required connection details
+    if [ -z "${PVE_URL}" ] || [ -z "${PVE_USER}" ] || [ -z "${PVE_TOKEN}" ] || 
[ -z "${PVE_SECRET}" ]; then
+        die "ensure-network-device: missing Proxmox API credentials. Set url, 
user, token, secret in extension details." 1
+    fi
+
+    # Test API connectivity by querying the SDN status
+    log "ensure-network-device: testing Proxmox SDN API at ${PVE_URL}..."
+    resp=$(api_get "/cluster/sdn" 2>/dev/null) || {
+        die "ensure-network-device: cannot reach Proxmox SDN API at 
https://${PVE_URL}:8006"; 2
+    }
+    log "ensure-network-device: Proxmox SDN API is reachable"
+
+    # Determine the zone name
+    if [ -n "${VPC_ID}" ]; then
+        zone=$(vpc_zone_name "${VPC_ID}")
+    else
+        zone=$(isolated_zone_name)
+    fi
+
+    # Determine VNet name
+    vnet=$(vnet_name "${NETWORK_ID:-0}" "${VPC_ID}")
+
+    # Return device details
+    if [ -n "${VPC_ID}" ]; then
+        printf '{"zone":"%s","vnet":"%s","node":"%s","vpc_id":"%s"}\n' \
+            "${zone}" "${vnet}" "${GW_NODE}" "${VPC_ID}"
+    else
+        printf '{"zone":"%s","vnet":"%s","node":"%s"}\n' \
+            "${zone}" "${vnet}" "${GW_NODE}"
+    fi
+
+    log "ensure-network-device: ${NETWORK_ID:+network=${NETWORK_ID} 
}${VPC_ID:+vpc=${VPC_ID} }zone=${zone} vnet=${vnet} node=${GW_NODE}"
+    exit 0
+fi
+
+##############################################################################
+# Command: implement-network
+#
+# 1. Ensure the SDN zone exists (VLAN zone for isolated, per-VPC zone for VPC)
+# 2. Create a VNet with the VLAN tag
+# 3. Create a subnet with gateway and CIDR
+# 4. Apply SDN changes
+# 5. Set up iptables chains on the gateway node for NAT/firewall
+##############################################################################
+
+if [ "${COMMAND}" = "implement-network" ]; then
+    [ -z "${NETWORK_ID}" ] && die "implement-network: missing --network-id" 1
+
+    log "implement-network: network=${NETWORK_ID} vlan=${VLAN} gw=${GATEWAY} 
cidr=${CIDR} vpc=${VPC_ID}"
+
+    # Determine zone and VNet names
+    if [ -n "${VPC_ID}" ]; then
+        zone=$(vpc_zone_name "${VPC_ID}")
+    else
+        zone=$(isolated_zone_name)
+    fi
+    vnet=$(vnet_name "${NETWORK_ID}" "${VPC_ID}")
+
+    # Step 1: Ensure the SDN zone exists
+    if [ -z "${VPC_ID}" ]; then
+        # Isolated network: use shared VLAN zone
+        ensure_sdn_zone "${zone}" "${SDN_ZONE_TYPE}" "${ZONE_BRIDGE}" || \
+            die "implement-network: failed to create SDN zone '${zone}'" 3
+    fi
+    # For VPC tiers, the zone is created by implement-vpc
+
+    # Step 2: Create VNet with VLAN tag
+    local tag="${VLAN}"
+    # Strip "vlan://" prefix if present
+    tag="${tag#vlan://}"
+    local alias_name="CloudStack network ${NETWORK_ID}"
+    create_vnet "${vnet}" "${zone}" "${tag}" "${alias_name}" || \
+        die "implement-network: failed to create VNet '${vnet}'" 3
+
+    # Step 3: Create subnet with gateway and CIDR
+    if [ -n "${CIDR}" ]; then
+        local snat_flag="0"
+        # Enable SNAT on subnet for isolated networks with SourceNat
+        # (For VPC networks, SNAT is managed at VPC level)
+        if [ -z "${VPC_ID}" ]; then
+            snat_flag="1"
+        fi
+
+        create_subnet "${vnet}" "${CIDR}" "${GATEWAY}" "${snat_flag}" "0" "" 
|| \
+            log "WARNING: Failed to create subnet — may already exist"
+    fi
+
+    # Step 4: Apply SDN changes
+    apply_sdn_changes
+
+    # Step 5: Set up iptables chains on the gateway node (for DNAT/firewall)
+    if [ -n "${GW_NODE}" ]; then
+        setup_network_chains "${GW_NODE}" "${NETWORK_ID}" || \
+            log "WARNING: Failed to set up iptables chains on ${GW_NODE}"
+    fi
+
+    # Enable IP forwarding on the gateway node
+    if [ -n "${GW_NODE}" ]; then
+        node_exec "${GW_NODE}" "sysctl -w net.ipv4.ip_forward=1 >/dev/null 
2>&1" || true
+    fi
+
+    # Step 6: Persist state
+    local nsd; nsd=$(_net_state_dir)
+    local vsd; vsd=$(_vpc_state_dir)
+    mkdir -p "${nsd}" "${vsd}"
+    save_state "${nsd}" vlan "${VLAN}"
+    save_state "${nsd}" cidr "${CIDR}"
+    save_state "${nsd}" gateway "${GATEWAY}"
+    save_state "${nsd}" vnet "${vnet}"
+    save_state "${nsd}" zone "${zone}"
+    save_state "${vsd}" zone "${zone}"
+
+    if [ -n "${VPC_ID}" ]; then
+        mkdir -p "${vsd}/tiers"
+        touch "${vsd}/tiers/${NETWORK_ID}"
+    fi
+
+    log "implement-network: done network=${NETWORK_ID} vnet=${vnet} 
zone=${zone}"
+    exit 0
+fi
+
+##############################################################################
+# Command: shutdown-network
+#
+# Remove the subnet and VNet from Proxmox SDN.
+# Clean up iptables chains on the gateway node.
+# For VPC tier networks, the zone is preserved.
+##############################################################################
+
+if [ "${COMMAND}" = "shutdown-network" ]; then
+    [ -z "${NETWORK_ID}" ] && die "shutdown-network: missing --network-id" 1
+    _load_state
+
+    log "shutdown-network: network=${NETWORK_ID} vnet=${STORED_VNET} 
vpc=${VPC_ID}"
+
+    # Clean up iptables chains on the gateway node
+    if [ -n "${GW_NODE}" ]; then
+        cleanup_network_chains "${GW_NODE}" "${NETWORK_ID}" || true
+    fi
+
+    # Remove subnet from VNet
+    if [ -n "${STORED_VNET}" ] && [ -n "${CIDR}" ]; then
+        delete_subnet "${STORED_VNET}" "${CIDR}" || true
+    fi
+
+    # Remove the VNet
+    if [ -n "${STORED_VNET}" ]; then
+        delete_vnet "${STORED_VNET}" || true
+    fi
+
+    # Apply SDN changes
+    apply_sdn_changes
+
+    # For isolated networks, clean up state fully
+    if [ -z "${VPC_ID}" ]; then
+        rm -rf "$(_net_state_dir)" 2>/dev/null || true
+    else
+        # VPC tier: remove from tier list
+        local vsd; vsd=$(_vpc_state_dir)
+        rm -f "${vsd}/tiers/${NETWORK_ID}" 2>/dev/null || true
+    fi
+
+    log "shutdown-network: done network=${NETWORK_ID}"
+    exit 0
+fi
+
+##############################################################################
+# Command: destroy-network
+#
+# Permanently remove the VNet and all associated state.
+##############################################################################
+
+if [ "${COMMAND}" = "destroy-network" ]; then
+    [ -z "${NETWORK_ID}" ] && die "destroy-network: missing --network-id" 1
+    _load_state
+
+    log "destroy-network: network=${NETWORK_ID} vnet=${STORED_VNET} 
vpc=${VPC_ID}"
+
+    # Clean up iptables chains on the gateway node
+    if [ -n "${GW_NODE}" ]; then
+        cleanup_network_chains "${GW_NODE}" "${NETWORK_ID}" || true
+    fi
+
+    # Remove subnet
+    if [ -n "${STORED_VNET}" ] && [ -n "${CIDR}" ]; then
+        delete_subnet "${STORED_VNET}" "${CIDR}" || true
+    fi
+
+    # Remove VNet
+    if [ -n "${STORED_VNET}" ]; then
+        delete_vnet "${STORED_VNET}" || true
+    fi
+
+    # Apply SDN changes
+    apply_sdn_changes
+
+    # Remove per-network state
+    rm -rf "$(_net_state_dir)" 2>/dev/null || true
+
+    # VPC tier cleanup
+    if [ -n "${VPC_ID}" ]; then
+        local vsd; vsd=$(_vpc_state_dir)
+        rm -f "${vsd}/tiers/${NETWORK_ID}" 2>/dev/null || true
+    fi
+
+    log "destroy-network: done network=${NETWORK_ID}"
+    exit 0
+fi
+
+##############################################################################
+# Command: implement-vpc
+#
+# Create a per-VPC VLAN zone.
+# Set up VPC-level source NAT if requested.
+##############################################################################
+
+if [ "${COMMAND}" = "implement-vpc" ]; then
+    [ -z "${VPC_ID}" ] && die "implement-vpc: missing --vpc-id" 1
+
+    log "implement-vpc: vpc=${VPC_ID} cidr=${CIDR} source_nat=${SOURCE_NAT} 
public_ip=${PUBLIC_IP}"
+
+    local zone
+    zone=$(vpc_zone_name "${VPC_ID}")
+
+    # Use the configured zone type for VPC (defaults to vlan)
+    local vpc_zone_type="${SDN_ZONE_TYPE}"
+
+    # Create VPC zone
+    ensure_sdn_zone "${zone}" "${vpc_zone_type}" "${ZONE_BRIDGE}" || \
+        die "implement-vpc: failed to create SDN zone '${zone}'" 3
+
+    # Apply SDN changes
+    apply_sdn_changes
+
+    # Set up VPC-level source NAT on the gateway node
+    if [ "${SOURCE_NAT}" = "true" ] && [ -n "${PUBLIC_IP}" ] && [ -n "${CIDR}" 
] && [ -n "${GW_NODE}" ]; then
+        local post_chain
+        post_chain="${CHAIN_PREFIX}_${VPC_ID}_VPC_POST"
+
+        # Ensure the chain exists
+        ensure_iptables_chain "${GW_NODE}" nat "${post_chain}" POSTROUTING
+
+        # Add SNAT rule for VPC CIDR
+        local pub_vlan="${PUBLIC_VLAN}"
+        pub_vlan="${pub_vlan#vlan://}"
+
+        node_exec "${GW_NODE}" "iptables -t nat -C ${post_chain} -s ${CIDR} -j 
SNAT --to-source ${PUBLIC_IP} 2>/dev/null || \
+            iptables -t nat -A ${post_chain} -s ${CIDR} -j SNAT --to-source 
${PUBLIC_IP}" || true
+
+        log "implement-vpc: SNAT configured: ${CIDR} -> ${PUBLIC_IP} on 
${GW_NODE}"
+    fi
+
+    # Persist VPC state
+    local vsd; vsd=$(_vpc_state_dir)
+    mkdir -p "${vsd}"
+    save_state "${vsd}" zone "${zone}"
+    [ -n "${CIDR}" ] && save_state "${vsd}" cidr "${CIDR}"
+
+    log "implement-vpc: done vpc=${VPC_ID} zone=${zone}"
+    exit 0
+fi
+
+##############################################################################
+# Command: shutdown-vpc
+#
+# Remove the VPC namespace (zone) if no tiers remain.
+##############################################################################
+
+if [ "${COMMAND}" = "shutdown-vpc" ]; then
+    [ -z "${VPC_ID}" ] && die "shutdown-vpc: missing --vpc-id" 1
+
+    log "shutdown-vpc: vpc=${VPC_ID}"
+
+    local vsd; vsd=$(_vpc_state_dir)
+    local zone
+    zone=$(read_state "${vsd}" zone "$(vpc_zone_name "${VPC_ID}")")
+
+    # Clean up VPC-level SNAT chain on gateway node
+    if [ -n "${GW_NODE}" ]; then
+        local post_chain="${CHAIN_PREFIX}_${VPC_ID}_VPC_POST"
+        node_exec "${GW_NODE}" "iptables -t nat -D POSTROUTING -j 
${post_chain} 2>/dev/null; true" || true
+        node_exec "${GW_NODE}" "iptables -t nat -F ${post_chain} 2>/dev/null; 
iptables -t nat -X ${post_chain} 2>/dev/null; true" || true
+    fi
+
+    # Delete the VPC SDN zone (will fail if VNets still exist)
+    delete_sdn_zone "${zone}" || true
+
+    # Apply SDN changes
+    apply_sdn_changes
+
+    log "shutdown-vpc: done vpc=${VPC_ID}"
+    exit 0
+fi
+
+##############################################################################
+# Command: destroy-vpc
+#
+# Permanently remove the VPC zone and all state.
+##############################################################################
+
+if [ "${COMMAND}" = "destroy-vpc" ]; then
+    [ -z "${VPC_ID}" ] && die "destroy-vpc: missing --vpc-id" 1
+
+    log "destroy-vpc: vpc=${VPC_ID}"
+
+    local vsd; vsd=$(_vpc_state_dir)
+    local zone
+    zone=$(read_state "${vsd}" zone "$(vpc_zone_name "${VPC_ID}")")
+
+    # Clean up VPC-level SNAT chain on gateway node
+    if [ -n "${GW_NODE}" ]; then
+        local post_chain="${CHAIN_PREFIX}_${VPC_ID}_VPC_POST"
+        node_exec "${GW_NODE}" "iptables -t nat -D POSTROUTING -j 
${post_chain} 2>/dev/null; true" || true
+        node_exec "${GW_NODE}" "iptables -t nat -F ${post_chain} 2>/dev/null; 
iptables -t nat -X ${post_chain} 2>/dev/null; true" || true
+    fi
+
+    # Delete the VPC SDN zone
+    delete_sdn_zone "${zone}" || true
+
+    # Apply SDN changes
+    apply_sdn_changes
+
+    # Remove VPC state
+    rm -rf "${vsd}" 2>/dev/null || true
+
+    log "destroy-vpc: done vpc=${VPC_ID}"
+    exit 0
+fi
+
+##############################################################################
+# Command: assign-ip
+#
+# Assign a public IP address. For source NAT, configure SNAT on the
+# gateway node.
+##############################################################################
+
+if [ "${COMMAND}" = "assign-ip" ]; then
+    [ -z "${NETWORK_ID}" ] && die "assign-ip: missing --network-id" 1
+    [ -z "${PUBLIC_IP}" ] && die "assign-ip: missing --public-ip" 1
+    _load_state
+
+    log "assign-ip: network=${NETWORK_ID} ip=${PUBLIC_IP} 
source_nat=${SOURCE_NAT} vpc=${VPC_ID}"
+
+    if [ -n "${GW_NODE}" ]; then
+        local post_chain fwd_chain
+        post_chain=$(nat_chain_post "${NETWORK_ID}")
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+
+        # Ensure chains exist
+        setup_network_chains "${GW_NODE}" "${NETWORK_ID}" || true
+
+        # Assign the public IP to the public bridge on the gateway node
+        if [ -n "${PUBLIC_CIDR}" ] && echo "${PUBLIC_CIDR}" | grep -q '/'; then
+            local prefix
+            prefix=$(echo "${PUBLIC_CIDR}" | cut -d'/' -f2)
+            node_exec "${GW_NODE}" "ip addr show dev ${PUB_BRIDGE} | grep -q 
'${PUBLIC_IP}/' || \
+                ip addr add ${PUBLIC_IP}/${prefix} dev ${PUB_BRIDGE}" || true
+        else
+            node_exec "${GW_NODE}" "ip addr show dev ${PUB_BRIDGE} | grep -q 
'${PUBLIC_IP}/' || \
+                ip addr add ${PUBLIC_IP}/32 dev ${PUB_BRIDGE}" || true
+        fi
+
+        # Set default route via public gateway if provided
+        if [ -n "${PUBLIC_GATEWAY}" ]; then
+            node_exec "${GW_NODE}" "ip route replace default via 
${PUBLIC_GATEWAY} dev ${PUB_BRIDGE} 2>/dev/null || true" || true
+        fi
+
+        # Source NAT: add SNAT rule for isolated networks
+        if [ "${SOURCE_NAT}" = "true" ] && [ -n "${CIDR}" ] && [ -z 
"${VPC_ID}" ]; then
+            node_exec "${GW_NODE}" "iptables -t nat -C ${post_chain} -s 
${CIDR} -j SNAT --to-source ${PUBLIC_IP} 2>/dev/null || \
+                iptables -t nat -A ${post_chain} -s ${CIDR} -j SNAT 
--to-source ${PUBLIC_IP}" || true
+
+            node_exec "${GW_NODE}" "iptables -t filter -C ${fwd_chain} -s 
${CIDR} -j ACCEPT 2>/dev/null || \
+                iptables -t filter -A ${fwd_chain} -s ${CIDR} -j ACCEPT" || 
true
+
+            log "assign-ip: SNAT configured: ${CIDR} -> ${PUBLIC_IP}"
+        fi
+
+        # Send gratuitous ARP
+        node_exec "${GW_NODE}" "arping -c 3 -U -I ${PUB_BRIDGE} ${PUBLIC_IP} 
>/dev/null 2>&1 || true" || true
+    fi
+
+    # Persist public IP state
+    local vsd; vsd=$(_vpc_state_dir)
+    mkdir -p "${vsd}/ips"
+    save_state "${vsd}/ips" "${PUBLIC_IP}" "${SOURCE_NAT}"
+    save_state "${vsd}/ips" "${PUBLIC_IP}.pvlan" "${PUBLIC_VLAN}"
+    save_state "${vsd}/ips" "${PUBLIC_IP}.tier" "${NETWORK_ID}"
+
+    log "assign-ip: done ${PUBLIC_IP} on network ${NETWORK_ID}"
+    exit 0
+fi
+
+##############################################################################
+# Command: release-ip
+#
+# Release a public IP address. Remove SNAT rules.
+##############################################################################
+
+if [ "${COMMAND}" = "release-ip" ]; then
+    [ -z "${NETWORK_ID}" ] && die "release-ip: missing --network-id" 1
+    [ -z "${PUBLIC_IP}" ] && die "release-ip: missing --public-ip" 1
+    _load_state
+
+    log "release-ip: network=${NETWORK_ID} ip=${PUBLIC_IP}"
+
+    if [ -n "${GW_NODE}" ]; then
+        local post_chain fwd_chain pr_chain
+        post_chain=$(nat_chain_post "${NETWORK_ID}")
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+        pr_chain=$(nat_chain_pr "${NETWORK_ID}")
+
+        # Remove SNAT rule
+        if [ -n "${CIDR}" ]; then
+            node_exec "${GW_NODE}" "iptables -t nat -D ${post_chain} -s 
${CIDR} -j SNAT --to-source ${PUBLIC_IP} 2>/dev/null || true" || true
+            node_exec "${GW_NODE}" "iptables -t filter -D ${fwd_chain} -s 
${CIDR} -j ACCEPT 2>/dev/null || true" || true
+        fi
+
+        # Remove all DNAT rules for this public IP
+        node_exec "${GW_NODE}" "iptables -t nat -S ${pr_chain} 2>/dev/null | 
grep -- '-d ${PUBLIC_IP}' | while read rule; do \
+            iptables -t nat -D ${pr_chain} \${rule#-A ${pr_chain}} 2>/dev/null 
|| true; done" || true
+
+        # Remove IP from public bridge
+        node_exec "${GW_NODE}" "ip addr del ${PUBLIC_IP}/32 dev ${PUB_BRIDGE} 
2>/dev/null || \
+            ip addr show dev ${PUB_BRIDGE} | grep '${PUBLIC_IP}/' | awk 
'{print \$2}' | \
+            xargs -I{} ip addr del {} dev ${PUB_BRIDGE} 2>/dev/null || true" 
|| true
+    fi
+
+    # Remove state
+    local vsd; vsd=$(_vpc_state_dir)
+    rm -f "${vsd}/ips/${PUBLIC_IP}" \
+          "${vsd}/ips/${PUBLIC_IP}.pvlan" \
+          "${vsd}/ips/${PUBLIC_IP}.tier" 2>/dev/null || true
+
+    log "release-ip: done ${PUBLIC_IP} on network ${NETWORK_ID}"
+    exit 0
+fi
+
+##############################################################################
+# Command: add-static-nat
+#
+# Configure 1:1 NAT (DNAT + SNAT) for a public IP ↔ private IP.
+##############################################################################
+
+if [ "${COMMAND}" = "add-static-nat" ]; then
+    [ -z "${NETWORK_ID}" ] && die "add-static-nat: missing --network-id" 1
+    [ -z "${PUBLIC_IP}" ] && die "add-static-nat: missing --public-ip" 1
+    [ -z "${PRIVATE_IP}" ] && die "add-static-nat: missing --private-ip" 1
+    _load_state
+
+    log "add-static-nat: network=${NETWORK_ID} ${PUBLIC_IP} <-> ${PRIVATE_IP}"
+
+    if [ -n "${GW_NODE}" ]; then
+        local pr_chain post_chain fwd_chain
+        pr_chain=$(nat_chain_pr "${NETWORK_ID}")
+        post_chain=$(nat_chain_post "${NETWORK_ID}")
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+
+        # DNAT: inbound public IP → private IP
+        node_exec "${GW_NODE}" "iptables -t nat -C ${pr_chain} -d ${PUBLIC_IP} 
-j DNAT --to-destination ${PRIVATE_IP} 2>/dev/null || \
+            iptables -t nat -A ${pr_chain} -d ${PUBLIC_IP} -j DNAT 
--to-destination ${PRIVATE_IP}" || true
+
+        # SNAT: outbound private IP → public IP
+        node_exec "${GW_NODE}" "iptables -t nat -C ${post_chain} -s 
${PRIVATE_IP} -j SNAT --to-source ${PUBLIC_IP} 2>/dev/null || \
+            iptables -t nat -A ${post_chain} -s ${PRIVATE_IP} -j SNAT 
--to-source ${PUBLIC_IP}" || true
+
+        # FORWARD: allow traffic to/from private IP
+        node_exec "${GW_NODE}" "iptables -t filter -C ${fwd_chain} -d 
${PRIVATE_IP} -j ACCEPT 2>/dev/null || \
+            iptables -t filter -A ${fwd_chain} -d ${PRIVATE_IP} -j ACCEPT" || 
true
+        node_exec "${GW_NODE}" "iptables -t filter -C ${fwd_chain} -s 
${PRIVATE_IP} -j ACCEPT 2>/dev/null || \
+            iptables -t filter -A ${fwd_chain} -s ${PRIVATE_IP} -j ACCEPT" || 
true
+    fi
+
+    # Persist state
+    local vsd; vsd=$(_vpc_state_dir)
+    mkdir -p "${vsd}/static-nat"
+    save_state "${vsd}/static-nat" "${PUBLIC_IP}" "${PRIVATE_IP}"
+
+    log "add-static-nat: done ${PUBLIC_IP} <-> ${PRIVATE_IP}"
+    exit 0
+fi
+
+##############################################################################
+# Command: delete-static-nat
+##############################################################################
+
+if [ "${COMMAND}" = "delete-static-nat" ]; then
+    [ -z "${NETWORK_ID}" ] && die "delete-static-nat: missing --network-id" 1
+    [ -z "${PUBLIC_IP}" ] && die "delete-static-nat: missing --public-ip" 1
+    _load_state
+
+    # Load private IP from state if not provided
+    local vsd; vsd=$(_vpc_state_dir)
+    if [ -z "${PRIVATE_IP}" ] && [ -f "${vsd}/static-nat/${PUBLIC_IP}" ]; then
+        PRIVATE_IP=$(cat "${vsd}/static-nat/${PUBLIC_IP}")
+    fi
+    [ -z "${PRIVATE_IP}" ] && die "delete-static-nat: missing --private-ip and 
no saved state" 1
+
+    log "delete-static-nat: network=${NETWORK_ID} ${PUBLIC_IP} <-> 
${PRIVATE_IP}"
+
+    if [ -n "${GW_NODE}" ]; then
+        local pr_chain post_chain fwd_chain
+        pr_chain=$(nat_chain_pr "${NETWORK_ID}")
+        post_chain=$(nat_chain_post "${NETWORK_ID}")
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+
+        node_exec "${GW_NODE}" "iptables -t nat -D ${pr_chain} -d ${PUBLIC_IP} 
-j DNAT --to-destination ${PRIVATE_IP} 2>/dev/null || true" || true
+        node_exec "${GW_NODE}" "iptables -t nat -D ${post_chain} -s 
${PRIVATE_IP} -j SNAT --to-source ${PUBLIC_IP} 2>/dev/null || true" || true
+        node_exec "${GW_NODE}" "iptables -t filter -D ${fwd_chain} -d 
${PRIVATE_IP} -j ACCEPT 2>/dev/null || true" || true
+        node_exec "${GW_NODE}" "iptables -t filter -D ${fwd_chain} -s 
${PRIVATE_IP} -j ACCEPT 2>/dev/null || true" || true
+    fi
+
+    rm -f "${vsd}/static-nat/${PUBLIC_IP}" 2>/dev/null || true
+
+    log "delete-static-nat: done ${PUBLIC_IP} <-> ${PRIVATE_IP}"
+    exit 0
+fi
+
+##############################################################################
+# Command: add-port-forward
+##############################################################################
+
+if [ "${COMMAND}" = "add-port-forward" ]; then
+    [ -z "${NETWORK_ID}" ] && die "add-port-forward: missing --network-id" 1
+    [ -z "${PUBLIC_IP}" ] && die "add-port-forward: missing --public-ip" 1
+    [ -z "${PUBLIC_PORT}" ] && die "add-port-forward: missing --public-port" 1
+    [ -z "${PRIVATE_IP}" ] && die "add-port-forward: missing --private-ip" 1
+    [ -z "${PRIVATE_PORT}" ] && die "add-port-forward: missing --private-port" 
1
+    _load_state
+    [ -z "${PROTOCOL}" ] && PROTOCOL="tcp"
+
+    log "add-port-forward: network=${NETWORK_ID} ${PUBLIC_IP}:${PUBLIC_PORT} 
-> ${PRIVATE_IP}:${PRIVATE_PORT} (${PROTOCOL})"
+
+    if [ -n "${GW_NODE}" ]; then
+        local pr_chain fwd_chain
+        pr_chain=$(nat_chain_pr "${NETWORK_ID}")
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+
+        # DNAT
+        node_exec "${GW_NODE}" "iptables -t nat -C ${pr_chain} -p ${PROTOCOL} 
-d ${PUBLIC_IP} --dport ${PUBLIC_PORT} \
+            -j DNAT --to-destination ${PRIVATE_IP}:${PRIVATE_PORT} 2>/dev/null 
|| \
+            iptables -t nat -A ${pr_chain} -p ${PROTOCOL} -d ${PUBLIC_IP} 
--dport ${PUBLIC_PORT} \
+            -j DNAT --to-destination ${PRIVATE_IP}:${PRIVATE_PORT}" || true
+
+        # FORWARD
+        node_exec "${GW_NODE}" "iptables -t filter -C ${fwd_chain} -p 
${PROTOCOL} -d ${PRIVATE_IP} --dport ${PRIVATE_PORT} -j ACCEPT 2>/dev/null || \
+            iptables -t filter -A ${fwd_chain} -p ${PROTOCOL} -d ${PRIVATE_IP} 
--dport ${PRIVATE_PORT} -j ACCEPT" || true
+        node_exec "${GW_NODE}" "iptables -t filter -C ${fwd_chain} -p 
${PROTOCOL} -s ${PRIVATE_IP} --sport ${PRIVATE_PORT} -j ACCEPT 2>/dev/null || \
+            iptables -t filter -A ${fwd_chain} -p ${PROTOCOL} -s ${PRIVATE_IP} 
--sport ${PRIVATE_PORT} -j ACCEPT" || true
+    fi
+
+    # Persist state
+    local safe_port
+    safe_port=$(echo "${PUBLIC_PORT}" | tr ':' '-')
+    local vsd; vsd=$(_vpc_state_dir)
+    mkdir -p "${vsd}/port-forward"
+    echo "${PROTOCOL} ${PUBLIC_IP} ${PUBLIC_PORT} ${PRIVATE_IP} 
${PRIVATE_PORT}" > \
+        "${vsd}/port-forward/${PROTOCOL}_${PUBLIC_IP}_${safe_port}"
+
+    log "add-port-forward: done ${PUBLIC_IP}:${PUBLIC_PORT} -> 
${PRIVATE_IP}:${PRIVATE_PORT}"
+    exit 0
+fi
+
+##############################################################################
+# Command: delete-port-forward
+##############################################################################
+
+if [ "${COMMAND}" = "delete-port-forward" ]; then
+    [ -z "${NETWORK_ID}" ] && die "delete-port-forward: missing --network-id" 1
+    [ -z "${PUBLIC_IP}" ] && die "delete-port-forward: missing --public-ip" 1
+    [ -z "${PUBLIC_PORT}" ] && die "delete-port-forward: missing 
--public-port" 1
+    [ -z "${PRIVATE_IP}" ] && die "delete-port-forward: missing --private-ip" 1
+    [ -z "${PRIVATE_PORT}" ] && die "delete-port-forward: missing 
--private-port" 1
+    _load_state
+    [ -z "${PROTOCOL}" ] && PROTOCOL="tcp"
+
+    log "delete-port-forward: network=${NETWORK_ID} 
${PUBLIC_IP}:${PUBLIC_PORT} -> ${PRIVATE_IP}:${PRIVATE_PORT}"
+
+    if [ -n "${GW_NODE}" ]; then
+        local pr_chain fwd_chain
+        pr_chain=$(nat_chain_pr "${NETWORK_ID}")
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+
+        node_exec "${GW_NODE}" "iptables -t nat -D ${pr_chain} -p ${PROTOCOL} 
-d ${PUBLIC_IP} --dport ${PUBLIC_PORT} \
+            -j DNAT --to-destination ${PRIVATE_IP}:${PRIVATE_PORT} 2>/dev/null 
|| true" || true
+        node_exec "${GW_NODE}" "iptables -t filter -D ${fwd_chain} -p 
${PROTOCOL} -d ${PRIVATE_IP} --dport ${PRIVATE_PORT} -j ACCEPT 2>/dev/null || 
true" || true
+        node_exec "${GW_NODE}" "iptables -t filter -D ${fwd_chain} -p 
${PROTOCOL} -s ${PRIVATE_IP} --sport ${PRIVATE_PORT} -j ACCEPT 2>/dev/null || 
true" || true
+    fi
+
+    local safe_port
+    safe_port=$(echo "${PUBLIC_PORT}" | tr ':' '-')
+    local vsd; vsd=$(_vpc_state_dir)
+    rm -f "${vsd}/port-forward/${PROTOCOL}_${PUBLIC_IP}_${safe_port}" 
2>/dev/null || true
+
+    log "delete-port-forward: done"
+    exit 0
+fi
+
+##############################################################################
+# Command: apply-fw-rules
+#
+# Apply firewall rules. Reads base64-encoded JSON from --fw-rules-file.
+# Uses iptables on the gateway node.
+##############################################################################
+
+if [ "${COMMAND}" = "apply-fw-rules" ]; then
+    [ -z "${NETWORK_ID}" ] && die "apply-fw-rules: missing --network-id" 1
+    _load_state
+
+    log "apply-fw-rules: network=${NETWORK_ID}"
+
+    # Read and decode the firewall rules payload
+    local rules_json=""
+    if [ -n "${FW_RULES_FILE}" ] && [ -f "${FW_RULES_FILE}" ]; then
+        rules_json=$(base64 -d < "${FW_RULES_FILE}" 2>/dev/null || cat 
"${FW_RULES_FILE}")
+    fi
+
+    if [ -z "${rules_json}" ] || [ "${rules_json}" = "{}" ]; then
+        log "apply-fw-rules: no rules to apply"
+        exit 0
+    fi
+
+    if [ -n "${GW_NODE}" ] && command -v jq >/dev/null 2>&1; then
+        local fwd_chain fw_chain
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+        fw_chain=$(fw_chain "${NETWORK_ID}")
+
+        # Ensure firewall chain exists
+        ensure_iptables_chain "${GW_NODE}" filter "${fw_chain}" "${fwd_chain}"
+
+        # Flush the firewall chain and rebuild
+        node_exec "${GW_NODE}" "iptables -t filter -F ${fw_chain}" || true
+
+        local default_egress_allow
+        default_egress_allow=$(echo "${rules_json}" | jq -r 
'.default_egress_allow // true')
+        local cidr
+        cidr=$(echo "${rules_json}" | jq -r '.cidr // ""')
+
+        # Process each rule
+        echo "${rules_json}" | jq -c '.rules[]?' 2>/dev/null | while IFS= read 
-r rule; do
+            local rule_type protocol port_start port_end public_ip
+            rule_type=$(echo "${rule}" | jq -r '.type // "ingress"')
+            protocol=$(echo "${rule}" | jq -r '.protocol // "all"')
+            port_start=$(echo "${rule}" | jq -r '.portStart // empty')
+            port_end=$(echo "${rule}" | jq -r '.portEnd // empty')
+            public_ip=$(echo "${rule}" | jq -r '.publicIp // empty')
+
+            local src_cidrs
+            src_cidrs=$(echo "${rule}" | jq -r '.sourceCidrs[]? // empty' 
2>/dev/null)
+
+            if [ "${rule_type}" = "egress" ]; then
+                # Egress rules
+                local action="ACCEPT"
+                [ "${default_egress_allow}" = "true" ] && action="DROP"
+
+                local ipt_args="-t filter"
+                if [ "${protocol}" != "all" ] && [ -n "${protocol}" ]; then
+                    ipt_args+=" -p ${protocol}"
+                    if [ -n "${port_start}" ]; then
+                        if [ -n "${port_end}" ] && [ "${port_end}" != 
"${port_start}" ]; then
+                            ipt_args+=" --dport ${port_start}:${port_end}"
+                        else
+                            ipt_args+=" --dport ${port_start}"
+                        fi
+                    fi
+                fi
+
+                for src in ${src_cidrs}; do
+                    node_exec "${GW_NODE}" "iptables ${ipt_args} -A 
${fw_chain} -s ${cidr} -d ${src} -j ${action}" || true
+                done
+            else
+                # Ingress rules
+                local ipt_args="-t filter"
+                if [ "${protocol}" != "all" ] && [ -n "${protocol}" ]; then
+                    ipt_args+=" -p ${protocol}"
+                    if [ -n "${port_start}" ]; then
+                        if [ -n "${port_end}" ] && [ "${port_end}" != 
"${port_start}" ]; then
+                            ipt_args+=" --dport ${port_start}:${port_end}"
+                        else
+                            ipt_args+=" --dport ${port_start}"
+                        fi
+                    fi
+                fi
+
+                if [ -n "${public_ip}" ]; then
+                    ipt_args+=" -d ${public_ip}"
+                fi
+
+                for src in ${src_cidrs}; do
+                    node_exec "${GW_NODE}" "iptables ${ipt_args} -A 
${fw_chain} -s ${src} -j ACCEPT" || true
+                done
+            fi
+        done
+
+        # Add default egress policy
+        if [ "${default_egress_allow}" = "true" ] && [ -n "${cidr}" ]; then
+            node_exec "${GW_NODE}" "iptables -t filter -A ${fw_chain} -s 
${cidr} -j ACCEPT" || true
+        elif [ -n "${cidr}" ]; then
+            node_exec "${GW_NODE}" "iptables -t filter -A ${fw_chain} -s 
${cidr} -j DROP" || true
+        fi
+
+        log "apply-fw-rules: firewall rules applied on ${GW_NODE}"
+    else
+        log "apply-fw-rules: skipped (no gateway node or jq not available)"
+    fi
+
+    exit 0
+fi
+
+##############################################################################
+# Command: apply-network-acl
+#
+# Apply VPC Network ACL rules on the gateway node.
+##############################################################################
+
+if [ "${COMMAND}" = "apply-network-acl" ]; then
+    [ -z "${NETWORK_ID}" ] && die "apply-network-acl: missing --network-id" 1
+    _load_state
+
+    log "apply-network-acl: network=${NETWORK_ID}"
+
+    local acl_json=""
+    if [ -n "${ACL_RULES_FILE}" ] && [ -f "${ACL_RULES_FILE}" ]; then
+        acl_json=$(base64 -d < "${ACL_RULES_FILE}" 2>/dev/null || cat 
"${ACL_RULES_FILE}")
+    fi
+
+    if [ -z "${acl_json}" ] || [ "${acl_json}" = "[]" ]; then
+        log "apply-network-acl: no ACL rules to apply"
+        exit 0
+    fi
+
+    if [ -n "${GW_NODE}" ] && command -v jq >/dev/null 2>&1; then
+        local fwd_chain acl_chain
+        fwd_chain=$(filter_chain "${NETWORK_ID}")
+        acl_chain="${CHAIN_PREFIX}_ACL_${NETWORK_ID}"
+
+        # Ensure ACL chain exists
+        ensure_iptables_chain "${GW_NODE}" filter "${acl_chain}" "${fwd_chain}"
+
+        # Flush and rebuild
+        node_exec "${GW_NODE}" "iptables -t filter -F ${acl_chain}" || true
+
+        # Allow established connections
+        node_exec "${GW_NODE}" "iptables -t filter -A ${acl_chain} -m state 
--state RELATED,ESTABLISHED -j ACCEPT" || true
+
+        # Process rules in order
+        echo "${acl_json}" | jq -c '.[]?' 2>/dev/null | while IFS= read -r 
rule; do
+            local action traffic_type protocol port_start port_end
+            action=$(echo "${rule}" | jq -r '.action // "Allow"')
+            traffic_type=$(echo "${rule}" | jq -r '.trafficType // "Ingress"')
+            protocol=$(echo "${rule}" | jq -r '.protocol // "all"')
+            port_start=$(echo "${rule}" | jq -r '.portStart // empty')
+            port_end=$(echo "${rule}" | jq -r '.portEnd // empty')
+
+            local ipt_action="ACCEPT"
+            [ "${action}" = "Deny" ] && ipt_action="DROP"
+
+            local ipt_args=""
+            if [ "${protocol}" != "all" ] && [ -n "${protocol}" ]; then
+                ipt_args+=" -p ${protocol}"
+                if [ -n "${port_start}" ]; then
+                    if [ -n "${port_end}" ] && [ "${port_end}" != 
"${port_start}" ]; then
+                        ipt_args+=" --dport ${port_start}:${port_end}"
+                    else
+                        ipt_args+=" --dport ${port_start}"
+                    fi
+                fi
+            fi
+
+            local src_cidrs
+            src_cidrs=$(echo "${rule}" | jq -r '.sourceCidrs[]? // empty' 
2>/dev/null)
+            local dest_cidrs
+            dest_cidrs=$(echo "${rule}" | jq -r '.destCidrs[]? // empty' 
2>/dev/null)
+
+            if [ "${traffic_type}" = "Ingress" ]; then
+                for src in ${src_cidrs:-0.0.0.0/0}; do
+                    node_exec "${GW_NODE}" "iptables -t filter -A 
${acl_chain}${ipt_args} -s ${src} -j ${ipt_action}" || true
+                done
+            else
+                for dst in ${dest_cidrs:-0.0.0.0/0}; do
+                    node_exec "${GW_NODE}" "iptables -t filter -A 
${acl_chain}${ipt_args} -d ${dst} -j ${ipt_action}" || true
+                done
+            fi
+        done
+
+        # Default deny at end
+        node_exec "${GW_NODE}" "iptables -t filter -A ${acl_chain} -j DROP" || 
true
+
+        log "apply-network-acl: ACL rules applied on ${GW_NODE}"
+    fi
+
+    exit 0
+fi
+
+##############################################################################
+# DHCP/DNS/UserData commands
+#
+# Proxmox SDN can provide DHCP natively via dnsmasq on nodes when
+# subnets are configured with DHCP ranges.  For basic DHCP operations
+# we update the Proxmox SDN subnet configuration.
+#
+# VM metadata (userdata, password, SSH key, network config) is delivered
+# via a small cloud-init ISO (cidata label) created by the
+# save-vm-data / save-userdata / save-password / save-sshkey commands
+# and attached to the VM via the Proxmox API.
+#
+# This integrates with the Proxmox.sh VM orchestrator: the VM is created
+# by Proxmox.sh, then this extension attaches the cloud-init drive with
+# the network and metadata information.
+##############################################################################
+
+case "${COMMAND}" in
+    config-dhcp-subnet|config-dns-subnet)
+        log "${COMMAND}: network=${NETWORK_ID} (handled by Proxmox SDN subnet 
DHCP)"
+        # Update the Proxmox SDN subnet to enable DHCP if needed
+        _load_state
+        if [ -n "${STORED_VNET}" ] && [ -n "${CIDR}" ] && [ -n "${GATEWAY}" ]; 
then
+            local sub_id
+            sub_id=$(subnet_id "${STORED_VNET}" "${CIDR}")
+            # Try to enable DHCP on the subnet via dhcp-dns-server
+            local dns_ip="${EXTENSION_IP:-${GATEWAY}}"
+            api_put "/cluster/sdn/vnets/${STORED_VNET}/subnets/${sub_id}" \
+                "gateway=${GATEWAY}&dhcp-dns-server=${dns_ip}" 2>/dev/null || 
true
+            apply_sdn_changes
+        fi
+        exit 0
+        ;;
+
+    remove-dhcp-subnet|remove-dns-subnet)
+        log "${COMMAND}: network=${NETWORK_ID} (Proxmox SDN manages DHCP 
lifecycle)"
+        exit 0
+        ;;
+
+    add-dhcp-entry)
+        log "add-dhcp-entry: network=${NETWORK_ID} mac=${MAC} ip=${VM_IP} 
(delegated to Proxmox SDN IPAM)"
+        # Proxmox SDN IPAM can manage DHCP entries.  If the PVE cluster has
+        # IPAM configured (e.g., PVE IPAM, Netbox, phpIPAM), we could
+        # register the MAC→IP mapping via the IPAM API.  For now, Proxmox
+        # SDN's built-in dnsmasq handles DHCP from the subnet range.
+        exit 0
+        ;;
+
+    remove-dhcp-entry)
+        log "remove-dhcp-entry: network=${NETWORK_ID} mac=${MAC} (delegated to 
Proxmox SDN IPAM)"
+        exit 0
+        ;;
+
+    set-dhcp-options)
+        log "set-dhcp-options: network=${NETWORK_ID} (not supported by Proxmox 
SDN)"
+        exit 0
+        ;;
+
+    add-dns-entry|remove-dns-entry)
+        log "${COMMAND}: network=${NETWORK_ID} (delegated to Proxmox SDN DNS)"
+        exit 0
+        ;;
+
+    
save-vm-data|save-userdata|save-password|save-sshkey|save-hypervisor-hostname)
+        log "${COMMAND}: network=${NETWORK_ID} ip=${VM_IP} 
vmid=${PROXMOX_VMID}"
+        _load_state
+
+        # Resolve the Proxmox VMID — may be passed via --vmid, found in
+        # VM_DATA_FILE JSON, or looked up from our state directory.
+        local vmid="${PROXMOX_VMID}"
+        if [ -z "${vmid}" ] && [ -n "${VM_DATA_FILE}" ] && [ -f 
"${VM_DATA_FILE}" ]; then
+            vmid=$(jq -r '.details.proxmox_vmid // empty' "${VM_DATA_FILE}" 
2>/dev/null || true)
+        fi
+        if [ -z "${vmid}" ] && [ -n "${VM_IP}" ]; then
+            local nsd; nsd=$(_net_state_dir)
+            vmid=$(read_state "${nsd}/vms" "${VM_IP}" "")
+        fi
+        if [ -z "${vmid}" ]; then
+            log "${COMMAND}: cannot determine Proxmox VMID — skipping 
cloud-init"
+            exit 0
+        fi
+
+        # Determine the node where the VM lives
+        local ci_node="${GW_NODE:-${PVE_NODE}}"
+
+        # Build cloud-init data directory
+        local ci_dir
+        ci_dir=$(mktemp -d /tmp/cs-cloudinit-XXXXXX)
+        trap 'rm -rf "${ci_dir}"' EXIT
+
+        # -- meta-data --
+        local instance_id="${HOSTNAME:-${VM_IP:-i-unknown}}"
+        local local_hostname="${HOSTNAME:-${VM_IP:-localhost}}"
+        cat > "${ci_dir}/meta-data" <<EOMETA
+instance-id: ${instance_id}
+local-hostname: ${local_hostname}
+EOMETA
+
+        # -- network-config (NoCloud v2 format) --
+        if [ -n "${VM_IP}" ] && [ -n "${CIDR}" ]; then
+            local prefix
+            prefix=$(echo "${CIDR}" | cut -d'/' -f2)
+            local gw="${GATEWAY}"
+            local dns="${DNS_SERVER:-${GATEWAY}}"
+            cat > "${ci_dir}/network-config" <<EONET
+version: 2
+ethernets:
+  id0:
+    match:
+      name: "e*"
+    addresses:
+      - ${VM_IP}/${prefix}
+    gateway4: ${gw}
+    nameservers:
+      addresses:
+        - ${dns}
+EONET
+        fi
+
+        # -- user-data --
+        local ud_content=""
+        if [ -n "${USERDATA}" ]; then
+            ud_content="${USERDATA}"
+        elif [ -n "${VM_DATA_FILE}" ] && [ -f "${VM_DATA_FILE}" ]; then
+            ud_content=$(jq -r '.userdata // empty' "${VM_DATA_FILE}" 
2>/dev/null || true)
+        fi
+
+        if [ -n "${ud_content}" ]; then
+            # If user-data starts with #cloud-config or #! use it as-is
+            printf '%s\n' "${ud_content}" > "${ci_dir}/user-data"
+        else
+            printf '#cloud-config\n' > "${ci_dir}/user-data"
+        fi
+
+        # Append password / SSH key if provided
+        if [ -n "${VM_PASSWORD}" ]; then
+            {
+                grep -q '^#cloud-config' "${ci_dir}/user-data" || printf 
'#cloud-config\n'
+                printf 'password: %s\nchpasswd:\n  expire: false\nssh_pwauth: 
true\n' "${VM_PASSWORD}"
+            } >> "${ci_dir}/user-data"
+        fi
+        if [ -n "${VM_SSHKEY}" ]; then
+            {
+                grep -q '^#cloud-config' "${ci_dir}/user-data" || printf 
'#cloud-config\n'
+                printf 'ssh_authorized_keys:\n  - %s\n' "${VM_SSHKEY}"
+            } >> "${ci_dir}/user-data"
+        fi
+
+        # Create the cloud-init ISO (cidata label)
+        local iso_name="cloudinit-${vmid}.iso"
+        local iso_path="${ci_dir}/${iso_name}"
+        if command -v genisoimage >/dev/null 2>&1; then
+            genisoimage -o "${iso_path}" -V cidata -J -R \
+                "${ci_dir}/meta-data" \
+                "${ci_dir}/network-config" \
+                "${ci_dir}/user-data" >/dev/null 2>&1
+        elif command -v mkisofs >/dev/null 2>&1; then
+            mkisofs -o "${iso_path}" -V cidata -J -R \
+                "${ci_dir}/meta-data" \
+                "${ci_dir}/network-config" \
+                "${ci_dir}/user-data" >/dev/null 2>&1
+        elif command -v xorrisofs >/dev/null 2>&1; then
+            xorrisofs -o "${iso_path}" -V cidata -J -R \
+                "${ci_dir}/meta-data" \
+                "${ci_dir}/network-config" \
+                "${ci_dir}/user-data" >/dev/null 2>&1
+        else
+            log "${COMMAND}: no ISO creation tool found 
(genisoimage/mkisofs/xorrisofs) — skipping"
+            exit 0
+        fi
+
+        if [ ! -f "${iso_path}" ]; then
+            log "${COMMAND}: failed to create cloud-init ISO"
+            exit 0
+        fi
+
+        # Upload the ISO to Proxmox storage
+        local upload_resp
+        upload_resp=$(curl -s -w '\n%{http_code}' \
+            -X POST \
+            -H "Authorization: 
PVEAPIToken=${PVE_USER}!${PVE_TOKEN}=${PVE_SECRET}" \
+            ${PVE_VERIFY_TLS:+"$([ "${PVE_VERIFY_TLS}" = "false" ] && echo 
'-k')"} \
+            -F "content=iso" \
+            -F "filename=@${iso_path}" \
+            
"https://${PVE_URL}:8006/api2/json/nodes/${ci_node}/storage/${CLOUDINIT_STORAGE}/upload";
 \
+            2>&1) || true
+        local upload_code
+        upload_code=$(echo "${upload_resp}" | tail -1)
+        log "${COMMAND}: ISO upload to ${CLOUDINIT_STORAGE} on ${ci_node}: 
HTTP ${upload_code}"
+
+        # Attach the ISO to the VM as ide2 (cloud-init drive)
+        local ide2_val="${CLOUDINIT_STORAGE}:iso/${iso_name},media=cdrom"
+        api_put "/nodes/${ci_node}/qemu/${vmid}/config" "ide2=$(urlencode 
"${ide2_val}")" || {
+            log "${COMMAND}: WARNING — failed to attach cloud-init ISO to VM 
${vmid}"
+        }
+
+        # Save VM IP → VMID mapping for future lookups
+        if [ -n "${VM_IP}" ]; then
+            local nsd; nsd=$(_net_state_dir)
+            mkdir -p "${nsd}/vms"
+            save_state "${nsd}/vms" "${VM_IP}" "${vmid}"
+        fi
+
+        log "${COMMAND}: cloud-init ISO attached to VM ${vmid} on ${ci_node}"
+        exit 0
+        ;;
+
+    apply-lb-rules)
+        log "apply-lb-rules: network=${NETWORK_ID} (LB not natively supported 
by Proxmox SDN)"
+        # Load balancing would require an external LB (HAProxy, etc.)
+        # on the gateway node.  For now, this is a no-op.
+        exit 0
+        ;;
+
+    restore-network)
+        log "restore-network: network=${NETWORK_ID} (state managed by Proxmox 
SDN)"
+        exit 0
+        ;;
+
+    update-vpc-source-nat-ip)
+        [ -z "${VPC_ID}" ] && die "update-vpc-source-nat-ip: missing --vpc-id" 
1
+        _load_state
+        log "update-vpc-source-nat-ip: vpc=${VPC_ID} public_ip=${PUBLIC_IP}"
+
+        if [ -n "${GW_NODE}" ] && [ -n "${PUBLIC_IP}" ] && [ -n "${CIDR}" ]; 
then
+            local post_chain="${CHAIN_PREFIX}_${VPC_ID}_VPC_POST"
+
+            # Flush and recreate the VPC SNAT chain
+            node_exec "${GW_NODE}" "iptables -t nat -F ${post_chain} 
2>/dev/null || true" || true
+            node_exec "${GW_NODE}" "iptables -t nat -A ${post_chain} -s 
${CIDR} -j SNAT --to-source ${PUBLIC_IP}" || true
+
+            log "update-vpc-source-nat-ip: SNAT updated to ${PUBLIC_IP} for 
VPC ${VPC_ID}"
+        fi
+        exit 0
+        ;;
+
+    custom-action)
+        log "custom-action: network=${NETWORK_ID} action=${ACTION_NAME}"
+
+        case "${ACTION_NAME}" in
+            dump-config)
+                # Dump SDN configuration from Proxmox
+                echo "=== Proxmox SDN Zones ==="
+                api_get "/cluster/sdn/zones" 2>/dev/null | jq '.' 2>/dev/null 
|| echo "(failed)"
+                echo ""
+                echo "=== Proxmox SDN VNets ==="
+                api_get "/cluster/sdn/vnets" 2>/dev/null | jq '.' 2>/dev/null 
|| echo "(failed)"
+                echo ""
+                echo "=== Local State ==="
+                find "${STATE_DIR}" -type f 2>/dev/null | sort | while read f; 
do
+                    echo "${f}: $(cat "${f}" 2>/dev/null)"
+                done
+                ;;
+            apply-sdn)
+                apply_sdn_changes
+                echo "SDN changes applied"
+                ;;
+            *)
+                echo "Unknown custom action: ${ACTION_NAME}"
+                exit 1
+                ;;
+        esac
+        exit 0
+        ;;
+
+    *)
+        log "Unknown command: ${COMMAND} (ignoring)"
+        # Unknown commands succeed silently for forward compatibility
+        exit 0
+        ;;
+esac
+

Reply via email to