From 1dec333f3f5b94cfeea403e27fa256403207b776 Mon Sep 17 00:00:00 2001 From: Romain Paquet Date: Tue, 24 Feb 2026 17:53:46 +0100 Subject: [PATCH] clan: import @shallerclan/dns service --- clanServices/dns.nix | 310 ++++++++++++++++++++++++++++++++++ clanServices/flake-module.nix | 2 + 2 files changed, 312 insertions(+) create mode 100644 clanServices/dns.nix diff --git a/clanServices/dns.nix b/clanServices/dns.nix new file mode 100644 index 0000000..c05f20f --- /dev/null +++ b/clanServices/dns.nix @@ -0,0 +1,310 @@ +{ ... }: +{ + _class = "clan.service"; + manifest.name = "dns"; + manifest.categories = [ "Network" ]; + manifest.description = "Clan-internal DNS and service exposure"; + manifest.readme = '' + # How it works + + Every dns-request from a clan machine lands at systemd-resolved and it resolves (forwards) requests with the following priority: + + 1. /etc/hosts file + 2. Local authority nameserver (if tld is ''${config.clan.core.settings.domain}) + 3. Configured system's dns-servers e.g. `networking.nameservers` + + The local authority nameserver is configured to answer requests only from localhost and it hosts the zonefile for the clan domain. + + For external requests, the server role must be deployed/configured. + + # Usage + + ```nix + # clan.nix + inventory.instances.dns = { + module.input = "self"; + module.name = "@schallerclan/dns"; + + roles.server = { + tags = [ "serve_dns" ]; + extraModules = [ modules/blocky.nix ]; + + machines."myMachine01" = {}; + }; + + roles.default.machines."myMachine01".settings = { + records = { + A = [ + "203.0.113.1" # www + "10.0.0.1" # wireguard + "100.0.0.1" # tailscale + ]; + AAAA = [ + "2001:db8::1" # www + "fc00::1" # wireguard + "fd00::1" # tailscale + "400::1" # mycelium + "200::1" # yggdrasil + ]; + }; + }; + + roles.default.machines."myMachine02".settings = { + records = { + A = [ + "203.0.113.2" # www + "10.0.0.2" # wireguard + "100.0.0.2" # tailscale + ]; + AAAA = [ + "2001:db8::2" # www + "fc00::2" # wireguard + "fd00::2" # tailscale + "400::2" # mycelium + "200::2" # yggdrasil + ]; + }; + + services = [ "foo" ]; + }; + }; + ``` + + The example will result into the following records, in the zonefile: + + ``` + myMachine01.clan A 203.0.113.1 + myMachine01.clan A 10.0.0.1 + myMachine01.clan A 100.0.0.1 + myMachine01.clan AAAA 2001:db8::1 + myMachine01.clan AAAA fd00::1 + myMachine01.clan AAAA fc00::1 + myMachine01.clan AAAA 400::1 + myMachine01.clan AAAA 200::1 + + myMachine02.clan A 203.0.113.2 + myMachine02.clan A 10.0.0.2 + myMachine02.clan A 100.0.0.2 + myMachine02.clan AAAA 2001:db8::2 + myMachine02.clan AAAA fd00::2 + myMachine02.clan AAAA fc00::2 + myMachine02.clan AAAA 400::2 + myMachine02.clan AAAA 200::2 + + foo.clan A 203.0.113.2 + foo.clan A 10.0.0.2 + foo.clan A 100.0.0.2 + foo.clan AAAA 2001:db8::2 + foo.clan AAAA fd00::2 + foo.clan AAAA fc00::2 + foo.clan AAAA 400::2 + foo.clan AAAA 200::2 + ``` + ''; + + roles.default = { + description = '' + Machines in this role will take part in dns. + + Machines will + - register their hostname with the configured records + - register their `settings.services` with the configured records + - be able to resolve all records locally + ''; + + interface = + { lib, ... }: + { + options = with lib.types; { + records = lib.mkOption { + type = attrsOf (coercedTo str (s: [ s ]) (listOf str)); + default = { }; + description = '' + DNS records for the machine and all its services. + + Technically, no restrictions on which dns records can be used. But + intended for A and AAAA records. + ''; + example = { + A = [ + "203.0.113.2" # www + "10.0.0.2" # wireguard + "100.0.0.2" # tailscale + ]; + AAAA = [ + "2001:db8::2" # www + "fd28:387a:6e:df00::2" # wireguard + "fd7a:115c:a1e0::2" # tailscale + "400::2" # mycelium + "200::2" # yggdrasil + ]; + }; + }; + + services = lib.mkOption { + type = listOf str; + default = [ ]; + description = '' + Service endpoints this host exposes (without TLD). Each entry will be resolved to .''${config.clan.core.settings.domain}. + ''; + }; + }; + }; + + perInstance = + { roles, settings, ... }: + { + nixosModule = + { + lib, + config, + pkgs, + ... + }: + { + networking.nameservers = [ "[::1]:1053#${config.clan.core.settings.domain}" ]; + + services.resolved.domains = [ "~${config.clan.core.settings.domain}" ]; + + services.unbound = { + enable = true; + + # https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound.conf.html + settings = { + server = { + port = 1053; + verbosity = 2; + interface = [ + "127.0.0.1" + "::1" + ]; + access-control = [ + "127.0.0.0/8 allow" + "::0/64 allow" + ]; + do-not-query-localhost = false; + domain-insecure = [ "${config.clan.core.settings.domain}." ]; + }; + + auth-zone = [ + { + name = config.clan.core.settings.domain; + zonefile = "${pkgs.writeTextFile ( + let + nsRecords = lib.lists.concatLists ( + # ↓ this machine + lib.lists.forEach (lib.attrsToList settings.records) ( + record: lib.lists.forEach record.value (value: "ns ${record.name} ${value}") + ) + ); + + machineRecords = lib.lists.concatLists ( + lib.lists.forEach (lib.attrNames roles.default.machines) ( + machine: + lib.lists.concatLists ( + lib.lists.forEach (lib.attrsToList roles.default.machines.${machine}.settings.records) ( + record: lib.lists.forEach record.value (value: "${machine} ${record.name} ${value}") + ) + ) + ) + ); + + serviceRecords = lib.lists.concatLists ( + lib.lists.forEach (lib.attrNames roles.default.machines) ( + machine: + lib.lists.concatLists ( + lib.lists.forEach roles.default.machines.${machine}.settings.services ( + service: + lib.lists.concatLists ( + lib.lists.forEach (lib.attrsToList roles.default.machines.${machine}.settings.records) ( + record: lib.lists.forEach record.value (value: "${service} ${record.name} ${value} ; ${machine}") + ) + ) + ) + ) + ) + ); + in + { + name = "db.${config.clan.core.settings.domain}.zone"; + text = lib.strings.concatStringsSep "\n\n" [ + '' + $ORIGIN ${config.clan.core.settings.domain}. + $TTL 3600 + @ IN SOA ns admin 1 7200 3600 1209600 3600 + @ IN NS ns + '' + (lib.strings.concatStringsSep "\n" nsRecords) + (lib.strings.concatStringsSep "\n" machineRecords) + (lib.strings.concatStringsSep "\n" serviceRecords) + ]; + } + )}"; + } + ]; + }; + }; + }; + }; + }; + + roles.server = { + description = '' + Additional role upon `roles.default`. + + Machines in this role will serve [blocky](https://0xerr0r.github.io/blocky/latest/) as a dns server on port 53, including all dns records in the default role. + + For blocky (dns server) configuration, make a new file at e.g. `modules/blocky.nix` and include it with `roles.server.extraModules [ modules/blocky.nix ];` + + ```nix + # modules/blocky.nix + { + # https://0xerr0r.github.io/blocky/latest/configuration + services.blocky.settings = { + # ... + }; + } + ``` + + With `systemctl status blocky.service` check, if blocky was configured correctly. + ''; + + perInstance = { + nixosModule = + { lib, config, ... }: + { + networking.firewall.allowedTCPPorts = [ 53 ]; + networking.firewall.allowedUDPPorts = [ 53 ]; + + services.resolved.enable = false; + services.blocky = { + enable = true; + settings = { + upstreams.groups.default = lib.mkDefault [ + # quad9 + "9.9.9.9" + "149.112.112.112" + "2620:fe::fe" + "2620:fe::9" + # cloudflare + "1.1.1.1" + "1.0.0.1" + "2606:4700:4700::1111" + "2606:4700:4700::1001" + ]; + + conditional.mapping = { + ${config.clan.core.settings.domain} = "tcp+udp:[::1]:1053"; + }; + }; + }; + }; + }; + }; + + perMachine = { + nixosModule = { + services.tailscale.extraUpFlags = [ "--accept-dns=false" ]; + }; + }; +} diff --git a/clanServices/flake-module.nix b/clanServices/flake-module.nix index 6bef516..2a428e5 100644 --- a/clanServices/flake-module.nix +++ b/clanServices/flake-module.nix @@ -6,4 +6,6 @@ ]; clan.modules."@rpqt/vaultwarden" = ./vaultwarden.nix; + + clan.modules."@schallerclan/dns" = ./dns.nix; }