---
title: Demystifying WireGuard and iptables
date: 2023-08-09
sn_id: 221471
---

# introduction

In this blog post, I will show you how to setup [WireGuard](https://wireguard.com) and
configure your Linux firewall with [`iptables`](https://wiki.archlinux.org/title/iptables).

We will establish a point-to-point connection between two machines across the internet.
One machine ("the local machine") sits behind a [NAT](https://en.wikipedia.org/wiki/Network_address_translation) 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.

[^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.

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](https://wiki.archlinux.org/title/iptables#Tables), 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.

[^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.

The order of the chains is defined in this (simplified) flow chart taken from [here](https://wiki.archlinux.org/title/iptables#Basic_concepts):

![iptables_flowchart.png](./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](https://github.com/torvalds/linux/blob/master/net/netfilter/nf_conntrack_ftp.c)
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.<br />
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](https://man.archlinux.org/man/iptables.8.en)
or ask a question in the [comments](https://stacker.news/items/221471).

---

# wireguard

## installation

Follow the instructions [here](https://www.wireguard.com/install) to install WireGuard on your local and remote machine.<br />
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`](https://man.archlinux.org/man/wg.8#pubkey),
you can generate a key pair with secure file permissions (handled by [`umask`](https://wiki.archlinux.org/title/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]

[^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](https://wiki.archlinux.org/title/umask), `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.

_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 networks[^4]:

* 10.0.0.0/8
* 172.16.0.0/12
* 192.168.0.0/16

[^4]: See the [wikipedia article](https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses) about private networks.

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/24[^5]:

```
$ 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
```

[^5]:  If you are confused by the /24 notation, you can read about CIDR [here](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing).

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.<br />
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.


[^6]: You could use any other path, but _/etc/wireguard/_ is searched automatically by `wg-quick` so our commands can be kept short.

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
```

```diff
- -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:

```diff
  -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
```

```diff
  -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](./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
```

```diff
  -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
```

```diff
  -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
```

```diff
  -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
```

```diff
  -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:

```diff
  -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.

```diff
- -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:

```diff
  -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:

```diff
- -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:

```diff
- -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:

```diff
  -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]](#ft-7):

```diff
  -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 ... :

```diff
  -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:

```diff
  -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 ... :

```diff
- -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:

```diff
  -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:

```diff
  -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:_

```diff
  -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:_

```diff
  -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](/index.xml).


Also, I would highly appreciate any feedback in the [comments](https://stacker.news/items/221471).
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!
