Once upon a time I wrote

With that in mind, I’ve decided to defer the redesign of modules until later down the line when I also need to do some serious thinking about services. There’s no point considering one without the other.

and today I’m pleased to say I think I have a plan. To recap the problem:

  • modules are (notionally, at least) functions that can change the global config, but are singletons - you can’t have an arbitrary number of “pptp” modules configured differently to each other, unless they agree between themselves on how to use config so that each knows not to look at attributes for the others.

  • a service is just a derivation, so you can have as many services as you want and they’re independent of each other. But as a derivation it can’t affect global state - it can’t create a user, or add to the kernel config, or change the busybox applets.

So how do we resolve this? We’re taking the obvious-in-hindsight path: do both! Where a service depends on global state (e.g. firewall support depends on kernel nftables options) then instead of making that service globally accessible in pkgs, we define it in a module that specifies the required state. Including the module does not create any services in itself, but makes the service definition available in config.system.service.firewall. So you do

  imports = [
    ../modules/firewall
  ];

  services.firewall = config.system.service.firewall {
    ruleset = import ./my-firewall-ruleset.nix;
  };

If you don’t add the import, there is no config.system.service.firewall, so no risk of accidentally adding the service without its prerequisite global config.

The other half of making this user-friendly is that we typeckeck the service definition parameters, using the NixOS module type system. For example, in the dnsmasq service we see

  t = {
    user = mkOption {
      type = types.str;
      default = "dnsmasq";
    };
    group = mkOption {
      type = types.str;
      default = "dnsmasq";
    };
    resolvconf = mkOption {
      type = types.nullOr liminix.lib.types.service;
      default = null;
    };
    interface = mkOption {
      type = liminix.lib.types.service;
      default = null;
    };
    upstreams = mkOption {
      type = types.listOf types.str;
      default = [];
    };
    ranges = mkOption {
      type = types.listOf types.str;
    };
    domain = mkOption {
      type = types.str;
    };
  };

(Some of these types are still a bit “loose”: we could use a type for IP addresses/networks, and a type for “s6 service that describes a network interface” instead of “any s6 service”. And I’ve been very slack about adding description fields, but they will be super-useful too when we get to the point of producing documentation from these definitions.)

As a related cleanup, we moved config.outputs to config.system.outputs, because it’s not right to be spattering derivations and build products all over the “config” namespace that rightfully ought to be for configuration. (By that token config.system is still philosophically a misuse of config, but it’s the only misuse of config we’re going to have: everything we want to carry around in our modules is going to live under that prefix). We also now use the module type system to specify the outputs by name instead of just having a grab bag that any module can add new names to. (I am grateful to Samuel Dionne-Riel here for the description and reasoning in mobile-nixos#406 describing the Mobile NixOS switch from system.build to mobile.outputs)

Current status: this is in progress

  • There are still services inlined in rotuer.nix that need to be moved into modules

  • Likewise there are services in pkgs.liminix.networking that need the same treatment: we aim to get rid of pkgs/liminix-tools

  • more work on the actual types and less use of str and anything types

  • yet to do: figure out how to produce documentation from the module and service definitions and incorporate that into the manual

  • There are “glue” services in rotuer.nix that bridge one subsystem to another - for example, “acquire lan prefix”, which depends on the output of dhcp6 and controls the ipv6 address of the lan device. It’s not yet obvious to me where they should live.

  • some services (e.g. ssh, though that might change if dropbear gets privilege separation and needs a non-root user specified) have no real dependencies on any global state, but it’s still A Bit Weird to have services scattered between config.system.service and pkgs, so maybe we’ll add them to the base module?