How to Use WireGuard With Firewalld

Among the firewall options for Linux, firewalld is a good balance between the simplicity of UFW and the complexity of iptables. Firewalld is a zone-based firewall: it classifies each connection as belonging to a specific zone, like external, internal, and so on, usually based on the network interface on which the connection was received, or the connection’s source IP address. Firewalld then handles the connection according to the configuration of that zone; for example, to accept the connection if it’s to a specific destination port, or to reject it otherwise.

This article will show you how to use firewalld with WireGuard for each of the primary WireGuard topologies:

Also see the notes about Ping and Troubleshooting at the end of the article.

Point to Point

Let’s take the simple two host, point-to-point WireGuard VPN (Virtual Private Network) described in the WireGuard Point to Point Configuration guide, and set up firewalld for it (replacing the iptables firewall described in the “extra” sections of the guide).

Here’s a network diagram of the scenario:

Point to Point VPN
Figure 1. Point to point scenario

On Endpoint A, which in this example is just a simple tablet computer, we’ll set up firewalld to disallow all new connections to Endpoint A, except to the UDP port on which WireGuard itself is listening (51821). On Endpoint B, which in this example is running a web server on TCP port 80, we’ll set up firewalld to disallow new connections except for two cases: 1) allow any connection to the UDP port on which WireGuard itself is listening (51822), and 2) allow connections tunneled through WireGuard to TCP port 80.

On each endpoint, we’ll set up two new firewalld zones: a mywg zone for the endpoint’s WireGuard interface, and a mysite zone for the endpoint’s local Ethernet interface (we’ll also optionally set up a third zone, myadmin, for admin SSH access). If you prefer, however, instead of creating new zones, you can use existing zones for these interfaces on one or both hosts, like work for one interface and home for the other, or internal for one interface and public for the other. In particular, if you’re already using a zone for a host’s local Ethernet interface, you can just continue using that zone for it (and simply substitute that zone’s name for mysite whenever you see a reference to mysite).

If you’ve already added some iptables commands to the WireGuard config on your hosts, shut down their WireGuard interfaces (sudo wg-quick down wg0), remove those commands, and start them back up again (sudo wg-quick up wg0). We won’t add anything extra to the WireGuard configuration files in this article — we’ll just use the firewall-cmd command-line tool for everything. Firewalld will persist our settings via files in the /etc/firewalld directory, and automatically set up the firewall when the host boots up.

Firewalld Configuration on Endpoint A

On Endpoint A, first check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

Create a new mysite zone to manage Endpoint A’s local Ethernet interface, and a new mywg zone for its WireGuard interface. If you’re also going to SSH into this host for administration, also create a special myadmin zone for that (if not, you can skip it):

$ sudo firewall-cmd --permanent --new-zone=myadmin
success
$ sudo firewall-cmd --permanent --new-zone=mysite
success
$ sudo firewall-cmd --permanent --new-zone=mywg
success
$ sudo firewall-cmd --reload
success

Note that the --reload command clears any non-permanent changes you have made, so use the --runtime-to-permanent command first if you have any temporary firewalld runtime changes you want to save before reloading.

myadmin Zone on Endpoint A

If you’re connected to Endpoint A through SSH, the first thing you should do is set up a zone (myadmin in this example) to allow the continued use of that connection from your local workstation (if you’re accessing Endpoint A physically, however, skip it). Run the following command on Endpoint A to see the IP address of the host from which you’re SSH’d in:

$ ss -tn | grep :22
ESTAB  0        0           192.168.1.11:22       192.168.1.2:33312

In this case, 192.168.1.11 is the IP address of Endpoint A on its own LAN (running SSH on TCP port 22); 192.168.1.2 is the host from which I’ve SSH’d. So run the following commands to continue to allow this connection once we start up the firewall zone for the local Ethernet interface:

$ sudo firewall-cmd --zone=myadmin --add-service=ssh
success
$ sudo firewall-cmd --zone=myadmin --add-source=192.168.1.2
success

If you want to allow any host on the LAN to be able to SSH into Endpoint A (not limited to just the host from which you’ve currently SSH’d), instead of 192.168.1.2 you could specify 192.168.1.0/24 (a range that includes 192.168.1.0-192.168.1.255) for the above.

The myadmin zone should now be active and filtering all connections from your SSH client (192.168.1.2). You can check this with the following command:

$ sudo firewall-cmd --info-zone=myadmin
myadmin (active)
  target: default
  icmp-block-inversion: no
  interfaces:
  sources: 192.168.1.2
  services: ssh
  ports:
  protocols:
  forward: no
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

mysite Zone on Endpoint A

Now we’ll set up the main zone for Endpoint A’s local Ethernet interface. For this zone (mysite), configure firewalld to allow access to your WireGuard port (51821 in this example):

$ sudo firewall-cmd --zone=mysite --add-port=51821/udp
success

If you set up a special zone for administration, like the myadmin zone above, do the same thing for that zone too:

$ sudo firewall-cmd --zone=myadmin --add-port=51821/udp
success

Now bind Endpoint A’s local Ethernet interface to the mysite zone. If you don’t know what that interface name is, run the following command on Endpoint A to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.1.11/24
wg0              UNKNOWN        10.0.0.1/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to mysite:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone mysite

If you’re not using NetworkManager, run the following command to bind Endpoint A’s local Ethernet interface to the mysite zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=mysite --add-interface=eth0
success

At this point, the mysite zone should be active and filtering connections to the eth0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mysite
mysite (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services:
  ports: 51821/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

mywg Zone on Endpoint A

Now we’ll set up the mywg zone. Since we don’t want to accept any new inbound connections through the WireGuard interface on Endpoint A (only allow connections Endpoint A itself has initiated through WireGuard), we don’t need to do anything other than just bind the zone to the WireGuard interface. If you’re using NetworkManager to manage the WireGuard interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone mywg

If you’re not using NetworkManager for WireGuard, run the following command to bind Endpoint A’s WireGuard interface to the mywg zone:

$ sudo firewall-cmd --zone=mywg --add-interface=wg0
success

Now the mywg zone should be active and filtering connections to the wg0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mywg
mywg (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And now the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
myadmin
  sources: 192.168.1.2
mysite
  interfaces: eth0
mywg
  interfaces: wg0

With this setup, firewalld will block any connections to Endpoint A other than through WireGuard (or directly through SSH from 192.168.1.2). Additionally, firewalld will block any new inbound connections to Endpoint A even when accessed through WireGuard — all connections through the WireGuard tunnel must be initiated by Endpoint A (for example, if a web server was running on Endpoint A at TCP port 80, no other host would be able to connect inbound to it, even through WireGuard; but Endpoint A would still be able to connect outbound through WireGuard to the web server running on Endpoint B).

Firewalld Configuration on Endpoint B

On Endpoint B, we’ll do the same thing — except that we’ll also add an additional setting to Endpoint B’s mywg zone to allow access through WireGuard to the HTTP server running on Endpoint B. First, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

So create a new mysite zone to manage Endpoint B’s local Ethernet interface, and a new mywg zone for its WireGuard interface. If you’re also going to SSH into Endpoint B for administration, also create a special myadmin zone for that (if not, you can skip it):

$ sudo firewall-cmd --permanent --new-zone=myadmin
success
$ sudo firewall-cmd --permanent --new-zone=mysite
success
$ sudo firewall-cmd --permanent --new-zone=mywg
success
$ sudo firewall-cmd --reload
success

myadmin Zone on Endpoint B

If you’re connected to Endpoint B through SSH, set up the myadmin zone to allow the continued use of that connection from your local workstation (but if you’re accessing Endpoint B physically, skip it). Run the following command on Endpoint B to see the IP address of the host from which you’re SSH’d in:

$ ss -tn | grep :22
ESTAB  0        0           192.168.200.22:22       198.51.100.1:59193

In this case I’ve SSH’d into Endpoint B (192.168.200.22) remotely from 198.51.100.1. So run the following command to continue to allow this connection once we start up the firewall zone for the local Ethernet interface:

$ sudo firewall-cmd --zone=myadmin --add-service=ssh
success
$ sudo firewall-cmd --zone=myadmin --add-source=198.51.100.1
success

The myadmin zone should now be active and filtering all connections from your SSH client (198.51.100.1). You can check this with the following command:

$ sudo firewall-cmd --info-zone=myadmin
myadmin (active)
  target: default
  icmp-block-inversion: no
  interfaces:
  sources: 198.51.100.1
  services: ssh
  ports:
  protocols:
  forward: no
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

mysite Zone on Endpoint B

Now we’ll set up the main zone for Endpoint B’s local Ethernet interface. For this zone (mysite), allow access to your WireGuard port (51822 in this example):

$ sudo firewall-cmd --zone=mysite --add-port=51822/udp
success

If you set up a special zone for administration, like the myadmin zone above, do the same thing for that zone too:

$ sudo firewall-cmd --zone=myadmin --add-port=51822/udp
success

Now bind Endpoint B’s local Ethernet interface to the mysite zone. If you don’t know what that interface name is, run the following command on Endpoint B to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.200.22/24
wg0              UNKNOWN        10.0.0.2/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone mysite

If you’re not using NetworkManager, run this command to bind Endpoint B’s local Ethernet interface to the mysite zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=mysite --add-interface=eth0
success

At this point, the mysite zone should be active and filtering connections to the eth0 interface of Endpoint B. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mysite
mysite (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services:
  ports: 51822/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

mywg Zone on Endpoint B

Now we’ll set up the mywg zone. With Endpoint B, we want to allow other hosts to initiate new connections to the HTTP server running on Endpoint B through WireGuard. So run the following command to enable this access:

$ sudo firewall-cmd --zone=mywg --add-service=http
success

Now we just need to bind the zone to the WireGuard interface. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone mywg

If you’re not using NetworkManager, run the following command to bind Endpoint B’s WireGuard interface to the mywg zone:

$ sudo firewall-cmd --zone=mywg --add-interface=wg0
success

Now the mywg zone should be active and filtering connections to the wg0 interface of Endpoint B. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mywg
mywg (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services: http
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
myadmin
  sources: 198.51.100.1
mysite
  interfaces: eth0
mywg
  interfaces: wg0

Firewalld is now up and running on Endpoint B, and will block any connections to Endpoint B other than through WireGuard (or directly through SSH from 198.51.100.1). Additionally, firewalld will block any new inbound connections to Endpoint B even through WireGuard, except to the web server running on Endpoint B.

Test It Out

You can test this out by trying to access Endpoint B’s HTTP server from Endpoint A:

$ curl 10.0.0.2
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

You should see the HTML of Endpoint B’s homepage printed. If you start up another web server running on Endpoint B on some other port, like say TCP port 8080 (run python3 -m http.server 8080 for a temporary server serving the contents of the current directory), you won’t be able to access it from Endpoint A (or anywhere else):

$ curl 10.0.0.2:8080
curl: (7) Failed to connect to 10.0.0.2 port 8080: No route to host

Once everything’s working the way you like, run the following command on each host to save your current firewalld configuration:

$ sudo firewall-cmd --runtime-to-permanent
success

If you don’t run this command, your temporary runtime configuration will be lost the next time you run the --reload command, or reboot.

On Endpoint A, this is what your zone configuration files will look like when saved:

<!-- Endpoint A /etc/firewalld/zones/myadmin.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <service name="ssh"/>
  <port port="51821" protocol="udp"/>
  <source address="192.168.1.2"/>
</zone>
<!-- Endpoint A /etc/firewalld/zones/mysite.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <port port="51821" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Endpoint A /etc/firewalld/zones/mywg.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <interface name="wg0"/>
</zone>

And on Endpoint B, this is what they’ll look like:

<!-- Endpoint B /etc/firewalld/zones/myadmin.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <service name="ssh"/>
  <port port="51822" protocol="udp"/>
  <source address="198.51.100.1"/>
</zone>
<!-- Endpoint B /etc/firewalld/zones/mysite.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <port port="51822" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Endpoint B /etc/firewalld/zones/mywg.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <service name="http"/>
  <interface name="wg0"/>
</zone>

Note that if you’re using NetworkManager to manage some of these interfaces, the firewalld zone configuration files won’t include <interface> entries for those interfaces (the mapping between zone and interface will be stored in NetworkManager’s own config files).

Hub and Spoke

To demonstrate firewalld with a hub-and-spoke topology, we’ll use the hub-and-spoke WireGuard VPN described in the WireGuard Hub and Spoke Configuration guide. Here’s a network diagram of the scenario:

Hub and Spoke VPN
Figure 2. Hub and spoke scenario

On the “spokes” in this example scenario, Endpoint A and Endpoint B, we’ll configure firewalld to disallow all new connections to the endpoints except through WireGuard — but allow full access to anything that is connected through WireGuard. On the “hub”, Host C, we’ll configure firewalld to disallow all new connections to the hub except through WireGuard; and we’ll prevent the hub from forwarding any new connections through its WireGuard tunnels except to the web server listening at TCP port 80 on Endpoint B.

On the spokes, we’ll use the predefined trusted firewalld zone for each endpoint’s WireGuard interface, and the predefined public firewalld zone for each endpoint’s Ethernet interface. If you’re already using a different firewalld zone for the Ethernet interface on one or both of those endpoints (like home or work etc), you can continue to use that zone, and just replace public with that zone’s name whenever you see a reference to public.

On the hub, we’ll create a new firewalld zone, mywg, for the hub’s WireGuard interface, and use the predefined public firewalld zone for the hub’s public-facing Ethernet interface. If you’re already using an existing firewalld zone for that Ethernet interface, you can continue to use that zone, and just replace public with that zone’s name whenever you see a reference to public.

This setup allows us to use the hub as the central place to enforce access control throughout our WireGuard VPN. When we add more endpoints to our VPN, we can set them up with the same firewalld configuration as Endpoint A or B, and disallow access to or from them simply via firewalld configuration changes on Host C.

If you’ve already added some iptables commands to the WireGuard config on your hosts, shut down their WireGuard interfaces (sudo wg-quick down wg0), remove those commands, and start them back up again (sudo wg-quick up wg0). We won’t add anything extra to the WireGuard configuration files in this article — we’ll just use the firewall-cmd command-line tool for everything. Firewalld will persist our settings via files in the /etc/firewalld directory, and automatically set up the firewall when the host boots up.

Firewalld Configuration on Endpoint A

On Endpoint A, first check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

public Zone on Endpoint A

First we’ll set up the main zone for Endpoint A’s public-facing Ethernet interface. For this zone (public), configure firewalld to allow access to your WireGuard port (51821 in this example):

$ sudo firewall-cmd --zone=public --add-port=51821/udp
success

Now bind Endpoint A’s public-facing Ethernet interface to the public zone. If you don’t know what that interface name is, run the following command on Endpoint A to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.1.11/24
wg0              UNKNOWN        10.0.0.1/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Endpoint A’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  ports: 51821/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

trusted Zone on Endpoint A

Now we’ll configure firewalld to accept all WireGuard connections to Endpoint A, by binding Endpoint A’s WireGuard interface to firewalld’s predefined trusted zone. If you’re using NetworkManager to manage the WireGuard interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone trusted

If you’re not using NetworkManager for WireGuard, run the following command to bind Endpoint A’s WireGuard interface to the trusted zone:

$ sudo firewall-cmd --zone=trusted --add-interface=wg0
success

Now the trusted zone should be active and accepting all connections to the wg0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=trusted
trusted (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And now the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
trusted
  interfaces: wg0

With this setup, firewalld will block all connections to Endpoint A other than through WireGuard (or through the other services enabled in the public zone, like SSH); and it will allow all connections through WireGuard.

Firewalld Configuration on Endpoint B

We’ll do the exact same things for Endpoint B as we did for Endpoint A. First, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

public Zone on Endpoint B

Now we’ll set up the main zone for Endpoint B’s public-facing Ethernet interface. For this zone (public), configure firewalld to allow access to your WireGuard port (51822 in this example):

$ sudo firewall-cmd --zone=public --add-port=51822/udp
success

And bind Endpoint B’s public-facing Ethernet interface to the public zone. If you don’t know what that interface name is, run the following command on Endpoint B to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.200.22/24
wg0              UNKNOWN        10.0.0.2/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Endpoint B’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Endpoint B. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  ports: 51822/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

trusted Zone on Endpoint B

Now we’ll configure firewalld to accept all WireGuard connections to Endpoint B, by binding Endpoint B’s WireGuard interface to firewalld’s predefined trusted zone. If you’re using NetworkManager to manage the WireGuard interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone trusted

If you’re not using NetworkManager for WireGuard, run the following command to bind Endpoint B’s WireGuard interface to the trusted zone:

$ sudo firewall-cmd --zone=trusted --add-interface=wg0
success

Now the trusted zone should be active and accepting all connections to the wg0 interface of Endpoint B. You can check this with the following command:

$ sudo firewall-cmd --info-zone=trusted
trusted (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And now the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
trusted
  interfaces: wg0

Firewalld Configuration on Host C

On Host C, we’ll create a new custom mywg zone to manage the connections that go through our WireGuard VPN; and use the predefined public zone to manage access to Host C’s public-facing Ethernet interface. But first, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

mywg Zone on Host C

First we’ll set up the mywg zone. In this particular scenario, we want to enable Host C to forward specific connections from one WireGuard endpoint to another, and nothing else (including no direct access to Host C through WireGuard).

Because firewalld doesn’t have built-in support for forwarding connections destined for other hosts, we’ll have to use “direct” iptables rules to do the forwarding; and because on newer Linux distros, firewalld is typically configured to use the nftables backend, we’ll have to do some additional work to ensure that nftables doesn’t reject connections before our iptables rules can accept them.

So create the new mywg zone, and set its default target (what happens to connections not otherwise handled) to ACCEPT:

$ sudo firewall-cmd --permanent --new-zone=mywg
success
$ sudo firewall-cmd --permanent --zone=mywg --set-target=ACCEPT
success
$ sudo firewall-cmd --reload
success

Note that the --reload command clears any non-permanent changes you have made, so use the --runtime-to-permanent command first if you have any temporary firewalld runtime changes you want to save before reloading.

Since we’ve just configured the mywg zone to accept all connections by default, next we’ll configure it to reject any connections attempted directly to Host C itself:

$ sudo firewall-cmd --zone=mywg --add-rich-rule='rule family="ipv4" priority="30001" protocol value="tcp" reject'
success
$ sudo firewall-cmd --zone=mywg --add-rich-rule='rule family="ipv4" priority="30002" protocol value="udp" reject'
success
$ sudo firewall-cmd --zone=mywg --add-rich-rule='rule family="ipv6" priority="30003" protocol value="tcp" reject'
success
$ sudo firewall-cmd --zone=mywg --add-rich-rule='rule family="ipv6" priority="30004" protocol value="udp" reject'
success

By setting a high priority number on these rich rules, you can easily override them later with other firewalld configuration settings — for example, if you wanted to allow SSH connections directly to Host C over WireGuard, you could enable it with a command like sudo firewall-cmd --zone=mywg --service=ssh.

Next, we’ll use firewalld’s --direct command to set up four iptables rules:

$ sudo firewall-cmd --direct --add-rule ipv4 filter FORWARD 0 -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
success
$ sudo firewall-cmd --direct --add-rule ipv4 filter FORWARD 1 -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -j ACCEPT
success
$ sudo firewall-cmd --direct --add-rule ipv4 filter FORWARD 2 -i wg0 -j REJECT
success
$ sudo firewall-cmd --direct --add-rule ipv6 filter FORWARD 0 -i wg0 -j REJECT
success

The first rule allows Host C to forward already-established connections (like the response to an HTTP request) coming back through the WireGuard tunnel. The second rule allows new TCP connections (like a new HTTP request) to be forwarded to port 80 of Endpoint B (10.0.0.2). The third rule rejects any other IPv4 connections from being forwarded through WireGuard; and the fourth rule does the same, but for IPv6 connections.

Note that if we ran the following iptables command directly, we’d get the same result (the advantage of doing this through firewalld is that firewalld will run the commands automatically for us when it starts up):

iptables -t filter -I FORWARD 0 -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -t filter -I FORWARD 1 -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -j ACCEPT
iptables -t filter -I FORWARD 2 -i wg0 -j REJECT
ip6tables -t filter -I FORWARD 0 -i wg0 -j REJECT

Finally, bind the mywg zone to the WireGuard interface. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone mywg

If you’re not using NetworkManager, run the following command to bind Host C’s WireGuard interface to the mywg zone:

$ sudo firewall-cmd --zone=mywg --add-interface=wg0
success

Now the mywg zone should be active and filtering connections to the wg0 interface of Host C. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mywg
mywg (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
        rule priority="30001" family="ipv4" protocol value="tcp" reject
        rule priority="30002" family="ipv4" protocol value="udp" reject
        rule priority="30003" family="ipv6" protocol value="tcp" reject
        rule priority="30004" family="ipv6" protocol value="udp" reject

And you can check the direct iptables rules we added with the following command:

$ sudo firewall-cmd --direct --get-all-rules
ipv4 filter FORWARD 0 -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
ipv4 filter FORWARD 1 -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -j ACCEPT
ipv4 filter FORWARD 2 -i wg0 -j REJECT
ipv6 filter FORWARD 0 -i wg0 -j REJECT

public Zone on Host C

Now we’ll set up the zone for Host C’s public-facing Ethernet interface, public. For this zone, allow access to your WireGuard port (51823 in this example):

$ sudo firewall-cmd --zone=public --add-port=51823/udp
success

Next, bind Host C’s public-facing Ethernet interface to the public zone. If you don’t know what that interface name is, run the following command on Host C to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.30.33/24
wg0              UNKNOWN        10.0.0.3/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run this command to bind Host C’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Host C. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  ports: 51823/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
mywg
  interfaces: wg0
public
  interfaces: eth0

Firewalld is now up and running on Host C, and will block any connection to Host C other than through WireGuard (or through other services enabled in its public zone, like SSH). Additionally, Firewalld will prevent Host C from being used to forward new connections between any hosts in its WireGuard network, except new connections to TCP port 80 of the web server running on Endpoint B (10.0.0.2).

Test It Out

You can test this out by trying to access Endpoint B’s HTTP server from Endpoint A:

$ curl 10.0.0.2
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

You should see the HTML of Endpoint B’s homepage printed. If you start up another web server running on Endpoint B on some other port, like say TCP port 8080 (run python3 -m http.server 8080 for a temporary server serving the contents of the current directory), you won’t be able to access it from Endpoint A (or anywhere else):

$ curl 10.0.0.2:8080
curl: (7) Failed to connect to 10.0.0.2 port 8080: No route to host

Similarly, if you start up a web server on Endpoint A (eg python3 -m http.server 8080), you won’t be able to access it from Endpoint B:

$ curl 10.0.0.1:8080
curl: (7) Failed to connect to 10.0.0.1 port 8080: No route to host

Only if you add additional direct iptables rules on Host C (like sudo firewall-cmd --direct --add-rule ipv4 filter FORWARD 1 -i wg0 -m state --state NEW -d 10.0.0.1 -p tcp --dport 8080 -j ACCEPT) would you be able to access the web server on Endpoint A.

Once everything’s working the way you like, run the following command on each host to save your current firewalld configuration:

$ sudo firewall-cmd --runtime-to-permanent
success

If you don’t run this command, your temporary runtime configuration will be lost the next time you run the --reload command, or reboot.

On Endpoint A and Endpoint B, this is what your zone configuration files will look like when saved:

<!-- Endpoint A and B /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <!-- (port 51822 on Endpoint B) -->
  <port port="51821" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Endpoint A and B /etc/firewalld/zones/trusted.xml -->
<zone target="ACCEPT">
  <short>Trusted</short>
  <description>All network connections are accepted.</description>
  <interface name="wg0"/>
</zone>

And on Host C, this is what they’ll look like:

<!-- Host C /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <port port="51823" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Host C /etc/firewalld/zones/mywg.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone target="ACCEPT">
  <rule family="ipv4" priority="30001">
    <protocol value="tcp"/>
    <reject/>
  </rule>
  <rule family="ipv4" priority="30002">
    <protocol value="udp"/>
    <reject/>
  </rule>
  <rule family="ipv6" priority="30003">
    <protocol value="tcp"/>
    <reject/>
  </rule>
  <rule family="ipv6" priority="30004">
    <protocol value="udp"/>
    <reject/>
  </rule>
  <interface name="wg0"/>
</zone>
<!-- Host C /etc/firewalld/direct.xml -->
<?xml version="1.0" encoding="utf-8"?>
<direct>
  <rule ipv="ipv4" table="filter" chain="FORWARD" priority="0">-i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT</rule>
  <rule ipv="ipv4" table="filter" chain="FORWARD" priority="1">-i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -j ACCEPT</rule>
  <rule ipv="ipv4" table="filter" chain="FORWARD" priority="2">-i wg0 -j REJECT</rule>
  <rule ipv="ipv6" table="filter" chain="FORWARD" priority="0">-i wg0 -j REJECT</rule>
</direct>

Note that if you’re using NetworkManager to manage some of these interfaces, the firewalld zone configuration files won’t include <interface> entries for those interfaces (the mapping between zone and interface will be stored in NetworkManager’s own config files).

Point to Site

For a point-to-site example, we’ll use the point-to-site WireGuard VPN described in the WireGuard Point to Site Configuration guide. We’ll set up a firewall on the remote endpoint (the “point”, Endpoint A), on the local endpoint (Endpoint B, inside the “site”), and on the local WireGuard server (Host β, the host that allows the “point” to access the “site”). This will replace the iptables firewall described in the “extra” section of the original guide, and well as the routing configuration for Host β described in the guide’s “configure routing” section.

Here’s a network diagram of the scenario:

Point to Site VPN
Figure 3. Point to site scenario

On the remote endpoint (Endpoint A), we’ll set up firewalld to disallow all new connections. On the local endpoint (Endpoint B), we’ll set up firewalld to disallow all new connections except to Endpoint B’s TCP port 80 (where a web server is listening).

On the local site’s WireGuard server (Host β), we’ll set up firewalld to to disallow all new connections to the server except for WireGuard connections (as well as SSH for administration), and allow the forwarding of connections from the WireGuard VPN (Virtual Private Network) to the Site B LAN (Local Area Network). Host β will forward packets to the Site B LAN via IP masquerading (also known as SNAT, or Source Network Address Translation), which will also prevent hosts in the Site B LAN from initiating new connections to hosts in the WireGuard VPN (they’ll only be able to respond back to connections initiated from within the WireGuard VPN).

On Endpoint A, we’ll create a new firewalld zone, mywg, for Endpoint A’s WireGuard interface, and use the predefined public firewalld zone for Endpoint A’s Ethernet interface. On Endpoint B, we won’t have a WireGuard interface at all, but we’ll also use the predefined public firewalld zone for Endpoint B’s Ethernet interface.

On Host β, we’ll set up several new firewalld zones: a mywg zone for its WireGuard interface, a mypub zone for its public-facing Ethernet interface, a mysite zone for connections to the Site B LAN, and a myadmin zone for admin SSH access. If you’re already using existing zones for Host β’s Ethernet interfaces, you can continue using those zones, and simply substitute the appropriate zone’s name for any firewalld commands you see (for example, you could substitute public for mypub, internal for mysite, etc).

If you’ve already added some iptables commands to the WireGuard config on your hosts, shut down their WireGuard interfaces (sudo wg-quick down wg0), remove those commands, and start them back up again (sudo wg-quick up wg0). We won’t add anything extra to the WireGuard configuration files in this article — we’ll just use the firewall-cmd command-line tool for everything. Firewalld will persist our settings via files in the /etc/firewalld directory, and automatically set up the firewall when the host boots up.

Firewalld Configuration on Endpoint A

On Endpoint A, first check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

Create a new mywg zone for Endpoint A’s WireGuard interface:

$ sudo firewall-cmd --permanent --new-zone=mywg
success
$ sudo firewall-cmd --reload
success

Note that the --reload command clears any non-permanent changes you have made, so use the --runtime-to-permanent command first if you have any temporary firewalld runtime changes you want to save before reloading.

public Zone on Endpoint A

First we’ll set up the main zone for Endpoint A’s public-facing Ethernet interface. For this zone (public), configure firewalld to allow access to your WireGuard port (51821 in this example):

$ sudo firewall-cmd --zone=public --add-port=51821/udp
success

Now bind Endpoint A’s public-facing Ethernet interface to the public zone. If you don’t know what that interface name is, run the following command on Endpoint A to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.1.11/24
wg0              UNKNOWN        10.0.0.1/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Endpoint A’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  ports: 51821/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

mywg Zone on Endpoint A

Now we’ll set up the mywg zone. Since we don’t want to accept any new inbound connections through the WireGuard interface on Endpoint A (only allow connections Endpoint A itself has initiated through WireGuard), we don’t need to do anything other than just bind the zone to the WireGuard interface. If you’re using NetworkManager to manage the WireGuard interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone mywg

If you’re not using NetworkManager for WireGuard, run the following command to bind Endpoint A’s WireGuard interface to the mywg zone:

$ sudo firewall-cmd --zone=mywg --add-interface=wg0
success

Now the mywg zone should be active and filtering connections to the wg0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mywg
mywg (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And now the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
mywg
  interfaces: wg0

With this setup, firewalld will block all new inbound connections to Endpoint A (other than through the services enabled in the public zone, like SSH), but still allow new connections to be established outbound through its Ethernet and WireGuard interfaces.

Firewalld Configuration on Endpoint B

On Endpoint B, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

On Endpoint B, the only thing we need to do is set up a firewalld zone for Endpoint B’s public-facing Ethernet interface. We’ll use the predefined public zone for this, and configure firewalld to allow access to Endpoint B’s HTTP service from within the zone:

$ sudo firewall-cmd --zone=public --add-service=http
success

Then we’ll bind Endpoint B’s public-facing Ethernet interface to the zone. If you don’t know what that interface name is, run the following command on Endpoint B to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.200.22/24

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Endpoint B’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Endpoint B. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client http mdns ssh
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

Packet Forwarding and Masquerading on Host B

In the original WireGuard Point to Site Configuration guide, the “Configure Routing on Host β” section directs you to to add some lines to the WireGuard configuration of Host β to turn on IP forwarding and IP masquerading. But since we’re using firewalld, you can omit these lines from your WireGuard configuration — we’ll configure IP masquerading through firewalld’s mysite zone configuration on Host β; and when we enable IP masquerading, firewalld will also automatically enable IP forwarding.

Firewalld Configuration on Host B

On Host β, the WireGuard server for Site B, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

Create a new mypub zone for Host β’s public-facing Ethernet interface, a new mysite zone for its LAN, and a new mywg zone for its WireGuard interface. If you’re also going to SSH into Host β for administration, also create a special myadmin zone for that (if not, you can skip it):

$ sudo firewall-cmd --permanent --new-zone=myadmin
success
$ sudo firewall-cmd --permanent --new-zone=mypub
success
$ sudo firewall-cmd --permanent --new-zone=mysite
success
$ sudo firewall-cmd --permanent --new-zone=mywg
success
$ sudo firewall-cmd --reload
success

myadmin Zone on Host B

If you’re connected to Host β through SSH, set up the myadmin zone to allow the continued use of that connection from your local workstation (but if you’re accessing Host β physically, skip it). Run the following command on Host β to see the IP address of the host from which you’re SSH’d in:

$ ss -tn | grep :22
ESTAB  0        0           192.168.200.2:22       198.51.100.1:59193

In this case I’ve SSH’d into Host β (192.168.200.22) remotely from 198.51.100.1. So run the following command to continue to allow this connection once we start up the firewall zone for the public-facing Ethernet interface:

$ sudo firewall-cmd --zone=myadmin --add-service=ssh
success
$ sudo firewall-cmd --zone=myadmin --add-source=198.51.100.1
success

The myadmin zone should now be active and filtering all connections from your SSH client (198.51.100.1). You can check this with the following command:

$ sudo firewall-cmd --info-zone=myadmin
myadmin (active)
  target: default
  icmp-block-inversion: no
  interfaces:
  sources: 198.51.100.1
  services: ssh
  ports:
  protocols:
  forward: no
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

mypub Zone on Host B

Now we’ll set up the main zone for Host β’s public-facing Ethernet interface. For this zone (mypub), allow access to your WireGuard port (51822 in this example):

$ sudo firewall-cmd --zone=mypub --add-port=51822/udp
success

If you set up a special zone for administration, like the myadmin zone above, do the same thing for that zone too:

$ sudo firewall-cmd --zone=myadmin --add-port=51822/udp
success

Now bind Host β’s public-facing Ethernet interface to the mypub zone. If you don’t know what that interface name is, run the following command on Host β to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             203.0.113.2/32
eth1             UP             192.168.200.2/24
wg0              UNKNOWN        10.0.0.2/32

In this case, the public-facing Ethernet interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone mypub

If you’re not using NetworkManager, run the following command to bind Host β’s local Ethernet interface to the mypub zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=mypub --add-interface=eth0
success

At this point, the mypub zone should be active and filtering connections to the eth0 interface of Host β. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mypub
mysite (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services:
  ports: 51822/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

mysite Zone on Host B

Now we’ll set up the zone for Host β’s connection to the Site B LAN. For this zone (mysite), set up packet masquerading from our WireGuard VPN’s subnet (10.0.0.0/24):

$ sudo firewall-cmd --zone=mysite --add-rich-rule='rule family="ipv4" source address="10.0.0.0/24" masquerade'
success

Then associate the zone with the Site B LAN. If Host β doesn’t have a dedicated LAN interface (ie its public-facing Ethernet interface, which we bound to the mypub zone, is the same as its LAN interface), run the following command to bind the LAN’s subnet (in this example, 192.168.200.0/24) to the mysite zone:

$ sudo firewall-cmd --zone=mysite --add-source=192.168.200.0/24
success

Otherwise, if Host β does have a dedicated Ethernet interface to the Site B LAN, instead bind the interface itself to the zone. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth1' connection.zone mysite

If you’re not using NetworkManager, run the following command to bind Host β’s dedicated LAN interface to the mysite zone (replace eth1 with the actual name of the interface, if not eth1):

$ sudo firewall-cmd --zone=mysite --add-interface=eth1
success

At this point, the mysite zone should be active and filtering connections to the 192.168.200.0/24 subnet (or the eth1 interface) of Host β. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mysite
mysite (active)
  target: default
  icmp-block-inversion: no
  interfaces:
  sources: 192.168.200.0/24
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
        rule family="ipv4" source address="10.0.0.0/24" masquerade

mywg Zone on Host B

Now for the mywg zone. We just need to bind this zone to Host β’s WireGuard interface. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify wg0 connection.zone mywg

If you’re not using NetworkManager for WireGuard, run the following command to bind Host β’s WireGuard interface to the mywg zone:

$ sudo firewall-cmd --zone=mywg --add-interface=wg0
success

Now the mywg zone should be active and filtering connections to the wg0 interface of Host β. You can check this with the following command:

$ sudo firewall-cmd --info-zone=mywg
mywg (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

And the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
myadmin
  sources: 198.51.100.1
mypub
  interfaces: eth0
mysite
  sources: 192.168.200.0/24
mywg
  interfaces: wg0

Firewalld is now up and running on Host β, and will block any new inbound connections to Host β other than through WireGuard (or directly through SSH from 198.51.100.1); and it will forward connections from its WireGuard interface to the Site B LAN (192.168.200.0/24).

Test It Out

You can test this out by trying to access Endpoint B’s HTTP server from Endpoint A:

$ curl 192.168.200.22
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

You should see the HTML of Endpoint B’s homepage printed. If you start up another web server running on Endpoint B on some other port, like say TCP port 8080 (run python3 -m http.server 8080 for a temporary server serving the contents of the current directory), you won’t be able to access it from Endpoint A (or anywhere else):

$ curl 192.168.200.22:8080
curl: (7) Failed to connect to 10.0.0.2 port 8080: No route to host

Once everything’s working the way you like, run the following command on each host to save your current firewalld configuration:

$ sudo firewall-cmd --runtime-to-permanent
success

If you don’t run this command, your temporary runtime configuration will be lost the next time you run the --reload command, or reboot.

On Endpoint A, this is what your zone configuration files will look like when saved:

<!-- Endpoint A /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <port port="51821" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Endpoint A /etc/firewalld/zones/mywg.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <interface name="wg0"/>
</zone>

And on Endpoint B, this is what it’ll look like:

<!-- Endpoint B /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <service name="http"/>
  <interface name="eth0"/>
</zone>

And on Host β, this is what they’ll look like:

<!-- Host β /etc/firewalld/zones/myadmin.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <service name="ssh"/>
  <port port="51822" protocol="udp"/>
  <source address="198.51.100.1"/>
</zone>
<!-- Host β /etc/firewalld/zones/mypub.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <port port="51822" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Host β /etc/firewalld/zones/mysite.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <rule family="ipv4">
    <source address="10.0.0.0/24"/>
    <masquerade/>
  </rule>
  <!-- <interface name="eth1"/> if interface bound instead of subnet -->
  <source address="192.168.200.0/24"/>
</zone>
<!-- Host β /etc/firewalld/zones/mywg.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <interface name="wg0"/>
</zone>

Note that if you’re using NetworkManager to manage some of these interfaces, the firewalld zone configuration files won’t include <interface> entries for those interfaces (the mapping between zone and interface will be stored in NetworkManager’s own config files).

Extra: Port Forwarding on Host B

If you want the reverse of the above example, like the scenario described by the WireGuard Point to Site With Port Forwarding guide, which allows Endpoint B (in the local site) to connect to a web server on Endpoint A (the remote “point”), you can do that by adding a port forward to the mysite zone on Host β.

Run the following command on Host β to forward TCP port 80 of Host β to TCP port 80 of Endpoint A:

$ sudo firewall-cmd --zone=mysite --add-forward-port='port=80:proto=tcp:toaddr=10.0.0.1'
success

If you want to forward to a destination port that’s different than the source port, for example to forward TCP port 80 on Host β to TCP port 8080 on Endpoint A, add the toport parameter to the --add-forward-port option: port=80:proto=tcp:toport=8080:toaddr=10.0.0.1.

And if you don’t need Endpoint A (or any other host in your WireGuard VPN) to be able to initiate connections to Endpoint B (or any other host in your local site), then you don’t need to add the IP masquerading rule to the mysite zone described above (if you already added it, you can remove it by running the same command with which you added it, except replacing the --add-rich-rule option with one named --remove-rich-rule).

You can check out your changes by running the following command:

$ sudo firewall-cmd --info-zone=mysite
mysite (active)
  target: default
  icmp-block-inversion: no
  interfaces:
  sources: 192.168.200.0/24
  services:
  ports:
  protocols:
  masquerade: no
  forward-ports: port=80:proto=tcp:toaddr=10.0.0.1
  source-ports:
  icmp-blocks:
  rich rules:

And you can test it out by trying to access TCP port 80 on Host β from Endpoint B (Host β will now forward its TCP port 80 to Endpoint A):

$ curl 192.168.200.2
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

Once everything’s working the way you like, run the following command to save your current firewalld configuration:

$ sudo firewall-cmd --runtime-to-permanent
success

And this is what your mysite zone configuration file will now look like on Host β:

<!-- Host β /etc/firewalld/zones/mysite.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <!-- <interface name="eth1"/> if interface bound instead of subnet -->
  <source address="192.168.200.0/24"/>
  <forward-port port="80" protocol="tcp" to-addr="10.0.0.1"/>
</zone>

Site to Site

For a site-to-site example, we’ll use the site-to-site WireGuard VPN laid out in the WireGuard Site to Site Configuration guide. We’ll set up firewalld on our endpoints in both sites, as well as on the WireGuard hosts, replacing the iptables firewall described in the “extra” section of the guide.

Here’s a network diagram of the scenario:

Site to Site VPN
Figure 4. Site to site scenario

On Endpoint A we’ll set up firewalld to disallow all new inbound connections; and on Endpoint B we’ll set up firewalld to allow new connections only to Endpoint B’s web server listening on TCP port 80. On the two WireGuard servers (Host α and Host β), we’ll allow any host to connect to the servers via WireGuard, and we’ll also allow the forwarding of any connections between Site A and Site B.

Site A and Site B already have been configured to route to each other through the two WireGuard servers (as described in the site-to-site configuration guide), so we won’t set up their firewalld configuration to enable NAT (Network Address Translation) via masquerading or port forwarding as we did for Host β in the Point to Site scenario. Instead we’ll enable direct packet forwarding between each WierGuard server’s WireGuard interface and its LAN (Local Area Network) interface.

On Endpoint A and B we’ll use the predefined public firewalld zone for each endpoint’s connection to its own LAN. On the WireGuard hosts, we’ll use the predefined public firewalld zone for each host’s connection to the Internet, and use the predefined internal zone for each host’s connection to its own LAN and WireGuard interface.

If you’ve already added some iptables commands to the WireGuard config on your hosts, shut down their WireGuard interfaces (sudo wg-quick down wg0), remove those commands, and start them back up again (sudo wg-quick up wg0). We won’t add anything extra to the WireGuard configuration files in this article — we’ll just use the firewall-cmd command-line tool for everything. Firewalld will persist our settings via files in the /etc/firewalld directory, and automatically set up the firewall when the host boots up.

Firewalld Configuration on Endpoint A

On Endpoint A, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

The only thing we need to do on Endpoint A is set up a firewalld zone for Endpoint A’s local Ethernet interface. If you don’t know what that interface name is, run the following command on Endpoint A to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.1.11/24

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Endpoint A’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Endpoint A. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

So now firewalld is now up and running on Endpoint A, and will block any new inbound connections to Endpoint A (except through those additional services, like SSH). Endpoint A will still be able to make new outbound connections, however — like to initiate new connections to the web server running on Endpoint B.

Firewalld Configuration on Endpoint B

On Endpoint B, check what firewalld zones you have active. If you haven’t set up firewalld yet, you’ll see no output when you run the following command:

$ sudo firewall-cmd --get-active-zones

For Endpoint B, we need to set up a firewalld zone for Endpoint B’s local Ethernet interface, and allow access to its HTTP service through it. We’ll use the predefined public zone for this, and configure firewalld to allow access to Endpoint B’s HTTP service from within that zone with the following command:

$ sudo firewall-cmd --zone=public --add-service=http
success

Then we’ll bind Endpoint B’s public-facing Ethernet interface to the zone. If you don’t know what that interface name is, run the following command on Endpoint B to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             192.168.200.22/24

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Endpoint B’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Endpoint B. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client http mdns ssh
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

Firewalld Configuration on Host A

On Host α, first check your firewalld version with the following command:

$ sudo firewall-cmd --version
0.9.3

If you’re using firewalld version 0.9.0 or newer, forwarding connections between the LAN and the WireGuard VPN will be easier to set up. If you’re running an older version of firewalld, it will still be possible, but will require more work.

If your firewalld version is older than 0.9.0, run the following commands first before you make any other firewalld configuration changes:

$ sudo firewall-cmd --permanent --zone=internal --set-target=ACCEPT
success
$ sudo firewall-cmd --reload
success

Note that the --reload command clears any non-permanent changes you’ve already made to firewalld, so use the --runtime-to-permanent command first if you have some temporary firewalld runtime changes you want to save before reloading.

public Zone on Host A

Before we further configure the internal zone, we’ll first set up the main zone for Host α’s public-facing Ethernet interface. For this zone (public), configure firewalld to allow access to your WireGuard port (51821 in this example):

$ sudo firewall-cmd --zone=public --add-port=51821/udp
success

Now bind Host α’s public-facing Ethernet interface to the public zone. If you don’t know what that interface name is, run the following command on Host α to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             198.51.100.1/32
eth1             UP             192.168.1.1/24
wg0              UNKNOWN        10.0.0.1/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Host α’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Host α. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  ports: 51821/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

internal Zone on Host A

Now we’ll configure the predefined internal firewalld zone, which we’ll use for Host α’s connection to Site A, as well as to its WireGuard interface.

If your firewalld version is older than 0.9.0, and you’ve configured its internal zone to default to accepting all connections as directed above, run the following commands to explicitly reject any connections directed to Host α itself:

$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv4" priority="30001" protocol value="tcp" reject'
success
$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv4" priority="30002" protocol value="udp" reject'
success
$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv6" priority="30003" protocol value="tcp" reject'
success
$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv6" priority="30004" protocol value="udp" reject'
success

By setting a high priority number on these rich rules, all other firewalld configuration settings will override them — for example, the internal zone’s predefined configuration explicitly allows access to the SSH service, so these rich rules won’t block access to SSH.

If your firewalld version is 0.9.0 or newer, don’t add those rich rules; instead run the following command:

$ sudo firewall-cmd --zone=internal --add-forward
success

This command enables Host α to forward connections from one host to another within the internal zone, without allowing access to Host α itself (whereas the --set-target=ACCEPT command above allows both connection forwarding and direct access to Host α).

Regardless of your firewalld version, the next step is to associate the internal zone with the Site A LAN. If Host α doesn’t have a dedicated LAN interface (ie its public-facing Ethernet interface, which we bound to the public zone, is the same as its LAN interface), run the following command to bind the LAN’s subnet (in this example, 192.168.1.0/24) to the internal zone:

$ sudo firewall-cmd --zone=internal --add-source=192.168.1.0/24
success

Otherwise, if Host α does have a dedicated Ethernet interface to the Site A LAN, instead bind the interface itself to the zone. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth1' connection.zone internal

If you’re not using NetworkManager, run the following command to bind Host α’s dedicated LAN interface to the internal zone (replace eth1 with the actual name of the interface, if not eth1):

$ sudo firewall-cmd --zone=internal --add-interface=eth1
success

At this point, the internal zone should be active and filtering connections to WireGuard and the 192.168.1.0/24 subnet (or the eth1 interface) of Host α. You can check this with the following command:

$ sudo firewall-cmd --info-zone=internal
internal (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources: 192.168.1.0/24
  services: dhcpv6-client mdns samba-client ssh
  ports:
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

The above is what you should see with firewalld version 0.9.0 and newer. Below is what you should see with older versions:

$ sudo firewall-cmd --info-zone=internal
internal (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: wg0
  sources: 192.168.1.0/24
  services: dhcpv6-client mdns samba-client ssh
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
        rule priority="30001" family="ipv4" protocol value="tcp" reject
        rule priority="30002" family="ipv4" protocol value="udp" reject
        rule priority="30003" family="ipv6" protocol value="tcp" reject
        rule priority="30004" family="ipv6" protocol value="udp" reject

And the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
internal
  interfaces: wg0
  sources: 192.168.1.0/24

With this setup, firewalld will block all new public-facing connections to Host α (other than through the services enabled in the public zone, like SSH), as well as block internal connections to Host α itself (other than through the services enabled in the internal zone, like SSH), but forward connections freely between the Site A LAN and its WireGuard interface.

Firewalld Configuration on Host B

We’ll configure Host β just like Host α. On Host β, first check your firewalld version with the following command:

$ sudo firewall-cmd --version
0.9.3

If you’re using firewalld version 0.9.0 or newer, forwarding connections between the LAN and the WireGuard VPN will be easier to set up. If you’re running an older version of firewalld, it will still be possible, but will require more work.

If your firewalld version is older than 0.9.0, run the following commands first before you make any other firewalld configuration changes:

$ sudo firewall-cmd --permanent --zone=internal --set-target=ACCEPT
success
$ sudo firewall-cmd --reload
success

Note that the --reload command clears any non-permanent changes you’ve already made to firewalld, so use the --runtime-to-permanent command first if you have some temporary firewalld runtime changes you want to save before reloading.

public Zone on Host B

Before we further configure the internal zone, we’ll first set up the main zone for Host β’s public-facing Ethernet interface. For this zone (public), configure firewalld to allow access to your WireGuard port (51822 in this example):

$ sudo firewall-cmd --zone=public --add-port=51822/udp
success

Now bind Host β’s public-facing Ethernet interface to the public zone. If you don’t know what that interface name is, run the following command on Host β to show the list of interfaces:

$ ip -brief address show
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             203.0.113.2/32
eth1             UP             192.168.200.2/24
wg0              UNKNOWN        10.0.0.2/32

In this case, the interface name is eth0. If you’re using NetworkManager to manage this interface, use nmcli to bind eth0 to public:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth0' connection.zone public

If you’re not using NetworkManager, run the following command to bind Host β’s local Ethernet interface to the public zone (replace eth0 with the actual name of the interface, if not eth0):

$ sudo firewall-cmd --zone=public --add-interface=eth0
success

At this point, the public zone should be active and filtering connections to the eth0 interface of Host β. You can check this with the following command:

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: dhcpv6-client mdns ssh
  ports: 51822/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

Note that because we’re using the predefined public zone, it comes with some additional services already unblocked, like the DHCPv6 client, mDNS, and SSH.

internal Zone on Host B

Now we’ll configure the predefined internal firewalld zone, which we’ll use for Host β’s connection to Site B, as well as to its WireGuard interface.

If your firewalld version is older than 0.9.0, and you’ve configured its internal zone to default to accepting all connections as directed above, run the following commands to explicitly reject any connections directed to Host β itself:

$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv4" priority="30001" protocol value="tcp" reject'
success
$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv4" priority="30002" protocol value="udp" reject'
success
$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv6" priority="30003" protocol value="tcp" reject'
success
$ sudo firewall-cmd --zone=internal --add-rich-rule='rule family="ipv6" priority="30004" protocol value="udp" reject'
success

By setting a high priority number on these rich rules, all other firewalld configuration settings will override them — for example, the internal zone’s predefined configuration explicitly allows access to the SSH service, so these rich rules won’t block access to SSH.

If your firewalld version is 0.9.0 or newer, don’t add those rich rules; instead run the following command:

$ sudo firewall-cmd --zone=internal --add-forward
success

This command enables Host β to forward connections from one host to another within the internal zone, without allowing access to Host β itself (whereas the --set-target=ACCEPT command above allows both connection forwarding and direct access to Host β).

Regardless of your firewalld version, the next step is to associate the internal zone with the Site B LAN. If Host β doesn’t have a dedicated LAN interface (ie its public-facing Ethernet interface, which we bound to the public zone, is the same as its LAN interface), run the following command to bind the LAN’s subnet (in this example, 192.168.200.0/24) to the internal zone:

$ sudo firewall-cmd --zone=internal --add-source=192.168.200.0/24
success

Otherwise, if Host β does have a dedicated Ethernet interface to the Site B LAN, instead bind the interface itself to the zone. If you’re using NetworkManager to manage this interface, use nmcli:

$ nmcli connection show
NAME         UUID                                  TYPE       DEVICE
System eth0  5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03  ethernet   eth0
System eth1  e0f1664a-b130-4289-b1d7-df0409842090  ethernet   eth1
wg0          51eae011-0334-4bfc-a1e2-9a58159508ce  wireguard  wg0
$ sudo nmcli connection modify 'System eth1' connection.zone internal

If you’re not using NetworkManager, run the following command to bind Host β’s dedicated LAN interface to the internal zone (replace eth1 with the actual name of the interface, if not eth1):

$ sudo firewall-cmd --zone=internal --add-interface=eth1
success

At this point, the internal zone should be active and filtering connections to WireGuard and the 192.168.200.0/24 subnet (or the eth1 interface) of Host β. You can check this with the following command:

$ sudo firewall-cmd --info-zone=internal
internal (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources: 192.168.200.0/24
  services: dhcpv6-client mdns samba-client ssh
  ports:
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

The above is what you should see with firewalld version 0.9.0 and newer. Below is what you should see with older versions:

$ sudo firewall-cmd --info-zone=internal
internal (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: wg0
  sources: 192.168.200.0/24
  services: dhcpv6-client mdns samba-client ssh
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
        rule priority="30001" family="ipv4" protocol value="tcp" reject
        rule priority="30002" family="ipv4" protocol value="udp" reject
        rule priority="30003" family="ipv6" protocol value="tcp" reject
        rule priority="30004" family="ipv6" protocol value="udp" reject

And the --get-active-zones command should report the following:

$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
internal
  interfaces: wg0
  sources: 192.168.200.0/24

Firewalld is now up and running on Host β, and will block all new public-facing connections to Host β (other than through the services enabled in the public zone, like SSH), as well as block internal connections to Host β itself (other than through the services enabled in the internal zone, like SSH). It will, however, allow all new connections to be forwarded from Site A to Site B, and vice versa, through its WireGuard interface.

Test It Out

You can test this out by trying to access Endpoint B’s web server from Endpoint A:

$ curl 192.168.200.22
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...

You should see the HTML of Endpoint B’s homepage printed. If you start up another web server running on Endpoint B on some other port, like say TCP port 8080 (run python3 -m http.server 8080 for a temporary server serving the contents of the current directory), you won’t be able to access it from Endpoint A (or anywhere else):

$ curl 192.168.200.22:8080
curl: (7) Failed to connect to 10.0.0.2 port 8080: No route to host

Once everything’s working the way you like, run the following command on each host to save your current firewalld configuration:

$ sudo firewall-cmd --runtime-to-permanent
success

If you don’t run this command, your temporary runtime configuration will be lost the next time you run the --reload command, or reboot.

On Endpoint A, this is what your active zone configuration file will look like when saved:

<!-- Endpoint A /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <interface name="eth0"/>
</zone>

And on Endpoint B, this is what it’ll look like:

<!-- Endpoint B /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <service name="http"/>
  <interface name="eth0"/>
</zone>

And on Host α, this is what your active zone configuration files will look like for firewalld version 0.9.0 and newer:

<!-- Host α /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <port port="51821" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Host α /etc/firewalld/zones/internal.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Internal</short>
  <description>For use on internal networks. You mostly trust the other computers on the networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="samba-client"/>
  <service name="dhcpv6-client"/>
  <forward/>
  <!-- <interface name="eth1"/> if interface bound instead of subnet -->
  <source address="192.168.1.0/24"/>
  <interface name="wg0"/>
</zone>

And this is what they’ll look like for firewalld versions older than 0.9.0:

<!-- Host α /etc/firewalld/zones/public.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone>
  <short>Public</short>
  <description>For use in public areas. You do not trust the other computers on networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <port port="51821" protocol="udp"/>
  <interface name="eth0"/>
</zone>
<!-- Host α /etc/firewalld/zones/internal.xml -->
<?xml version="1.0" encoding="utf-8"?>
<zone target="ACCEPT">
  <short>Internal</short>
  <description>For use on internal networks. You mostly trust the other computers on the networks to not harm your computer. Only selected incoming connections are accepted.</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="samba-client"/>
  <service name="dhcpv6-client"/>
  <rule family="ipv4" priority="30001">
    <protocol value="tcp"/>
    <reject/>
  </rule>
  <rule family="ipv4" priority="30002">
    <protocol value="udp"/>
    <reject/>
  </rule>
  <rule family="ipv6" priority="30003">
    <protocol value="tcp"/>
    <reject/>
  </rule>
  <rule family="ipv6" priority="30004">
    <protocol value="udp"/>
    <reject/>
  </rule>
  <!-- <interface name="eth1"/> if interface bound instead of subnet -->
  <source address="192.168.1.0/24"/>
  <interface name="wg0"/>
</zone>

Host β’s configuration files will look similar to Host α’s, just with a different WireGuard port and LAN subnet.

Note that if you’re using NetworkManager to manage some of these interfaces, the firewalld zone configuration files won’t include <interface> entries for those interfaces (the mapping between zone and interface will be stored in NetworkManager’s own config files).

Ping

If you try pinging a host running firewalld (or any host where the connection to the host is forwarded through firewalld), you may be surprised to see your pings succeed even when firewalld is blocking the initiation of most other new connections. For example, with the Point to Point or Hub and Spoke scenarios above, you can run ping 10.0.0.1 on Endpoint B and see replies back from Endpoint A; or with the Site to Site scenario, you can run ping 192.168.1.11 on Endpoint B and see replies back from Endpoint A (and although you can’t ping Endpoint A from Endpoint B with the Point to Site scenario because of NAT, you could still ping Endpoint A from Host β).

This is because firewalld by default allows all ICMP packets through, including the types of ICMP packets used by ping (echo-request and echo-reply). So rather than using ping to check your firewall, try connecting to the specific ports that your firewall allows or disallows.

If you’re looking for a general tool to try to connect to a variety of ports, try Nmap. For example, to test if TCP port 8080 is accessible on 10.0.0.1, run this:

$ sudo nmap -Pn -n -sS --reason -p8080 10.0.0.1
...
PORT     STATE SERVICE    REASON
8080/tcp open  http-proxy syn-ack ttl 63
...

Or to test if UDP port 51821 is accessible on 192.168.1.11, run this:

$ sudo nmap -Pn -n -sU --reason -p51821 192.168.1.11
...
PORT      STATE    SERVICE REASON
51821/udp filtered unknown admin-prohibited ttl 63
...

If you want to override firewalld’s default settings and block ICMP packets, you can block a specific type (say the redirect type) for a specific zone, like this:

$ sudo firewall-cmd --zone=public --add-icmp-block=redirect
success

Or you can block all types for a specific zone, like this (after removing any individual ICMP types you had previously blocked):

$ sudo firewall-cmd --zone=public --add-icmp-block-inversion
success

The --add-icmp-block-inversion command inverts the purpose of a zone’s ICMP blocklist — instead of enumerating the blocked types, it enumerates the accepted types.

Troubleshooting

Kernel Logging

You can turn on some limited firewall packet logging via firewalld’s Log Denied setting. By default it is off; run this command to turn it on:

$ sudo firewall-cmd --set-log-denied=all
success

Note that this command clears any non-permanent (runtime) changes you have made, so use the --runtime-to-permanent command first if you have any temporary firewalld runtime changes you want to save before turning it on.

Turning this setting on will write a kernel packet-filter log entry to the kernel message facility for every packet that is blocked because the packet failed to match any firewalld-managed rule. You can view these messages via the dmesg command; or if your system is set up with journald, via the journalctl -k command; or if your system uses rsyslogd, these messages will often be logged to files named /var/log/kern.log or /var/log/messages.

Here’s an example log message that you’d see in the Point to Point scenario on Endpoint B if you tried to run curl 10.0.0.2:8080 from Endpoint A:

Jul 02 01:02:03 endpoint-b kernel: FINAL_REJECT: IN=eth0 OUT= MAC=00:22:48:77:30:e5:74:83:ef:31:5e:2f:08:00 SRC=10.0.0.1 DST=10.0.0.2 LEN=60 TOS=0x00 PREC=0x00 TTL=62 ID=10774 DF PROTO=TCP SPT=41342 DPT=8080 WINDOW=62727 RES=0x00 SYN URGP=0

This logging can help you identify when certain packets are reaching a host, but are being rejected because you haven’t configured firewalld to accept them (and it will also light up on any attempts to scan the host for open ports).

Firewalld Logging

The firewalld daemon itself also writes some logging to the /var/log/firewalld log file. These entries pertain to the loading/saving/adding/removing of firewall rules, however, and not the actual evaluation of those rules — so they won’t help you diagnose why a particular host or connection can or cannot get through your firewall. But these entries can help to explain the sometimes cryptic messages you may see as the result of firewall-cmd commands; for example, if you see an error message like this:

$ sudo firewall-cmd --runtime-to-permanent
Error: RT_TO_PERM_FAILED

Check the /var/log/firewalld log — you’ll often find a little more detail about the error:

$ sudo cat /var/log/firewalld
2021-07-02 01:02:03 ERROR: ZONE_CONFLICT: eth0
2021-07-02 01:02:03 WARNING: Runtime To Permanent failed on zone 'mypub': org.fedoraproject.FirewallD1.Exception: ZONE_CONFLICT: eth0
2021-07-02 01:02:03 ERROR: RT_TO_PERM_FAILED

Tcpdump

Tcpdump can be very helpful when troubleshooting firewall issues — you can use it generally to determine if packets are transiting in and out as expected from a given host. For example, with the Site to Site scenario, if you see no output when you try to connect from Endpoint A to Endpoint B (ie when running curl 192.168.200.22), try running the following command on each host on the path between Endpoint A and Endpoint B (in that scenario, Endpoint A, Host α, Host β, and Endpoint B):

$ sudo tcpdump -ni any 'tcp port 80 and host 192.168.200.22'

While those commands are running, try connecting from Endpoint A to Endpoint B again. You should see each terminal running tcpdump print a series of lines that look like the following:

22:12:16.156120 IP 192.168.1.11.36112 > 192.168.200.22.80: Flags [S], seq 767605079, win 62167, options [mss 8881,sackOK,TS val 1689421470 ecr 0,nop,wscale 6], length 0
22:12:16.157801 IP 192.168.200.22.80 > 192.168.1.11.36112: Flags [S.], seq 3979107600, ack 767605080, win 62083, options [mss 8881,sackOK,TS val 3921390190 ecr 1689421470,nop,wscale 6], length 0
22:12:16.158587 IP 192.168.1.11.36112 > 192.168.200.22.80: Flags [.], ack 1, win 972, options [nop,nop,TS val 1689421475 ecr 3921390190], length 0
22:12:16.158637 IP 192.168.1.11.36112 > 192.168.200.22.80: Flags [P.], seq 1:78, ack 1, win 972, options [nop,nop,TS val 1689421475 ecr 3921390190], length 77: HTTP: GET / HTTP/1.1
22:12:16.158755 IP 192.168.200.22.80 > 192.168.1.11.36112: Flags [.], ack 78, win 969, options [nop,nop,TS val 3921390191 ecr 1689421475], length 0

Lines with 192.168.1.11.36112 > 192.168.200.22.80 represent packets being sent to Endpoint B TCP port 80, and lines with 192.168.200.22.80 > 192.168.1.11.36112 represent packets being sent back from Endpoint B in response. If the tcpdump command on a host outputs only lines with the former, and none with the latter, it means only packets sent to Endpoint B are making it to the host — no packets on the return trip back from Endpoint B are. If the tcpdump command on a host doesn’t output anything, it means no packets are reaching the host, not even those on the first leg of the trip to Endpoint B.

Iptables

Run the following command to determine whether firewalld on a particular host is using the iptables backend, or the nftables backend:

$ sudo grep -i backend /etc/firewalld/firewalld.conf
# FirewallBackend
# Selects the firewall backend implementation.
FirewallBackend=iptables

If it’s using the iptables backend, you can run the following command to see the (very, very long) list of iptables rules that firewalld has set up:

$ sudo iptables-save
# Generated by iptables-save v1.8.4 on Sun Jul  2 01:02:03 2021
*nat
:PREROUTING ACCEPT [12:1234]
:INPUT ACCEPT [4:567]
:OUTPUT ACCEPT [567:8910]
:POSTROUTING ACCEPT [123:4567]
:OUTPUT_direct - [0:0]
:POSTROUTING_ZONES - [0:0]
:POSTROUTING_direct - [0:0]
:POST_public - [0:0]
:POST_public_allow - [0:0]
:POST_public_deny - [0:0]
:POST_public_log - [0:0]
:POST_public_post - [0:0]
:POST_public_pre - [0:0]
...

If you know a little about iptables, this will help give you an idea about how firewalld is implementing the firewall settings you’ve configured.

Nftables

Run the following command to determine whether firewalld on a particular host is using the iptables backend, or the nftables backend:

$ sudo grep -i backend /etc/firewalld/firewalld.conf
# FirewallBackend
# Selects the firewall backend implementation.
FirewallBackend=nftables

If it’s using the nftables backend, you can run the following command to see the (very, very long) list of nftables rules that firewalld has set up:

$ sudo nft list ruleset
table inet firewalld {
        ct helper helper-netbios-ns-udp {
                type "netbios-ns" protocol udp
                l3proto ip
        }

        chain raw_PREROUTING {
                type filter hook prerouting priority raw + 10; policy accept;
...

If you know a little about nftables, this will help give you an idea about how firewalld is implementing the firewall settings you’ve configured.