WireGuard End-to-End Encrypted Hub-and-Spoke

This article will show you how to set up a Hub and Spoke WireGuard VPN (Virtual Private Network) with end-to-end encryption (E2EE). In a normal hub-and-spoke configuration, the connection between the hub and each spoke is encrypted, but the connections between the spokes are not — the hub decrypts and then re-encrypts WireGuard traffic as it forwards it from spoke to spoke.

That’s fine if you trust the hub; but if not, you need end-to-end encryption. Fortunately, you can create a separate, E2EE WireGuard tunnel between each pair of spokes, nesting it inside the original hub-and-spoke tunnel. The diagram below illustrates this tunnel-in-tunnel approach:

WireGuard End-to-End Encrypted Hub and Spoke Diagram

In this example, we want Endpoint A, behind a NAT (Network Address Translation) router in Site A, to be able to access a private web app hosted by Endpoint B behind another NAT router in Site B. To do so, we first set up Endpoint A to connect over one WireGuard tunnel to Host C in a third site, Site C, with a public IP address of 192.0.2.3; and do the same for Endpoint B.

Within this first WireGuard VPN, Endpoint A has an IP address of 10.0.0.1, Endpoint B has an IP address of 10.0.0.2, and Host C has an IP address of 10.0.0.3. Through this VPN, Endpoint A can use 10.0.0.2 to connect to Endpoint B, and Endpoint B can respond to Endpoint A at 10.0.0.1.

Then we set up a second WireGuard VPN nested within the first, where Endpoint A has an IP address of 10.9.9.1, and Endpoint B has an IP address of 10.9.9.2. Even though the connection will go through Host C, Host C will not be part of this network; and Host C will not be able to access the plaintext data sent between Endpoint A and Endpoint B, as it is now end-to-end encrypted.

To set up this second WireGuard VPN, we’ll create a second WireGuard interface on Endpoint A and Endpoint B, which we’ll name wg1 on each (we used wg0 for the first VPN). To use this second, nested VPN, to connect to Endpoint B, Endpoint A will use the Endpoint B’s IP address within the second VPN, 10.9.9.2 (and Endpoint B will reply to Endpoint A’s IP address within the second VPN, 10.9.9.1).

These are the steps we’ll follow:

Set Up a Normal Hub and Spoke VPN

First, follow the Hub and Spoke Configuration Guide to set up a normal hub-and-spoke WireGuard VPN. But unlike the directions of the “extra” section in that guide, do not add any additional firewall rules restricting the forwarding of WireGuard connections from spoke to spoke (see the Extra: Configure Firewall on Host C section of this guide for instructions on a firewall for Host C that will work for this scenario).

Once you’re able to connect from Endpoint A to Endpoint B through that VPN (ie, you’re able to run curl 10.0.0.2 on Endpoint A to connect to the webserver on Endpoint B), we can start setting up the inner, E2EE VPN.

Calculate the Inner MTU

Each network interface available to a host, including WireGuard interfaces, needs to be configured with a MTU (Maximum Transmission Unit) setting, to let the host OS know how much data can be sent through the interface within a single Ethernet frame. For a physical Ethernet interface, this is usually 1500 bytes (but may differ depending on the parameters of the physical network to which it’s connected).

Because data sent through a WireGuard interface is wrapped by WireGuard with an additional UDP packet plus some WireGuard metadata, the MTU for a WireGuard interface should be slightly less than the MTU of the physical interface through which it will ultimately be sent. You can use this formula to calculate the MTU for a WireGuard interface:

(MTU of physical interface) - ((IP header size) + (UDP header size) + (WireGuard metadata size))

If the host has multiple physical interfaces, use the smallest MTU of any physical interface through which WireGuard traffic may be sent (or if you’re going to send WireGuard traffic through another virtual network interface, use the MTU of that other virtual interface).

All but the first part of the above formula are constants: IPv4 headers are always 20 bytes; IPv6 headers are always 40 bytes. UDP headers are always 8 bytes; and the WireGuard metadata size is always 32 bytes (split evenly between a header and trailer). So we can simplify this formula down to the following, using 60 (20 + 8 + 32) if you know you’re only going to use IPv4 to transport WireGuard packets, and 80 (40 + 8 + 32) otherwise:

(MTU of physical interface) - (IPv4 only ? 60 : 80)

For example, for a host that has one physical Ethernet interface with a MTU of 1500, if you know you’re always going to connect to WireGuard endpoints over IPv4, you’d set the MTU of the WireGuard interface to 1440:

1500 - 60 = 1440

WireGuard will normally do this calculation for you, and assume that the connection to other WireGuard endpoints may sometimes use IPv6; so if you don’t specify an MTU setting in the [Interface] section of a WireGuard config file, WireGuard will normally set the interface’s MTU to 1420 (ie 1500 - 80).

However, this auto-calculated value is good only for the outer interface — for the inner interface, we need to perform the calculation a second time, starting with the MTU of the outer WireGuard interface instead of the physical interface. Run the ip address command on the host to see what the MTU has been set for the outer interface (wg0):

$ ip address
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 06:ea:80:ec:11:87 brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.11/24 brd 192.168.1.255 scope global dynamic eth0
       valid_lft 3226sec preferred_lft 3226sec
3: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000
    link/none
    inet 10.0.0.1/32 scope global wg0
       valid_lft forever preferred_lft forever

In the above example, the MTU for wg0 is 1420. If we’re only going to use IPv4 addresses for the inner tunnel’s endpoints, our MTU calculation for the inner interface will produce a value of 1360:

1420 - 60 = 1360

If we decide to use IPv6 addresses for the inner tunnel’s endpoints, our MTU calculation will produce a value of 1340:

1420 - 80 = 1340

I’m going to use 1340 as the MTU for the examples below (so that they would work equally well for both IPv4 and IPv6), but be sure to check the MTU size of the outer interface on each host, so you can set the MTU size of the inner interface appropriately.

Generate a Second Set of WireGuard Keys

For the second, inner VPN, you could re-use the same WireGuard keys you used for the outer VPN — the hub wouldn’t be able to decrypt the inner VPN’s traffic even if you did. But the best practice is to use a separate key pair for each WireGuard interface, even for interfaces on the same machine, as it allows keys to be rotated separately on each separate network.

Run the following commands to generate a new key pair for Endpoint A:

$ wg genkey > endpoint-a-wg1.key
$ wg pubkey < endpoint-a-wg1.key > endpoint-a-wg1.pub

And the following commands to generate a new key pair for Endpoint B:

$ wg genkey > endpoint-b-wg1.key
$ wg pubkey < endpoint-b-wg1.key > endpoint-b-wg1.pub

This will generate four files: endpoint-a-wg1.key, endpoint-a-wg1.pub, endpoint-b-wg1.key, and endpoint-b-wg1.pub. The *.key files contain the private keys, and the *.pub files contain the public keys. The content of each will be 44 characters of Base64-encoded text:

$ cat endpoint-a-wg1.key
0F11111111111111111111111111111111111111110=
$ cat endpoint-a-wg1.pub
hN+4fjPihcKbEDFQhqrMmW8oPqqaT4CUQYtyg3FUx3k=
$ cat endpoint-b-wg1.key
2G22222222222222222222222222222222222222220=
$ cat endpoint-b-wg1.pub
777Ntbnv/AA9Fvd53yVR6Fsrd02CNCM2ySXgzXbbSUI=

Configure a Second Interface on Endpoint A

So with those new keys in hand, configure a second WireGuard interface for Endpoint A at /etc/wireguard/wg1.conf (the first WireGuard interface we created was named wg0.conf, in the same directory):

# /etc/wireguard/wg1.conf

# local settings for Endpoint A
[Interface]
PrivateKey = 0F11111111111111111111111111111111111111110=
Address = 10.9.9.1/32
ListenPort = 59999
MTU = 1340

# remote settings for Endpoint B
[Peer]
PublicKey = 777Ntbnv/AA9Fvd53yVR6Fsrd02CNCM2ySXgzXbbSUI=
Endpoint = 10.0.0.2:59999
AllowedIPs = 10.9.9.2/32

Notice that via the Address setting of the [Interface] section we give this interface (wg1) an IP address of 10.9.9.1; whereas we gave the outer interface (wg0) an IP address of 10.0.0.1. We’ll do the same thing on Endpoint B, where the inner interface will have an IP address of 10.9.9.2, and the outer interface an IP address of 10.0.0.2.

In the [Peer] section for Endpoint B, we’ll use Endpoint B’s inner interface IP address in the AllowedIPs setting, and its outer address in the Endpoint setting. This means that on Endpoint A, any traffic directed to 10.9.9.2 will be sent first through the inner interface (wg1), wrapped in one layer of encryption for its “direct” connection to Endpoint B, and then sent as a new set of packets to 10.0.0.2. But because the AllowedIPs setting for the outer interface (wg0) includes 10.0.0.2, this new set of packets will also be sent through the outer interface, and encrypted a second time, and wrapped in another set of packets to be sent through Endpoint A’s physical Ethernet interface, on to Host C (where Host C will forward those packets to Endpoint B).

Also notice that we’ve set the ListenPort setting of the [Interface] section to be 59999. This means that any packets that Endpoint A receives on UDP port 59999, including packets received by the outer WireGuard interface, will be sent through this inner interface for decryption and reassembly into their original state. We’ll do the same thing on Endpoint B (which is why we’re using the same port in the Endpoint setting for Endpoint B). When Endpoint A receives packets on the listen port of its outer WireGuard interface (wg0) from Host C, and decrypts and reassembles those packets, and sees the reassembled packets are themselves directed to UDP port 59999, it will send them through this interface (wg1) for one last layer of decryption and reassembly.

One last thing to notice: we need to manually set the MTU setting in the [Interface] section to the value we calculated in the Calculate the Inner MTU section.

Configure a Second Interface on Endpoint B

Next, configure a second WireGuard interface for Endpoint B at /etc/wireguard/wg1.conf, mirroring the second interface on Endpoint A:

# /etc/wireguard/wg1.conf

# local settings for Endpoint B
[Interface]
PrivateKey = 2G22222222222222222222222222222222222222220=
Address = 10.9.9.2/32
ListenPort = 59999
MTU = 1340

# remote settings for Endpoint A
[Peer]
PublicKey = hN+4fjPihcKbEDFQhqrMmW8oPqqaT4CUQYtyg3FUx3k=
Endpoint = 10.0.0.1:59999
AllowedIPs = 10.9.9.1/32

This second interface on Endpoint B will have an IP address of 10.9.9.2 (configured via its Address setting), and will route any packets addressed to 10.9.9.1 to the second interface on Endpoint A (configured via its AllowedIPs setting). It uses Endpoint A’s 10.0.0.1 IP address as the Endpoint setting for its connection to Endpoint A, so as to tunnel this inner WireGuard connection (10.9.9.2 to 10.9.9.1) through the outer WireGuard connection (10.0.0.2 to 10.0.0.1 by way of Host C at 10.0.0.3).

For this inner tunnel, we’ll use the same ListenPort setting of 59999 as we used on Endpoint A. While we could use a different port on each different spoke if we wanted to, using the same port will make it easier to set up firewall rules on Host C to block all non-tunnel-in-tunnel traffic from being sent through the outer tunnel (see Extra: Configure Firewall on Host C).

Make sure you customize the MTU setting for Endpoint B with the value you calculated for it using the formula from the Calculate the Inner MTU section.

Start Up WireGuard

On both Endpoint A and Endpoint B, start the wg-quick service for new second interface (wg1). On Linux with systemd, run the following commands:

$ sudo systemctl enable wg-quick@wg1.service
$ sudo systemctl start wg-quick@wg1.service

On systems without systemd, run this command instead:

$ sudo wg-quick up wg1

If you run wg-quick up directly, you’ll see output like the following:

$ sudo wg-quick up /etc/wireguard/wg1.conf
[#] ip link add wg1 type wireguard
[#] wg setconf wg1 /dev/fd/63
[#] ip -4 address add 10.9.9.1/32 dev wg1
[#] ip link set mtu 1340 up dev wg1
[#] ip -4 route add 10.9.9.2/32 dev wg1

If you ran systemctl start instead, you can see the same output by running the journalctl tool, like so:

$ journalctl -u wg-quick@wg1.service
systemd[1]: Starting WireGuard via wg-quick(8) for wg1...
wg-quick[271288]: [#] ip link add wg1 type wireguard
wg-quick[271288]: [#] wg setconf wg1 /dev/fd/63
wg-quick[271288]: [#] ip -4 route add 10.9.9.1/32 dev wg1
wg-quick[271288]: [#] ip link set mtu 1340 up dev wg1
wg-quick[271288]: [#] ip -4 address add 10.9.9.2/32 dev wg1
systemd[1]: Started WireGuard via wg-quick(8) for wg1.

Make sure you also keep the first interface (wg0) up on all hosts. If you run the wg show command, you should see both interfaces listed on Endpoint A and Endpoint B:

$ sudo wg show
interface: wg0
  public key: /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
  private key: (hidden)
  listening port: 51821

peer: jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
  endpoint: 192.0.2.3:51823
  allowed ips: 10.0.0.0/24
  latest handshake: 7 minutes, 42 seconds ago
  transfer: 748 B received, 932 B sent

interface: wg1
  public key: hN+4fjPihcKbEDFQhqrMmW8oPqqaT4CUQYtyg3FUx3k=
  private key: (hidden)
  listening port: 59999

peer: 777Ntbnv/AA9Fvd53yVR6Fsrd02CNCM2ySXgzXbbSUI=
  endpoint: 10.0.0.2:59999
  allowed ips: 10.9.9.2/32

Test Out the Tunnel

Test out the new E2EE inner tunnel the same way as you tested the outer tunnel — but this time, use the IP address of Endpoint B’s inner interface. If you start up a webserver on port 80 of Endpoint B, you should be able to connect to it using an IP address of 10.9.9.2 from Endpoint A:

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

If you see any HTML output from this, your E2EE WireGuard tunnel works!

Extra: Allow Endpoint B to Initiate Access to Endpoint A

The directions for setting up the outer interfaces in the Hub and Spoke Configuration Guide instruct you to add a PersistentKeepalive setting to the configuration for Endpoint B, but not for Endpoint A. This keeps the outer tunnel between Host C and Endpoint B open at all times, so Host C can relay traffic initiated from Endpoint A through it to Endpoint B.

This is ideal for a scenario where Endpoint A always initiates connections to Endpoint B, but Endpoint B never needs to initiate connections to Endpoint A — like our example, where Endpoint B hosts a web app of which Endpoint A is a client. But if you have a scenario where sometimes Endpoint B also needs to initiate connections to Endpoint A, make sure you add a PersistentKeepalive setting to the configuration for Endpoint A’s outer interface (wg0):

# /etc/wireguard/wg0.conf

# local settings for Endpoint A
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/32
ListenPort = 51821

# remote settings for Host C
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
Endpoint = 192.0.2.3:51823
AllowedIPs = 10.0.0.0/24
PersistentKeepalive = 25

A PersistentKeepalive setting of 25 (seconds) should do the trick in most cases.

You do not need to add a PersistentKeepalive setting to any inner interface configuration — if you’ve set PersistentKeepalive on the outer interface, adding a PersistentKeepalive setting to the inner interface will just add extra traffic for no benefit.

Also, if you set up a firewall on Endpoint A, you’ll need to configure it to allow new connections on its inner interface (wg1), similar to the directions below for the firewall for Endpoint B.

Extra: Configure Firewall on Endpoint A

On Endpoint A, which only initiates outbound connections, and never needs to receive new inbound connections, you don’t need anything special — the default settings for a generic firewall are good for this (accept traffic from established connections, block new inbound connections, block connection forwarding).

If you don’t have a firewall set up on Endpoint A yet, and Endpoint A is running Linux, you can add add the following iptables commands to your WireGuard configuration to block new connections from other members of the WireGuard VPN (while still allowing two-way traffic through connections that Endpoint A has initiated):

# /etc/wireguard/wg1.conf

# local settings for Endpoint A
[Interface]
PrivateKey = 0F11111111111111111111111111111111111111110=
Address = 10.9.9.1/32
ListenPort = 59999
MTU = 1340

PreUp = iptables -A INPUT -i %i -m state --state ESTABLISHED,RELATED -j ACCEPT
PreUp = iptables -A INPUT -i %i -j REJECT
PostDown = iptables -D INPUT -i %i -m state --state ESTABLISHED,RELATED -j ACCEPT
PostDown = iptables -D INPUT -i %i -j REJECT

# remote settings for Endpoint B
[Peer]
PublicKey = 777Ntbnv/AA9Fvd53yVR6Fsrd02CNCM2ySXgzXbbSUI=
Endpoint = 10.0.0.2:59999
AllowedIPs = 10.9.9.2/32

You can add these same commands to both the inner interface (wg1) and the outer interface (wg0) — wg-quick will substitute the name of the interface for the %i token when it executes the PreUp or PostDown commands.

If you want to add these commands to a running interface, shut down the interface first (eg sudo wg-quick down wg1), make the change, and then start the interface back up (eg sudo wg-quick up wg1). This will make sure that the PostDown commands, executed when you shut the interface down, match the PreUp commands that were executed when you started the interface up.

Extra: Configure Firewall on Endpoint B

On Endpoint B, the default settings for a generic firewall (accept traffic from established connections, block new inbound connections, block connection forwarding) are good for its outer WireGuard interface and physical network interface — but for its inner WireGuard interface, the firewall needs to allow new HTTP connections from Endpoint A.

If you don’t have a firewall set up on Endpoint B yet, and Endpoint B is running Linux, you can add add the following iptables commands to your inner WireGuard configuration to block new connections from other members of the inner WireGuard VPN — except for connections to Endpoint B’s TCP port 80:

# /etc/wireguard/wg1.conf

# local settings for Endpoint B
[Interface]
PrivateKey = 2G22222222222222222222222222222222222222220=
Address = 10.9.9.2/32
ListenPort = 59999
MTU = 1340

PreUp = iptables -A INPUT -i wg1 -m state --state ESTABLISHED,RELATED -j ACCEPT
PreUp = iptables -A INPUT -i wg1 -m state --state NEW -p tcp --dport 80 -j ACCEPT
PreUp = iptables -A INPUT -i wg1 -j REJECT
PostDown = iptables -D INPUT -i wg1 -m state --state ESTABLISHED,RELATED -j ACCEPT
PostDown = iptables -D INPUT -i wg1 -m state --state NEW -p tcp --dport 80 -j ACCEPT
PostDown = iptables -D INPUT -i wg1 -j REJECT

# remote settings for Endpoint A
[Peer]
PublicKey = hN+4fjPihcKbEDFQhqrMmW8oPqqaT4CUQYtyg3FUx3k=
Endpoint = 10.0.0.1:59999
AllowedIPs = 10.9.9.1/32

For its outer WireGuard interface, you can block all new connections except for to its inner WireGuard listen port (59999):

# /etc/wireguard/wg0.conf

# local settings for Endpoint B
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2/32
ListenPort = 51822

PreUp = iptables -A INPUT -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
PreUp = iptables -A INPUT -i wg0 -m state --state NEW -p udp --dport 59999 -j ACCEPT
PreUp = iptables -A INPUT -i wg0 -j REJECT
PostDown = iptables -D INPUT -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
PostDown = iptables -D INPUT -i wg0 -m state --state NEW -p udp --dport 59999 -j ACCEPT
PostDown = iptables -D INPUT -i wg0 -j REJECT

# remote settings for Host C
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
Endpoint = 192.0.2.3:51823
AllowedIPs = 10.0.0.0/24
PersistentKeepalive = 25

If you’re using nftables on Endpoint B instead of iptables, use a ruleset like this:

#!/usr/sbin/nft -f
flush ruleset

define pub_iface = "eth0"
define outer_wg_iface = "wg0"
define outer_wg_port = 51822
define inner_wg_port = 59999

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # accept all loopback packets
        iif "lo" accept
        # accept all icmp/icmpv6 packets
        meta l4proto { icmp, ipv6-icmp } accept
        # accept all packets that are part of an already-established connection
        ct state vmap { invalid : drop, established : accept, related : accept }
        # drop new connections over rate limit
        ct state new limit rate over 1/second burst 10 packets drop

        # accept all DHCPv6 packets received on link-local address
        ip6 daddr fe80::/64 udp dport dhcpv6-client accept
        # accept all SSH packets received on a public interface
        iifname $pub_iface tcp dport ssh accept
        # accept all outer WireGuard packets received on a public interface
        iifname $pub_iface udp dport $outer_wg_port accept
        # accept all inner WireGuard packets received on the outer WireGuard interface
        iifname $outer_wg_iface udp dport $inner_wg_port accept

        # accept all HTTP packets received on the inner WireGuard interface
        iifname $inner_wg_iface tcp dport http accept

        # reject with polite "port unreachable" icmp response
        reject
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        reject with icmpx type host-unreachable
    }
}

See WireGuard Nftables Configuration Guide for an explanation of the various rules — and notice that this is ruleset is nearly identical to the nftables point-to-point configuration for Endpoint B, but with the addition of a rule to accept inner WireGuard packets on the outer WireGuard interface, and to adjust the rule that accepts HTTP packets to use the inner interface instead of the outer one.

If you’re using UFW for the firewall on Endpoint B, you minimally just need to open up UDP port 599999 on the outer WireGuard interface (wg0), and TCP port 80 on the inner WireGuard interface (wg1):

$ sudo ufw allow in on wg0 proto udp to any port 59999
Rules updated
Rules updated (v6)

$ sudo ufw allow in on wg1 proto tcp to any port 80
Rules updated
Rules updated (v6)

$ sudo ufw status

To                         Action      From
--                         ------      ----
59999/udp on wg0           ALLOW       Anywhere
80/tcp on wg1              ALLOW       Anywhere
59999/udp (v6) on wg0      ALLOW       Anywhere (v6)
80/tcp (v6) on wg1         ALLOW       Anywhere (v6)

And if you’re using firewalld for the firewall on Endpoint B, you might want to start by putting your physical network interface (eth0) into the public zone, and the two WireGuard interfaces (wg0 and wg1) into the trusted zone:

$ sudo firewall-cmd --zone=public --add-interface=eth0
success
$ sudo firewall-cmd --zone=trusted --add-interface=wg0
success
$ sudo firewall-cmd --zone=trusted --add-interface=wg1
success
$ sudo firewall-cmd --get-active-zones
public
  interfaces: eth0
trusted
  interfaces: wg1 wg0
$ sudo firewall-cmd --runtime-to-permanent
success

However, if you want to further lock down access to these interfaces, use the instructions for the firewalld point-to-point configuration for Endpoint B. If you do that, add wg1 (the inner interface) instead of wg0 (the outer interface) to the custom mywg zone, and add an additional custom zone for your wg0 interface:

$ sudo firewall-cmd --permanent --new-zone=myouter
success
$ sudo firewall-cmd --reload
success
$ sudo firewall-cmd --zone=myouter --add-port=59999/udp
success
$ sudo firewall-cmd --zone=myouter --add-interface=wg0
success
$ sudo firewall-cmd --info-zone=myouter
mysite (active)
  target: default
  icmp-block-inversion: no
  interfaces: wg0
  sources:
  services:
  ports: 59999/udp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:
$ sudo firewall-cmd --runtime-to-permanent
success

Extra: Configure Firewall on Host C

If you’re using an iptables firewall, the instructions from the extra firewall section of the Hub and Spoke Configuration Guide directs you to allow new HTTP connections to be forwarded through Endpoint B’s outer tunnel, and to block all other new connections. However, with the new inner, E2EE tunnel between Endpoint A and Endpoint B, Host C won’t ever see this HTTP traffic — it will only see encrypted WireGuard traffic from the inner tunnel passing through its outer tunnel.

So instead of allowing TCP port 80 traffic directed to Endpoint B through its WireGuard tunnel, we need to adjust the firewall to instead allow traffic directed to Endpoint B’s inner WireGuard listen port, UDP port 59999. Also, since a) the traffic will be end-to-end encrypted, so making a distinction between new and established connections with it won’t really add much security; and b) we’re using the same inner WireGuard port for all spokes; we can simplify the two original ACCEPT rules into one: allow all traffic forwarded through Host C’s WireGuard tunnel destined for UDP port 59999:

# /etc/wireguard/wg0.conf

# local settings for Host C
[Interface]
PrivateKey = CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCGA=
Address = 10.0.0.3/32
ListenPort = 51823

PreUp = sysctl -w net.ipv4.ip_forward=1

PreUp = iptables -A INPUT -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
PreUp = iptables -A INPUT -i wg0 -j REJECT
PreUp = iptables -A FORWARD -i wg0 -p udp --dport 59999 -j ACCEPT
PreUp = iptables -A FORWARD -i wg0 -j REJECT
PostDown = iptables -D INPUT -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
PostDown = iptables -D INPUT -i wg0 -j REJECT
PostDown = iptables -D FORWARD -i wg0 -p udp --dport 59999 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -j REJECT

# remote settings for Endpoint A
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1/32

# remote settings for Endpoint B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
AllowedIPs = 10.0.0.2/32

If you’re using nftables on Host C instead of iptables, use the same ruleset laid out for Host C in the Hub and Spoke section of the WireGuard Nftables Configuration Guide, except change this rule that allows HTTP traffic to be forwarded to Endpoint B:

        # forward all HTTP packets for Endpoint B
        ip daddr 10.0.0.2 tcp dport http accept

To a rule that allows all E2EE WireGuard traffic to be forwarded between any two spokes:

        # forward all E2EE WireGuard packets
        udp dport 59999 accept

(Note that neither UFW nor firewalld is a good fit for the hub role — consider using iptables or nftables instead.)