IT
October 24, 2023

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.

⚠️ Heads up!

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.

Here's how it looks like

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.