wireless bridge with proxy ARP, nftables, WireGuard, a network namespace and veth pair posted Sun, 12 Mar 2023 12:56:43 UTC

I recently had to move my home NAS device onto a wireless connection. In doing so, I wanted to maintain my use of a network namespaces and veth pair to isolate all of my WireGuard VPN traffic. However, under Linux, setting up what is effectively a bridged connection on a wireless interface has never been as easy as some other operating systems. As I'm not sure when I might finally be able to get a decent wired connection hooked back up to my NAS, it came time to figure out how to make all of this work together.

The starting point was the Bridging Network Connections with Proxy ARP page on the Debian wiki. It had the very basics of getting this set up, but was missing most of the details. After messing with things a bit, I finally ended up with a working configuration. Let's start with the very basics covered on the wiki. Since I'm old and crotchety, I'm still using /etc/sysctl.conf directly:


net.ipv4.ip_forward=1
net.ipv4.conf.all.proxy_arp=1

My motherboard happened to come with an Intel AX200 wireless interface. I'm currently using wpa_supplicant via systemd to statically configure my device for my home wireless network. I also have iwd currently installed although disabled, so my wireless interface's normal name is being overridden to wlan0 thanks to the iwd package dropping /lib/systemd/network/80-iwd.link in place. When configuring wpa_supplicant@wlan0.service in systemd, it's looking for the corresponding configuration file at /etc/wpa_supplicant/wpa_supplicant-wlan0.conf:


network={
	ssid='Super Secret SSID'
	bssid=12:34:56:78:9a:bc
	key_mgmt=SAE
	sae_password="super secret password"
	ieee80211w=2
}

I had to enable that service obviously with systemctl enable wpa_supplicant@wlan0.service since it isn't configured by default.

Next up was to configure the interface itself. As previously mentioned, since I'm an old man, I'm also still using /etc/network/interfaces, which contains the following relevant section now:


allow-hotplug wlan0
iface wlan0 inet static
	address 192.168.99.2
	gateway 192.168.99.1
	netmask 255.255.255.0
	post-up ip netns add vpn
	post-up ip link add veth.host type veth peer veth.vpn
	post-up ip link set dev veth.host up
	post-up ip link set veth.vpn netns vpn up
	post-up ip -n vpn address add 192.168.99.3/24 dev veth.vpn
	post-up ip route add 192.168.99.3/32 dev veth.host
	post-up ip link add wg1 type wireguard
	post-up ip link set wg1 netns vpn
	post-up ip -n vpn -4 address add 172.24.0.10/32 dev wg1
	post-up ip netns exec vpn wg setconf wg1 /etc/wireguard/wg1.conf
	post-up ip -n vpn link set wg1 up
	post-up ip -n vpn route add default dev wg1
	post-up ip netns exec vpn nft -f /etc/nftables-vpn.conf

The DHCP range starts higher up in my LAN, so I'm using the first couple of addresses at the bottom of the network after my gateway device for my NAS and this separate, VPN only network namespace. The WireGuard interface is configured within the namespace using the appropriate static address as supplied by my VPN provider. I have a separate WireGuard wg0 already configured outside the namespace, so wg1 is what we're using inside of it.

And the final piece listed their at the end of the interface setup loads in my firewall rules for the namespace from /etc/nftables-vpn.conf:


# VPN firewall

flush ruleset

table inet filter {
	chain input {
		type filter hook input priority filter; policy drop;

		# established/related connections
		ct state established,related accept

		# invalid connections
		ct state invalid drop

		# loopback interface
		iif lo accept

		# ICMP (routers may also want: mld-listener-query, nd-router-solicit)
		#ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, echo-reply, echo-request, nd-neighbor-advert, nd-neighbor-solicit, nd-router-advert, packet-too-big, parameter-problem, time-exceeded } accept
		ip protocol icmp icmp type { destination-unreachable, echo-reply, echo-request, parameter-problem, router-advertisement, source-quench, time-exceeded } accept

		# services
		iif veth.vpn tcp dport 9091 accept # Transmission
		iif veth.vpn tcp dport 9117 accept # Jackett
		iifname wg1 tcp dport { 49152-65535 } accept # Transmission
	}

	chain output {
		type filter hook output priority filter; policy drop;

		# explicitly allow my DNS traffic without VPN
		skuid nipsy ip daddr 192.168.99.1 tcp dport domain accept
		skuid nipsy ip daddr 192.168.99.1 udp dport domain accept

		# explicitly allow my Transmission or Jackett RPC traffic without VPN
		oifname veth.vpn skuid nipsy tcp sport 9091 accept
		oifname veth.vpn skuid nipsy tcp sport 9117 accept

		# allow any traffic out through VPN
		oifname wg1 accept

		# drop everything else
		counter drop
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
	}
}

Both Jackett and Transmssion are configured via pretty basic systemd service files you might find anywhere else, with the notable exception that both include the following in their [Service] section:


NetworkNamespacePath=/run/netns/vpn

which seems to do the trick for running them in the namespace correctly.

Lastly, if you create the namespace before setting the kernel parameters above via sysctl, the namespace will not inherit those settings. You'll probably need to either restart or delete and recreate the namespace for those values to be inherited properly. I'm honestly not sure if they're specifically relevant within the context of the namespace itself since I think they only apply to what's happening at the host level directly on wlan0, but it's worth mentioning if everything else looks right and it's still not working for you. Best of luck!