A secure, configurable shell script to run QEMU VMs with different QEMU arguments inside a virtual LAN with their public IP traffic forced through a wireguard VPN
Find a file
2026-02-27 04:42:11 +01:00
.gitignore initial commit 2026-02-04 04:06:49 +01:00
config.example.json rewrite tunnel monitor to actually force restart the tunnel instead of just being a fancy keepalive traffic loop 2026-02-27 03:50:38 +01:00
install-deps.sh make scripts executable 2026-02-04 05:06:14 +01:00
main.sh make tunnel restart more robust by re-applying ephemeral routing settings, which may have disappeared if the veth pair went down (even if just briefly) 2026-02-27 04:42:11 +01:00
README.md rewrite tunnel monitor to actually force restart the tunnel instead of just being a fancy keepalive traffic loop 2026-02-27 03:50:38 +01:00
test.sh generate netns-specific resolv.conf and hosts files, and revert manually specifying dns servers in curl commands because that's now handled by the generated resolv.conf 2026-02-04 23:02:41 +01:00
vm-vpn-netns.example.service allow disabling qemu args restrictions with env var 2026-02-05 06:29:02 +01:00

vm-vpn-netns

Runs one or more QEMU VMs inside a dedicated network namespace, forcing their public IP traffic through a wireguard VPN while putting them on isolated virtual LANs.

Install / setup

  1. Install dependencies
    Run install-deps.sh as root. If you are on an unsupported system, it will tell you what packages to install.

  2. Put configs/scripts somewhere root-owned

  • Copy config.example.json to your real config path (recommended: /etc/vm-vpn-netns/config.json) and ensure it is owned by root and not writable by others.
  • Put your WireGuard config somewhere root-owned (recommended: /etc/vm-vpn-netns/wg.conf) and point wireguard.config_path at it.
  • Copy main.sh to your installation path (recommended: /usr/local/sbin/vm-vpn-netns.sh) and ensure it is owned by root and not writable by others.

Requirements enforced by the script:

  • WG Endpoint = <literal IPv4>:<port> (no hostname)
  • WG AllowedIPs must include 0.0.0.0/0
  • WG Address must include at least one IPv4 address
  • WireGuard reconnect monitor is enabled by default and uses exponential backoff (capped at 5 minutes). Configure it via wireguard.reconnect.* or disable with "enabled": false.
  1. Choose a runtime directory
    Default is /run/vm-vpn-netns. If you change it, make sure it exists, is a directory, and is root-owned. The script will set it to 0711.
    Per-VM socket dirs under it are created and owned by the VM user.

  2. VM disk permissions
    Whatever vms[].run_as is, that user must be able to read/write the VM image paths you pass via extra_args (e.g. -drive file=...).

  3. Per-VM passt MTU Each VM can set vms[].mtu:

  • integer (68..65535): pass that value as passt --mtu
  • "wireguard": read the current MTU from the namespace WireGuard interface and pass it as --mtu
  • null or omitted: do not pass --mtu (passt default behavior) Recommended choice: "mtu": 1200 - see (6)
  1. Combat flaky UDP/DNS (recommended) As noted above, I strongly recommend setting a hardcoded mtu value of 1200.

This is because Wireguard does not fragment tunneled UDP packets on the way from host -> VPN endpoint. Instead, it just wraps an inner UDP packet of size N in an outer UDP packet of size (N+80). This wrapped packet may have to be fragmented (by IPv4) along the way to the endpoint. Behavior now depends on whether the host sets DF:

If DF is set on the outer packet by the host, the guest effectively becomes responsible for preventing this fragmentation by sending smaller inner packets. Because of the strong network isolation enforced by this setup, the ICMP packets which would normally signal fragmentation requirement to the guest are dropped. This is known as PMTUD black holes. I chose to keep this ICMP dropping behavior to prevent leaking parts of the host -> endpoint path to the guest.

If DF is not set, the packet will be fragmented silently along the way, which can again become unreliable for large UDP packets.

The 1200 recommendation is derived by taking the standard assumed-to-be-safe MTU of 1280 (the lower bound mandated by IPv6) and subtracting the Wireguard overhead of (up to) 80.

If you choose a bigger MTU (i.e. any other option than hardcoding N <= 1200), UDP may be unreliable to the point of being unusable, or may not work at all.

In addition to setting a conservative MTU for passt to advertise to the guest VM as described above, I recommend configuring DNS inside the VM to DNSOverTLS=yes if you can. This will cause DNS to use TCP instead of UDP, reducing the impact of flaky UDP. Note that DNSOverTLS requires your chosen DNS server(s) to support this feature.

  1. Update your firewall (optional) If you are using UFW, run the following once - it persists after that:
sudo ufw route allow in on vmvpn-veth-host out on enp2s0 proto udp

If you don't, UFW will drop packets between the host-side veth end and your NIC, unless you stop and restart UFW while the VM is trying to send packets.

Usage

  • Start (foreground): sudo ./main.sh run --config /etc/vm-vpn-netns/config.json
  • Status: sudo ./main.sh status --config /etc/vm-vpn-netns/config.json
  • Teardown: sudo ./main.sh teardown --config /etc/vm-vpn-netns/config.json

systemd

  1. Install the unit
  • Copy vm-vpn-netns.example.service to /etc/systemd/system/vm-vpn-netns.service
  • Edit ExecStart= to point to your installed main.sh (e.g. /usr/local/sbin/vm-vpn-netns.sh) and your config path.
  1. Enable + start
  • sudo systemctl daemon-reload
  • sudo systemctl enable --now vm-vpn-netns.service

Logs:

  • sudo journalctl -u vm-vpn-netns.service -f

How it works

The script creates a dedicated Linux network namespace with a veth link to the host used only to reach the WireGuard endpoint. Inside the namespace, WireGuard is brought up as the default route and nftables enforces a strict default-deny policy so traffic can only exit via the VPN. Each VM is connected to the namespace through its own unprivileged passt instance over a UNIX socket, avoiding TAP devices or bridges on the host. If the VPN goes down or rules are missing, traffic is dropped rather than leaked.

A reconnect monitor runs in the host namespace and uses only a ping probe via wg0 as its health signal (ping -c1 -W4). It requires a configurable number of consecutive failures (wireguard.reconnect.failures_before_restart) before force-recreating the WireGuard interface, then retries with exponential backoff (capped at 5 minutes) until ping succeeds again.

Running the tests

The test script (test.sh) verifies the security invariants while the service is running, including routing, nftables scoping, and ensuring no traffic leaks on the host-side veth interface.

  1. Start the service first (it must already have created the namespace, veth pair, WireGuard interface, and nftables rules):
sudo ./main.sh run --config /etc/vm-vpn-netns/config.json
  1. In another terminal, run the self-test as root:
sudo CONFIG_PATH=/etc/vm-vpn-netns/config.json ./test.sh
  1. Optional: run the kill-switch test, which temporarily brings wg0 down inside the namespace to confirm VPN-or-drop behavior (VM connectivity will be interrupted during this test):
sudo CONFIG_PATH=/etc/vm-vpn-netns/config.json ./test.sh --killswitch

The test exits non-zero on failure and prints the specific invariant that was violated.