Help with writing a NixOS Module

Howdy,

tl;dr :

I wrote some nix code that works and I cargo-culted a nix module 
from an existing one that seems to work as well, but I would like
some help in getting the functionality from my nix code into the module.

Lately I’ve been using a bunch of tinc VPN networks and while the supplied
tinc module on NixOS works pretty great, per network I can create a
network.nix and import it into my configuration:

    { config, lib, pkgs, ... }:
    
    {
      networking.firewall.allowedTCPPorts = [
        655
      ];
      networking.firewall.allowedUDPPorts = [
        655
      ];
    
      networking.interfaces."tinc.example".ipv4.addresses = [
        {
          address = "10.0.0.1";
          prefixLength = 24;
        }
      ];
    
      services.tinc.networks = {
        example = {
          name = "node0";
          hosts = {
            node0 = ''
              Address = 192.168.122.1
              Subnet = 10.0.0.1
              Subnet = 192.168.0.0/24
              Ed25519PublicKey = ...
              -----BEGIN RSA PUBLIC KEY-----
              ...
              -----END RSA PUBLIC KEY-----
            '';
            node1 = ''
              Subnet = 10.0.0.2
              Port = 655
              Ed25519PublicKey = ...
              -----BEGIN RSA PUBLIC KEY-----
              ...
              -----END RSA PUBLIC KEY-----
            '';
            node2 = ''
              Address = 192.168.122.3
              Subnet = 10.0.0.3
              Ed25519PublicKey = ...
              -----BEGIN RSA PUBLIC KEY-----
              ...
              -----END RSA PUBLIC KEY-----
            '';
          };
        };
      };
    }

However after some time, I noticed two things:

  1. my config files grew into an unmaintainable mess as the number of hosts
    inside the VPNs increased
  2. Since tinc is a mesh VPN it is a nice Idea to deploy the keys of all
    participating nodes to all participating nodes and doing this inside the configuration

At this point I did a bit of searching and found out that it should be possible to use builtins.readFile:

    { config, lib, pkgs, ... }:
    
    {
      networking.firewall.allowedTCPPorts = [
        655
      ];
      networking.firewall.allowedUDPPorts = [
        655
      ];
    
      networking.interfaces."tinc.example".ipv4.addresses = [
        {
          address = "10.0.0.1";
          prefixLength = 24;
        }
      ];
    
      services.tinc.networks = {
        example = {
          name = "node0";
          hosts = {
            node0 = (builtin.readFile /path/to/node0-config);
            node1 = (builtin.readFile /path/to/node1-config);
            node2 = (builtin.readFile /path/to/node2-config);
          };
        };
      };
    }

The above solves the problem of my configuration files becoming long, but I
still need to declare them in the configuration. At this point I started playing
around with nix. I started up nix repl and put together the following:

    { config, lib, pkgs, ... }:
    
    let
    
      node_name    = "node0";
      vpn_name     = "example";
      tcp_port     = 655;
      udp_port     = 655;
      ipv4_address = "10.0.0.1";
      ipv4_prefix  = 24;
    
    
      files        = builtins.readDir ("/etc/nixos/vpn/tinc/" + vpn_name);
      filenames    = builtins.attrNames files;
      filepaths    = map (x: "/etc/nixos/vpn/tinc/" + vpn_name + "/" + x) filenames;
      filecontents = map builtins.readFile filepaths;
      jsondata     = map (x: builtins.fromJSON x) filecontents;
    in
    {
    
    
      networking.firewall.allowedTCPPorts = [
        tcp_port
      ];
      networking.firewall.allowedUDPPorts = [
        udp_port
      ];
    
      networking.interfaces.("tinc." + vpn_name).ipv4.addresses = [
        {
          address      = ipv4_address;
          prefixLength = ipv4_prefix;
        }
      ];
    
      services.tinc.networks = {
        mcrn0 = {
          name = node_name;
    
          hosts = let
            attrsetdata = builtins.listToAttrs jsondata;
          in attrsetdata;
    
        };
      };
    }

At this point I can put all my VPN config files into (in this case) JSON format,
put them into a directory and fill out 6 values to set up the VPN:

    $ tree /etc/nixos/vpn/tinc/example
    /etc/nixos/vpn/tinc/example
    β”œβ”€β”€ node0.json
    β”œβ”€β”€ node1.json
    └── node2.json
    $ bat /etc/nixos/vpn/tinc/example/node0.json
    ───────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
           β”‚ File: /etc/nixos/vpn/tinc/example/node0.json
    ───────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       1   β”‚ {
       2   β”‚   "name" : "node0",
       3   β”‚   "value" : "Subnet = 10.0.0.1\nPort = 655\nEd25519PublicKey = ...\n-----BEGIN RSA PUBLIC KEY-----\n...\n-----END RSA PUBLIC KEY-----"
       4   β”‚ }
    ───────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

So far so good, however I’d like to manage more than one VPN and would prefer to
keep the functions at a single place instead of duplicating the nix code from
above. As far as I understand this is where modules come in.

The next thing I looked at was how to write a module (especially writing one
that is using the <name> feature was quite confusing to be honest). I played
around with a bunch of things, and finally ended up just taking the original
tinc module and changing it up a bit. This is what I’ve ended up with so far
(since it is tinc but I’m trying to do things different, I’ve dubbed it
tincDifferent) and so far it does not do too much:

    { config, lib, pkgs, ... }:
    
    with lib;
    
    let
    
      cfg = config.services.tincDifferent;
    
    in
    
    {
    
      options = {
    
        services.tincDifferent = {
    
          networks = mkOption {
            default = { };
            type = with types; attrsOf (submodule {
              options = {
    
                nodeName = mkOption {
                  default = null;
                  type = types.nullOr types.str;
                  description = ''
                    Name of the Node in the tinc network.
                  '';
                };
    
                tcpPort = mkOption {
                  default = 655;
                  type = types.int;
                  description = ''
                    TCP port of the tinc network.
                  '';
                };
    
                udpPort = mkOption {
                  default = 655;
                  type = types.int;
                  description = ''
                    UDP port of the tinc network.
                  '';
                };
    
                ipv4Address = mkOption {
                  default = null;
                  type = types.nullOr types.str;
                  description = ''
                    IPv4 Address of the machine on the tinc network.
                  '';
                  example = "10.0.0.1";
                };
    
                ipv4Prefix = mkOption {
                  default = null;
                  type = types.nullOr types.int;
                  description = ''
                    IPv4 Prefix of the machine on the tinc network.
                  '';
                  example = 24;
                };
    
              };
            });
    
            description = ''
              Defines the tinc networks which will be started.
              Each network invokes a different daemon.
            '';
          };
        };
    
      };
    
      config = {
    
        networking.firewall = fold (a: b: a // b) { }
          (flip mapAttrsToList cfg.networks (network: data:
            {
              allowedTCPPorts = [ data.tcpPort ];
              allowedUDPPorts = [ data.udpPort ];
            }
          ));
    
        networking.interfaces = fold (a: b: a // b) { }
          (flip mapAttrsToList cfg.networks (network: data:
          {
          "tinc.${network}".ipv4.addresses = [
            {
              address      = data.ipv4Address;
              prefixLength = data.ipv4Prefix;
            }
          ];
        }
        ));
    
      };
    }

I’ve basically introduced a few variables and for now setting the TCP and UDP
ports as well as the Interface seems to work, I’m basically using the fold and
flip code from the original tinc module to grab the network name as well as
the data configured for the network.

I can now use something like this to set up a network interface and open ports:

{ config, lib, pkgs, ... }:

{
  imports = [ ./modules/tincDifferent.nix ];

  services.tincDifferent.networks.example.nodeName = "node0";
  services.tincDifferent.networks.example.ipv4Address = "10.0.0.1";
  services.tincDifferent.networks.example.ipv4Prefix = 24;
}

What I’m really struggling with is the part from my previous code where I loop
over the files inside the directory and set up the nodes one by one. This is the
part where I’m can’t seem to make any progress… guess I’ve reached my limit of
what is possible with a bit of cargo culting and now I’m standing on top of my
fake runway wearing fake Headphones and waving sticks in the air, hoping for
some mighty being to drop me a bunch of nix expressions from the enigmatic
heavens.

So far this is what I’ve got, obviously it does not work :pleading_face: and I can’t seem to
fold or flip my head around it:

    services.tinc.networks = fold (a: b: a // b) { }
      (flip mapAttrsToList cfg.networks (network: data:
    {
      ${network} = {
        name = data.nodeName;
      };
    }
    ));

Before ending this cry for help, I would like to describe the rest of what’s on
my mind regarding this little VPN project, there are basically two additional
goals and so far I’m not sure I can tackle them by myself:

  1. I would like to have the ability to set up routes on a per host base. tinc
    itself is able to do so. Here I’m not sure which option I have to use, there
    is an easy way to set up routes on a per interface basis, but I’d set the
    route up by supplying a next hop, so I guess I’ll have to use these options
    somehow:
        networking.interfaces.<name>.ipv4.routes.*.via = ?
        networking.interfaces.<name>.ipv4.routes.*.prefixLength = ?

Here I’m not sure, on the commandline I just set the routes up like this:

        $ ip route add 192.168.0.0/24 via 10.0.0.1
  1. Secondly I’d like to stick to keeping a single configuration file per host.
    In my code above I’ve used JSON to store the name and config of the hosts
    and then used builtins.listToAttrs and builtins.fromJSON in order to
    first get a list and then get a attrset. I’ve tried writing some nix code
    so I can configure all of my hosts with .nix files, however trying to write
    something up didn’t really turn out to be very fruitful. Ideally I’d like to
    have a way of defining additional configuration on a per hosts base and from
    what is my understanding using plain .nix files would enable me to directly
    define stuff such as routes and at the same time take out information like
    the node name and its configuration in form of an attrset and feed it to the
    function that sets up tinc. I hope that makes any sense…

Thanks for reading this long text! Any help I can get is really appreciated :slight_smile:

1 Like

What an excellently prepared question! Indeed long, but so entertaining to read that I was stoked to give those things a look.

This looks correct to me, and (in isolation) evaluates to what I would expect. What is the output of that expression, what did you expect? Is there an error? flip does not really change anything here, it just swaps the arguments to mapAttrsToList, probably for readability. Actually one could write this a lot shorter. There is no need to convert to a list in between, there is a builtin to map over attribute values. We can use that since the keys are not changed:

services.tinc.networks = builtins.mapAttrs (network: data: { name = data.nodeName; }) cfg.networks;

From looking at the source it looks like that * means it’s a list of things with those attributes. Therefore usage would be like this:

networking.interfaces.<name>.ipv4.routes = [
  { address = "192.168.0.0"; prefixLength = 24; via = "10.0.0.1"; }
];

I did not now that before today, so how did I find this? Go to NixOS options search, type ipv4 routes, click on something that looks right, click on the link for Declared In and skim through the code to find where that thing in question is defined.

If I understand this correctly, with your module in place you have already achieved this. Your system configuration would then have this:

# configuration.nix
{ ...}:
{
  imports = [
    ./modules/tinkDifferent.nix
    ./config/host1.nix
    ./config/host2.nix
  ];
}
# host1.nix
{ ... }:
{
  services.tincDifferent.networks.example = {
    nodeName = "node0";
    ipv4Address = "10.0.0.1";
    ipv4Prefix = 24;
  };
}

Although you might just as well import the module for each host (instead of once at the top level), depending on how flexible you want to be when adding those host definitions to different systems.

Welcome to the forum and good luck with your endeavor!

What an excellently prepared question! Indeed long, but so entertaining to read that I was stoked to give those things a look.

Thanks, I tried to come prepared… :slight_smile:

What is the output of that expression, what did you expect? Is there an error?

Well I think, my problem at this point was that the code I used stopped making sense at some point in tome:

What I wanted it to do is basically:

  1. grab the name of the network
  2. then create a path that points to where the host config files are (In my case this is /etc/nixos/vpn/tinc/network/).
  3. inside this path, there are n files, each describing one VPN node. Currently in my nix code I do this:
    • JSON, so I have to readDir β†’ attrNames to get the file names inside
      the path
    • then to use readFile β†’ fromJSON, where I get a list of key/value pairs
      in JSON that contain the name of the VPN node and its configuration.
    • finally use listToAttrs to convert this list of key values into a
      single attrset, which I supply to the
      services.tinc.networks.network.hosts variable to set up the VPN
      participants.

And at this point into my answer I noticed, where my actual problem was… guess I was in
dire need of a rubber duck, Thanks for your help! :duck:

Initially I started out with the assumption that I needed to create a list of
pairs and then feed this list of pairs into services.tinc.networks.network.hosts

Then while writing (and with the help of a friend) I noticed on the NixOS
Options page, that actually I was trying to create a single attrset and using builtins.listToAttrs I could simply combine the pairs from the JSON files into a single attrset, which just worked. The Idea however that I needed to create a list of pairs was at that
point in time stuck in my head. This is why in my first example I’m using two
let statements, where in fact one would suffice:

let

  ...
  files        = builtins.readDir ("/etc/nixos/vpn/tinc/" + vpn_name);
  filenames    = builtins.attrNames files;
  filepaths    = map (x: "/etc/nixos/vpn/tinc/" + vpn_name + "/" + x) filenames;
  filecontents = map builtins.readFile filepaths;
  jsondata     = map (x: builtins.fromJSON x) filecontents;
in
{
  ...
  hosts = let
    attrsetdata = builtins.listToAttrs jsondata;
  in attrsetdata;
  ...
}

Since I don’t need to use two let statements here, I can just use something
like this instead and then I get the attrset I’m looking for:

  services.tinc.networks = builtins.mapAttrs (network: data: {
    name = data.nodeName;
    hosts = let
      files        = builtins.readDir ("/etc/nixos/vpn/tinc/" + network);
      filenames    = builtins.attrNames files;
      filepaths    = map (x: "/etc/nixos/vpn/tinc/" + network + "/" + x) filenames;
      filecontents = map builtins.readFile filepaths;
      jsondata     = map (x: builtins.fromJSON x) filecontents;
      attrsetdata = builtins.listToAttrs jsondata;
    in attrsetdata;
  }) cfg.networks;

At this point I’m happy to report that my module seems to work. This is the Code
so far:

{ config, lib, pkgs, ... }:

with lib;

let

  cfg = config.services.tincDifferent;

in

{

  options = {

    services.tincDifferent = {

      networks = mkOption {
        default = { };
        type = with types; attrsOf (submodule {
          options = {

            nodeName = mkOption {
              default = null;
              type = types.nullOr types.str;
              description = ''
                Name of the Node in the tinc network.
              '';
            };

            port = mkOption {
              default = 655;
              type = types.int;
              description = ''
                TCP / UDP port used byt the tinc network (The Port has to be supplied in the node configuration as well, since the original tinc module takes the Port from there).
              '';
            };

            ipv4Address = mkOption {
              default = null;
              type = types.nullOr types.str;
              description = ''
                IPv4 Address of the machine on the tinc network.
              '';
              example = "10.0.0.1";
            };

            ipv4Prefix = mkOption {
              default = null;
              type = types.nullOr types.int;
              description = ''
                IPv4 Prefix of the machine on the tinc network.
              '';
              example = 24;
            };

          };
        });

        description = ''
          Defines the tinc networks which will be started.
          Each network invokes a different daemon.
        '';
      };
    };

  };

  config = {

    networking.firewall = fold (a: b: a // b) { }
      (flip mapAttrsToList cfg.networks (network: data:
        {
          allowedTCPPorts = [ data.port ];
          allowedUDPPorts = [ data.port ];
        }
      ));

    services.tinc.networks = builtins.mapAttrs (network: data: {
      name = data.nodeName;
      hosts = let
        files        = builtins.readDir ("/etc/nixos/vpn/tinc/" + network);
        filenames    = builtins.attrNames files;
        filepaths    = map (x: "/etc/nixos/vpn/tinc/" + network + "/" + x) filenames;
        filecontents = map builtins.readFile filepaths;
        jsondata     = map (x: builtins.fromJSON x) filecontents;
        attrsetdata = builtins.listToAttrs jsondata;
      in attrsetdata;
    }) cfg.networks;

    networking.interfaces = fold (a: b: a // b) { }
      (flip mapAttrsToList cfg.networks (network: data:
      {
      "tinc.${network}".ipv4.addresses = [
        {
          address      = data.ipv4Address;
          prefixLength = data.ipv4Prefix;
        }
      ];
    }
    ));

  };
}

I can now create multiple tinc networks by simply doing something like this:

{ config, lib, pkgs, ... }:

{
  imports = [ ./modules/tincDifferent.nix ];

  services.tincDifferent.networks.0.nodeName = "node0";
  services.tincDifferent.networks.0.ipv4Address = "10.0.0.1";
  services.tincDifferent.networks.0.ipv4Prefix = 24;
  services.tincDifferent.networks.0.port = 655;

  services.tincDifferent.networks.1.nodeName = "node0";
  services.tincDifferent.networks.1.ipv4Address = "10.0.1.2";
  services.tincDifferent.networks.1.ipv4Prefix = 24;
  services.tincDifferent.networks.1.port = 656;
}

Thanks a lot for your input!

The next thing I plan to do is to replace the JSON files with nix files. I’ve already played around with this in a repl and I’m pretty sure I can use something similiar to this in order to configure the nodes, and then use (import file).extraConfig and (import file).tincConfig separately:

{
  extraConfig=
  {
    ipv4routes = [
      { address = "192.168.0.0"; prefixLength = 24; via = "10.0.0.1"; }
    ];
  };
  tincConfig =
  {
    name="node0";
    config="Subnet = 10.0.0.1\nPort = 655\nEd25519PublicKey = ...\n-----BEGIN RSA PUBLIC KEY-----\n...\n-----END RSA PUBLIC KEY-----";
  };
}

Exactly, that’s the general idea! :slight_smile:

Thanks! And again, thank you very much for your help :slight_smile: