DIY Debian Router - Part 8: WireGuard VPN for secure remote access

Oct 10, 2025

Introduction

This is Part 8 of the DIY Debian Router series. See Part 1 for the series introduction and links to all parts.

WireGuard is a modern VPN protocol offering simplicity, high performance, and strong cryptography. Deploying WireGuard on our router will allow secure remote access to the entire LAN without exposing individual services via port forwarding.

We'll cover:

  • WireGuard installation and key generation
  • Server configuration on the router
  • Client configuration for mobile and desktop
  • Firewall integration with nftables
  • IPv4 and IPv6 routing over VPN
  • Performance optimization and troubleshooting

WireGuard vs. traditional VPNs

Comparison against legacy VPN protocols:

FeatureWireGuardOpenVPNIPsec
Lines of code~4,000~100,000~400,000
CryptographyModern (Curve25519, ChaCha20)Configurable (often outdated defaults)Complex, legacy options
PerformanceExcellent (kernel-level)Good (userspace)Good (kernel, complex)
ConfigurationSimple (INI-style)Complex (OpenSSL-based)A mess
Key managementStatic public keysPKI certificatesIKE/PSK
RoamingSeamless (silent handshake)Requires reconnectRequires reconnect

WireGuard advantages:

  • Simplicity: Configuration is human-readable, minimal options
  • Performance: Faster than OpenVPN in typical scenarios
  • Security: Small codebase reduces attack surface; modern cryptography by default

Limitations:

  • Static addressing: No dynamic IP assignment (clients use fixed VPN IPs)
  • Key distribution: Manual key exchange (no PKI like OpenVPN)

Security model and threat assumptions

WireGuard provides:

  • Confidentiality: All traffic encrypted with ChaCha20-Poly1305
  • Authenticity: Cryptographic verification via Curve25519 public keys
  • Forward secrecy: Session keys rotated every 2 minutes
  • Replay protection: Nonce-based anti-replay

Threat model assumptions:

  • Attacker controls the network path between client and server (Internet)
  • Attacker can intercept, modify, or replay packets
  • Attacker does not have physical access to devices (private keys are secure)
  • Client devices may be compromised (principle of least privilege applies)

Not protected against:

  • Malware on client devices (VPN access = LAN access)
  • Compromised router (game over for all security)
  • Traffic analysis (connection timing, packet sizes)

See the WireGuard whitepaper for more details.

Key generation

WireGuard uses asymmetric cryptography (Curve25519). Each peer (server and clients) has a private key and corresponding public key.

Generate server private key:

cd /etc/wireguard
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key

Security note: Private keys must be protected (mode 600). The umask 077 ensures this.

View keys:

cat server_private.key  # Keep secret, never share
cat server_public.key   # Share with clients

Generate keys for each client device (repeat for laptop, phone, etc.):

wg genkey | tee client1_private.key | wg pubkey > client1_public.key
wg genkey | tee client2_private.key | wg pubkey > client2_public.key

Key distribution:

  • Client private keys are transferred to client devices (via secure channel: USB, encrypted email, password manager)
  • Client public keys remain on server (added to configuration)
  • Server public key is shared with all clients

You also have the option to create an optional pre-shared key (PSK) for each client. They add an additional layer of symmetric encryption, providing post-quantum resistance:

wg genpsk > client1_psk.key

Server configuration

/etc/wireguard/wg0.conf

[Interface]
# Server private key
PrivateKey = SERVER_PRIVATE_KEY_HERE
# VPN subnet (must not conflict with LAN subnet)
Address = 10.100.0.1/24
# VPN listening port (UDP)
ListenPort = 51820

# Client 1 (e.g., laptop)
[Peer]
# Client 1 public key
PublicKey = CLIENT1_PUBLIC_KEY_HERE
# Optional: Pre-shared key for post-quantum resistance
# PresharedKey = CLIENT1_PSK_HERE
# IP address assigned to this client
AllowedIPs = 10.100.0.2/32
# Optional: Keep connection alive (useful for NAT traversal)
# PersistentKeepalive = 25

# Client 2 (e.g., phone)
[Peer]
PublicKey = CLIENT2_PUBLIC_KEY_HERE
AllowedIPs = 10.100.0.3/32
PersistentKeepalive = 25

Replace SERVER_PRIVATE_KEY_HERE with the contents of /etc/wireguard/server_private.key. The VPN subnet (10.100.0.0/24) must not overlap with the LAN subnet. Each client requires a [Peer] section containing its public key and a unique /32 IP from the VPN subnet. Mobile clients and devices behind NAT should include PersistentKeepalive = 25 to maintain connections across network changes.

Firewall integration

WireGuard requires three firewall modifications:

  1. Variable definitions: Define WireGuard specific variables
  2. Input chain: Permit UDP traffic on port 51820 (WireGuard handshake)
  3. Forward chain: Permit traffic between VPN and LAN
  4. NAT postrouting: Masquerade VPN traffic to LAN (critical for routing)

Edit /etc/nftables.conf (configured in Part 6):

Step 1: Define WireGuard variables, add to configuration variables section:

define wireguard_port = 51820
define wireguard_net = 10.100.0.0/24

Step 2: Add input chain rules, locate the input chain and add before the final WAN drop rule:

chain input {
    type filter hook input priority filter; policy accept;

    # ... existing rules ...

    # === WireGuard Rules ===
    iifname $wan udp dport $wireguard_port accept comment "WireGuard VPN"
    iifname wg0 accept comment "Allow traffic from VPN clients"

    # ... rest of rules ...
}

Step 3: Add forward chain rules after the established/related rule:

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

    # ... existing rules ...

    # WireGuard VPN to LAN (remote access to LAN devices)
    iifname wg0 oifname $main_lan accept comment "VPN to LAN"

    # WireGuard VPN to WAN (Internet access for VPN clients)
    iifname wg0 oifname $wan accept comment "VPN to WAN"

    # ... rest of rules ...
}

Step 4: Add NAT postrouting rule for VPN-to-Internet traffic, locate the table ip nat section and add to the postrouting chain:

table ip nat {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;

        # ... existing rules ...

        # Masquerade VPN traffic going to Internet (same as LAN traffic)
        oifname $wan ip saddr $wireguard_net masquerade comment "NAT for VPN to Internet"
    }

    # ... rest of NAT table ...
}

Apply changes:

nft -f /etc/nftables.conf

Verify rules:

nft list chain inet filter input | grep -i VPN
nft list chain inet filter forward | grep VPN
nft list chain ip nat postrouting | grep VPN

DNS configuration for VPN clients

VPN clients need DNS resolution to work properly. The client configuration specifies DNS = 192.168.0.1, but by default Unbound (configured in Part 3) only allows clients on the LAN subnet, not the WireGuard subnet.

Edit /etc/unbound/unbound.conf and add the WireGuard VPN subnet to the ACL:

server:
    # ... existing config ...

    # Access control
    # ... other rules ...
    access-control: 10.100.0.0/24 allow
    # ... deny rule below ...

Restart Unbound:

systemctl restart unbound

Enabling and starting WireGuard

Enable and start WireGuard interface at boot:

systemctl enable --now wg-quick@wg0

Verify interface status:

wg show

Check interface address:

ip addr show wg0

Client configuration

Linux/macOS desktop client

Install WireGuard:

# Debian/Ubuntu:
apt install wireguard

# macOS (Homebrew):
brew install wireguard-tools

Create /etc/wireguard/wg0.conf on the client:

[Interface]
PrivateKey = CLIENT_PRIVATE_KEY_HERE
Address = 10.100.0.2/24
DNS = 192.168.0.1

[Peer]
PublicKey = SERVER_PUBLIC_KEY_HERE
PresharedKey = CLIENT_PSK_HERE
Endpoint = vpn.absurdum.ca:51820
AllowedIPs = 192.168.0.0/24, 10.100.0.0/24
PersistentKeepalive = 25

Configuration notes:

  • PrivateKey: Client's private key (generated earlier)
  • Address: VPN IP for this client (must match server's AllowedIPs for this peer)
  • DNS: Router's LAN IP for DNS resolution (optional, allows resolving internal hostnames)
  • Endpoint: Router's public IP or DDNS hostname + port
  • AllowedIPs:
    • 0.0.0.0/0 = route all traffic through VPN (full tunnel)
    • 192.168.0.0/24 = route only LAN traffic through VPN (split tunnel, Internet traffic uses local connection)

Recommendation: Use split tunnel (192.168.0.0/24) for better performance. Use full tunnel (0.0.0.0/0) when on untrusted networks.

Connect:

wg-quick up wg0

Verify connection:

wg show
ping 10.100.0.1  # Router's VPN IP
ping 192.168.0.1  # Router's LAN IP

Should get replies from both addresses.

Disconnect:

wg-quick down wg0

Mobile clients (iOS/Android)

Install WireGuard app:

Configuration via QR Code

Generate configuration and QR code:

cat > /etc/wireguard/client_phone.conf << EOF
[Interface]
PrivateKey = $(cat /etc/wireguard/client_private.key)
Address = 10.100.0.3/24
DNS = 192.168.0.1

[Peer]
PublicKey = $(cat /etc/wireguard/server_public.key)
PresharedKey = $(cat /etc/wireguard/client_psk.key)
Endpoint = vpn.absurdum.ca:51820
AllowedIPs = 192.168.0.0/24, 10.100.0.0/24
PersistentKeepalive = 25
EOF

qrencode -t ansiutf8 < /etc/wireguard/client_phone.conf

Setup on mobile:

  1. Open WireGuard app
  2. Tap "Add tunnel" → "Create from QR code"
  3. Scan QR code displayed in terminal
  4. Name the tunnel (e.g., "Home VPN")
  5. Toggle on to connect

Manual configuration on mobile

Alternatively, manually enter configuration in the app:

  1. Create new tunnel
  2. Enter interface settings (private key, address, DNS)
  3. Add peer (server public key, endpoint, allowed IPs)

Testing and verification

After you've connected a client, check peer status on the server:

wg show

Expected output includes:

peer: [CLIENT1 PUBLIC KEY]
  endpoint: [CLIENT PUBLIC IP]:random_port
  allowed ips: 10.100.0.2/32
  latest handshake: 10 seconds ago
  transfer: 5.2 KiB received, 8.1 KiB sent

You should see the client's public IP, a recent latest handshake, and non-zero values for transfers.

To test general connectivity from a client:

# Ping router's VPN IP
ping 10.100.0.1
# Ping router's LAN IP
ping 192.168.0.1
# Ping LAN device
ping 192.168.0.10
# Test DNS resolution (if DNS configured)
nslookup google.com

All tests should succeed.

You can also do some basic monitoring on the server:

watch -n 1 wg show

Looking for transfer counters to increase during client activity.

Or do a packet capture on the VPN interface:

tcpdump -i wg0 -n

You should see the decrypted traffic generated by your clients.

IPv6 over WireGuard

To route IPv6 traffic through VPN, assign IPv6 addresses to VPN interface.

Server configuration

Add IPv6 address to wg0.conf:

[Interface]
Address = 10.100.0.1/24, fd00:100::1/64

Assign IPv6 to clients:

[Peer]
AllowedIPs = 10.100.0.2/32, fd00:100::2/128

Client configuration

[Interface]
Address = 10.100.0.2/24, fd00:100::2/64

[Peer]
AllowedIPs = 192.168.0.0/24, 10.100.0.0/24, fd09:dead:beef::/48, fd00:100::/64

Test IPv6 connectivity:

ping6 fd00:100::1  # Router VPN IPv6
ping6 fd09:dead:beef::1  # Router LAN IPv6

Troubleshooting common issues

No handshake occurring

Symptom: wg show shows no latest handshake or endpoint.

Causes:

  1. Firewall blocking UDP 51820:

    # On server:
    nft list chain inet filter input | grep 51820
    

    If no rule exists, add it.

  2. Incorrect endpoint:

    • Verify client's Endpoint matches server's public IP/DDNS hostname
    • Test with telnet vpn.absurdum.ca 51820 (won't connect, but verifies port is reachable)
  3. NAT/firewall on client side:

    • Some networks block outbound UDP. Test from different network (mobile data).
  4. Incorrect keys:

    • Verify client's PublicKey in server config matches client_public.key
    • Verify server's PublicKey in client config matches server_public.key

Handshake succeeds but no traffic

Symptom: Handshake shown, but ping fails.

Causes:

  1. IP forwarding disabled:

    sysctl net.ipv4.ip_forward
    

    Should return 1.

  2. Missing forward chain rules:

    nft list chain inet filter forward | grep wg0
    

    Add rules permitting VPN ↔ LAN traffic.

  3. AllowedIPs mismatch:

    • Client's AllowedIPs must include destination subnet (e.g., 192.168.0.0/24)
    • Server's AllowedIPs for peer must include client's VPN IP (e.g., 10.100.0.2/32)

DNS resolution failing

Symptom: VPN connected, ping 192.168.0.1 works, but nslookup google.com fails.

Diagnostic steps:

  1. Verify Unbound permits VPN subnet:

    grep "access-control.*10.100" /etc/unbound/unbound.conf
    

    Should show access-control: 10.100.0.0/24 allow. If missing, add per DNS configuration section.

  2. Check client configuration: Verify DNS = 192.168.0.1 exists in client's wg0.conf.

  3. Test DNS directly:

    nslookup google.com 192.168.0.1
    

    If this works but normal lookups fail, client is ignoring VPN DNS. Check system DNS settings with resolvectl status (Linux) or scutil --dns (macOS).

  4. Verify Unbound is listening:

    ss -tulpn | grep 53
    

    Should show 0.0.0.0:53 or specific interface bindings including VPN subnet.

Security best practices

Key management

  • Never reuse keys: Each client should have unique private key
  • Rotate keys periodically: Generate new keys annually or after device compromise
  • Secure key distribution: Transfer keys via private/encrypted channels (password managers, encrypted USB)

Access control

Limit VPN access to specific LAN resources using firewall rules:

# Allow VPN clients to access only specific server:
iifname wg0 oifname $main_lan ip daddr 192.168.0.10 accept
iifname wg0 oifname $main_lan drop

This permits VPN clients to reach only 192.168.0.10, blocking rest of LAN.

Next Steps

With WireGuard working, we have something that allows secure remote access to our entire LAN. However, accessing this from the Internet requires either a static public IP or Dynamic DNS, see Part 10 for DDNS configuration to track changing residential IP addresses.

RSS
https://yusefkarim.absurdum.ca/posts/feed.xml