Safe port forwarding with iptables and WireGuard
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.1This works pretty well most of the time.
However, there are several issues here:
- 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
tmuxsession. - 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 ACCEPTNow 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:
- No terminal session to keep around.
- 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
sshcommand 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.:
- computer A is using WiFi with WPA2 for communication with B, while the attacker is within WiFi radio range and they captured the 4-way handshake between computer A and WiFi router (they don’t need to be connected to that WiFi network);
- a router (either the edge router or any router/switch on the path between computers A and B) is compromised by the attacker.
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.pubWe’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 wg0On 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 wg0That will:
- create a new network interface
wg0managed by WireGuard using the private keys stored in/tmp/wireguard.priv; - set the IP addresses as planned (
192.168.2.1for B,192.168.2.2for A); - inform each of them which real address (
endpoint) to find the other one at; - authorise each of them to use their virtual addresses
(
allowed-ips), when using given key to communicate with the other; - 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 ACCEPTNow on A you can ssh to C by:
ssh -p 8888 192.168.2.1If 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.