From d4db2f41db471ee25a03d9cdae37f55301b98f22 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Tue, 30 Dec 2025 23:38:51 -0600 Subject: unbound config in router profile is now services/router/dns.nix. unbound + dnsmasq config for local resolution and dhcp --- archetypes/profiles/router/default.nix | 28 ++++- archetypes/profiles/router/unbound.nix | 70 ------------ nixos/default.nix | 3 +- nixos/services/router/dns.nix | 171 ++++++++++++++++++++++++++++ nixos/services/router/unbound-blocklist.nix | 68 +++++++++++ nixos/unbound-blocklist.nix | 68 ----------- 6 files changed, 263 insertions(+), 145 deletions(-) delete mode 100644 archetypes/profiles/router/unbound.nix create mode 100644 nixos/services/router/dns.nix create mode 100644 nixos/services/router/unbound-blocklist.nix delete mode 100644 nixos/unbound-blocklist.nix diff --git a/archetypes/profiles/router/default.nix b/archetypes/profiles/router/default.nix index 0818a6b..646982b 100644 --- a/archetypes/profiles/router/default.nix +++ b/archetypes/profiles/router/default.nix @@ -1,12 +1,28 @@ { lib, pkgs, ... }: let mkRouter = lib.mkOverride 800; - # TODO pass mkRouter - #imports = [ - # ./unbound.nix - #]; - - nixosConfig = {}; + nixosConfig = { + services.unbound = { + _blocklists = { + enable = true; + blocklists = { + hageziNSFW = [ + "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/nsfw.txt" + "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/nsfw.txt" + "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/nsfw.txt" + ]; + hageziPro = [ + "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/pro.txt" + "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/pro.txt" + "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/pro.txt" + ]; + }; + }; + }; + services._router.dnsDhcpConfig = { + enable = mkRouter true; + }; + }; homeConfig = {}; in { diff --git a/archetypes/profiles/router/unbound.nix b/archetypes/profiles/router/unbound.nix deleted file mode 100644 index 1322193..0000000 --- a/archetypes/profiles/router/unbound.nix +++ /dev/null @@ -1,70 +0,0 @@ -{ - services.unbound = { - enable = true; - _blocklists = { - enable = true; - blocklists = { - hageziNSFW = [ - "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/nsfw.txt" - "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/nsfw.txt" - "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/nsfw.txt" - ]; - hageziPro = [ - "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/pro.txt" - "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/pro.txt" - "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/pro.txt" - ]; - }; - }; - settings = { - server = { - # Listen on all interfaces (or specify specific IPs) - interface = [ "0.0.0.0" "::0" ]; - - # Allow queries from local networks - access-control = [ - "127.0.0.0/8 allow" - "192.168.0.0/16 allow" - "10.0.0.0/8 allow" - "172.16.0.0/12 allow" - ]; - - ## Enable DNSSEC validation - #auto-trust-anchor-file: "/var/unbound/root.key" - - # Harden against out-of-zone data - harden-referral-path = true; - harden-dnssec-stripped = true; - - # Privacy options - qname-minimisation = true; - - # Cache settings - cache-min-ttl = 300; - cache-max-ttl = 86400; - - # Hide version - hide-identity = true; - hide-version = true; - - # Based on recommended settings in https://docs.pi-hole.net/guides/dns/unbound/#configure-unbound - harden-glue = true; - use-caps-for-id = false; - prefetch = true; - edns-buffer-size = 1232; - }; - # Forward unknown to public resolver via DoT - forward-zone = [ - { - name = "."; - forward-addr = [ - "9.9.9.9#dns.quad9.net" - "149.112.112.112#dns.quad9.net" - ]; - forward-tls-upstream = true; # Encrypted DNS - } - ]; - remote-control.control-enable = true; - }; - }; -} diff --git a/nixos/default.nix b/nixos/default.nix index 4b87741..76b5d6f 100644 --- a/nixos/default.nix +++ b/nixos/default.nix @@ -7,6 +7,8 @@ ./services/cgit.nix ./services/gitea.nix ./services/searxng.nix + ./services/router/dns.nix + ./services/router/unbound-blocklist.nix ./bootloader.nix ./doas.nix @@ -23,7 +25,6 @@ ./ssh.nix ./sudo.nix ./suspend.nix - ./unbound-blocklist.nix ./zshenv.nix ]; } diff --git a/nixos/services/router/dns.nix b/nixos/services/router/dns.nix new file mode 100644 index 0000000..2772a27 --- /dev/null +++ b/nixos/services/router/dns.nix @@ -0,0 +1,171 @@ +{ config, lib, ... }: let + #cfg = config.services._routing; + cfg = { + localDomain = "home.lan"; + defaultGateway = "127.0.0.1"; + dhcp = { + rangeStart = "192.168.1.50"; + rangeEnd = "192.168.1.150"; + subnetMask = "255.255.255.0"; + leaseTime = "12h"; + }; + log = { + dhcp = false; + dns = false; + }; + }; +in { + options.services._router.dnsDhcpConfig = { + enable = lib.mkEnableOption "enable pre-configured unbound(outbound) + dnsmasq(local) dns(+dhcp) server"; + # TODO + defaultGateway = lib.mkOption { + }; + localIPSubnet = lib.mkOption { + }; + dhcp = { + rangeStart = lib.mkOption { + }; + rangeEnd = lib.mkOption { + }; + rangeSubnetMask = lib.mkOption { + }; + leaseTime = lib.mkOption { + }; + staticLeases = lib.mkOption { + }; + }; + localDomain = lib.mkOption { + type = lib.types.str; + default = "home.lan"; + description = ""; + }; + #ipv6 = lib.mkEnableOption "enable ipv6"; # TODO + }; + + config = lib.mkIf cfg.enable { + # Unbound as a recursive DNS resolver. + # Preferred for its security/privacy features + performance + services.unbound = { + enable = true; + settings = { + server = { + # Listen on all interfaces (or specify specific IPs) + interface = [ "0.0.0.0" ]; # ++ lib.optionals cfg.ipv6 [ "::0" ]; + + # Allow queries from local networks + access-control = [ + "127.0.0.0/8 allow" + "192.168.0.0/16 allow" + "10.0.0.0/8 allow" + "172.16.0.0/12 allow" + ]; + + # Cache settings + cache-min-ttl = 300; # 5 min + cache-max-ttl = 60 * 60 * 24; # 1 day + + # Prefetch DNS records for better performance + prefetch-key = true; + prefetch = true; + + # Enable DNSSEC validation (signed DNS, prevents man-in-the-middle attacks) + # `auto-trust-anchor-file` is set by default to "/var/unbound/root.key" + trust-anchor-file = ''""''; + domain-insecure = cfg.localDomain; # Local domain is not DNSSEC-signed + harden-below-nxdomain = true; # Protect against non-existent domain response attacks + harden-glue = true; # Protect against incorrect DNS glue record attacks + harden-dnssec-stripped = true; # Ensures that DNSSEC signatures are not stripped from DNS responses + + # Allow queries to localhost for dnsmasq local domain resolution + do-not-query-localhost = false; + + # Privacy / Security + harden-referral-path = true; # Prevents receiving data from servers that are outside the zone in the DNS referral chain + qname-minimisation = true; # Minimizes the amount of information exposed in DNS queries by only sending the essential parts of the query + hide-identity = true; # Hide software identity in response + hide-version = true; # Hide unbound version in response + use-caps-for-id = false; # Disables using uppercase characters in the DNS transaction ID, for compatibility + edns-buffer-size = 1232; # Sets the EDNS (Extension mechanisms for DNS) buffer size to 1232 bytes, which is the default for many DNS resolvers + + # Logging + #verbosity = 3; + #log-queries = true; + #log-replies = true; + #log-local-actions = true; + }; + # Forward unknown to public resolver via DoT + forward-zone = [ + # Local DNS: forward to dnsmasq + { + name = ''"${cfg.localDomain}."''; # TODO mk config + forward-addr = "127.0.0.1@5353"; # TODO mk config + } + # Local reverse DNS: forward reverse lookups (PTR records) + { + name = ''"1.168.192.in-addr.arpa"''; # NOTE: 192.168.1.0 -> 1.168.192 + forward-addr = "127.0.0.1@5353"; + } + # Upstream DNS + { + name = ''"."''; + forward-addr = [ + "9.9.9.9#dns.quad9.net" + "149.112.112.112#dns.quad9.net" + ]; + forward-tls-upstream = true; # Encrypted DNS + } + ]; + remote-control.control-enable = true; + }; + }; + + # Configure dnsmasq for dhcp and local hostname resolution + services.dnsmasq = { + enable = true; + settings = let + mkDHCPRange = ipRangeStart: ipRangeEnd: subnetMask: leaseTime: "${ipRangeStart},${ipRangeEnd},${subnetMask},${leaseTime}"; + mkDHCPOption = option: value: "option:${option},${value}"; + mkDHCPStaticLease = macAddress: hostname: staticIp: "${macAddress},${hostname},${staticIp},infinite"; + #dhcpStaticLeases = builtins.map (); + in { + # General + no-resolv = true; # Do not read /etc/resolv.conf, resolve only the LAN + no-poll = true; # Do not poll /etc/resolv.conf for changes + # TODO config local domain + local = "/${cfg.localDomain}/"; # Use local-only for the defined domain (prevents upstream leaks) + domain = cfg.localDomain; # Define the local domain name + expand-hosts = true; # Create both fully-qualified and short-name entries from DHCP hostnames + + # DNS Server + port = 5353; # Use port 5353 for DNS server since unbound is the main DNS resolver + + # DHCP Server + # TODO config + #dhcp-range = mkDHCPRange "192.168.1.50" "192.168.1.150" "255.255.255.0" "12h"; # Enable DHCP on the LAN interface + dhcp-range = with cfg.dhcp; mkDHCPRange rangeStart rangeEnd subnetMask leaseTime; # Enable DHCP on the LAN interface + + # TODO config + #dhcp-host = [ mkDHCPStaticLease ... ]; # Setup static leases + #dhcp-host = dhcpStaticLeases; # Setup static leases + + dhcp-option = [ + (mkDHCPOption "router" cfg.defaultGateway) # Set default gateway for clients + #(mkDHCPOption "ntp-server" cfg.defaultGateway) # Set ntp server for clients + (mkDHCPOption "dns-server" cfg.defaultGateway) # Set dns server for clients + (mkDHCPOption "domain-search" cfg.localDomain) # Add search rule to clients so they can resolve hostnames w/o the local domain suffix + ]; + + # Logging + #log-dhcp = true; # Log DHCP events + #log-queries = true; # Log DNS queries + + # Cache + cache-size = 1000; # Small cache limit is fine since Unbound does the heavy caching + }; + }; + + # Search localDomain so host can resolve short names + # This is eq. to dnsmasq's dhcp-option "domain-search" for clients, it just adds a search rule to resolv.conf + networking.search = [ cfg.localDomain ]; + }; +} diff --git a/nixos/services/router/unbound-blocklist.nix b/nixos/services/router/unbound-blocklist.nix new file mode 100644 index 0000000..153f2c0 --- /dev/null +++ b/nixos/services/router/unbound-blocklist.nix @@ -0,0 +1,68 @@ +{ lib, config, pkgs, ... }: let + cfg = config.services.unbound._blocklists; +in { + options.services.unbound._blocklists = { + enable = lib.mkEnableOption "enable rpz blocklist generation in unbound"; + blocklists = lib.mkOption { + type = lib.types.attrsOf (lib.types.listOf lib.types.str); + example = { + hageziNSFW = [ + "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/nsfw.txt" + "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/nsfw.txt" + "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/nsfw.txt" + ]; + hageziPro = [ + "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/pro.txt" + "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/pro.txt" + "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/pro.txt" + ]; + }; + default = {}; + description = "blocklist urls in response policy zone (rpz) format"; + }; + # TODO + #extraBlacklistedDomains = lib.mkOption { + # type = lib.types.listOf lib.types.str; + # example = [ + # "example.com" + # "*.example.com" + # "elpmaxe.com" + # "*.elpmaxe.com" + # ]; + # default = []; + # description = "additional domains to block"; + #}; + #extraWhitelistedDomains = lib.mkOption { + # type = lib.types.listOf lib.types.str; + # example = [ + # "example.com" + # "*.example.com" + # "elpmaxe.com" + # "*.elpmaxe.com" + # ]; + # default = []; + # description = "whitelist domains that would otherwise be blocked"; + #}; + }; + + config = lib.mkIf (cfg.enable && config.services.unbound.enable) { + # Configure rpz + blocklists in unbound + services.unbound.settings = let + # https://unbound.docs.nlnetlabs.nl/en/latest/topics/filtering/rpz.html + rpzEntry = name: url: { inherit name url; rpz-action-override = "nxdomain"; }; # TODO extra attrs option instead of adding rpz-action-override by default + ## Generate extraBlockedDomains + #extraBlockedDomainsRPZ = lib.strings.concatStringsSep "\n" (builtins.map (domain: "${domain} CNAME .")); + #extraBlockedDomainsRPZFile = pkgs.writeText "extraBlockedDomains" '' + # $TTL 300 + # @ SOA localhost. root.localhost. 1 43200 3600 86400 300 + # NS localhost. + # ${extraBlockedDomainsRPZ} + #''; + #extraBlockedDomainsRPZEntries = rpzEntry "extraBlockedDomains" extraBlockedDomainsRPZFile; + rpz = lib.mapAttrsToList rpzEntry cfg.blocklists; + in { + server.module-config = ''"respip validator iterator"''; # Adds respip before validator and iterator. Needed for rpz config + inherit rpz; + }; + }; +} diff --git a/nixos/unbound-blocklist.nix b/nixos/unbound-blocklist.nix deleted file mode 100644 index 153f2c0..0000000 --- a/nixos/unbound-blocklist.nix +++ /dev/null @@ -1,68 +0,0 @@ -{ lib, config, pkgs, ... }: let - cfg = config.services.unbound._blocklists; -in { - options.services.unbound._blocklists = { - enable = lib.mkEnableOption "enable rpz blocklist generation in unbound"; - blocklists = lib.mkOption { - type = lib.types.attrsOf (lib.types.listOf lib.types.str); - example = { - hageziNSFW = [ - "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/nsfw.txt" - "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/nsfw.txt" - "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/nsfw.txt" - ]; - hageziPro = [ - "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/rpz/pro.txt" - "https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/rpz/pro.txt" - "https://codeberg.org/hagezi/mirror2/raw/branch/main/dns-blocklists/rpz/pro.txt" - ]; - }; - default = {}; - description = "blocklist urls in response policy zone (rpz) format"; - }; - # TODO - #extraBlacklistedDomains = lib.mkOption { - # type = lib.types.listOf lib.types.str; - # example = [ - # "example.com" - # "*.example.com" - # "elpmaxe.com" - # "*.elpmaxe.com" - # ]; - # default = []; - # description = "additional domains to block"; - #}; - #extraWhitelistedDomains = lib.mkOption { - # type = lib.types.listOf lib.types.str; - # example = [ - # "example.com" - # "*.example.com" - # "elpmaxe.com" - # "*.elpmaxe.com" - # ]; - # default = []; - # description = "whitelist domains that would otherwise be blocked"; - #}; - }; - - config = lib.mkIf (cfg.enable && config.services.unbound.enable) { - # Configure rpz + blocklists in unbound - services.unbound.settings = let - # https://unbound.docs.nlnetlabs.nl/en/latest/topics/filtering/rpz.html - rpzEntry = name: url: { inherit name url; rpz-action-override = "nxdomain"; }; # TODO extra attrs option instead of adding rpz-action-override by default - ## Generate extraBlockedDomains - #extraBlockedDomainsRPZ = lib.strings.concatStringsSep "\n" (builtins.map (domain: "${domain} CNAME .")); - #extraBlockedDomainsRPZFile = pkgs.writeText "extraBlockedDomains" '' - # $TTL 300 - # @ SOA localhost. root.localhost. 1 43200 3600 86400 300 - # NS localhost. - # ${extraBlockedDomainsRPZ} - #''; - #extraBlockedDomainsRPZEntries = rpzEntry "extraBlockedDomains" extraBlockedDomainsRPZFile; - rpz = lib.mapAttrsToList rpzEntry cfg.blocklists; - in { - server.module-config = ''"respip validator iterator"''; # Adds respip before validator and iterator. Needed for rpz config - inherit rpz; - }; - }; -} -- cgit v1.2.3