WireGuard Hub and Spoke Configuration

This article will cover how to set up three WireGuard peers in a Hub and Spoke topology. This is the configuration you’d use when you want to connect two endpoints running WireGuard, but both endpoints are behind restrictive NAT (Network Address Translation) or firewalls that do not allow either endpoint to accept new connections from the other. This requires using a third WireGuard host (a “hub”) that can accept new connections from the two endpoints (the “spokes”).

In the specific scenario I’ll cover for this article, we’ll have an end-user workstation, which I’ll call “Endpoint A”, on one LAN (Local Area Network); and an HTTP server listening on port 80, which I’ll call “Endpoint B”, on another; and a third host, the hub, which I’ll call “Host C”, on a third LAN; with the Internet between each of them. Endpoint A and Endpoint B are both behind NAT and firewalls that allow only established connections through. Host C is also behind NAT and a firewall, but its NAT + firewall allows port 51823 from the Internet to be forwarded on to Host C. We’ll use this port, 51823, for WireGuard on Host C.

The diagram below illustrates this scenario:

WireGuard hub-and-spoke scenario

In this article, we’ll install WireGuard on each host, and create a WireGuard tunnel between each spoke and the hub. Inside the WireGuard VPN (Virtual Private Network) that we’ll create, we’ll set Endpoint A to use an IP address of 10.0.0.1, Endpoint B to an IP address of 10.0.0.2, and Host C to 10.0.0.3. Once we have WireGuard configured and running on all three hosts, a user using Endpoint A will be able to access the HTTP server listening on port 80 of Endpoint B simply by navigating to http://10.0.0.2/ in her web browser.

These are the steps we’ll follow:

  1. Install WireGuard
  2. Generate WireGuard Keys
  3. Configure WireGuard on Host C
  4. Configure Routing on Host C
  5. Configure WireGuard on Endpoint A
  6. Configure WireGuard on Endpoint B
  7. Start Up WireGuard
  8. Test Out the Tunnels
  9. Basic Troubleshooting
  10. Extra: Configure Firewall on Host C

Install WireGuard

Install WireGuard on both Endpoint A, Endpoint B, and Host C by following the installation instructions for the appropriate platform on the WireGuard Installation page. You can verify that you’ve installed WireGuard successfully by running wg help on each host.

Generate WireGuard Keys

Next, generate three WireGuard keys, one for Endpoint A, one for Endpoint B, and one for Host C. A WireGuard key (also known as a “key pair”) is composed of two parts, the “private key” (also known as the “secret key”), and the “public key”. The magic of this kind of crypto (known as “public-key cryptography”) is that it’s trivial to compute the public key if you know the private key, but practically impossible to compute the private key if all you know is the public key.

While you don’t have to generate the keys on the hosts, generating a host’s key on the host itself often is the best way to keep its private key secure — that way the private key never leaves the host. If you generate your keys outside of the host, be very careful with the private keys, as WireGuard’s security depends entirely on keeping the private keys a secret.

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

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

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

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

And similar commands to generate a new key pair for Host C:

$ wg genkey > host-c.key
$ wg pubkey < host-c.key > host-c.pub

This will generate six files: endpoint-a.key, endpoint-a.pub, endpoint-b.key, endpoint-b.pub, host-c.key, and host-c.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.key
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
$ cat endpoint-a.pub
/TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=

$ cat endpoint-b.key
ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
$ cat endpoint-b.pub
fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=

$ cat host-c.key
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCGA=
$ cat host-c.pub
jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=

You don’t actually need to keep these files around — the content of each will be used in the WireGuard configuration files we build for the various hosts in the next sections. The private key for a host goes in the host’s own configuration file; and its public key goes in the configuration file of every other host you want to connect it to. Each end of a connection must be pre-configured with the other end’s public key in order for WireGuard to establish the connection.

With a Hub and Spoke topology, this means than the hub, Host C, must be configured with the public key of each spoke; whereas the spokes, Endpoint A and Endpoint B, only need to be configured with the public key of the hub.

Configure WireGuard on Host C

Now let’s configure Host C (the hub). On Host C, create a new file at /etc/wireguard/wg0.conf with the following content:

# /etc/wireguard/wg0.conf

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

# 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

Set the file’s owner to root, and its permissions to 600 (owner can read+write; everyone else is denied access — the file contains the host’s private key, which must be kept secret).

Let’s go through the configuration, setting-by-setting:

Interface.PrivateKey
This is Host C’s private key — replace this value with the actual private key you generated for Host C. This value is secret, and this is the only place where it should live.
Interface.Address

This is Host C’s IP address inside the WireGuard VPN we’re building — replace this value with a suitable value for your network. You can use any address you want for this, but the address should be within the “Private-Use” IPv4 or “Unique-Local” IPv6 address space (like 10.0.0.0/8 — see RFC 6890 for all available address blocks), and should not collide with any other private subnets to which the endpoint itself or any of its peers can connect.

With a Hub and Spoke topology, you typically decide on a single block of IP addresses to use for the whole WireGuard VPN, and assign each peer an IP address from within that block. You’ll use this block as the Peer.AllowedIps setting in the configuration file for each spoke (discussed in later sections). In this scenario, we’re going to use the 10.0.0.0/24 block, so we’ve chosen an address from within this block (10.0.0.3) for the hub, and that’s what we put in the hub’s Interface.Address setting.

Note that this setting isn’t used by WireGuard directly — it’s only used by the wg-quick helper service when it sets up a virtual network interface for WireGuard. In order for network packets to be routed correctly to and from this host when they’re outside of the WireGuard tunnel, they need to use the IP address you set here.

While you can configure multiple IP addresses for this setting, unless you have a special use-case, you should just use a single IP address (in the form of a /32 block with an IPv4 address, or a /64 block with an IPv6 address).

Interface.ListenPort

This is Host C’s WireGuard port. Host C must be able to receive UDP traffic for new connections on this port from the Internet (or wherever the traffic of the other WireGuard peers with which it will communicate comes from).

With a Hub and Spoke topology, this setting in Host C’s configuration file should match the port in the Peer.Endpoint setting of each endpoint’s configuration file (discussed in following sections).

Peer[0].PublicKey
This is Endpoint A’s public key — replace this value with the actual public key you generated for Endpoint A. This value is not secret; however, it is a globally-unique identifier for Endpoint A.
Peer[0].AllowedIps

This is Endpoint A’s IP address inside the WireGuard VPN we’re building — replace this value with a suitable value for your network.

With a Hub and Spoke topology, you’d typically choose a value for this address from the same block as the Interface.Address of the hub, as described above. And this Peer.AllowedIps setting for Endpoint A in the hub’s configuration file should match exactly the Interface.Address setting in Endpoint A’s configuration file (discussed in a following section).

While you can configure multiple IP addresses for this setting, unless you have a special use-case, you should just use a single IP address (in the form of a /32 block with an IPv4 address, or a /64 block with an IPv6 address).

Peer[1].PublicKey
This is Endpoint B’s public key — replace this value with the actual public key you generated for Endpoint B. This value is not secret; however, it is a globally-unique identifier for Endpoint B.
Peer[1].AllowedIps

This is Endpoint B’s IP address inside the WireGuard VPN we’re building — replace this value with a suitable value for your network.

With a Hub and Spoke topology, you’d typically choose a value for this address from the same block as the Interface.Address of the hub, as described above. And this Peer.AllowedIps setting for Endpoint B in the hub’s configuration file should match exactly the Interface.Address setting in Endpoint B’s configuration file (discussed in a following section).

While you can configure multiple IP addresses for this setting, unless you have a special use-case, you should just use a single IP address (in the form of a /32 block with an IPv4 address, or a /64 block with an IPv6 address).

Configure Routing on Host C

If you’ve already set up Host C to use as a router, you don’t need to do anything extra for WireGuard; just skip on to the next section.

Otherwise, for a Linux host, you need to turn on IP forwarding. There are a several ways to do this, but a convenient way with the wg-quick service we’ll use is to turn it on when the WireGuard interface is brought up. Update the /etc/wireguard/wg0.conf file on Host C to add an Interface.PreUp setting to it like the following:

# /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

# 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 IPv6 addresses for your WireGuard VPN, use this setting instead:

PreUp = sysctl -w net.ipv6.conf.all.forwarding=1

Note that IP forwarding is a potentially dangerous setting to turn on if you don’t have appropriate firewall rules in place in front of the host (or as part of the host’s own firewall) — with it on, the host will blindly try to forward on any packets it receives that are destined for other hosts (allowing a malicious actor with network access to the host to access any other hosts that the host itself can access).

Configure WireGuard on Endpoint A

Now let’s configure Endpoint A (the “client” spoke). On Endpoint A, create a new file at /etc/wireguard/wg0.conf with the following content:

# /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

Set the file’s owner to root, and its permissions to 600 (owner can read+write; everyone else is denied access — the file contains the endpoint’s private key, which must be kept secret).

Let’s go through the configuration, setting-by-setting:

Interface.PrivateKey
This is Endpoint A’s private key — replace this value with the actual private key you generated for Endpoint A. This value is secret, and this is the only place where it should live.
Interface.Address

This is Endpoint A’s IP address inside the WireGuard VPN we’re building — replace this value with a suitable value for your network.

With a Hub and Spoke topology, this setting in Endpoint A’s configuration file should match exactly the Peer.AllowedIps setting for Endpoint A in the hub’s configuration file (discussed in a previous section).

Interface.ListenPort

This is Endpoint A’s WireGuard port. Endpoint A must be able to receive UDP traffic for established connections on this port from the Internet (or wherever the traffic from Host C comes from).

If you omit this setting, WireGuard will select a new random, unused port in the in the operating system’s ephemeral port range (which may range from 1024 to 65535, depending on operating system) every time it starts up.

Peer.PublicKey
This is Host C’s public key — replace this value with the actual public key you generated for Host C. This value is not secret; however, it is a globally-unique identifier for Host C.
Peer.Endpoint

This is Host C’s publicly-facing IP address (and port) — the IP address and port Endpoint A will use to connect over the Internet to Host C to set up the WireGuard tunnel. Replace this value with the actual IP address that you would normally use to connect to Host C from Endpoint A (and suffix it with the actual WireGuard port you’ll use for Host C, if it’s not WireGuard’s default port of 51820).

Host C must be able to accept new UDP connections from the Internet at this address and port; and Endpoint A must be able to send UDP traffic over the Internet to it at this address and port.

Peer.AllowedIps

This is the block of addresses that Host C will route for Endpoint A inside the WireGuard VPN we’re building — replace this value with a suitable value for your network.

In this scenario, we’re going to use the 10.0.0.0/24 block. Since we want Endpoint A to be able to connect to Endpoint B, it’s important that this block includes the IP address that we configured for Endpoint B in Host C’s WireGuard configuration (discussed in a previous section), which was 10.0.0.2. If Endpoint B was the only host we ever wanted to connect to from Endpoint A in this WireGuard VPN, we could just set this block to 10.0.0.2/32.

Configure WireGuard on Endpoint B

Now let’s configure Endpoint B (the “server” spoke). On Endpoint B, create a new file at /etc/wireguard/wg0.conf with the following content:

# /etc/wireguard/wg0.conf

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

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

Set the file’s owner to root, and its permissions to 600 (owner can read+write; everyone else is denied access — the file contains the endpoint’s private key, which must be kept secret).

Let’s go through the configuration, setting-by-setting:

Interface.PrivateKey
This is Endpoint B’s private key — replace this value with the actual private key you generated for Endpoint B. This value is secret, and this is the only place where it should live.
Interface.Address

This is Endpoint B’s IP address inside the WireGuard VPN we’re building — replace this value with a suitable value for your network.

With a Hub and Spoke topology, this setting in Endpoint B’s configuration file should match exactly the Peer.AllowedIps setting for Endpoint B in the hub’s configuration file (discussed in a previous section).

Interface.ListenPort

This is Endpoint B’s WireGuard port. Endpoint B must be able to receive UDP traffic for established connections on this port from the Internet (or wherever the traffic from Host C comes from).

If you omit this setting, WireGuard will select a new random, unused port in the in the operating system’s ephemeral port range (which may range from 1024 to 65535, depending on operating system) every time it starts up.

Peer.PublicKey
This is Host C’s public key — replace this value with the actual public key you generated for Host C. This value is not secret; however, it is a globally-unique identifier for Host C.
Peer.Endpoint

This is Host C’s publicly-facing IP address (and port) — the IP address and port Endpoint B will use to connect over the Internet to Host C to set up the WireGuard tunnel. Replace this value with the actual IP address that you would normally use to connect to Host C from Endpoint B (and suffix it with the actual WireGuard port you’ll use for Host C, if it’s not WireGuard’s default port of 51820).

Host C must be able to accept new UDP connections from the Internet at this address and port; and Endpoint B must be able to send UDP traffic over the Internet to it at this address and port.

Peer.AllowedIps

This is the block of addresses that Host C will route for Endpoint B inside the WireGuard VPN we’re building — replace this value with a suitable value for your network.

In this scenario, we’re going to use the 10.0.0.0/24 block. Since we want Endpoint B to be able to connect back to Endpoint A, it’s important that this block includes the IP address that we configured for Endpoint A in Host C’s WireGuard configuration (discussed in a previous section), which was 10.0.0.1. If Endpoint A was the only endpoint we ever wanted to connect to from Endpoint B in this WireGuard VPN, we could just set this block to 10.0.0.1/32.

Peer.PersistentKeepalive

This is the number of seconds between which WireGuard will send keepalive packets from Endpoint B to Host C. If set to a non-zero number, as soon as the WireGuard interface is brought up, WireGuard will start trying to send keepalive packets to Host C; if set to 0 (the default), WireGuard will not send any keepalive packets to Host C.

This mechanism proactively opens up a new connection through the firewalls between Endpoint B and Host C, and keeps it established, so that Host C will be able to forward Endpoint B arbitrary traffic (in the form of HTTP requests) from Endpoint A. Without this setting, the traffic initiated by Endpoint A and routed through Host C would be blocked by firewall rules that allow only established connections through to Endpoint B.

Start Up WireGuard

On each host (Host C and Endpoint A and Endpoint B), start the wg-quick service. On Linux with systemd, run the following commands:

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

On systems without systemd, run this command instead:

$ sudo wg-quick up /etc/wireguard/wg0.conf

Either way, starting up the wg-quick service will set up a WireGuard network interface named wg0 on the host, and configure some routing rules to route packets destined for any IP address listed in the Peer.AllowedIPs setting(s) of the /etc/wireguard/wg0.conf file to go out the wg0 interface.

Note that the name wg0 is just the standard convention for your first WireGuard interface. You can create as many WireGuard interfaces as you like, and name them however you like. For example, you could create another configuration file named /etc/wireguard/mytunnel.conf, and start it up with the command wg-quick up /etc/wireguard/mytunnel.conf; this would create a new WireGuard interface named mytunnel.

But for now, if you ran wg-quick up directly on Host C, you’ll see output like the following:

$ sudo wg-quick up /etc/wireguard/wg0.conf
[#] sysctl -w net.ipv4.ip_forward=1
net.ipv4.ip_forward = 1
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.3/32 dev wg0
[#] ip link set mtu 8921 up dev wg0
[#] ip -4 route add 10.0.0.2/32 dev wg0
[#] ip -4 route add 10.0.0.1/32 dev wg0

The output shows the routing rules that wg-quick set up for you automatically.

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

$ journalctl -u wg-quick@wg0.service
systemd[1]: Starting WireGuard via wg-quick(8) for wg0...
wg-quick[38161]: [#] sysctl -w net.ipv4.ip_forward=1
wg-quick[38228]: net.ipv4.ip_forward = 1
wg-quick[38161]: [#] ip link add wg0 type wireguard
wg-quick[38161]: [#] wg setconf wg0 /dev/fd/63
wg-quick[38161]: [#] ip -4 address add 10.0.0.3/32 dev wg0
wg-quick[38161]: [#] ip link set mtu 8921 up dev wg0
wg-quick[38161]: [#] ip -4 route add 10.0.0.2/32 dev wg0
wg-quick[38161]: [#] ip -4 route add 10.0.0.1/32 dev wg0
systemd[1]: Started WireGuard via wg-quick(8) for wg0.

Test Out the Tunnels

On Endpoint B, start up a webserver on port 80 (or any other port, like 8080). If you don’t have a webserver handy, a dead-simple substitute is to use the http.server module of Python 3, like the following, running as root to listen on port 80:

$ sudo python3 -m http.server 80

Or run it as a regular user to listen on any port above 1023 (like port 8080):

$ python3 -m http.server 8080

The http.server module will serve the directory listing of and files in your current directory. A somewhat safer version of this is to create an empty directory in your current directory, and serve that instead:

$ mkdir dummydir && cd dummydir
$ python3 -m http.server 8080

On Endpoint A, use curl (or a regular webrowser) to request a page from the webserver running on Endpoint B port 80:

$ curl 10.0.0.2

Or if the webserver is running on port 8080 (or some other port), specify that port explicitly:

$ curl 10.0.0.2:8080

If you see any HTML output from this, your WireGuard tunnels work!

Basic Troubleshooting

If curl hangs, or you see an error like like Connection refused or No route to host, you may have neglected to turn on IP forwarding for Host C. Run this command on Host C to double check:

$ sudo sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 0

Or, if you’re using IPv6 addresses, run the IPv6 variant:

$ sudo sysctl net.ipv6.conf.all.forwarding
net.ipv6.conf.all.forwarding = 0

If the result is 0, you need to turn on IP forwarding (see the Configure Routing on Host C section above). If the result is 1, IP forwarding is on.

If IP forwarding was not the problem, you likely have some firewall rules or other network configuration either blocking the WireGuard tunnel itself from being set up, or blocking packets on one side of the tunnel or the other. But first, try running sudo wg on all three hosts, to double-check that the WireGuard interface on all three is up and running and configured as you expect.

On Host C, you’ll probably see output like this:

$ sudo wg
interface: wg0
  public key: jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
  private key: (hidden)
  listening port: 51823

peer: /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
  allowed ips: 10.0.0.1/32

peer: fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
  allowed ips: 10.0.0.2/32

If a peer had successfully connected, this display would include a “latest handshake” field for the peer, as well as a “transfer” field indicating some data had been received. The above output indicates that neither Endpoint A nor Endpoint B have connected successfully yet.

On Endpoint A, you’ll probably see output like this:

$ sudo wg
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
  transfer: 0 B received, 234 B sent

And on Endpoint B, you’ll probably see output like this:

$ sudo wg
interface: wg0
  public key: fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
  private key: (hidden)
  listening port: 51822

peer: jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
  endpoint: 192.0.2.3:51823
  allowed ips: 10.0.0.0/24
  transfer: 0 B received, 123 B sent

If the “transfer” field indicates some data has been sent to a peer, but none has been received (as it does for Endpoint A and Endpoint B above), it means WireGuard has attempted to send packets to the peer, but has gotten nothing in return. If this is the case for you, you need to fiddle with your firewalls or other network configuration until they allow Endpoint A to send UDP packets to Host C via the IP address and port configured in Endpoint A’s Peer.Endpoint setting (in this example, it’s 192.0.2.3:51823); and the same for Endpoint B.

Extra: Configure Firewall on Host C

If you already have some firewall software set up on Host C itself, you don’t need to do anything else to your WireGuard configuration. You may want to add some additional rules to your firewall specifically for restricting the forwarding of WireGuard traffic, but you don’t have to. If you do, it’s best to add those rules with whatever software you used to configure the firewall originally.

But, for a hub host running Linux, like Host C, if you don’t already have a firewall set up on the host, you may want to use iptables to add one. It’s fairly straightforward to configure iptables to reject any unexpected WireGuard traffic coming into the hub itself, as well as reject any WireGuard traffic slated to be forwarded on to an unexpected destination.

On Host C, first double-check what iptables rules you have in place with this command:

$ sudo iptables -L

If you get an error like command not found, you need to install iptables. If you have iptables installed, but don’t have any firewall rules set up, you’ll see output like the following:

$ sudo iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination

If you instead you see several screen-fulls of rules listed, you’re probably using some other tool to manage the firewall on Host C, and should use that tool instead of following the next steps.

As the next step, bring down the WireGuard interface on Host C with wg-quick or systemctl:

$ sudo wg-quick down /etc/wireguard/wg0.conf
$ # or alternately:
$ sudo systemctl stop wg-quick@wg0.service

Then edit the /etc/wireguard/wg0.conf file to add several new Interface.PreUp and Interface.PostDown settings, like the following:

# /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 -m state --state ESTABLISHED,RELATED -j ACCEPT
PreUp = iptables -A FORWARD -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -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 -m state --state ESTABLISHED,RELATED -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -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

Then bring the interface back up again with wg-quick or systemctl:

$ sudo wg-quick up /etc/wireguard/wg0.conf
$ # or alternately:
$ sudo systemctl start wg-quick@wg0.service

This updated configuration will cause wg-quick on Host C to add five new iptables rules before it brings up the WireGuard interface, and remove the same rules after it brings the interface down.

The first two rules (added to the INPUT chain) apply to traffic destined for Host C itself:

iptables -A INPUT -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -i wg0 -j REJECT

The first rule will accept all packets for already-established connections (ie connections that Host C initiated) coming through the WireGuard tunnel; and the second rule will reject anything else coming into Host C through the tunnel.

The last three rules (added to the FORWARD chain) apply to traffic forwarded between Endpoint A and Endpoint B (or any other spokes you may add):

iptables -A FORWARD -i wg0 -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp --dport 80 -j ACCEPT
iptables -A FORWARD -i wg0 -j REJECT

Similar to the rules for the INPUT chain, the first and third of the rules for the FORWARD chain allow already-established connections, but reject new connections.

The second rule, however, will allow new TCP connections to be forwarded to port 80 of Endpoint B (10.0.0.2 in this WireGuard network). If you want to allow access to a different port of Endpoint B other than port 80 (like 8080), specify that port instead of 80. If you want to allow access to multiple ports of Endpoint B, adjust the rule slightly to use the multiport match directive with the --dports flag (note the trailing s in --dports) — like, for example, to allow both port 80 and 443:

iptables -A FORWARD -i wg0 -m state --state NEW -d 10.0.0.2 -p tcp -m multiport --dports 80,443 -j ACCEPT

Whenever you make changes to these rules, make sure you make the exact same change to both the PreUp setting and its corresponding PostDown setting (the PreUp and PostDown rules should be the same, just with a -A flag for PreUp and -D flag for PostDown). And if you use IPv6 addresses in your WireGuard VPN, substitute the ip6tables command for iptables in everything above.

Also note that when you’re fiddling with PreUp and PostDown settings, it’s best to bring the interface down first, make your configuration changes, and then bring the interface back up again (rather than making the configuration changes while the interface is still up, and then trying to restart afterward). The reason is that most of the time, your “Up” and “Down” settings are meant to be symmetrical (like start up a service with PreUp and shut it down with PostDown, or add a firewall rule with PreUp and remove it with PostDown) — so if you change a PostDown rule while your WireGuard interface is still running, you might inadvertently leave a stray service running or firewall rule in place from the previous configuration version (which you’ll have to track down and clean up manually).