nix wireguard

Published as part of 'Wireguard Nix Flake' series.

Wireguard1 is a Linux Kernel Module which allows you to easily configure encrypted network interfaces, and it's one of my 'must haves' on any machine. When you can setup your own interfaces, you can self assign IP addresses, control how different processes access the internet, and rest easy knowing that every packet is always end to end encrypted.

This makes routing easy because you always know where a machine is located (the IP address you assigned it) no matter if you're on your home network, at the office, or in a cafe. When Wireguard is configured properly, the kernel abstracts having to do complex stuff like NAT punching to establish a connection, which saves you the headache of having to port forward every service behind a router. This makes up for the Internet Protocols lack of ability to give every machine it's own address, at least as long as you personally have less than 16,777,216 devices (and 18,446,744,073,709,551,616 if you use IPv6). For this tutorial we are going to be sticking with IPv4 - but if you have more than 16 million devices and are reading this tutorial then please hire me to manage them! I will write cursed IPv6 Nix code for you.

It also makes security easy, because you know all communication between machines on the network is end to end encrypted. This means no more configuring certificates for each service, or hooking them up to a reverse proxy if they don't have that ability. It also provides a rudimentary form of authentication. If you can connect to the network at all, then all the other nodes must know who you are so they can decrypt your traffic. This makes it easy to restrict access to some special services behind not only an interface, but by CIDR as well. This blanket encryption approach makes up for the Internet Protocols lack of security, and means that every request is private and not leaking metadata to my ISP, TLA or CA's.

However, Wireguard requires a configuration which is shared between all devices on each interface to work. If machines A, B, and C are part of the same network interface, they need to agree ahead of time on who has which IP, which machines control which routes, and which keys to use when communicating between them. This makes it ideal for a Nix configuration, where we can store all this information in one file, and have it auto configure the interface when the system rebuilds in accordance with all the other machines which import the same Nix Flake. This ensures that each machine in the network agrees on the interface's routes, and can safely share the keys necessary for encrypted communication.

Wireguard Flake Setup

This series assumes you are familiar with Nix, Flakes, and a little bit with Wireguard and Agenix.

We will setup a Nix Flake which will allow you to autoconfigure machines into Wireguard network interfaces in the following ways:

  1. Using one machine as a proxy for a set of other machines
  2. Using a separate daemon to automatically mesh a set of machines into a complete graph
  3. Combine a set of machines into a connected graph (if possible) using static routes

However, before we begin with that, we need to setup the project so that we have a nice foundation to build on. In this post we will review the basic project structure, and create the NixOS Module and Options we will use to configure it.

Project Overview

Here an overview of the files in the project. This structure will change some as we progress and add new features, but each post will start here to give you something to refer to as you're reading, and to know what has changed.

If you want to see a file in detail, checkout the repo at: https://codeberg.org/xvrqt/wgqt-flake/src/branch/tutorial

# Wireguard Nix Project Layout
################################

# Imports nixosModule.nix which contains our module
flake.nix

# Imports options.nix to setup this module's options for configuration
nixosModule.nix

# Sets up the options to all the user to configure Wireguard networks
options.nix

Boring!

The flake and nixosModule are so simple we're not even going to cover them in this post as flake.nix just imports nixosModule.nix for its nixosModule.default. Similary, nixosModule.nix just imports options.nix and where this part of the tutorial will focus on.

Options

The only thing this module does at the moment is setup options which all our Wireguard interfaces will need in order to configure themselves properly. So what does a Wireguard interface configuration file need to be complete?

  1. The private key of the machine it is running on
  2. A set of machines to connect to
  3. The public key of each of those machines

Easy Enough!

Creating Options

Let's setup some high level options which every network we setup will need going forward. We will store our options under: networking.wgqt = { ... }; to keep it separate from other networking configuration code, and ensure that everyone knows this module is for Wireguard and cuties.

{
  options = {
    networking = {
      wgqt = {
        # Our options here
      };
    };
  };
}

Enable

The first thing we should add is an option to enable/disble the module completely. We set it to true by default, because we assume you want to use wgqt functionality if you're including it in your project at all. It's not technicaly on our list of things a Wireguard configuration needs, but it is good module etiquette.

We add the import lib so we can create NixOS options, turning our attrset into a function.

{lib, ...}:
with lib;
{
  options = {
    networking = {
      wgqt = {
        enable = mkOption {
          type = types.bool;
          default = true;
          description = "Enable being a Wireguard cutie.";
        };
      };
    };
  };
}

Set of Machines

We also want to know about all the machines we will use in our interfaces, so we create an attribute set of machines, with their name being the key and the value being all the information we need to know about them to setup the connection.

To represent all the information we must know about a machine for Wireguard purposes, we create a new type, machine_type. For now, the only information a machine needs to have is their Wireguard public key, but we will be adding more information later which is why this isn't a simple { name = "public_key"; } attribute set.

We can setup type validation for the public_key to ensure it's the correct length for a Wireguard public key (or is the placeholder value "!").

{lib, ...}:
with lib;
let
  # The option submodule type for an individual machine
  machine_type = types.attrsOf (
    types.submodule {
      options = {
        public_key = mkOption {
          type = types.addCheck types.str (s: (s == "!") || ((stringLength s) == 44));
          default = "!";
          example = "CZc/OcuvBGUGDSll32yIidvRZr4WWRpKhS/a/ccPuwA=";
          description = "This machine's Wireguard public key.";
        };
      };
    });
in
{
  options = {
    networking = {
      wgqt = {
        # enable = mkOption { ... };
        machines = mkOption {
          type = types.attrsOf machine_type;
          default = {};
          description = "An attribute set of your machines, containing their public key.";
        };
      };
    };
  };
}

Okay, that gets two birds stoned at once:

  1. The private key of the machine it is running on
  2. A set of machines to connect to
  3. The public key of each of those machines

This Machine

This is the name of the machine which the interface will be built for, which we can use to lookup its attributes in the machines = {} attribute set. This is important because while the network topology is the same for every machine, the interface needs to be setup from the perspective of each individual machine and the machines it can directly connect to.

{lib, config, ...}:
let
  # machine_type = mkOption { ... };
in
with lib;
{
  options = {
    networking = {
      wgqt = {
        # enable = mkOption { ... };
        # machines = mkOption { ... };
        machine = mkOption {
          type = types.str;
          example = "laptop";
          description = "The name of the machine listed in networking.wgqt.machines which is the machine for which the interface will be built (i.e. this machine).";
        };
    };
  };
}

This Machine's Private Key File

The last thing on our list is the Wireguard private key of this machine (the same machine listed in the option above). We don't store private keys, or secrets of any kind, in the Nix Config because it's not secure. Instead we will read the key from a file, and only store the path in the Nix Config. We will store the path as a string, instead of a Nix Path type because otherwise it will be copied in to the world readable /nix/store which is the opposite of what we want.

Do you think the behavior of Nix Path types is confusing? You're correct! If you really want to scream, try creating a Path from a String and vice-versa without looking up how to do it. What a total nightmare.

{lib, ...}:
let
  # machine_type = mkOption { ... };
in
with lib;
{
  options = {
    networking = {
      wgqt = {
        # enable = mkOption { ... };
        # machine = mkOption { ... };
        # machines = mkOption { ... };
        private_key_file = mkOption {
          type = types.str;
          description = "Path to a Wireguard private key which matches this machines Wireguard public key.";
        };
    };
  };
}

With this option set, we're all good to go!

  1. The private key of the machine it is running on
  2. A set of machines to connect to
  3. The public key of each of those machines

Config Checks

While these options are not too complicated we shoudl add some basic sanity checks to help ensure that as we build off of them, the things we assume are true - are actually true. Since Nix has infamously unhelpful error messages, we can create some assertions which will help users understand why their config is not building closer to the source instead of an obtuse expected set found <<THUNK>> deeply nested in the bowels of routing config code.

Things we want to check for:

  1. There are at least two machines (otherwise we can't make a network)
  2. The machine name matches one of the machines in the set
  3. A Wireguard private key file path is set
  4. Each machine in machines has a public key set

So let's update our let ... in with these checks.


{ lib, config, ... }:
with lib; with builtins;
let
  cfg = config.networking.wgqt;

  # machine_type  = types.submodule { ... };

   # Config Checks
  cfgCheck = cfg.enable;
  machineNames = attrNames cfg.machines;
  atLeastTwoMachines = (length machineNames) >= 2;
  nameExtantInMachines = hasAttr cfg.machine cfg.machines;
  privateKeyPathIsSet = cfg.private_key_file != null;
  machinesWithoutPublicKeys = (attrNames
    (filterAttrs
      (_: machine: machine.public_key == "!")
      cfg.machines));
in {}

The last one checks if a public_key == "!" which is its default placeholder value. We must use a placeholder value because machine_type is an option submodule so checks like machine?public_key will always return true.

Now for their matching assertions:

{
  # Ensure that our options are set correctly
  config = {
    assertions = [
      # Check that there are at least two machines
      {
        assertion = cfgCheck && atLeastTwoMachines;
        # Maybe check if there is also at least one proxy or network
        message = ''You must have at least two machines in `networking.wgqt.machines` to form a Wireguard interface.'';
      }
      # Check that each 'machine' in 'machines' has a 'public_key' set
      {
        assertion = cfgCheck && ((length machinesWithoutPublicKeys) == 0);
        # Maybe check if there is also at least one proxy or network
        message = ''The following machines in `networking.wgqt.machines` do not have a `public_key` set: ${concatStringsSep ", " machinesWithoutPublicKeys}.'';
      }
      # Check that 'machine' exists in 'machines'
      {
        assertion = cfgCheck && nameExtantInMachines;
        # Maybe check if there is also at least one proxy or network
        message = ''The machine listed in `networking.wgqt.machine` must exist in `networking.wgqt.machines`.'';
      }
      # Check that the private key path is set
      {
        assertion = cfgCheck && privateKeyPathIsSet;
        message = ''No private key set in ``networking.wgqt.private_key_file`'';
      }
    ];
  };
}

We also create a systemd tmpfile rule to ensure that which ever file the user sets, it is read only by the root user.

    config = {
    # assertions = [ ... ];

    # Ensure the Wireguard private key has the correct owner and permissions
    systemd.tmpfiles.rules = [
      "z ${cfg.private_key_file} 0400 root root -"
    ];
  };
}

Systemd tmpfile rules are really powerful and I recommend learning their basics if you do a lot of configuration of daemons or systems. If you don't do a lot of that... why are you reading this? Unless you're my sister who said she would read it because she loves me; in which case: omg hai~~ <3

What Have We Wrought

Let's see our final options.nix all put together [source]

{ lib, config, ... }:
with lib; with builtins;
let
  # Shorthand
  cfg = config.networking.wgqt;

  # The option submodule type for an individual machine
  machine_type = types.submodule {
    options = {
      public_key = mkOption {
        type = types.addCheck types.str (s: (s == "!") || ((stringLength s) == 44));
        default = "!";
        example = "CZc/OcuvBGUGDSll32yIidvRZr4WWRpKhS/a/ccPuwA=";
        description = "This machine's Wireguard public key";
      };
    };
  };

  # Config Checks
  cfgCheck = cfg.enable;
  machineNames = attrNames cfg.machines;
  atLeastTwoMachines = (length machineNames) >= 2;
  nameExtantInMachines = hasAttr cfg.machine cfg.machines;
  privateKeyPathIsSet = cfg.private_key_file != null;
  machinesWithoutPublicKeys = (attrNames
    (filterAttrs
      (_: machine: machine.public_key == "!")
      cfg.machines));

in
{
  # Ensure that our options are set correctly
  config = {
    assertions = [
      # Check that there are at least two machines
      {
        assertion = cfgCheck && atLeastTwoMachines;
        # Maybe check if there is also at least one proxy or network
        message = ''You must have at least two machines in `networking.wgqt.machines` to form a Wireguard interface.'';
      }
      # Check that each 'machine' in 'machines' has a 'public_key' set
      {
        assertion = cfgCheck && ((length machinesWithoutPublicKeys) == 0);
        # Maybe check if there is also at least one proxy or network
        message = ''The following machines in `networking.wgqt.machines` do not have a `public_key` set: ${concatStringsSep ", " machinesWithoutPublicKeys}.'';
      }
      # Check that 'machine' exists in 'machines'
      {
        assertion = cfgCheck && nameExtantInMachines;
        # Maybe check if there is also at least one proxy or network
        message = ''The machine listed in `networking.wgqt.machine` must exist in `networking.wgqt.machines`.'';
      }
      # Check that the private key path is set
      {
        assertion = cfgCheck && privateKeyPathIsSet;
        message = ''No private key set in ``networking.wgqt.private_key_file`'';
      }
    ];

    # Ensure the Wireguard private key has the correct owner and permissions set
    systemd.tmpfiles.rules = [
      "z ${cfg.private_key_file} 0600 root root -"
    ];
  };

  # The actual options themselves
  options = {
    networking = {
      wgqt = {
        enable = mkOption {
          type = types.bool;
          description = "Enable being a Wireguard cutie.";
          default = true;
        };

        # Private key which will be used to secure all the interfaces generated
        # by this module
        private_key_file = mkOption {
          type = types.str;
          description = "Path to a Wireguard private key which matches this machines Wireguard public key.";
        };

        # This Machine's name
        # Must match one of the keys in the machines attribute set
        machine = mkOption {
          description = "The name of the machine listed in networking.wgqt.machines which is the machine for which the interface will be built (i.e. this machine).";
          type = types.str;
        };

        # Attrset of machines that can be part of wgqt networks
        machines = mkOption {
          description = "An attribute set of your machines, containing their public key.";
          type = types.attrsOf machine_type;
          default = defaults.machines;
        };
      };
    };
  };
}

Not bad, about a 100 lines including comments.

Sample Config

Using our newly created Flake we could try our options like so:

# Our System's flake.nix
{
  description = "My NixOS configuration";

  inputs = {
    # Other inputs...
    wgqt.url = "git+https://codeberg.org/xvrqt/wgqt-flake?ref=tutorial";
  };

  outputs = inputs @ {
     # Other parameters
     wgqt,
     ...
    }: {
      nixosConfigurations = {
          nyaa = nixpkgs.lib.nixosSystem {
            # pkgs = { ... };
            modules = [
              # Other modules
              wgqt.nixosModules.default
              {
                networking.wgqt = {
                  enable = true;
                  machine = "nyaa";
                  private_key_file = "/secrets/wg/priv.key";
                  machines = {
                    nyaa = {
                      public_key = "CZc/OcuvBGUGDSll32yIidvRZr4WWRpKhS/a/ccPuwA=";
                    };
                    spark = {
                      public_key = "wOhi1aPMOsEtgaEBNkXl3/CV2vmMcBV3Rc6YO6T+hTg=";
                    };
                    lighthouse = {
                      public_key = "c8IBktSuL7bN/2cCxfRULV+JexCjQwXQVxSQvVrimk0=";
                    };
                    archive = {
                      public_key = "X7VQqHnVWUmT7T/5+U6cZHkKA0dm+AVmgp1ba8bTqlc=";
                    };
                    tavern = {
                      public_key = "17Usc2wwxBjh0uBfhV4RC4u5KSVq9nTaUqglNcXkhyo=";
                    };
                  };
                };
              }
            ];
          };
        };
    };
}

In the future we'll elide the rest of the flake to focue on the config, but it's helpful for new Nix users to see how modules in flakes are imported and used. If you do include your module, and you set all the options correctly... nothing should happen. This is because our module doesn't have any side effects. We don't install or configure anything yet, and we're still about one blog post away from doing so.

Fin

Wow, that was a lot to just set up the basic project struture, an instatiate the NixOS Module with a few high level options. I had fun, and I hope you did too because in the next chapter we are going to create a package which will auto generate Wireguard public/private key pairs, preshared key pairs for each pair of machines, and then encrypt them all using Agenix.

It might surprise you, but I am not a fan of writing Nix code - and even less so of writing Bash script.So may god have mercy on me as I try to write Nix code which generates a Bash script, which generates Nix code which generates cryptographic keys which are then encrypted by different cryptographic keys.

  1. https://www.man7.org/linux/man-pages/man8/wg.8.html