About NFS and WireGuard

NFS is a network file-system that’s included in the mainline Linux kernel. It’s everywhere that a complete Linux kernel exists. There are Windows and macOS clients for the protocol, and it can be used with Kubernetes and Docker Swarm. Out of the box, the protocol is not encrypted nor does it provide authentication.

WireGuard is a VPN protocol that’s in the process of being included in the mainline Linux kernel. Despite not being in the current mainline kernel, it’s easy to install on pretty much any Linux platform. It has cross-platform implementations, and there are WireGuard clients for Android, OpenWRT, macOS and Windows. Edit: within a few hours of writing this, WireGuard was slated for release in Linux 5.6.

Why tunnel NFS over a VPN?

As stated earlier, the NFS protocol is not encrypted. If you’re on an untrusted network, an attacker can easily sniff your NFS traffic. Even if you’re on a trusted network, an untrusted device on your network can sniff your traffic. On WiFi, an attacker doesn’t even need to connect to your network, because WEP, WPA, and WPA2 can be cracked. Enterprise-grade wireless encryption and isolation fare better, but why risk it?

The typical method of providing NFS authentication is via Kerberos. If you don’t already use Kerberos in your network infrastructure, there is an operational and cognitive overhead for its provision and maintenance. In my opinion, the less moving parts, the better.

NFS allows us to restrict access of our network shares to specific IP addresses, subnets and hostnames, giving us some privacy control. However, this is not entirely secure.

If we setup a VPN, we can have our NFS server only listen on that network, and not on the wider unencrypted network. Our VPN server can handle authentication and give our authenticated clients hardcoded IP addresses. In our NFS exports, we can restrict which IP addresses can access certain NFS resources, and have a fine-grained policy for both authenticated clients and our shared resources.

Why WireGuard?

WireGuard is a simple protocol built upon cryptographic primitives that are modern and secure. Its concise implementation gives it a smaller attack surface than other solutions. The protocol and implementation itself have been formally verified, and has the approval of security experts throughout the industry.

While OpenVPN is widely supported across platforms, it is a behemoth in comparison, which means that it has a wider attack surface. It’s relatively complicated to provision and maintain. As opposed to the in-kernel WireGuard protocol, there is at least one extra context switch between kernel networking and the userspace OpenVPN software. Not only that, but AES is resource intensive on platforms without hardware accelerated AES support, whereas ChaCha20-Poly1305 is performant on low-powered devices without hardware acceleration. Since I use many low-powered Linux devices, that context switch and lack of hardware acceleration for AES on OpenVPN has a measurable impact on the speed of my VPN traffic.

Authentication Model

Our authentication will be based on the public key authentication WireGuard provides, and restricting clients to specific IP addresses on our WireGuard network. Authentication is based on our users’ knowledge of a secret, in this case the private key from the key pair our WireGuard server knows about. This is otherwise known as cryptokey routing, and is one of the principles WireGuard is built upon.

WireGuard clients will request a specific IP address from our WireGuard server. Our server will only provision a unique but unchanging IP to the client with the corresponding public key. That way, we can know that traffic from that address is definitely from our authenticated client. We can then use that IP address in our NFS exports to restrict our shares and privileges to only that user.

We can think of those client IP addresses as being our users, and their private keys as passwords. From there, we can configure our NFS server using the basic IP address restrictions the kernel server provides.

NFS over WireGuard

First, we need to choose a subnet for our VPN. I’m going to use 10.8.0.0/24 for our subnet, and 10.8.0.1 as the address for our WireGuard server.

For this article, our NFS server and WireGuard host will have the hostname host.

Setting up NFS

Server Installation

If you’re on a Debian-based Linux distribution, you can install an NFS server like so:

sudo apt install nfs-kernel-server

Server Configuration

We need to setup our NFS server to listen only on 10.8.0.1. We’re also going to configure the server to use only NFSv4, versus older versions of the protocol.

On a Debian-based distribution, our configuration file lives on /etc/default/nfs-kernel-server. We’re going to edit the RPCNFSDOPTS and RPCMOUNTDOPTS variables in that file.

To force NFSv4, we need to disable NFSv2 and NFSv3. NFSv4 has less moving parts and operates only on a single port. Older versions of NFS use many ports and require ancillary services to run alongside the NFS server.

Add the following to the configuration file:

RPCNFSDOPTS="-N 2 -N 3"

Next, we’re going to instruct the server to listen on 10.8.0.1 and to further disable older versions of the NFS protocol:

RPCMOUNTDOPTS="--manage-gids -N 2 -N 3 -H 10.8.0.1"

To restrict the NFS server to listen only on the specified host, the /etc/nfs.conf must be created or modified with the following configuration section.

[nfsd]
host=10.8.0.1

Make sure you save the configuration files.

Adding Network Shares

Let’s create the directory structures and bind mounts needed for exportfs.

sudo mkdir -p /export/example
sudo touch /export/example/it_worked

Now we need to edit /etc/exports to allow a client to mount our example share:

/export                  10.8.0.0/24(fsid=0,no_subtree_check)
/export/example          10.8.0.0/24(rw,sync,no_subtree_check,crossmnt,no_root_squash)

The first line and fsid option sets the root for our shares. With fsid and crossmnt, we can exclude the /export prefix on our client at mount time, and just mount /export/example as /example.

The second line will allow any client on the 10.8.0.0/24 subnet to mount /export/example as readable and writable. The sync option makes writes synchronous, while async makes them asynchronous. I find that synchronous writes are easier to reason about.

no_root_squash will prevent files that belong to root from being exported with ownership to the nobody user. I want my network shares to reflect their source ownership as accurately as possible, so I enable this option.

Let’s export our shares:

sudo exportfs -a

Starting the Server

First, let’s disable rpcbind because it isn’t required for NFSv4:

sudo systemctl mask rpcbind.service
sudo systemctl mask rpcbind.socket

Then, start the server, or restart it if it’s already running:

sudo systemctl restart nfs-kernel-server.service

Great, now let’s test it out on a client machine. Ironically, if everything works as configured, mounting the share should fail, but with specific errors.

Configuring the NFS Client

Connect a separate Linux machine to your network, such that it can reach your host via it’s hostname host.

Client Installation

Again, assuming you’re using a Debian-based platform, install the NFS client software like so:

sudo apt install nfs-common

Testing NFS Security

We’re going to try to mount our network share, and hopefully it will fail. Replace the hostname host in the follow commands with the hostname of the machine running your NFS server.

sudo mkdir /mnt/NFS
sudo mount -t nfs -o vers=4.2 host:/example /mnt/NFS

Hopefully, you will be met with one of the following errors:

mount.nfs: access denied by server while mounting host:/example

Or, this error:

mount.nfs: Operation not permitted

Setting up WireGuard

Installing WireGuard

Head over to the official WireGuard website and choose your installation option.

On my platform, no configuration is needed and the following will install the needed packages:

sudo apt install wireguard

After the software is installed, insert the wireguard module into the kernel:

sudo modprobe wireguard

Generating Key Pairs

Our server will need to generate a key pair, and each of our clients will need to generate their own unique key pairs.

On both our server and client issue the following. Remember to treat your private keys like passwords, and to delete the privatekey files when we are done with them.

umask 077 
wg genkey > privatekey
wg pubkey < privatekey > publickey

Configuring the WireGuard Server

On the server, identify the network device you’re using to connect to your network. I am using eth0. Replace eth0 with your network device.

You will need to choose a port to listen on. In this case, we’ll use 51820.

Copy the contents of our server’s privatekey to your clipboard. Then, create a file located at /etc/wireguard/wgNFS.conf. Keep the contents of your first client’s publickey nearby, as we will use it in that file.

[Interface]
Address = 10.8.0.1/24

PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE;iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT;iptables -A FORWARD -m conntrack --ctstate NEW -s 10.8.0.1/24 -m policy --pol none --dir in -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.8.0.1/24 -m policy --pol none --dir out -j MASQUERADE; iptables -A INPUT -p udp --dport 51820 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

ListenPort = 51820
PrivateKey = HOST_PRIVATE_KEY_GOES_HERE

# client1
[Peer]
PublicKey = CLIENT_PUBLIC_KEY_GOES_HERE
AllowedIPs = 10.8.0.2/32

Configuring the WireGuard Client

For your client, you will need your host’s publickey and your client’s privatekey.

Create a file located at /etc/wireguard/wgNFS.conf:

[Interface]
Address = 10.8.0.2/24
PrivateKey = CLIENT_PRIVATE_KEY_GOES_HERE

[Peer]
PublicKey = HOST_PUBLIC_KEY_GOES_HERE
Endpoint = host:51820
AllowedIPs = 10.8.0.0/24

The last line tells the WireGuard client to route traffic on the 10.8.0.0/24 subnet through the WireGuard server endpoint.

Delete privatekey Files

On your host and clients delete your privatekey files after configuring WireGuard.

rm privatekey

Starting and Connecting to the VPN

On both the host and client, but starting with the host, issue:

sudo wg-quick up wgNFS

Test that the connection works by pinging the host from the client:

$ ping -c 1 10.8.0.1
PING 10.8.0.1 (10.8.0.1) 56(84) bytes of data.
64 bytes from 10.8.0.1: icmp_seq=1 ttl=64 time=0.89 ms

--- 10.8.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.893/0.893/0.893/0.000 ms

Mount NFS Share over WireGuard

On the client machine, run the following:

sudo mount -t nfs -o vers=4.2 10.8.0.1:/example /mnt/NFS

Test to see if the share mounted.

$ ls /mnt/NFS
it_worked

You should be able to access the it_worked example file we created earlier.

Making it Permanent

WireGuard via systemd

On both the server and client, add a WireGuard service to systemd. This service will start at boot.

sudo systemctl enable [email protected]
sudo systemctl daemon-reload

Tear down the existing wgNFS connection on both machines and then create the VPN connection via the new systemd service.

# stop WireGuard
sudo wg-quick down wgNFS

# start WireGuard via systemd
sudo systemctl start [email protected]

Unmount the example share on the client.

sudo umount -f /mnt/NFS

Mount NFS Share Automatically via systemd

Add an entry to /etc/fstab on the client with the parameters for the shares.

10.8.0.1:/example   /mnt/NFS    nfs vers=4.2,_netdev,noauto,x-systemd.automount,[email protected]

Reload systemd and remote-fs.target.

sudo systemctl daemon-reload 
sudo systemctl restart remote-fs.target

Now, whenever the mountpoint is accessed, systemd will automatically mount the NFS share to the mountpoint specified in /etc/fstab.

$ ls /mnt/NFS
it_worked