{ config, lib, ... }: let cfg = config.router; ipv4 = (import ./lib.nix { inherit lib; }).ipv4; lansByName = cfg.lans; lans = lib.attrValues lansByName; validIp = s: ipv4.parse s != null; hostnameOk = s: builtins.match "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" s != null; # A DNS name: labels of letters, digits, dashes and underscores. dnsNameOk = s: builtins.match "[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?(\\.[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?)*" s != null; # Assertions about a single LAN; skipped (reported once) when its addresses # do not even parse, so we never feed null into the integer helpers. perLan = name: lan: let where = "router.lans.${name}"; addrOk = validIp lan.ipv4.address; startOk = validIp lan.dhcp.pool.start; endOk = validIp lan.dhcp.pool.end; addr = ipv4.parse lan.ipv4.address; start = ipv4.parse lan.dhcp.pool.start; end = ipv4.parse lan.dhcp.pool.end; net = { address = addr; inherit (lan.ipv4) prefixLength; }; parsed = addrOk && startOk && endOk; in [ { assertion = addrOk; message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address."; } { # IFNAMSIZ is 16 including the terminating NUL. assertion = lib.stringLength lan.device <= 15; message = "router: ${where}: device name `${lan.device}` is longer than 15 characters."; } ] ++ lib.optionals (lan.ipv6.address != null) ( let m = builtins.match "([0-9a-fA-F:]+)/([0-9]{1,3})" lan.ipv6.address; in [ { assertion = isV6 lan.ipv6.address && v6Cidr lan.ipv6.address && m != null; message = "router: ${where}.ipv6.address `${lan.ipv6.address}` is not an IPv6 address in CIDR notation."; } ] ++ lib.optionals (m != null) [ { assertion = builtins.elemAt m 1 == "64"; message = "router: ${where}.ipv6.address must be a /64 (SLAAC needs one)."; } { assertion = builtins.match "[fF][cCdD][0-9a-fA-F]{2}:.*" (builtins.elemAt m 0) != null; message = "router: ${where}.ipv6.address must be a unique-local address (fc00::/7)."; } ] ) ++ lib.optionals lan.dhcp.enable ( [ { assertion = startOk && endOk; message = "router: ${where}.dhcp.pool.start/end must be valid IPv4 addresses."; } ] ++ lib.optionals parsed [ { assertion = start <= end; message = "router: ${where}.dhcp.pool starts after it ends."; } { assertion = ipv4.inSubnet net start && ipv4.inSubnet net end; message = "router: ${where}.dhcp.pool is not inside ${lan.ipv4.subnet}."; } { assertion = !(start <= addr && addr <= end); message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}."; } ] ) ++ lib.concatLists ( lib.mapAttrsToList ( host: r: let rwhere = "${where}.dhcp.reservations.${host}"; rok = validIp r.address; in [ { assertion = hostnameOk host; message = "router: ${rwhere}: `${host}` is not a valid hostname (letters, digits and dashes)."; } { assertion = builtins.match "([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}" r.mac != null; message = "router: ${rwhere}.mac `${r.mac}` is not a MAC address like aa:bb:cc:dd:ee:ff."; } { assertion = rok; message = "router: ${rwhere}.address `${r.address}` is not a valid IPv4 address."; } { assertion = lan.dhcp.enable; message = "router: ${rwhere}: reservations need dhcp.enable on the LAN."; } ] ++ lib.optionals (rok && addrOk) [ { assertion = ipv4.inSubnet net (ipv4.parse r.address); message = "router: ${rwhere}.address ${r.address} is not inside ${lan.ipv4.subnet}."; } { assertion = r.address != lan.ipv4.address; message = "router: ${rwhere}.address is the router's own address."; } ] ) lan.dhcp.reservations ) ++ ( let rs = lib.attrValues lan.dhcp.reservations; macs = map (r: lib.toLower r.mac) rs; addrs = map (r: r.address) rs; in [ { assertion = lib.length macs == lib.length (lib.unique macs); message = "router: ${where}.dhcp.reservations: two reservations share a MAC address."; } { assertion = lib.length addrs == lib.length (lib.unique addrs); message = "router: ${where}.dhcp.reservations: two reservations share an address."; } ] ); wgs = lib.attrValues cfg.wireguard; zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs); isV6 = lib.hasInfix ":"; v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null; v6Cidr = s: let m = builtins.match "([0-9a-fA-F:.]+)/([0-9]{1,3})" s; in isV6 s && m != null && lib.toInt (builtins.elemAt m 1) <= 128; validCidr = s: v4Cidr s || v6Cidr s; lanNets = map (l: { address = ipv4.parse l.ipv4.address; inherit (l.ipv4) prefixLength; }) (lib.filter (l: validIp l.ipv4.address) lans); # IPv4 networks a tunnel brings into its zone (own addresses and what peers may send). wgV4Nets = w: lib.filter (n: n != null) ( map ipv4.parseNet ( lib.filter (n: !isV6 n && v4Cidr n) ( w.addresses ++ lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers) ) ) ); zoneV4Nets = zone: map (l: { address = ipv4.parse l.ipv4.address; inherit (l.ipv4) prefixLength; }) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans) ++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs); portOk = p: if builtins.isInt p then p >= 1 && p <= 65535 else let m = builtins.match "([0-9]+)-([0-9]+)" p; lo = lib.toInt (builtins.elemAt m 0); hi = lib.toInt (builtins.elemAt m 1); in lo >= 1 && hi <= 65535 && lo < hi; ruleAssertions = r: let where = "router.firewall.rules (${r.from} -> ${r.to})"; fromWan = r.from == "wan"; v6Dest = r.destination != null && isV6 r.destination; dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination; inZone = dest != null && lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) ( zoneV4Nets r.to ); singleHost = dest != null && dest.prefixLength == 32; plainPorts = lib.all builtins.isInt r.ports; internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs; in [ { assertion = fromWan || lib.elem r.from zoneNames; message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames})."; } { assertion = lib.elem r.to zoneNames; message = if r.to == "wan" then "router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel." else "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames})."; } { assertion = r.from != r.to; message = "router: ${where}: source and destination are the same zone; traffic within a zone is already allowed."; } { assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp"; message = "router: ${where}: ports are only valid with protocol tcp or udp."; } { assertion = lib.all portOk r.ports; message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high)."; } { assertion = lib.all validCidr ( map ( x: if lib.hasInfix "/" x then x else if isV6 x then "${x}/128" else "${x}/32" ) r.sources ); message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR."; } ] # Rules from the outside must not be able to expose a whole zone by accident. ++ lib.optionals fromWan [ { assertion = r.destination != null; message = "router: ${where}: rules from `wan` must name a destination host or network."; } { assertion = r.protocol != "any"; message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp)."; } { assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ]; message = "router: ${where}: rules from `wan` must list the ports they open."; } ] ++ lib.optionals (r.destination != null) [ { assertion = v6Dest || dest != null; message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR."; } { assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone; message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`."; } { assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address; message = "router: ${where}: destination `${r.destination}` has host bits set."; } { assertion = !v6Dest || v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128"); message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR."; } ] ++ lib.optionals r.dnat [ { assertion = fromWan; message = "router: ${where}: dnat is only valid for rules from `wan`."; } { assertion = singleHost; message = "router: ${where}: dnat needs a single IPv4 host as destination."; } { assertion = r.protocol == "tcp" || r.protocol == "udp"; message = "router: ${where}: dnat needs protocol tcp or udp."; } { assertion = lib.all (x: !isV6 x) r.sources; message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4."; } ] ++ lib.optionals (r.toPort != null) [ { assertion = r.dnat && lib.length r.ports == 1 && plainPorts; message = "router: ${where}: toPort needs dnat and exactly one (non-range) port."; } ] ++ lib.optionals r.hairpin [ { assertion = r.dnat; message = "router: ${where}: hairpin only applies to dnat rules."; } { assertion = internetMember; message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true."; } ]; dnsCfg = cfg.dns; anyIp = s: validIp s || v6Cidr "${s}/128"; dnsAssertions = let names = lib.attrNames dnsCfg.hosts ++ lib.attrNames dnsCfg.overrides ++ lib.attrNames dnsCfg.forwardZones; recordSets = dnsCfg.hosts // dnsCfg.overrides; shortNames = lib.filter (n: !(lib.hasInfix "." n)) (lib.attrNames dnsCfg.hosts); reservationNames = lib.concatMap (l: lib.attrNames l.dhcp.reservations) lans; in [ { assertion = lib.all dnsNameOk names && (dnsCfg.domain == null || dnsNameOk dnsCfg.domain); message = "router.dns: a name in domain/hosts/overrides/forwardZones is not a valid DNS name."; } { assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues recordSets); message = "router.dns: every host/override needs at least one valid IPv4 or IPv6 address."; } { assertion = lib.all (as: as != [ ] && lib.all anyIp as) (lib.attrValues dnsCfg.forwardZones); message = "router.dns.forwardZones: every zone needs at least one valid resolver address."; } { assertion = dnsCfg.domain != null || shortNames == [ ]; message = "router.dns.hosts: `${lib.concatStringsSep ", " shortNames}` has no dot, so router.dns.domain must be set."; } { assertion = lib.length (lib.attrNames dnsCfg.hosts) == lib.length ( lib.unique ( map (n: if lib.hasInfix "." n || dnsCfg.domain == null then n else "${n}.${dnsCfg.domain}") ( lib.attrNames dnsCfg.hosts ) ) ); message = "router.dns.hosts: two names are the same once placed in the domain."; } { assertion = lib.all (n: !(lib.elem n (lib.attrNames dnsCfg.hosts))) reservationNames; message = "router.dns.hosts: a name is also a DHCP reservation, which already publishes it."; } ]; overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets; wgAssertions = name: w: let where = "router.wireguard.${name}"; peers = lib.attrValues w.peers; allowed = lib.concatMap (p: p.allowedIPs) peers; v4Allowed = lib.filter (n: v4Cidr n) allowed; v4Own = lib.filter v4Cidr w.addresses; asNet = n: ipv4.parseNet n; in [ { assertion = w.peers != { }; message = "router: ${where} has no peers."; } { assertion = lib.stringLength w.interface <= 15; message = "router: ${where}: device name `${w.interface}` is longer than 15 characters."; } { assertion = lib.all validCidr w.addresses; message = "router: ${where}.addresses must be IPv4/IPv6 addresses in CIDR notation."; } { assertion = lib.all validCidr allowed; message = "router: ${where}: every peer allowedIPs entry must be an IPv4/IPv6 CIDR."; } { assertion = !(lib.any (n: lib.hasSuffix "/0" n) allowed); message = "router: ${where}: default-route tunnels (allowedIPs ending in /0) are not supported; list the destinations instead."; } { assertion = lib.length allowed == lib.length (lib.unique allowed); message = "router: ${where}: an allowedIPs entry appears more than once (WireGuard would give it to one peer only)."; } { assertion = lib.all (n: !(overlapsLan (asNet n))) ( lib.filter (n: !lib.hasSuffix "/0" n) (v4Own ++ v4Allowed) ); message = "router: ${where}: a tunnel address or allowedIPs entry overlaps one of the LAN subnets."; } { assertion = lib.all (p: builtins.match "[A-Za-z0-9+/]{43}=" p.publicKey != null) peers; message = "router: ${where}: a peer publicKey is not a valid WireGuard public key."; } { assertion = lib.all ( p: p.endpoint == null || builtins.match ".+:[0-9]{1,5}" p.endpoint != null ) peers; message = "router: ${where}: a peer endpoint must look like host:port or [ipv6]:port."; } ]; overlapPairs = lib.concatLists ( lib.imap0 ( i: a: map (b: { inherit a b; }) (lib.drop (i + 1) (lib.attrNames lansByName)) ) (lib.attrNames lansByName) ); in { config = lib.mkIf cfg.enable { assertions = [ { assertion = lans != [ ]; message = "router: at least one LAN must be defined."; } { assertion = lib.any (l: l.allowManagement) lans; message = "router: no LAN has allowManagement = true; you would be locked out of the router."; } { assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans)) || ( cfg.wan.ipv4.type == "pppoe" && cfg.wan.ipv4.pppoe.vlan != null && (!cfg.wan.ipv6.enable || cfg.wan.ipv6.transport == "ppp") ); message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN (only allowed for PPPoE on a VLAN, with IPv6 disabled or inside the PPP session)."; } { assertion = cfg.wan.ipv6.transport == "native" || cfg.wan.ipv4.type == "pppoe"; message = "router: wan.ipv6.transport = \"ppp\" needs a PPPoE uplink (wan.ipv4.type = \"pppoe\")."; } { assertion = cfg.wan.ipv4.pppoe.vlan == null || !(lib.any (l: l.interface == cfg.wan.interface && l.vlan == cfg.wan.ipv4.pppoe.vlan) lans); message = "router: VLAN ${toString cfg.wan.ipv4.pppoe.vlan} on `${cfg.wan.interface}` is used by both the PPPoE uplink and a LAN."; } { assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != ""; message = "router: router.wan.ipv4.pppoe.username must be set for a PPPoE uplink."; } { assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.passwordFile != null; message = "router: router.wan.ipv4.pppoe.passwordFile must be set for a PPPoE uplink."; } { assertion = lib.length (lib.filter (l: l.ipv6.pd) lans) <= 1; message = "router: more than one LAN sets ipv6.pd, but the delegated /64 cannot be split."; } { assertion = !(lib.any (l: l.ipv6.pd) lans) || cfg.wan.ipv6.enable; message = "router: a LAN sets ipv6.pd but router.wan.ipv6.enable is false."; } { assertion = let devices = map (l: l.device) lans; in lib.length devices == lib.length (lib.unique devices); message = "router: two LANs share the same device (same interface and VLAN)."; } ] ++ lib.concatLists (lib.mapAttrsToList perLan lansByName) ++ dnsAssertions ++ lib.concatMap ruleAssertions cfg.firewall.rules ++ [ { assertion = !(lib.elem "wan" zoneNames); message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`."; } ] ++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard) ++ [ { assertion = let ports = lib.filter (p: p != null) (map (w: w.listenPort) wgs); in lib.length ports == lib.length (lib.unique ports); message = "router: two WireGuard tunnels listen on the same UDP port."; } { assertion = let own = map (w: w.interface) wgs; others = map (l: l.device) lans ++ [ cfg.wan.interface ]; in lib.length own == lib.length (lib.unique own) && !(lib.any (d: lib.elem d others) own); message = "router: a WireGuard device name clashes with a LAN device or the WAN interface."; } ] ++ map ( { a, b }: let la = lansByName.${a}; lb = lansByName.${b}; in { # Only comparable when both addresses parse; the per-LAN assertion reports the rest. assertion = !(validIp la.ipv4.address && validIp lb.ipv4.address) || !(ipv4.overlaps { address = ipv4.parse la.ipv4.address; inherit (la.ipv4) prefixLength; } { address = ipv4.parse lb.ipv4.address; inherit (lb.ipv4) prefixLength; } ); message = "router: LANs `${a}` (${la.ipv4.subnet}) and `${b}` (${lb.ipv4.subnet}) overlap."; } ) overlapPairs; }; }