Your Own WireGuard VPN: an Ubuntu Server with Windows and Android Clients

Four years ago I wrote about setting up your own IKEv2 VPN on strongSwan, and that article is still one of the most read on this blog. Since then IKEv2 has got a competitor that does the same job noticeably more simply: WireGuard. Where strongSwan needs a certificate authority, certificates, a choice of algorithms and three configuration files, WireGuard gets by with a pair of keys and ten lines. Today: how to set up such a server on Ubuntu from scratch and connect Windows and Android to it.

What a VPN is in terms of interfaces and routes, how a full tunnel differs from a split one, and where DNS and IPv6 leaks come from is covered in part twenty of "Networking for beginners". None of that is repeated here, but everything that comes up along the way is explained on the spot. How the WireGuard protocol works inside — the handshake, the cryptography, the packet format — you don't need to know to configure it, and this article doesn't cover it.

The addresses in this article are documentation addresses: the server is 203.0.113.10 and 2001:db8::10, the tunnel network is 10.66.66.0/24. Keys are placeholders in angle brackets.


How WireGuard works

Five things are enough to understand every line of the config.

Keys instead of certificates and passwords. Every participant — the server, a laptop, a phone — has its own key pair: a private key that never leaves the device, and a public key that is given to the other side. The server knows the public keys of all its clients; a client knows the server's public key. That is enough both to recognize each other and to agree on encryption. No certificate authority, no expiry dates, no logins. In WireGuard the public key is the device's "name". A key is 32 bytes encoded in base64, a 44-character string like gI6EdUSYvn8ugXOt8QQD6Yc1SMvDv31U6PSYwpeIE1E=.

A single UDP port. WireGuard runs over UDP (part nine), port 51820 by default, though any port will do. There are no separate ports for negotiation and data as in IPsec, and no TCP connection to keep alive. A client can change its address — switch from Wi-Fi to mobile data — and the tunnel keeps working from the new address without reconnecting: the server simply remembers where the last valid packet came from.

An interface and routes. Like any VPN, WireGuard on a machine is a network interface (wg0) with an address from the tunnel's "internal" network, plus routes that lead into it. A packet that enters the interface is encrypted and sent over UDP to the other side's address; a packet that arrives over UDP is decrypted and comes out of the interface as an ordinary IP packet. Everything else is up to the routing table.

AllowedIPs — routes and an access list at once. This is the key setting, and it does two things simultaneously. For outgoing packets: "send packets to these addresses to this peer", i.e. a route. For incoming packets: "from this peer, accept only packets with these source addresses", i.e. a filter. On a client, AllowedIPs = 0.0.0.0/0, ::/0 means "everything goes into the tunnel" (a full tunnel) and "accept replies from the server from anywhere". On the server, AllowedIPs = 10.66.66.2/32 for each client means "send him only packets for his address" and "accept from him only packets from his address": a client cannot spoof someone else's address even if it wants to. This mechanism is called cryptokey routing: routing by keys.

There is no such thing as "connected". WireGuard does not establish a connection. When there is something to send, it checks whether it has fresh session keys with that peer (they live for two minutes), and if not, it performs a handshake: one packet there, one back, and then data flows. When there is no data, there are no packets at all. Because of this, the "connected" status in clients is nominal, and to keep a home router from forgetting the UDP flow during a quiet period there is PersistentKeepalive — more on it below.

How this differs from IKEv2 in practice:

IKEv2/IPsec WireGuard
What is needed for mutual trust certificates and a certificate authority, or a username and password via EAP the public keys of both sides
Choice of cryptography negotiated from a list on connection; can be configured weakly fixed, nothing to configure
Ports UDP 500 and 4500, ESP protocol a single UDP port
Client address change MOBIKE, if both sides support it just works
Built into the OS Windows, macOS, iOS, Android — no extra software needs a client; built into the Linux kernel since 5.6
Server config several files and sections one file, ten lines for the server and four per client
Code hundreds of thousands of lines about four thousand

IKEv2 being built into operating systems is its main advantage and still the reason it lives on in corporate networks. For your own server that your own devices connect to, the difference doesn't matter: a WireGuard client installs in a minute.


The server

You need a virtual machine with a public address and Ubuntu. I use Ubuntu 26.04, but everything below works the same on 20.04: the WireGuard module is in the kernel starting from 5.6, and Ubuntu backported it to older kernels. The smallest resources are plenty — 1 vCPU and 1 GB of memory handle a dozen clients. SSH login with a key as root.

Installation

apt update && apt install -y wireguard-tools qrencode
wg --version

The wireguard-tools package provides the wg utility (interface management) and wg-quick (brings an interface up from a config: addresses, routes, DNS). WireGuard itself is a kernel module and doesn't need installing; on old kernels you install the wireguard metapackage instead of wireguard-tools, which pulls in the module. qrencode will be needed for the phone.

Server keys

umask 077
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
cat /etc/wireguard/server.pub      # public key — the clients will get it

umask 077 makes the files get created with owner-only permissions: the server's private key is the most important secret here — with it, anyone can impersonate your server. wg genkey prints a private key, wg pubkey reads a private key and prints the public one.

Server config

/etc/wireguard/wg0.conf:

[Interface]
Address = 10.66.66.1/24, fd66:6666:6666::1/64
ListenPort = 51820
PrivateKey = <server private key>

This is the core of the config; the firewall rules will be added to it below. Address is the addresses of the wg0 interface in the tunnel network, IPv4 and IPv6; clients will get addresses from the same networks. ListenPort is the UDP port. PrivateKey is the contents of server.key; after that the file itself can be deleted, the key lives in the config. Clients will be added below as [Peer] sections.

The tunnel network can be any private network that doesn't overlap with networks your clients find themselves in: 192.168.0.0/24 and 192.168.1.0/24 are used by every other router, so 10.66.66.0/24 is safer. The IPv6 network is a ULA, a unique local address, from the fd00::/8 range: the IPv6 counterpart of private addresses, not routed on the internet.

Forwarding and NAT

So far the server can only receive packets from the tunnel. To pass them on to the internet and return the replies, two things are needed.

Forwarding — permission for the kernel to forward packets between interfaces, off by default:

cat > /etc/sysctl.d/99-wireguard.conf <<EOF
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl --system

NAT — rewriting the source address. A client's packet leaves for the internet from 10.66.66.2, an address nobody can reply to: it's a private address. The server must replace it with its own public address and return the reply to the client — exactly what a home router does (part six). In Linux this is a MASQUERADE rule in the nat table of netfilter, the kernel's packet filtering subsystem, managed with the iptables command (and ip6tables for IPv6).

It's convenient to keep the rules right in the interface config: wg-quick has PostUp — commands run after the interface comes up — and PostDown — after it goes down. The rules appear together with the tunnel and disappear with it; nothing needs to be saved and restored at boot. The full server config:

[Interface]
Address = 10.66.66.1/24, fd66:6666:6666::1/64
ListenPort = 51820
PrivateKey = <server private key>

# IPv4: forward from the tunnel to the internet, replies back, drop everything else from the tunnel; NAT
PostUp   = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT
PostUp   = iptables -A FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostUp   = iptables -A FORWARD -i wg0 -j DROP
PostUp   = iptables -t nat -A POSTROUTING -s 10.66.66.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT
PostDown = iptables -D FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -j DROP
PostDown = iptables -t nat -D POSTROUTING -s 10.66.66.0/24 -o eth0 -j MASQUERADE

# IPv6: the same
PostUp   = ip6tables -A FORWARD -i wg0 -o eth0 -j ACCEPT
PostUp   = ip6tables -A FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostUp   = ip6tables -A FORWARD -i wg0 -j DROP
PostUp   = ip6tables -t nat -A POSTROUTING -s fd66:6666:6666::/64 -o eth0 -j MASQUERADE
PostDown = ip6tables -D FORWARD -i wg0 -o eth0 -j ACCEPT
PostDown = ip6tables -D FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
PostDown = ip6tables -D FORWARD -i wg0 -j DROP
PostDown = ip6tables -t nat -D POSTROUTING -s fd66:6666:6666::/64 -o eth0 -j MASQUERADE

eth0 is the server's internet-facing interface; check your name with ip -br addr. What the rules do, in order:

  • FORWARD -i wg0 -o eth0 -j ACCEPT — let through packets that come from the tunnel and go out to the internet. The FORWARD chain in iptables is transit: packets the server neither accepts for itself nor sends on its own, but forwards between interfaces.
  • FORWARD -i eth0 -o wg0 … RELATED,ESTABLISHED -j ACCEPT — in the opposite direction, let through only replies to connections started from the tunnel. conntrack is the kernel's connection table: through it netfilter remembers who started what and tells a reply apart from an attempt from outside to reach a VPN client.
  • FORWARD -i wg0 -j DROP — drop everything else from the tunnel. This protects the server's other networks: if the machine has a second interface into the hosting provider's private network, or tunnels to somewhere else, VPN clients won't get there. Order matters: rules are checked top to bottom, and DROP comes after the permits. Without this rule, with the FORWARD ACCEPT policy (which is the default), a client could go anywhere through the server.
  • nat POSTROUTING -s 10.66.66.0/24 -o eth0 -j MASQUERADE — for packets from the tunnel network going out through eth0, substitute the address of eth0. POSTROUTING is the point after the routing decision, when it is already known which interface the packet will leave through.

PostDown repeats the same commands with -D instead of -A: delete the rule. If the tunnel is restarted, the rules don't pile up.

A word on IPv6. NAT for IPv6 is an unusual thing: there are enough addresses for everyone, and the right way would be to hand clients real public addresses from the server's block. But for that the hosting provider has to route a whole block to the server, usually a /64, and many give a server a single address or a handful. Then what's left is NAT66: clients go out from the server's IPv6 address. That's better than giving clients no IPv6 at all: a client that has IPv6 at home but not in the tunnel will send traffic to sites with AAAA records past the tunnel, in the clear. If your hosting provider routes a /64, give clients addresses from it and skip NAT66 — then the POSTROUTING rules for ip6tables aren't needed either, only the transit ones remain.

Inbound port. On a clean Ubuntu, incoming packets aren't filtered, and port 51820 is open by itself. If the server has a firewall with an INPUT DROP policy, add a permit — for example, in the same PostUp:

PostUp   = iptables -A INPUT -p udp --dport 51820 -j ACCEPT
PostUp   = ip6tables -A INPUT -p udp --dport 51820 -j ACCEPT
PostDown = iptables -D INPUT -p udp --dport 51820 -j ACCEPT
PostDown = ip6tables -D INPUT -p udp --dport 51820 -j ACCEPT

Cloud providers often also have a firewall outside the machine, in the control panel: UDP 51820 has to be opened there too. If the server's firewall is managed by ufw, the PostUp rules don't conflict with it, but the port is opened with its own command ufw allow 51820/udp, and forwarding and NAT are better moved into its files /etc/ufw/before.rules and before6.rules: when ufw reloads its rules it flushes the chains and restores its own rules, but not anyone else's.

Starting it up

chmod 600 /etc/wireguard/wg0.conf
systemctl enable --now wg-quick@wg0
wg show wg0

wg-quick@wg0 is a templated systemd service: wg0 in the name is the name of the config in /etc/wireguard/. It creates the interface, assigns addresses, adds a route to the tunnel network, loads the keys and runs PostUp. wg show displays the interface, its public key and port; there are no peers yet. The firewall rules should be in place:

iptables -S FORWARD
iptables -t nat -S POSTROUTING

The first output shows three rules with wg0, the second shows MASQUERADE.


Clients: adding and revoking

Every device gets its own keys. That way a lost phone is revoked with a single command and the other devices notice nothing. For each device you need to: generate a key pair, pick a free address in the tunnel network, add a [Peer] section on the server and assemble the client config. By hand that's five minutes and two mistakes per device, hence a script. Put it in /usr/local/sbin/wg-user and make it executable (chmod 755).

#!/usr/bin/env bash
# wg-user list | add <name> | show <name> | del <name>
set -euo pipefail

WG_IF=wg0
CFG=/etc/wireguard/$WG_IF.conf
CLIENTS=/etc/wireguard/clients
NET4=10.66.66                 # clients get $NET4.N/32
NET6=fd66:6666:6666::         # and ${NET6}N/128
ENDPOINT=203.0.113.10:51820
DNS="1.1.1.1, 2606:4700:4700::1111"
KEEPALIVE=25

die() { echo "wg-user: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run as root"
umask 077
mkdir -p "$CLIENTS"

check_name() { [[ "$1" =~ ^[A-Za-z0-9._@-]{1,64}$ ]] || die "bad name"; }
has_user() { grep -qx "# user: $1" "$CFG"; }
peer_pubkey() { awk -v n="# user: $1" '$0==n{f=1} f&&/^PublicKey/{print $3; exit}' "$CFG"; }
server_pubkey() { awk '/^PrivateKey/{print $3; exit}' "$CFG" | wg pubkey; }
sync() { wg syncconf "$WG_IF" <(wg-quick strip "$WG_IF"); }

next_host() {
    local used n
    used=$(grep -oE "AllowedIPs = $NET4\.[0-9]+" "$CFG" | awk -F. '{print $NF}' | sort -n)
    for n in $(seq 2 254); do grep -qx "$n" <<<"$used" || { echo "$n"; return; }; done
    die "no free addresses"
}

show_conf() {
    local f=$CLIENTS/$1.conf
    [ -f "$f" ] || die "no client config for $1"
    cat "$f"; echo
    qrencode -t ansiutf8 < "$f"
}

case "${1:-}" in
    list)
        declare -A names
        while read -r name pub; do names[$pub]=$name; done < <(awk '/^# user: /{n=$3} /^PublicKey/{print n, $3}' "$CFG")
        printf '%-20s %-34s %-24s %s\n' NAME ADDRESSES ENDPOINT LAST_HANDSHAKE
        wg show "$WG_IF" dump | tail -n +2 | while IFS=$'\t' read -r pub psk endpoint ips hs rx tx ka; do
            if [ "$hs" = 0 ]; then hs_s=never; else hs_s="$(( $(date +%s) - hs ))s ago"; fi
            printf '%-20s %-34s %-24s %s\n' "${names[$pub]:-?}" "${ips//,/ }" "${endpoint:-(none)}" "$hs_s"
        done ;;
    add)
        [ $# -eq 2 ] || die "usage: wg-user add <name>"; check_name "$2"
        has_user "$2" && die "user $2 already exists"
        n=$(next_host); priv=$(wg genkey); pub=$(wg pubkey <<<"$priv"); psk=$(wg genpsk)
        addr4="$NET4.$n"; addr6="${NET6}$(printf '%x' "$n")"
        printf '# user: %s\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nAllowedIPs = %s/32, %s/128\n\n' "$2" "$pub" "$psk" "$addr4" "$addr6" >> "$CFG"
        sync
        cat > "$CLIENTS/$2.conf" <<EOF
[Interface]
PrivateKey = $priv
Address = $addr4/32, $addr6/128
DNS = $DNS

[Peer]
PublicKey = $(server_pubkey)
PresharedKey = $psk
Endpoint = $ENDPOINT
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = $KEEPALIVE
EOF
        echo "added $2 ($addr4, $addr6)" >&2
        show_conf "$2" ;;
    show)
        [ $# -eq 2 ] || die "usage: wg-user show <name>"; has_user "$2" || die "no user $2"
        show_conf "$2" ;;
    del)
        [ $# -eq 2 ] || die "usage: wg-user del <name>"; has_user "$2" || die "no user $2"
        pub=$(peer_pubkey "$2")
        cp -p "$CFG" "$CFG.$(date +%Y%m%d-%H%M%S)"
        awk -v n="# user: $2" '$0==n{skip=1} !skip{print} skip&&/^$/{skip=0}' "$CFG" > "$CFG.new" && mv "$CFG.new" "$CFG"
        [ -n "$pub" ] && wg set "$WG_IF" peer "$pub" remove
        rm -f "$CLIENTS/$2.conf"
        echo "removed $2" >&2 ;;
    *) die "usage: wg-user list | add <name> | show <name> | del <name>" ;;
esac

What it does on add:

  • generates the client's keys and a preshared key (wg genpsk) — an additional shared secret for the "server — this client" pair. It's optional, but it adds a symmetric layer on top of public-key cryptography: even if a way to derive a private key from a public one ever appears, traffic protected with a PSK stays private;
  • picks an address: the first free one in 10.66.66.0/24, and the same number in the IPv6 network;
  • appends a [Peer] to the server — the client's public key, the PSK and its addresses in AllowedIPs, with a # user: name comment that the script later uses to find the block;
  • applies it without a restart: wg syncconf loads the difference between the current state and the config into the running interface, and the other clients notice nothing. wg-quick strip removes the fields from the config that only wg-quick understands (Address, DNS), not wg;
  • assembles the client config and shows it as text and as a QR code — the phone will scan it right off the screen.

The client config looks like this:

[Interface]
PrivateKey = <client private key>
Address = 10.66.66.2/32, fd66:6666:6666::2/128
DNS = 1.1.1.1, 2606:4700:4700::1111

[Peer]
PublicKey = <server public key>
PresharedKey = <shared secret>
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25

Line by line:

  • Address — the client's addresses in the tunnel, /32 and /128: one device, one pair of addresses.
  • DNS — which resolvers to use while the tunnel is up. Queries to them go through the tunnel (the addresses are covered by AllowedIPs), and the client's ISP won't see the site names. Without this line the client keeps asking its ISP's or router's DNS, and that is exactly the DNS leak from part twenty. You can point it at your own resolver on the server if you have one there.
  • Endpoint — where to connect. An address here, not a name: then connecting doesn't depend on DNS. A name works too; the client resolves it at startup.
  • AllowedIPs = 0.0.0.0/0, ::/0 — a full tunnel: all traffic, IPv4 and IPv6, goes through the server. For a split tunnel, you list only the networks that should go through the VPN, for example 10.66.66.0/24, 192.168.100.0/24 — the tunnel network and the network behind the server; everything else goes directly. WireGuard clients build their routes from exactly this list.
  • PersistentKeepalive = 25 — send an empty packet to the server every 25 seconds. It's needed when the client is behind NAT, which is almost always: a home router and a mobile carrier remember a UDP flow for 30–60 seconds after the last packet and then forget it; after that, a reply from the server arriving first after a pause gets lost. Keepalive keeps the entry from going stale. The cost is slightly higher battery drain on a phone; on the server this line isn't needed.

Using the script:

wg-user add laptop           # config and QR code on screen
wg-user add phone
wg-user list                 # who exists, where they connect from, when the last handshake was
wg-user show phone           # the QR code again
wg-user del laptop           # revoke: the laptop's tunnel drops at once, the phone keeps working

The client config is a secret: it contains the private key. Get it onto the device in a way that doesn't leave it in a chat history: a QR code from the screen, a file over SSH, a password manager.


Windows client

The official client is WireGuard for Windows, from wireguard.com/install or with a single command:

winget install WireGuard.WireGuard

It consists of a service, a virtual adapter driver and a management window; it runs on Windows 10 and 11, installs in half a minute and asks for administrator rights.

Importing the tunnel. The simplest way is a config file. Copy the text printed by wg-user add laptop into a file with a .conf extension, for example laptop.conf (the file name becomes the tunnel name, up to 15 characters: Latin letters, digits, _, -, =, +, .). In the WireGuard window: Add Tunnel → Import tunnel(s) from file… and pick the file. Or Add empty tunnel… and paste the whole text into the field. The client shows the tunnel in the list on the left and its parameters with the public key on the right; it stores the private key encrypted and doesn't show it in the window. Delete laptop.conf after importing.

Connecting — the Activate button. A second later the status becomes Active, and Latest handshake and the Transfer counters appear in the Peer block. If there's no handshake and the received counter stays at zero, packets aren't reaching the server or the server isn't accepting them: check the port in the server's firewall and that the keys match.

Kill switch. Right-click the tunnel → Edit: when AllowedIPs is 0.0.0.0/0, ::/0, a Block untunneled traffic (kill-switch) checkbox appears at the bottom of the window. It adds Windows Firewall rules that block any traffic bypassing the tunnel, except packets to the server itself and DHCP. While the tunnel is active and has gone down, nothing goes out to the internet in the clear. For a laptop in a café it's exactly what you need; at home, where you turn the tunnel on for the sake of a single site, you can leave it off.

What happens in the system — in PowerShell:

PS C:\> Get-NetAdapter | Where-Object InterfaceDescription -like 'WireGuard*'

Name    InterfaceDescription   Status  MacAddress  LinkSpeed
----    --------------------   ------  ----------  ---------
laptop  WireGuard Tunnel       Up                  100 Gbps

PS C:\> Get-NetIPAddress -InterfaceAlias laptop | Select-Object IPAddress, PrefixLength

IPAddress            PrefixLength
---------            ------------
fd66:6666:6666::2             128
10.66.66.2                     32

PS C:\> Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object InterfaceAlias, NextHop, RouteMetric, InterfaceMetric

InterfaceAlias NextHop     RouteMetric InterfaceMetric
-------------- -------     ----------- ---------------
laptop         0.0.0.0               0               0
Ethernet       192.168.1.1           0              25

Two default routes: the old one via the router and the new one via the tunnel. The one with the lower sum of metrics wins — the WireGuard client gives its interface metric 0, and all traffic goes into it. Don't look for a separate route to the server 203.0.113.10 in the table; there isn't one: the client sends encrypted packets to the server through a socket bound to the physical interface, and the routing table doesn't apply to them. Otherwise they would end up in the tunnel, get encrypted again, and so on forever. On Linux, wg-quick solves the same problem with policy routing: it marks its own packets (fwmark) and with an ip rule sends everything except the marked ones through a separate table with the tunnel's routes. The interface's DNS is 1.1.1.1 from the config; on top of that, the client blocks DNS queries through other interfaces, so there's no leak.

Autostart. A tunnel left active comes up by itself on the next boot: the service remembers the state. No separate setting is needed.

You can also do without the window: wireguard.exe /installtunnelservice C:\path\laptop.conf installs the tunnel as a service, wireguard.exe /uninstalltunnelservice laptop removes it. That comes in handy if tunnels need to be rolled out to many machines by a script.


Android client

The app is called WireGuard, by WireGuard Development Team, available on Google Play and F-Droid. It runs on Android 5 and later; on phones without the kernel module it runs in user space — slower, but the same otherwise.

Importing. Tap the blue + at the bottom right. Three options: Scan from QR code — point the camera at the QR code printed by wg-user add phone (right off the terminal screen; a laptop screen reads fine from half a meter); Import from file or archive — a .conf file, if you've transferred it to the phone; Create from scratch — type it in by hand. After scanning, the app asks for a tunnel name, and then the tunnel appears in the list.

Connecting — the toggle to the right of the tunnel name. Android asks for permission to create a VPN connection — once, tap OK. A key icon appears in the status bar. Tap the tunnel name to expand its parameters: addresses, DNS, peer, Latest handshake and Transfer. Until there's a handshake, the tunnel isn't working.

Per-app exclusions. In the tunnel settings (the pencil) → All Applications you can exclude apps from the tunnel or, conversely, send only selected ones through it. Banking apps that don't like VPNs, or a local video service that won't show content through a foreign server, go here.

Always-on VPN. To have the tunnel come up when the phone starts and not drop when the system kills the app in the background: Android Settings → Network & internet → VPN → the gear next to WireGuard → Always-on VPN. In the same place, Block connections without VPN is the counterpart of a kill switch: no tunnel, no internet at all. The path to this screen differs slightly between manufacturers, but it's called the same everywhere.

Battery. A 25-second keepalive keeps the radio from sleeping for long, which costs a few percent of charge per day. If the phone isn't behind NAT (rare) or you're fine with a one-second delay on the first packet after idling, you can drop keepalive; on Android, where the system puts apps to sleep on its own, people usually keep it.


Looking under the hood

On the server. Who is connected and when they last showed signs of life:

# wg show wg0
interface: wg0
  public key: <server public key>
  private key: (hidden)
  listening port: 51820

peer: <laptop public key>
  preshared key: (hidden)
  endpoint: 198.51.100.7:53421
  allowed ips: 10.66.66.2/32, fd66:6666:6666::2/128
  latest handshake: 48 seconds ago
  transfer: 14.21 MiB received, 203.56 MiB sent
  persistent keepalive: every 25 seconds

endpoint is where the client's last packet came from: the public address of its router and the NAT port. A latest handshake older than two or three minutes with a live client means packets aren't getting through. wg-user list shows the same with names.

Packets as the network sees them:

# tcpdump -ni eth0 udp port 51820 -c 4
IP 198.51.100.7.53421 > 203.0.113.10.51820: UDP, length 148
IP 203.0.113.10.51820 > 198.51.100.7.53421: UDP, length 92
IP 198.51.100.7.53421 > 203.0.113.10.51820: UDP, length 128
IP 203.0.113.10.51820 > 198.51.100.7.53421: UDP, length 1392

The first two are the handshake: the request is exactly 148 bytes, the reply exactly 92. After that comes data, with a 32-byte WireGuard header added to the length of the inner packet. The contents aren't visible, but the fact of WireGuard is visible to anyone watching the traffic: firewalls and DPI recognize it by those two lengths. If the goal is not only to protect the traffic but also to hide that it's a VPN, you need a different tool: that's covered in a separate article.

Forwarding and NAT in action — the rule counters:

# iptables -L FORWARD -nv
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target  prot opt in     out     source       destination
 3012  211K ACCEPT  0    --  wg0    eth0    0.0.0.0/0    0.0.0.0/0
 4188 4967K ACCEPT  0    --  eth0   wg0     0.0.0.0/0    0.0.0.0/0    ctstate RELATED,ESTABLISHED
    5   300 DROP    0    --  wg0    *       0.0.0.0/0    0.0.0.0/0

# iptables -t nat -L POSTROUTING -nv
Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target      prot opt in     out     source          destination
  164 11K   MASQUERADE  0    --  *      eth0    10.66.66.0/24   0.0.0.0/0

The five packets in DROP are someone's attempts from the tunnel to reach addresses that don't lead to the internet via eth0. The counter in the nat table is lower than in FORWARD: the NAT rule fires once per connection, after which conntrack rewrites the addresses on its own.

On the client. Where you exit from:

curl.exe -4 https://ifconfig.co      # 203.0.113.10
curl.exe -6 https://ifconfig.co      # 2001:db8::10

Both addresses are the server's, so neither IPv4 nor IPv6 bypasses the tunnel. Any DNS leak test site checks for DNS leaks: the resolver in the result should be the one in the DNS line, not your ISP's. On Windows, Resolve-DnsName whoami.akamai.net returns the address of the resolver that came to Akamai with your query.

MTU. The WireGuard header plus UDP plus IP is 60 bytes for IPv4 and 80 for IPv6, so the tunnel interface gets an MTU of 1420 instead of 1500 by default (part six explains what that is and what can go wrong). If the client itself sits behind PPPoE with an MTU of 1492 (part four) or behind yet another tunnel, 1420 may be too much: sites open, but large pages and downloads hang. Then add MTU = 1380 to the client's [Interface]; wg-quick as well as the Windows and Android clients understand this line.


When it doesn't work

  • No handshake, received 0. Packets don't reach the server, or the server drops them. Check: whether the server is listening (ss -ulnp | grep 51820), whether its firewall lets them through (iptables -S INPUT), the hosting provider's firewall (clouds often have their own, outside the machine), and whether the keys match — the server's public key in the client config against wg show wg0, the client's public key in the server's [Peer] against what the client shows. One wrong character means silence without a single error message: WireGuard doesn't respond to packets it can't verify.
  • There's a handshake but no internet. The tunnel works, the server doesn't forward. Check sysctl net.ipv4.ip_forward (should be 1), the MASQUERADE rule in iptables -t nat -S and the transit rules in iptables -S FORWARD: if they're missing, PostUp didn't run — journalctl -u wg-quick@wg0 shows which command failed. For IPv6, the same with ip6tables and net.ipv6.conf.all.forwarding.
  • Works for a minute, then drops, then works again. The client is behind NAT without PersistentKeepalive. Add PersistentKeepalive = 25 to the client's [Peer].
  • Sites open, large files and video hang. MTU, see above.
  • Through the tunnel you see the server's IPv4 address but the ISP's IPv6. The client's AllowedIPs lacks ::/0, or the server has no IPv6 address on wg0 and no NAT66. Either add them, or disable IPv6 on the client while the tunnel is up.
  • The clock on the server or the client is way off. WireGuard protects against replay of old handshakes with a counter that includes the time. If the server's clock is a year behind after a reinstall, handshakes from clients will be rejected as "old". Time synchronization — timedatectl on the server.

The result

A WireGuard server is ten lines of config, one UDP port and a few iptables rules for forwarding and NAT that live together with the interface. A device is a key pair and a [Peer] section; the script does that in one command and prints a QR code. The Windows client imports a file and supports a kill switch; the Android client scans a QR code and runs in always-on mode. All traffic, IPv4 and IPv6, and DNS queries go through the server; revoking a device is one command that doesn't touch the others.

What WireGuard doesn't do: hide the very fact of a VPN. In a network where VPNs are blocked by signature, you need to disguise the traffic as ordinary HTTPS, and that's the subject of the article about Xray and REALITY. But where nobody minds a VPN — in a café, in a hotel, on a trip — WireGuard is today the simplest way to make sure your traffic is seen only by your server.

Only registered users can leave a comment.

Sign in or register to leave a comment.