{ 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' ( 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 = types.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; }; 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; }; 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; }; 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?"; }; }; }; 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.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 = { name, ... }: { options = { hwaddr = mkOption { type = with types; nullOr str; default = null; description = "Static MAC address"; }; type = mkOption { type = types.enum [ "phys" "veth" "pppoe" "bridge" ]; 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"; }; }; }; 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}; 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.upstreamInstance = mkOption { type = with types; nullOr int; default = null; description = "OSPF instance for advertising the default route"; }; wireguard = mkOption { default = {}; type = with types; attrsOf (submodule ( { name, ... }: { options = { endpoint = mkOption { type = str; }; publicKey = mkOption { type = str; }; privateKey = mkOption { type = str; }; addresses = mkOption { type = listOf str; }; upBandwidth = mkOption { type = with types; nullOr int; }; }; } )); }; 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; }; 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 ( { name, ... }: { options = { htmode = mkOption { type = enum [ "HT20" "HT40-" "HT40+" "VHT80" ]; }; channel = mkOption { type = int; }; ssids = mkOption { type = attrsOf (submodule ( { name, ... }: { options = { net = mkOption { type = str; }; psk = mkOption { type = nullOr str; default = null; }; }; } )); }; }; } )); }; }; }; bgpOpts = { asn = mkOption { type = types.int; }; peers = mkOption { type = with types; attrsOf (submodule ({ name, ... }: { 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 = map (net: config.site.net.${net}.vlan ) 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}\""; }; }; }; in { options.site = { net = mkOption { description = "All subnets"; default = {}; type = with types; attrsOf (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; }; }; 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: [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 = map (name: { assertion = ! config.site.net ? ${name}; message = "Host \"${name}\" must be named differently if net \"${name}\" exists."; }) (builtins.attrNames config.site.hosts); }