├── .gitignore ├── COPYING ├── README.md ├── checks.nix ├── default.nix ├── flake.lock ├── flake.nix ├── lib.nix └── modules ├── corerad.nix ├── dhcpcd.nix ├── hostapd.nix ├── kea.nix ├── nftables.nix └── radvd.nix /.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | result-* 3 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 chayleaf 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nixos-router 2 | 3 | This project has an ambitious goal of creating a framework for writing 4 | NixOS router configurations - in other words, being the 5 | [simple-nixos-mailserver](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/) 6 | of the networking world, but without the "simple" part, because 7 | networking is hard. This may include complex features like running 8 | multiple DHCP servers, using network namespaces, having interfaces turn 9 | on and off while the rest of the system keeps working, etc. 10 | 11 | Sadly, NixOS is written without such requirements in mind. That means 12 | this project has to create the code from scratch. This is both a 13 | blessing and a curse - we can't reuse the existing code, but we can 14 | write new, better code that is more flexible. 15 | 16 | That said, I'm not using this at an enterprise or an ISP, this is simply 17 | for my home router config (yes, overkill, I know). So this is bound to 18 | not fit everybody's needs right now. Obviously, I'm willing to add more 19 | features even at the cost of breaking existing configs if necessary 20 | (after all, this project is in its infancy). 21 | 22 | I'll try to keep breaking changes to a minimum, but as I said, I can't 23 | guarantee they won't happen (even NixOS has them). 24 | 25 | There are separate branches for stable NixOS versions (like the 24.05 26 | branch for NixOS 24.05), each branch will only be supported until the 27 | next stable NixOS release. Support entails non-breaking bugfixes and 28 | certain backports. For nixos-unstable, use the master branch. 29 | 30 | I doubt it would be easy to upstream these changes to nixpkgs, as it 31 | would introduce many breaking changes on top of breaking changes, so I 32 | am not willing to work on it. Some parts are more upstreamable, such as 33 | allowing JSON nftables rulesets, while other are less upstreamable, 34 | like... most parts of this repo, which are a reimplementation of 35 | scripted networking, which some nixpkgs members want to get rid of in 36 | general (in favor of systemd-networkd). 37 | 38 | When [this](https://github.com/systemd/systemd/issues/11103) gets 39 | closed, I might be able to migrate to systemd-networkd. This may be the 40 | time when `networking.interfaces` and this repo become compatible again. 41 | 42 | This module expects you to use nftables, so it modifies NixOS's default 43 | settings to use nftables. Firewall is still set to use iptables, but if 44 | you set `networking.nftables.enable` to `true`, it should use nftables. 45 | 46 | ## Roadmap 47 | 48 | I think the next logical step for this project is adding nftables 49 | options for common scenarios like NAT, so this is what I want to do 50 | next. Right now you're expected to create the nftables rules from 51 | scratch, but something like `networking.nat` (but with more 52 | customizability) could be nice. Another potential way to improve this is 53 | adding the missing virtual device types (currently only bridges and veth 54 | pairs are supported; `networking` has bridge, bond, MacVLAN, 6-to-4, 55 | VLAN, Open vSwitch device support). 56 | 57 | ## See also 58 | 59 | - [notnft](https://github.com/chayleaf/notnft) - a Nix DSL for writing 60 | JSON nftables rules. If you use the included NixOS module, it is 61 | automatically used for type checking JSON nftables rules. 62 | - [My router 63 | config](https://github.com/chayleaf/dotfiles/blob/master/system/hosts/router/default.nix) 64 | using this framework. 65 | 66 | ## Options 67 | 68 | See [the wiki](https://github.com/chayleaf/nixos-router/wiki/Options) 69 | for an automatically built option list. This is a manually condensed 70 | version of the option list. 71 | 72 | - `router.enable` - whether to do anything at all 73 | - `router.networkNamespaces` - per-network namespace config. 74 | - A special namespace `default` is available for configuring the 75 | default namespace. 76 | - `.extraStartCommands` - extra commands to execute at 77 | network namespace start 78 | - `.extraStopCommands` - extra commands to execute at 79 | network namespace stop 80 | - `.rules` - IP routing rules to add for this namespace via 81 | `ip rule` 82 | - `.ipv6` - whether this rule is an IPv6 rule 83 | - `.extraArgs` - arguments to pass to the `ip rule` 84 | command. May be a list or a string. 85 | - `.sysctl` - per-netns sysctl config 86 | - This is useful because with separate network namespaces, sysctl 87 | config is separate to some extent as well (e.g. forwarding rules) 88 | - `.nftables` - nftables config to run in this namespace 89 | (see `router.nftables` for options) 90 | - Difference from `networking.nftables` - this supports JSON 91 | rulesets, and lets you specify custom stop/reload rules, while 92 | `networking.nftables` always flushes the ruleset on stop. Also, it 93 | supports loading static rules and file-based rules at the same 94 | time. One-way `networking.nftables` operability is supported. 95 | - `.nftables.textFile` - `.nft` file to load 96 | - `.nftables.textRules` - nft rules to load 97 | - `.nftables.jsonFile` - `.json` file to load 98 | - `.nftables.jsonRules` - JSON rules to load 99 | - `.nftables.{stopTextFile,stopTextRules,stopJsonFile,stopJsonRules}` - 100 | same as above, but get executed *before first start* and at 101 | stop/reload time. Basically, they are supposed to undo the changes 102 | this ruleset applies, or do nothing if it's not applied anyway. 103 | They default to `flush ruleset` if no stop rules are set. 104 | - `router.tunnels.` - IP tunnels 105 | - `.mode` - tunnel mode (`gre | ipip | isatap | sit | vti`) 106 | - `.remote` - remote address 107 | - `.local` - local address 108 | - `.ttl` - time to live 109 | - `router.veths.` - veth pairs 110 | - `.peerName` - peer name (second device to be created at the 111 | same time) 112 | - `router.interfaces.` - per-interface config 113 | - Difference from `networking.interfaces` - it's just subtly 114 | different... Many features were added that `networking.interfaces` 115 | is incompatible with. This means you can't use it together with 116 | `networking.interfaces`, as both `router.interfaces` and 117 | `networking.interfaces` expect having to set the interfaces up. 118 | - `.bridge` - bridge name to enslave this device to 119 | - `.vlans.*` - VLAN filtering configuration 120 | - `.vid` - VLAN id filter 121 | - `.untagged` - whether this should match untagged traffic 122 | (defaults to false) 123 | - `.extraInitCommands` - extra commands to execute before 124 | bridge/address configuration 125 | - `.networkNamespace` - the network namespace where this device 126 | and all dependent services will run 127 | - `.dependentServices` - services that should depend on this 128 | interface 129 | - each is either a string (service name) or an attrset with the key 130 | `service` and the rest being attrs to to pass to 131 | `router-lib.mkServiceForIf'`, for example, you can set `inNetns` 132 | to false to not use this interface's network namespace. 133 | - `.systemdLink.linkConfig` - values to add to 134 | [systemd.link(5)](https://www.freedesktop.org/software/systemd/man/systemd.link.html) 135 | `[Link]` config for this interface 136 | - `.systemdLink.matchConfig` - values to add to 137 | [systemd.link(5)](https://www.freedesktop.org/software/systemd/man/systemd.link.html) 138 | `[Match]` config for this interface. Defaults to `{ OriginalName = 139 | ""; }` 140 | - `.hostapd` - run hostapd to turn this device into a wireless 141 | access point 142 | - `.hostapd.enable` - enable hostapd 143 | - `.hostapd.settings` - hostapd settings (attrset). See 144 | example 145 | [hostapd.conf](https://w1.fi/cgit/hostap/plain/hostapd/hostapd.conf) 146 | for a list of options. Personally, I copied OpenWRT configs for 147 | my router. 148 | - There's a way to host multiple ssids on a single interface in 149 | hostapd (on supported interfaces), this module doesn't currently 150 | support it 151 | - `.dhcpcd` - run `dhcpcd` on this interface. 152 | - The reasons for adding it here: 153 | - `dhcpcd` may fail in rare cases when not specifing the interface 154 | list in the command line, which I do here. 155 | - Strong caution is needed when running a single `dhcpcd` on many 156 | interfaces, as settings may "leak" into other interfaces. 157 | Example of an option you need to be careful with is IPv6 router 158 | solicitation (ipv6rs). 159 | - `.dhcpcd.enable` - enable dhcpcd on this interface 160 | - `.dhcpcd.extraConfig` - extra text config for dhcpcd 161 | - `.ipv4` - IPv4-specific config: 162 | - `.ipv4.enableForwarding` - sets `forwarding` sysctl for 163 | this device so it can forward packets it receives. 164 | - `.ipv4.rpFilter` - set `rp_filter` value for this device to 165 | check reverse path and block non-existent IPs (value 2) or IPs 166 | coming from the wrong interfaces (value 1). Alternatively, you can 167 | ignore this and query fib in nftables. 168 | - `.ipv4.addresses` - IPv4 addresses of this device 169 | - `.address` - the address 170 | - `.prefixLength` - network prefix length 171 | - `.assign` - whether to actually assign the address to this 172 | device (defaults to `false` if the first octet is zero and 173 | `true` otherwise). If not, it will simply be used as default 174 | value for service config. 175 | - `.gateways` - for DHCP servers - the IPv4 gateways for 176 | this network. If not set, `address` is used as the sole gateway. 177 | - `.dns` - for DHCP servers - IPv4 DNS servers for this 178 | network. 179 | - `.keaSettings` - prefix-specific Kea settings (only used 180 | if Kea is enabled). `pools` has sane defaults (reserve 16 181 | addresses before and after the interface address and 182 | before and after prefix start/end, make the rest available 183 | for DHCP clients), `option-data` defaults to whatever you 184 | set in `gateways` and `dns`. If you want to unset those 185 | settings, overwrite `pools` and `option-data` with empty 186 | (or non-empty) lists. 187 | - `.ipv4.routes` - List of IPv4 route to add when this device 188 | is online. 189 | - `.extraArgs` - arguments to pass to the `ip -4 add` 190 | command. May be a list or a string. 191 | - There is no other options for `routes`, that's it. 192 | - `.ipv4.kea` - Kea settings (maintained replacement for 193 | dhcpd) 194 | - `.ipv4.kea.enable` - enable Kea 195 | - `.ipv4.kea.extraArgs` - extra args to pass to Kea 196 | - `.ipv4.kea.configFile` - Kea config file (if this is set, 197 | all other Kea settings are ignored) 198 | - `.ipv4.kea.settings` - Kea settings. Defaults to one 199 | `subnet4` (see `addresses.keaSettings` for a way to configure 200 | it), `valid-lifetime = 4000`, `lease-database` set to a file at 201 | `/var/lib/kea/dhcp4-${interface}.leases`, and obviously 202 | `interfaces-config` set. You may overwrite any of it. 203 | - `.ipv6` - IPv6-specific config: 204 | - `.ipv6.enableForwarding` - sets `forwarding` sysctl for 205 | this device so it can forward packets it receives. Obviously, 206 | you better setup a firewall if you do this. 207 | - `.ipv6.addresses` - List of IPv6 addresses of this device 208 | - `.address` - the address 209 | - `.prefixLength` - network prefix length 210 | - `.assign` - whether to actually assign the address to this 211 | device (defaults to `false` if the first octet is zero and 212 | `true` otherwise). Otherwise, it will simply be used as 213 | default value for service config. 214 | - `.gateways` - for DHCP servers - the IPv6 gateways for 215 | this network. If empty, I don't know what happens, you guess. 216 | - Each gateway may be a string in the CIDR notation. 217 | Alternatively, it may be an attrset with the following 218 | attrs: 219 | - `.address` - the address 220 | - `.prefixLength` - network prefix length 221 | - `.radvdSettings` - radvd `route` settings for this 222 | gateway (attrset) 223 | - `.coreradSettings` - CoreRAD `route` settings for 224 | this gateway (attrset) 225 | - `.dns` - for DHCP servers - IPv6 DNS servers for this 226 | network. 227 | - Each DNS server may be a string (the DNS address). 228 | Alternatively, if may be an attrset with the following attrs: 229 | - `address` - the DNS server address 230 | - `radvdSettings` - radvd `RDNSS` settings for this 231 | DNS server (attrset) 232 | - `coreradSettings` - CoreRAD `rdnss` settings for this 233 | DNS server (attrset) 234 | - `.keaSettings` - prefix-specific Kea settings (only used 235 | if Kea is enabled). `pools` has sane defaults (reserve 16 236 | addresses before and after the interface address and 237 | before and after prefix start/end, make the rest available 238 | for DHCP clients), `option-data` defaults to whatever you 239 | set in `dns`. If you want to unset it settings, overwrite 240 | `pools` and `option-data` with empty (or non-empty) lists. 241 | - `.radvdSettings` - radvd per-prefix settings (attrset). 242 | `AdvAutonomous` defaults to `true` if `AdvManagedFlag` is set to 243 | true in per-interface radvd settings. 244 | - `.coreradSettings` - CoreRAD per-prefix settings 245 | (attrset). `autonomous` defaults to `true` if `managed` is set 246 | to true in per-interface CoreRAD settings. 247 | - `.ipv6.routes` - List of IPv6 routes to add when this 248 | device is online. 249 | - `.extraArgs` - arguments to pass to the `ip -6 add` 250 | command. May be a list or a string. 251 | - There is no other options for `routes`, that's it. 252 | - `.ipv6.kea` - Kea settings (maintained replacement for 253 | dhcpd) 254 | - `.ipv6.kea.enable` - enable Kea 255 | - `.ipv6.kea.extraArgs` - extra args to pass to Kea 256 | - `.ipv6.kea.configFile` - Kea config file (if this is set, 257 | all other Kea settings are ignored) 258 | - `.ipv6.kea.settings` - Kea settings. Defaults to one 259 | `subnet6` (see `addresses.*.keaSettings` for a way to configure 260 | it), `valid-lifetime = 4000`, `preferred-lifetime = 3000` 261 | `lease-database` set to a file at 262 | `/var/lib/kea/dhcp6-${interface}.leases`, and obviously 263 | `interfaces-config` set. You may overwrite any of it. 264 | - `.ipv6.radvd` - radvd settings (IPv6 router advertisement 265 | daemon) 266 | - `.ipv6.radvd.enable` - enable radvd 267 | - `.ipv6.radvd.interfaceSettings` - per-interface settings 268 | (attrs). Defaults to `AdvSendAdvert = true`, if any DHCP server 269 | (e.g. Kea) is enabled then `AdvManagedFlag` and 270 | `AdvOtherConfigFlag` default to true as well. 271 | - `.ipv6.corerad` - CoreRAD settings (IPv6 router 272 | advertisement daemon) 273 | - `.ipv6.corerad.enable` - enable radvd 274 | - `.ipv6.corerad.interfaceSettings` - per-interface 275 | settings (attrs). Defaults to `advertise = true`, if any DHCP 276 | server (e.g. Kea) is enabled then `managed` and `other_config` 277 | default to true as well. 278 | - `.ipv6.corerad.settings` - general CoreRAD settings 279 | (useful for setting `debug` options) 280 | -------------------------------------------------------------------------------- /checks.nix: -------------------------------------------------------------------------------- 1 | { nixpkgs }: 2 | 3 | let 4 | inherit (nixpkgs) lib; 5 | inherit (lib.nixosSystem { 6 | system = "x86_64-linux"; 7 | modules = [ 8 | (import ./.) 9 | { 10 | system.stateVersion = "23.05"; 11 | fileSystems."/" = { device = "none"; fsType = "tmpfs"; neededForBoot = false; options = [ "defaults" "size=2G" "mode=755" ]; }; 12 | boot.loader.grub.device = "nodev"; 13 | router.enable = true; 14 | router.interfaces.br0 = { 15 | ipv4.kea.enable = true; 16 | ipv6.corerad.enable = true; 17 | ipv6.kea.enable = true; 18 | ipv4.addresses = [ { address = "192.168.1.1"; prefixLength = 24; } ]; 19 | ipv6.enableForwarding = true; 20 | }; 21 | networking.nftables.enable = true; 22 | } 23 | ]; 24 | }) config; 25 | drv = config.system.build.toplevel; 26 | 27 | eq = a: b: 28 | lib.assertMsg (a == b) "Expected ${builtins.toJSON a} == ${builtins.toJSON b}"; 29 | matches = r: s: 30 | lib.assertMsg (builtins.match r s != null) "Expected ${s} to match ${r}"; 31 | notMatches = r: s: 32 | lib.assertMsg (builtins.match r s == null) "Expected ${s} not to match ${r}"; 33 | backtrip = func1: func2: a: b: 34 | assert eq (func1 a) b; eq (func2 b) a; 35 | 36 | router-lib = import ./lib.nix { 37 | inherit lib config; 38 | }; 39 | inherit (router-lib) parseIp serializeIp invMask ip4Regex ip6Regex; 40 | backtripIp = a: b: 41 | assert matches (if lib.hasInfix ":" a then ip6Regex else ip4Regex) a; 42 | backtrip parseIp serializeIp a b; 43 | in 44 | 45 | assert backtripIp "0.0.0.0" [ 0 0 0 0 ]; 46 | assert backtripIp "127.0.0.1" [ 127 0 0 1 ]; 47 | assert backtripIp "255.255.255.255" [ 255 255 255 255 ]; 48 | assert backtripIp "::" [ 0 0 0 0 0 0 0 0 ]; 49 | assert backtripIp "a::" [ 10 0 0 0 0 0 0 0 ]; 50 | assert backtripIp "::a" [ 0 0 0 0 0 0 0 10 ]; 51 | assert backtripIp "a:a:a:a:a:a:a:a" [ 10 10 10 10 10 10 10 10 ]; 52 | assert backtripIp "ffff::ffff" [ 65535 0 0 0 0 0 0 65535 ]; 53 | assert backtripIp "ffff:ffff:ffff::ffff" [ 65535 65535 65535 0 0 0 0 65535 ]; 54 | assert backtripIp "ffff:ffff:ffff:ffff:ffff:ffff:ffff::" [ 65535 65535 65535 65535 65535 65535 65535 0 ]; 55 | assert backtripIp "ffff::ffff:ffff:ffff" [ 65535 0 0 0 0 65535 65535 65535 ]; 56 | assert eq (invMask [ 255 128 0 0 ]) [ 0 127 255 255 ]; 57 | assert eq (invMask [ 65535 32768 0 0 0 0 0 0 ]) [ 0 32767 65535 65535 65535 65535 65535 65535 ]; 58 | assert matches ip4Regex "0.0.9.255"; 59 | assert matches ip4Regex "255.249.199.99"; 60 | assert notMatches ip4Regex "0.0.00"; 61 | assert notMatches ip4Regex "0.0.0.0."; 62 | assert notMatches ip4Regex "0.0.0.."; 63 | assert notMatches ip4Regex "0.0.00.0"; 64 | assert notMatches ip4Regex "0.0..0"; 65 | assert notMatches ip4Regex "0.0..0.0"; 66 | assert notMatches ip4Regex "0.0.0.300"; 67 | assert notMatches ip4Regex "0.0.0.256"; 68 | assert notMatches ip4Regex "0.0.0.260"; 69 | assert notMatches ip6Regex "::fffg"; 70 | assert notMatches ip6Regex "::fffff"; 71 | assert notMatches ip6Regex ":f:"; 72 | assert notMatches ip6Regex "f:f:f:f:f:f:f:f:"; 73 | assert notMatches ip6Regex "f:f:f:f:f:f:f:f:f"; 74 | 75 | builtins.trace drv.outPath drv 76 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , options 4 | , pkgs 5 | , utils 6 | , ... 7 | }: 8 | 9 | let 10 | notnft = config._module.args.notnft or config.notnft or null; 11 | cfg = config.router; 12 | nftType = extraDesc: extraStopDesc: lib.types.submodule { 13 | options.textFile = lib.mkOption { 14 | description = "Text rules file to run${extraDesc}."; 15 | type = with lib.types; nullOr path; 16 | default = null; 17 | }; 18 | options.textRules = lib.mkOption { 19 | description = "Text rules to run${extraDesc}. Make sure to add \"flush ruleset\" as the first line if you want to reset old rules!"; 20 | type = with lib.types; nullOr lines; 21 | default = null; 22 | }; 23 | options.jsonFile = lib.mkOption { 24 | description = "JSON rules file to run${extraDesc}."; 25 | type = with lib.types; nullOr path; 26 | default = null; 27 | }; 28 | options.jsonRules = lib.mkOption { 29 | description = "JSON rules to run${extraDesc}."; 30 | type = lib.types.nullOr (notnft.types.ruleset or (pkgs.formats.json { }).type); 31 | default = null; 32 | }; 33 | options.stopTextFile = lib.mkOption { 34 | description = "Text rules file to run${extraStopDesc}. Make sure to set this to \"flush ruleset\" if you want to reset the old rules!"; 35 | type = with lib.types; nullOr path; 36 | default = null; 37 | }; 38 | options.stopTextRules = lib.mkOption { 39 | description = "Text rules to run${extraStopDesc}. Make sure to add \"flush ruleset\" as the first line if you want to reset old rules!"; 40 | type = with lib.types; nullOr lines; 41 | default = null; 42 | }; 43 | options.stopJsonFile = lib.mkOption { 44 | description = "JSON rules file to run${extraStopDesc}."; 45 | type = with lib.types; nullOr path; 46 | default = null; 47 | }; 48 | options.stopJsonRules = lib.mkOption { 49 | description = "JSON rules to run${extraStopDesc}."; 50 | type = lib.types.nullOr (notnft.types.ruleset or (pkgs.formats.json { }).type); 51 | default = null; 52 | }; 53 | }; 54 | # a set of { = [ ]; } 55 | bridges = lib.zipAttrs 56 | (lib.mapAttrsToList 57 | (interface: icfg: if icfg.bridge == null || icfg.hostapd.enable then { } else { 58 | "${icfg.bridge.name}" = interface; 59 | }) 60 | cfg.interfaces); 61 | router-lib = import ./lib.nix { 62 | inherit lib config utils; 63 | }; 64 | in 65 | { 66 | imports = [ 67 | ./modules/hostapd.nix 68 | ./modules/kea.nix 69 | ./modules/nftables.nix 70 | ./modules/radvd.nix 71 | ./modules/corerad.nix 72 | ./modules/dhcpcd.nix 73 | ]; 74 | 75 | options.router = { 76 | enable = lib.mkEnableOption "router config"; 77 | networkNamespaces = lib.mkOption { 78 | description = "Network namespace config (default = default namespace)"; 79 | type = lib.types.attrsOf (lib.types.submodule { 80 | options.nftables = lib.mkOption { 81 | description = "Per-namespace nftables rules."; 82 | default = { }; 83 | type = nftType " on namespace start" " on namespace stop *and before the first start*"; 84 | }; 85 | options.sysctl = lib.mkOption { 86 | description = "Per-namespace sysctl rules."; 87 | default = { }; 88 | inherit (options.boot.kernel.sysctl) type; 89 | example = lib.literalExpression '' 90 | { "net.ipv4.tcp_syncookies" = false; "vm.swappiness" = 60; } 91 | ''; 92 | }; 93 | options.extraStartCommands = lib.mkOption { 94 | description = "Start commands for this namespace."; 95 | default = ""; 96 | type = lib.types.lines; 97 | }; 98 | options.extraStopCommands = lib.mkOption { 99 | description = "Stop commands for this namespace."; 100 | default = ""; 101 | type = lib.types.lines; 102 | }; 103 | options.rules = lib.mkOption { 104 | description = "IP routing rules added when this network namespace starts"; 105 | default = [ ]; 106 | type = lib.types.listOf (lib.types.submodule { 107 | options.ipv6 = lib.mkOption { 108 | description = "Whether this rule is ipv6"; 109 | type = lib.types.bool; 110 | }; 111 | options.extraArgs = lib.mkOption { 112 | description = "Rule args, i.e. everything after \"ip rule add\""; 113 | type = with lib.types; either str (listOf anything); 114 | }; 115 | }); 116 | }; 117 | }); 118 | }; 119 | veths = lib.mkOption { 120 | default = { }; 121 | description = "veth pairs"; 122 | type = lib.types.attrsOf (lib.types.submodule { 123 | options.peerName = lib.mkOption { 124 | description = "Name of veth peer (the second veth device created at the same time)"; 125 | type = lib.types.str; 126 | }; 127 | }); 128 | }; 129 | tunnels = lib.mkOption { 130 | default = { }; 131 | description = "tunnels"; 132 | type = lib.types.attrsOf (lib.types.submodule { 133 | options.mode = lib.mkOption { 134 | description = "tunnel mode"; 135 | type = with lib.types; nullOr (enum [ "gre" "ipip" "isatap" "sit" "vti" ]); 136 | }; 137 | options.remote = lib.mkOption { 138 | description = "remote ip"; 139 | type = with lib.types; nullOr (either (enum ["any"]) router-lib.types.ip); 140 | }; 141 | options.local = lib.mkOption { 142 | description = "local ip"; 143 | type = with lib.types; nullOr (either (enum ["any"]) router-lib.types.ip); 144 | }; 145 | options.ttl = lib.mkOption { 146 | description = "ttl"; 147 | type = with lib.types; nullOr (either (enum ["inherit"]) ints.u8); 148 | }; 149 | }); 150 | }; 151 | interfaces = lib.mkOption { 152 | default = { }; 153 | description = "All interfaces managed by nixos-router"; 154 | type = lib.types.attrsOf (lib.types.submodule { 155 | options.dependentServices = lib.mkOption { 156 | description = "Patch those systemd services to depend on this interface"; 157 | default = [ ]; 158 | type = with lib.types; listOf (either str attrs); 159 | }; 160 | 161 | options.bridge = lib.mkOption { 162 | description = "Add this device to this bridge"; 163 | default = null; 164 | type = with lib.types; nullOr (coercedTo str (name: { inherit name; }) (submodule { 165 | options.name = lib.mkOption { 166 | description = "Name of the bridge"; 167 | type = lib.types.str; 168 | }; 169 | options.vlans = lib.mkOption { 170 | description = "VLANs to add to this bridge"; 171 | default = [ ]; 172 | type = with lib.types; listOf (submodule { 173 | options.vid = lib.mkOption { 174 | description = "VLAN id"; 175 | type = lib.types.int; 176 | }; 177 | options.untagged = lib.mkOption { 178 | description = "should this match untagged traffic"; 179 | type = lib.types.bool; 180 | default = false; 181 | }; 182 | }); 183 | }; 184 | })); 185 | }; 186 | 187 | options.extraInitCommands = lib.mkOption { 188 | description = "Extra commands for interface initialization to be executed before bridge/address configuration."; 189 | default = ""; 190 | example = lib.literalExpression '' 191 | ''' 192 | ''${pkgs.ethtool}/bin/ethtool --offload eth0 tso off 193 | '''''; 194 | type = lib.types.lines; 195 | }; 196 | options.networkNamespace = lib.mkOption { 197 | description = "Network namespace name to create this device in"; 198 | default = null; 199 | type = with lib.types; nullOr str; 200 | }; 201 | options.systemdLink.linkConfig = lib.mkOption { 202 | description = "This device's systemd.link(5) link config"; 203 | default = { }; 204 | type = lib.types.attrs; 205 | }; 206 | options.systemdLink.matchConfig = lib.mkOption { 207 | description = "This device's systemd.link(5) match config"; 208 | default = { }; 209 | type = lib.types.attrs; 210 | }; 211 | options.hostapd = lib.mkOption { 212 | description = "hostapd options"; 213 | default = { }; 214 | type = lib.types.submodule { 215 | options.enable = lib.mkEnableOption "hostapd"; 216 | options.settings = lib.mkOption { 217 | description = "hostapd config"; 218 | default = { }; 219 | type = lib.types.attrs; 220 | }; 221 | }; 222 | }; 223 | options.dhcpcd = lib.mkOption { 224 | description = "dhcpcd options"; 225 | default = { }; 226 | type = lib.types.submodule { 227 | options.enable = lib.mkEnableOption "dhcpcd (this option disables networking.useDHCP)"; 228 | options.extraConfig = lib.mkOption { 229 | description = "dhcpcd text config"; 230 | default = ""; 231 | type = lib.types.lines; 232 | }; 233 | }; 234 | }; 235 | options.ipv4 = lib.mkOption { 236 | description = "IPv4 config"; 237 | default = { }; 238 | type = lib.types.submodule { 239 | options.enableForwarding = lib.mkEnableOption "Enable IPv4 forwarding for this device"; 240 | options.rpFilter = lib.mkOption { 241 | description = "rp_filter value for this device (see kernel docs for more info)"; 242 | type = with lib.types; nullOr int; 243 | default = null; 244 | }; 245 | options.addresses = lib.mkOption { 246 | description = "Device's IPv4 addresses"; 247 | default = [ ]; 248 | type = lib.types.listOf (lib.types.submodule { 249 | options.address = lib.mkOption { 250 | description = "IPv4 address"; 251 | type = router-lib.types.ipv4; 252 | }; 253 | options.prefixLength = lib.mkOption { 254 | description = "IPv4 prefix length"; 255 | type = lib.types.int; 256 | }; 257 | options.assign = lib.mkOption { 258 | description = "Whether to assign this address to the device. Default: no if the first hextet is zero, yes otherwise."; 259 | type = with lib.types; nullOr bool; 260 | default = null; 261 | }; 262 | options.gateways = lib.mkOption { 263 | description = "IPv4 gateway addresses (optional)"; 264 | default = null; 265 | type = with lib.types; nullOr (listOf str); 266 | }; 267 | options.dns = lib.mkOption { 268 | description = "IPv4 DNS servers associated with this device"; 269 | type = with lib.types; listOf str; 270 | default = [ ]; 271 | }; 272 | options.keaSettings = lib.mkOption { 273 | default = { }; 274 | type = (pkgs.formats.json { }).type; 275 | example = { 276 | pools = [{ pool = "192.168.1.15 - 192.168.1.200"; }]; 277 | option-data = [{ 278 | name = "domain-name-servers"; 279 | code = 6; 280 | csv-format = true; 281 | space = "dhcp4"; 282 | data = "8.8.8.8, 8.8.4.4"; 283 | }]; 284 | }; 285 | description = "Kea IPv4 prefix-specific settings"; 286 | }; 287 | }); 288 | }; 289 | options.routes = lib.mkOption { 290 | description = "IPv4 routes added when this device starts"; 291 | default = [ ]; 292 | type = lib.types.listOf (lib.types.submodule { 293 | options.extraArgs = lib.mkOption { 294 | description = "Route args, i.e. everything after \"ip route add\""; 295 | type = with lib.types; either str (listOf anything); 296 | }; 297 | }); 298 | }; 299 | options.kea = lib.mkOption { 300 | description = "Kea options"; 301 | default = { }; 302 | type = lib.types.submodule { 303 | options.enable = lib.mkEnableOption "Kea for IPv4"; 304 | options.extraArgs = lib.mkOption { 305 | type = with lib.types; listOf str; 306 | default = [ ]; 307 | description = "List of additional arguments to pass to the daemon."; 308 | }; 309 | options.configFile = lib.mkOption { 310 | type = with lib.types; nullOr path; 311 | default = null; 312 | description = "Kea config file (takes precedence over settings)"; 313 | }; 314 | options.settings = lib.mkOption { 315 | default = { }; 316 | type = (pkgs.formats.json { }).type; 317 | description = "Kea settings"; 318 | }; 319 | }; 320 | }; 321 | }; 322 | }; 323 | options.ipv6 = lib.mkOption { 324 | description = "IPv6 config"; 325 | default = { }; 326 | type = lib.types.submodule { 327 | options.enableForwarding = lib.mkEnableOption "Enable IPv6 forwarding for this device"; 328 | options.addresses = lib.mkOption { 329 | description = "Device's IPv6 addresses"; 330 | default = [ ]; 331 | type = lib.types.listOf (lib.types.submodule { 332 | options.address = lib.mkOption { 333 | description = "IPv6 address"; 334 | type = router-lib.types.ipv6; 335 | }; 336 | options.prefixLength = lib.mkOption { 337 | description = "IPv6 prefix length"; 338 | type = lib.types.int; 339 | }; 340 | options.assign = lib.mkOption { 341 | description = "Whether to assign this address to the device. Default: no if the first hextet is zero, yes otherwise"; 342 | type = with lib.types; nullOr bool; 343 | default = null; 344 | }; 345 | options.gateways = lib.mkOption { 346 | description = "IPv6 gateways information (optional)"; 347 | default = [ ]; 348 | type = with lib.types; listOf (either router-lib.types.ipv6 (submodule { 349 | options.address = lib.mkOption { 350 | description = "Gateway's IPv6 address"; 351 | type = router-lib.types.ipv6; 352 | }; 353 | options.prefixLength = lib.mkOption { 354 | description = "Gateway's IPv6 prefix length (defaults to interface address's prefix length)"; 355 | type = nullOr int; 356 | default = null; 357 | }; 358 | options.radvdSettings = lib.mkOption { 359 | default = { }; 360 | type = attrsOf (oneOf [ bool str int ]); 361 | example = { 362 | AdvRoutePreference = "high"; 363 | }; 364 | description = "radvd prefix-specific route settings"; 365 | }; 366 | options.coreradSettings = lib.mkOption { 367 | default = { }; 368 | type = (pkgs.formats.toml { }).type; 369 | example = { 370 | preference = "high"; 371 | }; 372 | description = "CoreRAD prefix-specific route settings"; 373 | }; 374 | })); 375 | }; 376 | options.dns = lib.mkOption { 377 | description = "IPv6 DNS servers associated with this device"; 378 | type = with lib.types; listOf (either str (submodule { 379 | options.address = lib.mkOption { 380 | description = "DNS server's address"; 381 | type = lib.types.str; 382 | }; 383 | options.radvdSettings = lib.mkOption { 384 | default = { }; 385 | type = attrsOf (oneOf [ bool str int ]); 386 | example = { FlushRDNSS = false; }; 387 | description = "radvd prefix-specific RDNSS settings"; 388 | }; 389 | options.coreradSettings = lib.mkOption { 390 | default = { }; 391 | type = (pkgs.formats.toml { }).type; 392 | example = { lifetime = "auto"; }; 393 | description = "CoreRAD prefix-specific RDNSS settings"; 394 | }; 395 | })); 396 | default = [ ]; 397 | }; 398 | options.keaSettings = lib.mkOption { 399 | default = { }; 400 | type = (pkgs.formats.json { }).type; 401 | example = { 402 | pools = [{ 403 | pool = "fd01:: - fd01::ffff:ffff:ffff:ffff"; 404 | }]; 405 | option-data = [{ 406 | name = "dns-servers"; 407 | code = 23; 408 | csv-format = true; 409 | space = "dhcp6"; 410 | data = "aaaa::, bbbb::"; 411 | }]; 412 | }; 413 | description = "Kea prefix-specific settings"; 414 | }; 415 | options.radvdSettings = lib.mkOption { 416 | default = { }; 417 | type = with lib.types; attrsOf (oneOf [ bool str int ]); 418 | example = { 419 | AdvOnLink = true; 420 | AdvAutonomous = true; 421 | Base6to4Interface = "ppp0"; 422 | }; 423 | description = "radvd prefix-specific settings"; 424 | }; 425 | options.coreradSettings = lib.mkOption { 426 | default = { }; 427 | type = (pkgs.formats.toml { }).type; 428 | example = { 429 | on_link = true; 430 | autonomous = true; 431 | }; 432 | description = "CoreRAD prefix-specific settings"; 433 | }; 434 | }); 435 | }; 436 | options.routes = lib.mkOption { 437 | description = "IPv6 routes added when this device starts"; 438 | default = [ ]; 439 | type = lib.types.listOf (lib.types.submodule { 440 | options.extraArgs = lib.mkOption { 441 | description = "Route args, i.e. everything after \"ip route add\""; 442 | type = with lib.types; either str (listOf anything); 443 | }; 444 | }); 445 | }; 446 | options.kea = lib.mkOption { 447 | description = "Kea options"; 448 | default = { }; 449 | type = lib.types.submodule { 450 | options.enable = lib.mkEnableOption "Kea for IPv6"; 451 | options.extraArgs = lib.mkOption { 452 | type = with lib.types; listOf str; 453 | default = [ ]; 454 | description = "List of additional arguments to pass to the daemon."; 455 | }; 456 | options.configFile = lib.mkOption { 457 | type = with lib.types; nullOr path; 458 | default = null; 459 | description = "Kea config file (takes precedence over settings)"; 460 | }; 461 | options.settings = lib.mkOption { 462 | default = { }; 463 | type = (pkgs.formats.json { }).type; 464 | description = "Kea settings"; 465 | }; 466 | }; 467 | }; 468 | options.radvd = lib.mkOption { 469 | description = "radvd options"; 470 | default = { }; 471 | type = lib.types.submodule { 472 | options.enable = lib.mkEnableOption "radvd"; 473 | options.interfaceSettings = lib.mkOption { 474 | default = { }; 475 | type = with lib.types; attrsOf (oneOf [ bool str int ]); 476 | example = { 477 | UnicastOnly = true; 478 | }; 479 | description = "radvd interface-specific settings"; 480 | }; 481 | }; 482 | }; 483 | options.corerad = lib.mkOption { 484 | description = "CoreRAD options"; 485 | default = { }; 486 | type = lib.types.submodule { 487 | options.enable = lib.mkEnableOption "CoreRAD"; 488 | options.configFile = lib.mkOption { 489 | type = with lib.types; nullOr path; 490 | default = null; 491 | description = "CoreRAD config file (takes precedence over settings)"; 492 | }; 493 | options.interfaceSettings = lib.mkOption { 494 | default = { }; 495 | type = (pkgs.formats.toml { }).type; 496 | description = "CoreRAD interface-specific settings"; 497 | }; 498 | options.settings = lib.mkOption { 499 | default = { }; 500 | type = (pkgs.formats.toml { }).type; 501 | example = { 502 | debug.address = "localhost:9430"; 503 | debug.prometheus = true; 504 | }; 505 | description = "General CoreRAD settings"; 506 | }; 507 | }; 508 | }; 509 | }; 510 | }; 511 | }); 512 | }; 513 | }; 514 | 515 | config = lib.mkIf cfg.enable { 516 | _module.args = { 517 | inherit router-lib; 518 | }; 519 | 520 | environment.systemPackages = with pkgs; [ 521 | bind 522 | conntrack-tools 523 | dig.dnsutils 524 | ethtool 525 | tcpdump 526 | ]; 527 | 528 | # performance tweaks 529 | powerManagement.cpuFreqGovernor = lib.mkDefault "ondemand"; 530 | services.irqbalance.enable = lib.mkDefault true; 531 | 532 | boot.kernel.sysctl = { 533 | "net.netfilter.nf_log_all_netns" = true; 534 | }; 535 | 536 | networking.usePredictableInterfaceNames = true; 537 | networking.firewall.filterForward = lib.mkDefault false; 538 | networking.firewall.allowPing = lib.mkDefault true; 539 | networking.firewall.rejectPackets = lib.mkDefault false; # drop rather than reject 540 | 541 | router.networkNamespaces = 542 | builtins.zipAttrsWith (k: vs: { }) 543 | (builtins.filter (x: x != null) 544 | ([{ default = { }; }] ++ lib.mapAttrsToList 545 | (name: icfg: { 546 | ${if icfg.networkNamespace == null then "default" else icfg.networkNamespace} = { }; 547 | }) 548 | cfg.interfaces)); 549 | 550 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces 551 | (interface: icfg: 552 | let 553 | escapedInterface = utils.escapeSystemdPath interface; 554 | ips = (builtins.filter 555 | (x: x.assign == true || (x.assign == null && !(lib.hasPrefix "0." x.address))) 556 | icfg.ipv4.addresses) 557 | ++ (builtins.filter 558 | (x: x.assign == true || (x.assign == null && !(lib.hasPrefix ":" x.address || lib.hasPrefix "0:" x.address))) 559 | icfg.ipv6.addresses); 560 | routeFlags = x: if builtins.isList x.extraArgs then lib.escapeShellArgs (map toString x.extraArgs) else x.extraArgs; 561 | routes4 = map routeFlags icfg.ipv4.routes; 562 | routes6 = map routeFlags icfg.ipv6.routes; 563 | in 564 | { 565 | # network-addresses config 566 | # sets up per-device addresses and routes 567 | # nixos does it too, but the way it does it is too simple and doesn't work for some routers 568 | name = "network-addresses-${escapedInterface}"; 569 | value = assert lib.assertMsg 570 | (!config.networking.interfaces?${interface}) 571 | "router.interfaces and networking.interfaces are incompatible! Remove interface `${interface}` from at least one of them."; 572 | router-lib.mkServiceForIf interface { 573 | description = "Address configuration of ${interface}"; 574 | wantedBy = [ "network-setup.service" "network.target" ]; 575 | before = [ "network-setup.service" ]; 576 | after = [ "network-pre.target" ]; 577 | serviceConfig.Type = "oneshot"; 578 | serviceConfig.RemainAfterExit = true; 579 | stopIfChanged = false; 580 | path = [ pkgs.iproute2 pkgs.sysctl ]; 581 | script = '' 582 | ${icfg.extraInitCommands} 583 | 584 | state="/run/nixos/network/addresses/${interface}" 585 | mkdir -p $(dirname "$state") 586 | ${lib.optionalString (icfg.bridge != null && !icfg.hostapd.enable) '' 587 | ip link set "${interface}" master "${icfg.bridge.name}" up && echo "${interface} " >> "/run/${icfg.bridge.name}.interfaces" || true 588 | ''} 589 | ip link set "${interface}" up 590 | ${lib.flip lib.concatMapStrings ips (ip: 591 | let cidr = "${ip.address}/${toString ip.prefixLength}"; in '' 592 | echo "${cidr}" >> $state 593 | echo -n "adding address ${cidr}... " 594 | if out=$(ip addr add "${cidr}" dev "${interface}" 2>&1); then 595 | echo "done" 596 | elif ! echo "$out" | grep "File exists" >/dev/null 2>&1; then 597 | echo "'ip addr add "${cidr}" dev "${interface}"' failed: $out" 598 | exit 1 599 | fi 600 | '' 601 | )} 602 | state="/run/nixos/network/routes/${interface}" 603 | mkdir -p $(dirname "$state") 604 | echo -n "" > "$state" 605 | ${lib.concatMapStrings (route: '' 606 | echo -n "adding route ${route}... " 607 | if out=$(ip -4 route add ${route} 2>&1 && echo ${lib.escapeShellArg ("ip -4 route del " + route)} >> "$state"); then 608 | echo "done" 609 | elif ! echo "$out" | grep "File exists" >/dev/null 2>&1; then 610 | echo "'ip -4 route add "${lib.escapeShellArg route}"' failed: $out" 611 | exit 1 612 | fi 613 | '') routes4} 614 | ${lib.concatMapStrings (route: '' 615 | echo -n "adding route ${route}... " 616 | if out=$(ip -6 route add ${route} 2>&1 && echo ${lib.escapeShellArg ("ip -6 route del " + route)} >> "$state"); then 617 | echo "done" 618 | elif ! echo "$out" | grep "File exists" >/dev/null 2>&1; then 619 | echo "'ip -6 route add "${lib.escapeShellArg route}"' failed: $out" 620 | exit 1 621 | fi 622 | '') routes6} 623 | ${lib.optionalString (icfg.ipv4.rpFilter != null) '' 624 | sysctl ${lib.escapeShellArg "net.ipv4.conf.${interface}.rp_filter=${toString icfg.ipv4.rpFilter}"} 625 | ''} 626 | ${lib.optionalString icfg.ipv4.enableForwarding '' 627 | sysctl ${lib.escapeShellArg "net.ipv4.conf.${interface}.forwarding=1"} 628 | ''} 629 | ${lib.optionalString icfg.ipv6.enableForwarding '' 630 | sysctl ${lib.escapeShellArg "net.ipv6.conf.${interface}.forwarding=1"} 631 | ''} 632 | ''; 633 | preStop = '' 634 | state="/run/nixos/network/routes/${interface}" 635 | ${lib.optionalString (icfg.bridge != null && !icfg.hostapd.enable) '' 636 | ip link set "${interface}" nomaster up && echo "${interface} " >> "/run/${icfg.bridge.name}.interfaces" || true 637 | ''} 638 | if [ -e "$state" ]; then 639 | while read cmd; do 640 | echo -n "running route delete command $cmd... " 641 | $cmd >/dev/null 2>&1 && echo "done" || echo "failed" 642 | done < "$state" 643 | rm -f "$state" 644 | fi 645 | 646 | state="/run/nixos/network/addresses/${interface}" 647 | if [ -e "$state" ]; then 648 | while read cidr; do 649 | echo -n "deleting address $cidr... " 650 | ip addr del "$cidr" dev "${interface}" >/dev/null 2>&1 && echo "done" || echo "failed" 651 | done < "$state" 652 | rm -f "$state" 653 | fi 654 | ''; 655 | }; 656 | }) 657 | // lib.flip lib.mapAttrs' bridges (interface: value: 658 | let 659 | escapedInterface = utils.escapeSystemdPath interface; 660 | in 661 | { 662 | name = "${escapedInterface}-netdev"; 663 | value = router-lib.mkServiceForIf' { inherit interface; includeBasicDeps = false; } { 664 | description = "Router Bridge Interface ${interface}"; 665 | wantedBy = [ "network-setup.service" "network.target" "sys-subsystem-net-devices-${escapedInterface}.device" ]; 666 | partOf = [ "network-setup.service" ]; 667 | after = [ "network-pre.target" ] 668 | # soft dependency, order it but don't require 669 | ++ map router-lib.mainDepForIf value; 670 | before = [ "network-setup.service" ]; 671 | serviceConfig.Type = "oneshot"; 672 | serviceConfig.RemainAfterExit = true; 673 | path = [ pkgs.iproute2 ]; 674 | script = 675 | let 676 | vlan_filtering = builtins.any (x: config.router.interfaces.${x}.bridge.vlans != [ ]) value; 677 | in 678 | '' 679 | ip link show dev "${interface}" >/dev/null 2>&1 && ip link del "${interface}" || true 680 | echo "Adding bridge ${interface}..." 681 | ip link add name "${interface}" type bridge 682 | 683 | ${ 684 | lib.optionalString vlan_filtering '' 685 | ip link set dev "${interface}" type bridge vlan_filtering 1 686 | bridge vlan del dev "${interface}" vid 1 self 687 | '' 688 | } 689 | 690 | ip link set "${interface}" up 691 | echo -n > "/run/${interface}.interfaces" 692 | ${lib.concatMapStrings (i: 693 | let 694 | bridge = config.router.interfaces.${i}.bridge; 695 | vid_commands = lib.concatMapStrings (x: 696 | '' 697 | bridge vlan add dev "${i}" vid ${builtins.toString x.vid} ${lib.optionalString x.untagged "pvid untagged"} 698 | '') bridge.vlans; 699 | in 700 | '' 701 | ip link set "${i}" master "${interface}" up && echo "${i} " >> "/run/${interface}.interfaces" || true 702 | ${lib.optionalString vlan_filtering '' 703 | bridge vlan del dev "${i}" vid 1 704 | ${vid_commands} 705 | ''} 706 | '') value} 707 | ip link set "${interface}" up 708 | ''; 709 | postStop = '' 710 | ip link set "${interface}" down || true 711 | ip link del "${interface}" || true 712 | rm -f "/run/${interface}.interfaces" 713 | ''; 714 | reload = '' 715 | for interface in `cat /run/${interface}.interfaces`; do 716 | ip link set "$interface" nomaster up || true 717 | done 718 | ${lib.concatMapStrings (i: '' 719 | ip link set "${i}" master "${interface}" up && echo "${i}" >> "/run/${interface}.interfaces" || true 720 | '') value} 721 | ''; 722 | reloadIfChanged = true; 723 | }; 724 | }) 725 | // lib.flip lib.mapAttrs' cfg.veths (interface: value: 726 | let 727 | escapedInterface = utils.escapeSystemdPath interface; 728 | escapedPeerInterface = utils.escapeSystemdPath value.peerName; 729 | in 730 | { 731 | name = "${escapedInterface}-netdev"; 732 | value = router-lib.mkServiceForIf' { inherit interface; includeBasicDeps = false; } { 733 | description = "Virtual Ethernet Interfaces ${interface}/${value.peerName}"; 734 | wantedBy = [ 735 | "network-setup.service" 736 | "network.target" 737 | "sys-subsystem-net-devices-${escapedInterface}.device" 738 | "sys-subsystem-net-devices-${escapedPeerInterface}.device" 739 | ]; 740 | partOf = [ "network-setup.service" ]; 741 | after = [ "network-pre.target" ]; 742 | before = [ "network-setup.service" ]; 743 | serviceConfig.Type = "oneshot"; 744 | serviceConfig.RemainAfterExit = true; 745 | stopIfChanged = false; 746 | path = [ pkgs.iproute2 ]; 747 | script = '' 748 | ip link show dev "${interface}" >/dev/null 2>&1 && ip link del "${interface}" || true 749 | echo "Adding veth ${interface}..." 750 | ip link add name "${interface}" type veth peer name "${value.peerName}" 751 | ip link set "${interface}" up 752 | ''; 753 | postStop = '' 754 | ip link set "${interface}" down || true 755 | ip link del "${interface}" || true 756 | ''; 757 | }; 758 | }) 759 | // lib.flip lib.mapAttrs' cfg.tunnels (interface: value: 760 | let 761 | escapedInterface = utils.escapeSystemdPath interface; 762 | flags = [ interface ] 763 | ++ lib.optionals (value.mode != null) [ "mode" value.mode ] 764 | ++ lib.optionals (value.remote != null) [ "remote" value.remote ] 765 | ++ lib.optionals (value.local != null) [ "local" value.local ] 766 | ++ lib.optionals (value.ttl != null) [ "ttl" (toString value.ttl) ]; 767 | in 768 | { 769 | name = "${escapedInterface}-netdev"; 770 | value = router-lib.mkServiceForIf' { inherit interface; includeBasicDeps = false; } { 771 | description = "IP Tunnel ${interface}"; 772 | wantedBy = [ 773 | "network-setup.service" 774 | "network.target" 775 | "sys-subsystem-net-devices-${escapedInterface}.device" 776 | ]; 777 | partOf = [ "network-setup.service" ]; 778 | after = [ "network-pre.target" ]; 779 | before = [ "network-setup.service" ]; 780 | serviceConfig.Type = "oneshot"; 781 | serviceConfig.RemainAfterExit = true; 782 | stopIfChanged = false; 783 | path = [ pkgs.iproute2 ]; 784 | script = '' 785 | ip link show dev "${interface}" >/dev/null 2>&1 && ip link del "${interface}" || true 786 | echo "Adding tunnel ${interface}..." 787 | ip tunnel add ${lib.escapeShellArgs flags} 788 | ip link set "${interface}" up 789 | ''; 790 | postStop = '' 791 | ip link set "${interface}" down || true 792 | ip tunnel del "${interface}" || true 793 | ''; 794 | }; 795 | }) 796 | // lib.flip lib.mapAttrs' (lib.filterAttrs (k: v: v.rules != [ ]) cfg.networkNamespaces) (name: value: 797 | let 798 | args = x: if builtins.isList x.extraArgs then lib.escapeShellArgs (map toString x.extraArgs) else x.extraArgs; 799 | in 800 | { 801 | name = "netns-rules-${name}"; 802 | value = { 803 | description = "Network Namespace rules for ${name}"; 804 | before = [ "network-pre.target" ]; 805 | wants = [ "network-pre.target" ]; 806 | bindsTo = [ "netns-${name}.service" ]; 807 | after = [ "netns-${name}.service" ]; 808 | wantedBy = [ "network-setup.service" "network.target" ]; 809 | serviceConfig.Type = "oneshot"; 810 | serviceConfig.RemainAfterExit = true; 811 | serviceConfig.NetworkNamespacePath = lib.mkIf (name != "default") "/var/run/netns/${name}"; 812 | stopIfChanged = false; 813 | path = [ pkgs.iproute2 ]; 814 | script = builtins.concatStringsSep "\n" (map 815 | (rule: '' 816 | state="/run/nixos/network/netns_rules/${name}" 817 | mkdir -p $(dirname "$state") 818 | if out=$(ip ${if rule.ipv6 then "-6" else "-4"} rule add ${args rule} 2>&1 && echo ${lib.escapeShellArg ("ip ${if rule.ipv6 then "-6" else "-4"} rule del " + args rule)} >> "$state"); then 819 | echo "done" 820 | elif ! echo "$out" | grep "File exists" >/dev/null 2>&1; then 821 | echo "'ip ${if rule.ipv6 then "-6" else "-4"} rule add "${lib.escapeShellArg (args rule)}"' failed: $out" 822 | exit 1 823 | fi 824 | '') 825 | value.rules); 826 | postStop = builtins.concatStringsSep "\n" (map 827 | (rule: '' 828 | state="/run/nixos/network/netns_rules/${name}" 829 | if [ -e "$state" ]; then 830 | while read cmd; do 831 | echo -n "running rule delete command $cmd... " 832 | $cmd >/dev/null 2>&1 && echo "done" || echo "failed" 833 | done < "$state" 834 | rm -f "$state" 835 | fi 836 | '') 837 | value.rules); 838 | }; 839 | }) 840 | // lib.flip lib.mapAttrs' 841 | (lib.filterAttrs (k: v: builtins.any (x: x != null) (builtins.attrValues v.sysctl)) cfg.networkNamespaces) 842 | (name: value: { 843 | name = "sysctl-netns-${name}"; 844 | value = { 845 | description = "sysctl config for ${name}"; 846 | before = [ "network-pre.target" ]; 847 | wants = [ "network-pre.target" ]; 848 | bindsTo = [ "netns-${name}.service" ]; 849 | after = [ "netns-${name}.service" ]; 850 | wantedBy = [ "network-setup.service" "network.target" ]; 851 | serviceConfig.Type = "oneshot"; 852 | serviceConfig.RemainAfterExit = true; 853 | serviceConfig.NetworkNamespacePath = lib.mkIf (name != "default") "/var/run/netns/${name}"; 854 | stopIfChanged = false; 855 | serviceConfig.ExecStart = "${lib.getExe pkgs.sysctl} ${ 856 | lib.escapeShellArgs 857 | (lib.mapAttrsToList 858 | (k: v: "${k}=${if v == false then "0" else toString v}") 859 | (lib.filterAttrs (k: v: v != null) value.sysctl)) 860 | }"; 861 | }; 862 | }) 863 | // lib.flip lib.mapAttrs' cfg.networkNamespaces (name: value: { 864 | name = "netns-${name}"; 865 | value = { 866 | description = "Network Namespace Init for ${name}"; 867 | before = [ "network-pre.target" ]; 868 | wants = [ "network-pre.target" ]; 869 | wantedBy = [ "network-setup.service" "network.target" ]; 870 | stopIfChanged = false; 871 | serviceConfig.Type = "oneshot"; 872 | serviceConfig.RemainAfterExit = true; 873 | path = [ pkgs.iproute2 ]; 874 | script = (if name == "default" then '' 875 | mkdir -p /var/run/netns 876 | ln -s /proc/1/ns/net /var/run/netns/default || true 877 | '' else '' 878 | ip netns add "${name}" || true 879 | '') + lib.optionalString (value.extraStartCommands != "") '' 880 | ip netns exec "${name}" ${pkgs.writeShellScript "netns-init-${name}" '' 881 | export PATH=$PATH:${pkgs.iproute2}/bin 882 | ${value.extraStartCommands} 883 | ''} 884 | ''; 885 | } // lib.optionalAttrs (name != "default" || value.extraStopCommands != "") { 886 | postStop = lib.optionalString (value.extraStopCommands != "") '' 887 | ip netns exec "${name}" ${pkgs.writeShellScript "netns-uninit-${name}" '' 888 | export PATH=$PATH:${pkgs.iproute2}/bin 889 | ${value.extraStopCommands} 890 | ''} 891 | '' + lib.optionalString (name != "default") '' 892 | ip netns delete "${name}" 893 | ''; 894 | }; 895 | }) 896 | // lib.flip lib.mapAttrs' 897 | (lib.filterAttrs (k: v: router-lib.requiresNetnsSetup k) cfg.interfaces) 898 | (interface: value: 899 | let 900 | escapedInterface = utils.escapeSystemdPath interface; 901 | isVeth = router-lib.isVethPeer interface; 902 | vethParent = router-lib.vethParent interface; 903 | peerNs = cfg.interfaces.${vethParent}.networkNamespace or null; 904 | in 905 | { 906 | name = "setup-netns-for-${escapedInterface}"; 907 | value = router-lib.mkServiceForIf' 908 | ( 909 | # require rather than bind 910 | # because as soon as we move the interface, it's gone from the service's namespace 911 | # and bind would stop the service immediately 912 | { bindType = "requires"; } // (if isVeth then { interface = vethParent; } else { inherit interface; preNetns = true; }) 913 | ) 914 | { 915 | description = "Network Namespace configuration of ${interface}"; 916 | wantedBy = [ "network-setup.service" "network.target" ]; 917 | before = [ "network-setup.service" ]; 918 | after = [ "network-pre.target" ]; 919 | serviceConfig.Type = "oneshot"; 920 | serviceConfig.RemainAfterExit = true; 921 | stopIfChanged = false; 922 | path = [ pkgs.iproute2 ]; 923 | script = '' 924 | ip link set "${interface}" netns "${value.networkNamespace}" 925 | ''; 926 | preStop = 927 | if isVeth then '' 928 | ip netns exec "${value.networkNamespace}" ip link set "${interface}" netns "${if peerNs != null then peerNs else "1"}" 929 | '' else '' 930 | ip netns exec "${value.networkNamespace}" ip link set "${interface}" netns 1 931 | ''; 932 | }; 933 | }) 934 | // { 935 | network-setup = { 936 | partOf = map (name: "network-addresses-${utils.escapeSystemdPath name}.service") (builtins.attrNames cfg.interfaces); 937 | }; 938 | } 939 | // router-lib.zipHeads (builtins.concatLists (lib.mapAttrsToList 940 | (interface: icfg: map 941 | (service: { 942 | ${service.service or service} = 943 | if builtins.isString service then router-lib.mkServiceForIf interface { } 944 | else router-lib.mkServiceForIf' (builtins.removeAttrs service [ "service" ] // { inherit interface; }) { }; 945 | }) 946 | icfg.dependentServices) 947 | cfg.interfaces)); 948 | systemd.network.links = lib.flip lib.mapAttrs' cfg.interfaces (name: value: { 949 | name = "40-${name}"; 950 | value = { 951 | matchConfig = 952 | if value.systemdLink.matchConfig == { } 953 | then { OriginalName = name; } 954 | else value.systemdLink.matchConfig; 955 | linkConfig = value.systemdLink.linkConfig; 956 | }; 957 | }); 958 | networking.useDHCP = lib.mkIf (builtins.any (x: x.dhcpcd.enable) (builtins.attrValues cfg.interfaces)) false; 959 | }; 960 | } 961 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1718160348, 6 | "narHash": "sha256-9YrUjdztqi4Gz8n3mBuqvCkMo4ojrA6nASwyIKWMpus=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "57d6973abba7ea108bac64ae7629e7431e0199b6", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A router framework for NixOS"; 3 | 4 | inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 | 6 | outputs = { self, nixpkgs }: 7 | let 8 | forEachSystem = func: nixpkgs.lib.genAttrs [ "aarch64-linux" "aarch64-darwin" "x86_64-darwin" "x86_64-linux" ] (system: func { 9 | inherit system; 10 | pkgs = import nixpkgs { inherit system; }; 11 | }); 12 | in 13 | { 14 | # This is the standard library for dealing with ip addresses. 15 | # Changes to it aren't considered breaking, but of course I won't 16 | # change it for no reason. 17 | lib = forEachSystem ({ ... }: import ./lib.nix { inherit (nixpkgs) lib; }); 18 | nixosModules.default = import ./.; 19 | formatter = forEachSystem ({ pkgs, ... }: pkgs.nixpkgs-fmt); 20 | checks.x86_64-linux.default = let pkgs = nixpkgs.legacyPackages.x86_64-linux; in pkgs.callPackage ./checks.nix { 21 | inherit nixpkgs; 22 | }; 23 | packages = forEachSystem ({ pkgs, system }: 24 | let 25 | inherit (nixpkgs) lib; 26 | eval = import /${pkgs.path}/nixos/lib/eval-config.nix { 27 | inherit system; 28 | modules = [ ./default.nix ]; 29 | }; 30 | doc = pkgs.nixosOptionsDoc { 31 | options = eval.options.router; 32 | transformOptions = opt: opt // { 33 | declarations = map 34 | (decl: 35 | if lib.hasPrefix (toString ./.) (toString decl) 36 | then 37 | let subpath = lib.removePrefix "/" (lib.removePrefix (toString ./.) (toString decl)); 38 | in { url = "https://github.com/chayleaf/nixos-router/blob/${self.sourceInfo.rev or "master"}/${subpath}"; name = subpath; } 39 | else decl) 40 | opt.declarations; 41 | }; 42 | }; 43 | in 44 | builtins.removeAttrs doc [ "optionsNix" "optionsDocBook" ]); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /lib.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config ? null 3 | , utils ? null 4 | , ... 5 | }: 6 | 7 | let 8 | cfg = config.router; 9 | vethParents = lib.mapAttrs' 10 | (name: value: { 11 | name = value.peerName; 12 | value = name; 13 | }) 14 | cfg.veths; 15 | bridges = builtins.zipAttrsWith 16 | (k: builtins.filter (x: x != null)) 17 | (builtins.filter (x: x != null) 18 | (lib.mapAttrsToList 19 | (interface: icfg: if icfg.bridge == null then null else { 20 | ${icfg.bridge.name} = if icfg.hostapd.enable then null else interface; 21 | }) 22 | cfg.interfaces)); 23 | # finds the longest zero-only sequence in a parsed IPv6 24 | # returns an attrset with maxStart (start of the sequence) and maxLen (sequence length) 25 | longestZeroSeq = 26 | lib.flip builtins.foldl' { } ({ curLen ? 0, maxLen ? 0, curStart ? -1, maxStart ? -1, i ? 0 }: n: 27 | let 28 | updateCur = n == 0; 29 | newCurLen = if updateCur then curLen + 1 else 0; 30 | # prefer :: in the middle, as it saves more space (a::b vs ::a:b/a:b::) 31 | updateMax = newCurLen > maxLen || (maxLen == newCurLen && maxStart <= 0); 32 | newCurStart = if updateCur then (if curStart == -1 then i else curStart) else -1; 33 | in 34 | { 35 | i = i + 1; 36 | curLen = newCurLen; 37 | curStart = newCurStart; 38 | maxLen = if updateMax then newCurLen else maxLen; 39 | maxStart = if updateMax then newCurStart else maxStart; 40 | }); 41 | in 42 | lib.optionalAttrs (config != null && utils != null) 43 | rec { 44 | isVethPeer = interface: vethParents?${interface}; 45 | vethParent = interface: vethParents.${interface} or null; 46 | 47 | # interface is virtual and managed by nixos-router 48 | ifIsVirtualRouter = interface: 49 | bridges?${interface} 50 | || cfg.veths?${interface} 51 | || vethParents?${interface} 52 | || cfg.tunnels?${interface}; 53 | # interface is virtual and managed by nixos 54 | ifIsVirtualNixos = interface: 55 | (lib.filterAttrs (k: v: v.virtual) config.networking.interfaces)?${interface} 56 | || config.networking.bridges?${interface} 57 | || config.networking.bonds?${interface} 58 | || config.networking.macvlans?${interface} 59 | || config.networking.sits?${interface} 60 | || config.networking.vlans?${interface} 61 | || config.networking.vswitches?${interface}; 62 | # interface is virtual 63 | ifIsVirtual = interface: ifIsVirtualRouter interface || ifIsVirtualNixos interface; 64 | 65 | requiresNetnsSetup = interface: 66 | if vethParents?${interface} then 67 | (cfg.interfaces.${vethParents.${interface}}.networkNamespace or null) != (cfg.interfaces.${interface}.networkNamespace or null) 68 | else 69 | !(ifIsVirtualRouter interface) && (cfg.interfaces.${interface}.networkNamespace or null) != null; 70 | 71 | mainDepForIf = interface: mainDepForIf' { inherit interface; }; 72 | mainDepForIf' = { interface, preNetns ? false, inNetns ? !preNetns, ... }: 73 | let 74 | netns = cfg.interfaces.${interface}.networkNamespace or null; 75 | # if device is in a netns, systemd wont see the sys-subsystem-net-devices unit 76 | # instead, we depend on the service that moves the device into the target namespace 77 | in 78 | if !preNetns && netns != null && requiresNetnsSetup interface then 79 | "setup-netns-for-${utils.escapeSystemdPath interface}.service" 80 | # or if it's a virtual device, we can just depend on the device configuration service 81 | else if ifIsVirtual interface then 82 | "${utils.escapeSystemdPath (vethParents.${interface} or interface)}-netdev.service" 83 | # finally, for non-virtual devices in the default namespace, use systemd subsystem device 84 | else "sys-subsystem-net-devices-${utils.escapeSystemdPath interface}.device"; 85 | 86 | # create a service that expects to run alongside the interface `name` 87 | mkServiceForIf = interface: mkServiceForIf' { inherit interface; }; 88 | # `attrs` is the actual service config which this function extends 89 | # includeBasicDeps = actually depend on the interface, rather than just the netns 90 | # preNetns = run before the network namespace is applied (no guarantees, used internally) 91 | # inNetns = run in the device's network namespace (true by default) 92 | # bindType = where to put the dependencies (bindsTo by default) 93 | mkServiceForIf' = args0@{ interface, includeBasicDeps ? true, preNetns ? false, inNetns ? !preNetns, bindType ? "bindsTo" }: attrs: 94 | let 95 | netns = cfg.interfaces.${interface}.networkNamespace or null; 96 | deps = 97 | lib.optional includeBasicDeps (mainDepForIf' args0) 98 | # dont forget that we need the device's network namespace to be active 99 | ++ lib.optional (netns != null) "netns-${netns}.service"; 100 | in 101 | attrs // { 102 | after = attrs.after or [ ] ++ deps; 103 | ${bindType} = attrs.${bindType} or [ ] ++ deps; 104 | serviceConfig = attrs.serviceConfig or { } 105 | // lib.optionalAttrs (inNetns && netns != null) { 106 | NetworkNamespacePath = "/var/run/netns/${netns}"; 107 | }; 108 | }; 109 | } // rec { 110 | # parses a hexadecimal number 111 | parseHex = x: (builtins.fromTOML "x=0x${x}").x; 112 | # parses a binary number 113 | parseBin = x: (builtins.fromTOML "x=0b${x}").x; 114 | # parses a decimal number 115 | parseDec = builtins.fromJSON; 116 | 117 | # zip attrs, taking one attr value for each key 118 | zipHeads = builtins.zipAttrsWith (_: builtins.head); 119 | 120 | # generate 0b11111... 121 | gen1Bits = count: 122 | if count == 0 then 0 else (gen1Bits (count - 1)) * 2 + 1; 123 | lshift = a: b: if b == 0 then a else lshift (a * 2) (b - 1); 124 | rshift = a: b: if b == 0 then a else rshift (a / 2) (b - 1); 125 | # generate an integer of `total` bits with `set` most significant bits set 126 | genIntMask = total: set: lshift (gen1Bits set) (total - set); 127 | 128 | # generate subnet mask for ipv4 (not serialized) 129 | genMask4 = prefixLength: 130 | builtins.genList 131 | (i: 132 | let 133 | len = prefixLength - i * 8; 134 | in 135 | if len <= 0 then 0 136 | else if len >= 8 then 255 137 | else genIntMask 8 len) 4; 138 | # generate subnet mask for ipv6 (not serialized) 139 | genMask6 = prefixLength: 140 | builtins.genList 141 | (i: 142 | let 143 | len = prefixLength - i * 16; 144 | in 145 | if len <= 0 then 0 146 | else if len >= 16 then 65535 147 | else genIntMask 16 len) 8; 148 | 149 | # invert a mask 150 | invMask4 = map (builtins.bitXor 255); 151 | invMask6 = map (builtins.bitXor 65535); 152 | invMask = mask: (if builtins.length mask == 4 then invMask4 else invMask6) mask; 153 | 154 | # deserialized mask operations 155 | orMask = lib.zipListsWith builtins.bitOr; 156 | andMask = lib.zipListsWith builtins.bitAnd; 157 | 158 | # serialized mask operations 159 | # applyMask - throw away any bits after prefixLength 160 | applyMask = { address, prefixLength }: 161 | let 162 | parsed = parseIp address; 163 | subnetMask = (if builtins.length parsed == 4 then genMask4 else genMask6) prefixLength; 164 | in 165 | { 166 | address = serializeIp (andMask subnetMask parsed); 167 | inherit prefixLength; 168 | }; 169 | 170 | ip4Regex = 171 | let 172 | compRegex = "(25[0-5]|(2[0-4]|10|1?[1-9])?[0-9])"; 173 | in 174 | "(${compRegex}\\.){3}${compRegex}"; 175 | 176 | cidr4Regex = "${ip4Regex}/(3[0-2]|[1-2]?[0-9])"; 177 | 178 | ip6Regex = 179 | let 180 | compRegex = "([1-9a-f][0-9a-f]{0,3}|0)"; 181 | compStartRegex = "([1-9a-f][0-9a-f]{0,3}:|0:)"; 182 | compEndRegex = "(:[1-9a-f][0-9a-f]{0,3}|:0)"; 183 | # exactly n components with trailing : 184 | compStartExact = n: 185 | if n == 1 then "${compRegex}:" 186 | else "${compStartRegex}{${toString n}}"; 187 | compEndUpTo = n: 188 | if n == 1 then ":${compRegex}?" 189 | else "(:|${compEndRegex}{1,${toString n}})"; 190 | in 191 | builtins.concatStringsSep "|" [ 192 | # the end is either :: or :${compRegex} 193 | "${compStartExact 7}(:|${compRegex})" 194 | # there's :: in the middle 195 | (compStartExact 6 + compEndUpTo 1) 196 | (compStartExact 5 + compEndUpTo 2) 197 | (compStartExact 4 + compEndUpTo 3) 198 | (compStartExact 3 + compEndUpTo 4) 199 | (compStartExact 2 + compEndUpTo 5) 200 | (compStartExact 1 + compEndUpTo 6) 201 | # there's :: at the start 202 | (":" + compEndUpTo 7) 203 | ]; 204 | 205 | cidr6Regex = "(${ip6Regex})/(12[0-8]|(1[01]|[1-9]?)[0-9])"; 206 | 207 | types = { 208 | ipv4 = lib.types.strMatching ip4Regex; 209 | ipv6 = lib.types.strMatching ip6Regex; 210 | ip = lib.types.strMatching "${ip4Regex}|${ip6Regex}"; 211 | cidr4 = lib.types.strMatching cidr4Regex; 212 | cidr6 = lib.types.strMatching cidr6Regex; 213 | cidr = lib.types.strMatching "${cidr4Regex}|${cidr6Regex}"; 214 | }; 215 | 216 | # parses an IPv4 address into an array of integers 217 | parseIp4 = s: map parseDec (lib.splitString "." s); 218 | # serializes an IPv4 address 219 | serializeIp4 = ip: builtins.concatStringsSep "." (map toString ip); 220 | 221 | # parses an IPv6 address 222 | parseIp6 = s: 223 | let 224 | # parts before and after :: 225 | halves = map (x: if x == "" then [ ] else map parseHex (lib.splitString ":" x)) (lib.splitString "::" s); 226 | a = builtins.head halves; 227 | b = if builtins.length halves == 1 then [ ] else builtins.elemAt halves 1; 228 | in 229 | a ++ (builtins.genList (_: 0) (8 - builtins.length a - builtins.length b)) ++ b; 230 | 231 | # serializes an IPv6 address 232 | serializeIp6 = ip: 233 | let 234 | # find longest sequence of zeroes in the IP (max = length, maxS = start) 235 | inherit (longestZeroSeq ip) maxLen maxStart; 236 | hextets = map (x: lib.toLower (lib.toHexString x)) ip; 237 | join = builtins.concatStringsSep ":"; 238 | in 239 | if maxStart == -1 then join hextets 240 | else join (lib.take maxStart hextets) + "::" + join (lib.drop (maxStart + maxLen) hextets); 241 | 242 | parseIp = s: if lib.hasInfix ":" s then parseIp6 s else parseIp4 s; 243 | serializeIp = x: (if builtins.length x == 4 then serializeIp4 else serializeIp6) x; 244 | 245 | # returns { address, prefixLength } 246 | parseCidr = cidr: 247 | let split = lib.splitString "/" cidr; in { 248 | address = builtins.head split; 249 | prefixLength = parseDec (builtins.elemAt split 1); 250 | }; 251 | serializeCidr = { address, prefixLength }: "${address}/${toString prefixLength}"; 252 | 253 | # zeroes out everything past prefixLength 254 | normalizeCidr = { address, prefixLength }: 255 | let 256 | parsed = parseIp address; 257 | mask = (if builtins.length parsed == 4 then genMask4 else genMask6) prefixLength; 258 | in 259 | { 260 | address = serializeIp (lib.zipListsWith builtins.bitAnd parsed mask); 261 | inherit prefixLength; 262 | }; 263 | } 264 | -------------------------------------------------------------------------------- /modules/corerad.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , utils 5 | , router-lib 6 | , ... 7 | }: 8 | 9 | let 10 | cfg = config.router; 11 | in 12 | { 13 | config = lib.mkIf cfg.enable { 14 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces (interface: icfg: 15 | let 16 | cfg = icfg.ipv6.corerad; 17 | settingsFormat = pkgs.formats.toml { }; 18 | ifaceConfig = { 19 | name = interface; 20 | monitor = false; 21 | advertise = true; 22 | managed = icfg.ipv6.kea.enable && icfg.ipv6.addresses != [ ]; 23 | other_config = icfg.ipv6.kea.enable && icfg.ipv6.addresses != [ ]; 24 | } // cfg.interfaceSettings; 25 | configFile = if cfg.configFile != null then cfg.configFile else 26 | settingsFormat.generate "corerad-${interface}.toml" ({ 27 | interfaces = [ 28 | (ifaceConfig // { 29 | prefix = map 30 | ({ address, prefixLength, coreradSettings, ... }: { 31 | prefix = router-lib.serializeCidr (router-lib.applyMask { inherit address prefixLength; }); 32 | autonomous = !ifaceConfig.managed; 33 | } // coreradSettings) 34 | icfg.ipv6.addresses; 35 | route = builtins.concatLists (map 36 | ({ address, prefixLength, gateways, ... }: map 37 | (gateway: { 38 | prefix = router-lib.serializeCidr (router-lib.applyMask { 39 | address = if builtins.isString gateway then gateway else gateway.address; 40 | prefixLength = if gateway.prefixLength or null != null then gateway.prefixLength else prefixLength; 41 | }); 42 | } // (gateway.coreradSettings or { })) 43 | gateways) 44 | icfg.ipv6.addresses); 45 | rdnss = builtins.concatLists (map 46 | ({ dns, ... }: map 47 | (dns: { 48 | servers = if builtins.isString dns then [ dns ] else [ dns.address ]; 49 | } // (dns.coreradSettings or { })) 50 | dns) 51 | icfg.ipv6.addresses); 52 | } // cfg.interfaceSettings) 53 | ]; 54 | } // cfg.settings); 55 | package = pkgs.corerad; 56 | in 57 | { 58 | name = "corerad-${utils.escapeSystemdPath interface}"; 59 | value = lib.mkIf icfg.ipv6.corerad.enable (router-lib.mkServiceForIf interface { 60 | description = "CoreRAD IPv6 NDP RA daemon (${interface})"; 61 | after = [ "network.target" ]; 62 | wantedBy = [ "multi-user.target" ]; 63 | serviceConfig = { 64 | LimitNPROC = 512; 65 | LimitNOFILE = 1048576; 66 | CapabilityBoundingSet = "CAP_NET_ADMIN CAP_NET_RAW"; 67 | AmbientCapabilities = "CAP_NET_ADMIN CAP_NET_RAW"; 68 | NoNewPrivileges = true; 69 | DynamicUser = true; 70 | Type = "notify"; 71 | NotifyAccess = "main"; 72 | ExecStart = "${lib.getBin package}/bin/corerad -c=${configFile}"; 73 | Restart = "on-failure"; 74 | RestartKillSignal = "SIGHUP"; 75 | }; 76 | }); 77 | }); 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /modules/dhcpcd.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , utils 5 | , router-lib 6 | , ... 7 | }: 8 | 9 | let 10 | cfg = config.router; 11 | exitHook = pkgs.writeText "dhcpcd.exit-hook" '' 12 | if [ "$reason" = BOUND -o "$reason" = REBOOT ]; then 13 | # Restart ntpd. We need to restart it to make sure that it 14 | # will actually do something: if ntpd cannot resolve the 15 | # server hostnames in its config file, then it will never do 16 | # anything ever again ("couldn't resolve ..., giving up on 17 | # it"), so we silently lose time synchronisation. This also 18 | # applies to openntpd. 19 | /run/current-system/systemd/bin/systemctl try-reload-or-restart ntpd.service openntpd.service chronyd.service || true 20 | fi 21 | ''; 22 | in 23 | { 24 | config = lib.mkIf (cfg.enable && builtins.any (x: x.dhcpcd.enable) (builtins.attrValues cfg.interfaces)) { 25 | users.users.dhcpcd = { 26 | isSystemUser = true; 27 | group = "dhcpcd"; 28 | }; 29 | users.groups.dhcpcd = { }; 30 | environment.systemPackages = [ pkgs.dhcpcd ]; 31 | environment.etc."dhcpcd.exit-hook".source = exitHook; 32 | 33 | powerManagement.resumeCommands = builtins.concatStringsSep "\n" (lib.mapAttrsToList 34 | (interface: icfg: '' 35 | # Tell dhcpcd to rebind its interfaces if it's running. 36 | /run/current-system/systemd/bin/systemctl reload "dhcpcd-${utils.escapeSystemdPath interface}.service" 37 | '') 38 | cfg.interfaces); 39 | 40 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces (interface: icfg: 41 | let 42 | dhcpcdConf = pkgs.writeText "dhcpcd-${interface}.conf" '' 43 | hostname 44 | option domain_name_servers, domain_name, domain_search, host_name 45 | option classless_static_routes, ntp_servers, interface_mtu 46 | nohook lookup-hostname 47 | denyinterfaces ve-* vb-* lo peth* vif* tap* tun* virbr* vnet* vboxnet* sit* 48 | allowinterfaces ${interface} 49 | waitip 50 | ${icfg.dhcpcd.extraConfig} 51 | ''; 52 | in 53 | { 54 | name = "dhcpcd-${utils.escapeSystemdPath interface}"; 55 | value = lib.mkIf icfg.dhcpcd.enable (router-lib.mkServiceForIf interface { 56 | description = "DHCP Client for ${interface}"; 57 | wantedBy = [ "multi-user.target" "network-online.target" ]; 58 | wants = [ "network.target" ]; 59 | before = [ "network-online.target" ]; 60 | restartTriggers = [ exitHook ]; 61 | stopIfChanged = false; 62 | path = [ pkgs.dhcpcd pkgs.nettools config.networking.resolvconf.package ]; 63 | unitConfig.ConditionCapability = "CAP_NET_ADMIN"; 64 | serviceConfig = { 65 | Type = "forking"; 66 | PIDFile = "/run/dhcpcd/${interface}.pid"; 67 | RuntimeDirectory = "dhcpcd"; 68 | ExecStart = "@${pkgs.dhcpcd}/sbin/dhcpcd dhcpcd --quiet --config ${dhcpcdConf} ${lib.escapeShellArg interface}"; 69 | ExecReload = "${pkgs.dhcpcd}/sbin/dhcpcd --rebind"; 70 | Restart = "always"; 71 | }; 72 | }); 73 | }); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /modules/hostapd.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , utils 5 | , router-lib 6 | , ... 7 | }: 8 | 9 | let 10 | cfg = config.router; 11 | hostapd = pkgs.hostapd; 12 | in 13 | { 14 | config = lib.mkIf (cfg.enable && builtins.any (x: x.hostapd.enable) (builtins.attrValues cfg.interfaces)) { 15 | environment.systemPackages = [ hostapd ] ++ (with pkgs; [ wirelesstools ]); 16 | hardware.wirelessRegulatoryDatabase = true; 17 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces (interface: icfg: 18 | let 19 | escapedInterface = utils.escapeSystemdPath interface; 20 | compileValue = k: v: 21 | if builtins.isBool v then (if v then "1" else "0") 22 | else if builtins.isList v then builtins.concatStringsSep " " (map (compileValue k) v) 23 | else if k == "ssid2" then "P${builtins.toJSON (toString v)}" 24 | else toString v; 25 | compileSettings = x: 26 | let 27 | y = builtins.removeAttrs x [ "ssid" ]; 28 | z = if y?ssid2 then y else y // { ssid2 = x.ssid; }; 29 | in 30 | if !x?ssid && !x?ssid2 then 31 | throw "Must specify ssid for hostapd" 32 | else if x.wpa_key_mgmt == defaultSettings.wpa_key_mgmt && !x?wpa_passphrase && !x?sae_password then 33 | throw "Either change authentication methods or specify wpa_passphrase for hostapd" 34 | else builtins.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k}=${compileValue k v}") z); 35 | forceSettings = { 36 | inherit interface; 37 | }; 38 | defaultSettings = { 39 | driver = "nl80211"; 40 | logger_syslog = -1; 41 | logger_syslog_level = 2; 42 | logger_stdout = -1; 43 | logger_stdout_level = 2; 44 | # not sure if enabling it when it isn't supported is gonna break anything? 45 | ieee80211n = true; # wifi 4 46 | ieee80211ac = true; # wifi 5 47 | ieee80211ax = true; # wifi 6 48 | # ieee80211be = true; # wifi 7 49 | ctrl_interface = "/run/hostapd"; 50 | disassoc_low_ack = true; 51 | wmm_enabled = true; 52 | uapsd_advertisement_enabled = true; 53 | utf8_ssid = true; 54 | sae_require_mfp = true; 55 | ieee80211w = 1; # optional mfp 56 | sae_pwe = 2; 57 | auth_algs = 1; 58 | wpa = 2; 59 | wpa_pairwise = [ "CCMP" ]; 60 | wpa_key_mgmt = [ "WPA-PSK" "WPA-PSK-SHA256" "SAE" ]; 61 | okc = true; 62 | group_mgmt_cipher = "AES-128-CMAC"; 63 | qos_map_set = "0,0,2,16,1,1,255,255,18,22,24,38,40,40,44,46,48,56"; # from openwrt 64 | # ap_isolate = true; # to isolate clients 65 | } // lib.optionalAttrs (icfg.hostapd.settings?country_code) { 66 | ieee80211d = true; 67 | } // lib.optionalAttrs (icfg.bridge != null) { 68 | bridge = icfg.bridge.name; 69 | }; 70 | settings = defaultSettings // icfg.hostapd.settings // forceSettings; 71 | configFile = pkgs.writeText "hostapd.conf" (compileSettings settings); 72 | in 73 | { 74 | name = "hostapd-${escapedInterface}"; 75 | value = lib.mkIf icfg.hostapd.enable (router-lib.mkServiceForIf interface rec { 76 | description = "hostapd wireless AP (${interface})"; 77 | path = [ hostapd ]; 78 | after = lib.optional (settings.bridge != null) (router-lib.mainDepForIf settings.bridge); 79 | bindsTo = after; 80 | requiredBy = [ "network-link-${escapedInterface}.service" ]; 81 | wantedBy = [ "multi-user.target" ]; 82 | serviceConfig = { 83 | ExecStart = "${hostapd}/bin/hostapd ${configFile}"; 84 | Restart = "always"; 85 | }; 86 | }); 87 | }); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /modules/kea.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , utils 5 | , router-lib 6 | , ... 7 | }: 8 | 9 | let 10 | cfg = config.router; 11 | # add x to last component of a parsed ipv4 12 | addToLastComp4 = x: ip: 13 | let 14 | n0 = lib.last ip; 15 | nx = n0 + x; 16 | n = if nx >= 255 then 254 else if nx < 2 then 2 else nx; 17 | in 18 | if x > 0 && n0 >= 255 then null 19 | else if x < 0 && n0 < 2 then null 20 | else lib.init ip ++ [ n ]; 21 | # add x to last component of a parsed ipv6 22 | addToLastComp6 = x: ip: 23 | let 24 | n0 = lib.last ip; 25 | nx = n0 + x; 26 | n = if nx >= 65535 then 65534 else if nx <= 2 then 2 else nx; 27 | in 28 | if x > 0 && n0 >= 65535 then null 29 | else if x < 0 && n0 < 2 then null 30 | else lib.init ip ++ [ n ]; 31 | format = pkgs.formats.json { }; 32 | package = pkgs.kea; 33 | commonServiceConfig = { 34 | ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; 35 | DynamicUser = true; 36 | User = "kea"; 37 | ConfigurationDirectory = "kea"; 38 | StateDirectory = "kea"; 39 | UMask = "0077"; 40 | }; 41 | in 42 | { 43 | config = lib.mkIf cfg.enable (lib.mkMerge [ 44 | ( 45 | let 46 | configs = lib.flip builtins.mapAttrs cfg.interfaces (interface: icfg: 47 | let 48 | cfg4 = icfg.ipv4.kea; 49 | in 50 | if cfg4.configFile != null then cfg4.configFile else 51 | (format.generate "kea-dhcp4-${interface}.conf" { 52 | Dhcp4 = { 53 | valid-lifetime = 4000; 54 | interfaces-config.interfaces = [ interface ]; 55 | lease-database = { 56 | type = "memfile"; 57 | persist = true; 58 | name = "/var/lib/private/kea/dhcp4-${interface}.leases"; 59 | }; 60 | subnet4 = lib.imap1 61 | (id: { address, prefixLength, gateways, dns, keaSettings, ... }: 62 | let 63 | subnetMask = router-lib.genMask4 prefixLength; 64 | parsed = router-lib.parseIp4 address; 65 | minIp = router-lib.andMask subnetMask parsed; 66 | maxIp = router-lib.orMask (router-lib.invMask4 subnetMask) parsed; 67 | in 68 | { 69 | inherit id interface; 70 | subnet = router-lib.serializeCidr { inherit address prefixLength; }; 71 | option-data = 72 | lib.optional (dns != [ ]) 73 | { 74 | name = "domain-name-servers"; 75 | code = 6; 76 | csv-format = true; 77 | space = "dhcp4"; 78 | data = builtins.concatStringsSep ", " dns; 79 | } 80 | ++ [{ 81 | name = "routers"; 82 | code = 3; 83 | csv-format = true; 84 | space = "dhcp4"; 85 | data = builtins.concatStringsSep ", " (if gateways != null then gateways else [ address ]); 86 | }]; 87 | pools = 88 | let 89 | a = addToLastComp4 16 minIp; 90 | b = addToLastComp4 (-16) parsed; 91 | c = addToLastComp4 16 parsed; 92 | d = addToLastComp4 (-16) maxIp; 93 | in 94 | lib.optional (a != null && b != null && a <= b) 95 | { 96 | pool = "${router-lib.serializeIp4 a}-${router-lib.serializeIp4 b}"; 97 | } 98 | ++ lib.optional (c != null && d != null && c <= d) { 99 | pool = "${router-lib.serializeIp4 c}-${router-lib.serializeIp4 d}"; 100 | }; 101 | } // keaSettings) 102 | icfg.ipv4.addresses; 103 | } // cfg4.settings; 104 | })); 105 | in 106 | { 107 | environment.etc = lib.mapAttrs' 108 | (interface: icfg: { 109 | name = "kea/dhcp4-server-${interface}.conf"; 110 | value = lib.mkIf (icfg.ipv4.kea.enable && icfg.ipv4.addresses != [ ]) { 111 | source = configs.${interface}; 112 | }; 113 | }) 114 | cfg.interfaces; 115 | 116 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces (interface: icfg: { 117 | name = "kea-dhcp4-server-${utils.escapeSystemdPath interface}"; 118 | value = lib.mkIf (icfg.ipv4.kea.enable && icfg.ipv4.addresses != [ ]) (router-lib.mkServiceForIf interface { 119 | description = "Kea DHCP4 Server (${interface})"; 120 | documentation = [ "man:kea-dhcp4(8)" "https://kea.readthedocs.io/en/kea-${package.version}/arm/dhcp4-srv.html" ]; 121 | after = [ "network-online.target" "time-sync.target" ]; 122 | wants = [ "network-online.target" ]; 123 | wantedBy = [ "multi-user.target" ]; 124 | environment = { KEA_PIDFILE_DIR = "/run/kea4-${interface}"; KEA_LOCKFILE_DIR = "/run/kea4-${interface}"; }; 125 | restartTriggers = [ configs.${interface} ]; 126 | 127 | serviceConfig = { 128 | ExecStart = "${package}/bin/kea-dhcp4 -c " 129 | + lib.escapeShellArgs ([ "/etc/kea/dhcp4-server-${interface}.conf" ]); 130 | AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" ]; 131 | CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" ]; 132 | RuntimeDirectory = "kea4-${interface}"; 133 | } // commonServiceConfig; 134 | }); 135 | }); 136 | } 137 | ) 138 | ( 139 | let 140 | configs = lib.flip builtins.mapAttrs cfg.interfaces (interface: icfg: 141 | let 142 | cfg6 = icfg.ipv6.kea; 143 | in 144 | if cfg6.configFile != null then cfg6.configFile else 145 | (format.generate "kea-dhcp6-${interface}.conf" { 146 | Dhcp6 = { 147 | valid-lifetime = 4000; 148 | preferred-lifetime = 3000; 149 | interfaces-config.interfaces = [ interface ]; 150 | lease-database = { 151 | type = "memfile"; 152 | persist = true; 153 | name = "/var/lib/private/kea/dhcp6-${interface}.leases"; 154 | }; 155 | subnet6 = lib.imap1 156 | (id: { address, prefixLength, dns, keaSettings, ... }: 157 | let 158 | subnetMask = router-lib.genMask6 prefixLength; 159 | parsed = router-lib.parseIp6 address; 160 | minIp = router-lib.andMask subnetMask parsed; 161 | maxIp = router-lib.orMask (router-lib.invMask6 subnetMask) parsed; 162 | in 163 | { 164 | inherit id interface; 165 | option-data = 166 | lib.optional (dns != [ ]) { 167 | name = "dns-servers"; 168 | code = 23; 169 | csv-format = true; 170 | space = "dhcp6"; 171 | data = builtins.concatStringsSep ", " (map (x: if builtins.isString x then x else x.address) dns); 172 | }; 173 | subnet = router-lib.serializeCidr { inherit address prefixLength; }; 174 | pools = 175 | let 176 | a = addToLastComp6 16 minIp; 177 | b = addToLastComp6 (-16) parsed; 178 | c = addToLastComp6 16 parsed; 179 | d = addToLastComp6 (-16) maxIp; 180 | in 181 | lib.optional (a != null && b != null && a <= b) 182 | { 183 | pool = "${router-lib.serializeIp6 a}-${router-lib.serializeIp6 b}"; 184 | } ++ lib.optional (c != null && d != null && c <= d) { 185 | pool = "${router-lib.serializeIp6 c}-${router-lib.serializeIp6 d}"; 186 | }; 187 | } // keaSettings) 188 | icfg.ipv6.addresses; 189 | } // cfg6.settings; 190 | })); 191 | in 192 | { 193 | environment.etc = lib.mapAttrs' 194 | (interface: icfg: { 195 | name = "kea/dhcp6-server-${interface}.conf"; 196 | value = lib.mkIf (icfg.ipv6.kea.enable && icfg.ipv6.addresses != [ ]) { 197 | source = configs.${interface}; 198 | }; 199 | }) 200 | cfg.interfaces; 201 | 202 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces (interface: icfg: { 203 | name = "kea-dhcp6-server-${utils.escapeSystemdPath interface}"; 204 | value = lib.mkIf (icfg.ipv6.kea.enable && icfg.ipv6.addresses != [ ]) (router-lib.mkServiceForIf interface { 205 | description = "Kea DHCP6 Server (${interface})"; 206 | documentation = [ "man:kea-dhcp6(8)" "https://kea.readthedocs.io/en/kea-${package.version}/arm/dhcp6-srv.html" ]; 207 | after = [ "network-online.target" "time-sync.target" ]; 208 | wants = [ "network-online.target" ]; 209 | wantedBy = [ "multi-user.target" ]; 210 | environment = { KEA_PIDFILE_DIR = "/run/kea6-${interface}"; KEA_LOCKFILE_DIR = "/run/kea6-${interface}"; }; 211 | restartTriggers = [ configs.${interface} ]; 212 | 213 | serviceConfig = { 214 | ExecStart = "${package}/bin/kea-dhcp6 -c " 215 | + lib.escapeShellArgs ([ "/etc/kea/dhcp6-server-${interface}.conf" ]); 216 | AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" ]; 217 | CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" ]; 218 | RuntimeDirectory = "kea6-${interface}"; 219 | } // commonServiceConfig; 220 | }); 221 | }); 222 | } 223 | ) 224 | ]); 225 | } 226 | -------------------------------------------------------------------------------- /modules/nftables.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , router-lib 5 | , ... 6 | }: 7 | 8 | let 9 | cfg = config.router; 10 | nftFlags = ""; 11 | 12 | mkNftStartCmd = attrs: 13 | let 14 | haveTextFile = attrs.nftables.textFile != null; 15 | haveTextRules = attrs.nftables.textRules != null; 16 | haveStopTextFile = attrs.nftables.stopTextFile != null; 17 | haveStopTextRules = attrs.nftables.stopTextRules != null; 18 | haveJsonFile = attrs.nftables.jsonFile != null; 19 | haveJsonRules = attrs.nftables.jsonRules != null; 20 | haveStopJsonFile = attrs.nftables.stopJsonFile != null; 21 | haveStopJsonRules = attrs.nftables.stopJsonRules != null; 22 | jsonAfter = haveStopTextRules; 23 | # whether to inject text file rules into the text rules 24 | injectTextFile = haveTextFile && haveTextRules; 25 | # whether to inject stop rules before start rules 26 | injectStopRules = 27 | # make sure there's exactly one set of rules to inject to, 28 | # since all stop rules must run before all start rules 29 | (haveJsonRules != haveTextRules) 30 | # if stop files are used, don't inject, since we can't inject anything to files 31 | && !haveStopTextFile && !haveStopJsonFile 32 | # if text/json stop rules exist, ensure the text/json start rules to inject to exist 33 | && (haveStopTextRules -> haveTextRules) && (haveStopJsonRules -> haveJsonRules); 34 | nft = "${pkgs.nftables}/bin/nft"; 35 | fallback = d: x: if x != null then x else d; 36 | in 37 | '' 38 | ${lib.optionalString (!injectStopRules) (mkNftStopCmd attrs)} 39 | ${lib.optionalString (!jsonAfter && haveJsonRules) "${nft} -j ${nftFlags} -f ${pkgs.writeTextFile { 40 | name = "nftables-ruleset.json"; 41 | text = builtins.toJSON { 42 | nftables = (lib.optionals injectStopRules (attrs.nftables.stopJsonRules.nftables or [ { flush.ruleset = null; } ])) 43 | ++ attrs.nftables.jsonRules.nftables; 44 | }; 45 | }}"} 46 | ${lib.optionalString haveTextRules "${nft} ${nftFlags} -f ${pkgs.writeTextFile { 47 | name = "nftables-ruleset.nft"; 48 | text = (lib.optionalString injectStopRules ((fallback "flush ruleset" attrs.nftables.stopTextRules) + "\n")) 49 | + (lib.optionalString injectTextFile "include \"${attrs.nftables.textFile}\"\n") 50 | + attrs.nftables.textRules; 51 | }}"} 52 | ${lib.optionalString (haveTextFile && !injectTextFile) "${nft} ${nftFlags} -f ${attrs.nftables.textFile}"} 53 | ${lib.optionalString haveJsonFile "${nft} -j ${nftFlags} -f ${attrs.nftables.jsonFile}"} 54 | ${lib.optionalString (jsonAfter && haveJsonRules) "${nft} -j ${nftFlags} -f ${pkgs.writeTextFile { 55 | name = "nftables-ruleset.json"; 56 | text = builtins.toJSON { 57 | nftables = (lib.optionals injectStopRules (attrs.nftables.stopJsonRules.nftables or [ { flush.ruleset = null; } ])) 58 | ++ attrs.nftables.jsonRules.nftables; 59 | }; 60 | }}"} 61 | ''; 62 | 63 | # TODO: remember previous deletions 64 | mkNftStopCmd = attrs: 65 | let 66 | haveStopTextFile = attrs.nftables.stopTextFile != null; 67 | haveStopTextRules = attrs.nftables.stopTextRules != null; 68 | haveStopJsonFile = attrs.nftables.stopJsonFile != null; 69 | haveStopJsonRules = attrs.nftables.stopJsonRules != null; 70 | stopRulesEmpty = !haveStopJsonRules && !haveStopTextRules && !haveStopTextFile && !haveStopJsonFile; 71 | in 72 | '' 73 | ${lib.optionalString stopRulesEmpty "${pkgs.nftables}/bin/nft ${nftFlags} flush ruleset"} 74 | ${lib.optionalString haveStopTextFile "${pkgs.nftables}/bin/nft ${nftFlags} -f ${attrs.nftables.stopTextFile}"} 75 | ${lib.optionalString haveStopTextRules "${pkgs.nftables}/bin/nft ${nftFlags} -f ${pkgs.writeTextFile { 76 | name = "nftables-ruleset.nft"; 77 | text = attrs.nftables.stopTextRules; 78 | }}"} 79 | ${lib.optionalString haveStopJsonFile "${pkgs.nftables}/bin/nft -j ${nftFlags} -f ${attrs.nftables.stopJsonFile}"} 80 | ${lib.optionalString haveStopJsonRules "${pkgs.nftables}/bin/nft -j ${nftFlags} -f ${pkgs.writeTextFile { 81 | name = "nftables-ruleset.json"; 82 | text = builtins.toJSON attrs.nftables.stopJsonRules; 83 | }}"} 84 | ''; 85 | 86 | hasNftablesRules = x: 87 | x.nftables.textFile != null 88 | || x.nftables.textRules != null 89 | || x.nftables.jsonFile != null 90 | || x.nftables.jsonRules != null 91 | || x.nftables.stopTextFile != null 92 | || x.nftables.stopTextRules != null 93 | || x.nftables.stopJsonFile != null 94 | || x.nftables.stopJsonRules != null; 95 | 96 | enableNftables = builtins.any hasNftablesRules (builtins.attrValues cfg.networkNamespaces); 97 | in 98 | { 99 | config = lib.mkMerge [ 100 | (lib.mkIf cfg.enable { 101 | # separate this out so it doesn't depend on systemHasNftables 102 | networking.nftables.enable = lib.mkDefault config.networking.firewall.enable; 103 | }) 104 | (lib.mkIf (cfg.enable && config.networking.nftables.enable) { 105 | router.networkNamespaces.default = 106 | let 107 | inherit (config.networking.nftables) ruleset rulesetFile flushRuleset extraDeletions; 108 | tables = lib.filterAttrs (_: t: t.enable) config.networking.nftables.tables; 109 | in 110 | lib.mkIf (rulesetFile != null || ruleset != "" || tables != { }) { 111 | nftables.textRules = lib.mkIf (ruleset != "" || tables != { }) '' 112 | ${ruleset} 113 | ${builtins.concatStringsSep "\n" (lib.mapAttrsToList (_: t: '' 114 | table ${t.family} ${t.name} { 115 | ${builtins.concatStringsSep "\n" (map (s: " ${s}") (lib.splitString "\n" t.content))} 116 | } 117 | '') tables)} 118 | ''; 119 | nftables.textFile = lib.mkIf (rulesetFile != null) rulesetFile; 120 | nftables.stopTextRules = lib.mkIf (!flushRuleset || extraDeletions != "") '' 121 | ${if flushRuleset then "flush ruleset" 122 | else builtins.concatStringsSep "\n" (lib.mapAttrsToList (_: t: '' 123 | table ${t.family} ${t.name} 124 | delete table ${t.family} ${t.name} 125 | '') tables)} 126 | ${extraDeletions} 127 | ''; 128 | }; 129 | }) 130 | (lib.mkIf (cfg.enable && enableNftables) { 131 | environment.systemPackages = [ pkgs.nftables ]; 132 | boot.blacklistedKernelModules = [ "ip_tables" ]; 133 | # make the firewall use nftables by default 134 | services.fail2ban.banaction = lib.mkDefault "nftables-multiport"; 135 | services.fail2ban.banaction-allports = lib.mkDefault "nftables-allport"; 136 | services.fail2ban.packageFirewall = lib.mkDefault pkgs.nftables; 137 | services.opensnitch.settings.Firewall = lib.mkDefault "nftables"; 138 | systemd.services = 139 | router-lib.zipHeads 140 | (lib.flip lib.mapAttrsToList 141 | (lib.filterAttrs (_: hasNftablesRules) cfg.networkNamespaces) 142 | (name: value: { 143 | "nftables-netns-${name}" = { 144 | description = "nftables firewall for network namespace ${name}"; 145 | wantedBy = [ "network-online.target" ]; 146 | before = [ "network-online.target" ]; 147 | # only do it after all interfaces have been brought online, since 148 | # nftables may fail otherwise 149 | # XXX: is running in network-pre and restarting on fail a couple times 150 | # more resilient for some configs? I don't know, so I'm leaving 151 | # this "cleaner" solution here 152 | # FIXME: OpenVPN still runs after nftables sometimes 153 | after = [ "network.target" "netns-${name}.service" ]; 154 | bindsTo = [ "netns-${name}.service" ]; 155 | script = mkNftStartCmd value; 156 | reload = mkNftStartCmd value; 157 | preStop = mkNftStopCmd value; 158 | serviceConfig = { 159 | Type = "oneshot"; 160 | RemainAfterExit = true; 161 | NetworkNamespacePath = "/var/run/netns/${name}"; 162 | Restart = "on-failure"; 163 | RestartSec = "10s"; 164 | }; 165 | reloadIfChanged = true; 166 | }; 167 | })) 168 | // lib.optionalAttrs config.networking.nftables.enable { 169 | # a stub for compatibility with services that depend on networking.nftables 170 | nftables = lib.mkIf config.networking.nftables.enable (lib.mkForce { 171 | description = "nftables stub"; 172 | bindsTo = [ "nftables-netns-default.service" ]; 173 | serviceConfig.Type = "oneshot"; 174 | serviceConfig.RemainAfterExit = true; 175 | serviceConfig.ExecStart = "${pkgs.coreutils}/bin/true"; 176 | }); 177 | }; 178 | }) 179 | ]; 180 | } 181 | -------------------------------------------------------------------------------- /modules/radvd.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , utils 5 | , router-lib 6 | , ... 7 | }: 8 | 9 | let 10 | cfg = config.router; 11 | in 12 | { 13 | config = lib.mkIf (cfg.enable && builtins.any (x: x.ipv6.radvd.enable) (builtins.attrValues cfg.interfaces)) { 14 | users.users.radvd = { 15 | isSystemUser = true; 16 | group = "radvd"; 17 | description = "Router Advertisement Daemon User"; 18 | }; 19 | users.groups.radvd = { }; 20 | 21 | systemd.services = lib.flip lib.mapAttrs' cfg.interfaces (interface: icfg: 22 | let 23 | ifaceOpts = { 24 | AdvSendAdvert = true; 25 | AdvManagedFlag = icfg.ipv6.kea.enable && icfg.ipv6.addresses != [ ]; 26 | AdvOtherConfigFlag = icfg.ipv6.kea.enable && icfg.ipv6.addresses != [ ]; 27 | } // icfg.ipv6.radvd.interfaceSettings; 28 | prefixOpts = { 29 | # if dhcp6 is enabled: don't autoconfigure addresses, ask dhcp 30 | AdvAutonomous = !ifaceOpts.AdvManagedFlag; 31 | }; 32 | compileOpt = x: 33 | if x == true then "on" 34 | else if x == false then "off" 35 | else toString x; 36 | compileOpts = lib.mapAttrsToList (k: v: "${k} ${compileOpt v};"); 37 | indent = map (x: " " + x); 38 | confFile = pkgs.writeText "radvd-${interface}.conf" ( 39 | builtins.concatStringsSep "\n" ( 40 | [ "interface ${interface} {" ] 41 | ++ indent ( 42 | compileOpts ifaceOpts 43 | ++ builtins.concatLists (map 44 | ({ address, gateways, prefixLength, dns, radvdSettings, ... }: 45 | [ "prefix ${address}/${toString prefixLength} {" ] 46 | ++ indent (compileOpts (prefixOpts // radvdSettings)) 47 | ++ [ "};" ] 48 | ++ (builtins.concatLists (map 49 | (gateway: 50 | [ "route ${if builtins.isString gateway then gateway else gateway.address}/${toString (if gateway.prefixLength or null != null then gateway.prefixLength else prefixLength)} {" ] 51 | ++ indent (compileOpts (gateway.radvdSettings or { })) 52 | ++ [ "};" ]) 53 | gateways)) 54 | ++ (builtins.concatLists (map 55 | (dns: 56 | [ "RDNSS ${if builtins.isString dns then dns else dns.address} {" ] 57 | ++ indent (compileOpts (dns.radvdSettings or { })) 58 | ++ [ "};" ]) 59 | dns))) 60 | icfg.ipv6.addresses) 61 | ) ++ [ "};" ] 62 | ) 63 | ); 64 | package = pkgs.radvd; 65 | in 66 | { 67 | name = "radvd-${utils.escapeSystemdPath interface}"; 68 | value = lib.mkIf icfg.ipv6.radvd.enable (router-lib.mkServiceForIf interface { 69 | description = "IPv6 Router Advertisement Daemon (${interface})"; 70 | wantedBy = [ "multi-user.target" ]; 71 | after = [ "network.target" ]; 72 | serviceConfig = { 73 | ExecStart = "@${package}/bin/radvd radvd -n -u radvd -C ${confFile}"; 74 | Restart = "always"; 75 | }; 76 | }); 77 | }); 78 | }; 79 | } 80 | --------------------------------------------------------------------------------