nixos - wireguard & modules
Refactoring#
In my previous post I attacked the kubernetes configuration using only one module, the rest created relied on functions.
It didn’t feel idiomatic and when trying to add wireguard, the cracks started to appear. It felt like I was fighting with the language.
After some wonderful duck debugging with the excellent community on IRC, I decided to refactor what I had in to modules.
Modules#
The NixOS wiki has an excellent article on modules, but the gist is that a module is a set that has the following syntax:
{
imports = [
# paths to other modules that should be included
];
options = [
# define options that can be used to determine the
# resulting config
];
config = [
# the processed config that describes your desired
# state based on options
];
}
Usually the module is a function: { config, pkgs, ... }: {}.
There are also variations on the syntax where options aren’t required and the
returning scope is essentially config.
Wireguard Module#
I wanted the nodes to operate over a wireguard mesh so that I could potentially run nodes on different networks and guarantee nothing was unintentionally exposed.
Our wireguard.nix module looks like:
{ lib, pkgs, config, ... }:
with lib;
let
# make a shorthand for our config
# that is passed in/set by the caller
cfg = config.role.wireguard;
in {
# here we define the options we want to
# be able to expose to the caller which
# help to
options.role.wireguard = {
enable = mkEnableOption "wireguard peer";
interface = mkOption {
type = types.str;
default = "enp0s3";
};
# an option that expects a list of strings
ips = mkOption { type = types.listOf types.str; };
privateKey = mkOption { type = types.str; };
peers = mkOption { type = types.listOf types.attrs; };
};
config = mkIf cfg.enable {
# using cfg, create the actual definition
# as described at:
# https://nixos.wiki/wiki/Wireguard
};
}
When calling the module we are able to set the options (massively simplified to highlight the module functionality):
{
imports = [
./wireguard.nix
# other imports
];
# set the options that were declared in `wireguard.nix`
role.wireguard = {
enable = true;
# create a list of ips that this node owns
ips = pkgs.lib.mapAttrsToList
createWireguardIP
metadata.hosts."${hostname}".wireguard.ips;
privateKey =
metadata.hosts."${hostname}".wireguard.privateKey;
peers = wireguardPeers;
};
# other settings, overrides and option definitions
}
Peers#
For each peer you need to make available a list of all peers on the mesh. This
required me to add additional information to the hosts.toml file, so that a
node config became:
[hosts.il]
diskUUID = "01b3bcba-c0ef-4538-8dd2-f7dee2952322"
host = "192.168.10.121"
wireguard.privateKey = "...080EQPtydDdvqJS9+gvIpSDXzaunsKEGY="
wireguard.publicKey = "...nGK6CzvrAImlx9I7u0nybtUtORdgpflFA="
wireguard.ips = { "10.0.0.1" = "32" }
Correctly managing secrets will be an upcoming post.
To create the peer list we map over the hosts.toml.hosts set:
# create a wireguard ip
createWireguardIP = ip: cidr: "${ip}/${cidr}";
# create a wireguard peer (called with key/value)
createWireguardPeer = hostname: value: {
publicKey = value.ireguard.publicKey;
# create a list of ips that this peer owns
allowedIPs = pkgs.lib.mapAttrsToList
createWireguardIP
value.wireguard.ips;;
# endpoint to connect to peer
endpoint = "${value.host}:51280";
};
# build a list of wireguard peers
wireguardPeers =
pkgs.lib.mapAttrsToList
# function to call with the key/value
createWireguardPeer
# the set to iterate over
metadata.hosts;
Deploy & Test#
To deploy we just run nixops deploy -d kluster and once running we can check
the status of wireguard as well as check routing:
nixops ssh-for-each -d kluster -- wg show
nixops ssh-for-each -d kluster -- ping -c 4 10.0.0.1
Thoughts#
For me this method is much easier to reason with than the previous function
heavy approach, I am still able to keep things generic by wrapping it all in a
function: node = makeNode "hostname", but I am able to extract the specifics
out in to their own files.
I would like to improve my hosts.toml file so that I can instead define the
options for individual roles in there, making the deployment file even more
generic.
I’m sure eventually once I’m using machines with varying hardware I will regret that decision, but for now it makes sense.