diff --git a/clanServices/coredns/README.md b/clanServices/coredns/README.md new file mode 100644 index 0000000..6283045 --- /dev/null +++ b/clanServices/coredns/README.md @@ -0,0 +1,73 @@ +!!! Danger "Experimental" + This service is experimental and will change in the future. + +This module enables hosting clan-internal services easily, which can be resolved +inside your VPN. This allows defining a custom top-level domain (e.g. `.clan`) +and exposing endpoints from a machine to others, which will be +accessible under `http://.clan` in your browser. + +The service consists of two roles: + +- A `server` role: This is the DNS-server that will be queried when trying to + resolve clan-internal services. It defines the top-level domain. +- A `default` role: This does two things. First, it sets up the nameservers so + that clan-internal queries are resolved via the `server` machine, while + external queries are resolved as normal via DHCP. Second, it allows exposing + services (see example below). + +## Example Usage + +Here the machine `dnsserver` is designated as internal DNS-server for the TLD +`.foo`. `server01` will host an application that shall be reachable at +`http://one.foo` and `server02` is going to be reachable at `http://two.foo`. +`client` is any other machine that is part of the clan but does not host any +services. + +When `client` tries to resolve `http://one.foo`, the DNS query will be +routed to `dnsserver`, which will answer with `192.168.1.3`. If it tries to +resolve some external domain (e.g. `https://clan.lol`), the query will not be +routed to `dnsserver` but resolved as before, via the nameservers advertised by +DHCP. + +```nix +inventory = { + + machines = { + dnsserver = { }; # 192.168.1.2 + server01 = { }; # 192.168.1.3 + server02 = { }; # 192.168.1.4 + client = { }; # 192.168.1.5 + }; + + instances = { + coredns = { + + module.name = "@clan/coredns"; + module.input = "self"; + + # Add the default role to all machines, including `client` + roles.default.tags.all = { }; + + # DNS server queries to http://.foo are resolved here + roles.server.machines."dnsserver".settings = { + ip = "192.168.1.2"; + tld = "foo"; + }; + + # First service + # Registers http://one.foo will resolve to 192.168.1.3 + # underlying service runs on server01 + roles.default.machines."server01".settings = { + ip = "192.168.1.3"; + services = [ "one" ]; + }; + + # Second service + roles.default.machines."server02".settings = { + ip = "192.168.1.4"; + services = [ "two" ]; + }; + }; + }; +}; +``` diff --git a/clanServices/coredns/default.nix b/clanServices/coredns/default.nix new file mode 100644 index 0000000..520c968 --- /dev/null +++ b/clanServices/coredns/default.nix @@ -0,0 +1,233 @@ +{ ... }: + +{ + _class = "clan.service"; + manifest.name = "coredns"; + manifest.description = "Clan-internal DNS and service exposure"; + manifest.categories = [ "Network" ]; + manifest.readme = builtins.readFile ./README.md; + + roles.server = { + description = "A DNS server that resolves services in the clan network."; + interface = + { lib, ... }: + { + options.tld = lib.mkOption { + type = lib.types.str; + default = "clan"; + description = '' + Top-level domain for this instance. All services below this will be + resolved internally. + ''; + }; + + options.ip = lib.mkOption { + type = lib.types.str; + # TODO: Set a default + description = "IP for the DNS to listen on"; + }; + + options.dnsPort = lib.mkOption { + type = lib.types.int; + default = 1053; + description = "Port of the clan-internal DNS server"; + }; + }; + + perInstance = + { + roles, + settings, + ... + }: + { + nixosModule = + { + lib, + pkgs, + ... + }: + + let + hostServiceEntries = + host: + lib.strings.concatStringsSep "\n" ( + map ( + service: + let + ip = roles.default.machines.${host}.settings.ip; + isIPv4 = addr: (builtins.match "\\." addr) != null; + recordType = if (isIPv4 ip) then "A" else "AAAA"; + in + "${service} IN ${recordType} ${ip} ; ${host}" + ) roles.default.machines.${host}.settings.services + ); + + hostnameEntries = '' + crocus 10800 IN AAAA fd28:387a:90:c400:6db2:dfc3:c376:9956 + genepi 10800 IN AAAA fd28:387a:90:c400:ab23:3d38:a148:f539 + verbena 10800 IN AAAA fd28:387a:90:c400::1 + haze 10800 IN AAAA fd28:387a:90:c400:840e:e9db:4c08:b920 + ''; + + zonefile = builtins.toFile "${settings.tld}.zone" ( + '' + $TTL 3600 ; 1 Hour + $ORIGIN ${settings.tld}. + ${settings.tld}. IN SOA ns1 admin.rpqt.fr. ( + 2025112300 ; serial + 10800 ; refresh + 3600 ; retry + 604800 ; expire + 300 ; minimum + ) + + ${builtins.concatStringsSep "\n" ( + lib.lists.imap1 (i: _m: "@ 1D IN NS ns${toString i}.${settings.tld}.") ( + lib.attrNames roles.server.machines + ) + )} + + ${builtins.concatStringsSep "\n" ( + lib.lists.imap1 (i: m: "ns${toString i} 10800 IN CNAME ${m}.${settings.tld}.") ( + lib.attrNames roles.server.machines + ) + )} + + '' + + hostnameEntries + + "\n" + + (lib.strings.concatStringsSep "\n" ( + map (host: hostServiceEntries host) (lib.attrNames roles.default.machines) + )) + ); + in + { + networking.firewall.interfaces.wireguard = { + allowedTCPPorts = [ settings.dnsPort ]; + allowedUDPPorts = [ settings.dnsPort ]; + }; + + services.coredns = { + enable = true; + config = + + let + dnsPort = builtins.toString settings.dnsPort; + in + + '' + .:${dnsPort} { + forward . 1.1.1.1 + cache 30 + } + + ${settings.tld}:${dnsPort} { + file ${zonefile} + } + ''; + }; + }; + }; + }; + + roles.default = { + description = "A machine that registers the 'server' role as resolver and registers services under the configured TLD in the resolver."; + interface = + { lib, ... }: + { + options.services = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Service endpoints this host exposes (without TLD). Each entry will + be resolved to . using the configured top-level domain. + ''; + }; + + options.ip = lib.mkOption { + type = lib.types.str; + # TODO: Set a default + description = "IP on which the services will listen"; + }; + + options.dnsPort = lib.mkOption { + type = lib.types.int; + default = 1053; + description = "Port of the clan-internal DNS server"; + }; + }; + + perInstance = + { roles, settings, ... }: + { + nixosModule = + { config, lib, ... }: + { + + networking.nameservers = map ( + m: + let + port = config.services.unbound.settings.port or 53; + in + "127.0.0.1:${toString port}#${roles.server.machines.${m}.settings.tld}" + ) (lib.attrNames roles.server.machines); + + services.resolved.domains = map (m: "~${roles.server.machines.${m}.settings.tld}") ( + lib.attrNames roles.server.machines + ); + + services.unbound = { + enable = true; + resolveLocalQueries = true; + checkconf = true; + settings = { + server = { + # port = 5353; + verbosity = 2; + interface = [ "127.0.0.1" ]; + access-control = [ "127.0.0.0/8 allow" ]; + do-not-query-localhost = "no"; + domain-insecure = map (m: "${roles.server.machines.${m}.settings.tld}.") ( + lib.attrNames roles.server.machines + ); + }; + + # Default: forward everything else to DHCP-provided resolvers + # forward-zone = [ + # { + # name = "."; + # forward-addr = "127.0.0.53@53"; # Forward to systemd-resolved + # } + # ]; + forward-zone = [ + { + name = "."; + forward-tls-upstream = true; + forward-addr = [ + "9.9.9.9#dns.quad9.net" + "149.112.112.112#dns.quad9.net" + "1.1.1.1@853#cloudflare-dns.com" + "1.0.0.1@853#cloudflare-dns.com" + "2606:4700:4700::1111@853#cloudflare-dns.com" + "2606:4700:4700::1001@853#cloudflare-dns.com" + "8.8.8.8#dns.google" + "8.8.4.4#dns.google" + "2001:4860:4860::8888#dns.google" + "2001:4860:4860::8844#dns.google" + ]; + } + ]; + + stub-zone = { + name = "${roles.server.machines.${(lib.head (lib.attrNames roles.server.machines))}.settings.tld}."; + stub-addr = map ( + m: "${roles.server.machines.${m}.settings.ip}@${builtins.toString settings.dnsPort}" + ) (lib.attrNames roles.server.machines); + }; + }; + }; + }; + }; + }; +} diff --git a/clanServices/coredns/flake-module.nix b/clanServices/coredns/flake-module.nix new file mode 100644 index 0000000..69c8537 --- /dev/null +++ b/clanServices/coredns/flake-module.nix @@ -0,0 +1,18 @@ +{ ... }: +let + module = ./default.nix; +in +{ + clan.modules = { + "@rpqt/coredns" = module; + }; + # perSystem = + # { ... }: + # { + # clan.nixosTests.coredns = { + # imports = [ ./tests/vm/default.nix ]; + + # clan.modules."@rpqt/coredns" = module; + # }; + # }; +} diff --git a/clanServices/flake-module.nix b/clanServices/flake-module.nix index 34805a6..138d0f4 100644 --- a/clanServices/flake-module.nix +++ b/clanServices/flake-module.nix @@ -1,6 +1,7 @@ { imports = [ ./buildbot/flake-module.nix + ./coredns/flake-module.nix ./prometheus/flake-module.nix ]; }