yakubin’s notes

Safe port forwarding with iptables and WireGuard

port forwarding network topology

You have a problem: there is a computer (C) you want to log into remotely from your computer (A), but C is behind a firewall, a NAT or in a private network, not directly exposed to the broader Internet. There is another computer (B), which can connect to C and is accessible from the Internet. So you use ssh to connect to B and establish an SSH tunnel to C:

ssh -L 8888:<IP-ADDRESS-OF-C>:22 <IP-ADDRESS-OF-B>

In another terminal you connect to C by connecting to the local port created by the SSH tunnel:

ssh -p 8888 127.0.0.1

This works pretty well most of the time.

However, there are several issues here:

  1. You need to keep a terminal session open as long as you want to maintain the SSH tunnel, be it in a real terminal or a tmux session.
  2. If the SSH tunnel ever drops, because your ISP has an outage for a couple minutes/hours, or due to any other network issues, you need to reestablish the SSH tunnel, instead of caring only about your connection to computer C.

The problem is that we’re trying to use TCP for tunneling, and TCP is a connection-oriented protocol, which forces us to maintain the connection for the tunnel and reestablish it after downtime. What we need instead is a connectionless protocol, like IP or UDP.

Port forwarding with iptables

Let’s see how we can solve this problem at the routing layer. On computer B as root:

# Enable forwarding of IP packets.
echo 1 > /proc/sys/net/ipv4/ip_forward

# Redirect packets incoming at port 8888 to <IP-ADDRESS-OF-C> at port 22.
iptables -t nat -A PREROUTING -p tcp --dport 8888 -j DNAT --to-destination <IP-ADDRESS-OF-C>:22

# For outgoing TCP packets going to destination port 22, change the source IP
# address to the IP address of this computer (B), and the source port to a
# free port (let's call it P) on this computer.
#
# For incoming TCP packets going to destination IP address of B, and the
# destination port P, change destination IP address to the original source IP
# address A (before the change to B) and destination port to the original
# source port (before the change to P).
iptables -t nat -A POSTROUTING -p tcp --dport 22 -j MASQUERADE

# The following is needed when Docker is installed. Otherwise iptables will
# drop all forwarded packets.
iptables -A FORWARD -j ACCEPT

Now you can ssh to C from A with:

ssh -p 8888 <IP-ADDRESS-OF-B>

This solution has a caveat: you need root access on the intermediate machine (B). But when you have it, it works beautifully:

  1. No terminal session to keep around.
  2. When there is a network outage, or you lose connectivity to computer B for whatever reason, after things go back to normal, you don’t need to go through any ceremony to establish the forwarding again. You just reconnect to C with the same ssh command as if nothing ever happened.

There is one risk here though: a malevolent party can now try to attack computer C via traffic going to it on port 22, masking itself by sending packets to computer B on port 8888 instead, thereby disguising the traffic going to computer C as something sent by computer B.

You can limit the risk by dropping all packets coming to B at port 8888, which do not originate from a selected trusted IP address (in this case computer A). Attacker will still be able to send packets to computer C by spoofing the IP address in packets sent to computer B, but they will not be able to receive responses to those packets (since those will be sent to the trusted IP address of computer A), unless they can intercept traffic going between computers A and B, because e.g.:

Without the ability to receive packets from C, the attacker is severely handicapped in their ability to compromise C; but they can still prevent computer A from connecting to C, if fail2ban, or anything else of the sort, is enabled on computer C.

When we got rid of SSH, we lost the much needed authentication. If you have total control over what is happening on the network used for communication between A and B, then everything’s fine. You may use iptables as is without sweating over those risks. If you’d like something more secure though, read on.

How can we add authentication at the routing layer? There’s been some hype around WireGuard for a while now. It allows you to set up authenticated, encrypted UDP-backed tunnels for IP packets.

Authenticating forwarded packets with WireGuard

First, we need to generate the private and public keys on computers A and B:

cd /tmp
umask 077
wg genkey | tee wireguard.priv | wg pubkey > wireguard.pub

We’ll create a virtual WireGuard network, where B has the IP address 192.168.2.1 and A has the IP address 192.168.2.2. Let’s say that B’s public key (/tmp/wireguard.pub) is:

eKykV2v7ryzmr/n+Ok+J8XPsEKAZYqhTQTh4yK/4eTg=

and A’s public key is:

uqGxfggNgE41Q/o9muAiqr4YwD0lIgourMlT31xVkig=

Then on B we should run the following as root:

ip link add dev wg0 type wireguard
ip address add dev wg0 192.168.2.1 peer 192.168.2.2
wg set wg0 listen-port 51820 private-key /tmp/wireguard.priv peer uqGxfggNgE41Q/o9muAiqr4YwD0lIgourMlT31xVkig= allowed-ips 192.168.2.2 endpoint <IP-ADDRESS-OF-A>:8172
ip link set up wg0

On A:

ip link add dev wg0 type wireguard
ip address add dev wg0 192.168.2.2 peer 192.168.2.1
wg set wg0 listen-port 8172 private-key /tmp/wireguard.priv peer eKykV2v7ryzmr/n+Ok+J8XPsEKAZYqhTQTh4yK/4eTg= allowed-ips 192.168.2.1 endpoint <IP-ADDRESS-OF-B>:51820
ip link set up wg0

That will:

  1. create a new network interface wg0 managed by WireGuard using the private keys stored in /tmp/wireguard.priv;
  2. set the IP addresses as planned (192.168.2.1 for B, 192.168.2.2 for A);
  3. inform each of them which real address (endpoint) to find the other one at;
  4. authorise each of them to use their virtual addresses (allowed-ips), when using given key to communicate with the other;
  5. set WireGuard to use the 51820 UDP port on B and the 8172 UDP port on A for the tunnel.

At that point, querying the state of the wg0 network interface gives some weird results. It’s not UP, nor DOWN; it’s just UNKNOWN:

$ ip -br l | grep wg0
wg0              UNKNOWN        <POINTOPOINT,NOARP,UP,LOWER_UP>

Nonetheless, it works and we can ping A from B and vice versa using their virtual addresses (192.168.2.1 and 192.168.2.2).

Now let’s modify the iptables commands so that only the packets coming from the tunnel, or the ones coming from C from TCP port 22 (ssh) are forwarded by B. On B:

# The PREROUTING rule, redirecting packets addressed for this computer(B) port
# 8888 to C port 22, is modified to apply only when they come from the wg0
# network interface (the WireGuard tunnel). Otherwise, no change.
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A PREROUTING -p tcp -i wg0 --dport 8888 -j DNAT --to-destination <IP-ADDRESS-OF-C>:22
iptables -t nat -A POSTROUTING -p tcp --dport 22 -j MASQUERADE

# By default, drop forwarded packets (i.e. those which are not addressed for
# this computer).
iptables -P FORWARD DROP

# But forward (accept) packets which come from the wg0 network interface
# (the WireGuard tunnel).
iptables -A FORWARD -i wg0 -j ACCEPT

# Also forward TCP packets coming from C port 22 which are in an
# already-established TCP connection (connection was started from the other
# side, and those are the responses).
iptables -A FORWARD -p tcp -s <IP-ADDRESS-OF-C> --sport 22 -m state --state ESTABLISHED -j ACCEPT

Now on A you can ssh to C by:

ssh -p 8888 192.168.2.1

If network is out, you just repeat that same command when it’s up again. And if someone wants to attack C, they’ll have to find another way than through port forwarding done by B.