From 617dd624903cdb23951f1484c19bc1574b10fa74 Mon Sep 17 00:00:00 2001 From: Linnnus Date: Sun, 16 Mar 2025 13:22:12 +0100 Subject: ahmed: Add certificates for local DNS --- hosts/ahmed/local-dns/certificates.nix | 52 +++++++++++++++++++++++++++++++ hosts/ahmed/local-dns/default.nix | 41 ++++++++++++++++++++++++ hosts/ahmed/local-dns/dns-resolver.nix | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 hosts/ahmed/local-dns/certificates.nix create mode 100644 hosts/ahmed/local-dns/default.nix create mode 100644 hosts/ahmed/local-dns/dns-resolver.nix (limited to 'hosts/ahmed/local-dns') diff --git a/hosts/ahmed/local-dns/certificates.nix b/hosts/ahmed/local-dns/certificates.nix new file mode 100644 index 0000000..25784c1 --- /dev/null +++ b/hosts/ahmed/local-dns/certificates.nix @@ -0,0 +1,52 @@ +# Getting HTTPS to work for local domains is pretty hard. The approach I've +# gone with is to request a wildcard domain for `*.rumpenettet.linus.onl`. We +# can do this because `linus.onl` is a public domain which we have control +# over. +# +# This module requests a certificate from letsencrypt using DNS-01 +# verification. I have an API token which can modify DNS records for +# `linus.onl`. This is how Lego (i.e. `security.acme`) proves domain ownership +# when renewing the certificate. +# +# Any services running under `rumpenettet.local.onl` and use this certificate. +# For NGINX that happens via `useACMEHost` and one of the options that enable +# HTTPS. +{ + lib, + config, + ... +}: { + security.acme = { + certs.${config.linus.local-dns.domain} = { + dnsProvider = "cloudflare"; + dnsResolver = "1.1.1.1:53"; + environmentFile = config.age.secrets.cloudflare-acme-token.path; + dnsPropagationCheck = true; + domain = "*.${config.linus.local-dns.domain}"; + + group = config.services.nginx.group; + reloadServices = ["nginx"]; + }; + }; + + # This file contains the variables that Lego needs to authenticate to + # Cloudflare. This is how we prove ownership of the domain. + # + # See: https://go-acme.github.io/lego/dns/cloudflare/ + # See: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#EnvironmentFile= + age.secrets.cloudflare-acme-token.file = ../../../secrets/cloudflare-acme-token.env.age; + + # Use the certificate for each subdomain in NGINX. Luckily, we can be pretty + # opinionated since this isn't reusable logic. + # + # NOTE: This assumes that each subdomain *has* an NGINX virtual host, which + # may not be the case in the future. + services.nginx.virtualHosts = let + virtualHostConfig = subdomain: + lib.nameValuePair "${subdomain}.${config.linus.local-dns.domain}" { + forceSSL = true; + useACMEHost = config.linus.local-dns.domain; # Same as security.acme.certs.${...} above. + }; + in + builtins.listToAttrs (map virtualHostConfig config.linus.local-dns.subdomains); +} diff --git a/hosts/ahmed/local-dns/default.nix b/hosts/ahmed/local-dns/default.nix new file mode 100644 index 0000000..6ac96e9 --- /dev/null +++ b/hosts/ahmed/local-dns/default.nix @@ -0,0 +1,41 @@ +# This module sets up local DNS so that services on this host become visible to devices on LAN. +# The work is split in submodules, coordinated via the options set in this module: +# +# - certificates.nix: Get certs for HTTPS (surprisingly hard) +# - dns-resolver.nix: Make local domains visible to devices +# +# See the files for more info on how each part works. +{lib, ...}: { + imports = [ + ./certificates.nix + ./dns-resolver.nix + ]; + + options = { + linus.local-dns = { + domain = lib.mkOption { + description = '' + A (sub)domain we have ownership over. + + To devices using our DNS cache (on port 53), it will look like this + computer has the authority over that domain. It should not be used to + server anything public, as that would then be overwritten. + ''; + type = lib.types.nonEmptyStr; + }; + + # TODO: This assumes that all subdomains are located on this host. What about our NAS? Be more flexible. + subdomains = lib.mkOption { + description = '' + List of subdomains that to {option}`domain` which are in use. + ''; + type = with lib.types; listOf nonEmptyStr; + default = []; + }; + }; + }; + + config = { + linus.local-dns.domain = "rumpenettet.linus.onl"; + }; +} diff --git a/hosts/ahmed/local-dns/dns-resolver.nix b/hosts/ahmed/local-dns/dns-resolver.nix new file mode 100644 index 0000000..1954a52 --- /dev/null +++ b/hosts/ahmed/local-dns/dns-resolver.nix @@ -0,0 +1,57 @@ +# This module creates a local DNS server which provides "split horizon DNS". +# +# It only serves devices on the LAN (see `services.dnscache.clientIps`) and for +# those, it claims to have authority over the domain set in `config.linus.local-dns.domain`. +# +# See: https://www.fefe.de/djbdns/split-horizon +{ + config, + metadata, + lib, + ... +}: { + services.dnscache = { + enable = true; + clientIps = [ + "192.168" # LAN + "127.0.0.1" # Local connections + ]; + + domainServers = { + # Forward any requests to the split domain to our local, authoritative name server. + ${config.linus.local-dns.domain} = ["127.0.0.1"]; + }; + }; + + # Authoritative name server which claims ownership of the split domain. + services.tinydns = { + enable = true; + + # We will only listen for internal queries from the DNS cache. + ip = "127.0.0.1"; + + # Here we publish all the services we want. + data = let + subdomainToARecord = subdomain: "=${subdomain}.${config.linus.local-dns.domain}:${metadata.hosts.ahmed.ipAddress}"; + ARecords = lib.concatMapStringsSep "\n" subdomainToARecord config.linus.local-dns.subdomains; + in '' + # We are authoritative over ${config.linus.local-dns.domain}. + # Here we simply identify as localhost, as only the local dnscache instance will ever see this (I think). + .${config.linus.local-dns.domain}:127.0.0.1:a + # Next, we link all the subdomains to our LAN IP. + ${ARecords} + ''; + }; + + # Allow other devices on LAN to interact with us. In the router's DHCP + # settings, I have set ahmed's IP as the primary DNS server. This will make + # all clients (which respect DNS from DHCP) use ahmed if he's online. + # + # Notably, the NAT on the router does not route external trafic here; we are + # a non-authoritative DNS resolver, so we don't want to service the global + # internet. + networking.firewall = { + allowedTCPPorts = [53]; + allowedUDPPorts = [53]; + }; +} -- cgit v1.2.3