WireGuard Port Forwarding From the Internet to Other Networks

When you have a private server on a private network that’s not publicly accessible from the Internet, but you want to expose a service running on it to public Internet traffic, you can do so via WireGuard. The original WireGuard Port Forwarding From the Internet article covers several common versions of this scenario, where you have a public server exposed to the Internet directly connected through WireGuard to the private server running the service you want to expose.

However, when the private server you want to reach is multiple hops away from the public server, you may need to apply a more complicated technique to properly route return traffic back to the Internet: Connection Marking. For example, say you want to connect as below from somewhere on the Internet to a private HTTP service listening on port 8080 of your private server, using a URL of http://public.example.com:2000/ to send the connection first to the public server, and then on to the private server.

You may need to use connection marking when the private server on which the service is running is not directly connected to WireGuard, but is on the same private LAN (Local Area Network) as an intermediate server with a WireGuard connection:

Port Forwarding From the Internet Through WireGuard to a LAN

Or when the service is running as a Docker container with a Docker bridge network on an intermediate server with a WireGuard connection:

Port Forwarding From the Internet Through WireGuard to a Container

Or when access to the service is forwarded through one or more intermediate WireGuard networks (the reverse of the “Internet Gateway as a Spoke” scenario from the Multi-Hop WireGuard article):

Port Forwarding From the Internet With a WireGuard Hub

Use the following decision tree to determine which technique to use when the private server is not directly connected to the public server:

  1. Will the private service need to know the original Internet source address of each connection (eg for logging or access control)? If no: Use Masquerading.

  2. Otherwise: Will all Internet connections to the private service originate from a small, fixed set of source addresses? If yes: Use Static Routes.

  3. Otherwise: Will both the private and intermediate servers themselves use the public server for all of their outbound Internet access (eg for software updates, DNS queries, etc)? If yes: Use the Default Route.

  4. Otherwise: Will all connections to the private service originate through the public server? If yes: Use Policy Routing.

  5. Otherwise: Will the intermediate server be the default gateway for the private server? If yes: Use Connection Marking on the intermediate server.

  6. Otherwise: Use Connection Marking on the private server.

In the following examples, we’ll start with a couple of base WireGuard configurations, and make slight adjustments to each when necessary to demonstrate a particular technique (note these base configurations are virtually identical to the WireGuard Point to Site With Port Forwarding article):

Base Public Server WireGuard Config
# /etc/wireguard/wg0.conf

# local settings for the public server
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2
ListenPort = 51822

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# port forwarding
PreUp = iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 172.31.0.3:8080
PostDown = iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 172.31.0.3:8080

# remote settings for the intermediate server
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1, 172.31.0.3
Base Intermediate Server WireGuard Config
# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1
ListenPort = 51821

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2
PersistentKeepalive = 25

When the private service is run inside a Docker container on a Docker bridge network, the Docker host is functionally the “intermediate server”, and the Docker container is the “private server”.

However, when the private server is itself running WireGuard, and directly connected to the intermediate server over WireGuard — using the intermediate server as the hub of a WireGuard network between it and the public server — the base WireGuard configuration for each server will be slightly different, to account for their hub-and-spoke topology:

Base Public Server WireGuard Config as a Spoke
# /etc/wireguard/wg0.conf

# local settings for the public server spoke
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2/24
ListenPort = 51822

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# port forwarding
PreUp = iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 10.0.0.3:8080
PostDown = iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 10.0.0.3:8080

# remote settings for the intermediate server hub
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.0/24
Base Intermediate Server WireGuard Config as a Hub
# local settings for the intermediate server hub
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51821

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# remote settings for the public server spoke
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2
PersistentKeepalive = 25

# remote settings for the private server spoke
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
AllowedIPs = 10.0.0.3
Base Private Server WireGuard Config as a Spoke
# /etc/wireguard/wg0.conf

# local settings for the private server spoke
[Interface]
PrivateKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCGA=
Address = 10.0.0.3/24
ListenPort = 51823

# remote settings for intermediate server hub
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
Endpoint = 172.31.0.1:51821
AllowedIPs = 10.0.0.0/24
PersistentKeepalive = 25

With these base configurations, in particular note that when using the intermediate server as a WireGuard hub, the public server forwards connections to the private server’s WireGuard IP address of 10.0.0.3; whereas when the intermediate server is not used as a WireGuard hub, the public server forwards connections to the private server’s LAN (or Docker) address of 172.31.0.3:

These examples build off the basic WireGuard and networking techniques explained by the WireGuard Point to Site With Port Forwarding, WireGuard Hub and Spoke Configuration, Multi-Hop WireGuard, and WireGuard Port Forwarding From the Internet guides; see them for more information about how these techniques work.

Masquerading

Reach for the masquerading technique only when the private service doesn’t care about the original source addresses of connections made to it (as masquerading will rewrite the source address of each matched connection so as to appear that it came from the intermediate server). Apply the same masquerading rules from the Masquerading section of the WireGuard Port Forwarding From the Internet article to both the public server and the intermediate servers.

When using iptables, the WireGuard config for the public server would look like this:

# /etc/wireguard/wg0.conf

# local settings for the public server
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2
ListenPort = 51822

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# port forwarding
PreUp = iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 172.31.0.3:8080
PostDown = iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 172.31.0.3:8080

# packet masquerading
PreUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE

# remote settings for the intermediate server
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1, 172.31.0.3

And the WireGuard config for the intermediate server would look like this:

# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1
ListenPort = 51821

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# packet masquerading
PreUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2
PersistentKeepalive = 25
Tip
When the private server already knows how to route packets back to the public server — such as when the intermediate server is also the LAN router for the private server, or the private server is hosted as a Docker container on the intermediate server, or the intermediate server is operating as a WireGuard hub between the public and private servers — you don’t actually have to add masquerading to the intermediate server at all — you only need to add it to the public server.

Static Routes

Use this technique only when you know that connections to the private service from the Internet will originate just from a few specific addresses or address blocks (such as when you’re only trying to forward inbound connections, like for web hooks, from a specific third-party service to the private service). Apply the static routes to the AllowedIPs setting of the intermediate server as shown in the Static Routes section of the WireGuard Port Forwarding From the Internet article — plus add the same static routes to the private server.

The intermediate server’s WireGuard configuration would look like this (if you need to forward connections from just 198.51.100.87 and the 192.0.2.144/30 address block to the private service):

# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1
ListenPort = 51821

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2, 198.51.100.87, 192.0.2.144/30
PersistentKeepalive = 25

If the private server is using the intermediate server as a WireGuard hub to the public server, also make a similar change to the AllowedIPs setting on the private server:

# /etc/wireguard/wg0.conf

# local settings for the private server spoke
[Interface]
PrivateKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCGA=
Address = 10.0.0.3/24
ListenPort = 51823

# remote settings for intermediate server hub
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
Endpoint = 172.31.0.1:51821
AllowedIPs = 10.0.0.0/24, 198.51.100.87, 192.0.2.144/30
PersistentKeepalive = 25

Otherwise, explicitly add static routes on the private server to send the connections from the those specific addresses back through the intermediate server (where 172.31.0.1 is the intermediate server’s LAN address, and eth0 is the private server’s LAN interface):

$ sudo ip route add 198.51.100.87 via 172.31.0.1 dev eth0
$ sudo ip route add 192.0.2.144/30 via 172.31.0.1 dev eth0
Warning
Configuration applied via iproute2 utilities such as the ip command do not persist across system reboots. To apply static routes following a reboot, include the iproute2 commands in a custom init script; or if using NetworkManager, instead use NetworkManager’s own tools to configure static routes.
Tip
When the private server already uses the intermediate server as its default gateway — such as when the intermediate server is also the LAN router for the private server, or the private server is hosted as a Docker container on the intermediate server — you don’t actually have to add any routes to the private server, as it will already route all of its Internet traffic through the intermediate server.

Default Route

Reach for this technique only when you want the private and intermediate servers to use the public server for all of their Internet access, anyway (including access unrelated to port forwarding, such to download software updates or make DNS queries). Change the AllowedIPs setting of the intermediate server as shown in the Default Route section of the WireGuard Port Forwarding From the Internet article — plus set the default gateway of the private server to route Internet traffic through the intermediate server.

The intermediate server’s WireGuard configuration should look like this:

# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1
ListenPort = 51821

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

If the private server is using the intermediate server as a WireGuard hub to the public server, also make a similar change to the AllowedIPs setting on the private server:

# /etc/wireguard/wg0.conf

# local settings for the private server spoke
[Interface]
PrivateKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCGA=
Address = 10.0.0.3/24
ListenPort = 51823

# remote settings for intermediate server hub
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
Endpoint = 172.31.0.1:51821
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Otherwise, if the private server isn’t already using the intermediate server as its default gateway, change it to do so:

$ sudo ip route add default via 172.31.0.1 dev eth0
Tip
If the intermediate server is also the LAN router for the private server, or if the private server is hosted as a Docker container on the intermediate server, the private server will already be set up with the intermediate server as its default gateway — you won’t have to add any routes to the private server in this case.

Also, if the private or intermediate servers need to access other internal networks through their original default gateway, you will have to add an explicit route for each such network. For example, if the intermediate server needs to access the 192.168.99.0/24 network through its LAN router 192.168.1.1, you would have to add a route on the intermediate server like the following:

$ sudo ip route add 192.168.99.0/24 via 192.168.1.1 dev eth0

And finally, you will probably also want to add packet masquerading to the public server for outbound connections from the intermediate and private servers. When using iptables, the public server’s WireGuard config would look like this:

# /etc/wireguard/wg0.conf

# local settings for the public server
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2
ListenPort = 51822

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# port forwarding
PreUp = iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 172.31.0.3:8080
PostDown = iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 2000 -j DNAT --to-destination 172.31.0.3:8080

# packet masquerading to the Internet
PreUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

# remote settings for the intermediate server
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1, 172.31.0.3

Note that this masquerading applies in the opposite direction as the Masquerading technique shown earlier. It won’t affect connections that are forwarded inbound to the private server, but it will affect outbound connections initiated by the private and intermediate servers (eg for software updates or DNS queries etc), allowing them to correctly return back through the public server.

Policy Routing

You’ll need to use policy routing in situations where the above techniques won’t work. When the private service only needs to handle connections from the Internet (and not from other internal networks), or when the private server is using the intermediate server as a WireGuard hub to a single public server (and to no other public servers), you’ll be able to use policy routing alone (without also having to use Connection Marking).

For Hub-and-Spoke

When the private server is using the intermediate server as a WireGuard hub to the public server, make the same changes shown in the Policy Routing section of the WireGuard Port Forwarding From the Internet article to the private server’s WireGuard config:

# /etc/wireguard/wg0.conf

# local settings for the private server spoke
[Interface]
PrivateKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCGA=
Address = 10.0.0.3/24
ListenPort = 51823
Table = 123

# policy routing for spoke return traffic
PreUp = ip rule add from 10.0.0.3 table 123 priority 456
PostDown = ip rule del from 10.0.0.3 table 123 priority 456

# remote settings for intermediate server hub
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
Endpoint = 172.31.0.1:51821
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Also make a similar change to the intermediate server, but with a different policy routing rule:

# /etc/wireguard/wg0.conf

# local settings for the intermediate server hub
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51821
Table = 123

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# policy routing for hub traffic forwarding
PreUp = ip rule add iif wg0 table 123 priority 456
PostDown = ip rule del iif wg0 table 123 priority 456

# remote settings for the public server spoke
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

# remote settings for the private server spoke
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
AllowedIPs = 10.0.0.3

The policy routing rule on the hub routes all incoming packets received on the hub’s WireGuard interface using the custom routing table 123. WireGuard’s wg-quick script automatically sets up this table with routes inferred from the AllowedIPs settings in the interface’s WireGuard config. This ensures that incoming WireGuard packets on the hub will always be forwarded out the same WireGuard interface.

The policy routing rule on the spoke applies not to incoming packets — but to outgoing ones. It routes all outgoing packets with a source address that matches the spoke’s WireGuard interface address, using the spoke’s custom routing table 123. This works because all outgoing return traffic generated by the private service will use the source address of the network interface on which the connection was originally received; when the private service receives a new connection through WireGuard at its WireGuard IP address of 10.0.0.3, that’s the source address it will use for return traffic.

When Not Hub-and-Spoke

When the private server is not connected to the intermediate server via WireGuard, you’ll need to use similar policy routing rules, but you’ll need to select packets using a different set of criteria.

On the private server, if the intermediate server isn’t already its default gateway, run the following two commands to make it the default gateway just for the private service:

$ sudo ip route add default via 172.31.0.1 dev eth0 table 123
$ sudo ip rule add ipproto tcp sport 8080 table 123 priority 456

The first command sets up a custom routing table (123) with a default route to the intermediate server (172.31.0.1). The second command adds a policy routing rule to route all outgoing packets sent from the private service (listening on TCP port 8080) using the custom routing table.

On the intermediate server, make similar changes to its WireGuard configuration as for hub-and-spoke, but with a different policy routing rule:

# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51821
Table = 123

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# policy routing for return traffic forwarding
PreUp = ip rule add from 172.31.0.3 table 123 priority 456
PostDown = ip rule del from 172.31.0.3 table 123 priority 456

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

This policy rule will route all packets the intermediate server receives from the private server (172.31.0.3) using the custom routing table.

Connection Marking

Reach for this technique last, as it’s more complicated to set up, and can be difficult to maintain if you need to use multiple marks for multiple different purposes. However, you can usually solve even the trickiest routing problems with it, as it allows you to make routing decisions based on the full bidirectional connection to which a packet belongs, instead of just the properties of a lone packet itself.

Background

There are several different types of “marks” that can be applied to various aspects of a network connection under the Linux networking stack. These are the two relevant to this technique:

  1. Packet mark (aka “fwmark” aka “firewall mark” aka “nfmark” aka “netfilter mark”): an arbitrary, user-defined 32-bit value that can be applied to an individual packet, available for use by the core firewall (aka netfilter), connection tracking (aka conntrack), and routing subsystems.

  2. Connection mark (aka “ctmark” aka “connmark”): a separate arbitrary, user-defined 32-bit value that can be applied to a full bidirectional connection, available for use by the firewall and connection tracking subsystems.

In order to keep track of connections, you need to use the connection mark; but in order to use the connection mark with routing, you need to copy the connection mark to the packet mark on each individual packet you want to affect.

Also, there is only one single packet mark field available on each packet, and one connection mark field available for each connection. Therefore, if you ever need to use the packet or connection mark for more than one purpose (for example, for routing one group of packets and rate limiting another group), you’ll need to choose different mark values for those different purposes (eg a mark value of 1 for one purpose, a mark value of 2 for another, and so on) — and if you need to maintain different mark values on the same packet or connection at the same time, you will need to resort to bitmasking the mark value when setting and checking the mark.

Connmark on the Intermediate Server

When the techniques discussed earlier won’t work, and the intermediate server is either a Docker host running the private server as a container, or is the LAN router for the private server, you’ll need to use connection marking in conjunction with policy routing on the intermediate server.

There are six steps to this technique:

  1. Set up a custom routing table: do this by adding a Table setting to the [Interface] section of the WireGuard config (in the following example, table number 123).

  2. Loosen Source Address Validation: if the private server’s net.ipv4.conf.all.rp_filter kernel parameter is set to 1 (strict), you need to set the WireGuard interface’s rp_filter value to 2 (loose).

  3. Mark new connections: do this with a firewall rule that sets a connection mark on all new connections that come in through the WireGuard interface (in the following example, using a mark value of 1).

  4. Mark return packets: do this with a firewall rule that copies the connection mark only to outgoing packets (in the following example, using an iptables rule that ignores packets incoming through the WireGuard interface, but otherwise matches packets that are part of a connection with an existing connection mark value of 1).

  5. Add a policy routing rule: use this rule to match marked packets, and route them using the custom table.

  6. Set up a custom default route: change the AllowedIPs setting in the public server’s [Peer] section of the WireGuard config to include all Internet addresses (for IPv4, 0.0.0.0/0); this will become the default route for the custom routing table.

When using iptables, the WireGuard config file for the intermediate server would look like this:

# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51821
Table = 123

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# loose reverse path forwarding validation
PreUp = sysctl -w net.ipv4.conf.wg0.rp_filter=2

# mark new connections coming in through WireGuard
PreUp = iptables -t mangle -A PREROUTING -i wg0 -m state --state NEW -j CONNMARK --set-mark 1
PostDown = iptables -t mangle -D PREROUTING -i wg0 -m state --state NEW -j CONNMARK --set-mark 1

# mark return packets to go out through WireGuard via policy routing
PreUp = iptables -t mangle -A PREROUTING ! -i wg0 -m connmark --mark 1 -j MARK --set-mark 1
PostDown = iptables -t mangle -D PREROUTING ! -i wg0 -m connmark --mark 1 -j MARK --set-mark 1

# policy routing for marked connections
PreUp = ip rule add fwmark 1 table 123 priority 456
PostDown = ip rule del fwmark 1 table 123 priority 456

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

If you’re using nftables instead of iptables, rather than adding the above iptables rules to the intermediate server’s WireGuard config file, add the following table to its nftables config file (or if you already have a similar “mangle” prerouting chain, just copy these two rules to your existing chain):

table inet mangle {
    chain prerouting {
        type filter hook prerouting priority -150; policy accept;
        # mark new connections coming in through WireGuard
        iifname "wg0" ct state new ct mark set 1
        # mark return packets to go out through WireGuard via policy routing
        iifname != "wg0" ct mark 1 meta mark set 1
    }
}

You won’t need to adjust the private server at all in this scenario, as it will already use the intermediate server as its default gateway.

Connmark on the Private Server

When the earlier techniques won’t work, and the intermediate server is on the private server’s LAN but is not the private server’s default gateway, you’ll need to use connection marking in conjunction with policy routing on the private server. The steps you need to follow are similar to the above Connmark on the Intermediate Server section, but the details are a bit different as the private server will need to return traffic through its LAN interface to the intermediate server, instead of through a WireGuard interface.

We’ll also perform these steps in a little different order than the previous section:

  1. Mark new connections

  2. Mark return packets

  3. Set up a custom routing table & default route

  4. Add a policy routing rule

  5. Loosen source address validation

First, add a firewall rule to mark new connections incoming from the intermediate server, matching them by the intermediate server’s MAC address (eg 12:34:56:78:90:ab) on the LAN it shares with the private server:

$ sudo iptables -t mangle -A PREROUTING -m --mac-source 12:34:56:78:90:ab -m state --state NEW -j CONNMARK --set-mark 1

Next, add a second firewall rule to mark outgoing return packets that have the connection mark from the first rule:

$ sudo iptables -t mangle -A OUTPUT -m connmark --mark 1 -j MARK --set-mark 1

Thirdly, add a custom routing table (123) with a default route to the intermediate server (172.31.0.1):

$ sudo ip route add default via 172.31.0.1 dev eth0 table 123

Then add a policy routing rule to route marked packets using the custom table (123):

$ sudo ip rule add fwmark 1 table 123 priority 456

Finally, if you’re using strict reverse path forwarding Source Address Validation on the private server, you’ll need to loosen it:

$ sudo sysctl -w net.ipv4.conf.all.rp_filter=2
Warning
None of the above configuration will persist across reboots. To apply them following a reboot, add the commands to a custom init script.

To use nftables instead of iptables for the first two steps, add the following table to the private server’s nftables config file (or if you already have similar “mangle” prerouting and output chains, just copy these rules to your existing chains):

table inet mangle {
    chain prerouting {
        type filter hook prerouting priority -150; policy accept;
        # mark new connections coming in from the intermediate server
        ether saddr 12:34:56:78:90:ab ct state new ct mark set 1
    }
    chain output {
        type route hook output priority -150; policy accept;
        # mark return packets to go out to the intermediate server via policy routing
        ct mark 1 meta mark set 1
    }
}

On the intermediate server, set up Policy Routing the same way as in the When Not Hub-and-Spoke sub-section (no connection marking on the intermediate server is necessary unless the private server also uses it as a route to other internal networks):

# /etc/wireguard/wg0.conf

# local settings for the intermediate server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51821
Table = 123

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# policy routing for return traffic forwarding
PreUp = ip rule add from 172.31.0.3 table 123 priority 456
PostDown = ip rule del from 172.31.0.3 table 123 priority 456

# remote settings for the public server
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Connmark With Hub-and-Spoke

With the relatively simple hub-and-spoke WireGuard network shown as an example in this article, you wouldn’t ever actually need to use connection marking. But for the sake of illustrating this technique, let’s imagine a more complicated scenario with two public servers (call them “Public Server 1” and “Public Server 2”) that forward traffic from the Internet through WireGuard to the same destination port on the same private server.

In this more complicated scenario, the intermediate server would use a separate WireGuard interface to connect to each public server (wg1 and wg2, in addition to using wg0 for its connection to the private server). The intermediate server would need to use connection marking to keep track of which interface (wg1 or wg2) to route the return traffic from the private server.

In this case, the intermediate server’s WireGuard config for its connection to the private server (wg0) might look like the following:

# /etc/wireguard/wg0.conf

# local settings for the intermediate server hub
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1
ListenPort = 51821

# packet forwarding
PreUp = sysctl -w net.ipv4.ip_forward=1

# remote settings for the private server spoke
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
AllowedIPs = 10.0.0.3

And the intermediate server’s WireGuard config for its connection to the first public server (wg1) might look like this:

# /etc/wireguard/wg1.conf

# local settings for the intermediate server hub
[Interface]
PrivateKey = 0F11111111111111111111111111111111111111110=
Address = 10.100.100.1/24
ListenPort = 51000
Table = 10

# loose reverse path forwarding validation
PreUp = sysctl -w net.ipv4.conf.wg1.rp_filter=2

# mark new connections coming in from public server 1
PreUp = iptables -t mangle -A PREROUTING -i wg1 -m state --state NEW -j CONNMARK --set-mark 1
PostDown = iptables -t mangle -D PREROUTING -i wg1 -m state --state NEW -j CONNMARK --set-mark 1

# mark return packets to go out to public server 1 via policy routing
PreUp = iptables -t mangle -A PREROUTING ! -i wg1 -m connmark --mark 1 -j MARK --set-mark 1
PostDown = iptables -t mangle -D PREROUTING ! -i wg1 -m connmark --mark 1 -j MARK --set-mark 1

# policy routing for marked connections from public server 1
PreUp = ip rule add fwmark 1 table 10 priority 100
PostDown = ip rule del fwmark 1 table 10 priority 100

# remote settings for public server 1 spoke
[Peer]
PublicKey = MMsBeTT9v/7XNWB8a/jSMn9O8olPVNduUwvUPJ6eB14=
Endpoint = 198.51.100.11:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

And the intermediate server’s WireGuard config for its connection to the second public server (wg2) like this:

# /etc/wireguard/wg2.conf

# local settings for the intermediate server hub
[Interface]
PrivateKey = 2G22222222222222222222222222222222222222220=
Address = 10.200.200.1/24
ListenPort = 52000
Table = 20

# loose reverse path forwarding validation
PreUp = sysctl -w net.ipv4.conf.wg2.rp_filter=2

# mark new connections coming in from public server 2
PreUp = iptables -t mangle -A PREROUTING -i wg2 -m state --state NEW -j CONNMARK --set-mark 2
PostDown = iptables -t mangle -D PREROUTING -i wg2 -m state --state NEW -j CONNMARK --set-mark 2

# mark return packets to go out to public server 2 via policy routing
PreUp = iptables -t mangle -A PREROUTING ! -i wg2 -m connmark --mark 2 -j MARK --set-mark 2
PostDown = iptables -t mangle -D PREROUTING ! -i wg2 -m connmark --mark 2 -j MARK --set-mark 2

# policy routing for marked connections from public server 2
PreUp = ip rule add fwmark 2 table 20 priority 200
PostDown = ip rule del fwmark 2 table 20 priority 200

# remote settings for public server 2 spoke
[Peer]
PublicKey = kAYKIv6gpBDqueaVNOsY8ddKmIC2dnnXJQ0iKzWxfBI=
Endpoint = 203.0.113.22:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

This configuration ensures that replies to traffic originally forwarded in from Public Server 1 will return back out through Public Server 1, replies to traffic originally forwarded in from Public Server 2 will return back out through Public Server 2, and any Internet traffic originating on the intermediate server itself (such as for its own software updates or DNS queries) will be sent out through the intermediate server’s default gateway.

Source Address Validation

The connection-marking examples above are not compatible with strict RPF (Reverse Path Forwarding) SAV (Source Address Validation). This kind of SAV (aka “reverse path filtering”) automatically drops incoming packets if they do not come in the same interface the routing subsystem calculates that it will send return packets back out (aka “martian packets”) — and the above examples do not allow the routing subsystem to make accurate reverse path calculations (as the above examples don’t mark packets on the forward path, only the return path).

Tip

If you are unsure as to whether or not you’re using strict RPF, check three things:

  1. Run sysctl net.ipv4.conf.all.rp_filter; if the output is 1, you’re using strict RPF (for all IPv4 traffic).

  2. Run sudo iptable-save | grep rpfilter; if you have any matches (and the match does not contain the --loose option), you’re using strict RPF (at least for some traffic).

  3. Run sudo nft list ruleset | grep -e 'fib.*iif'; if you have any matches (and the match also contains saddr), you’re using strict RPF (at least for some traffic).

It’s possible to use connection marking with strict RPF; however, it’s slightly more complicated, and won’t provide any benefit unless you also add additional policy routing rules (or additional routes to your custom routing tables) for address blocks that should never be routed through WireGuard. Therefore, when using this technique, if the Linux kernel’s own built-in RPF setting (the net.ipv4.conf.all.rp_filter network kernel parameter) is configured to “strict” (1), you should change it to “loose” (2) for the interfaces with which you’re using connection marking (as shown in the above examples).

If you’re applying SAV via firewall, you should customize your firewall rules to account for how you’re using WireGuard. For example, you might be using an nftables rule like the following to drop any packets that fail strict RPF:

table inet raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;
        # enforce strict RPF
        fib saddr . mark . iif oif missing drop
    }
}

If so, you might want to simply exempt the WireGuard interface from the strict RPF check:

table inet raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;
        # enforce strict RPF, except for WireGuard
        iifname != "wg0" fib saddr . mark . iif oif missing drop
    }
}

Or you might want to first validate that only packets with a Internet-routable source address are coming in the WireGuard interface (in addition to packets received directly from other WireGuard peers):

# note: not an exhaustive list
define ipv4_bogons = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }

table inet raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;
        # allow packets from known WireGuard peers
        iifname "wg0" ip saddr 10.0.0.0/24 accept
        # allow packets forwarded through WireGuard from the Internet
        iifname "wg0" ip saddr != $ipv4_bogons accept
        # enforce strict RPF
        fib saddr . mark . iif oif missing drop
    }
}