WireGuard Port Forwarding From the Internet

When you have a private server that’s not publicly accessible from the Internet (for example, because it’s behind NAT), but you want to expose a service running on it to public Internet traffic, you can do so via WireGuard — as long as you have another server that is publicly accessible from the Internet. This article will show you how.

Setting up a WireGuard connection between two servers and forwarding traffic from one to the other is usually pretty easy. The part that can be tricky is returning traffic back. For example, say you have a web app running on port 8080 of a private server behind NAT (Network Address Translation) at one site, and you want to make it accessible to the public Internet through port 2000 of a public server that has a domain name of public.example.com:

Port Forwarding From the Internet

On this private server, in order for the web app to work properly, it needs to be able to access a database server and a message queue on another internal network to which its connected; and you also need to be able to access it from a second internal network for administration via SSH. So the private server can’t simply send all its traffic through its WireGuard connection to the public server — it must selectively send only traffic that constitutes responses from its web app back to the source of the forwarded public traffic.

There are several different techniques you can use to send the return traffic back through the WireGuard connection from the private server to the public server. Which technique you need to use depends on what else the private server needs to access outside of its WireGuard connection. Following are the main techniques:

Basic Connection

The basic WireGuard connection between the two servers is similar to that of the WireGuard Point to Site With Port Forwarding configuration guide.

For this example, we’ll configure WireGuard on our private server like the following, using the public server’s public IP address of 203.0.113.2 to start up a WireGuard connection with the public server; and using a PersistentKeepalive setting to keep the connection alive through the NAT fronting the private server:

# /etc/wireguard/wg0.conf

# local settings for the private server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.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

These settings are similar to “Endpoint A” from the WireGuard Point to Site With Port Forwarding guide.

On the public server, we need to turn on packet forwarding. We also need to add a firewall rule to set up port forwarding (also known as DNAT, Destination Network Address Translation) to translate the destination address of packets sent to TCP port 2000 of the public server to TCP port 8080 on the private server (10.0.0.1). We’ll do this via PreUp commands in its WireGuard config (for convenience):

# /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 -p tcp --dport 2000 -j DNAT --to-destination 10.0.0.1:8080
PostDown = iptables -t nat -D PREROUTING -p tcp --dport 2000 -j DNAT --to-destination 10.0.0.1:8080

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

These settings are similar to “Host β” from the WireGuard Point to Site With Port Forwarding guide.

If you’re using nftables instead of iptables on your public server, instead of running the above iptables command to set up port forwarding, you’d add a rule like tcp dport 2000 dnat ip to 10.0.0.1:8080 to a nat prerouting chain in your nftables configuration. Following is a full example nftables config file that does this:

#!/usr/sbin/nft -f
flush ruleset

define pub_iface = "eth0"
define wg_iface = "wg0"
define wg_port = 51822

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # accept all loopback packets
        iif "lo" accept
        # accept all icmp/icmpv6 packets
        ip protocol icmp accept
        ip6 nexthdr ipv6-icmp accept
        # accept all packets that are part of an already-established connection
        ct state established,related accept

        # accept all SSH packets received on a public interface
        iif $pub_iface tcp dport ssh accept
        # accept all WireGuard packets received on a public interface
        iif $pub_iface udp dport $wg_port accept

        # reject with polite "port unreachable" icmp response
        reject
    }

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

        # forward all packets that are part of an already-established connection
        ct state established,related accept
        # forward any incoming packets from a public interface that will go out through WireGuard
        iif $pub_iface oifname $wg_iface accept

        # reject with polite "host unreachable" icmp response
        reject with icmpx type host-unreachable
    }
}
table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100; policy accept;
        # rewrite destination address of all TCP port 2000 packets to port 8080 on 10.0.0.1
        tcp dport 2000 dnat ip to 10.0.0.1:8080
    }
}

See the Point to Site With Port Forwarding section of the WireGuard Nftables configuration guide for a full explanation of these firewall rules.

This configuration will give you basic connectivity between the private server and the public server (test it out by running curl 10.0.0.1:8080 on the public server — you should see output from the web app on the private server). It won’t allow any Internet traffic to be forwarded between the two servers, however. To do so, you must add one of the techniques from the following sections.

Masquerading

The simplest way to allow return traffic to be forwarded back from the private server to the public server is to use SNAT (Source Network Address Translation) on the public server to translate the source address of forwarded packets to use the public server’s own IP address. When SNAT is used this way, it is known as “masquerading” (in this example, the public server is “masquerading” the identity of packets from the Internet with its own identity).

Because the private server receives these forwarded packets with their source address already rewritten to use the public server’s WireGuard IP address, no routing changes need to be made on the private server — the private server knows it can reply to these packets by sending traffic directly back to the public server. The public server’s firewall takes care of remembering the original traffic sources, and rewriting reply-packet destinations back to the original source IP addresses.

To apply masquerading with iptables, add the following PreUp and PostDown commands to the public server’s WireGuard config file:

# /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 -p tcp --dport 2000 -j DNAT --to-destination 10.0.0.1:8080
PostDown = iptables -t nat -D PREROUTING -p tcp --dport 2000 -j DNAT --to-destination 10.0.0.1: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 private server
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1

Or if you’re using nftables instead of iptables, add the following nat postrouting chain to the public service’s nftables config file:

#!/usr/sbin/nft -f
flush ruleset

define pub_iface = "eth0"
define wg_iface = "wg0"
define wg_port = 51822

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # accept all loopback packets
        iif "lo" accept
        # accept all icmp/icmpv6 packets
        ip protocol icmp accept
        ip6 nexthdr ipv6-icmp accept
        # accept all packets that are part of an already-established connection
        ct state established,related accept

        # accept all SSH packets received on a public interface
        iif $pub_iface tcp dport ssh accept
        # accept all WireGuard packets received on a public interface
        iif $pub_iface udp dport $wg_port accept

        # reject with polite "port unreachable" icmp response
        reject
    }

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

        # forward all packets that are part of an already-established connection
        ct state established,related accept
        # forward incoming packets from a public interface that will go out through WireGuard
        iif $pub_iface oifname $wg_iface accept

        # reject with polite "host unreachable" icmp response
        reject with icmpx type host-unreachable
    }
}
table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100; policy accept;
        # rewrite destination address of all TCP port 2000 packets to port 8080 on 10.0.0.1
        tcp dport 2000 dnat ip to 10.0.0.1:8080
    }

    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        # masquerade all packets going out through WireGuard
        oifname $wg_iface masquerade
    }
}

The downside of masquerading is that it hides the original source IP address of clients using the web app from the web app itself — all traffic the private server receives will use the public server’s WireGuard IP address (10.0.0.2) as its source. But you should now at least be able to connect to the web app on the private server from any device over the Internet, using the public server’s hostname and port (eg curl public.example.com:2000).

Static Routes

If you know ahead of time that your Internet traffic will only come from a few, static ranges of IP addresses (like say 198.51.100.87 and the range 192.0.2.144 to 192.0.2.147), another option you can use is simply to define static routes for these IP addresses on the private server, so that it will always send traffic back to those IP addresses through the public server. This is the same approach used in the WireGuard Point to Site With Port Forwarding article, where we know all the traffic will come from Site B’s subnet (192.168.200.0/24 in that article).

All you have to do in this case is add the static IP addresses to the AllowedIPs setting in the private server’s WireGuard config:

# /etc/wireguard/wg0.conf

# local settings for the private server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.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

When you do this, you’ll be able to connect to the web app on the private server using the public server’s hostname and port (eg curl public.example.com:2000) from any host that uses one of those AllowedIPs addresses as its public Internet address (such as 192.0.2.145). The web app on the private server will see the original source IP address (eg 192.0.2.145), but still will be able to correctly send replies back through the public server.

Default Route

If the private server doesn’t actually need to access a separate database and message queue (or if it could instead access them through WireGuard), and you didn’t actually need to to SSH into the private server for administration (or if you could instead SSH into it through WireGuard), you could let WireGuard take over the private server’s default route — and send all its traffic through WireGuard to the public server by default.

In that case, you’d simply set the AllowedIPs setting to 0.0.0.0/0 in the private server’s WireGuard config:

# /etc/wireguard/wg0.conf

# local settings for the private server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.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 you did this, you’d be able to connect to the web app on the private server from any host on the Internet, using the public server’s hostname and port (eg curl public.example.com:2000). The web app on the private server would see the original source IP address, and would send replies (as well as all its other traffic) through its WireGuard connection to the public server.

Default Route With Exceptions

If you know ahead of time that the private server’s internal traffic is limited to just a few subnets, you can let WireGuard take over the private server’s default route, and add a few static routes for your internal traffic as exceptions. This is the inverse of the Static Routes option described above — instead of enumerating the IP addresses or ranges you always want to send through the WireGuard tunnel, you enumerate the IP addresses or ranges you never want to send through the WireGuard tunnel.

This is perfect for the example scenario, where the database and message queue used by the private server are on the 10.11.12.0/24 subnet, and SSH access will only come from the 192.168.99.0/24 subnet. In this case, you’d set the private server’s WireGuard config to use an AllowedIPs value of 0.0.0.0/0, and add explicit routes on the private server for these internal networks — which you can also do via its WireGuard config file:

# /etc/wireguard/wg0.conf

# local settings for the private server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1

# static route for database and message queue
PreUp = ip route add 10.11.12.0/24 via 192.168.1.1 dev eth0
PostDown = ip route del 10.11.12.0/24 via 192.168.1.1 dev eth0
# static route for SSH access
PreUp = ip route add 192.168.99.0/24 via 192.168.1.1 dev eth0
PostDown = ip route del 192.168.99.0/24 via 192.168.1.1 dev eth0

# 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

Make sure you use the private server’s actual default gateway and network interface instead of 192.168.1.1 and eth0. You can determine the server’s default gateway by running the ip route command — the line beginning with default shows the default gateway:

$ ip route
default via 192.168.1.1 dev eth0 proto dhcp src 192.168.1.11 metric 100
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.11 metric 100

If you use this approach, you’ll be able to connect to the web app on the private server from any host on the Internet, using the public server’s hostname and port (eg curl public.example.com:2000). The web app on the private server will see the original source IP address of all Internet traffic, but will still be able to correctly send replies back through the public server.

Policy Routing

The final option is to use policy routing. It’s also a really good fit for the example scenario. Usually, the way you use policy routing with WireGuard is by combining three things:

  1. Add a Table setting to the [Interface] section of your WireGuard config. This directs wq-quick to add routes to a custom table instead of your main routing table.

  2. Add your custom policy rules via the ip rule add command to direct selected traffic to use the custom table.

  3. Set AllowedIPs = 0.0.0.0/0 for one of the peers in your WireGuard config. This will ensure that any traffic directed to use the custom routing table (and not matching the AllowedIPs of some other peer) will be sent to that peer.

To return all traffic on the private server that originally entered through its WireGuard interface back through that same interface, use this for the private server’s WireGuard config:

# /etc/wireguard/wg0.conf

# local settings for the private server
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1
Table = 123

PreUp = ip rule add from 10.0.0.1 table 123 priority 456
PostDown = ip rule del from 10.0.0.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

The above policy rule works because return traffic will use the same IP address for the source of its return packets as the original packets used for their destination address. The destination address of the packets forwarded from the public server to the private server were all translated to 10.0.0.1 by the public server; so reply packets generated by the private server will use 10.0.0.1 as their source address. The above policy rule matches these packets, and ensures they are routed via the custom 123 routing table; and wg-quick sets up the default route of this table to use the WireGuard interface.

Tip

To reference a custom routing table by name instead of by number, add an entry for it to your /etc/iproute2/rt_tables file. For example, you could define foo as the name for the custom table above by adding a 123 foo entry for it:

$ echo '123 foo' | sudo tee -a /etc/iproute2/rt_tables

You can then reference table 123 in your WireGuard config as foo instead of 123:

Table = foo
PreUp = ip rule add from 10.0.0.1 table foo priority 456
PostDown = ip rule del from 10.0.0.1 table foo priority 456

With this approach, you’ll be able to connect to the web app on the private server from any host on the Internet, using the public server’s hostname and port (eg curl public.example.com:2000). The web app on the private server will see the original source IP address, but will also be able to correctly send replies back through the public server. All the private server’s other traffic, including traffic originating on the private server itself, will continue to use the server’s main routing table, unaffected by your WireGuard configuration.