How to Set Up a WireGuard Jumphost

Setting up a WireGuard jumphost is easy! It’s a great way to securely access services on a private internal network from a remote location (and often a better choice than using an SSH jumphost).

This article will show you how, using the following example scenario, where we will provide Alice, Bob, and Cindy remote access to select services in a private cloud site through a WireGuard jumphost:

WireGuard Jumphost

The three services we’ll grant access to, a back-end server which needs to be administered via SSH, a MySQL database, and an internal web-app, are accessible only from within the cloud site, via private IP addresses (using the private 192.168.200.0/24 address space). We’ll set up a jumphost with a public IP address (198.51.100.10 in this example), and configure it so that Alice, Bob, and Cindy can connect to it through an encrypted WireGuard tunnel.

We’ll follow these steps:

Inventory the Systems

First, we need to determine which client systems need access to this jumphost, designate WireGuard IP addresses for them, and identify which services in the site each client should be able to access.

If you don’t have a fancy inventory management system for this, you can just draw up a simple spreadsheet like the following to keep track of each system, and the access granted to each:

Table 1. Initial Inventory Table
WireGuard Client WireGuard IP Address Site Service Site IP Address Port

Alice’s Workstation

?

Back-end server admin

192.168.200.21

TCP 22

Alice’s Workstation

?

MySQL database

192.168.200.22

TCP 3360

Alice’s Workstation

?

Internal web-app

192.168.200.23

TCP 80

Bob’s Workstation

?

MySQL database

192.168.200.22

TCP 3360

Bob’s Workstation

?

Internal web-app

192.168.200.23

TCP 80

Cindy’s Laptop

?

Internal web-app

192.168.200.23

TCP 80

Add an entry for each connection between client and internal service you want to enable. Once you’ve completed that, come up with an available private-use IP address range to use for the WireGuard network, and fill in a WireGuard IP address for each client.

Also include entries in your table for any remote access you need to the jumphost itself for administration. In this example, we’ll grant Alice access to administer the jumphost over SSH from her workstation. We’ll use the 10.0.0.0/24 address space for our WireGuard network, and we’ll use 10.0.0.1 as the WireGuard IP address for the jumphost itself:

Table 2. Completed Inventory Table
WireGuard Client WireGuard IP Address Site Service Site IP Address Port

Alice’s Workstation

10.0.0.11

Jumphost admin

10.0.0.1

TCP 22

Alice’s Workstation

10.0.0.11

Back-end server admin

192.168.200.21

TCP 22

Alice’s Workstation

10.0.0.11

MySQL database

192.168.200.22

TCP 3360

Alice’s Workstation

10.0.0.11

Internal web-app

192.168.200.23

TCP 80

Bob’s Workstation

10.0.0.12

MySQL database

192.168.200.22

TCP 3360

Bob’s Workstation

10.0.0.12

Internal web-app

192.168.200.23

TCP 80

Cindy’s Laptop

10.0.0.13

Internal web-app

192.168.200.23

TCP 80

Configure WireGuard on the Jumphost

Now provision the jumphost server with a public IP address (in this example we’ll use 198.51.100.10), connect to it, and install WireGuard on it.

Then run the following commands on the jumphost to generate a new WireGuard key pair for it:

$ wg genkey > jumphost.key
$ wg pubkey < jumphost.key > jumphost.pub
$ cat jumphost.key
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
$ cat jumphost.pub
/TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=

Next, create a WireGuard configuration file at /etc/wireguard/wg0.conf on the jumphost, and copy the private key from the jumphost.key file (generated with the wg genkey command) into it:

# jumphost /etc/wireguard/wg0.conf

# local settings for the jumphost
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51820
PreUp = sysctl -w net.ipv4.conf.all.forwarding=1

Configure the rest of the fields like this:

[Interface] Address

Set this to the WireGuard IP address you designated for the jumphost in the Inventory the Systems section (10.0.0.1 in this example), and include the netmask (/24) of the entire address range you designated for WireGuard IP addresses (10.0.0.0/24).

[Interface] ListenPort

Set this to the UDP port that WireGuard clients will connect to (we’ll use 51820 in this example). This port must be exposed to the public Internet (more on that in the Configure the Firewall on the Jumphost section).

[Interface] PreUp

PreUp, PostUp, PreDown, and PostDown fields specify arbitrary commands that should be run when the interface is started up and shut down. We’ll use a PreUp command to ensure that the kernel parameter which allows IPv4 packet forwarding (net.ipv4.conf.all.forwarding) has been turned on.

We’ll add peers via [Peer] entries to this WireGuard config later. You can now delete the jumphost.key file generated above; the only place the private key should live is in the WireGuard configuration of the jumphost itself.

Start up this interface by running the following command on the jumphost:

$ sudo wg-quick up wg0
[#] sysctl -w net.ipv4.conf.all.forwarding=1
net.ipv4.conf.all.forwarding=1
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.1/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
Tip

If you’re using systemd on the jumphost, run the following commands to have it start up this WireGuard interface, and to have it start up the interface automatically on system boot:

$ sudo systemctl start wg-quick@wg0.service
$ sudo systemctl enable wg-quick@wg0.service
Created symlink /etc/systemd/system/multi-user.target.wants/wg-quick@wg0.service → /lib/systemd/system/wg-quick@.service.

You can check that WireGuard is running via the wg command:

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

Configure the Firewall on the Jumphost

Now we’ll configure the firewall on the jumphost to handle three things:

  1. Limit access to the private site’s network from our WireGuard clients

  2. Lock down access to the jumphost itself

  3. Masquerade connections from the WireGuard clients

Since the latest versions of most Linux distros now come with nftables, we’ll use it for our firewall (but you could also use other firewall tools — check out our “point-to-site” guides for firewalld, UFW, and iptables for similar functionality).

On the jumphost, make sure the nftables package is installed, and update (or create) the nftables ruleset at /etc/nftables.conf (or /etc/sysconfig/nftables.conf on Fedora-based distros) to the following:

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

define lan_iface = "eth0"
define wg_iface = "wg0"
define wg_port = 51820

define wg_jumphost = 10.0.0.1
define alices_workstation = 10.0.0.11
define bobs_workstation = 10.0.0.12
define cindys_laptop = 10.0.0.13

define backend_server = 192.168.200.21
define mysql_database = 192.168.200.22
define internal_web_app = 192.168.200.23

table inet filter {
    chain inventory-access-policies {
        # log access attempts
        log level debug prefix "inventory-access-policies: "
        # enforce access policies
        ip saddr . ip daddr . tcp dport {
            $alices_workstation . $wg_jumphost . 22,
            $alices_workstation . $backend_server . 22,
            $alices_workstation . $mysql_database . 3360,
            $alices_workstation . $internal_web_app . 80,
            $bobs_workstation . $mysql_database . 3360,
            $bobs_workstation . $internal_web_app . 80,
            $cindys_laptop . $internal_web_app . 80,
        } accept
        reject with icmpx type admin-prohibited
    }

    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 WireGuard packets received on a public interface
        iifname $lan_iface udp dport $wg_port accept
        # filter packets inbound from WireGuard network through inventory-access-policies chain
        iifname $wg_iface goto inventory-access-policies

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

    chain wg-forward {
        # forward all icmp/icmpv6 packets
        meta l4proto { icmp, ipv6-icmp } accept
        # forward all packets that are part of an already-established connection
        ct state vmap { invalid : drop, established : accept, related : accept }

        # filter through inventory-access-policies chain
        goto inventory-access-policies
    }

    chain forward {
        type filter hook forward priority 0; policy drop;

        # filter packets inbound from WireGuard network through wg-forward chain
        iifname $wg_iface goto wg-forward
        # forward packets outbound to WireGuard network that are part of an already-established connection
        oifname $wg_iface ct state vmap { invalid : drop, established : accept, related : accept }

        # reject with polite "host unreachable" icmp response
        reject with icmpx type host-unreachable
    }
}
table inet nat {
    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        # masquerade all packets from WireGuard network to LAN network
        iifname $wg_iface oifname $lan_iface masquerade
    }
}

The first section of this nftables config file contains a bunch of variable definitions. Replace the lan_iface variable value with the actual LAN interface name on the jumphost, and the wg_port variable value with the WireGuard listen port you chose in the Configure WireGuard on the Jumphost section above.

define lan_iface = "eth0"
define wg_iface = "wg0"
define wg_port = 51820

Also replace all the IP address definitions with definitions for the actual systems in your WireGuard and private site’s networks:

define wg_jumphost = 10.0.0.1
define alices_workstation = 10.0.0.11
define bobs_workstation = 10.0.0.12
define cindys_laptop = 10.0.0.13

define backend_server = 192.168.200.21
define mysql_database = 192.168.200.22
define internal_web_app = 192.168.200.23

Below that, in the inventory-access-policies chain, replace the access policy rules with rules that match the inventory table you drew up in the Inventory the Systems section above:

    chain inventory-access-policies {
        # log access attempts
        log level debug prefix "inventory-access-policies: "
        # enforce access policies
        ip saddr . ip daddr . tcp dport {
            $alices_workstation . $wg_jumphost . 22,
            $alices_workstation . $backend_server . 22,
            $alices_workstation . $mysql_database . 3360,
            $alices_workstation . $internal_web_app . 80,
            $bobs_workstation . $mysql_database . 3360,
            $bobs_workstation . $internal_web_app . 80,
            $cindys_laptop . $internal_web_app . 80,
        } accept
        reject with icmpx type admin-prohibited
    }

Notice that each line in the policy rule above (such as $alices_workstation . $wg_jumphost . 22) corresponds to a line from the Inventory the Systems table: the first value of the first line, $alices_workstation (10.0.0.11), corresponds to the “WireGuard IP Address” column; the second value, $wg_jumpost (10.0.0.1), corresponds to the “Site IP Address” column; and the third value, 22, corresponds to the “Port” column.

Tip

If you need to allow access to any UDP services, you’ll need a separate rule for them. The following example shows a rule that allows access from Alice’s workstation to the back-end server on UDP port 3389 (remote desktop):

        ip saddr . ip daddr . udp dport {
            $alices_workstation . $backend_server . 3389,
        } accept

The next chain definition, the main input filter hook, locks down access to the jumphost itself. The only inbound connections it allows to the jumphost (other than ICMP requests, which are used for “ping” and other network signaling) are through WireGuard. It also limits new connections to 1 per second (after an initial burst of 10 connections):

    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 WireGuard packets received on a public interface
        iifname $lan_iface udp dport $wg_port accept
        # filter packets inbound from WireGuard network through inventory-access-policies chain
        iifname $wg_iface goto inventory-access-policies

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

The next two chains work similarly to lock down connections that the jumphost will forward, limiting it to forwarding WireGuard connections only:

    chain wg-forward {
        # forward all icmp/icmpv6 packets
        meta l4proto { icmp, ipv6-icmp } accept
        # forward all packets that are part of an already-established connection
        ct state vmap { invalid : drop, established : accept, related : accept }

        # filter through inventory-access-policies chain
        goto inventory-access-policies
    }

    chain forward {
        type filter hook forward priority 0; policy drop;

        # filter packets inbound from WireGuard network through wg-forward chain
        iifname $wg_iface goto wg-forward
        # forward packets outbound to WireGuard network that are part of an already-established connection
        oifname $wg_iface ct state vmap { invalid : drop, established : accept, related : accept }

        # reject with polite "host unreachable" icmp response
        reject with icmpx type host-unreachable
    }

The last section of this nftables config file masquerades the packets it forwards from the WireGuard network to the private site’s network, so that packets from WireGuard clients will appear to the other servers in the site’s network as if they came from the jumphost itself (which allows replies to those packets to be correctly routed back through the jumphost, without additional configuration):

table inet nat {
    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        # masquerade all packets from WireGuard network to LAN network
        iifname $wg_iface oifname $lan_iface masquerade
    }
}

You can activate this ruleset by running the following command on the jumphost:

$ sudo nft -f /etc/nftables.conf
Warning

If you are currently SSH’d into the jumphost, do not activate this ruleset yet! — wait until you have successfully set up a WireGuard client that can administer the jumphost through SSH. Otherwise, you will drop your SSH connection, and you will not be able to reconnect.

Tip

If you’re using systemd on the jumphost, run the following commands when you are ready to activate the ruleset, as well to apply the ruleset on system boot:

$ sudo systemctl restart nftables
$ sudo systemctl enable nftables
Created symlink /etc/systemd/system/multi-user.target.wants/nftables.service → /lib/systemd/system/nftables.service.

Finally, adjust the private site’s network firewall to allow UDP port 51820 on the jumphost to be accessed from anywhere on the public Internet. Also make sure that the jumphost itself has been granted network access to all the services you listed in the Inventory the Systems section above.

Configure WireGuard on a Client

Now on Alice’s workstation, install WireGuard on it. On Windows or macOS, launch the WireGuard application, and add a new empty tunnel — the WireGuard app will automatically generate a new key pair for you.

On Linux, run the following commands to generate a key pair:

$ wg genkey > alice.key
$ wg pubkey < alice.key > alice.pub
$ cat alice.key
ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
$ cat alice.pub
fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=

On Windows or macOS, keep the private key generated for you, and edit the tunnel configuration to be the following:

# local settings for Alice's Workstation
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.11

# remote settings for the jumphost
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
Endpoint = 198.51.100.10:51820
AllowedIPs = 192.168.200.0/24, 10.0.0.1

On Linux, edit the /etc/wireguard/wg0.conf file to include the same content, pasting in the private key from the contents of the alice.key file generated via the wg genkey command.

Configure the rest of the fields like this:

[Interface] Address

Set this to the WireGuard IP address you designated for the client in the Inventory the Systems section.

[Peer] PublicKey

Copy the contents of the jumphost.pub file you generated in the Configure WireGuard on the Jumphost section, and paste it in here.

[Peer] Endpoint

Set this to the public IP address of the jumphost (198.51.100.10 in this example), plus the WireGuard listen port of the jumphost (51820).

[Peer] AllowedIPs

Set this to the list of internal network blocks used by the private site (just 192.168.200.0/24 in this example). For clients that may need to administer the jumphost itself, add the jumphost’s own WireGuard IP address (10.0.0.1). These ranges should cover all the IP addresses listed in the “Site IP Address” column of the table drawn up in the Inventory the Systems section.

Copy the public key (the alice.pub file, or the public key shown for the interface in the Windows or macOS app) from Alice’s Workstation to the jumphost (and you can now delete the alice.key file, if you had generated it). On the jumphost, edit the /etc/wireguard/wg0.conf file to add a [Peer] entry for Alice’s Workstation:

# jumphost /etc/wireguard/wg0.conf

# local settings for the jumphost
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51820
PreUp = sysctl -w net.ipv4.conf.all.forwarding=1

# remote settings for Alice's Workstation
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
AllowedIPs = 10.0.0.11

Paste in the public key copied from Alice’s workstation, and set the AllowedIPs field to the WireGuard IP address you designated for her workstation in the Inventory the Systems section. You will probably also want to track the public key of each WireGuard client as another field in your inventory, so you can rebuild the jumphost configuration on demand from it.

On the jumphost, reload the updated WireGuard configuration by running the following command:

$ sudo bash -c 'wg syncconf wg0 <(wg-quick strip wg0)'

Now when you run the wg command on the jumphost, you should see the newly added peer listed:

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

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

Test It Out

Back on the client, Alice’s workstation, start up the WireGuard tunnel to the jumphost. Under Windows or macOS, select the tunnel in the WireGuard app, and click the “Activate” button. Under Linux, run the following command:

$ sudo wg-quick up wg0
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.0.0.11 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 192.168.200.0/24 dev wg0
[#] ip -4 route add 10.0.0.1 dev wg0

Now try SSH’ing into the jumphost from Alice’s workstation, using the jumphost’s WireGuard IP address (and Alice’s credentials):

$ ssh alice@10.0.0.1
...
alice@jumphost:~$

If that’s successful, and you’ve been using SSH to administer the jumphost, you can now safely activate the nftables firewall we set up in the Configure the Firewall on the Jumphost section:

alice@jumphost:~$ sudo nft -f /etc/nftables.conf

With the firewall up and operational, Alice will be able to access the other services in the private site’s network to which she’s been granted access.

She’ll be able to access the back-end server from her workstation:

$ ssh alice@192.168.200.21 -p 22
...
alice@backend:~$

And she’ll be able to access the MySQL database server from her workstation:

$ mysql -h 192.168.200.22 -P 3306
...
mysql>

And she’ll also be able to access the internal web-app from her workstation:

$ curl 192.168.200.23:80
<!DOCTYPE html>
<html>
...

The firewall logging we added in the Configure the Firewall on the Jumphost section will log every connection attempted from the WireGuard client through the jumphost (both when the connection is allowed and denied). These log messages will be sent to the kernel message buffer on the jumphost. The logging daemon on most Linux systems will automatically capture and store these messages; if you’re using systemd on the jumphost, you can view them using the following command:

$ journalctl --dmesg --grep inventory-access-policies
Nov 09 03:37:20 jumphost kernel: inventory-access-policies: IN=wg0 OUT=eth0 MAC= SRC=10.0.0.11 DST=192.168.200.21 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=50928 DF PROTO=TCP SPT=41612 DPT=22 WINDOW=64860 RES=0x00 SYN URGP=0
Nov 09 03:39:13 jumphost kernel: inventory-access-policies: IN=wg0 OUT=eth0 MAC= SRC=10.0.0.11 DST=192.168.200.22 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=57782 DF PROTO=TCP SPT=59294 DPT=3360 WINDOW=64860 RES=0x00 SYN URGP=0
Nov 09 03:40:01 jumphost kernel: inventory-access-policies: IN=wg0 OUT=eth0 MAC= SRC=10.0.0.11 DST=192.168.200.23 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=34377 DF PROTO=TCP SPT=35284 DPT=80 WINDOW=64860 RES=0x00 SYN URGP=0

You can now repeat the Configure WireGuard on a Client process for the rest of your clients (Bob’s workstation and Cindy’s laptop in this example), and they’ll have similar access to each service to which they’ve been granted access.

Tip

If you have an internal DNS server set up at the private site, you can configure your WireGuard clients to use it to resolve the DNS names for your internal services (so users can use friendly DNS names instead of IP addresses to access them). See the WireGuard With AWS Split DNS tutorial for an example of how to do this on AWS with Route53.