nixos-config/hosts/plover/modules/services/bind.nix

331 lines
9.9 KiB
Nix

# 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
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
{
sops.secrets =
let
dnsFileAttribute = {
owner = config.users.users.named.name;
group = config.users.users.named.group;
mode = "0400";
};
in
lib.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;
in
lib.mkAfter ''
[ -f '${domainZone'}' ] || {
install -Dm0600 '${domainZone}' '${domainZone'}'
replace-secret '#mailboxSecurityKey#' '${secretPath "${domain}/mailbox-security-key"}' '${domainZone'}'
replace-secret '#mailboxSecurityKeyRecord#' '${secretPath "${domain}/mailbox-security-key-record"}' '${domainZone'}'
}
[ -f '${fqdnZone'}' ] || {
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")
];
# 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"
"CAP_NET_RAW"
];
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;
};
};
# Set up the firewall. Take note the ports with the transport layer being
# accepted in Bind.
networking.firewall = let
ports = [
53 # DNS
853 # DNS-over-TLS/DNS-over-QUIC
dnsOverHTTPSPort
];
in {
allowedUDPPorts = ports;
allowedTCPPorts = ports;
};
# 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;
'';
};
};
# Then generate a DH parameter for the application.
security.dhparams.params.bind.bits = 4096;
# Set up a fail2ban which is apparently already available in the package.
services.fail2ban.jails."named-refused".settings = {
enabled = true;
backend = "systemd";
filter = "named-refused[journalmatch='_SYSTEMD_UNIT=bind.service']";
maxretry = 3;
};
# Add the following to be backed up.
services.borgbackup.jobs.services-backup.paths = [ zonesDir ];
}