# 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, foodogsquaredLib, ... }:

let
  hostCfg = config.hosts.plover;
  cfg = hostCfg.services.dns-server;

  inherit (config.networking) domain fqdn;

  zonesDir = "/etc/bind/zones";
  getZoneFile = domain: "${zonesDir}/${domain}.zone";

  zonefile = pkgs.substituteAll {
    src = ./zones/${domain}.zone;
    ploverWANIPv4 = config.state.network.ipv4;
    ploverWANIPv6 = config.state.network.ipv6;
  };

  fqdnZone = pkgs.substituteAll {
    src = ./zones/${fqdn}.zone;
    ploverWANIPv4 = config.state.network.ipv4;
    ploverWANIPv6 = config.state.network.ipv6;
  };

  dnsSubdomain = "ns1.${domain}";
in
{
  options.hosts.plover.services.dns-server.enable =
    lib.mkEnableOption "preferred DNS server";

  config = lib.mkIf cfg.enable (lib.mkMerge [
    {
      state.ports = {
        dns.value = 53;
        dnsOverHTTPS.value = 8443;
        dnsOverTLS.value = 853;
      };

      sops.secrets =
        let
          dnsFileAttribute = {
            owner = config.users.users.named.name;
            group = config.users.users.named.group;
            mode = "0400";
          };
        in
        foodogsquaredLib.sops-nix.getSecrets ./secrets.yaml {
          "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"
          config.state.network.ipv4
        ];

        listenOnIpv6 = [
          "::1"
          config.state.network.ipv6
        ];

        extraConfig = ''
          include "${config.state.paths.dataDir}/dns/*-dnskeys.conf";
        '';

        # 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 "; " [ "10.0.0.0/8" ]}; 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.
              tls-port ${builtins.toString config.state.ports.dnsOverTLS.value};
              https-port ${builtins.toString config.state.ports.dnsOverHTTPS.value};
              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 "${getZoneFile fqdn}";
              };

              zone "${domain}" {
                type primary;

                file "${getZoneFile domain}";
                allow-transfer { ${lib.concatStringsSep "; " config.state.network.secondaryNameservers}; };
                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' = getZoneFile domain;
            fqdnZone' = getZoneFile fqdn;
          in
          lib.mkAfter ''
            # Install the domain zone.
            [ -f ${lib.escapeShellArg domainZone'} ] && install -Dm0600 ${zonefile} ${lib.escapeShellArg domainZone'}

            # Install the internal DNS zones.
            [ -f ${lib.escapeShellArg fqdnZone'} ] && install -Dm0600 '${fqdnZone}' ${lib.escapeShellArg 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.monitoring.enable {
      state.ports.bindStatistics.value = 9423;

      services.bind.extraConfig = ''
        statistics-channels {
          inet 127.0.0.1 port ${builtins.toString config.state.ports.bindStatistics.value} allow { 127.0.0.1; };
        };
      '';

      services.prometheus.exporters = {
        bind = {
          enable = true;
          bindURI = "http://127.0.0.1/${builtins.toString config.state.ports.bindStatistics.value}";
        };
      };
    })

    (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 config.state.ports.dnsOverHTTPS.value}" = { };
        };
      };

      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 = {
        allowedUDPPorts = [ config.state.ports.dns.value ];
        allowedTCPPorts = with config.state.ports; [
          dns.value
          dnsOverHTTPS.value
          dnsOverTLS.value
        ];
      };
    })

    # 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;
      };
    })
  ]);
}