diff --git a/nix/nixos-modules/default.nix b/nix/nixos-modules/default.nix index 5c2844b..5df53ec 100644 --- a/nix/nixos-modules/default.nix +++ b/nix/nixos-modules/default.nix @@ -28,6 +28,7 @@ in ./frontend ./improvedDefaults ./packages + (lib.importFlakeMod ./router) ./vmDisko # files ./autoUnfree.nix diff --git a/nix/nixos-modules/router/README.md b/nix/nixos-modules/router/README.md new file mode 100644 index 0000000..e1f2cb3 --- /dev/null +++ b/nix/nixos-modules/router/README.md @@ -0,0 +1,48 @@ +# NixOS Router Framework + +This is another NixOS router framework working better for my usecase + + +## Features + +- designed for environments with dynamic IP address configs + - uses DHCPv4 on WAN to get private or public IPv4 + - uses DHCPv6 on WAN to get public IPv6 prefix via DHCP prefix delegation (DHCP-PD) +- allows easy exposing & forwarding of ports + - exposed port rules auto-adapt to changing IPv6 prefix + - port forwardings (i.e. DNAT) work on IPv4 & IPv6 + - configuring them only requires MAC & static IPv4 +- configures AdGuard Home as filtering DNS server for clients +- stays mostly compatible with common NixOS networking & firewall configs, e.g.: + - `.openFirewall` & `.allowedTCPPorts`/`.allowedUDPPorts` options continue to work (opens port on all interfaces) + +I also develop a NixOS test which tries to verify that these features work as expected, which will be published later in this flake. + + +### Restrictions + +Given all features, this module comes up with a few restrictions (; incomplete list): + +- supports only one WAN & one LAN interface +- does not allow easy integration of a VPN network +- fully relies on systemd-networkd for DHCPv4/v6 client, DHCPv4 server & prefix-delegated router advertisements + +It is not impossible or really, really hard to overcome these limitations but it may require changing this module in substantional ways. + + +## Example Use + +(**TODO** link to yet uncommited stuff) + + +## Inspirators + +I was inspired to implement this by other, similar projects, which were sadly lacking some features highly important to me. +However, as a form of credit & to provide further ressources to you: + +- [nixos-router](https://github.com/chayleaf/nixos-router) by [@chayleaf](https://github.com/chayleaf) + - utilizes network namespaces (mine does not!) + - because of that, (at time of writing) it ditched systemd-networkd for now, which I wanted to use + - was not designed for a environment with dynamic IPs +- [NixOS based router in 2023](https://github.com/ghostbuster91/blogposts/blob/a2374f0039f8cdf4faddeaaa0347661ffc2ec7cf/router2023-part2/main.md) by [@ghostbuster91](https://github.com/ghostbuster91) + - was a useful ressource in creating my module diff --git a/nix/nixos-modules/router/default.nix b/nix/nixos-modules/router/default.nix new file mode 100644 index 0000000..684e068 --- /dev/null +++ b/nix/nixos-modules/router/default.nix @@ -0,0 +1,958 @@ +{ + lib, # uses some of my library extensions + ... +}@flakeArg: +{ config, ... }: +let + cfg = config.x-banananetwork.routerVM; + # TODO avert to options + wanName = "wan0"; + lanName = "lan0"; + # my lib + escapeNftablesStr = + arg: + let + inherit (builtins) match replaceStrings; + string = toString arg; + in + if match "[[:alnum:],._+:@%/-]+" string == null then + ''"${replaceStrings [ ''"'' ] [ ''\"'' ] string}"'' + else + string; + filterAttrs = + filtFun: + assert builtins.isFunction filtFun; + lib.filterAttrs (_: filtFun); + filterMapAttrs = + attr: filtFun: mapFun: + assert builtins.isAttrs attr; + assert builtins.isFunction filtFun; + assert builtins.isFunction mapFun; + builtins.mapAttrs (_: mapFun) (filterAttrs filtFun attr); + filterMapAttrsToList = + attr: filtFun: mapFun: + assert builtins.isAttrs attr; + assert builtins.isFunction filtFun; + assert builtins.isFunction mapFun; + lib.mapAttrsToList (_: mapFun) (filterAttrs filtFun attr); + # useful to combine port rules + mapListJoin = + sep: list: mapFun: + assert builtins.isString sep; + assert builtins.isList list; + assert builtins.isFunction mapFun; + builtins.concatStringsSep sep (builtins.map mapFun list); + filterMap = + list: mapFun: + assert builtins.isList list; + assert builtins.isFunction mapFun; + map mapFun (builtins.filter (x: x.enable) list); + filterMapJoin = + sep: attr: mapFun: + assert builtins.isString sep; + assert builtins.isAttrs attr; + assert builtins.isFunction mapFun; + builtins.concatStringsSep sep (filterMapAttrsToList attr (x: x.enable) mapFun); + mkDisableOption = arg: (lib.mkEnableOption arg) // { default = true; }; + # TODO think about to make it just readOnly, requiring defaultText + mkOutputOption = + arg: + lib.mkOption ( + { + internal = true; + readOnly = true; + } + // arg + ); + mkInterfaceOption = + name: + lib.mkOption { + description = '' + MAC address of device to be used as ${name} interface. + + Corresponds to the MACAdress= option in the [Match] section + in {manpage}`systemd.network(5)` files. + ''; + type = lib.types.eui48; + example = "AA:BB:CC:DD:EE:FF"; + }; + whitespaceList = list: builtins.concatStringsSep " " list; + hostnameType = lib.types.strMatching "^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" // { + description = "hostname as per RFC"; + }; + formatMAC = + let + badChars = [ + "." + ":" + "_" + "-" + ]; + goodChars = map (x: "") badChars; + in + mac: builtins.replaceStrings badChars goodChars mac; + protoList = [ + "dccp" + "sctp" + "tcp" + "udp" + "udplite" + ]; + protoType = lib.types.enum protoList; + # required for IPv4 as handled at built time + dnatMapBuilder = + proto: destMap: devices: embedFun: + let + devFilt = filterAttrs (dev: (destMap dev) != null) devices; + protoMap = dev: (dev.nftUpdateAddressesConfig destMap).${proto}.forwarded; + mapEntry = + { + wanPort, + dest, + lanPort, + }: + "${toString wanPort} : ${dest} . ${toString lanPort}"; + builtMap = dev: lib.concatMapStringsSep ", " mapEntry (protoMap dev); + built = filterMapJoin ", " devFilt builtMap; + in + if built == "" then "" else embedFun built; + # TODO allow exposure of devices without static IPs + # - by marking "dropped" packets during forwarding instead + # - and then filtering them on postrouting using dest MAC + # - disadvantage: enables use of all addresses (incl. privacy extensions) + deviceSubmodule = + { name, ... }@device: + let + dev = device.config; + in + { + options = { + enable = lib.mkOption { + description = "Configure rules for this device"; + type = lib.types.bool; + default = true; + }; + name = lib.mkOption { + description = "hostname of the device"; + type = hostnameType; + default = name; + }; + description = lib.mkOption { + description = "Descriptive, human-readable name for that device"; + type = lib.types.str; + default = name; + }; + mac = lib.mkOption { + description = "MAC Address of device"; + type = lib.types.eui48; + }; + staticIPv4 = lib.mkOption { + description = "Static DHCPv4 lease address for device"; + type = with lib.types; nullOr ipv4AddressPlain; + default = null; + }; + fullyExposed = lib.mkEnableOption "to fully expose this device via IPv6, making other firewall rules except NATs irrelevant"; + forwardedExposed = mkDisableOption '' + Whether to enable automatic exposure of port forwardings via IPv6. + + For example, if a port forwarding from 8080 to 80 is established, + then the port 80 will also be exposed directly for the device’s IPv6, + making using NAT optional''; + # TODO really limit ICMP pings to IPv6 addresses (currently fully allowed) + allowICMPEcho = lib.mkEnableOption "forwarding of ICMP echos via IPv6 to the device" // { + default = device.config.exposedPorts != [ ]; + defaultText = lib.literalExpression ''''; + }; + exposedPorts = lib.mkOption { + description = '' + Ports which should be accessible to the public via IPv6. + + Adding one port does enable ICMP pings to that host. + ''; + type = lib.types.attrsOf ( + lib.types.submodule ( + { name, ... }@exposed: + let + cfg = exposed.config; + comment = "comment ${escapeNftablesStr cfg.comment}"; + accept = "accept ${comment}"; + in + { + options = { + enable = mkDisableOption "this port exposure rule"; + comment = lib.mkOption { + description = '' + Comment for this port forwarding rule. + + Will be added to nftables rules. + ''; + default = "${device.name} - ${name}"; + defaultText = lib.literalExpression ''''${device.name} - ''${name}''; + }; + port = lib.mkOption { + description = "port"; + type = lib.types.port; + }; + protocol = lib.mkOption { + description = "Protocol for which the port forwarding will be established"; + type = protoType; + default = "tcp"; + }; + nftablesForwardingRules = lib.mkOption { + internal = true; + default = '' + ip6 daddr ${dev.nftablesIPv6Dest} ${cfg.protocol} dport ${toString cfg.port} ${accept} + ''; + }; + }; + } + ) + ); + default = { }; + }; + forwardedPorts = lib.mkOption { + description = '' + Ports which should be forwarded from the router WAN IPv4 & v6 address to this device. + + The port is then made accessible using DNAT + on all packets sent to the router’s public interface + via IPv4 (if `config`{staticIPv4}` is set) and IPv6. + ''; + type = lib.types.attrsOf ( + lib.types.submodule ( + { name, ... }@forwarded: + let + cfg = forwarded.config; + comment = "comment ${escapeNftablesStr cfg.comment}"; + accept = "accept ${comment}"; + l4Rule = "${cfg.protocol} dport ${toString cfg.lanPort} ${accept}"; + l4WanRule = "${cfg.protocol} dport ${toString cfg.wanPort}"; + in + { + options = { + enable = mkDisableOption "this port forwarding rule"; + comment = lib.mkOption { + description = '' + Comment for this port forwarding rule. + + Will be added to nftables rules. + ''; + default = "${device.name} - ${name}"; + defaultText = lib.literalExpression ''''${device.name} - ''${name}''; + }; + protocol = lib.mkOption { + description = "Protocol for which the port forwarding will be established."; + type = protoType; + default = "tcp"; + }; + wanPort = lib.mkOption { + description = "port on the WAN side"; + type = lib.types.port; + default = cfg.lanPort; + defaultText = lib.literalExpression "cfg.lanPort"; + }; + lanPort = lib.mkOption { + description = "port on the LAN / client side"; + type = lib.types.port; + }; + expose = lib.mkOption { + description = "Enables exposure of {option}`lanPort` directly via IPv6"; + type = lib.types.bool; + default = device.config.forwardedExposed; + defaultText = lib.literalExpression "cfg.forwardedExposed"; + }; + nftablesForwardingRules = mkOutputOption { + default = '' + ${lib.optionalString (dev.staticIPv4 != null) "ip daddr ${dev.staticIPv4} ${l4Rule}"} + ip6 daddr ${dev.nftablesIPv6Dest} ${l4Rule} + ''; + }; + nftablesPreroutingRules = mkOutputOption { + default = '' + ${lib.optionalString ( + dev.nftablesIPv4Dest != null + ) "${l4WanRule} dnat ip to ${dev.nftablesIPv4Dest}:${toString cfg.lanPort} ${comment}"} + ''; + }; + }; + } + ) + ); + default = { }; + example = { + http = { + wanPort = 8080; + lanPort = 80; + }; + quic = { + wanPort = 8443; + lanPort = 443; + protocol = "udp"; + }; + }; + }; + nftablesIPv4Dest = mkOutputOption { default = dev.staticIPv4; }; + nftablesIPv6Dest = mkOutputOption { default = ''@${lanName}v6_${formatMAC dev.mac}''; }; + nftUpdateAddressesConfig = mkOutputOption { + default = + destMap: + let + inherit (builtins) attrValues groupBy; + inherit (lib.attrsets) genAttrs; + groupByProto = attrs: groupBy (x: x.protocol) (attrValues attrs); + exposed = groupByProto dev.exposedPorts; + forwarded = groupByProto dev.forwardedPorts; + in + genAttrs protoList (proto: { + exposed = filterMap (exposed.${proto} or [ ]) (exp: { + inherit (exp) port; + dest = destMap dev; + }); + forwarded = filterMap (forwarded.${proto} or [ ]) (fw: { + inherit (fw) wanPort lanPort; + dest = destMap dev; + }); + }); + }; + nftablesForwardingRules = mkOutputOption { + default = + if dev.fullyExposed then + '' + ip6 daddr ${dev.nftablesIPv6Dest} accept comment ${escapeNftablesStr "${dev.description} full exposure"} + '' + else + '' + ${filterMapJoin "\n" dev.exposedPorts (ep: ep.nftablesForwardingRules)} + ${filterMapJoin "\n" dev.forwardedPorts (fw: fw.nftablesForwardingRules)} + ip daddr ${dev.nftablesIPv4Dest} jump drop-reject + ip6 daddr ${dev.nftablesIPv6Dest} jump drop-reject + ''; + }; + nftablesPreroutingRules = mkOutputOption { + default = filterMapJoin "\n" dev.forwardedPorts (fw: fw.nftablesPreroutingRules); + }; + }; + config = { + exposedPorts = filterMapAttrs dev.forwardedPorts (fw: fw.enable && fw.expose) (fw: { + comment = "${fw.comment} (exposed port forwarding)"; + port = fw.lanPort; + inherit (fw) protocol; + }); + }; + }; + # automatically assign mark integers for these reasons (if enabled) + nftMarksDeclared = { + inherit (cfg) protectWanSubnets; + "${wanName}_snatShortcutted" = true; + }; + nftMarks = lib.trivial.pipe nftMarksDeclared [ + lib.attrsToList + (builtins.filter (x: x.value)) + (lib.imap1 ( + i: m: { + inherit (m) name; + value = toString i; # because will mostly be used in nftables rules + } + )) + builtins.listToAttrs + ]; +in +{ + + options = { + boot.loader.systemd-boot.bootCounting.enable = lib.mkOption { + description = '' + to already pin option which will be coming in the future + see https://nixos.org/manual/nixos/unstable/#sec-automatic-boot-assessment + ''; + visible = false; + type = lib.types.bool; + }; + + x-banananetwork.routerVM = { + + enable = lib.mkEnableOption "router functionality. This is intended to be disabled by a specialisation for recovery reasons"; + + wanMAC = mkInterfaceOption "WAN"; + + lanMAC = mkInterfaceOption "VM LAN"; + lanDomain = lib.mkOption { + description = "domain advertised via DHCP / RA / DHCPv6"; + type = lib.types.str; + default = config.networking.domain; + defaultText = lib.literalExpression "config.networking.domain"; + }; + lanIPv4Address = lib.mkOption { + description = '' + The IPv4 address used for the router & + its subnet used for addresses of clients. + ''; + type = lib.types.str; + }; + lanIPv6ULAPrefix = lib.mkOption { + description = '' + An IPv6 unique local address prefix to announce on LAN as well. + ''; + type = lib.types.str; + }; + + trustableIPv6Prefixes = lib.mkOption { + description = '' + A list of IPv6 prefixes which should be trustable. + + In general, this means following features should be locked to trustable links only: + - announced routes to these prefixes + - TODO packets from/to these prefixes to/from links + ''; + type = with lib.types; listOf str; + default = [ ]; + }; + + # TODO DNS entries for WAN & LAN side + lanDevices = lib.mkOption { + description = '' + Describes properties for a LAN device + (e.g. DHCP static leases & port forwardings). + + For IPv6 based rules to work, + devices must use their stable EUI64 IPv6 address + for the prefix announced via SLAAC. + You can recognise those as they reflect the MAC address of the interface. + + The router cannot forward ports for devices only using + private IPv6 addresses according to + RFC 4941 (IPv6 privacy extensions) + or RFC 7217 (stable private IPv6 addresses). + ''; + type = lib.types.attrsOf (lib.types.submodule deviceSubmodule); + default = { }; + }; + + lanEmitRejections = lib.mkEnableOption '' + Whether to emit ICMP(v6) rejections to the trusted LAN + if the router’s firewall blocks a package. + + Should make it easier to debug cases + where the router blocks certain packages''; + + # TODO test + protectWanSubnets = lib.mkEnableOption '' + firewall rules *trying* to protect the WANs local subnets. + + Despite best efforts, I cannot make any gurantees + that this can prevent all attempts from LAN devices accessing WAN devices, + especially because of possible, additional IPv6 subnets.''; + acceptableWanMACs = lib.mkOption { + description = '' + When {option}`x-banananetwork.routerVM.protectWanSubnets` is enabled, + this provides a list of devices which should still be accessible. + + If you want your LAN to have connection to the Internet, + this list MUST include your gateway’s MAC address. + ''; + type = lib.types.listOf lib.types.eui48; + default = [ ]; + example = [ "AA:BB:CC:DD:EE:FF" ]; + }; + + dns = { + upstreams = lib.mkOption { + description = "List of DNS servers used by DNS server for clients."; + type = lib.types.listOf lib.types.str; + }; + fallbacks = lib.mkOption { + description = "List of DNS servers used as fallbacks (i.e. when all upstreams are unreachable). Used by DNS server for clients."; + type = lib.types.listOf lib.types.str; + default = [ ]; + }; + bootstraps = lib.mkOption { + description = "List of DNS servers used to bootstrap list of other DNS servers. Used by DNS server for clients."; + type = lib.types.listOf lib.types.str; + default = cfg.dns.localFallbacks; + defaultText = lib.literalExpression "config.x-banananetwork.routerVM.dns.localFallbacks"; + }; + localFallbacks = lib.mkOption { + description = "List of DNS servers used as local fallbacks, i.e. added aside of own DNS server to {file}`/etc/resolv.conf` of router itself."; + type = with lib.types; listOf ipAddress; + default = [ ]; + }; + webui.username = lib.mkOption { + description = "Username for AdGuard Home admin account."; + type = lib.types.str; + }; + webui.password = lib.mkOption { + description = '' + Hash for AdGuard Home admin account. + Can be created with .e.g. {command}`mkpasswd --method=bcrypt`' + + For more info, read in the [Adguard Home Wiki](https://github.com/AdguardTeam/AdguardHome/wiki/Configuration#reset-web-password) + ''; + type = lib.types.str; + }; + filterlists = lib.mkOption { + description = "List of URLs of filterlists which entries should be blocked"; + type = with lib.types; listOf str; + default = [ ]; + }; + }; + + }; + }; + + # TODO configure NAT64 with 464XLAT + # requires: https://github.com/systemd/systemd/issues/23674 + # or services.clat.enable = true; & test how it works + + # IPv6 prefix delegation config inspired by: https://major.io/p/dhcpv6-prefix-delegation-with-systemd-networkd/ + + config = lib.mkIf cfg.enable { + + warnings = lib.singleton ( + lib.mkIf (lib.versionAtLeast lib.version "24.11") "remove manual sysctl for ip forwarding, as can be replaced by systemd-networkd settings" + ); + + # I will pin a lot of stuff, to ensure future changes on NixOS are noticed + networking = { + enableIPv6 = true; # also just a statement + nameservers = [ + # localhost + "::1" + "127.0.0.1" + ] ++ cfg.dns.localFallbacks; + tempAddresses = "disabled"; # do not manage that here + useDHCP = false; # do not intervene with router config + useNetworkd = true; + }; + services.resolved.enable = false; # invoked by systemd.network + + # general + systemd.network = { + enable = true; + config.networkConfig = lib.mkIf (lib.versionAtLeast lib.version "24.11") { + # these options are introduced with systemd 256 -> NixOS 24.11 + IPv4Forwarding = true; + IPv6Forwarding = true; + UseDomains = false; + }; + wait-online = { + anyInterface = false; # all which are listed as required + enable = true; + # more is configured per netdev/network as RequiredForOnline + # TODO add that hint to nixos docs in systemd.network.wait-online.anyInterface etc. + }; + }; + boot.kernel.sysctl = lib.mkIf (!lib.versionAtLeast lib.version "24.11") { + "net.ipv4.conf.default.forwarding" = true; + "net.ipv4.conf.all.forwarding" = true; + "net.ipv6.conf.default.forwarding" = true; + "net.ipv6.conf.all.forwarding" = true; + }; + # expose for easier debugging + environment.systemPackages = lib.singleton config.services.nft-update-addresses.package; + + # following settings are ordered according to the respective man pages + + # configure "real" devices + systemd.network.links = + let + # defaults + linkConfig = { + MACAddressPolicy = "persistent"; + NamePolicy = ""; # -> use Name= + AlternativeNamesPolicy = "mac slot path onboard"; + AutoNegotiation = true; + }; + in + { + # auto-generated files are using numbers "70" & higher + "10-${wanName}" = { + matchConfig.PermanentMACAddress = cfg.wanMAC; + linkConfig = linkConfig // { + Name = wanName; + Description = "WAN device"; + }; + }; + "10-${lanName}" = { + matchConfig.PermanentMACAddress = cfg.lanMAC; + linkConfig = linkConfig // { + Name = lanName; + Description = "LAN device"; + }; + }; + }; + + # virtual devices + systemd.network.netdevs = { }; + + # networks + systemd.network.networks = { + "10-${wanName}" = { + matchConfig.Name = wanName; + linkConfig = { + RequiredForOnline = "degraded"; # = has addresses assigned + RequiredFamilyForOnline = "any"; + }; + networkConfig = { + DHCP = "yes"; # both IPv4 & IPv6 (prefix delegation) + LinkLocalAddressing = "ipv6"; + IPv6LinkLocalAddressGenerationMode = "eui64"; + LLMNR = false; + MulticastDNS = false; + LLDP = "routers-only"; + DNSDefaultRoute = true; + IPv6AcceptRA = true; + }; + dhcpV6Config = { + PrefixDelegationHint = "::/60"; # TODO make configurable + }; + ipv6AcceptRAConfig = { + PrefixDenyList = whitespaceList cfg.trustableIPv6Prefixes; + RouteDenyList = whitespaceList cfg.trustableIPv6Prefixes; + }; + }; + "10-${lanName}" = { + matchConfig.Name = lanName; + linkConfig = { + RequiredForOnline = false; + }; + networkConfig = { + DHCPServer = true; + LinkLocalAddressing = "ipv6"; + IPv6LinkLocalAddressGenerationMode = "eui64"; + LLMNR = true; + MulticastDNS = true; + LLDP = "routers-only"; + EmitLLDP = true; + IPv6AcceptRA = false; + IPv4ReversePathFilter = "strict"; + IPv6SendRA = true; + DHCPPrefixDelegation = true; + }; + address = lib.singleton "fe80::1/64"; # also link-local router + dhcpPrefixDelegationConfig = { + UplinkInterface = "${wanName}"; + SubnetId = "0x0"; + Announce = true; + Assign = true; + Token = "static:::1"; + ManageTemporaryAddress = false; + }; + dhcpServerConfig = { + ServerAddress = cfg.lanIPv4Address; + # pool automatically set from CIDR + UplinkInterface = ":none"; + DNS = "_server_address"; + # TODO emit domain ipv4 as well + SendOption = lib.singleton "15:string:${cfg.lanDomain}"; + EmitNTP = false; # IPv6 ftw. + EmitSIP = false; + EmitPOP3 = false; + EmitSMTP = false; + EmitLPR = false; + }; + dhcpServerStaticLeases = + filterMapAttrsToList cfg.lanDevices (dev: dev.enable && dev.staticIPv4 != null) + (dev: { + dhcpServerStaticLeaseConfig = { + MACAddress = dev.mac; + Address = dev.staticIPv4; + }; + }); + ipv6SendRAConfig = { + # TODO until https://github.com/systemd/systemd/issues/29651 is fixed: + RouterLifetimeSec = lib.mkDefault (5 * 60); # set so low in case of a prefix renewal + UplinkInterface = ":none"; + DNS = "_link_local"; + Domains = whitespaceList (lib.singleton cfg.lanDomain); + }; + ipv6Prefixes = lib.singleton { + ipv6PrefixConfig = { + Prefix = cfg.lanIPv6ULAPrefix; + Assign = true; + Token = "static:::1"; + }; + }; + }; + }; + + # firewall + + networking.firewall = { + enable = true; + checkReversePath = "strict"; + filterForward = false; # replace with own chains + extraInputRules = '' + jump router-inbound + ''; + }; + networking.nftables = { + enable = true; + # TODO assert that openFirewall / allowedTCP/UDPPorts does not collide with DNAT rules + tables = { + # append to OS table + "nixos-fw".content = '' + set v6ula { + type ipv6_addr + flags interval + elements = { fc00::/7 } + } + ${mapListJoin "\n" protoList (proto: '' + map ${lanName}v4dnat${proto} { + type inet_service : ipv4_addr . inet_service + ${ + dnatMapBuilder proto (dev: dev.staticIPv4) cfg.lanDevices (built: '' + elements = { ${built} } + '') + } + } + '')} + chain drop-reject { + ct state invalid drop comment "just drop early packages" + icmp type destination-unreachable drop comment "avoid virtual loop" + icmpv6 type destination-unreachable drop comment "avoid virtual loop" + ${lib.optionalString cfg.lanEmitRejections '' + iifname ${lanName} ip saddr ${lanName}v4net reject comment "emit error to trusted net" + ''} + drop comment "drop anything else" + } + chain global { + ct state established,related accept + ct state invalid jump drop-reject + ct state != { new, untracked } jump drop-reject + } + # TODO export/upstream + # these assume working connection state tracking + chain rfc4890-icmpv6-site-both { + # TODO required? icmpv6 type echo-reply accept "already established" + ip6 daddr fe80::/10 ip6 nexthdr icmpv6 drop comment "do not route link-local stuff, just in case" + ip6 daddr ff00::/8 icmpv6 type echo-reply drop comment "drop ping responses to multicast" + icmpv6 type parameter-problem icmpv6 code 0 accept comment "bad header" + icmpv6 type { + mld-listener-query, + mld-listener-report, + mld-listener-done, + mld-listener-reduction, + nd-router-solicit, + nd-router-advert, + nd-neighbor-solicit, + nd-neighbor-advert, + nd-redirect, + router-renumbering, + 139, 140, # node information queries / replies + } jump drop-reject + ip6 nexthdr icmpv6 jump drop-reject + } + chain rfc4890-icmpv6-site-inbound { + # TODO allow pingable hosts (but not here) + icmpv6 type time-exceeded icmpv6 code 1 accept comment "reassembly failed" + jump rfc4890-icmpv6-site-both + } + chain rfc4890-icmpv6-site-outbound { + icmpv6 type { echo-request, destination-unreachable, packet-too-big } accept; + icmpv6 type time-exceeded icmpv6 code { 0, 1 } accept comment "transit/reassembly failed" + icmpv6 type parameter-problem icmpv6 code { 1, 2 } accept comment "unknown header-type/option" + jump rfc4890-icmpv6-site-both + } + chain router-inbound { + # stuff with .openFirewall is already accepted + # to global just in case + jump global + iifname ${wanName} jump ${wanName}-inbound + iifname ${lanName} jump ${lanName}-inbound + } + chain ${wanName}-inbound { + ip6 daddr @${lanName}v6net jump drop-reject comment "drop requests from outbound to router" + } + chain ${lanName}-inbound { + ip version 4 udp sport 68 udp dport 67 accept comment "DHCPv4" + icmpv6 type nd-router-solicit accept comment "IPv6 SLAAC" + udp dport 53 accept comment "DNS" + tcp dport 53 accept comment "DNS" + } + chain router-forward { + type filter hook forward priority filter; policy drop; + jump global + ct status dnat accept + iifname ${lanName} oifname ${wanName} jump ${lanName}-to-${wanName} + iifname ${wanName} oifname ${lanName} jump ${wanName}-to-${lanName} + jump drop-reject + } + chain ${wanName}-to-${lanName} { + jump rfc4890-icmpv6-site-inbound + ${filterMapJoin "\n" cfg.lanDevices (dev: dev.nftablesForwardingRules)} + } + chain ${lanName}-to-${wanName} { + jump rfc4890-icmpv6-site-outbound + ${lib.optionalString cfg.protectWanSubnets '' + # TODO try with & without mark + #meta mark set ${nftMarks.protectWanSubnets} comment "protect WAN subnets" + ip daddr @${wanName}v6net reject comment "protect ${wanName} subnet" + ip6 daddr @${wanName}v6net reject comment "protect ${wanName} subnet" + ''} + ip6 saddr @v6ula reject comment "no general outbound with unique link locals" + ip6 daddr @v6ula reject comment "no general outbound to unique link locals" + ip saddr @${lanName}v4net accept comment "outbound allowed" + ip6 saddr @${lanName}v6net accept comment "outbound allowed" + } + chain router-prerouting { + type nat hook prerouting priority -100; policy accept; + ${ + mapListJoin "\n" protoList (proto: '' + iifname != ${wanName} ip daddr @${wanName}v4addr meta mark set ${ + nftMarks."${wanName}_snatShortcutted" + } + iifname != ${wanName} ip6 daddr @${wanName}v6addr meta mark set ${ + nftMarks."${wanName}_snatShortcutted" + } + ip daddr @${wanName}v4addr dnat ip to ${proto} dport map @${lanName}v4dnat${proto} + ip6 daddr @${wanName}v6addr dnat ip6 to ${proto} dport map @${lanName}v6dnat${proto} + '') + } + } + chain router-postrouting { + type nat hook postrouting priority 100; policy accept; + iifname ${lanName} oifname ${wanName} ip version 4 masquerade comment "persistent not required as only one source addr available, avoid randonmness" + meta mark ${nftMarks."${wanName}_snatShortcutted"} masquerade + } + ''; + "router-netdev" = lib.mkIf cfg.protectWanSubnets { + family = "netdev"; + content = '' + set wan_accepted { + typeof ether daddr + elements = { ${builtins.concatStringsSep ", " cfg.acceptableWanMACs} } + } + chain egress { + meta mark ${nftMarks.protectWanSubnets} ether daddr @wan_accepted accept + meta mark ${nftMarks.protectWanSubnets} jump drop-reject + } + ''; + }; + }; + }; + boot.blockedKernelModules = [ + "ip_tables" + "iptable_nat" + ]; + + # prefix updater + services.nft-update-addresses = { + enable = true; + settings = + let + inherit (builtins) attrValues; + inherit (lib.attrsets) genAttrs; + inherit (lib.lists) flatten; + flatMap = mapFun: list: flatten (map mapFun list); + destMap = dev: dev.mac; + portMap = proto: dev: (dev.nftUpdateAddressesConfig destMap).${proto}; + combinePortCfg = devCfgs: { + exposed = flatMap (devCfg: devCfg.exposed) devCfgs; + forwarded = flatMap (devCfg: devCfg.forwarded) devCfgs; + }; + combinePerProto = + devices: proto: + lib.trivial.pipe proto [ + portMap + (filterMap (attrValues devices)) + combinePortCfg + ]; + protoPortCfg = devices: genAttrs protoList (combinePerProto devices); + in + { + nftTable = "nixos-fw"; + interfaces = { + ${wanName} = { }; + ${lanName}.ports = protoPortCfg cfg.lanDevices; + }; + }; + }; + + # DNS server + # TODO exclude router from filtering itself (without config) + # TODO add support for https://opennic.org/ + # TODO change NixOS upstream module to remove yaml-merge dependency if not required + + services.adguardhome = { + enable = true; + allowDHCP = false; + mutableSettings = false; + port = 3000; + settings = { + http = { + session_ttl = "5:00"; + }; + users = lib.singleton { + name = "admin"; + password = ""; # TODO bcrypt htpasswd random stuff + }; + auth_attempts = 5; + block_auth_min = 5; + dns = { + port = 53; + anonymize_client_ip = false; + ratelimit = 100; # queries / second + ratelimit_subnet_len_ipv4 = 32; + ratelimit_subnet_len_ipv6 = 128; + refuse_any = true; + upstream_dns = cfg.dns.upstreams; + fallback_dns = cfg.dns.fallbacks; + bootstrap_dns = cfg.dns.bootstraps; + local_ptr_upstreams = [ + # TODO + ]; + upstream_mode = "load_balance"; + upstream_timeout = "10s"; + use_http3_upstream = true; + enable_dnssec = true; + cache_time = 36000; + resolve_clients = true; + serve_plain_dns = true; + hostsfile_enabled = false; + }; + filtering = { + protection_enabled = true; + filtering_enabled = true; + blocking_mode = "nxdomain"; + blocked_response_ttl = 60; # seconds + safe_search.enabled = false; + safebrowsing_enabled = true; + }; + querylog = { + enabled = true; + file_enabled = true; + interval = "${toString (2 * 24)}h"; + }; + statistics = { + enabled = true; + interval = "${toString (7 * 24)}h"; + }; + filters = lib.trivial.flip map cfg.dns.filterlists (url: { + enabled = true; + inherit url; + name = url; + ID = builtins.hashString "sha1" url; + }); + dhcp.enabled = false; + tls.enabled = false; + log.file = "syslog"; # journal + }; + }; + + # fallback specialisation for trivial network access + + specialisation.trivialNetwork.configuration = { + x-banananetwork.routerVM.enable = lib.mkForce false; + networking = { + # DHCP on all interfaces + useDHCP = lib.mkForce true; + useNetworkd = lib.mkForce true; + }; + }; + # order of specialisation v. older generation not clear + # and no plan which order is senseful, for now + boot.loader.systemd-boot.bootCounting.enable = false; + + }; + +}