WireGuard Transparent Tunnel

Sometimes you may have a network service that you want to expose to clients of a WireGuard network, as well as to clients not using WireGuard, using the same IP address regardless of whether or not they’re running WireGuard. In other words, the WireGuard tunnel is used “transparently” when up, and ignored when down. This article will show you how.

For example, say we have a webserver running on Endpoint B, with a public IP address of 203.0.113.2, and a DNS name of app.example.com. We want any client, like Endpoint C, to be able to access the webserver at https://app.example.com over the Internet — but we also want Endpoint A to be able to access the webserver through its point-to-point WireGuard connection to Endpoint B when up, transparently using the same IP address and DNS name with WireGuard running as without:

WireGuard Point-to-Point Connection Between Endpoints A and Endpoint B

If we have a simple Point-to-Point WireGuard Configuration set up between Endpoint A and Endpoint B, Endpoint A would be able to access the webserver on Endpoint B through WireGuard by using Endpoint B’s WireGuard address of 10.0.0.2:

$ curl --insecure https://10.0.0.2
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...
Note

When attempting to access an HTTPS URL, the --insecure (aka -k) flag instructs cURL to skip verifying that the server’s TLS certificate matches its hostname. Without this flag, we’d get the following error if we tried to access the HTTPS webserver on Endpoint B using its WireGuard address:

$ curl https://10.0.0.2
curl: (60) SSL: no alternative certificate subject name matches target host name '10.0.0.2'

However, when using Endpoint B’s public IP address (203.0.113.2) and DNS name (app.example.com), access from Endpoint A would still go over the public Internet instead of through the WireGuard tunnel:

$ curl https://app.example.com
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

We can see this by checking the webserver’s access logs on Endpoint B — requests that were sent through the WireGuard tunnel will show up with Endpoint A’s WireGuard IP address (10.0.0.1) instead of its public IP address (198.51.100.1):

$ tail -F /var/log/nginx/access.log
192.0.2.3 - - [05/May/2023:00:10:17 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
10.0.0.1 - - [05/May/2023:00:12:09 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
198.51.100.1 - - [05/May/2023:00:12:31 +0000] "GET /" 200 28786 "-" "curl/7.81.0"

Transparent Access From Endpoint A

If we had already set up the WireGuard configuration on Endpoint A as described in the Point-to-Point WireGuard Configuration article, its configuration would look like the following:

# /etc/wireguard/wg0.conf

# local settings for Endpoint A
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/32
ListenPort = 51821

# remote settings for Endpoint B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2/32

To allow Endpoint A to transparently use its WireGuard connection to Endpoint B to access the webserver on Endpoint B with Endpoint B’s public IP address and DNS name, we need to make four changes to this config file.

So shut down the WireGuard interface on Endpoint A (eg sudo wg-quick down wg0), and make the following changes to Endpoint A’s WireGuard configuration:

Add Public IP to AllowedIPs

The first thing we need to do on Endpoint A is add Endpoint B’s public IP address to the AllowedIPs setting in Endpoint A’s WireGuard configuration:

AllowedIPs = 10.0.0.2/32, 203.0.113.2/32

This would normally break Endpoint A’s WireGuard connection with Endpoint B, as the address in its Endpoint setting is now also included in its AllowedIPs setting (which would normally cause Endpoint A to try to route encrypted WireGuard traffic to Endpoint B back through the WireGuard interface again and again in an infinite loop).

Use Custom Routing Table

To avoid this breakage, we need to use a custom routing table for the WireGuard interface. We can do this by adding the Table setting to the [Interface] section of Endpoint A’s WireGuard config, setting it to any unused routing table number (such as 123):

Table = 123

When we start up the interface, routes for all addresses included in the interface’s AllowedIPs settings will be added to this custom table (instead of to the main routing table).

Tip

View the routes for a custom routing table by running the ip route show table command with the table’s name or number:

$ ip route show table 123
10.0.0.2 dev wg0 scope link
203.0.113.2 dev wg0 scope link

By default, however, Endpoint A won’t route any traffic using this custom table.

Add Policy Routing for Public IP

So next we need to add some policy routing rules that tell Endpoint A when to use this custom routing table. We can add these rules via PreUp or PostUp scripts in Endpoint A’s WireGuard configuration, and tear them down via corresponding PostDown or PreDown scripts.

Tip

When editing PreUp/PostUp/PreDown/PostDown scripts in a WireGuard configuration file, don’t edit them while the interface is up — always shut down the interface first before editing the scripts; then make your changes and start the interface back up. Otherwise, the next time you try to stop (or restart) the interface, the PostDown/PreDown scripts may fail because they don’t match the commands that the previous version of the PreUp/PostUp scripts ran when the interface was originally started up.

We need to make sure encrypted WireGuard traffic sent to Endpoint B’s WireGuard listen port is routed out Endpoint A’s physical Ethernet interface as normal, but all other traffic sent to Endpoint B is routed out Endpoint A’s virtual WireGuard interface first (where it will be encrypted and tunneled to Endpoint B’s WireGuard listen port). We can accomplish this with two rules.

The first rule routes traffic to the listen port (51822) of Endpoint B (203.0.113.2) using Endpoint A’s normal (main) routing table:

PreUp = ip rule add to 203.0.113.2 dport 51822 table main priority 455
PostDown = ip rule del to 203.0.113.2 dport 51822 table main priority 455

And the second rule routes all other traffic to Endpoint B using the custom routing table (123) we configured via the Table setting above:

PreUp = ip rule add to 203.0.113.2 table 123 priority 456
PostDown = ip rule del to 203.0.113.2 table 123 priority 456

Make sure to give the first rule a smaller priority number than the second (455 vs 456 in this example), as rules with smaller priority numbers are evaluated first.

Tip

View the policy routing rules on a host by running the ip rule list command:

$ ip rule list
0:	from all lookup local
455:	from all to 203.0.113.2 dport 51822 lookup main
456:	from all to 203.0.113.2 lookup 123
32766:	from all lookup main
32767:	from all lookup default

Custom rules usually should be set with a priority number that places them between the rule for the local routing table (0) and the rule for the main routing table (32766).

Add Route for WireGuard IP

At this point, we’ve done enough on Endpoint A to ensure that any traffic to Endpoint B’s public address of 203.0.113.2 will use be routed through WireGuard. However, we’ve broken Endpoint A’s ability to route traffic to Endpoint B’s WireGuard address of 10.0.0.2. We can fix this with any one of three options:

Expand Address Subnet Mask

The first option to fix this is to expand the network prefix (aka subnet mask) specified for the WireGuard interface on Endpoint A to include Endpoint B’s WireGuard address. Instead of using a network prefix of /32, we could use /30:

Address = 10.0.0.1/30

This will cause iproute2 to automatically add a route for the 10.0.0.0/30 subnet (consisting of addresses 10.0.0.0 through 10.0.0.3) when the WireGuard interface is started up and its address is assigned.

Tip

If Endpoint A was connected to more hosts than just Endpoint B through the same WireGuard interface, we might want to use a different subnet mask to include all those hosts in the same route. For example, if hosts with addresses from 10.0.0.0 to 10.0.0.255 were part of the WireGuard network, we would instead use a /24 network prefix; or if hosts with addresses from 10.0.0.0 to 10.0.255.255 were part of the WireGuard network, we would instead use a /16 network prefix; etc.

Add Route to Main Table

The second option would be to explicitly add a route for 10.0.0.2 to Endpoint A’s main routing table, via a PostUp script:

PostUp = ip route add 10.0.0.2 dev wg0
Note

Since this command can be executed only after the interface is already up, we must run it in as PostUp script instead of a PreUp. Also, when the interface is shut down, the kernel will automatically remove all of its routes from all routing tables, so we need no corresponding PreDown script to explicitly delete the route.

Add Rule for Custom Table

The third option would be to add another policy routing rule that uses our custom table (123) to route 10.0.0.2:

PreUp = ip rule add to 10.0.0.2 table 123 priority 457
PostDown = ip rule del to 10.0.0.2 table 123 priority 457

Test It Out

After making all four changes, using the first option (Expand Address Subnet Mask) for the fourth change, the WireGuard configuration for Endpoint A will look like this:

# /etc/wireguard/wg0.conf

# local settings for Endpoint A
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/30
ListenPort = 51821
Table = 123

PreUp = ip rule add to 203.0.113.2 dport 51822 table main priority 455
PostDown = ip rule del to 203.0.113.2 dport 51822 table main priority 455
PreUp = ip rule add to 203.0.113.2 table 123 priority 456
PostDown = ip rule del to 203.0.113.2 table 123 priority 456

# remote settings for Endpoint B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2/32, 203.0.113.2/32

Now start the interface up (eg sudo wg-quick up wg0). If you haven’t already set up the WireGuard configuration on Endpoint B, follow the directions in the Point-to-Point WireGuard Configuration article to do so now.

Once WireGuard is up and running on both endpoints, we should be able to access Endpoint B using its IP address and public DNS name from Endpoint A, transparently through the WireGuard tunnel:

$ curl https://app.example.com
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

We can verify that requests from Endpoint A are coming through the WireGuard tunnel by checking the webserver’s access logs on Endpoint B. Requests for https://app.example.com from Endpoint A should now show up with Endpoint A’s WireGuard IP address (10.0.0.1) instead of its public IP address (198.51.100.1):

$ tail -F /var/log/nginx/access.log
192.0.2.3 - - [05/May/2023:00:10:17 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
10.0.0.1 - - [05/May/2023:00:12:09 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
198.51.100.1 - - [05/May/2023:00:12:31 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
10.0.0.1 - - [05/May/2023:00:33:55 +0000] "GET /" 200 28786 "-" "curl/7.81.0"

Transparent Access to Both Endpoints

After making the above changes, traffic sent from Endpoint A to Endpoint B using Endpoint B’s public IP address (203.0.133.2) as its destination address will now be routed through the WireGuard tunnel. However, the source address for this traffic will still use Endpoint A’s WireGuard IP address (10.0.0.1).

If we want to alter this source address so that the source of the traffic appears from Endpoint B’s perspective to be using Endpoint A’s public address (198.51.100.1), we need to do two more things:

  1. Make the same changes to the WireGuard Configuration for Endpoint B that we made to Endpoint A

  2. Add an SNAT (Source Network Address Translation) rule either to the Firewall for Endpoint A or the Firewall for Endpoint B

WireGuard Configuration for Endpoint B

On Endpoint B, shut down its WireGuard interface, and make the same changes we made to its configuration that we made on Endpoint A:

# /etc/wireguard/wg0.conf

# local settings for Endpoint B
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2/30
ListenPort = 51822
Table = 123

PreUp = ip rule add to 198.51.100.1 dport 51821 table main priority 455
PostDown = ip rule del to 198.51.100.1 dport 51821 table main priority 455
PreUp = ip rule add to 198.51.100.1 table 123 priority 456
PostDown = ip rule del to 198.51.100.1 table 123 priority 456

# remote settings for Endpoint A
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1/32, 198.51.100.1/32

Since Endpoint A’s public IP address is 198.51.100.1 and its WireGuard listen port is 51821, here we’ve used 198.51.100.1 in Endpoint B’s configuration where we used 203.0.113.2 in Endpoint A’s configuration, and 51821 where we used 51822.

Firewall for Endpoint A

To set up a SNAT rule for Endpoint A’s traffic on Endpoint A’s own firewall, the rule must be added to the POSTROUTING chain of the nat table. We could do this with iptables via a PreUp script in Endpoint A’s WireGuard config file like the following:

PreUp = iptables -A POSTROUTING -t nat -s 10.0.0.1 -d 203.0.113.2 -j SNAT --to-source 198.51.100.1
PostDown = iptables -D POSTROUTING -t nat -s 10.0.0.1 -d 203.0.113.2 -j SNAT --to-source 198.51.100.1

Firewall for Endpoint B

Alternatively, to set up a SNAT rule for Endpoint A’s traffic on Endpoint B’s firewall, the rule must be added to the INPUT chain of the nat table. We could do this with iptables via a PreUp script in Endpoint B’s WireGuard config file like the following:

PreUp = iptables -A INPUT -t nat -s 10.0.0.1 -d 203.0.113.2 -j SNAT --to-source 198.51.100.1
PostDown = iptables -D INPUT -t nat -s 10.0.0.1 -d 203.0.113.2 -j SNAT --to-source 198.51.100.1

Test It Out

After making those changes for Endpoint B, and starting its WireGuard interface back up (eg sudo wg-quick up wg0), we should still be able to access Endpoint B using its IP address and public DNS name from Endpoint A, transparently through the WireGuard tunnel:

$ curl https://app.example.com
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

But now if we check the webserver’s access logs on Endpoint B, we should see requests sent through the WireGuard tunnel recorded with Endpoint A’s public IP address (198.51.100.1) instead of its WireGuard IP address (10.0.0.1):

$ tail -F /var/log/nginx/access.log
192.0.2.3 - - [05/May/2023:00:10:17 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
10.0.0.1 - - [05/May/2023:00:12:09 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
198.51.100.1 - - [05/May/2023:00:12:31 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
10.0.0.1 - - [05/May/2023:00:33:55 +0000] "GET /" 200 28786 "-" "curl/7.81.0"
198.51.100.1 - - [05/May/2023:00:41:12 +0000] "GET /" 200 28786 "-" "curl/7.81.0"

However, now that all traffic sent through the WireGuard tunnel is transparently using the public IP addresses of Endpoint A and Endpoint B, how do we tell the difference between traffic sent through the tunnel and traffic sent over the public Internet? We can tell the difference by checking the network interface used by the traffic.

If we use tcpdump to monitor the wg0 interface of Endpoint A or Endpoint B, and run our cURL command again, we’ll see the request come through the WireGuard tunnel:

$ sudo tcpdump -niwg0
00:42:23.683002 wg0  Out IP 198.51.100.1.55764 > 203.0.113.2.443: Flags [S], seq 1216151227, win 62727, options [mss 1400,sackOK,TS val 1162632069 ecr 0,nop,wscale 6], length 0
00:42:23.692731 wg0  In  IP 203.0.113.2.443 > 198.51.100.1.55764: Flags [S.], seq 1045553059, ack 1216151228, win 65535, options [mss 1400,sackOK,TS val 2488797916 ecr 1162632069,nop,wscale 10], length 0
...