Linux

The platform source routing was written for, and the one where you can keep your default route.

Install

# Debian / Ubuntu
sudo apt install wireguard-tools

# RHEL / Fedora / Alma / Rocky
sudo dnf install wireguard-tools

# Alpine
sudo apk add wireguard-tools

You need wireguard-tools, not a kernel module: WireGuard has been in the Linux kernel since 5.6. On anything older, install wireguard-dkms as well.

sudo install -m 600 anchoredip-42.conf /etc/wireguard/aip0.conf
sudo wg-quick up aip0
sudo systemctl enable wg-quick@aip0

The interface takes its name from the filename. Keep it short — Linux interface names are capped at 15 characters.

What a source-routed config actually does

Nothing in it touches your default route. It builds a private routing table and sends only traffic that already has a leased address as its source into the tunnel:

the interesting lines of your .conf

Table = off
PostUp = ip route add default dev %i table 51820
PostUp = ip rule add from 194.0.108.64 lookup 51820
PostUp = ip rule add from 10.128.4.7 lookup 51820

So the machine keeps its own identity for everything else — package updates, your SSH session, monitoring — and uses ours only when a socket is bound to the leased address. That is deliberate: a config that grabbed the default route would drop your SSH session the moment you brought it up.

There is a rule for the overlay address (10.128.x.y) as well as the public one. On a machine that does not hold the public IPv4 — every machine but the inbound one — binding to the overlay address is how traffic leaves through us, and the edge rewrites the source to your leased address on the way out.

Making an application use it

The application has to bind its outgoing socket to the leased address. Most tools have a flag for it:

curl --interface 194.0.108.64 https://partner.example.com
wget --bind-address=194.0.108.64 https://partner.example.com
ssh -b 194.0.108.64 user@partner.example.com

Python (requests)

import requests
from requests_toolbelt.adapters import source

s = requests.Session()
s.mount("https://", source.SourceAddressAdapter("194.0.108.64"))
s.get("https://partner.example.com")

Node.js

const https = require('node:https');
const agent = new https.Agent({ localAddress: '194.0.108.64' });
await fetch('https://partner.example.com', { agent });

Go

dialer := &net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP("194.0.108.64")}}
client := &http.Client{Transport: &http.Transport{DialContext: dialer.DialContext}}

If the application has no such option, give it a full tunnel instead — in a container or a network namespace, so the rest of the host is unaffected. See Docker.

Full tunnel on Linux

Create the tunnel in full-tunnel mode and it is an ordinary wg-quick config: everything leaves through us, nothing needs binding. Useful for a dedicated worker or a container, and dangerous on a box you are connected to over SSH from an address the tunnel will now route away from.

if you must, keep a way back in first

# route your own SSH source back out the normal way
sudo ip rule add to 203.0.113.9 lookup main priority 100

AmneziaWG

If a plain WireGuard handshake never completes on a network that otherwise works, the handshake is probably being recognised and dropped. Create the tunnel with the AmneziaWG transport and use the AmneziaWG tools, which are a drop-in fork:

# packages: amneziawg-tools, plus amneziawg-dkms or amneziawg-go
sudo install -m 600 anchoredip-43.conf /etc/amnezia/amneziawg/aip0.conf
sudo awg-quick up aip0
sudo awg show

The obfuscation parameters (Jc, S1, H1…) are already in the file and must match ours exactly — do not edit them. Obfuscation belongs to the interface, not to a peer, so an AmneziaWG config will not work against a stock wg and vice versa.

Checking it

sudo wg show                       # handshake age, bytes in and out
curl -s --interface 194.0.108.64 https://api.ipify.org
ip rule list
ip route show table 51820

A handshake under two minutes old means the tunnel is live. Nothing at all means it never completed — see troubleshooting.