skip to content
Hacktivate
Table of Contents

Why you can’t just bridge Wi-Fi

If you’re coming from VirtualBox or VMware, you’re used to a checkbox. Tick “bridged”, and your VM turns up on the LAN like any other machine on the shelf. Try the same trick with KVM over a wireless card and you walk straight into a wall. The wall, to be fair to KVM, isn’t KVM’s fault.

A Layer 2 bridge earns its keep by relaying frames on behalf of a whole crowd of MAC addresses, one per guest, all leaving through the same physical port. Wired Ethernet shrugs and gets on with it. Wi-Fi does not. The 802.11 standard says a station may only send frames carrying its own MAC as the source, so the moment your host tries to speak for a guest, the access point drops the frame. No error, no log, no hint. Just silence, which is the most annoying kind of failure.

So we stop arguing with the radio and move up a layer. Instead of bridging Ethernet frames we route IP packets, and we lean on proxy ARP to keep the guests looking local to everyone else. The good part: you don’t need anything exotic to pull this off. libvirt and the Linux kernel between them cover the entire job. No hand-rolled TAP devices, no per-guest routes to babysit, no unmaintained daemons quietly rotting in the background.

Below are two approaches that both work. Which one you want comes down to a single question: are you happy with your guests living on their own little routed subnet (Option A, simpler and sturdier), or do they need to sit on the exact same subnet as the rest of your LAN (Option B)?


This is the one to reach for first. libvirt sets up a small dedicated subnet, runs DHCP on it for your guests, and routes between that subnet and your wireless interface. It’s all declarative, it survives reboots without complaining, and you manage it through virsh or virt-manager like any other network.

Your guests get their own subnet and reach the internet and your LAN happily. Other machines on the LAN reach the guests through the host, which is now quietly acting as a router. For the things most of us run (dev boxes, small servers, a K8s node or three) this is exactly what you want.

1. Define the network

Create a file called wifi-routed.xml. Swap wlan0 for your wireless interface, and pick a small subnet that doesn’t overlap your LAN’s DHCP range:

wifi-routed.xml
<network>
<name>wifi-routed</name>
<forward dev="wlan0" mode="route">
<interface dev="wlan0"/>
</forward>
<bridge name="virbr1" stp="on" delay="0"/>
<domain name="wifi-routed"/>
<ip address="192.168.100.1" netmask="255.255.255.0">
<dhcp>
<range start="192.168.100.10" end="192.168.100.100"/>
</dhcp>
</ip>
</network>

2. Register and start it

Terminal window
virsh net-define wifi-routed.xml
virsh net-autostart wifi-routed
virsh net-start wifi-routed

Use net-define rather than the older net-create: the former writes the network to disk so it’s still there after a reboot, the latter forgets it the moment libvirt restarts. net-autostart is what brings it up on boot.

3. Enable IP forwarding

libvirt will usually take care of this for you, but it’s the sort of thing you want written down somewhere permanent rather than left to chance. Add it to /etc/sysctl.d/99-kvm-wifi.conf:

/etc/sysctl.d/99-kvm-wifi.conf
net.ipv4.ip_forward = 1

Then apply it straight away with sudo sysctl --system.

4. Attach your VMs

In virt-manager, open the VM’s NIC and point the network source at wifi-routed. If you prefer the CLI:

Terminal window
virsh edit <vm-name>
<interface type='network'>
<source network='wifi-routed'/>
<model type='virtio'/>
</interface>

Boot the guest. It picks up an address from libvirt’s DHCP (something like 192.168.100.10), reaches the internet, and is reachable from the host and, thanks to the routing you just set up, from the rest of the LAN too.


Option B: same-subnet with kernel proxy ARP

Reach for this one when your guests really do have to appear on the same subnet as your physical LAN, say because other machines need to find them without a routing hop in the way. It still runs on proxy ARP, but the kernel’s own implementation via sysctl, which retires the old parprouted daemon completely.

1. Create a network on the LAN subnet

Let’s say your Wi-Fi lives on 192.168.1.0/24. Carve out a slice of that range your router’s DHCP never hands out (192.168.1.128/28 will do nicely) so nothing ends up fighting over an address. Create wifi-proxyarp.xml:

wifi-proxyarp.xml
<network>
<name>wifi-proxyarp</name>
<forward dev="wlan0" mode="route">
<interface dev="wlan0"/>
</forward>
<bridge name="virbr2" stp="on" delay="0"/>
<domain name="wifi-proxyarp"/>
<ip address="192.168.1.129" netmask="255.255.255.240">
<dhcp>
<range start="192.168.1.136" end="192.168.1.142"/>
</dhcp>
</ip>
</network>

Register and start it exactly as in Option A:

Terminal window
virsh net-define wifi-proxyarp.xml
virsh net-autostart wifi-proxyarp
virsh net-start wifi-proxyarp

2. Enable proxy ARP on both interfaces

This is the step that makes the whole thing tick, and the modern stand-in for parprouted. The kernel starts answering ARP requests for your guests’ IPs on their behalf, so the rest of the LAN cheerfully believes the guests are sitting right there next to them. Turn it on for the wireless interface and the libvirt bridge:

Terminal window
sudo sysctl -w net.ipv4.conf.wlan0.proxy_arp=1
sudo sysctl -w net.ipv4.conf.virbr2.proxy_arp=1

Then make it stick by adding it to /etc/sysctl.d/99-kvm-wifi.conf:

/etc/sysctl.d/99-kvm-wifi.conf
net.ipv4.ip_forward = 1
net.ipv4.conf.all.proxy_arp = 1
net.ipv4.conf.wlan0.proxy_arp = 1
net.ipv4.conf.virbr2.proxy_arp = 1

Followed by sudo sysctl --system.

3. Attach VMs and configure addresses

Point each VM’s NIC at the wifi-proxyarp network, the same virsh edit block as Option A, just with <source network='wifi-proxyarp'/>. Guests in the .136-.142 range get their addresses over DHCP, the gateway is the bridge itself at 192.168.1.129, and DNS can go straight to your real router at 192.168.1.1.

Because those addresses sit inside the LAN’s own /24, other machines on the network can now talk to your guests directly, with the host answering ARP and shuffling packets in the background without anyone noticing.


Legacy method: parprouted + manual TAP (for reference only)

Older guides, including (I’ll admit) the earlier version of this very post, built the whole thing by hand: create a TAP device with ip tuntap, run the parprouted daemon to ferry ARP requests between wlan0 and the TAP, add a host route for every guest, then launch QEMU with a raw -netdev tap argument.

It still works. There’s just not much reason to put yourself through it any more:

  • parprouted is unmaintained, and the kernel’s built-in proxy_arp (Option B) does the same job without pulling in an external dependency.
  • Manual TAP devices need one interface per VM, and they can’t do DHCP without extra helpers bolted on.
  • Every last piece of it has to be recreated, or scripted, at each boot.

If you already have a parprouted setup humming along, moving to Option A or B lets you delete the daemon, the manual routes, and all the per-VM TAP bookkeeping in one go. And if you’re starting fresh today, there’s no reason to go down that road at all.


Summary

Option A: routed network Option B: same-subnet proxy ARP Legacy: parprouted
Guest subnet Separate, routed to LAN Same /24 as LAN Same as LAN
Proxy ARP by libvirt routing Kernel sysctl parprouted daemon
DHCP for guests Yes (libvirt) Yes (libvirt) No
Per-VM setup None None One TAP each
Maintained Yes Yes No

Start with Option A. Only move to Option B if you genuinely need your guests on the same subnet as everything else on the LAN. Either way you’re working with libvirt and a handful of standard kernel sysctl settings, and nothing unmaintained is left sitting in the path of your packets.