{ config, lib, ... }: let inherit (lib) mkOption mkEnableOption types; rlib = import ./lib.nix { inherit lib; }; in { options.router = { enable = mkEnableOption "the router role"; wan = { interface = mkOption { type = types.str; example = "eth0"; description = '' Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged; with PPPoE it also carries the (optionally tagged) PPPoE session. The link may also carry LANs ("router on a stick") when the uplink is PPPoE on a VLAN and IPv6 is disabled: the untagged link is then free for a LAN and only the PPP device counts as an uplink. ''; }; ipv4 = { type = mkOption { type = types.enum [ "dhcp" "pppoe" ]; default = "dhcp"; description = "How the router gets its IPv4 uplink."; }; pppoe = { vlan = mkOption { type = types.nullOr (types.ints.between 1 4094); default = null; description = "VLAN ID the ISP requires for PPPoE, or null for untagged."; }; username = mkOption { type = types.str; default = ""; description = "PPP authentication username."; }; passwordFile = mkOption { type = types.nullOr ( types.pathWith { absolute = true; inStore = false; } ); default = null; example = "/var/lib/secrets/pppoe-password"; description = '' File containing the PPP password, read at service start. Must not be in the Nix store (it would be world-readable). ''; }; mtu = mkOption { type = types.ints.between 576 1500; default = 1492; description = "PPP MTU/MRU; TCP MSS is clamped to match on forwarded traffic."; }; }; interface = mkOption { type = types.str; readOnly = true; internal = true; description = "Device IPv4 traffic leaves through (derived)."; }; }; ipv6.enable = mkEnableOption '' IPv6 on the uplink: DHCPv6 (soliciting even without RA flags) plus prefix delegation. The ISP is expected to delegate a single /64 ''; }; lans = mkOption { default = { }; description = "LAN segments served by this router, keyed by name."; type = types.attrsOf ( types.submodule ( { name, config, ... }: { options = { interface = mkOption { type = types.str; example = "eth1"; description = '' Interface carrying this LAN. With `vlan` set this is the parent (trunk) interface on which the VLAN device is created. ''; }; vlan = mkOption { type = types.nullOr (types.ints.between 1 4094); default = null; description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`."; }; device = mkOption { type = types.str; readOnly = true; internal = true; description = "Device the LAN's addresses live on (derived)."; }; zone = mkOption { type = types.str; default = name; description = '' Firewall zone this LAN belongs to. LANs sharing a zone may talk to each other freely; traffic between zones is denied unless a rule in `router.firewall.rules` allows it. Defaults to a zone named after the LAN. ''; }; internet = mkOption { type = types.bool; default = false; description = "Whether this LAN may reach the internet (opt-in)."; }; ipv4 = { address = mkOption { type = types.str; example = "192.168.10.1"; description = "Router address on this LAN."; }; prefixLength = mkOption { type = types.ints.between 8 30; default = 24; }; subnet = mkOption { type = types.str; readOnly = true; description = "Network in CIDR notation, derived from `address` and `prefixLength`."; }; }; dhcp = { enable = mkOption { type = types.bool; default = true; }; pool = { start = mkOption { type = types.str; example = "192.168.10.100"; }; end = mkOption { type = types.str; example = "192.168.10.199"; }; }; }; ipv6.pd = mkOption { type = types.bool; default = false; description = '' Whether this LAN receives the /64 delegated on the uplink (assigned to the interface and announced via SLAAC). A /64 cannot be split, so at most one LAN may set this; the others stay IPv4-only. ''; }; allowManagement = mkOption { type = types.bool; default = true; description = "Whether clients on this LAN may reach SSH on the router."; }; }; config.device = if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}"; # "invalid" is only ever seen when assertions.nix is already failing. config.ipv4.subnet = if rlib.ipv4.parse config.ipv4.address == null then "invalid" else rlib.ipv4.cidr config.ipv4.address config.ipv4.prefixLength; } ) ); }; wireguard = mkOption { default = { }; description = '' WireGuard tunnels, keyed by name. A tunnel is a firewall member like a LAN (it has a `zone`, `internet`, `allowManagement`), so `router.firewall.rules` also cover traffic to and from it. Peers' `allowedIPs` are the destinations routed into the tunnel; routing is by destination only (no default-route tunnels). ''; type = types.attrsOf ( types.submodule ( { name, ... }: { options = { interface = mkOption { type = types.str; default = "wg-${name}"; description = "Name of the WireGuard device."; }; privateKeyFile = mkOption { type = types.pathWith { absolute = true; inStore = false; }; example = "/var/lib/secrets/wg-rw.key"; description = '' File with the private key, outside the Nix store. It is handed to systemd-networkd as a systemd credential, so it can be root-only (mode 0600) and is read when networkd starts. ''; }; listenPort = mkOption { type = types.nullOr types.port; default = null; description = "UDP port to listen on (opened on the uplinks); null for a dial-out-only tunnel."; }; mtu = mkOption { type = types.nullOr (types.ints.between 576 9000); default = null; description = "Tunnel MTU; null keeps the kernel default (1420)."; }; addresses = mkOption { type = types.listOf types.str; default = [ ]; example = [ "10.100.0.1/24" "fd42:100::1/64" ]; description = '' Addresses (CIDR, IPv4 and/or IPv6) on the tunnel interface; empty means no transfer network (fine for site-to-site). ''; }; zone = mkOption { type = types.str; default = name; description = "Firewall zone; shared with LANs or other tunnels if you want a common policy."; }; internet = mkOption { type = types.bool; default = false; description = "Whether traffic from this tunnel may reach the internet via the uplink."; }; allowManagement = mkOption { type = types.bool; default = false; description = "Whether SSH on the router is reachable through this tunnel."; }; dns = mkOption { type = types.bool; default = false; description = "Whether the router's resolver answers on the tunnel's IPv4 address."; }; masquerade = mkOption { type = types.bool; default = false; description = "Source-NAT IPv4 traffic leaving through this tunnel to the tunnel address."; }; killSwitch = mkOption { type = types.bool; default = true; description = '' Drop forwarded traffic for this tunnel's peers' `allowedIPs` when it would leave through an uplink (for example because the tunnel device is down), instead of letting it leak. ''; }; peers = mkOption { default = { }; type = types.attrsOf ( types.submodule { options = { publicKey = mkOption { type = types.str; }; presharedKeyFile = mkOption { type = types.nullOr ( types.pathWith { absolute = true; inStore = false; } ); default = null; }; endpoint = mkOption { type = types.nullOr types.str; default = null; example = "vpn.example.org:51820"; }; allowedIPs = mkOption { type = types.listOf types.str; default = [ ]; example = [ "192.168.50.0/24" ]; }; persistentKeepalive = mkOption { type = types.nullOr (types.ints.between 1 65535); default = null; }; }; } ); }; }; } ) ); }; firewall.rules = mkOption { default = [ ]; description = '' Allow rules between zones, on top of the default (deny between zones). Rules are one-directional; return traffic is allowed by connection tracking. `to` must be the zone of a LAN or tunnel; `from` may also be the reserved zone `wan` (traffic arriving on the uplinks), which publishes services to the outside: such rules must name a `destination` and protocol (and ports for tcp/udp), so a typo cannot expose a whole zone. ''; type = types.listOf ( types.submodule { options = { from = mkOption { type = types.str; description = "Source zone, or `wan`."; }; to = mkOption { type = types.str; description = "Destination zone."; }; protocol = mkOption { type = types.enum [ "any" "tcp" "udp" "icmp" ]; default = "any"; }; ports = mkOption { type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+")); default = [ ]; example = [ 443 "50000-50100" ]; description = '' Destination ports or ranges (tcp/udp only); empty means all ports. With `dnat` these are the ports on the router's public address. ''; }; destination = mkOption { type = types.nullOr types.str; default = null; example = "192.168.10.5"; description = '' Restrict the rule to an IPv4 address or CIDR inside the destination zone; null means the whole zone. IPv6 addresses are accepted too (for pinholes) but cannot be checked against the zone, because delegated prefixes are not known at build time. ''; }; sources = mkOption { type = types.listOf types.str; default = [ ]; example = [ "198.51.100.0/24" ]; description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source."; }; dnat = mkOption { type = types.bool; default = false; description = '' Port forward: traffic from `wan` to `ports` on the router's public IPv4 address is redirected to `destination` (one IPv4 host). The source address is preserved. ''; }; toPort = mkOption { type = types.nullOr types.port; default = null; description = "Port on `destination` when it differs from the public port (single-port forwards only)."; }; hairpin = mkOption { type = types.bool; default = false; description = '' Let LANs with `internet = true` reach this forward through the router's public address (NAT loopback, source is masqueraded). ''; }; rateLimit = mkOption { type = types.nullOr (types.strMatching "[0-9]+/(second|minute|hour)"); default = null; example = "10/minute"; description = "Limit new connections per source address; the excess is dropped."; }; log = mkOption { type = types.bool; default = false; description = "Log new connections accepted by this rule, and connections dropped by `rateLimit` (kernel log)."; }; }; } ); }; dns.forwarders = mkOption { type = types.listOf types.str; default = [ ]; description = "Upstream resolvers. When empty, Unbound resolves recursively."; }; }; config.router.wan.ipv4.interface = if config.router.wan.ipv4.type == "pppoe" then "pppoe-wan" else config.router.wan.interface; }