Tunneling Static Public IPs over WireGuard

NOTE: My setup has drifted from this post, but I still find historical value in this description so I am leaving the post up.

Motivation§

Let's say you have a server somewhere, it has access to the internet, but maybe it:

  1. is behind a NAT (or even a CGNAT); or
  2. is restricted from incoming or outgoing ports (e.g. 25, 53, 80, 443, etc.); or
  3. is behind a restrictive firewall; or
  4. has a dynamic IP public address; or even
  5. has all of the above (if like me, you're using a residential internet service provider)!

In any case, you want to self-host some services and provide access to them from the open internet, without fighting with NAT or firewall port forwarding, dynamic DNS, or an inability to receive incoming connections. Wouldn't it be nice if you could just rent a static, public IP address and assign it to your server? We can get close, but we'll need the help of a VPS provider, and some legwork to setup a WireGuard tunnel and some niche networking settings.

Walkthrough§

The first thing you'll need is a VPS. Even the smallest configurations will do. I will describe the steps to follow if you're using NixOS on both the VPS and your server, but the steps should translate well to any capable server OS. There are only a few criteria to look for:

  1. Allows assigning at least two pairs of static, public IP addresses to your server. They will likely give you an entire /48 or /56 of IPv6 space to use, but you will want at least two IPv4 addresses.
  2. Has opened all the ports you want to host on (outgoing SMTP port 25 can be hard to find).
  3. Has reputable IP addresses so you don't start out on any spam/blocklists (again, particularly important for outgoing SMTP).

I use Contabo (not affiliated) and so far, it's been a good experience across all three criteria.

For simplicity, let's call this VPS your gateway server, and the other your hosting server.

One of the gateway's IP address pairs it will use for itself. On this IP address, we will host the "server" half of our WireGuard tunnel and likely SSH for administration. You can of course also host any normal services you like. I personally use mine as a backup nameserver. You can configure this pair of IP addresses manually on the gateway's main network interface. On my gateway, I use the following config but yours of course may vary:

{...}: {
    networking = {
        enableIPv6 = true;
        interfaces.ens18 = {
            # Allow default route and nameservers to be autoconfigured.
            useDHCP = true;
            tempAddress = "disabled";
            ipv4.addresses = [
                {
                    address = "209.126.80.126";
                    prefixLength = 21;
                }
            ];
            ipv6.addresses = [
                {
                    address = "2605:a140:2146:1434:8abc:eb50:da25:5f70";
                    prefixLength = 64;
                }
            ];
        };
        defaultGateway6 = {
            address = "fe80::1";
            interface = "ens18";
        };
        hosts = {
            "209.126.80.126" = ["edinburgh.averywinters.org" "edinburgh"];
            "2605:a140:2146:1434:8abc:eb50:da25:5f70" = ["edinburgh.averywinters.org" "edinburgh"];
        };
    };
}

The second IP address pair we will not assign to any network interface on the gateway. We will, however, configure the server to use Proxy ARP, proxy NDP and IP forwarding. This way, the gateway server will responds to ARP and NDP requests for the secondary IP address pair from the upstream router. And, once we configure the WireGuard interface, it will forward those incoming and outgoing packets between the tunnel interface and the gateway's primary interface. The config looks like this for me:

{...}: {
    # Enable forwarding and proxy ARP + NDP so we can route our secondary IPs to
    # amsterdam over wireguard.
    boot.kernel.sysctl = {
        "net.ipv4.conf.all.forwarding" = true;
        "net.ipv6.conf.all.forwarding" = true;
        "net.ipv4.conf.all.proxy_arp" = true;
        "net.ipv6.conf.all.proxy_ndp" = true;
    };
    # Proxy ARP and NDP (adding the IPv4 address here is optional,
    # but I like the consistency).
    networking.localCommands = ''
        ip -4 neigh add proxy 154.12.229.99 dev ens18
        ip -6 neigh add proxy 2605:a140:2146:1434:a2c5:34bb:afcf:101d dev ens18
    '';
}

Next, we will setup the server end of the WireGuard tunnel on our gateway server like so. Once this is up, the gateway server will start forwarding any packets it gets over the tunnel.

{config, ...}: {
    # Route our secondary IP addresses to amsterdam.
    networking.wg-quick.interfaces.wg-amsterdam = {
        # The addresses here are our gateway's IP as seen from the hosting server inside
        # the tunnel. You can pick any private addresses you like.
        address = ["192.168.88.3/32" "fd3a:e9a5:7a16:3d11:55c8:e71e:74d3:2c3a/128"];
        listenPort = 51820;
        privateKeyFile = config.age.secrets.wireguardKey.path;
        peers = [
            {
                publicKey = "zMxFfsMdUEtK/n0+OK+4z0db+FTl/SH1F7EB27Z8jls=";
                allowedIPs = ["154.12.229.99/32" "2605:a140:2146:1434:a2c5:34bb:afcf:101d/128"];
            }
        ];
    };
}

Finally, we configure the client end of the WireGuard tunnel on our hosting server. This interface will also be configured as the default route (for anything that isn't the WireGuard traffic itself of course).

{...}: {
    # Use edinburgh's secondary IP addresses as our public IP and default route.
    networking = {
        enableIPv6 = true;
        hosts = {
            "154.12.229.99" = ["amsterdam.averywinters.org" "amsterdam"];
            "2605:a140:2146:1434:a2c5:34bb:afcf:101d" = ["amsterdam.averywinters.org" "amsterdam"];
        };
        wg-quick.interfaces.wg-edinburgh = {
            address = ["154.12.229.99/32" "2605:a140:2146:1434:a2c5:34bb:afcf:101d/128"];
            privateKeyFile = config.age.secrets.wireguardKey.path;
            peers = [
                {
                    # edinburgh, but don't use fqdn because DNS isn't working yet
                    endpoint = "209.126.80.126:51820";
                    publicKey = "iH7+ZqiqYJ/HMDshLP8gjdZYd175frk6qi4YBKaw0BA=";
                    # needed to keep the tunnel alive behind a NAT or dynamic IP
                    persistentKeepalive = 25;
                    allowedIPs = ["0.0.0.0/0" "::/0"];
                }
            ];
        };
    };
}

Once you've done this, your hosting server should be able to accept incoming traffic on its new IP addresses, and should have all its outgoing traffic originate from its new IP addresses as well! The code for my full server configs can be found here.