{ 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; # 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.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}."; } ] ); zoneNames = lib.unique (map (l: l.zone) lans); ruleAssertions = r: let where = "router.firewall.rules (${r.from} -> ${r.to})"; dest = if r.destination == null then null else ipv4.parseNet r.destination; destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans; inZone = dest != null && lib.any ( l: dest.prefixLength >= l.ipv4.prefixLength && ipv4.inSubnet { address = ipv4.parse l.ipv4.address; inherit (l.ipv4) prefixLength; } dest.address ) destLans; in [ { assertion = lib.elem r.from zoneNames; message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames})."; } { assertion = lib.elem r.to zoneNames; message = "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."; } ] ++ lib.optionals (r.destination != null) [ { assertion = dest != null; message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR."; } { assertion = dest == null || !(lib.elem r.to zoneNames) || inZone; message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`."; } { assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address; message = "router: ${where}: destination `${r.destination}` has host bits set."; } ]; 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)); message = "router: the WAN interface `${cfg.wan.interface}` is also used by 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) ++ lib.concatMap ruleAssertions cfg.firewall.rules ++ 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; }; }