introduction

In this blog post, I will show you how to setup WireGuard and configure your Linux firewall with iptables.

We will establish a point-to-point connection between two machines across the internet. One machine (“the local machine”) sits behind a NAT router and therefore is not reachable from the public internet. The other machine (“the remote machine”) has a static public IPv4 address and sits in a data center (for example, if you rent a server from cloud providers). This is the simplest network topology and therefore useful to get the basics down first.

In following blog articles, I will show you how to create more advanced network topologies which include VPN gateways and port forwarding.1 VPN gateways connect multiple devices together and port forwarding is usually used to expose internal services to the internet.

It is important to me that you get a good understanding of iptables and how to use it from this post since I think there is a lack of good guides about it. I believe it is way more helpful to explain fundamentals well compared to just handing out instructions to follow. With a good understanding, you will be able to help yourself a lot better in case you run into problems.


iptables primer

iptables is a command line utility for configuring Linux kernel firewalls.

It acts upon tables which consist of chains which in turn consist of rules and a policy. The rules have criteria for packets and targets like ACCEPT or DROP. These targets are basically actions which are executed if a packet matches. For example, a rule could check if a packet comes from a specific IP address range and use the ACCEPT target to accept it and let it pass through the firewall. Chain policies are used as fallback targets in case no rule matches.

Each table has a specific purpose. For example, the default table (which is used if you don’t specify a table in a command) is the filter table which contains an INPUT, FORWARD and OUTPUT chain and is used to accept or drop IP packets. This is the only table we will need in this post. There is also the nat table (which we will use for port forwarding in a future blog post) and three others, but they are very specialized and thus not needed in the vast majority of use cases.

During the lifetime of a packet inside the Linux firewall, the chains of tables are consulted to decide the fate of a packet. The chains traverse through their rules in order until they find a matching rule whose target terminates the chain.2 If no matching rule was found, then the chain’s policy target is used.

The order of the chains is defined in this (simplified) flow chart taken from here:

iptables_flowchart.png

Packets that come in on any network interface enter this flow chart at the top and thus go first through the PREROUTING chain of the nat table. However, not all packets originate from outside a network interface or reach the bottom of this flow chart. Packets for local processes stop at [local process] and packets which are generated by local processes enter this flow chart at [local process].

The routing decision after the PREROUTING chain involves deciding if the final destination of the packet is the local machine (in which case the packet traverses to the INPUT chain at the left) or another machine in case we act as a router (in which case the packet traverses to the FORWARD chain at the right).

The routing decision after [local process] involves assigning a source address, which outgoing interface to use and other necessary information. Since the packet may have changed inside the nat table which could affect routing, there is a final routing decision just before the POSTROUTING chain to consider these changes.

With iptables -S (short for iptables --list-rules), we can lookup the current firewall configuration for a table. You can specify a table like this: iptables -t nat -S. For example, the filter table of a host inside a home network could look like this (as mentioned, if you don’t specify a table, the filter table is used):

$ iptables -S
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT DROP
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -j LOG --log-prefix "[INPUT:DROP] " --log-level 6
-A OUTPUT -p udp -m udp --sport 67:68 --dport 67:68 -m comment --comment DHCP -j ACCEPT
-A OUTPUT -p udp -m udp --dport 53 -m comment --comment DNS -j ACCEPT
-A OUTPUT -p udp -m udp --sport 123 --dport 123 -m comment --comment NTP -j ACCEPT
-A OUTPUT -p tcp -m tcp --dport 80 -m comment --comment HTTP -j ACCEPT
-A OUTPUT -p tcp -m tcp --dport 443 -m comment --comment HTTPS -j ACCEPT
-A OUTPUT -p tcp -m tcp --dport 22 -m comment --comment SSH -j ACCEPT
-A OUTPUT -j LOG --log-prefix "[OUTPUT:DROP] " --log-level 6

The first three statements show that the chain policy for the INPUT, FORWARD and OUTPUT chains is to drop packets. This means that any packet not matched by any (terminating) rule will be dropped.

The OUTPUT chain contains rules to allow outgoing packets for various common application layer protocols like DHCP, DNS, NTP, HTTP, HTTPS and SSH. The INPUT chain contains a rule which only allows packets for related or established connections. This makes the firewall stateful.

Additionally, packets are logged before they are dropped by the chain policies. This uses the LOG target which is a non-terminating target. Logging packets is very useful for debugging or can be used before applying new firewall rules to make sure you don’t lock yourself out accidentally.

A packet is considered to be part of an established connection if it is part of a response to outgoing or incoming packets. For example, if you’re browsing a website, the incoming packets carrying the requested web page content would be considered as part of an established connection. This makes the firewall stateful since it needs to keep an internal state of outgoing and incoming packets.

A connection is considered related when it is related to another established connection. A good example of related connections are data connections in FTP since FTP creates new connections for data transfers instead of reusing the current established connection to the server. Kernel modules like this implement connection tracking for individual protocols such that related connections can be found by the firewall.

The syntax in the output of iptables -S is the same syntax you would use to configure the firewall. This means that we can set the INPUT chain policy to ACCEPT with

$ iptables -P INPUT ACCEPT

whereas with

$ iptables -A INPUT -p tcp --dport 3000 -j ACCEPT

we could append a rule to the INPUT chain to open port 3000 for TCP packets.

Other useful commands are

$ iptables -D <chain> <rulenum>

to delete rules or

$ iptables -R <chain> <rulenum> <rule-specification>

to replace rules with a rule number and a rule specification like

-p <proto> --dport <destination port> -j <target>.

You can look up rule numbers with --line-numbers:

$ iptables -S --line-numbers

These were all commands we will use in this blog post.

If anything is still unclear, don’t hesitate to refer to the manual or ask a question in the comments.


wireguard

installation

Follow the instructions here to install WireGuard on your local and remote machine. When you are done, you should be able to run the following command:

$ wg --version
wireguard-tools v1.0.20210914 - https://git.zx2c4.com/wireguard-tools/

configuration

WireGuard is a peer-to-peer (P2P) protocol like Bitcoin. This means that by default, the protocol does not distinguish between servers and clients. To create VPN gateways or any other network topology, you will have to configure your peers accordingly. In this blog post, we will only connect two peers together so we can keep the configuration simple.

key generation

WireGuard uses asymmetric cryptography for its encryption. Therefore, you need to generate a key pair using the commands genkey and pubkey on your local and remote machine. As mentioned in man wg, you can generate a key pair with secure file permissions (handled by umask) like this:

$ umask 077
$ wg genkey | tee /etc/wireguard/wg_private.key | wg pubkey > /etc/wireguard/wg_public.key

This will generate a private key at /etc/wireguard/wg_private.key and a public key at /etc/wireguard/wg_public.key which are only readable and writeable by the current user.3

local machine keys:

$ cat /etc/wireguard/wg_private.key
l0c4l+s3cR37+RDr+dJdgX/ACeRQLANiduQRJK9O23A=
$ cat /etc/wireguard/wg_public.key
/wH4OzafBUJVvRGzK8itUweV/GpwoUzn7OS99lr7gHI=

remote machine keys:

$ cat /etc/wireguard/wg_private.key
r3M073+s3cR37+fouaQZbP5QqfgwypHjKGBNmztxNEc=
$ cat /etc/wireguard/wg_public.key
GL33DRrI8/2yAT6+r5mTtBLd7CoErAAsio3yNqQ3K1M=

ip range selection

You need to decide which IP range you want to use for your virtual private network (VPN). This will be the IP range from which you will assign IP addresses to hosts inside the VPN. The important part is to not pick an IP range which is already in use. Fortunately, the IPv4 specification reserved following IP ranges for use in private networks4:

  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16

IP addresses in these ranges are not routable in the public internet since they are ignored by all public routers. For example, my local area network (LAN) uses 192.168.178.0/245:

$ ip address
... other output ...
2: enp3s0: &ltBROADCAST,MULTICAST,UP,LOWER_UP&gt mtu 1500 qdisc fq_codel state UP group default qlen 1000
  link/ether 9c:6b:00:06:a7:54 brd ff:ff:ff:ff:ff:ff
  inet 192.168.178.146/24 brd 192.168.178.255 scope global dynamic noprefixroute enp3s0
  valid_lft 804365sec preferred_lft 804365sec
  inet6 fe80::6de5:ba8f:c52b:52bd/64 scope link noprefixroute
  valid_lft forever preferred_lft forever

If you are already part of other private networks (company or university VPN for example), you can check the IP ranges they use by connecting and running ip address afterwards as above. In this blog post, we will assume that 10.172.16.0/24 is still free to use and thus can be selected for our VPN.

peer configuration

We can configure our peers via the file /etc/wireguard/wg0.conf.6 As with the keys, it makes sense to run umask 077 before creating the files. The files will then be created with read and write access only given to the current user.

For every peer, we need to define the interface by specifying the private key and IP address. For the remote machine, we also need to set ListenPort to specify on which port the machine should listen for incoming WireGuard UDP packets. We don’t set it for the local machine since we don’t need a fixed port. We only need a fixed port if peers need to know the port in advance to initiate connections. However, the local machine is not reachable from the internet so it is not possible to initiate connections to it. Therefore, we rely on the local machine initiating connections. WireGuard will pick a random free port when the interface is brought up.

We also need to define the peers of every peer in the configuration. This is done by adding a peer section which starts with [Peer] per peer. Since we only have one peer per peer, there will only be a single [Peer] section per configuration. We need to set the public key of every peer such that WireGuard can use this public key to encrypt the packets. The peers can then decrypt the packets using their private key. We also need to set which IP addresses we want to route to each peer via AllowedIPs. In our case here, this will just be the IP address of each peer. When we setup a VPN gateway, this will be more interesting since there, we will route packets through multiple peers.

For the local machine, we will also set Endpoint to the public IP address of the remote machine and port as used in ListenPort. This lets WireGuard know how to reach the peer to establish a VPN connection.

To keep the connection alive, we will also use PersistentKeepalive in the local machine configuration. This specifies the interval in seconds in which keep-alive packets are sent. Without this, stateful firewalls may kill the VPN connection after some time since WireGuard is not a chatty protocol by itself. Additionally, our local machine is behind NAT which is another reason to use PersistentKeepalive to keep NAT mappings valid.

local machine configuration:

[Interface]
Address = 10.172.16.2/32
PrivateKey = l0c4l+s3cR37+RDr+dJdgX/ACeRQLANiduQRJK9O23A=

[Peer]
AllowedIPs = 10.172.16.1/32
PublicKey = GL33DRrI8/2yAT6+r5mTtBLd7CoErAAsio3yNqQ3K1M=
Endpoint = 139.162.153.133:51913
PersistentKeepalive = 25

remote machine configuration:

[Interface]
Address = 10.172.16.1/32
PrivateKey = r3M073+s3cR37+fouaQZbP5QqfgwypHjKGBNmztxNEc=
ListenPort = 51913

[Peer]
AllowedIPs = 10.172.16.2/32
PublicKey = /wH4OzafBUJVvRGzK8itUweV/GpwoUzn7OS99lr7gHI=

As a last step, make sure that the file permissions are correctly set and the owner of all created files is root:

$ ls -l /etc/wireguard
-rw------- 1 root root 1155 Aug 03 15:38 wg0.conf
-rw------- 1 root root 45   Aug 03 15:31 wg_private.key
-rw-r--r-- 1 root root 45   Aug 03 15:31 wg_public.key

If the public key is readable by other users, that’s fine. If there is something wrong with your file permissions, run these commands:

$ chown root:root -R /etc/wireguard
$ chmod 600 -R /etc/wireguard

interface control

We are now done with all WireGuard configuration. Run this on the local and remote machine to bring the interfaces up:

$ wg-quick up wg0

If you use systemd, you can run wg-quick up wg0 on boot using a systemd service:

$ systemctl enable wg-quick@wg0

To take the interface down, run this:

$ wg-quick down wg0

To see the configuration and peer information of WireGuard interfaces, run wg:

$ wg
interface: wg0
public key: /wH4OzafBUJVvRGzK8itUweV/GpwoUzn7OS99lr7gHI=
private key: (hidden)
listening port: 60646

peer: GL33DRrI8/2yAT6+r5mTtBLd7CoErAAsio3yNqQ3K1M=
endpoint: 139.162.153.133:51913
allowed ips: 10.172.16.1/32
transfer: 0 B received, 444 B sent
persistent keepalive: every 25 seconds

As you can see in the line beginning with transfer, we did not receive any packets yet. This is because we did not properly configure our firewalls yet.


firewall configuration with iptables

initial configuration

We are starting with the following minimal set of firewall rules for the local machine:

(local) $ iptables -S
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT DROP
-A INPUT -m state --state ESTABLISHED -j ACCEPT
-A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT

and remote machine:

(remote) $ iptables -S
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT DROP
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
-A OUTPUT -m state --state ESTABLISHED -j ACCEPT

You should be able to see that these firewall rules only allow SSH access from the local machine to the remote machine with IP address 139.162.153.133. Since WireGuard uses UDP, it therefore makes sense that we currently don’t have a VPN connection. We can check if we have a VPN connection with wg (check for latest handshake or received bytes) or by trying to ping one machine from the other. Therefore, we run

(local) $ ping 10.172.16.1

at the local machine and

(remote) $ ping 10.172.16.2

at the remote machine.

We need to run both commands since it is not guaranteed that the other direction also works if one machine can reach the other as you will later see. We will also keep these commands running until the end so we can immediately see if a VPN connection is up or was lost.

To fully understand which rules are required and why, we will configure the firewall in four steps:

  1. Configure local OUTPUT chain with remote INPUT chain policy set to ACCEPT
  2. Keep VPN connection up with remote INPUT chain policy switched back to DROP
  3. Configure remote OUTPUT chain with local INPUT chain policy set to ACCEPT
  4. Keep VPN connection up with local INPUT chain policy switched back to DROP

By first setting the INPUT chain policy to ACCEPT in the receiving machine, we can focus on a single machine at every step since we know that only the OUTPUT rules can currently be responsible for any connection failure.

local OUTPUT chain configuration

As mentioned, we will set the INPUT chain policy of the remote filter table to ACCEPT first:

(remote) $ iptables -P INPUT ACCEPT
- -P INPUT DROP
+ -P INPUT ACCEPT
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT

We know that ping uses ICMP packets so we need to allow ICMP in our local firewall:

(local) $ iptables -A OUTPUT -p icmp -j ACCEPT

We also know that WireGuard uses UDP. This mean we need to also allow outgoing UDP packets:

(local) $ iptables -A OUTPUT -p udp -j ACCEPT

We have made these changes locally now:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
+ -A OUTPUT -p icmp -j ACCEPT
+ -A OUTPUT -p udp -j ACCEPT

This is sufficient for a ping from the local to the remote machine:

(local) $ ping 10.172.16.1
PING 10.172.16.1 (10.172.16.1) 56(84) bytes of data.
64 bytes from 10.172.16.1: icmp_seq=18 ttl=64 time=9.28 ms
64 bytes from 10.172.16.1: icmp_seq=19 ttl=64 time=8.88 ms
64 bytes from 10.172.16.1: icmp_seq=20 ttl=64 time=9.25 ms

The current rules are very broad however. This is bad for security. We will fix this now.

However, if we limit UDP packets to only the wg0 interface, the ping stops working:

(local) $ iptables -R OUTPUT 3 -o wg0 -p udp -j ACCEPT
  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -p icmp -j ACCEPT
- -A OUTPUT -p udp -j ACCEPT
+ -A OUTPUT -o wg0 -p udp -j ACCEPT

This is because wg0 is the virtual network interface, not the actual physical network interface that sends the UDP packets. WireGuard works by wrapping all packets (like ICMP here) in UDP packets before sending them out “over the wire”. The following chart should make more clear what this means:

wireguard_layering.png

If we use the physical network interface (which is enp3s0 for the local machine as can be seen in ip address), the ping works again:

(local) $ iptables -R OUTPUT 3 -o enp3s0 -p udp -j ACCEPT
  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -p icmp -j ACCEPT
- -A OUTPUT -o wg0 -p udp -j ACCEPT
+ -A OUTPUT -o enp3s0 -p udp -j ACCEPT

We can also limit the UDP packets to port 51913 of our remote machine:

(local) $ iptables -R OUTPUT 3 -o enp3s0 -p udp -d 139.162.153.133 --dport 51913 -j ACCEPT
  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -p icmp -j ACCEPT
- -A OUTPUT -o enp3s0 -p udp -j ACCEPT
+ -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

What would not work is to limit the UDP packets using internal IPs since the physical network interface is unaware of our VPN:

(local) $ iptables -R OUTPUT 3 -o enp3s0 -p udp -d 10.172.16.1 -j ACCEPT
  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -p icmp -j ACCEPT
- -A OUTPUT -o enp3s0 -p udp -j ACCEPT
+ -A OUTPUT -d 10.172.16.1/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

To confirm our understanding, we can limit the ICMP packets to only the wg0 interface. The ping should continue to work:

(local) $ iptables -R OUTPUT 2 -o wg0 -p icmp -j ACCEPT
  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
- -A OUTPUT -p icmp -j ACCEPT
+ -A OUTPUT -o wg0 -p icmp -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

And it indeed does:

64 bytes from 10.172.16.1: icmp_seq=50 ttl=64 time=9.05 ms
64 bytes from 10.172.16.1: icmp_seq=51 ttl=64 time=8.78 ms
64 bytes from 10.172.16.1: icmp_seq=52 ttl=64 time=9.12 ms

Usually, all traffic is allowed inside a VPN. Therefore, this rule is commonly used:

(local) $ iptables -R OUTPUT 2 -o wg0 -j ACCEPT

Done. The changes we applied to the local firewall configuration are:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
+ -A OUTPUT -o wg0 -j ACCEPT
+ -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

switch remote INPUT chain policy back to DROP

We will set the remote INPUT chain policy back to DROP now.

- -P INPUT ACCEPT
+ -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT

The ping stopped working but we know that the local OUTPUT chain is properly configured.

After allowing inbound ICMP and UDP packets, the ping from the local machine to the remote machine works again:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
+ -A INPUT -p icmp -j ACCEPT
+ -A INPUT -p udp -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT

We will limit the UDP packets to only port 51913 and the physical network interface. The physical network interface of the remote machine is eth0:

(remote) $ iptables -R INPUT 3 -i eth0 -p udp --dport 51913 -j ACCEPT

To actually enable all VPN traffic from the local to the remote machine, we also need to allow it on the remote machine:

(remote) $ iptables -R INPUT 2 -i wg0 -j ACCEPT

Done. We applied the following changes to the remote firewall:

- -P INPUT ACCEPT
+ -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
+ -A INPUT -i wg0 -j ACCEPT
+ -A INPUT -i eth0 -p udp -m udp --dport 51913 -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT

remote OUTPUT chain configuration

We will take care of pinging the local machine from the remote machine now. As you can see, having a connection from one direction does not mean that the other direction works, too (even though response packets arrive).

To focus on the OUTPUT chain configuration, we will set the local INPUT chain policy to ACCEPT:

- -P INPUT DROP
+ -P INPUT ACCEPT
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -o wg0 -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

And start with allowing outgoing ICMP packets:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A INPUT -i wg0 -j ACCEPT
  -A INPUT -i eth0 -p udp -m udp --dport 51913 -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT
+ -A OUTPUT -p icmp -j ACCEPT

However, this time, we notice that the ping already works even without allowing UDP packets:

(remote) $ ping 10.172.16.2
PING 10.172.16.2 (10.172.16.2) 56(84) bytes of data.
64 bytes from 10.172.16.2: icmp_seq=8 ttl=64 time=9.16 ms
64 bytes from 10.172.16.2: icmp_seq=9 ttl=64 time=8.74 ms
64 bytes from 10.172.16.2: icmp_seq=10 ttl=64 time=8.95 ms

The explanation is that the UDP packets are able to use an established connection. Only allowing TCP packets kills the connection [7]:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A INPUT -i wg0 -j ACCEPT
  -A INPUT -i eth0 -p udp -m udp --dport 51913 -j ACCEPT
- -A OUTPUT -m state --state ESTABLISHED -j ACCEPT
+ -A OUTPUT -p tcp -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -p icmp -j ACCEPT

We could allow UDP packets through a separate rule … :

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A INPUT -i wg0 -j ACCEPT
  -A INPUT -i eth0 -p udp -m udp --dport 51913 -j ACCEPT
  -A OUTPUT -p tcp -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -p icmp -j ACCEPT
+ -A OUTPUT -p udp -j ACCEPT

… but since we don’t have a specific IP address for limiting the traffic, we will revert back to the previous stateful rule and also allow any traffic from the virtual network interface:

(remote) $ iptables -R OUTPUT 1 -m state --state ESTABLISHED -j ACCEPT
(remote) $ iptables -D OUTPUT 3
(remote) $ iptables -R OUTPUT 2 -o wg0 -j ACCEPT

Done. We effectively only added a single rule to the remote firewall:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A INPUT -i wg0 -j ACCEPT
  -A INPUT -i eth0 -p udp -m udp --dport 51913 -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT
+ -A OUTPUT -o wg0 -j ACCEPT

switch local INPUT policy back to DROP

We have a bidirectional connection now. The only thing left to do is to revert back to a local INPUT chain policy of DROP and keep the connection up.

When going back to DROP as the INPUT chain policy … :

- -P INPUT ACCEPT
+ -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -o wg0 -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

… we notice that the ping continues to work. This is because of the first INPUT rule:

-A INPUT -m state --state ESTABLISHED -j ACCEPT

If we kill the connection and then run ping again, it no longer works:

64 bytes from 10.172.16.2: icmp_seq=93 ttl=64 time=9.16 ms
64 bytes from 10.172.16.2: icmp_seq=94 ttl=64 time=9.06 ms
64 bytes from 10.172.16.2: icmp_seq=95 ttl=64 time=8.83 ms
64 bytes from 10.172.16.2: icmp_seq=96 ttl=64 time=9.15 ms
^C
--- 10.172.16.2 ping statistics ---
96 packets transmitted, 96 received, 0% packet loss, time 95139ms
rtt min/avg/max/mdev = 8.602/9.114/9.606/0.209 ms
(remote) $ ping 10.172.16.2
PING 10.172.16.2 (10.172.16.2) 56(84) bytes of data.

This is expected since it only worked because of an established connection.

After allowing ICMP packets, the ping also works immediately again:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
+ -A INPUT -p icmp -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -o wg0 -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

This is again because the UDP packets can still use the established VPN connection. This is similar to what happened while configuring the remote OUTPUT chain.

But again, the proper configuration would be to allow all traffic into the wg0 interface but limit incoming UDP packets with a source IP address and port filter:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
  -A INPUT -p icmp -j ACCEPT
- -A INPUT -p icmp -j ACCEPT
+ -A INPUT -i wg0 -j ACCEPT
+ -A INPUT -s 139.162.153.133/32 -i enp3s0 -p udp -m udp --sport 51913 -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
  -A OUTPUT -o wg0 -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

final configuration

local firewall configuration:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -m state --state ESTABLISHED -j ACCEPT
+ -A INPUT -i wg0 -j ACCEPT
+ -A INPUT -s 139.162.153.133 -i enp3s0 -p udp -m udp --sport 51913 -j ACCEPT
  -A OUTPUT -d 139.162.153.133/32 -p tcp -m tcp --dport 22 -j ACCEPT
+ -A OUTPUT -o wg0 -j ACCEPT
+ -A OUTPUT -d 139.162.153.133/32 -o enp3s0 -p udp -m udp --dport 51913 -j ACCEPT

remote firewall configuration:

  -P INPUT DROP
  -P FORWARD DROP
  -P OUTPUT DROP
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
+ -A INPUT -i wg0 -j ACCEPT
+ -A INPUT -i eth0 -p udp -m udp --dport 51913 -j ACCEPT
  -A OUTPUT -m state --state ESTABLISHED -j ACCEPT
+ -A OUTPUT -o wg0 -j ACCEPT

Thanks for reading my first blog post! If you want to read more content like this, please consider subscribing via RSS.

Also, I would highly appreciate any feedback in the comments. You can tell me if it was too long, too boring, too complicated or anything else, that’s no problem! I am very new to this whole blogging thing and thus could really really need any kind of feedback. I’ll even pay you 100 sats!


  1. Originally, I wanted to make a blog post how to use WireGuard and port forwarding to expose your bitcoin node at home to the internet with a static public IPv4 address. This avoids that inbound connections drop when your ISP changes your public IPv4 address. However, I realized that I want to be thorough with explaining the basics first and not skip anything just to get to the port forwarding part faster. ↩︎

  2. Some targets don’t terminate the chain. For example, targets can redirect to another user-defined chain and then return or just log a packet. ↩︎

  3. If you are confused by the mask 077 like me since it looks like it gives everyone full access except to yourself: as mentioned here, umask uses the logical complement of the permission bits. This means that any bit set via umask will not be set in the file permissions. ↩︎

  4. See the wikipedia article about private networks. ↩︎

  5. If you are confused by the /24 notation, you can read about CIDR here↩︎

  6. You could use any other path, but /etc/wireguard/ is searched automatically by wg-quick so our commands can be kept short. ↩︎