WireGuard VPN server with IPv6 support, secure DNS and nftables

The VPN server can be handy in different situations:

  • when you need a static IP address and traffic encryption (traveling, mobile internet, etc.)
  • hide your traffic from your home ISP
  • If your home ISP doesn’t provide a static IP address
  • you want to use IPv6 but ISP supports only IPv4
  • and more…

VPN can be bought from a VPN service like NordVPN, but this is not an exclusive VPN server. One of their servers handles hundreds of connections and they can’t provide static IP and port forwarding. Some of the servers can get banned on Google or YouTube. The best solution is to buy VPS and set up your VPN server.

Guide Scope

In this guide I will explain (or at least try) how to configure Debian 11:

  • WireGuard server
  • WireGuard client
  • DNS server
  • Traffic and port forwarding with nftables

Prerequisites

We need VPS with SSH access and Linux OS.

VPS

For VPN purposes we don’t need crazy powerful VPS. Even a machine with 256 Mb of RAM and 1 vCPU will work. Search only for VM-based VPS (like Amazon, DigitalOcean, Google, etc.). We need access rights to network interfaces + a way to install WireGuard kernel modules. 

OS

I used Debian 11 for my VPN server. Ubuntu Server LTS is good too (20.04 or 22.04). It should work on other distributions with modern kernels, but package names can differ.

WireGuard server setup

We will start from the server side of our VPN.

Install packages

Debian 11 has needed packages in repositories. Simply install them.

sudo apt install wireguard wireguard-tools

Generate server private key and public keys

Run this command to generate server public and private keys.

wg genkey | tee server_private_key | wg pubkey > server_public_key

Create server config

Create a file called /etc/wireguard/wg0.conf on the server and add the following content.

[Interface]
Address = 172.16.0.1/24
Address = fdf5:6028:947d:1234::1/64
SaveConfig = true
PrivateKey = <insert server_private_key>
ListenPort = 51820

Where 172.16.0.2-172.16.0.254 is our IPv4 private range (if you need more than 253 addresses, use /16 mask and 172.16.0.2-172.16.254.254 address range). You can use any standard IPv4 private range: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. For IPv6 private subnet generation you can use this service.

This will create a wg0 network interface for our VPN.

Enable VPN startup

Enable the WireGuard interface on the VPN server as follows:

sudo chown -v root:root /etc/wireguard/wg0.conf
sudo chmod -v 600 /etc/wireguard/wg0.conf
sudo wg-quick up wg0
sudo systemctl enable wg-quick@wg0.service

The last line will enable the auto startup of the interface on the system boot.

Confirm that you have network interface wg0 by running ip a:

wg0: flags=209<UP,POINTOPOINT,RUNNING,NOARP>  mtu 1420
       inet 172.16.0.1  netmask 255.255.255.0  destination 172.16.0.1
       inet6 fdf5:6028:947d:1234::1  prefixlen 64  scopeid 0x0<global>
       unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 1000  (UNSPEC)
       RX packets 0  bytes 0 (0.0 B)
       RX errors 0  dropped 0  overruns 0  frame 0
       TX packets 0  bytes 0 (0.0 B)
       TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

WireGuard client setup

WireGuard has clients for all major platforms. In this guide, we will use console setup on Ubuntu 22.04. On other platforms, the config file will be the same.

Install packages

Install the same packages as for the server.

sudo apt install wireguard wireguard-tools

Generate client private key and public keys

Run this command to generate client public and private keys.

wg genkey | tee client_private_key | wg pubkey > client_public_key

Create client config

Create a file called /etc/wireguard/wg0-client.conf on the client and add the following content.

[Interface]
Address = 172.16.0.2/32, fdf5:6028:947d:1234::2/128
PrivateKey = <insert client_private_key>
DNS = 172.16.0.1, fdf5:6028:947d:1234::1

[Peer]
PublicKey = <insert server_public_key>
Endpoint = <insert vpn_server_address>:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 21

Similar to the server case, wg0-client.conf will result in an interface named wg0-client so you can rename the file if you want something different.

AllowedIPs = 0.0.0.0/0, ::/0 will allow and route all IPv4 and IPv6 traffic on the client through the VPN tunnel. This can be narrowed down if you only want some traffic routed over the VPN.

DNS = 172.16.0.1, fdf5:6028:947d:1234::1 will set the DNS resolver IP to our VPN server. This is important to prevent DNS leaks when on the VPN.

Address = 172.16.0.2/32, fdf5:6028:947d:1234::2/128 is an IPv4 and IPv6 address of this client.

Add Peer to the server

For each client, you have to add a [Peer] section to the server-side config. wg command allows you to add it without manual editing. Run this command on the server machine to add our client:

sudo wg set wg0 peer <client_public_key> allowed-ips 172.16.0.2/32,fdf5:6028:947d:1234::2/128

Connect to the server

Run this command to enable a VPN connection:

sudo wg-quick up wg0-client

Check that it works

The ping should work now so let’s try to ping the server’s local IP.

ping -c 4 172.16.0.1

DNS server setup

DNS queries can leak information about sites we visited so we need our own secure private caching DNS server for our VPN. I checked several options and found that unbound is a good fit.

Its features are:

  • Lightweight and fast
  • Easy to install and configure
  • Security oriented
  • Supports DNSSEC

Let’s proceed with it.

Installation

Install unbound from a repository.

sudo apt install unbound

Download the root DNS servers list and save it.

sudo curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache

Configuration

Create /etc/unbound/unbound.conf.d/server.conf file with this content:

server:
  num-threads: 4

  #Enable logs
  verbosity: 1

  #list of Root DNS Server
  root-hints: "/var/lib/unbound/root.hints"

  #Respond to DNS requests on all interfaces
  interface: 0.0.0.0
  interface: ::0
  max-udp-size: 3072

  #Authorized IPs to access the DNS Server
  access-control: 0.0.0.0/0                 refuse
  # localhost
  access-control: 127.0.0.1/8               allow
  access-control: ::1/128                   allow
  access-control: 1::1/64                   allow
  # VPN
  access-control: 172.16.0.0/24             allow
  access-control: fdf5:6028:947d:1234::/64  allow
  
  #not allowed to be returned for public internet  names
  #localhost
  private-address: 127.0.0.1/8
  private-address: ::1/128
  private-address: 1::1/64
  # VPNs
  private-address: 172.16.0.0/24
  private-address: fdf5:6028:947d:1234::/64
  
  # Hide DNS Server info
  hide-identity: yes
  hide-version: yes

  #Limit DNS Fraud and use DNSSEC
  harden-glue: yes
  harden-dnssec-stripped: yes
  harden-referral-path: yes
  
  # interfaces
  do-ip4: yes
  do-ip6: yes
  do-udp: yes
  do-tcp: yes

  #Add an unwanted reply threshold to clean the cache and avoid when possible a DNS Poisoning
  unwanted-reply-threshold: 10000000
  
  #Have the validator print validation failures to the log.
  val-log-level: 1

  #Minimum lifetime of cache entries in seconds
  cache-min-ttl: 1800

  #Maximum lifetime of cached entries
  cache-max-ttl: 14400
  prefetch: yes
  prefetch-key: yes

Start it

We should enable the unbound service to autostart and start the DNS server.

sudo systemctl enable unbound
sudo systemctl start unbound

Test it

Let’s check that it responds properly. Should return IP addresses of www.google.com.

nslookup www.google.com 172.16.0.1

If it returns IP addresses then we are good!

Traffic Forwarding

New Linux distros moved to the nftables firewall interface so we will use them to set up proper forwarding.

Install it

We need to install the nft program first.

sudo apt install nftables

Enable forwarding in the sysctl

Edit the file /etc/sysctl.conf and set the following lines as:

net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1

Then reload the config without reboot:

sudo sysctl -p

Configure forwarding

Edit /etc/nftables.conf file like this:

#!/usr/sbin/nft -f

flush ruleset

define DEV_VPN = wg0
define DEV_WORLD = eth0
define IP_WORLD_V4 = <insert your server IPv4>
define IP_WORLD_V6 = <insert your server IPv6>
define PORT_VPN = 51820
define DEV_LOCAL_NETS = { $DEV_VPN }
define DEV_OUT_NETS = { $DEV_WORLD }

table inet global {
    map port_forwards_tcp_ipv4 {
        type ipv4_addr . inet_service : ipv4_addr . inet_service
        # lets forward port for our torrent. Our 12345 to 12345 on 172.16.0.2
        elements = { $IP_WORLD_V4 . 12345 : 172.16.0.2 . 12345 }
    }

    map port_forwards_tcp_ipv6 {
        type ipv6_addr . inet_service : ipv6_addr . inet_service
        # lets forward port for our torrent. Our 12345 to 12345 on fdf5:6028:947d:1234::2
	    elements = { $IP_WORLD_V6 . 12345 : [fdf5:6028:947d:1234::2] . 12345}
    }

    map port_forwards_udp_ipv4 {
        type ipv4_addr . inet_service : ipv4_addr . inet_service
        # lets forward port for our torrent. Our 12345 to 12345 on 172.16.0.2
        elements = { $IP_WORLD_V4 . 12345 : 172.16.0.2 . 12345 }
    }

    map port_forwards_udp_ipv6 {
        type ipv6_addr . inet_service : ipv6_addr . inet_service
        # lets forward port for our torrent. Our 12345 to 12345 on fdf5:6028:947d:1234::2
        elements = { $IP_WORLD_V6 . 12345 : [fdf5:6028:947d:1234::2] . 12345}
    }

    chain inbound_world {
        # accepting ping (icmp-echo-request) for diagnostic purposes.
        # However, it also lets probes discover this host is alive.
        # This sample accepts them within a certain rate limit:
        #
        # icmp type echo-request limit rate 5/second accept

        # Allow IPv6 configuration packets
        icmpv6 type {nd-neighbor-solicit,nd-neighbor-advert,nd-router-solicit, 
        	 nd-router-advert,mld-listener-query,destination-unreachable,
             packet-too-big,time-exceeded,parameter-problem} accept

        # allow SSH
        tcp dport { 22 } accept
        # allow VPN connection
		udp dport { $PORT_VPN } accept
    }

    chain inbound_vpn {
        # accepting ping (icmp-echo-request) for diagnostic purposes.
        icmp type echo-request limit rate 5/second accept

        # Allow IPv6 configuration packets
        icmpv6 type {nd-neighbor-solicit,nd-neighbor-advert,nd-router-solicit,
             nd-router-advert,mld-listener-query,destination-unreachable,
             packet-too-big,time-exceeded,parameter-problem} accept

        # allow DNS and SSH from the private network
        tcp dport { 22, 53 } accept
	    udp dport { 53 } accept
    }

    chain inbound {
        # drop all traffic by default
        type filter hook input priority filter; policy drop;

        # Allow traffic from established and related packets, drop invalid
        ct state vmap { established : accept, related : accept, invalid : drop }
        # Allow dnat (port forwarding)
        ct status dnat accept

        # allow loopback traffic, anything else jump to chain for further evaluation
        iifname vmap { lo : accept, $DEV_WORLD : jump inbound_world, $DEV_VPN : jump inbound_vpn}

        # the rest is dropped by the above policy
    }

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

        # Allow traffic from established and related packets, drop invalid
        ct state vmap { established : accept, related : accept, invalid : drop }
        # Allow port forwarding
        ct status dnat accept

        # connections from the internal nets to the out nets are allowed
        iifname $DEV_LOCAL_NETS oifname $DEV_OUT_NETS accept
        # the rest is dropped by the above policy
    }

    chain prerouting {
       type nat hook prerouting priority dstnat; policy accept;
       dnat ip addr . port to ip daddr . tcp dport map @port_forwards_tcp_ipv4
       dnat ip6 addr . port to ip6 daddr . tcp dport map @port_forwards_tcp_ipv6
       dnat ip addr . port to ip daddr . udp dport map @port_forwards_udp_ipv4
       dnat ip6 addr . port to ip6 daddr . udp dport map @port_forwards_udp_ipv6
    }

    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        # Hide IPs from local nets to the internet.
        # We are using SNAT because we have static IP and it wil work faster than MASQUERADE
        iifname $DEV_LOCAL_NETS oifname $DEV_WORLD snat ip to $IP_WORLD_V4
	    iifname $DEV_LOCAL_NETS oifname $DEV_WORLD snat ip6 to $IP_WORLD_V6
    }
}

I added comments in the file for the rules explanation.

Enable and firewall

Load new rules to the firewall:

sudo nft -f /etc/nftables.conf

Enable firewall auto start:

sudo systemctl enable nftables

Test it

Connect from your client to the VPN server:

sudo wg-quick up wg0-client

And ping www.google.com:

ping -c 4 www.google.com

Check your DNS server. It should say 172.16.0.1 or fdf5:6028:947d:1234::1 responded.

nslookup www.google.com

Check that your IP addresses are changed, go to www.whatismyip.com.

If you want to disconnect from the VPN you can bring the VPN interface down.

sudo wg-quick down wg0-client

Done

That’s it. It should work.

If you want to add more clients:

  1. Generate new client private and public keys
  2. Create client config with private key and unused IPs from the pool
  3. Add client public key and these IPs as Peer to the server

Useful links that helped with this post:


Comments

2 responses to “WireGuard VPN server with IPv6 support, secure DNS and nftables”

  1. the above config result these errors when starting nftables. seams like the map definitions for ipv4 and 6 are missing

    /etc/nftables.conf:92:58-80: Error: No such file or directory; did you mean map ‘port_forwards_t>
    dnat ip6 addr . port to ip6 daddr . tcp dport map @port_forwards_tcp_ipv6
    /etc/nftables.conf:92:58-80: Error: No such file or directory; did you mean map ‘port_forwards_t>
    dnat ip6 addr . port to ip6 daddr . tcp dport map @port_forwards_tcp_ipv6
    nftables.service: Main process exited, code=exited, status=1/FAILURE

    1. Strange, maps are defined at the top of the config.

Leave a Reply

Your email address will not be published. Required fields are marked *