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

weizhou pushed a commit to branch network-namespace
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git


The following commit(s) were added to refs/heads/network-namespace by this push:
     new 5bfe166  Network Namespace: support VPC
5bfe166 is described below

commit 5bfe166c2ebb9ae0968135d499e55c696928b5c2
Author: Wei Zhou <[email protected]>
AuthorDate: Tue Apr 7 21:48:38 2026 +0200

    Network Namespace: support VPC
---
 Network-Namespace/README.md                    | 590 ++++++++++++++++++-------
 Network-Namespace/network-namespace-wrapper.sh | 519 +++++++++++++++++++---
 Network-Namespace/network-namespace.sh         |  66 ++-
 3 files changed, 963 insertions(+), 212 deletions(-)

diff --git a/Network-Namespace/README.md b/Network-Namespace/README.md
index 3bfe1bf..b57982b 100644
--- a/Network-Namespace/README.md
+++ b/Network-Namespace/README.md
@@ -67,8 +67,8 @@ required**.
 │  NetworkExtensionElement.java                            │
 │      │ executes (path resolved from Extension record)    │
 │      ▼                                                   │
-│  /etc/cloudstack/extensions/<ext-name>/                  │
-│      network-namespace.sh                                │
+│  /usr/share/cloudstack-management/extensions/<ext-name>/ │
+│      <ext-name>.sh   (network-namespace.sh)              │
 └──────────────────────┬───────────────────────────────────┘
                        │ SSH (host : port from extension details)
                        │ credentials from extension_resource_map_details
@@ -82,23 +82,24 @@ required**.
 │                                                          │
 │  HOST side                                               │
 │  ─────────────────────────────────────────────────       │
-│  eth0.1910  ─────────────────────────────────┐           │
+│  eth1.1910  ─────────────────────────────────┐           │
 │  (VLAN sub-iface)                            │           │
-│                                    breth0-1910  (bridge) │
-│  veth-host-1910  ────────────────────────────┘           │
+│                                    breth1-1910  (bridge) │
+│  vh-1910-d1  ─────────────────────────────────┘          │
 │      │                                                   │
-│  NAMESPACE  cs-net-209  (or  cs-net-<vpcId>)             │
+│  NAMESPACE  cs-net-209  (isolated)                       │
+│             cs-vpc-5    (VPC, vpc-id=5)                  │
 │  ─────────────────────────────────────────────────       │
-│  veth-ns-1910  ← gateway IP 10.1.1.1/24                  │
+│  vn-1910-d1  ← gateway IP 10.1.1.1/24                    │
 │                                                          │
 │  PUBLIC side (source-NAT IP 10.0.56.4 on VLAN 101):      │
 │                                                          │
 │  HOST side                                               │
-│  eth0.101   ─────────────────────────────────┐           │
-│                                   breth0-101  (bridge)   │
+│  eth1.101   ─────────────────────────────────┐           │
+│                                   breth1-101  (bridge)   │
 │  vph-101-209 ────────────────────────────────┘           │
 │      │                                                   │
-│  NAMESPACE  cs-net-209  (or  cs-net-<vpcId>)             │
+│  NAMESPACE  cs-net-209  (or  cs-vpc-<vpcId>)             │
 │  vpn-101-209  ← source-NAT IP  10.0.56.4/32              │
 │  default route → 10.0.56.1 (upstream gateway)            │
 └──────────────────────────────────────────────────────────┘
@@ -108,25 +109,23 @@ required**.
 
 | Object | Name pattern | Example (VLAN 1910, net 209, pub-VLAN 101) |
 |--------|--------------|-------------------------------------------|
-| Namespace (standalone network) | `cs-net-<networkId>` | `cs-net-209` |
-| Namespace (VPC network)        | `cs-net-<vpcId>`     | `cs-net-5` |
-| Guest host bridge              | `br<ethX>-<vlan>`    | `breth0-1910` |
+| Namespace (isolated network)   | `cs-net-<networkId>` | `cs-net-209` |
+| Namespace (VPC network)        | `cs-vpc-<vpcId>`     | `cs-vpc-5` |
+| Guest host bridge              | `br<ethX>-<vlan>`    | `breth1-1910` |
 | Guest veth – host side         | `vh-<vlan>-<id>`     | `vh-1910-d1` |
 | Guest veth – namespace side    | `vn-<vlan>-<id>`     | `vn-1910-d1` |
-| Public host bridge             | `br<pub_ethX>-<pvlan>` | `breth0-101` |
+| Public host bridge             | `br<pub_ethX>-<pvlan>` | `breth1-101` |
 | Public veth – host side        | `vph-<pvlan>-<id>`   | `vph-101-209` |
 | Public veth – namespace side   | `vpn-<pvlan>-<id>`   | `vpn-101-209` |
 
-`ethX` (and `pub_ethX`) is the **physical NIC** resolved from the
-`kvmnetworklabel` (`public_kvmnetworklabel`) stored in the physical-network
-extension details:
+`ethX` (and `pub_ethX`) is the NIC specified in the `guest.network.device`
+(and `public.network.device`) key when registering the extension on the
+physical network.  Both default to `eth1` when not explicitly set.
 
-* `eth1`     → `eth1`  (not in `/sys/devices/virtual/net/` → already physical)
-* `cloudbr1` → `eth1`  (virtual bridge → first non-virtual
-  `/sys/class/net/cloudbr1/brif/` member)
-
-Both labels are automatically included in 
`--physical-network-extension-details`
-by `NetworkExtensionElement` — no extra registration step is needed.
+> **Note:** when `<vlan>` or `<id>` would make the interface name exceed the
+> Linux 15-character limit, the `<id>` portion is shortened to its hex
+> representation (for numeric IDs) or a 6-character MD5 prefix (for
+> non-numeric IDs).
 
 **Key design principles:**
 
@@ -139,9 +138,10 @@ by `NetworkExtensionElement` — no extra registration step 
is needed.
   VLAN sub-interfaces live on the **host** (not inside the namespace) so that
   guest VMs whose NICs are connected to `brethX-<vlan>` reach the namespace
   gateway without any additional configuration.
-* **VPC networks** share a single namespace per VPC (`cs-net-<vpcId>`).  
Multiple
-  guest VLANs are each connected via their own veth pair (`veth-host-<vlan>` /
-  `veth-ns-<vlan>`).
+* **VPC networks** share a single namespace per VPC (`cs-vpc-<vpcId>`).  
Multiple
+  guest VLANs are each connected via their own veth pair (`vh-<vlan>-<id>` /
+  `vn-<vlan>-<id>`).
+* **Isolated networks** each get their own namespace (`cs-net-<networkId>`).
 * The two scripts are intentionally decoupled: you can replace either script
   with a custom implementation (Python, Go, etc.) as long as the interface
   contract (arguments and exit codes) is maintained.
@@ -208,36 +208,56 @@ by `NetworkExtensionElement` — no extra registration step 
is needed.
 During package installation the `network-namespace.sh` script is deployed to:
 
 ```
-/etc/cloudstack/extensions/<extension-name>/network-namespace.sh
+/usr/share/cloudstack-management/extensions/<extension-name>/<extension-name>.sh
 ```
 
+The extension path is set to `network-namespace` at creation time;
+`NetworkExtensionElement` looks for `<extensionName>.sh` inside the directory.
 In **developer mode** the extensions directory defaults to `extensions/` 
relative
-to the repo root working directory, so 
`extensions/network-namespace/network-namespace.sh`
-is found automatically when `path=network-namespace` is set at extension 
creation
-time (CloudStack looks for `<extensionName>.sh` inside the directory).
+to the repo root, so `extensions/network-namespace/network-namespace.sh` is
+found automatically.
 
 ### Remote network device
 
-Copy `network-namespace-wrapper.sh` to each remote device that will act as the
-network gateway:
+Copy `network-namespace-wrapper.sh` to **each** remote device that will act as 
the
+network gateway, inside a subdirectory named after the extension:
 
 ```bash
 # From the CloudStack source tree:
+DEVICE=root@<kvm-host>
+EXT_NAME=network-namespace        # must match the extension name in CloudStack
+
+ssh ${DEVICE} "mkdir -p /etc/cloudstack/extensions/${EXT_NAME}"
 scp extensions/network-namespace/network-namespace-wrapper.sh \
-    root@<device>:/etc/cloudstack/extensions/network-namespace-wrapper.sh
-chmod +x /etc/cloudstack/extensions/network-namespace-wrapper.sh
+    ${DEVICE}:/etc/cloudstack/extensions/${EXT_NAME}/${EXT_NAME}-wrapper.sh
+ssh ${DEVICE} "chmod +x 
/etc/cloudstack/extensions/${EXT_NAME}/${EXT_NAME}-wrapper.sh"
 ```
 
-The default path expected by `network-namespace.sh` is
-`/etc/cloudstack/extensions/network-namespace/network-namespace-wrapper.sh`.
-You can override this per-physical-network by passing a `script_path` detail
-when calling `registerExtension` (see below).
+The wrapper derives its state directory and log path from the directory it is
+installed in:
+
+* **State:** `/var/lib/cloudstack/<ext-name>/`
+  (e.g. `/var/lib/cloudstack/network-namespace/`)
+* **Log (wrapper):** `/var/log/cloudstack/extensions/<ext-name>/<ext-name>.log`
+  (e.g. 
`/var/log/cloudstack/extensions/network-namespace/network-namespace.log`)
+* **Log (proxy, on management server):** 
`/var/log/cloudstack/extensions/<ext-name>.log`
 
 **Prerequisites on the remote device:**
-* `iproute2` (`ip link`, `ip addr`, `ip netns`)
-* `iptables`
-* `sshd` running and reachable from the management server
-* The SSH user must have permission to run `ip` and `iptables` (root or `sudo`)
+
+| Package / tool | Purpose |
+|----------------|---------|
+| `iproute2` (`ip`, `ip netns`) | Namespace, bridge, veth, route management |
+| `iptables` + `iptables-save` | NAT and filter rules inside namespace |
+| `arping` | Gratuitous ARP after public IP assignment |
+| `dnsmasq` | DHCP and DNS service inside namespace |
+| `haproxy` | Load balancing inside namespace |
+| `apache2` (Debian/Ubuntu) or `httpd` (RHEL/CentOS) | Metadata / user-data 
HTTP service (port 80) |
+| `python3` | DHCP options parsing, haproxy config generation, vm-data 
processing |
+| `util-linux` (`flock`) | Serialise concurrent operations per network |
+| `sshd` | Reachable from the management server on the configured port 
(default 22) |
+
+The SSH user must have permission to run `ip`, `iptables`, `iptables-save`,
+and `ip netns exec` (root or passwordless `sudo` for those commands).
 
 ---
 
@@ -321,6 +341,24 @@ This creates a **Network Service Provider** (NSP) entry 
named `my-extnet` on the
 physical network and enables it automatically.  The NSP name is the **extension
 name** — not the generic string `NetworkExtension`.
 
+After registering, set the connection details for the remote KVM device(s):
+
+```bash
+cmk updateRegisteredExtension \
+    extensionid=<extension-uuid> \
+    resourcetype=PhysicalNetwork \
+    resourceid=<phys-net-uuid> \
+    "details[0].key=hosts"                 
"details[0].value=192.168.10.1,192.168.10.2" \
+    "details[1].key=username"              "details[1].value=root" \
+    "details[2].key=sshkey"                
"details[2].value=<pem-key-contents>" \
+    "details[3].key=guest.network.device"  "details[3].value=eth1" \
+    "details[4].key=public.network.device" "details[4].value=eth1"
+```
+
+The `hosts` value is a comma-separated list of KVM host IPs; 
`ensure-network-device`
+picks one per network and stores it in `--network-extension-details`.  Use 
`sshkey`
+(PEM private key) for passwordless authentication, or `password` + `sshpass`.
+
 Verify:
 ```bash
 cmk listNetworkServiceProviders physicalnetworkid=<phys-net-uuid>
@@ -369,8 +407,8 @@ cmk updateNetworkOffering id=<offering-uuid> state=Enabled
 ```
 
 > The `serviceCapabilityList` entries must match the values declared in the
-> extension's `network.capabilities` detail.  If the extension's JSON does not
-> declare a capability value for a service, CloudStack accepts any value (or no
+> extension's `network.service.capabilities` detail.  If the extension's JSON 
does
+> not declare a capability value for a service, CloudStack accepts any value 
(or no
 > value) without error.
 
 ### 4. Create an isolated network
@@ -402,9 +440,11 @@ network-namespace-wrapper.sh implement \
     --cidr 10.0.1.0/24
 ```
 
-The wrapper creates a VLAN sub-interface and Linux bridge, assigns the gateway
-IP to the bridge, enables IP forwarding, and creates dedicated per-network
-iptables chains (`CS_EXTNET_42` in `nat` and `CS_EXTNET_FWD_42` in `filter`).
+The wrapper creates a VLAN sub-interface and Linux bridge, a guest veth pair
+(`vh-100-2a`/`vn-100-2a`), assigns the gateway IP to the namespace veth,
+enables IP forwarding inside the namespace, and creates per-network iptables
+chains: `CS_EXTNET_42_PR` (nat PREROUTING), `CS_EXTNET_42_POST` (nat
+POSTROUTING), and `CS_EXTNET_FWD_42` (filter FORWARD).
 
 ### 5. Acquire a public IP and enable Source NAT
 
@@ -426,10 +466,15 @@ network-namespace.sh assign-ip \
 ```
 
 The wrapper:
-1. Adds `203.0.113.10/32` as a secondary address on the physical interface.
-2. Adds an iptables SNAT rule: traffic from `10.0.1.0/24` outbound → source 
`203.0.113.10`.
-3. Adds an iptables FORWARD rule allowing traffic from the guest CIDR to the
-   physical interface.
+1. Creates public VLAN sub-interface `eth1.<pvlan>` and bridge 
`breth1-<pvlan>` on the host.
+2. Creates veth pair `vph-<pvlan>-42` (host, in bridge) / `vpn-<pvlan>-42` 
(namespace).
+3. Assigns `203.0.113.10/32` to `vpn-<pvlan>-42` **inside the namespace**.
+4. Adds host route `203.0.113.10/32 dev vph-<pvlan>-42` so the host can reach 
it.
+5. Adds an iptables SNAT rule in `CS_EXTNET_42_POST`: traffic from 
`10.0.1.0/24`
+   out `vpn-<pvlan>-42` → source `203.0.113.10`.
+6. Adds an iptables FORWARD ACCEPT rule in `CS_EXTNET_FWD_42` for the guest 
CIDR.
+7. If `--public-gateway` is set, adds/replaces the namespace default route via
+   `vpn-<pvlan>-42`.
 
 When the IP is released (via `disassociateIpAddress`), `release-ip` is called,
 which removes all associated rules and the IP address.
@@ -454,15 +499,15 @@ network-namespace.sh add-static-nat \
     --private-ip 10.0.1.5
 ```
 
-iptables rules added:
+iptables rules added (all run inside the namespace via `ip netns exec`):
 ```bash
-# DNAT inbound
-iptables -t nat -A CS_EXTNET_42 -d 203.0.113.20 -j DNAT --to-destination 
10.0.1.5
-# SNAT outbound
-iptables -t nat -A CS_EXTNET_42 -s 10.0.1.5 -o eth0 -j SNAT --to-source 
203.0.113.20
-# FORWARD inbound + outbound
-iptables -t filter -A CS_EXTNET_FWD_42 -d 10.0.1.5 -o cs-br-42 -j ACCEPT
-iptables -t filter -A CS_EXTNET_FWD_42 -s 10.0.1.5 -i cs-br-42 -j ACCEPT
+# DNAT inbound  (CS_EXTNET_42_PR = nat PREROUTING chain)
+iptables -t nat -A CS_EXTNET_42_PR -d 203.0.113.20 -j DNAT --to-destination 
10.0.1.5
+# SNAT outbound  (CS_EXTNET_42_POST = nat POSTROUTING chain)
+iptables -t nat -A CS_EXTNET_42_POST -s 10.0.1.5 -o vpn-<pvlan>-42 -j SNAT 
--to-source 203.0.113.20
+# FORWARD inbound + outbound  (CS_EXTNET_FWD_42 = filter FORWARD chain)
+iptables -t filter -A CS_EXTNET_FWD_42 -d 10.0.1.5 -o vn-100-2a -j ACCEPT
+iptables -t filter -A CS_EXTNET_FWD_42 -s 10.0.1.5 -i vn-100-2a -j ACCEPT
 ```
 
 ```bash
@@ -498,14 +543,14 @@ network-namespace.sh add-port-forward \
     --protocol TCP
 ```
 
-iptables rules added:
+iptables rules added (inside the namespace):
 ```bash
-# DNAT inbound
-iptables -t nat -A CS_EXTNET_42 -p tcp -d 203.0.113.20 --dport 2222 \
+# DNAT inbound  (CS_EXTNET_42_PR = nat PREROUTING chain)
+iptables -t nat -A CS_EXTNET_42_PR -p tcp -d 203.0.113.20 --dport 2222 \
     -j DNAT --to-destination 10.0.1.5:22
-# FORWARD
+# FORWARD  (CS_EXTNET_FWD_42 = filter FORWARD chain)
 iptables -t filter -A CS_EXTNET_FWD_42 -p tcp -d 10.0.1.5 --dport 22 \
-    -o cs-br-42 -j ACCEPT
+    -o vn-100-2a -j ACCEPT
 ```
 
 Port ranges (e.g. `80:90`) are supported and passed verbatim to iptables 
`--dport`.
@@ -533,11 +578,16 @@ network-namespace.sh destroy  --network-id 42 --vlan 100
 
 The wrapper:
 1. Removes jump rules from PREROUTING, POSTROUTING, and FORWARD.
-2. Flushes and deletes iptables chains `CS_EXTNET_42` and `CS_EXTNET_FWD_42`.
-3. Brings down and deletes bridge `cs-br-42`.
-4. Brings down and deletes VLAN interface `eth0.100` (VLAN ID read from state 
if
-   not passed in arguments).
-5. Removes all state under `/var/lib/cloudstack/network-namespace/42/`.
+2. Flushes and deletes iptables chains `CS_EXTNET_42_PR`, `CS_EXTNET_42_POST`,
+   `CS_EXTNET_FWD_42`, and any `CS_EXTNET_FWRULES_42` / `CS_EXTNET_FWI_*` 
chains.
+3. Deletes public veth pairs (`vph-<pvlan>-42` / `vpn-<pvlan>-42`) that were
+   created during `assign-ip` (read from state files).
+4. On `destroy`: also deletes the guest veth host-side (`vh-100-2a`) and 
removes
+   the namespace `cs-net-42` entirely.
+5. Removes all state under `/var/lib/cloudstack/<ext-name>/network-42/`.
+
+> The host bridge `breth1-100` and VLAN sub-interface `eth1.100` are **not**
+> removed — they may still be used by other networks or for VM connectivity.
 
 ### 9. Unregister and delete the extension
 
@@ -547,10 +597,15 @@ cmk updateNetworkServiceProvider id=<nsp-uuid> 
state=Disabled
 cmk deleteNetworkServiceProvider id=<nsp-uuid>
 
 # Remove external network device credentials (if any)
-# Device credentials are stored as `extension_resource_map_details` for the
-# extension registration. Remove or update them via `updateExtension` or
-# by unregistering the extension from the physical network 
(unregisterExtension)
-# and then updating the Extension record if necessary.
+# Device credentials are stored as extension_resource_map_details for the
+# extension registration. Remove or update them via `updateRegisteredExtension`
+# (set cleanupdetails=true to wipe all details) or by supplying new details.
+# Example: clear all registration details for a physical network:
+cmk updateRegisteredExtension \
+    extensionid=<extension-uuid> \
+    resourcetype=PhysicalNetwork \
+    resourceid=<phys-net-uuid> \
+    cleanupdetails=true
 
 # Unregister the extension from the physical network
 cmk unregisterExtension \
@@ -575,14 +630,18 @@ network:
 # Register two extensions, each backed by a different device
 cmk registerExtension id=<ext-a-uuid> resourcetype=PhysicalNetwork 
resourceid=<pn-uuid>
 cmk registerExtension id=<ext-b-uuid> resourcetype=PhysicalNetwork 
resourceid=<pn-uuid>
-```
 
-# Store device connection details and script_path as registration details
-# (use updateNetworkServiceProvider or updateExtension details in the API / 
CMK)
-# Example: set hosts, sshkey, script_path for the registered extension on the 
physical network
-# Note: details are stored in extension_resource_map_details for the 
registration
-cmk updateExtension id=<ext-uuid> "details[0].key=hosts" 
"details[0].value=10.0.0.1,10.0.0.2" \
-    "details[1].key=script_path" 
"details[1].value=/etc/cloudstack/extensions/network-namespace/network-namespace-wrapper.sh"
+# Store device connection details as registration details for each extension.
+# Details are stored in extension_resource_map_details for the registration.
+# Example: set hosts and guest/public network devices for ext-a on the 
physical network:
+cmk updateRegisteredExtension \
+    extensionid=<ext-a-uuid> \
+    resourcetype=PhysicalNetwork \
+    resourceid=<pn-uuid> \
+    "details[0].key=hosts"                 
"details[0].value=10.0.0.1,10.0.0.2" \
+    "details[1].key=guest.network.device"  "details[1].value=eth1" \
+    "details[2].key=public.network.device" "details[2].value=eth1"
+```
 
 When creating network offerings, reference the specific extension name:
 
@@ -613,10 +672,11 @@ It receives the command as its first positional argument 
followed by named
 `--option value` pairs.
 
 All commands:
-* Write timestamped entries to `/var/log/cloudstack/network-namespace.log`.
-* Use a per-network flock file 
(`/var/lib/cloudstack/network-namespace/lock-<id>`)
-  to serialise concurrent operations.
-* Persist state under `/var/lib/cloudstack/network-namespace/<network-id>/`.
+* Write timestamped entries to 
`/var/log/cloudstack/extensions/<ext-name>/<ext-name>.log`.
+* Use a per-network flock file (`${STATE_DIR}/lock-network-<id>`) — or
+  `lock-vpc-<id>` for VPC networks — to serialise concurrent operations.
+* Persist state under `/var/lib/cloudstack/<ext-name>/network-<network-id>/`
+  (or `vpc-<vpc-id>/` for VPC-wide shared state such as public IPs).
 
 ### `implement`
 
@@ -632,17 +692,17 @@ network-namespace-wrapper.sh implement \
 ```
 
 Actions:
-1. Create namespace `cs-net-<vpc-id>` (VPC) or `cs-net-<network-id>` 
(standalone).
-2. Resolve `ethX` from `kvmnetworklabel` in 
`--physical-network-extension-details`.
-3. Create VLAN sub-interface `ethX.<vlan>` on the host.
-4. Create host bridge `brethX-<vlan>` and attach `ethX.<vlan>` to it.
-5. Create veth pair `veth-host-<vlan>` (host) / `veth-ns-<vlan>` (namespace).
-   Attach host end to `brethX-<vlan>`.
-6. Assign `<gateway>/<prefix>` to `veth-ns-<vlan>` inside the namespace.
+1. Create namespace `cs-vpc-<vpc-id>` (VPC) or `cs-net-<network-id>` 
(isolated).
+2. Resolve `GUEST_ETH` from `guest.network.device` in 
`--physical-network-extension-details`
+   (defaults to `eth1` when absent).
+3. Create VLAN sub-interface `GUEST_ETH.<vlan>` on the host.
+4. Create host bridge `br<GUEST_ETH>-<vlan>` and attach `GUEST_ETH.<vlan>` to 
it.
+5. Create veth pair `vh-<vlan>-<id>` (host, in bridge) / `vn-<vlan>-<id>` 
(namespace).
+6. Assign `<gateway>/<prefix>` to `vn-<vlan>-<id>` inside the namespace.
 7. Enable IP forwarding inside the namespace.
-8. Create iptables chains `CS_EXTNET_<id>_PR` (PREROUTING DNAT),
-   `CS_EXTNET_<id>_POST` (POSTROUTING SNAT), and `CS_EXTNET_FWD_<id>` 
(FORWARD).
-9. Save VLAN, gateway, CIDR, namespace, network-id or vpc-id to state files.
+8. Create iptables chains `CS_EXTNET_<id>_PR` (nat PREROUTING DNAT),
+   `CS_EXTNET_<id>_POST` (nat POSTROUTING SNAT), and `CS_EXTNET_FWD_<id>` 
(filter FORWARD).
+9. Save VLAN, gateway, CIDR, namespace, and network-id / vpc-id to state files.
 
 ### `shutdown`
 
@@ -654,12 +714,15 @@ network-namespace-wrapper.sh shutdown \
 ```
 
 Actions:
-1. Flush and remove iptables chains (PREROUTING, POSTROUTING, FORWARD jumps +
-   chain contents).
-2. Delete public veth pairs (`vph-<pvlan>-<id>`) that were created during
-   `assign-ip` (read from state).
-3. Keep namespace and guest veth (`veth-host-<vlan>` / `veth-ns-<vlan>`) 
intact —
-   guest VMs can still connect to `brethX-<vlan>`.
+1. Stop dnsmasq, haproxy, apache2, and password-server processes running inside
+   the namespace (if any).
+2. Flush and remove iptables chains (PREROUTING, POSTROUTING, FORWARD jumps +
+   chain contents), including `CS_EXTNET_FWRULES_<id>` and all 
`CS_EXTNET_FWI_*`
+   ingress chains.
+3. Delete public veth pairs (`vph-<pvlan>-<id>` / `vpn-<pvlan>-<id>`) that were
+   created during `assign-ip` (read from state).
+4. Keep namespace and guest veth (`vh-<vlan>-<id>` / `vn-<vlan>-<id>`) intact —
+   guest VMs can still connect to `br<GUEST_ETH>-<vlan>`.
 
 ### `destroy`
 
@@ -671,14 +734,14 @@ network-namespace-wrapper.sh destroy \
 ```
 
 Actions (superset of shutdown):
-1. Delete guest veth host-side (`veth-host-<vlan>`).
-2. Delete public veth pairs.
+1. Delete guest veth host-side (`vh-<vlan>-<id>`).
+2. Delete public veth pairs (`vph-<pvlan>-<id>` / `vpn-<pvlan>-<id>`).
 3. Delete the namespace (removes all interfaces inside it).
-4. Remove state directory.
+4. Remove per-network state directory `network-<id>/`.
 
-> The host bridge `brethX-<vlan>` and VLAN sub-interface `ethX.<vlan>` are NOT
-> removed on destroy — they may still be used by other networks or for VM
-> connectivity.
+> The host bridge `br<GUEST_ETH>-<vlan>` and VLAN sub-interface 
`GUEST_ETH.<vlan>`
+> are NOT removed on destroy — they may still be used by other networks or for
+> VM connectivity.
 
 ### `assign-ip`
 
@@ -699,10 +762,11 @@ network-namespace-wrapper.sh assign-ip \
 ```
 
 Actions:
-1. Resolve `pub_ethX` from `public_kvmnetworklabel` (falls back to 
`kvmnetworklabel`).
-2. Create VLAN sub-interface `pub_ethX.<pvlan>` and bridge 
`brpub_ethX-<pvlan>` on the host.
+1. Resolve `PUB_ETH` from `public.network.device` in 
`--physical-network-extension-details`
+   (defaults to `eth1` when absent).
+2. Create VLAN sub-interface `PUB_ETH.<pvlan>` and bridge 
`br<PUB_ETH>-<pvlan>` on the host.
 3. Create veth pair `vph-<pvlan>-<id>` (host) / `vpn-<pvlan>-<id>` (namespace).
-   Attach host end to `brpub_ethX-<pvlan>`.
+   Attach host end to `br<PUB_ETH>-<pvlan>`.
 4. Assign `<public-ip>/32` (or `/<prefix>` if `--public-cidr` given) to
    `vpn-<pvlan>-<id>` inside the namespace.
 5. Add host route `<public-ip>/32 dev vph-<pvlan>-<id>` so the host can reach 
it.
@@ -760,10 +824,10 @@ iptables rules added (chains `CS_EXTNET_<id>_PR` / 
`_POST` / `FWD_<id>`):
 |-------|-------|------|
 | `nat` | `CS_EXTNET_<id>_PR`   | `-d <public-ip> -j DNAT --to-destination 
<private-ip>` |
 | `nat` | `CS_EXTNET_<id>_POST` | `-s <private-ip> -o vpn-<pvlan>-<id> -j SNAT 
--to-source <public-ip>` |
-| `filter` | `CS_EXTNET_FWD_<id>` | `-d <private-ip> -o veth-ns-<vlan> -j 
ACCEPT` |
-| `filter` | `CS_EXTNET_FWD_<id>` | `-s <private-ip> -i veth-ns-<vlan> -j 
ACCEPT` |
+| `filter` | `CS_EXTNET_FWD_<id>` | `-d <private-ip> -o vn-<vlan>-<id> -j 
ACCEPT` |
+| `filter` | `CS_EXTNET_FWD_<id>` | `-s <private-ip> -i vn-<vlan>-<id> -j 
ACCEPT` |
 
-State saved to 
`/var/lib/cloudstack/network-namespace/<id>/static-nat/<public-ip>`.
+State saved to `${STATE_DIR}/network-<id>/static-nat/<public-ip>`.
 
 ### `delete-static-nat`
 
@@ -792,17 +856,17 @@ network-namespace-wrapper.sh add-port-forward \
     --protocol tcp|udp
 ```
 
-iptables rules added:
+iptables rules added (inside the namespace):
 
 | Table | Chain | Rule |
 |-------|-------|------|
-| `nat` | `CS_EXTNET_<id>` | `-p <proto> -d <public-ip> --dport <public-port> 
-j DNAT --to-destination <private-ip>:<private-port>` |
-| `filter` | `CS_EXTNET_FWD_<id>` | `-p <proto> -d <private-ip> --dport 
<private-port> -o cs-br-<id> -j ACCEPT` |
+| `nat` | `CS_EXTNET_<id>_PR` | `-p <proto> -d <public-ip> --dport 
<public-port> -j DNAT --to-destination <private-ip>:<private-port>` |
+| `filter` | `CS_EXTNET_FWD_<id>` | `-p <proto> -d <private-ip> --dport 
<private-port> -o vn-<vlan>-<id> -j ACCEPT` |
 
 Port ranges (`80:90`) are passed verbatim to iptables `--dport`.
 
 State saved to
-`/var/lib/cloudstack/network-namespace/<id>/port-forward/<proto>_<public-ip>_<public-port>`.
+`${STATE_DIR}/network-<id>/port-forward/<proto>_<public-ip>_<public-port>`.
 
 ### `delete-port-forward`
 
@@ -818,6 +882,217 @@ network-namespace-wrapper.sh delete-port-forward \
 
 Removes the DNAT and FORWARD rules added by `add-port-forward`.
 
+### `apply-fw-rules`
+
+Called when CloudStack applies or removes firewall rules for the network.
+
+```
+network-namespace-wrapper.sh apply-fw-rules \
+    --network-id <id> \
+    --vlan <vlan-id> \
+    --fw-rules <base64-json> \
+    [--vpc-id <vpc-id>]
+```
+
+The `--fw-rules` value is a Base64-encoded JSON object:
+```json
+{
+  "default_egress_allow": true,
+  "cidr": "10.0.1.0/24",
+  "rules": [
+    {
+      "type": "ingress",
+      "protocol": "tcp",
+      "portStart": 22,
+      "portEnd": 22,
+      "publicIp": "203.0.113.10",
+      "sourceCidrs": ["0.0.0.0/0"]
+    },
+    {
+      "type": "egress",
+      "protocol": "all",
+      "sourceCidrs": ["0.0.0.0/0"]
+    }
+  ]
+}
+```
+
+iptables design (two independent parts, both inside the namespace):
+
+* **Ingress** (mangle PREROUTING, per public IP):
+  Per-public-IP chains `CS_EXTNET_FWI_<pubIp>` check traffic *before* DNAT so
+  the match is against the real public destination IP.  Traffic not matched by
+  explicit ALLOW rules is dropped.
+
+* **Egress** (filter FORWARD, chain `CS_EXTNET_FWRULES_<networkId>`):
+  Inserted at position 1 of `CS_EXTNET_FWD_<networkId>`.  Applies the
+  `default_egress_allow` policy (allow-by-default or deny-by-default) to VM
+  outbound traffic on `-i vn-<vlan>-<id>`.
+
+### `config-dhcp-subnet` / `remove-dhcp-subnet`
+
+Configure or tear down dnsmasq DHCP service for the network inside the 
namespace.
+
+**`config-dhcp-subnet` arguments:**
+```
+network-namespace-wrapper.sh config-dhcp-subnet \
+    --network-id <id>      \
+    --gateway <gw>         \
+    --cidr <cidr>          \
+    [--dns <dns-server>]   \
+    [--domain <domain>]    \
+    [--vpc-id <vpc-id>]
+```
+
+Actions: writes a dnsmasq configuration file under
+`${STATE_DIR}/network-<id>/dnsmasq/` and starts or reloads the dnsmasq process
+inside the namespace.  DNS on port 53 is **disabled** by `config-dhcp-subnet`
+(use `config-dns-subnet` to enable it).
+
+**`remove-dhcp-subnet` arguments:**
+```
+network-namespace-wrapper.sh remove-dhcp-subnet --network-id <id>
+```
+
+Actions: stops dnsmasq and removes the dnsmasq configuration directory.
+
+### `add-dhcp-entry` / `remove-dhcp-entry`
+
+Add or remove a static DHCP host reservation (MAC → IP mapping) from dnsmasq.
+
+```
+network-namespace-wrapper.sh add-dhcp-entry \
+    --network-id <id>    \
+    --mac <mac>          \
+    --ip <vm-ip>         \
+    [--hostname <name>]  \
+    [--default-nic true|false]
+```
+
+When `--default-nic false`, the DHCP option 3 (default gateway) is suppressed
+for that MAC so the VM does not get a competing default route via a secondary 
NIC.
+
+```
+network-namespace-wrapper.sh remove-dhcp-entry \
+    --network-id <id> \
+    --mac <mac>
+```
+
+### `set-dhcp-options`
+
+Set extra DHCP options for a specific NIC (identified by `--nic-id`) using a
+JSON map of option-code → value pairs.
+
+```
+network-namespace-wrapper.sh set-dhcp-options \
+    --network-id <id>           \
+    --nic-id <nic-id>           \
+    --options '{"119":"example.com"}'
+```
+
+### `config-dns-subnet` / `remove-dns-subnet`
+
+Enable or disable DNS (port 53) in the dnsmasq instance.
+
+```
+network-namespace-wrapper.sh config-dns-subnet \
+    --network-id <id>    \
+    --gateway <gw>       \
+    --cidr <cidr>        \
+    [--extension-ip <ip>] \
+    [--domain <domain>]  \
+    [--vpc-id <vpc-id>]
+```
+
+Actions: like `config-dhcp-subnet` but enables DNS on port 53.  Also registers 
a
+`data-server` hostname entry (using `--extension-ip` if provided, otherwise
+`--gateway`) for metadata service discovery.
+
+```
+network-namespace-wrapper.sh remove-dns-subnet --network-id <id>
+```
+
+Actions: disables DNS (rewrites config to disable port 53) but keeps DHCP 
running.
+
+### `add-dns-entry` / `remove-dns-entry`
+
+Add or remove a hostname → IP mapping in the dnsmasq hosts file.
+
+```
+network-namespace-wrapper.sh add-dns-entry \
+    --network-id <id>   \
+    --ip <vm-ip>        \
+    --hostname <name>
+
+network-namespace-wrapper.sh remove-dns-entry \
+    --network-id <id> \
+    --ip <vm-ip>
+```
+
+### `save-vm-data`
+
+Write the full VM metadata/userdata/password set for a VM in a single call.
+Called on network restart and VM deploy.
+
+```
+network-namespace-wrapper.sh save-vm-data \
+    --network-id <id>  \
+    --ip <vm-ip>       \
+    --vm-data <base64-json>
+```
+
+The `--vm-data` value is a Base64-encoded JSON array of `{dir, file, content}`
+entries (same format as `generateVmData()` in the Java layer).  Writes files
+under `${STATE_DIR}/network-<id>/metadata/<vm-ip>/latest/`.  After writing,
+starts or reloads both the **apache2 metadata HTTP service** (port 80) and the
+**VR-compatible password server** (port 8080) inside the namespace.
+
+### `save-userdata` / `save-password` / `save-sshkey` / 
`save-hypervisor-hostname`
+
+Granular variants that write individual VM metadata fields:
+
+```
+network-namespace-wrapper.sh save-userdata       --network-id <id> --ip 
<vm-ip> --userdata <base64>
+network-namespace-wrapper.sh save-password       --network-id <id> --ip 
<vm-ip> --password <plain>
+network-namespace-wrapper.sh save-sshkey         --network-id <id> --ip 
<vm-ip> --sshkey <base64>
+network-namespace-wrapper.sh save-hypervisor-hostname \
+    --network-id <id> --ip <vm-ip> --hypervisor-hostname <name>
+```
+
+Each command writes the relevant file and restarts/reloads apache2 (and
+the password server, for `save-password`).
+
+### `apply-lb-rules`
+
+Apply or revoke load-balancing rules via haproxy inside the namespace.
+
+```
+network-namespace-wrapper.sh apply-lb-rules \
+    --network-id <id>        \
+    --lb-rules <json-array>  \
+    [--vpc-id <vpc-id>]
+```
+
+`--lb-rules` is a JSON array of LB rule objects.  Set `"revoke": true` on a
+rule to remove it.  The wrapper regenerates the haproxy configuration from the
+persistent per-rule JSON files under `${STATE_DIR}/network-<id>/haproxy/` and
+reloads haproxy inside the namespace.  haproxy is stopped when no active rules
+remain.
+
+### `restore-network`
+
+Batch-restore DHCP/DNS/metadata/services for all VMs on a network in a single
+call.  Invoked on network restart to rebuild all state at once instead of N
+per-VM calls.
+
+```
+network-namespace-wrapper.sh restore-network \
+    --network-id <id>          \
+    --restore-data <base64-json> \
+    [--gateway <gw>] [--cidr <cidr>] [--dns <dns>] \
+    [--domain <dom>] [--extension-ip <ip>] [--vpc-id <vpc-id>]
+```
+
 ### `custom-action`
 
 ```
@@ -830,11 +1105,12 @@ Built-in actions:
 
 | Action | Description |
 |--------|-------------|
-| `reboot-device` | Bounces the bridge: `ip link set cs-br-<id> down && up` |
-| `dump-config` | Prints iptables rules and bridge/interface state to stdout |
+| `reboot-device` | Bounces the guest veth pair (`vh-<vlan>-<id>` down → up) |
+| `dump-config` | Prints namespace IP addresses, iptables rules, and 
per-network state to stdout |
 
 To add custom actions, place an executable script at
-`/var/lib/cloudstack/network-namespace/hooks/custom-action-<name>.sh`.
+`${STATE_DIR}/hooks/custom-action-<name>.sh`
+(e.g. `/var/lib/cloudstack/network-namespace/hooks/custom-action-<name>.sh`).
 Unknown action names are delegated to the hook if present; otherwise the 
command
 fails with a descriptive error.
 
@@ -861,37 +1137,34 @@ These keys are explicitly set when calling 
`registerExtension`:
 | `username` | SSH user — default: `root` |
 | `password` | SSH password via `sshpass` — sensitive, not logged |
 | `sshkey` | PEM-encoded SSH private key — sensitive, not logged; preferred 
over password |
+| `guest.network.device` | Host NIC for guest (internal) traffic, e.g. `eth1` 
— defaults to `eth1` when absent |
+| `public.network.device` | Host NIC for public (NAT/external) traffic, e.g. 
`eth1` — defaults to `eth1` when absent |
 
-These keys are **automatically injected** by `NetworkExtensionElement` from the
-physical network record — no manual registration needed:
+This key is **automatically injected** by `NetworkExtensionElement` from the
+physical network record:
 
 | JSON key | Description |
 |----------|-------------|
 | `physicalnetworkname` | Physical network name from CloudStack DB |
-| `kvmnetworklabel` | KVM guest traffic label (e.g. `eth0`, `cloudbr0`) |
-| `vmwarenetworklabel` | VMware guest traffic label |
-| `xennetworklabel` | XenServer guest traffic label |
-| `public_kvmnetworklabel` | KVM public traffic label (used for public 
bridges) |
-| `public_vmwarenetworklabel` | VMware public traffic label |
 
-The wrapper script uses `kvmnetworklabel` (and `public_kvmnetworklabel`) to
-derive the physical NIC `ethX` via `/sys/devices/virtual/net/` inspection, then
-names bridges as `brethX-<vlan>`.
+The wrapper script uses `guest.network.device` (and `public.network.device`) to
+name bridges as `br<eth>-<vlan>` and veth pairs as `vh-<vlan>-<id>` /
+`vn-<vlan>-<id>` (guest) and `vph-<pvlan>-<id>` / `vpn-<pvlan>-<id>` (public).
 
 ### Per-network details (keys in `--network-extension-details`)
 
 | JSON key | Description |
 |----------|-------------|
 | `host` | Previously selected host IP (set by `ensure-network-device`) |
-| `namespace` | Linux network namespace name (e.g. `cs-net-<networkId>`) |
+| `namespace` | Linux network namespace name (e.g. `cs-net-<networkId>` or 
`cs-vpc-<vpcId>`) |
 
 ### Additional per-command arguments
 
 | CLI Argument | Commands | Description |
 |--------------|----------|-------------|
-| `--vpc-id <id>` | all | Inject when network belongs to a VPC; namespace 
becomes `cs-net-<vpcId>` |
+| `--vpc-id <id>` | all | Present when the network belongs to a VPC; namespace 
becomes `cs-vpc-<vpcId>` |
 | `--public-vlan <pvlan>` | `assign-ip`, `release-ip` | Public IP's VLAN tag 
(e.g. `101`) |
-| `--network-id <id>` | `assign-ip`, `release-ip`, `add-static-nat`, 
`delete-static-nat`, `add-port-forward`, `delete-port-forward` | VPC ID if VPC 
network, else network ID — used in public veth names (`vph-<pvlan>-<id>`, 
`vpn-<pvlan>-<id>`) |
+| `--network-id <id>` | most | Network ID — CHOSEN_ID for veth names is 
`<vpc-id>` when VPC, else `<network-id>` |
 
 ### Action parameters (custom-action only)
 
@@ -955,44 +1228,51 @@ argument; hook scripts should parse the JSON argument as 
needed.
 
 The integration smoke test at
 `test/integration/smoke/test_network_extension_namespace.py`
-exercises the full lifecycle using a **Linux network namespace** on the Marvin
-node as the simulated remote device:
+exercises the full lifecycle against real KVM hosts in the zone.
 
 ```
-Marvin node (this machine — also acts as the remote network device)
-  ├── ip netns add cs-extnet-<id>            ← isolated namespace
-  ├── ~/.ssh/authorized_keys ← test RSA key  ← management server connects here
-  └── /etc/cloudstack/extensions/
-        └── network-namespace-wrapper.sh     ← copied from repo by test
-
-KVM hosts in the zone (best-effort, skipped if none found)
-  └── /etc/cloudstack/extensions/
-        └── network-namespace-wrapper.sh     ← copied by KvmHostDeployer
+Management server
+  └── /usr/share/cloudstack-management/extensions/<ext-name>/
+        └── network-namespace.sh             ← deployed / referenced by test
+              SSHes to KVM host
+              runs network-namespace-wrapper.sh <cmd> <args>
 
-Management server (may be the same machine)
+KVM host(s) in the zone
   └── /etc/cloudstack/extensions/<ext-name>/
-        └── network-namespace.sh             ← deployed by test (static copy)
-              reads CS_PHYSICAL_NETWORK_EXTENSION_DETAILS (JSON)
-                    CS_NETWORK_EXTENSION_DETAILS          (JSON)
-              SSHes back to Marvin node :22
-              runs ip netns exec cs-extnet-<id> <script> <args>
+        └── network-namespace-wrapper.sh     ← copied to KVM hosts by test 
setup
+              creates cs-net-<id> or cs-vpc-<id> namespaces
+              manages bridges, veth pairs, iptables, dnsmasq, haproxy, apache2
 ```
 
 The test covers:
 * Create / list / update / delete external network device.
 * Full network lifecycle: implement → assign-ip (source NAT) → static NAT →
-  port forwarding → shutdown / destroy.
+  port forwarding → firewall rules → DHCP/DNS → shutdown / destroy.
 * NSP state transitions: Disabled → Enabled → Disabled → Deleted.
+* Tests `test_04`, `test_05`, `test_06` (DHCP, DNS, LB) require `arping`,
+  `dnsmasq`, and `haproxy` on the KVM hosts; the test skips them automatically
+  if these tools are not installed.
 
 Run the test:
 ```bash
 cd test/integration/smoke
-nosetests test_network_extension_provider.py \
+python -m pytest test_network_extension_namespace.py \
     --with-marvin --marvin-config=<config.cfg> \
     -s -a 'tags=advanced,smoke' 2>&1 | tee /tmp/extnet-test.log
 ```
 
-**Prerequisites:**
-* `iproute2` on the Marvin node (`ip netns list` must succeed).
-* The Marvin node must be reachable by SSH from the management server on port 
22.
-* Set `MARVIN_NODE_IP=<ip>` if auto-detection of the Marvin node IP fails.
+**Prerequisites on KVM hosts:**
+* `iproute2` (`ip`, `ip netns`)
+* `iptables` + `iptables-save`
+* `arping` (for GARP on IP assignment)
+* `dnsmasq` (DHCP + DNS — required for `test_04` / DNS tests)
+* `haproxy` (LB — required for `test_05` / LB tests)
+* `apache2` / `httpd` (metadata HTTP service — required for UserData tests)
+* `python3` (vm-data processing, haproxy config generation)
+* `util-linux` (`flock`) (lock serialization)
+* SSH access from management server (root or sudo-capable user)
+
+**Prerequisites on the Marvin / test runner node:**
+* Python Marvin library installed (`pip install -r requirements.txt`)
+* A valid Marvin config file pointing to the CloudStack environment
+* The test runner must be able to SSH to the management server and to KVM hosts
diff --git a/Network-Namespace/network-namespace-wrapper.sh 
b/Network-Namespace/network-namespace-wrapper.sh
index 8051c01..c8af377 100755
--- a/Network-Namespace/network-namespace-wrapper.sh
+++ b/Network-Namespace/network-namespace-wrapper.sh
@@ -349,6 +349,7 @@ pub_veth_ns_name() {
 nat_chain()    { echo "${CHAIN_PREFIX}_${1}"; }
 filter_chain()   { echo "${CHAIN_PREFIX}_FWD_${1}"; }
 firewall_chain() { echo "${CHAIN_PREFIX}_FWRULES_${1}"; }
+acl_chain()      { echo "${CHAIN_PREFIX}_ACL_${1}"; }
 
 ensure_public_ip_on_namespace() {
     local public_ip="$1" public_cidr="$2" pveth_n="$3" pveth_h="$4" addr_spec 
prefix
@@ -460,10 +461,15 @@ parse_args() {
     LB_RULES_JSON="[]"
     DEFAULT_NIC="true"
     VM_DATA=""
+    VM_DATA_FILE=""
     DOMAIN=""
     EXTENSION_IP=""
     RESTORE_DATA=""
+    RESTORE_DATA_FILE=""
     FW_RULES_JSON=""
+    FW_RULES_FILE=""
+    ACL_RULES_JSON=""
+    ACL_RULES_FILE=""
 
     while [ $# -gt 0 ]; do
         case "$1" in
@@ -495,10 +501,15 @@ parse_args() {
             --lb-rules)            LB_RULES_JSON="$2";       shift 2 ;;
             --default-nic)         DEFAULT_NIC="$2";         shift 2 ;;
             --vm-data)             VM_DATA="$2";             shift 2 ;;
+            --vm-data-file)        VM_DATA_FILE="$2";        shift 2 ;;
             --domain)              DOMAIN="$2";              shift 2 ;;
             --extension-ip)        EXTENSION_IP="$2";        shift 2 ;;
             --restore-data)        RESTORE_DATA="$2";        shift 2 ;;
+            --restore-data-file)   RESTORE_DATA_FILE="$2";   shift 2 ;;
             --fw-rules)            FW_RULES_JSON="$2";       shift 2 ;;
+            --fw-rules-file)       FW_RULES_FILE="$2";       shift 2 ;;
+            --acl-rules)           ACL_RULES_JSON="$2";      shift 2 ;;
+            --acl-rules-file)      ACL_RULES_FILE="$2";      shift 2 ;;
             # consumed by _pre_scan_args — skip silently
             --physical-network-extension-details|--network-extension-details)
                                    shift 2 ;;
@@ -577,7 +588,7 @@ _load_state() {
 }
 
 ##############################################################################
-# Command: implement
+# Command: implement-network
 #
 # 1. Create namespace cs-net-<id>
 # 2. Create host bridge br<ethX>-<vlan> with ethX.<vlan> sub-interface
@@ -586,11 +597,11 @@ _load_state() {
 # 5. Set up iptables chains inside namespace
 ##############################################################################
 
-cmd_implement() {
+cmd_implement_network() {
     parse_args "$@"
     acquire_lock "${NETWORK_ID}"
 
-    log "implement: network=${NETWORK_ID} ns=${NAMESPACE} vlan=${VLAN} 
gw=${GATEWAY} cidr=${CIDR}"
+    log "implement-network: network=${NETWORK_ID} ns=${NAMESPACE} vlan=${VLAN} 
gw=${GATEWAY} cidr=${CIDR}"
 
     local veth_h veth_n nchain_pr nchain_post fchain
     veth_h=$(veth_host_name "${VLAN}" "${CHOSEN_ID}")
@@ -706,22 +717,22 @@ cmd_implement() {
 
     _dump_iptables "${NAMESPACE}"
     release_lock
-    log "implement: done network=${NETWORK_ID} namespace=${NAMESPACE}"
+    log "implement-network: done network=${NETWORK_ID} namespace=${NAMESPACE}"
 }
 
 ##############################################################################
-# Command: shutdown
+# Command: shutdown-network
 # Flush iptables chains and remove this network's veth pairs.
 # For VPC networks the shared namespace is preserved (other tiers still use 
it).
 # For isolated networks the namespace is also removed.
 ##############################################################################
 
-cmd_shutdown() {
+cmd_shutdown_network() {
     parse_args "$@"
     _load_state
     acquire_lock "${NETWORK_ID}"
 
-    log "shutdown: network=${NETWORK_ID} ns=${NAMESPACE} vpc=${VPC_ID}"
+    log "shutdown-network: network=${NETWORK_ID} ns=${NAMESPACE} vpc=${VPC_ID}"
 
     local nchain_pr nchain_post fchain
     nchain_pr="${CHAIN_PREFIX}_${NETWORK_ID}_PR"
@@ -752,7 +763,7 @@ cmd_shutdown() {
             if [ -f "${tier_f}" ]; then
                 local owner_tier; owner_tier=$(cat "${tier_f}" 2>/dev/null || 
true)
                 if [ -n "${owner_tier}" ] && [ "${owner_tier}" != 
"${NETWORK_ID}" ]; then
-                    log "shutdown: skipping veth for $(basename "${f%.pvlan}") 
(owned by tier ${owner_tier})"
+                    log "shutdown-network: skipping veth for $(basename 
"${f%.pvlan}") (owned by tier ${owner_tier})"
                     continue
                 fi
             fi
@@ -760,7 +771,7 @@ cmd_shutdown() {
             pvlan=$(cat "${f}")
             pveth_h=$(pub_veth_host_name "${pvlan}" "${CHOSEN_ID}")
             ip link del "${pveth_h}" 2>/dev/null || true
-            log "shutdown: removed public veth ${pveth_h}"
+            log "shutdown-network: removed public veth ${pveth_h}"
         done
     fi
 
@@ -768,7 +779,7 @@ cmd_shutdown() {
     local veth_h
     veth_h=$(veth_host_name "${VLAN}" "${CHOSEN_ID}")
     ip link del "${veth_h}" 2>/dev/null || true
-    log "shutdown: removed guest veth ${veth_h}"
+    log "shutdown-network: removed guest veth ${veth_h}"
 
     # Clean transient public IP state.
     # For isolated networks the state dir is per-network, so wipe it entirely.
@@ -791,28 +802,28 @@ cmd_shutdown() {
     # across tiers and must only be deleted when the last tier is destroyed.
     if [ -z "${VPC_ID}" ]; then
         ip netns del "${NAMESPACE}" 2>/dev/null || true
-        log "shutdown: deleted namespace ${NAMESPACE}"
+        log "shutdown-network: deleted namespace ${NAMESPACE}"
     else
-        log "shutdown: preserved shared namespace ${NAMESPACE} (VPC tier)"
+        log "shutdown-network: preserved shared namespace ${NAMESPACE} (VPC 
tier)"
     fi
 
     release_lock
-    log "shutdown: done network=${NETWORK_ID}"
+    log "shutdown-network: done network=${NETWORK_ID}"
 }
 
 ##############################################################################
-# Command: destroy
+# Command: destroy-network
 # Delete this network's state entirely.
-# For VPC networks the shared namespace is only deleted when this is the last
-# remaining tier (tracked via vpc-<vpcId>/tiers/<networkId> marker files).
+# For VPC tier networks the shared namespace is preserved — the namespace is
+# removed only when shutdownVpc() calls shutdown-vpc or destroy-vpc.
 ##############################################################################
 
-cmd_destroy() {
+cmd_destroy_network() {
     parse_args "$@"
     _load_state
     acquire_lock "${NETWORK_ID}"
 
-    log "destroy: network=${NETWORK_ID} ns=${NAMESPACE} vpc=${VPC_ID}"
+    log "destroy-network: network=${NETWORK_ID} ns=${NAMESPACE} vpc=${VPC_ID}"
 
     # Remove this tier's guest veth host-side
     local veth_h
@@ -831,7 +842,7 @@ cmd_destroy() {
             if [ -f "${tier_f}" ]; then
                 local owner_tier; owner_tier=$(cat "${tier_f}" 2>/dev/null || 
true)
                 if [ -n "${owner_tier}" ] && [ "${owner_tier}" != 
"${NETWORK_ID}" ]; then
-                    log "destroy: skipping veth for $(basename "${f%.pvlan}") 
(owned by tier ${owner_tier})"
+                    log "destroy-network: skipping veth for $(basename 
"${f%.pvlan}") (owned by tier ${owner_tier})"
                     continue
                 fi
             fi
@@ -855,29 +866,18 @@ cmd_destroy() {
     # Deregister this tier from VPC tracking
     if [ -n "${VPC_ID}" ]; then
         rm -f "${vsd}/tiers/${NETWORK_ID}" 2>/dev/null || true
-        # Only delete the VPC-wide namespace and state when no more tiers 
remain
-        local remaining_tiers
-        remaining_tiers=$(find "${vsd}/tiers/" -type f 2>/dev/null | wc -l)
-        if [ "${remaining_tiers}" -le 0 ]; then
-            if ip netns list 2>/dev/null | grep -q "^${NAMESPACE}\b"; then
-                ip netns del "${NAMESPACE}"
-                log "destroy: deleted shared namespace ${NAMESPACE} (last VPC 
tier)"
-            fi
-            rm -rf "${vsd}"
-            log "destroy: removed VPC state dir ${vsd}"
-        else
-            log "destroy: preserved namespace ${NAMESPACE} (${remaining_tiers} 
tier(s) remaining)"
-        fi
+        # The VPC namespace is managed by shutdown-vpc / destroy-vpc — do NOT 
delete it here.
+        log "destroy-network: deregistered tier ${NETWORK_ID} from VPC 
${VPC_ID} (namespace preserved)"
     else
         # Isolated network: delete the namespace directly
         if ip netns list 2>/dev/null | grep -q "^${NAMESPACE}\b"; then
             ip netns del "${NAMESPACE}"
-            log "destroy: deleted namespace ${NAMESPACE}"
+            log "destroy-network: deleted namespace ${NAMESPACE}"
         fi
     fi
 
     release_lock
-    log "destroy: done network=${NETWORK_ID}"
+    log "destroy-network: done network=${NETWORK_ID}"
 }
 
 ##############################################################################
@@ -961,7 +961,9 @@ cmd_assign_ip() {
     fi
 
     # ---- Source NAT ----
-    if [ "${SOURCE_NAT}" = "true" ] && [ -n "${CIDR}" ]; then
+    # For VPC tiers the SNAT rule covers the entire VPC CIDR and is set up by
+    # implement-vpc (using --vpc-cidr).  Duplicate SNAT rules here would 
conflict.
+    if [ "${SOURCE_NAT}" = "true" ] && [ -n "${CIDR}" ] && [ -z "${VPC_ID}" ]; 
then
         ip netns exec "${NAMESPACE}" iptables -t nat \
             -C "${nchain_post}" -s "${CIDR}" -o "${pveth_n}" -j SNAT 
--to-source "${PUBLIC_IP}" 2>/dev/null || \
         ip netns exec "${NAMESPACE}" iptables -t nat \
@@ -971,6 +973,8 @@ cmd_assign_ip() {
         ip netns exec "${NAMESPACE}" iptables -t filter \
             -A "${fchain}" -o "${pveth_n}" -s "${CIDR}" -j ACCEPT
         log "Source NAT: ${CIDR} -> ${PUBLIC_IP} via ${pveth_n}"
+    elif [ "${SOURCE_NAT}" = "true" ] && [ -n "${VPC_ID}" ]; then
+        log "assign-ip: skipping SNAT rules for VPC tier (managed by 
implement-vpc)"
     fi
 
     # ---- Persist state ----
@@ -2231,20 +2235,36 @@ cmd_save_vm_data() {
     acquire_lock "${NETWORK_ID}"
     log "save-vm-data: network=${NETWORK_ID} ip=${VM_IP}"
     [ -z "${VM_IP}" ]   && die "save-vm-data: missing --ip"
-    [ -z "${VM_DATA}" ] && die "save-vm-data: missing --vm-data"
+
+    local vm_data_file="${VM_DATA_FILE}"
+    local cleanup_vm_data_file="false"
+    if [ -z "${vm_data_file}" ]; then
+        [ -z "${VM_DATA}" ] && die "save-vm-data: missing --vm-data or 
--vm-data-file"
+        vm_data_file=$(mktemp /tmp/cs-extnet-vm-data-XXXXXX)
+        cleanup_vm_data_file="true"
+        printf '%s' "${VM_DATA}" > "${vm_data_file}"
+    fi
+    [ -f "${vm_data_file}" ] || die "save-vm-data: payload file not found: 
${vm_data_file}"
 
     local meta_dir; meta_dir=$(_metadata_dir)
     local passwd_f; passwd_f=$(_passwd_file)
     mkdir -p "${meta_dir}" "$(dirname "${passwd_f}")"
     touch "${passwd_f}"
 
-    python3 - "${VM_IP}" "${meta_dir}" "${passwd_f}" "${VM_DATA}" << 'PYEOF'
+    python3 - "${VM_IP}" "${meta_dir}" "${passwd_f}" "${vm_data_file}" << 
'PYEOF'
 import base64, json, os, sys
 
 vm_ip      = sys.argv[1]
 meta_dir   = sys.argv[2]
 passwd_f   = sys.argv[3]
-data_b64   = sys.argv[4]
+data_file  = sys.argv[4]
+
+try:
+    with open(data_file, 'r', encoding='utf-8') as f:
+        data_b64 = f.read().strip()
+except Exception as e:
+    print(f"save-vm-data: failed to read vm-data file: {e}", file=sys.stderr)
+    sys.exit(1)
 
 # Decode the outer base64 wrapper, then parse the JSON array
 try:
@@ -2321,6 +2341,10 @@ if password_written:
 print(f"save-vm-data: wrote {len(entries)} entries for {vm_ip}")
 PYEOF
 
+    if [ "${cleanup_vm_data_file}" = "true" ]; then
+        rm -f "${vm_data_file}" 2>/dev/null || true
+    fi
+
     _write_apache2_conf
     _svc_start_or_reload_apache2
     _svc_start_or_reload_passwd_server
@@ -2389,6 +2413,15 @@ cmd_apply_fw_rules() {
     acquire_lock "${NETWORK_ID}"
     log "apply-fw-rules: network=${NETWORK_ID} ns=${NAMESPACE}"
 
+    local fw_rules_file="${FW_RULES_FILE}"
+    local cleanup_fw_rules_file="false"
+    if [ -z "${fw_rules_file}" ]; then
+        fw_rules_file=$(mktemp /tmp/cs-extnet-fw-rules-XXXXXX)
+        cleanup_fw_rules_file="true"
+        printf '%s' "${FW_RULES_JSON:-}" > "${fw_rules_file}"
+    fi
+    [ -f "${fw_rules_file}" ] || die "apply-fw-rules: payload file not found: 
${fw_rules_file}"
+
     local veth_n fchain fw_chain
     veth_n=$(veth_ns_name "${VLAN}" "${CHOSEN_ID}")
     fchain=$(filter_chain "${NETWORK_ID}")
@@ -2406,15 +2439,22 @@ cmd_apply_fw_rules() {
     ip netns exec "${NAMESPACE}" iptables -t filter -N "${fw_chain}"
 
     # ---- 4. Build iptables rules via Python ----
-    python3 - "${NAMESPACE}" "${FW_RULES_JSON:-}" "${veth_n}" \
+    python3 - "${NAMESPACE}" "${fw_rules_file}" "${veth_n}" \
               "${fw_chain}" << 'PYEOF'
 import base64, json, re, subprocess, sys
 
 namespace = sys.argv[1]
-rules_b64 = sys.argv[2]
+rules_file = sys.argv[2]
 veth_n    = sys.argv[3]
 fw_chain  = sys.argv[4]   # filter table egress chain (CS_EXTNET_FWRULES_<N>)
 
+try:
+    with open(rules_file, 'r', encoding='utf-8') as f:
+        rules_b64 = f.read().strip()
+except Exception as e:
+    print(f"apply-fw-rules: failed to read rules file: {e}", file=sys.stderr)
+    sys.exit(1)
+
 # Prefix for per-public-IP ingress chains in the mangle table.
 # e.g. CS_EXTNET_FWI_10.0.56.20
 FW_INGRESS_PREFIX = 'CS_EXTNET_FWI_'
@@ -2592,6 +2632,11 @@ print(f"apply-fw-rules: built {n_in} ingress rule(s) 
across {len(pub_ip_rules)}
 PYEOF
 
     local py_exit=$?
+
+    if [ "${cleanup_fw_rules_file}" = "true" ]; then
+        rm -f "${fw_rules_file}" 2>/dev/null || true
+    fi
+
     if [ ${py_exit} -ne 0 ]; then
         # Python script failed — leave chain empty but continue so that the
         # fchain catch-all rules remain effective (fail-open for existing 
traffic).
@@ -2768,7 +2813,16 @@ cmd_restore_network() {
     _load_state
     acquire_lock "${NETWORK_ID}"
     log "restore-network: network=${NETWORK_ID} ns=${NAMESPACE}"
-    [ -z "${RESTORE_DATA}" ] && die "restore-network: missing --restore-data"
+
+    local restore_data_file="${RESTORE_DATA_FILE}"
+    local cleanup_restore_data_file="false"
+    if [ -z "${restore_data_file}" ]; then
+        [ -z "${RESTORE_DATA}" ] && die "restore-network: missing 
--restore-data or --restore-data-file"
+        restore_data_file=$(mktemp /tmp/cs-extnet-restore-data-XXXXXX)
+        cleanup_restore_data_file="true"
+        printf '%s' "${RESTORE_DATA}" > "${restore_data_file}"
+    fi
+    [ -f "${restore_data_file}" ] || die "restore-network: payload file not 
found: ${restore_data_file}"
 
     local dhcp_hosts; dhcp_hosts=$(_dnsmasq_dhcp_hosts)
     local dhcp_opts;  dhcp_opts=$(_dnsmasq_dhcp_opts)
@@ -2780,19 +2834,26 @@ cmd_restore_network() {
     touch "${dhcp_hosts}" "${dhcp_opts}" "${dns_hosts}" "${passwd_f}"
 
     python3 - \
-        "${RESTORE_DATA}" \
+        "${restore_data_file}" \
         "${dhcp_hosts}" "${dhcp_opts}" "${dns_hosts}" \
         "${meta_dir}" "${passwd_f}" \
         << 'PYEOF'
 import base64, json, os, sys
 
-restore_b64  = sys.argv[1]
+restore_file = sys.argv[1]
 dhcp_hosts_f = sys.argv[2]
 dhcp_opts_f  = sys.argv[3]
 dns_hosts_f  = sys.argv[4]
 meta_dir     = sys.argv[5]
 passwd_f     = sys.argv[6]
 
+try:
+    with open(restore_file, 'r', encoding='utf-8') as f:
+        restore_b64 = f.read().strip()
+except Exception as e:
+    print(f"restore-network: failed to read restore-data file: {e}", 
file=sys.stderr)
+    sys.exit(1)
+
 # ---- Decode the outer base64, then parse JSON ----
 try:
     data = json.loads(base64.b64decode(restore_b64).decode('utf-8'))
@@ -2938,16 +2999,16 @@ print("restore-network: done")
 PYEOF
 
     # ------------------------------------------------------------------
-    # Decode dhcp_enabled / dns_enabled from RESTORE_DATA so we can
+    # Decode dhcp_enabled / dns_enabled from restore_data_file so we can
     # reconfigure dnsmasq correctly even when the namespace (and therefore
-    # dnsmasq.conf) was deleted and recreated.  RESTORE_DATA is passed as
-    # a positional argument to avoid shell-quoting issues with base64 data.
+    # dnsmasq.conf) was deleted and recreated.
     # ------------------------------------------------------------------
     local _r_flags
-    _r_flags=$(python3 - "${RESTORE_DATA}" 2>/dev/null << 'PYFLAGSEOF'
-import base64, json, sys
+    _r_flags=$(python3 - "${restore_data_file}" 2>/dev/null << 'PYFLAGSEOF'
+import base64, json, sys, pathlib
 try:
-    d = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8'))
+    b64_data = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8').strip()
+    d = json.loads(base64.b64decode(b64_data).decode('utf-8'))
     dhcp = 'true' if d.get('dhcp_enabled', False) else 'false'
     dns  = 'true' if d.get('dns_enabled',  False) else 'false'
     print(dhcp + ' ' + dns)
@@ -2955,6 +3016,10 @@ except Exception:
     print('false false')
 PYFLAGSEOF
 )
+
+    if [ "${cleanup_restore_data_file}" = "true" ]; then
+        rm -f "${restore_data_file}" 2>/dev/null || true
+    fi
     local _r_dhcp _r_dns
     _r_dhcp="${_r_flags%% *}"; _r_dhcp="${_r_dhcp:-false}"
     _r_dns="${_r_flags##* }";  _r_dns="${_r_dns:-false}"
@@ -2989,6 +3054,344 @@ PYFLAGSEOF
     log "restore-network: done network=${NETWORK_ID}"
 }
 
+##############################################################################
+# Helpers: parse VPC-level args (no --network-id required)
+##############################################################################
+
+parse_vpc_args() {
+    VPC_ID=""
+    NAMESPACE=""
+    VPC_CIDR=""
+    PUBLIC_IP=""
+    PUBLIC_VLAN=""
+    PUBLIC_GATEWAY=""
+    PUBLIC_CIDR=""
+    SOURCE_NAT="false"
+
+    while [ $# -gt 0 ]; do
+        case "$1" in
+            --vpc-id)              VPC_ID="$2";          shift 2 ;;
+            --namespace)           NAMESPACE="$2";        shift 2 ;;
+            --cidr)                VPC_CIDR="$2";         shift 2 ;;
+            --public-ip)           PUBLIC_IP="$2";        shift 2 ;;
+            --public-vlan)         PUBLIC_VLAN="$2";      shift 2 ;;
+            --public-gateway)      PUBLIC_GATEWAY="$2";   shift 2 ;;
+            --public-cidr)         PUBLIC_CIDR="$2";      shift 2 ;;
+            --source-nat)          SOURCE_NAT="$2";       shift 2 ;;
+            # consumed by _pre_scan_args — skip silently
+            --physical-network-extension-details|--network-extension-details)
+                                   shift 2 ;;
+            *)                     shift ;;
+        esac
+    done
+
+    [ -z "${VPC_ID}" ] && die "Missing --vpc-id"
+
+    if [ -z "${NAMESPACE}" ]; then
+        local NS_FROM_DETAILS
+        NS_FROM_DETAILS=$(_json_get "${EXTENSION_DETAILS}" "namespace")
+        NAMESPACE="${NS_FROM_DETAILS:-cs-vpc-${VPC_ID}}"
+    fi
+
+    # Normalise VLANs
+    if [ -n "${PUBLIC_VLAN}" ]; then
+        PUBLIC_VLAN=$(normalize_vlan "${PUBLIC_VLAN}")
+    fi
+}
+
+##############################################################################
+# Command: implement-vpc
+# Creates the VPC namespace, enables IP forwarding, and optionally sets up
+# VPC-level source NAT (--public-ip / --public-vlan / --source-nat true).
+# State is persisted to ${STATE_DIR}/vpc-<vpcId>/
+##############################################################################
+
+cmd_implement_vpc() {
+    parse_vpc_args "$@"
+    log "implement-vpc: vpc=${VPC_ID} ns=${NAMESPACE} cidr=${VPC_CIDR}"
+
+    # ---- 1. Create (or ensure) VPC namespace ----
+    if ! ip netns list 2>/dev/null | grep -q "^${NAMESPACE}\b"; then
+        ip netns add "${NAMESPACE}"
+        log "implement-vpc: created namespace ${NAMESPACE}"
+    fi
+    ip netns exec "${NAMESPACE}" ip link set lo up 2>/dev/null || true
+
+    # Disable IPv6 inside the namespace
+    ip netns exec "${NAMESPACE}" sysctl -w net.ipv6.conf.all.disable_ipv6=1 
>/dev/null 2>&1 || true
+    ip netns exec "${NAMESPACE}" sysctl -w 
net.ipv6.conf.default.disable_ipv6=1 >/dev/null 2>&1 || true
+    ip netns exec "${NAMESPACE}" sysctl -w net.ipv6.conf.lo.disable_ipv6=1 
>/dev/null 2>&1 || true
+
+    # ---- 2. IP forwarding ----
+    ip netns exec "${NAMESPACE}" sysctl -w net.ipv4.ip_forward=1 >/dev/null 
2>&1 || true
+
+    # ---- 3. VPC-level source NAT (if source NAT IP provided) ----
+    # Creates the public veth pair and SNAT rule for the entire VPC CIDR.
+    if [ -n "${PUBLIC_IP}" ] && [ -n "${PUBLIC_VLAN}" ] && [ "${SOURCE_NAT}" = 
"true" ] && [ -n "${VPC_CIDR}" ]; then
+        local pveth_h pveth_n pub_br
+        pveth_h=$(pub_veth_host_name "${PUBLIC_VLAN}" "${VPC_ID}")
+        pveth_n=$(pub_veth_ns_name   "${PUBLIC_VLAN}" "${VPC_ID}")
+        ensure_host_bridge "${PUB_ETH}" "${PUBLIC_VLAN}"
+        pub_br=$(host_bridge_name "${PUB_ETH}" "${PUBLIC_VLAN}")
+
+        if ! ip link show "${pveth_h}" >/dev/null 2>&1; then
+            ip link add "${pveth_h}" type veth peer name "${pveth_n}"
+            ip link set "${pveth_n}" netns "${NAMESPACE}"
+            ip link set "${pveth_h}" master "${pub_br}"
+            ip link set "${pveth_h}" up
+            ip netns exec "${NAMESPACE}" ip link set "${pveth_n}" up
+            log "implement-vpc: created public veth ${pveth_h} <-> ${pveth_n}"
+        else
+            ip link set "${pveth_h}" up 2>/dev/null || true
+            ip netns exec "${NAMESPACE}" ip link set "${pveth_n}" up 
2>/dev/null || true
+        fi
+
+        # Assign public IP
+        local ADDR_SPEC
+        if [ -n "${PUBLIC_CIDR}" ] && echo "${PUBLIC_CIDR}" | grep -q '/'; then
+            local PREFIX
+            PREFIX=$(echo "${PUBLIC_CIDR}" | cut -d'/' -f2)
+            ADDR_SPEC="${PUBLIC_IP}/${PREFIX}"
+        else
+            ADDR_SPEC="${PUBLIC_IP}/32"
+        fi
+        ip netns exec "${NAMESPACE}" ip addr show "${pveth_n}" 2>/dev/null | \
+            grep -q "${PUBLIC_IP}/" || \
+            ip netns exec "${NAMESPACE}" ip addr add "${ADDR_SPEC}" dev 
"${pveth_n}"
+
+        # Host route
+        ip route show | grep -q "^${PUBLIC_IP}" || \
+            ip route add "${PUBLIC_IP}/32" dev "${pveth_h}" 2>/dev/null || true
+
+        # Default route inside namespace toward upstream gateway
+        if [ -n "${PUBLIC_GATEWAY}" ]; then
+            ip netns exec "${NAMESPACE}" ip route replace default \
+                via "${PUBLIC_GATEWAY}" dev "${pveth_n}" 2>/dev/null || \
+            ip netns exec "${NAMESPACE}" ip route add default \
+                via "${PUBLIC_GATEWAY}" dev "${pveth_n}" 2>/dev/null || true
+            log "implement-vpc: default route via ${PUBLIC_GATEWAY} dev 
${pveth_n}"
+        fi
+
+        # VPC SNAT rule — covers the entire VPC CIDR (all tiers)
+        # Use a VPC-level POSTROUTING chain: CS_EXTNET_<vpcId>_VPC_POST
+        local vpc_post_chain="${CHAIN_PREFIX}_${VPC_ID}_VPC_POST"
+        ensure_chain nat "${vpc_post_chain}"
+        ensure_jump  nat POSTROUTING "${vpc_post_chain}"
+
+        ip netns exec "${NAMESPACE}" iptables -t nat \
+            -C "${vpc_post_chain}" -s "${VPC_CIDR}" -o "${pveth_n}" -j SNAT 
--to-source "${PUBLIC_IP}" 2>/dev/null || \
+        ip netns exec "${NAMESPACE}" iptables -t nat \
+            -A "${vpc_post_chain}" -s "${VPC_CIDR}" -o "${pveth_n}" -j SNAT 
--to-source "${PUBLIC_IP}"
+        log "implement-vpc: VPC SNAT ${VPC_CIDR} -> ${PUBLIC_IP} via 
${pveth_n}"
+
+        # Persist public IP state
+        local vsd="${STATE_DIR}/vpc-${VPC_ID}"
+        mkdir -p "${vsd}/ips"
+        echo "true"         > "${vsd}/ips/${PUBLIC_IP}"
+        echo "${PUBLIC_VLAN}" > "${vsd}/ips/${PUBLIC_IP}.pvlan"
+    fi
+
+    # ---- 4. Persist VPC state ----
+    local vsd="${STATE_DIR}/vpc-${VPC_ID}"
+    mkdir -p "${vsd}"
+    echo "${NAMESPACE}" > "${vsd}/namespace"
+    [ -n "${VPC_CIDR}" ] && echo "${VPC_CIDR}" > "${vsd}/cidr"
+
+    log "implement-vpc: done vpc=${VPC_ID} namespace=${NAMESPACE}"
+}
+
+##############################################################################
+# Command: shutdown-vpc
+# Removes the VPC namespace after all tiers have been shut down.
+# Called by shutdownVpc() in NetworkExtensionElement after all tiers are gone.
+##############################################################################
+
+cmd_shutdown_vpc() {
+    parse_vpc_args "$@"
+    log "shutdown-vpc: vpc=${VPC_ID} ns=${NAMESPACE}"
+
+    if ip netns list 2>/dev/null | grep -q "^${NAMESPACE}\b"; then
+        ip netns del "${NAMESPACE}"
+        log "shutdown-vpc: deleted namespace ${NAMESPACE}"
+    else
+        log "shutdown-vpc: namespace ${NAMESPACE} not found (already removed?)"
+    fi
+
+    log "shutdown-vpc: done vpc=${VPC_ID}"
+}
+
+##############################################################################
+# Command: destroy-vpc
+# Destroys the VPC namespace and removes all VPC state.
+##############################################################################
+
+cmd_destroy_vpc() {
+    parse_vpc_args "$@"
+    log "destroy-vpc: vpc=${VPC_ID} ns=${NAMESPACE}"
+
+    if ip netns list 2>/dev/null | grep -q "^${NAMESPACE}\b"; then
+        ip netns del "${NAMESPACE}"
+        log "destroy-vpc: deleted namespace ${NAMESPACE}"
+    fi
+
+    local vsd="${STATE_DIR}/vpc-${VPC_ID}"
+    rm -rf "${vsd}"
+    log "destroy-vpc: removed VPC state dir ${vsd}"
+
+    log "destroy-vpc: done vpc=${VPC_ID}"
+}
+
+##############################################################################
+# Command: apply-network-acl
+# Applies VPC network ACL rules to the FORWARD chain inside the namespace.
+# Rules are passed as a Base64-encoded JSON array via --acl-rules-file or
+# --acl-rules.  Each rule has:
+#   number, action (allow|deny), trafficType (ingress|egress),
+#   protocol, portStart, portEnd, icmpType, icmpCode, sourceCidrs[]
+##############################################################################
+
+cmd_apply_network_acl() {
+    parse_args "$@"
+    _load_state
+    acquire_lock "${NETWORK_ID}"
+    log "apply-network-acl: network=${NETWORK_ID} ns=${NAMESPACE} cidr=${CIDR}"
+
+    local acl_rules_file="${ACL_RULES_FILE}"
+    local cleanup_acl_file="false"
+    if [ -z "${acl_rules_file}" ]; then
+        acl_rules_file=$(mktemp /tmp/cs-extnet-acl-rules-XXXXXX)
+        cleanup_acl_file="true"
+        printf '%s' "${ACL_RULES_JSON:-}" > "${acl_rules_file}"
+    fi
+    [ -f "${acl_rules_file}" ] || die "apply-network-acl: payload file not 
found: ${acl_rules_file}"
+
+    local veth_n acl_chain_name fchain
+    veth_n=$(veth_ns_name "${VLAN}" "${CHOSEN_ID}")
+    acl_chain_name=$(acl_chain "${NETWORK_ID}")
+    fchain=$(filter_chain "${NETWORK_ID}")
+
+    # ---- 1. Remove existing jump from fchain to acl chain (idempotent) ----
+    ip netns exec "${NAMESPACE}" iptables -t filter \
+        -D "${fchain}" -j "${acl_chain_name}" 2>/dev/null || true
+
+    # ---- 2. Flush and delete old ACL chain ----
+    ip netns exec "${NAMESPACE}" iptables -t filter -F "${acl_chain_name}" 
2>/dev/null || true
+    ip netns exec "${NAMESPACE}" iptables -t filter -X "${acl_chain_name}" 
2>/dev/null || true
+
+    # ---- 3. Create fresh ACL chain ----
+    ip netns exec "${NAMESPACE}" iptables -t filter -N "${acl_chain_name}"
+
+    # ---- 4. Build iptables ACL rules via Python ----
+    python3 - "${NAMESPACE}" "${acl_rules_file}" "${veth_n}" \
+              "${acl_chain_name}" "${CIDR:-}" << 'PYEOF'
+import base64, json, subprocess, sys
+
+namespace    = sys.argv[1]
+rules_file   = sys.argv[2]
+veth_n       = sys.argv[3]
+acl_chain    = sys.argv[4]
+net_cidr     = sys.argv[5]   # tier CIDR (may be empty)
+
+try:
+    with open(rules_file, 'r', encoding='utf-8') as f:
+        rules_b64 = f.read().strip()
+except Exception as e:
+    print(f"apply-network-acl: failed to read rules file: {e}", 
file=sys.stderr)
+    sys.exit(1)
+
+def _run(table, *args):
+    cmd = ['ip', 'netns', 'exec', namespace, 'iptables', '-t', table] + 
list(args)
+    r = subprocess.run(cmd, capture_output=True)
+    if r.returncode != 0:
+        print(f"iptables ({table}): {r.stderr.decode().strip()}", 
file=sys.stderr)
+    return r
+
+def iptf(*args):
+    _run('filter', *args)
+
+if rules_b64:
+    try:
+        rules = json.loads(base64.b64decode(rules_b64).decode('utf-8'))
+    except Exception as e:
+        print(f"apply-network-acl: failed to decode rules: {e}", 
file=sys.stderr)
+        sys.exit(1)
+else:
+    rules = []
+
+# Always allow RELATED,ESTABLISHED so active sessions are not dropped.
+iptf('-A', acl_chain, '-m', 'state', '--state', 'RELATED,ESTABLISHED', '-j', 
'ACCEPT')
+
+for rule in sorted(rules, key=lambda r: r.get('number', 999)):
+    direction  = rule.get('trafficType', 'ingress').lower()
+    protocol   = (rule.get('protocol') or 'all').lower()
+    port_start = rule.get('portStart')
+    port_end   = rule.get('portEnd')
+    icmp_type  = rule.get('icmpType')
+    icmp_code  = rule.get('icmpCode')
+    src_cidrs  = rule.get('sourceCidrs') or ['0.0.0.0/0']
+    action     = 'ACCEPT' if rule.get('action', 'deny').lower() == 'allow' 
else 'DROP'
+
+    for src_cidr in src_cidrs:
+        a = []
+        if direction == 'ingress':
+            # Traffic toward VMs (going OUT on guest veth_n into the tier 
subnet)
+            a = ['-o', veth_n]
+            if net_cidr:
+                a += ['-d', net_cidr]
+            if src_cidr and src_cidr not in ('0.0.0.0/0', '::/0', ''):
+                a += ['-s', src_cidr]
+        else:
+            # Traffic FROM VMs (coming IN on guest veth_n from the tier subnet)
+            a = ['-i', veth_n]
+            if net_cidr:
+                a += ['-s', net_cidr]
+            # For egress rules sourceCidrs is used as destination filter
+            if src_cidr and src_cidr not in ('0.0.0.0/0', '::/0', ''):
+                a += ['-d', src_cidr]
+
+        if protocol not in ('all', ''):
+            a += ['-p', protocol]
+            if protocol in ('tcp', 'udp') and port_start is not None:
+                port_spec = str(port_start)
+                if port_end is not None and port_end != port_start:
+                    port_spec = f"{port_start}:{port_end}"
+                a += ['--dport', port_spec]
+            elif protocol == 'icmp' and icmp_type is not None and icmp_type != 
-1:
+                icmp_spec = str(icmp_type)
+                if icmp_code is not None and icmp_code != -1:
+                    icmp_spec += f"/{icmp_code}"
+                a += ['--icmp-type', icmp_spec]
+
+        iptf('-A', acl_chain, *a, '-j', action)
+
+# Default: DROP all unmatched traffic (implicit deny at end of ACL)
+iptf('-A', acl_chain, '-j', 'DROP')
+
+print(f"apply-network-acl: applied {len(rules)} ACL rule(s) to chain 
{acl_chain}")
+PYEOF
+
+    local py_exit=$?
+
+    if [ "${cleanup_acl_file}" = "true" ]; then
+        rm -f "${acl_rules_file}" 2>/dev/null || true
+    fi
+
+    if [ ${py_exit} -ne 0 ]; then
+        log "apply-network-acl: Python rule builder exited ${py_exit}; ACL 
chain may be incomplete"
+    fi
+
+    # ---- 5. Insert jump from fchain to acl chain at position 1 ----
+    # ACL rules take precedence over the catch-all ACCEPT rules in fchain.
+    if ip netns exec "${NAMESPACE}" iptables -t filter -n -L "${fchain}" 
>/dev/null 2>&1; then
+        ip netns exec "${NAMESPACE}" iptables -t filter \
+            -I "${fchain}" 1 -j "${acl_chain_name}" 2>/dev/null || true
+        log "apply-network-acl: inserted ACL jump in ${fchain}"
+    fi
+
+    release_lock
+    log "apply-network-acl: done network=${NETWORK_ID}"
+}
+
 ##############################################################################
 # Main dispatcher
 ##############################################################################
@@ -2999,9 +3402,13 @@ COMMAND="${1:-}"
 shift || true
 
 case "${COMMAND}" in
-    implement)                cmd_implement                "$@" ;;
-    shutdown)                 cmd_shutdown                 "$@" ;;
-    destroy)                  cmd_destroy                  "$@" ;;
+    implement-network)        cmd_implement_network        "$@" ;;
+    shutdown-network)         cmd_shutdown_network         "$@" ;;
+    destroy-network)          cmd_destroy_network          "$@" ;;
+    # VPC lifecycle
+    implement-vpc)            cmd_implement_vpc            "$@" ;;
+    shutdown-vpc)             cmd_shutdown_vpc             "$@" ;;
+    destroy-vpc)              cmd_destroy_vpc              "$@" ;;
     assign-ip)                cmd_assign_ip                "$@" ;;
     release-ip)               cmd_release_ip               "$@" ;;
     add-static-nat)           cmd_add_static_nat           "$@" ;;
@@ -3028,15 +3435,19 @@ case "${COMMAND}" in
     # Load balancing (haproxy)
     apply-fw-rules)           cmd_apply_fw_rules           "$@" ;;
     apply-lb-rules)           cmd_apply_lb_rules           "$@" ;;
+    # ACL rules (VPC network ACLs)
+    apply-network-acl)        cmd_apply_network_acl        "$@" ;;
     # Custom actions
     custom-action)            cmd_custom_action            "$@" ;;
     "")
-        echo "Usage: $0 {implement|shutdown|destroy|assign-ip|release-ip|" \
+        echo "Usage: $0 {implement-network|shutdown-network|destroy-network|" \
+             "implement-vpc|shutdown-vpc|destroy-vpc|" \
+             "assign-ip|release-ip|" \
              
"add-static-nat|delete-static-nat|add-port-forward|delete-port-forward|" \
              
"config-dhcp-subnet|remove-dhcp-subnet|add-dhcp-entry|remove-dhcp-entry|set-dhcp-options|"
 \
              
"config-dns-subnet|remove-dns-subnet|add-dns-entry|remove-dns-entry|" \
              
"save-userdata|save-password|save-sshkey|save-hypervisor-hostname|save-vm-data|restore-network|"
 \
-             "apply-fw-rules|apply-lb-rules|custom-action} [options]" >&2
+             "apply-fw-rules|apply-lb-rules|apply-network-acl|custom-action} 
[options]" >&2
         exit 1 ;;
     *)
         echo "Unknown command: ${COMMAND}" >&2
diff --git a/Network-Namespace/network-namespace.sh 
b/Network-Namespace/network-namespace.sh
index 4155f01..090b46f 100755
--- a/Network-Namespace/network-namespace.sh
+++ b/Network-Namespace/network-namespace.sh
@@ -152,6 +152,10 @@ EXTENSION_DETAILS="{}"
 NETWORK_ID=""
 CURRENT_DETAILS="{}"
 VPC_ID=""
+VM_DATA_FILE=""
+FW_RULES_FILE=""
+RESTORE_DATA_FILE=""
+ACL_RULES_FILE=""
 FORWARD_ARGS=()
 
 while [ $# -gt 0 ]; do
@@ -173,6 +177,18 @@ while [ $# -gt 0 ]; do
         --current-details)
             CURRENT_DETAILS="${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 ;;
         *)
             FORWARD_ARGS+=("$1")
             shift ;;
@@ -280,12 +296,28 @@ ssh_exec() {
     fi
 }
 
+upload_file_to_remote() {
+    local host="$1" local_file="$2" tag="$3"
+    [ -f "${local_file}" ] || die "Missing local payload file: ${local_file}" 1
+
+    local remote_tmp
+    remote_tmp=$(ssh_exec "${host}" "mktemp /tmp/cs-extnet-${tag}-XXXXXX") || \
+        die "Failed to create remote temp file for ${tag}" 2
+    remote_tmp=$(printf '%s' "${remote_tmp}" | tr -d '\r\n')
+    [ -n "${remote_tmp}" ] || die "Failed to resolve remote temp file for 
${tag}" 2
+
+    cat "${local_file}" | ssh_exec "${host}" "cat > '${remote_tmp}' && chmod 
600 '${remote_tmp}'" || \
+        die "Failed to upload payload file for ${tag}" 2
+
+    printf '%s' "${remote_tmp}"
+}
+
 # ---------------------------------------------------------------------------
 # ensure-network-device
 # ---------------------------------------------------------------------------
 
 if [ "${COMMAND}" = "ensure-network-device" ]; then
-    [ -z "${NETWORK_ID}" ] && die "ensure-network-device: missing 
--network-id" 1
+    [ -z "${NETWORK_ID}" ] && [ -z "${VPC_ID}" ] && die 
"ensure-network-device: missing --network-id or --vpc-id" 1
 
     if [ ${#HOST_LIST[@]} -eq 0 ]; then
         die "ensure-network-device: no hosts configured. Set 'hosts' in 
registerExtension details." 1
@@ -311,7 +343,7 @@ if [ "${COMMAND}" = "ensure-network-device" ]; then
             h="${h// /}"
             if [ "${h}" = "${CURRENT_HOST}" ]; then
                 if host_reachable "${CURRENT_HOST}"; then
-                    log "ensure-network-device: network=${NETWORK_ID} keeping 
current host=${CURRENT_HOST}"
+                    log "ensure-network-device: 
${NETWORK_ID:+network=${NETWORK_ID} }${VPC_ID:+vpc=${VPC_ID} }keeping current 
host=${CURRENT_HOST}"
                     if [ -n "${VPC_ID}" ]; then
                         printf 
'{"host":"%s","namespace":"%s","vpc_id":"%s"}\n' \
                             "${CURRENT_HOST}" "${NAMESPACE}" "${VPC_ID}"
@@ -350,7 +382,7 @@ if [ "${COMMAND}" = "ensure-network-device" ]; then
         _H="${HOST_LIST[$_IDX]// /}"
         if host_reachable "${_H}"; then
             _SELECTED_HOST="${_H}"
-            log "ensure-network-device: network=${NETWORK_ID} hash-selected 
host=${_SELECTED_HOST} (key=${_ROUTE_KEY}, idx=${_IDX})"
+            log "ensure-network-device: ${NETWORK_ID:+network=${NETWORK_ID} 
}${VPC_ID:+vpc=${VPC_ID} }hash-selected host=${_SELECTED_HOST} 
(key=${_ROUTE_KEY}, idx=${_IDX})"
             break
         fi
         log "ensure-network-device: host ${_H} not reachable, trying next"
@@ -386,6 +418,28 @@ for arg in "${FORWARD_ARGS[@]}"; do
     remote_args+=("'${arg//"'"/"'\\''"}'" )
 done
 
+REMOTE_PAYLOAD_FILES=()
+if [ -n "${VM_DATA_FILE}" ]; then
+    REMOTE_VM_DATA_FILE=$(upload_file_to_remote "${REMOTE_HOST}" 
"${VM_DATA_FILE}" "vm-data")
+    REMOTE_PAYLOAD_FILES+=("${REMOTE_VM_DATA_FILE}")
+    remote_args+=("'--vm-data-file'" "'${REMOTE_VM_DATA_FILE//"'"/"'\\''"}'")
+fi
+if [ -n "${FW_RULES_FILE}" ]; then
+    REMOTE_FW_RULES_FILE=$(upload_file_to_remote "${REMOTE_HOST}" 
"${FW_RULES_FILE}" "fw-rules")
+    REMOTE_PAYLOAD_FILES+=("${REMOTE_FW_RULES_FILE}")
+    remote_args+=("'--fw-rules-file'" "'${REMOTE_FW_RULES_FILE//"'"/"'\\''"}'")
+fi
+if [ -n "${ACL_RULES_FILE}" ]; then
+    REMOTE_ACL_RULES_FILE=$(upload_file_to_remote "${REMOTE_HOST}" 
"${ACL_RULES_FILE}" "acl-rules")
+    REMOTE_PAYLOAD_FILES+=("${REMOTE_ACL_RULES_FILE}")
+    remote_args+=("'--acl-rules-file'" 
"'${REMOTE_ACL_RULES_FILE//"'"/"'\\''"}'")
+fi
+if [ -n "${RESTORE_DATA_FILE}" ]; then
+    REMOTE_RESTORE_DATA_FILE=$(upload_file_to_remote "${REMOTE_HOST}" 
"${RESTORE_DATA_FILE}" "restore-data")
+    REMOTE_PAYLOAD_FILES+=("${REMOTE_RESTORE_DATA_FILE}")
+    remote_args+=("'--restore-data-file'" 
"'${REMOTE_RESTORE_DATA_FILE//"'"/"'\\''"}'")
+fi
+
 PHYS_ESCAPED="${PHYS_DETAILS//\'/\'\\\'\'}"
 EXT_ESCAPED="${EXTENSION_DETAILS//\'/\'\\\'\'}"
 REMOTE_CMD="'${REMOTE_SCRIPT}' '${COMMAND}' ${remote_args[*]} 
--physical-network-extension-details '${PHYS_ESCAPED}' 
--network-extension-details '${EXT_ESCAPED}'"
@@ -395,6 +449,12 @@ log "Remote: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} 
cmd=${COMMAND}"
 RC=0
 ssh_exec "${REMOTE_HOST}" "${REMOTE_CMD}" || RC=$?
 
+if [ ${#REMOTE_PAYLOAD_FILES[@]} -gt 0 ]; then
+    for _rf in "${REMOTE_PAYLOAD_FILES[@]}"; do
+        ssh_exec "${REMOTE_HOST}" "rm -f '${_rf}'" >/dev/null 2>&1 || true
+    done
+fi
+
 if [ ${RC} -ne 0 ]; then
     if [ ${RC} -eq 255 ]; then
         log "SSH connection failed (rc=255): 
host=${REMOTE_HOST}:${REMOTE_PORT} user=${REMOTE_USER}"

Reply via email to