main
  1{lib, ...}: let
  2  octets = ip: lib.splitString "." ip;
  3  lastOctet = ip: lib.toInt (builtins.elemAt (octets ip) 3);
  4  prefix = ip: lib.concatStringsSep "." (lib.take 3 (octets ip));
  5  joinIP = prefix: last: "${prefix}.${toString last}";
  6in {
  7  den.aspects.services.kea = {
  8    settings.host = {
  9      interfaces = lib.mkOption {
 10        description = "Interfaces the DHCP server listens on.";
 11        type = lib.types.listOf lib.types.str;
 12      };
 13      staticRange = {
 14        start = lib.mkOption {
 15          description = "Start of the static IP range. Reservations must fall within it.";
 16          type = lib.types.str;
 17          default = "192.168.0.2";
 18        };
 19        end = lib.mkOption {
 20          description = "End of the static IP range. Reservations must fall within it.";
 21          type = lib.types.str;
 22          default = "192.168.0.50";
 23        };
 24      };
 25      poolEnd = lib.mkOption {
 26        description = "End of the dynamic pool. The pool starts right after the static range.";
 27        type = lib.types.str;
 28        default = "192.168.0.200";
 29      };
 30      router = lib.mkOption {
 31        description = "Default gateway advertised to clients.";
 32        type = lib.types.nullOr lib.types.str;
 33        default = null;
 34      };
 35      dnsServers = lib.mkOption {
 36        description = "DNS servers advertised to clients.";
 37        type = lib.types.listOf lib.types.str;
 38        default = [];
 39      };
 40      extraReservations = lib.mkOption {
 41        description = "Reservations for devices not managed by Nix, e.g. an AP.";
 42        type = lib.types.listOf (lib.types.submodule {
 43          options = {
 44            hostname = lib.mkOption {
 45              description = "Hostname for the device.";
 46              type = lib.types.nullOr lib.types.str;
 47              default = null;
 48            };
 49            mac = lib.mkOption {
 50              description = "MAC address to reserve.";
 51              type = lib.types.str;
 52            };
 53            ip = lib.mkOption {
 54              description = "Fixed IP to assign.";
 55              type = lib.types.str;
 56            };
 57          };
 58        });
 59        default = [];
 60      };
 61    };
 62
 63    nixos = {
 64      host,
 65      dhcpReservation,
 66      ...
 67    }: let
 68      cfg = host.settings.services.kea;
 69
 70      fromHosts = lib.unique (
 71        map (e: {
 72          hostname = e.source.host.name;
 73          mac = e.value.mac;
 74          ip = e.value.ip;
 75        })
 76        dhcpReservation
 77      );
 78
 79      reservations = cfg.extraReservations ++ fromHosts;
 80
 81      staticPrefix = prefix cfg.staticRange.start;
 82      staticStart = lastOctet cfg.staticRange.start;
 83      staticEnd = lastOctet cfg.staticRange.end;
 84      poolStart = joinIP staticPrefix (staticEnd + 1);
 85
 86      inStaticRange = ip:
 87        prefix ip
 88        == staticPrefix
 89        && lastOctet ip >= staticStart
 90        && lastOctet ip <= staticEnd;
 91
 92      nameOf = r:
 93        if r.hostname != null
 94        then r.hostname
 95        else r.ip;
 96
 97      macs = map (r: r.mac) reservations;
 98      ips = map (r: r.ip) reservations;
 99    in {
100      assertions =
101        (map (r: {
102            assertion = inStaticRange r.ip;
103            message =
104              "Kea reservation '${nameOf r}' has ip '${r.ip}' outside the static "
105              + "range '${cfg.staticRange.start} - ${cfg.staticRange.end}'";
106          })
107          reservations)
108        ++ [
109          {
110            assertion = lib.unique macs == macs;
111            message = "Kea reservations contain duplicate MAC addresses";
112          }
113          {
114            assertion = lib.unique ips == ips;
115            message = "Kea reservations contain duplicate IP addresses";
116          }
117        ];
118
119      services.kea.dhcp4 = {
120        enable = true;
121        settings = {
122          interfaces-config.interfaces = cfg.interfaces;
123          lease-database = {
124            name = "/var/lib/kea/dhcp4.leases";
125            persist = true;
126            type = "memfile";
127          };
128          valid-lifetime = 4000;
129          subnet4 = [
130            {
131              id = 1;
132              subnet = "${staticPrefix}.0/24";
133              pools = [{pool = "${poolStart} - ${cfg.poolEnd}";}];
134              reservations = map (r:
135                {
136                  hw-address = r.mac;
137                  ip-address = r.ip;
138                }
139                // lib.optionalAttrs (r.hostname != null) {hostname = r.hostname;})
140              reservations;
141              option-data =
142                lib.optional (cfg.router != null) {
143                  name = "routers";
144                  data = cfg.router;
145                }
146                ++ lib.optional (cfg.dnsServers != []) {
147                  name = "domain-name-servers";
148                  data = lib.concatStringsSep ", " cfg.dnsServers;
149                };
150            }
151          ];
152        };
153      };
154    };
155  };
156}