Needlessly complicated: Wireguard inside LXC container as a gateway
It all started with a sudden itch to burn everything to the ground on my VPS server and put everything in LXC containers, including the Wireguard VPN I had. I figured it'd be a good way to learn both LXC and bits of networking, as simple UFW won't do. Well, little did I know it'd also be a major pain for a few days, so here's a short manual to ease it for other people.
Note that all of the instructions are written for Arch Linux, but since you're doing LXC containers, you probably know what to do.
Installing LXC and LXD
First we need to install both LXC and LXD, the first one is the virtualization API itself and the second is it's management daemon.
sudo pacman -S lxc lxd
You may also want to consider adding yourself to lxd group to not to do sudo
every single time:
sudo usermod -a -G lxd $USER
And speaking of user permissions: to run LXC containers in unprivileged mode, you have to change default mappings of your UID/GIDs:
sudo usermod -v 1000000-1000999999 -w 1000000-1000999999 root
Alternatively you could also edit /etc/subuid
and /etc/subguid
files directly.
And don't forget to enable the services:
sudo systemctl enable --now lxd lxc
Now you should initialize the daemon. You probably want default answers to all of the questions.
If you get weird errors such as nftables not knowing what masquerade is or any other obscure errors, try to upgrade your kernel! Apparently it's really important.
lxd init
That should create everything you need, including lxdbr0
interface that will do all the heavy lifting for us on the networking side.
After that, all that's left is creating your container (to see all images use lxc image list images:
, with colon):
lxc launch images:archlinux/current/amd64 wireguard
Now you can see your container with lxc list
and connect to it using lxc exec wireguard bash
Configuring Wireguard
Because LXC containers do not virtualize kernel and use host one, we would need to have wireguard kernel module loaded in host, so install wireguard-dkms
packge on the host system and make sure the module is loaded.
However in container we only need userspace package — so you need to install wireguard-tools
inside it.
Unless you are doing this solely to learn like me, I would recommend using tools such as netmaker or at least scripts to manage peers. Unfortunately, beautiful wireguard-install script would require some tinkering to not to refuse to install in LXC, and to do it properly (i.e. not install kernel module).
As per disclaimer above, install userspace tools:
[root@wireguard ~]$ pacman -S wireguard-tools
And configure your wireguard server profile at /etc/wireguard/wg0.conf
:
[root@wireguard ~]$ wg genkey | tee /etc/wireguard/server-sk.key [root@wireguard ~]$ cat /etc/wireguard/server-sk.key | wg pubkey | tee /etc/wireguard/server-pk.key [root@wireguard ~]$ wg genkey | tee /etc/wireguard/peer1-sk.key [root@wireguard ~]$ cat /etc/wireguard/peer1-sk.key | wg pubkey | tee /etc/wireguard/peer1-pk.key [root@wireguard ~]$ wg genkey | tee /etc/wireguard/peer2-sk.key [root@wireguard ~]$ cat /etc/wireguard/peer2-sk.key | wg pubkey | tee /etc/wireguard/peer2-pk.key
[Interface] Address = 10.10.10.0/24 SaveConfig = true ListenPort = 51820 PrivateKey = <server-sk goes here> [Peer] PublicKey = <peer1-pk goes here> AllowedIPs = 10.10.10.2/32 [Peer] PublicKey = <peer2-pk goes here> AllowedIPs = 10.10.10.3/32
Keen eye might notice that we're missing the usual masquerade commands in the interface configuration:
PostUp = iptables -t nat -I POSTROUTING -o eth0 -j MASQUERADE PreDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
Indeed - it doesn't work as is right now, but will start to if we add it. However, the one caveat of that is that we will not be able to connect to other containers through VPN by external address: meaning if we, say, have a website hosted at myservice.wido.dev:443, we wouldn't be able to connect to it while connected to VPN, even if it's public! I have no idea why this is exactly, it may be also because I was using UDP and just missed something specific to it.
My findings are as follows: if you connect to myservice.wido.dev
, for some reason, the request goes from wg0 interface, but never response gets back; despite being masqueraded, the reply arrives designated specifically for eth0 interface and is never de-masqueraded back to wg0. I don't know whether this is a bug in LXC/LXD or a mistake of mine that I didn't notice, but I'd be glad to hear anyone who knows.
Okay, so how do you fix it? I'm gonna save you the two days I've spent looking around and tell you the answer: routing tables! (You can see your ip using lxc list
)
ip route add 10.10.10.0/24 via <ip of your wg container> dev lxdbr0 src <ip of your host machine>
Don't forget to make it permanent (depends on your distro), here's the service for systemd-networkd way (see the devices with systemctl list-units
):
[Unit] Description='Add static routes to wireguard IP subnet' After=sys-devices-virtual-net-lxdbr0.device After=lxc.service After=lxd.service WantedBy=sys-devices-virtual-net-lxdbr0.device [Service] ExecStart=/usr/bin/ip route add 10.10.10.0/24 via <ip of your wg container> dev lxdbr0 src <ip of your host machine> [Install] WantedBy=multi-user.target
🚨 (Update) UDP Wild goose chase
Quick update: I was banging my head against the wall for an entire week, but I finally figured out why UDP wasn't working with this setup, or rather, was working very incosistently: if you send a request to 77.77.77.77, but a reply comes from a different IP address, say, 10.50.50.1 (i.e. lxdbr0's address), wireguard will drop such packet. Given that UDP is a stateless protocol, this kind of makes sense, but I just wish it was mentioned anywhere in the internet. Anyways, this is the reason we have the src part in our routing.
And also, while we're at the host system, don't forget one more thing: port forwarding! Because we don't want to do it ourselves, let's make LXD do it for us:
lxc config device add networking wg-proxy proxy listen=udp:0.0.0.0:51280 connect=udp:127.0.0.1:51280
Configuring firewall
We've done so much, and it may be working for you already depending on your distro. Congrats! But in reality, we're not done yet. One last step we have to do in to configure firewall. I will be using nftables for this, as this setup may imply you have specific networking needs as do I.
Here's how my /etc/nftables/nftables.conf
looks like:
#!/usr/bin/nft -f # vim:set ts=2 tw=2 sw=2 expandtab: table inet filter delete table inet filter table inet filter { chain input { # type = [filter, route, nat] # hooks = [ingress, prerouting, input, forward, output, postrouting] # priority = int (mnemonics: mangle = -150, dstnat = -100, filter = 0, srcnat = 100) # policy = [accept, drop] type filter hook input priority 0 policy drop # You will NOT get reply back without this ct state established,related accept ct state invalid drop ip protocol icmp accept comment "Allow ICMP" iif lo accept comment "Allow loopback" # If you want to configure ACL, you should add a # jump-chain here instead of just accept iifname "lxdbr0" accept comment "Allow LXC communication" tcp dport 3737 accept comment "Allow sshd" udp dport 51820 accept comment "Allow wireguard" tcp dport 443 accept comment "Allow myservice" counter comment "Count input traffic" } chain forward { type filter hook forward priority 0 policy drop oifname "lxdbr0" accept iifname "lxdbr0" accept } chain lxc { type nat hook postrouting priority srcnat policy accept oif "enp1s0" masquerade iifname "lxdbr0" masquerade } }
With this configuration, you should be done! Congratulations on taming LXC networking.