{ config, options, lib, ... }: with lib; let # A host needs to know a network if it # - is a configured interface, or # - is required behind at least two different links getHostLinkNetworks = hostName: link: let hostConfig = config.site.hosts.${hostName}; # all the host's links hostLinkNetworks = builtins.mapAttrs (link: _: networksBehindLink hostName link ) hostConfig.links; # how many links have a net in networksBehindLink networkLinkCount = net: builtins.length ( builtins.filter (builtins.elem net) (builtins.attrValues hostLinkNetworks) ); in # access port if config.site.net ? ${link} then [ link ] # multiple vlans on this link else builtins.filter (net: # this port and local interface hostConfig.interfaces ? ${net} || # this port and another networkLinkCount net > 1 ) hostLinkNetworks.${link}; networksBehindLink = hostName: name: networksBehindLink' { "${hostName}" = true; } [ name ]; networksBehindLink' = seen: links: if links == [] then # Done, result is the seen link names that are networks: builtins.filter (name: config.site.net ? ${name} ) (builtins.attrNames seen) else let link = builtins.head links; seen' = seen // { "${link}" = true; }; onlyUnseen = builtins.filter (link: ! seen' ? ${link}); links' = builtins.tail links; in if config.site.hosts ? ${link} then networksBehindLink' seen' (builtins.filter (link: ! seen' ? ${link}) ( links' ++ ( builtins.attrNames config.site.hosts.${link}.interfaces ) ++ ( onlyUnseen (builtins.attrNames config.site.hosts.${link}.links) ) )) else if config.site.net ? ${link} then networksBehindLink' seen' links' else throw "Link to invalid target ${link}"; dhcpOpts = { start = mkOption { description = "First IP address in pool"; type = types.str; }; end = mkOption { description = "Last IP address in pool"; type = types.str; }; time = mkOption { description = "Renew time in seconds"; type = types.int; }; max-time = mkOption { description = "Max renew time in seconds"; type = types.int; }; server = mkOption { description = "Container that runs the DHCP server"; type = types.str; }; router = mkOption { description = "Gateway"; type = types.str; }; fixed-hosts = mkOption { type = with types; attrsOf str; default = {}; }; }; netOpts = { name, ... }: { options = { vlan = mkOption { description = "VLAN tag number"; type = with types; nullOr int; }; subnet4 = mkOption { description = "v.w.x.y/z"; type = with types; nullOr str; default = null; }; subnet4Net = mkOption { type = with types; nullOr types.str; default = let inherit (config.site.net.${name}) subnet4; s = lib.splitString "/" subnet4; in if subnet4 != null && builtins.length s == 2 then builtins.head s else null; description = "Don't set, use subnet4 instead!"; }; subnet4Len = mkOption { type = with types; nullOr types.int; default = let inherit (config.site.net.${name}) subnet4; s = lib.splitString "/" subnet4; in if subnet4 != null && builtins.length s == 2 then lib.toInt (elemAt s 1) else null; description = "Don't set, use subnet4 instead!"; }; subnets6 = mkOption { description = "IPv6 subnets w/o prefixlen (always 64)"; type = with types; attrsOf str; default = {}; }; hosts4 = mkOption { description = "Attribute set of hostnames to IPv4 addresses"; type = with types; attrsOf str; default = {}; }; hosts6 = mkOption { description = "Attribute set of contexts to attribute sets of hostnames to IPv4 addresses"; type = with types; attrsOf (attrsOf str); default = {}; }; ospf = { secret = mkOption { type = with types; nullOr str; default = null; }; }; dhcp = mkOption { type = with types; nullOr (submodule { options = dhcpOpts; }); default = null; }; ipv6Router = mkOption { description = "Who sends router advertisements?"; type = with types; nullOr str; default = config.site.net.${name}.dhcp.router or null; }; domainName = mkOption { description = "Domain name option"; type = types.str; default = "${name}.zentralwerk.org"; }; extraRecords = mkOption { type = with types; listOf (submodule { options = { name = mkOption { type = str; }; type = mkOption { type = enum [ "A" "AAAA" "MX" "SRV" "CNAME" "TXT" ]; }; data = mkOption { type = str; }; }; }); default = []; description = "Extraneous DNS records"; }; dynamicDomain = mkOption { type = types.bool; default = false; description = "Domain updated by DHCP server?"; }; mtu = mkOption { type = with types; nullOr int; default = null; }; }; }; upstreamOpts = { provider = mkOption { type = types.str; }; link = mkOption { type = with types; nullOr str; default = null; description = "Underlying interface name for eg. PPPoE"; }; staticIpv4Address = mkOption { type = with types; nullOr str; default = null; }; upBandwidth = mkOption { type = with types; nullOr int; default = null; }; noNat.subnets4 = mkOption { type = with types; listOf str; default = []; description = "Do not NAT traffic from these public static subnets"; }; noNat.subnets6 = mkOption { type = with types; listOf str; default = []; description = "Do not NAT66 traffic from these public static subnets"; }; user = mkOption { type = with types; nullOr str; default = null; }; password = mkOption { type = with types; nullOr str; default = null; }; }; interfaceOpts = { ... }: { options = { hwaddr = mkOption { type = with types; nullOr str; default = null; description = "Static MAC address"; }; type = mkOption { type = types.enum [ "phys" "veth" "pppoe" "bridge" "wireguard" "vxlan" ]; description = '' veth: Virtual ethernet to be attached to a bridge. phys: (Physical) interface from a server moved into the container. Do not use with VLAN interfaces because they won't be moved back after lxc-stop. ''; }; gw4 = mkOption { type = with types; nullOr str; default = null; description = "IPv4 gateway"; }; gw6 = mkOption { type = with types; nullOr str; default = null; description = "IPv6 gateway"; }; upstream = mkOption { type = with types; nullOr (submodule { options = upstreamOpts; }); default = null; description = "Upstream interface configuration"; }; wireguard = mkOption { default = null; type = with types; nullOr (submodule { options = { endpoint = mkOption { type = str; }; publicKey = mkOption { type = str; }; privateKey = mkOption { type = str; }; addresses = mkOption { type = listOf str; }; }; }); }; vxlan = mkOption { default = null; type = with types; nullOr (submodule { options = { peer = mkOption { type = str; }; }; }); }; }; }; hostOpts = { name, ... }: { options = { prebuilt = mkOption { type = types.bool; default = false; description = '' Include the container system in the server's `build-container` script. ''; }; firstboot = mkOption { type = types.bool; default = false; description = '' true if host is a newly flashed OpenWRT device with a default address ''; }; role = mkOption { type = types.enum [ "ap" "switch" "server" "container" "client" ]; default = "client"; }; model = mkOption { type = types.str; default = { ap = "unknown"; switch = "unknown"; server = "pc"; container = "lxc"; client = "any"; }."${config.site.hosts.${name}.role}"; }; password = mkOption { type = with types; nullOr str; default = null; }; location = mkOption { type = with types; nullOr str; default = null; }; interfaces = mkOption { default = {}; type = with types; attrsOf (submodule interfaceOpts); description = "Network interfaces"; }; physicalInterfaces = mkOption { default = lib.filterAttrs (_: { type, ... }: builtins.elem type [ "phys" "veth" ] ) config.site.hosts.${name}.interfaces; type = with types; attrsOf (submodule interfaceOpts); description = "Network interfaces that are not virtual (don't set!)"; }; isRouter = mkOption { type = types.bool; # isRouter = Part of the core network? default = config.site.hosts.${name}.interfaces ? core && config.site.net.core.hosts4 ? ${name} && config.site.hosts.${name}.role == "container"; description = "Should this host route?"; }; firewall.enable = mkOption { type = types.bool; default = false; description = "Enable firewall to disallow incoming connections from core"; }; forwardPorts = mkOption { type = with types; listOf (submodule { options = { proto = mkOption { type = types.enum [ "tcp" "udp" ]; }; sourcePort = mkOption { type = types.int; }; destination = mkOption { type = types.str; }; reflect = mkOption { type = types.bool; default = true; description = '' Enable NAT reflection Any forwarded connection will have our static IPv4 address as source so that forwarded services become available internally. Unfortunately, this breaks identification by IPv4 adress. ''; }; }; }); default = []; }; ospf.stubNets4 = mkOption { type = with types; listOf str; default = []; description = "Additional IPv4 networks to announce"; }; ospf.stubNets6 = mkOption { type = with types; listOf str; default = []; description = "Additional IPv6 networks to announce"; }; ospf.allowedUpstreams = mkOption { type = with types; listOf str; default = []; description = "Accept default routes from these OSPF routers, in order of preference"; }; ospf.allowedUpstreams6 = mkOption { type = with types; listOf str; default = config.site.hosts.${name}.ospf.allowedUpstreams; description = "Accept IPv6 default routes from these OSPF3 routers, in order of preference"; }; ospf.upstreamInstance = mkOption { type = with types; nullOr int; default = null; description = "OSPF instance for advertising the default route"; }; bgp = mkOption { default = null; type = with types; nullOr (submodule { options = bgpOpts; }); }; services.dns = { enable = mkOption { type = types.bool; default = false; }; }; services.dnscache.enable = mkOption { type = types.bool; default = false; }; services.yggdrasil = { enable = mkOption { type = types.bool; default = false; }; keys = mkOption { type = types.str; default = ""; }; }; links = mkOption { description = "Which port is connected to what other device? Keys are either network names or known hostnames."; default = {}; type = with types; attrsOf (submodule (linkOpts name)); }; wifi = mkOption { default = {}; type = with types; attrsOf (submodule ( { ... }: { options = { htmode = mkOption { type = enum [ "HT20" "HT40-" "HT40+" "VHT80" ]; }; channel = mkOption { type = int; }; ssids = mkOption { type = attrsOf (submodule ({ config, ... }: { options = { net = mkOption { type = str; }; psk = mkOption { type = nullOr str; default = null; }; encryption = mkOption { type = enum [ "none" "owe" "wpa2" "wpa3" ]; default = if config.psk == null then "none" else "wpa3"; }; mode = mkOption { type = enum [ "ap" "sta" ]; default = "ap"; }; ifname = mkOption { type = nullOr str; default = null; }; }; })); }; }; } )); }; wifiOnLink.enable = mkOption { type = types.bool; default = true; description = '' Install the wifi-on-link.sh script on OpenWRT devices. ''; }; }; }; bgpOpts = { asn = mkOption { type = types.int; }; peers = mkOption { type = with types; attrsOf (submodule ({ ... }: { options = { asn = mkOption { type = types.int; }; }; })); default = {}; }; }; linkOpts = hostName: { name, ... }: { options = { ports = mkOption { type = with types; listOf str; description = "Port names"; }; group = mkOption { type = with types; nullOr str; default = null; description = "Link aggregation group with a fixed number"; }; nets = mkOption { type = with types; listOf str; description = "Automatically generated"; default = getHostLinkNetworks hostName name; }; vlans = mkOption { type = with types; listOf int; description = "Automatically generated, do not set"; default = builtins.concatMap (net: let inherit (config.site.net.${net}) vlan; in if vlan != null then [ vlan ] else [] ) config.site.hosts.${hostName}.links.${name}.nets; }; trunk = mkOption { type = types.bool; description = "Trunk with tagged VLANs?"; default = if config.site.net ? ${name} then false else if config.site.hosts ? ${name} then true else throw "Invalid link target: \"${name}\""; }; }; }; vpnOpts = { privateKey = mkOption { type = types.str; }; port = mkOption { type = types.int; default = 1337; }; peers = mkOption { type = with types; listOf (submodule { options = { publicKey = mkOption { type = str; }; allowedIPs = mkOption { type = listOf str; }; }; }); }; }; in { options.site = { net = mkOption { description = "All subnets"; default = {}; type = with types; attrsOf (submodule netOpts); }; net-combined = mkOption { description = "All hosts of all subnets"; default = {}; type = with types; submodule netOpts; }; hosts = mkOption { description = "All the static hosts"; default = {}; type = with types; attrsOf (submodule hostOpts); }; sshPubKeys = mkOption { type = with types; listOf str; }; dyndnsKey = mkOption { type = types.str; }; vpn.wireguard = vpnOpts; mqttServer = { host = mkOption { type = types.str; default = config.site.net.serv.hosts4.broker; }; user = mkOption { type = types.str; default = "user"; }; password = mkOption { type = types.str; default = "secret"; }; }; }; config.warnings = let findCollisions = getter: xs: (builtins.foldl' ({ known, dup }: k: let ks = builtins.toString k; in if known ? ${ks} then { inherit known; dup = dup ++ [ks]; } else { known = known // { ${ks} = true; }; inherit dup; } ) { known = {}; dup = []; } ( concatMap getter (builtins.attrValues xs) )).dup; reportCollisions = name: getter: xs: map (k: "Duplicate ${name}: ${k}") (findCollisions getter xs); ospfUpstreamXorGw = builtins.concatMap (hostName: let hostConf = config.site.hosts.${hostName}; gwNets = builtins.filter (netName: hostConf.interfaces.${netName}.gw4 != null ) (builtins.attrNames hostConf.interfaces); in if gwNets != [] && hostConf.ospf.allowedUpstreams != [] then [ '' Host ${hostName} has gateway on ${builtins.head gwNets} but accepts default routes from OSPF '' ] else [] ) (builtins.attrNames config.site.hosts); in (reportCollisions "VLAN tag" (x: lib.optional (x.vlan != null) x.vlan) config.site.net) ++ (reportCollisions "IPv4 subnet" (x: if x.subnet4 == null then [] else [x.subnet4]) config.site.net) ++ (reportCollisions "IPv6 subnet" (x: builtins.attrValues x.subnets6) config.site.net) ++ ospfUpstreamXorGw; config.assertions = # Duplicate host/net name check map (name: { assertion = ! config.site.net ? ${name}; message = "Host \"${name}\" must be named differently if net \"${name}\" exists."; }) (builtins.attrNames config.site.hosts) ++ # Duplicate address check (let addrHosts = builtins.foldl' (result: { hosts4, ... }: builtins.foldl' (result: host: let addr = hosts4.${host}; in if result ? ${addr} then result // { "${addr}" = result.${addr} ++ [ host ]; } else result // { "${addr}" = [ host ]; } ) result (builtins.attrNames hosts4) ) {} (builtins.attrValues config.site.net) // builtins.foldl' (result: net: builtins.foldl' (result: ctx: builtins.foldl' (result: host: let addr = config.site.net.${net}.hosts6.${ctx}.${host}; in if result ? ${addr} then result // { "${addr}" = result.${addr} ++ [ host ]; } else result // { "${addr}" = [ host ]; } ) result (builtins.attrNames config.site.net.${net}.hosts6.${ctx}) ) result (builtins.attrNames config.site.net.${net}.hosts6) ) {} (builtins.attrNames config.site.net); in map (addr: { assertion = builtins.length addrHosts.${addr} == 1; message = "Address ${addr} is assigned to more than one host: ${lib.concatStringsSep " " addrHosts.${addr}}"; }) (builtins.attrNames addrHosts)) ++ # duplicate vlan check (let vlanNets = builtins.foldl' (result: net: let vlan = toString config.site.net.${net}.vlan; in if config.site.net.${net}.vlan != null && result ? ${vlan} then result // { "${vlan}" = result.${vlan} ++ [ net ]; } else result // { "${vlan}" = [ net ]; } ) {} (builtins.attrNames config.site.net); in map (vlan: { assertion = builtins.length vlanNets.${vlan} == 1; message = "VLAN ${vlan} is used by more than one network: ${lib.concatStringsSep " " vlanNets.${vlan}}"; }) (builtins.attrNames vlanNets)) ++ # Duplicate switch port check builtins.concatMap (hostName: let ports = lib.unique ( builtins.concatMap (linkName: config.site.hosts.${hostName}.links.${linkName}.ports ) (builtins.attrNames config.site.hosts.${hostName}.links) ); linksOfPort = port: builtins.attrNames ( lib.filterAttrs (_: { ports, ... }: builtins.elem port ports) config.site.hosts.${hostName}.links ); in map (port: { assertion = builtins.length (linksOfPort port) == 1; message = "${hostName}: port ${port} is used in more than one link: ${lib.concatStringsSep " " (linksOfPort port)}"; }) ports ) (builtins.attrNames config.site.hosts) ++ # Duplicate switch port group check builtins.concatMap (hostName: let groups = lib.unique ( builtins.filter builtins.isString ( builtins.map (linkName: config.site.hosts.${hostName}.links.${linkName}.group ) (builtins.attrNames config.site.hosts.${hostName}.links) ) ); linksOfGroup = wantedGroup: builtins.attrNames ( lib.filterAttrs (_: { group, ... }: group == wantedGroup) config.site.hosts.${hostName}.links ); in map (group: { assertion = builtins.length (linksOfGroup group) == 1; message = "${hostName}: group ${group} is used in more than one link: ${lib.concatStringsSep " " (linksOfGroup group)}"; }) groups ) (builtins.attrNames config.site.hosts) ++ # wifi psk checks builtins.concatMap (hostName: builtins.concatMap (wifiPath: map (ssid: let ssidConf = config.site.hosts.${hostName}.wifi.${wifiPath}.ssids.${ssid}; in if builtins.elem ssidConf.encryption [ "none" "owe" ] then { assertion = ssidConf.psk == null; message = "${hostName}: SSID ${ssid} has encryption ${ssidConf.encryption} but a PSK is set"; } else if builtins.elem ssidConf.encryption [ "wpa2" "wpa3" ] then { assertion = ssidConf.psk != null; message = "${hostName}: SSID ${ssid} has encryption ${ssidConf.encryption} but no PSK is set"; } else throw "Unsupported WiFi encryption ${ssidConf.encryption}" ) (builtins.attrNames config.site.hosts.${hostName}.wifi.${wifiPath}.ssids) ) (builtins.attrNames config.site.hosts.${hostName}.wifi) ) (builtins.attrNames config.site.hosts); }