# The DNS server for my domains. Take note it uses a hidden master setup with # the secondary nameservers of the service (as of 2023-10-05, we're using # Hetzner's secondary nameservers). { config, lib, pkgs, ... }: let hostCfg = config.hosts.plover; cfg = hostCfg.services.dns-server; inherit (config.networking) domain fqdn; inherit (import ../hardware/networks.nix) interfaces clientNetworks serverNetworks secondaryNameServers; secondaryNameServersIPs = lib.foldl' (total: addresses: total ++ addresses.IPv4 ++ addresses.IPv6) [ ] (lib.attrValues secondaryNameServers); domainZone = pkgs.substituteAll { src = ../../config/dns/${domain}.zone; ploverWANIPv4 = interfaces.wan.IPv4.address; ploverWANIPv6 = interfaces.wan.IPv6.address; }; fqdnZone = pkgs.substituteAll { src = ../../config/dns/${fqdn}.zone; ploverLANIPv4 = interfaces.lan.IPv4.address; ploverLANIPv6 = interfaces.lan.IPv6.address; }; zonesDir = "/etc/bind/zones"; zoneFile = domain: "${zonesDir}/${domain}.zone"; dnsSubdomain = "ns1.${domain}"; dnsOverHTTPSPort = 8443; in { options.hosts.plover.services.dns-server.enable = lib.mkEnableOption "preferred DNS server"; config = lib.mkIf cfg.enable (lib.mkMerge [ { sops.secrets = let dnsFileAttribute = { owner = config.users.users.named.name; group = config.users.users.named.group; mode = "0400"; }; in lib.private.getSecrets ../../secrets/secrets.yaml { "dns/${domain}/mailbox-security-key" = dnsFileAttribute; "dns/${domain}/mailbox-security-key-record" = dnsFileAttribute; "dns/${domain}/keybase-verification-key" = dnsFileAttribute; "dns/${domain}/rfc2136-key" = dnsFileAttribute // { reloadUnits = [ "bind.service" ]; }; }; # Install the utilities. environment.systemPackages = [ config.services.bind.package ]; services.bind = { enable = true; forward = "first"; cacheNetworks = [ "127.0.0.1" "::1" ]; listenOn = [ "127.0.0.1" interfaces.lan.IPv4.address interfaces.wan.IPv4.address ]; listenOnIpv6 = [ "::1" interfaces.lan.IPv6.address interfaces.wan.IPv6.address ]; # Welp, since the template is pretty limited, we'll have to go with our # own. This is partially based from the NixOS Bind module except without # the template for filling in zones since we use views. configFile = let cfg = config.services.bind; certDir = path: "/run/credentials/bind.service/${path}"; listenInterfaces = lib.concatMapStrings (entry: " ${entry}; ") cfg.listenOn; listenInterfacesIpv6 = lib.concatMapStrings (entry: " ${entry}; ") cfg.listenOnIpv6; in pkgs.writeText "named.conf" '' include "/etc/bind/rndc.key"; include "${config.sops.secrets."dns/${domain}/rfc2136-key".path}"; controls { inet 127.0.0.1 allow {localhost;} keys {"rndc-key";}; }; tls ${dnsSubdomain} { key-file "${certDir "key.pem"}"; cert-file "${certDir "cert.pem"}"; dhparam-file "${config.security.dhparams.params.bind.path}"; ciphers "HIGH:!kRSA:!aNULL:!eNULL:!RC4:!3DES:!MD5:!EXP:!PSK:!SRP:!DSS:!SHA1:!SHA256:!SHA384"; prefer-server-ciphers yes; session-tickets no; }; http ${dnsSubdomain} { endpoints { "/dns-query"; }; }; acl trusted { ${lib.concatStringsSep "; " (clientNetworks ++ serverNetworks)}; localhost; }; acl cachenetworks { ${lib.concatMapStrings (entry: " ${entry}; ") cfg.cacheNetworks} }; acl badnetworks { ${lib.concatMapStrings (entry: " ${entry}; ") cfg.blockedNetworks} }; options { # Native DNS. listen-on { ${listenInterfaces} }; listen-on-v6 { ${listenInterfacesIpv6} }; # DNS-over-TLS. listen-on tls ${dnsSubdomain} { ${listenInterfaces} }; listen-on-v6 tls ${dnsSubdomain} { ${listenInterfacesIpv6} }; # DNS-over-HTTPS. https-port ${builtins.toString dnsOverHTTPSPort}; listen-on tls ${dnsSubdomain} http ${dnsSubdomain} { ${listenInterfaces} }; listen-on-v6 tls ${dnsSubdomain} http ${dnsSubdomain} { ${listenInterfacesIpv6} }; allow-query { cachenetworks; }; blackhole { badnetworks; }; forward ${cfg.forward}; forwarders { ${lib.concatMapStrings (entry: " ${entry}; ") cfg.forwarders} }; directory "${cfg.directory}"; pid-file "/run/named/named.pid"; }; view internal { match-clients { trusted; }; allow-query { any; }; allow-recursion { any; }; // We'll use systemd-resolved as our forwarder. forwarders { 127.0.0.53 port 53; }; zone "${fqdn}" { type primary; file "${zoneFile fqdn}"; }; zone "${domain}" { type primary; file "${zoneFile domain}"; allow-transfer { ${lib.concatStringsSep "; " secondaryNameServersIPs}; }; update-policy { grant rfc2136key.${domain}. zonesub TXT; }; }; }; view external { match-clients { any; }; forwarders { }; empty-zones-enable yes; allow-query { any; }; allow-recursion { none; }; zone "${domain}" { in-view internal; }; }; ${cfg.extraConfig} ''; }; systemd.services.bind = { path = with pkgs; [ replace-secret ]; preStart = let domainZone' = zoneFile domain; fqdnZone' = zoneFile fqdn; secretPath = path: config.sops.secrets."dns/${path}".path; rndc = lib.getExe' config.services.bind.package "rndc"; in lib.mkAfter '' # Install the domain zone. { install -Dm0600 '${domainZone}' '${domainZone'}' replace-secret '#mailboxSecurityKey#' '${secretPath "${domain}/mailbox-security-key"}' '${domainZone'}' replace-secret '#mailboxSecurityKeyRecord#' '${secretPath "${domain}/mailbox-security-key-record"}' '${domainZone'}' #${rndc} sync "${domain}" IN external } # Install the internal DNS zones. install -Dm0600 '${fqdnZone}' '${fqdnZone'}' ''; serviceConfig = { # Additional service hardening. You can see most of the options from # systemd.exec(5) manual. Run it as an unprivileged user. User = config.users.users.named.name; Group = config.users.users.named.group; UMask = "0037"; # Get the credentials into the service. LoadCredential = let certDirectory = config.security.acme.certs."${dnsSubdomain}".directory; certCredentialPath = path: "${path}:${certDirectory}/${path}"; in [ (certCredentialPath "cert.pem") (certCredentialPath "key.pem") (certCredentialPath "fullchain.pem") ]; LogFilterPatterns = [ # systemd-resolved doesn't have DNS cookie support, it seems. "~missing expected cookie from 127.0.0.53#53" ]; # Lock and protect various system components. LockPersonality = true; PrivateTmp = true; NoNewPrivileges = true; RestrictSUIDSGID = true; ProtectHome = true; ProtectHostname = true; ProtectClock = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectKernelLogs = true; ProtectControlGroups = true; ProtectProc = "invisible"; # Make the filesystem invisible to the service. ProtectSystem = "strict"; ReadWritePaths = [ config.services.bind.directory "/etc/bind" ]; ReadOnlyPaths = [ config.security.dhparams.params.bind.path config.security.acme.certs."${dnsSubdomain}".directory ]; # Set up writable directories. RuntimeDirectory = "named"; RuntimeDirectoryMode = "0750"; CacheDirectory = "named"; CacheDirectoryMode = "0750"; ConfigurationDirectory = "bind"; ConfigurationDirectoryMode = "0755"; # Filtering system calls. SystemCallFilter = [ "@system-service" ]; SystemCallErrorNumber = "EPERM"; SystemCallArchitectures = "native"; # Granting and restricting its capabilities. Take note we're not using # syslog for this even if the application can so no syslog capability. # Additionally, we're using omitting the program's ability to chroot and # chown since the user and the directories are already configured. CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; # Restrict what address families can it access. RestrictAddressFamilies = [ "AF_LOCAL" "AF_NETLINK" "AF_BRIDGE" "AF_INET" "AF_INET6" ]; # Restricting what namespaces it can create. RestrictNamespaces = true; }; }; # Then generate a DH parameter for the application. security.dhparams.params.bind.bits = 4096; } (lib.mkIf hostCfg.services.reverse-proxy.enable { # Making this with nginx. services.nginx.upstreams.local-dns = { extraConfig = '' zone dns 64k; ''; servers = { "127.0.0.1:${builtins.toString dnsOverHTTPSPort}" = { }; }; }; services.nginx.virtualHosts."${dnsSubdomain}" = { forceSSL = true; enableACME = true; acmeRoot = null; extraConfig = '' add_header Strict-Transport-Security max-age=31536000; ''; kTLS = true; locations = { "/".return = "444"; "/dns-query".extraConfig = '' grpc_pass grpcs://local-dns; grpc_socket_keepalive on; grpc_connect_timeout 10s; grpc_ssl_verify off; grpc_ssl_protocols TLSv1.3 TLSv1.2; ''; }; }; services.nginx.streamConfig = '' upstream dns_servers { server localhost:53; } server { listen 53 udp reuseport; proxy_timeout 20s; proxy_pass dns_servers; } ''; }) # Set up the firewall. Take note the ports with the transport layer being # accepted in Bind. (lib.mkIf hostCfg.services.firewall.enable { networking.firewall = let ports = [ 53 # DNS 853 # DNS-over-TLS/DNS-over-QUIC ]; in { allowedUDPPorts = ports; allowedTCPPorts = ports; }; }) # Add the following to be backed up. (lib.mkIf hostCfg.services.backup.enable { services.borgbackup.jobs.services-backup.paths = [ zonesDir ]; }) # Set up a fail2ban which is apparently already available in the package. (lib.mkIf hostCfg.services.fail2ban.enable { services.fail2ban.jails."named-refused".settings = { enabled = true; backend = "systemd"; filter = "named-refused[journalmatch='_SYSTEMD_UNIT=bind.service']"; maxretry = 3; }; }) ]); }