diff --git a/clanServices/coredns/README.md b/clanServices/coredns/README.md new file mode 100644 index 000000000..e36f83e8c --- /dev/null +++ b/clanServices/coredns/README.md @@ -0,0 +1,68 @@ +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 + thatclan-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 + roles.server.machines."dnsserver".settings = { + ip = "192.168.1.2"; + tld = "foo"; + }; + + # First service + 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 000000000..51a1689a0 --- /dev/null +++ b/clanServices/coredns/default.nix @@ -0,0 +1,157 @@ +{ ... }: +{ + _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 = { + + 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"; + }; + }; + + perInstance = + { + roles, + settings, + ... + }: + { + nixosModule = + { + lib, + pkgs, + ... + }: + { + + networking.firewall.allowedTCPPorts = [ 53 ]; + networking.firewall.allowedUDPPorts = [ 53 ]; + + services.coredns = + let + + # Get all service entries for one host + hostServiceEntries = + host: + lib.strings.concatStringsSep "\n" ( + map ( + service: "${service} IN A ${roles.default.machines.${host}.settings.ip} ; ${host}" + ) roles.default.machines.${host}.settings.services + ); + + zonefile = pkgs.writeTextFile { + name = "db.${settings.tld}"; + text = '' + $TTL 3600 + @ IN SOA ns.${settings.tld}. admin.${settings.tld}. 1 7200 3600 1209600 3600 + IN NS ns.${settings.tld}. + ns IN A ${settings.ip} ; DNS server + + '' + + (lib.strings.concatStringsSep "\n" ( + map (host: hostServiceEntries host) (lib.attrNames roles.default.machines) + )); + }; + + in + { + enable = true; + config = '' + . { + forward . 1.1.1.1 + cache 30 + } + + ${settings.tld} { + file ${zonefile} + } + ''; + }; + }; + }; + }; + + roles.default = { + 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"; + }; + }; + + perInstance = + { roles, ... }: + { + nixosModule = + { lib, ... }: + { + + networking.nameservers = map (m: "127.0.0.1:5353#${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; + 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 + } + ]; + stub-zone = map (m: { + name = "${roles.server.machines.${m}.settings.tld}."; + stub-addr = "${roles.server.machines.${m}.settings.ip}"; + }) (lib.attrNames roles.server.machines); + }; + }; + }; + }; + }; +} diff --git a/clanServices/coredns/flake-module.nix b/clanServices/coredns/flake-module.nix new file mode 100644 index 000000000..7d74a7407 --- /dev/null +++ b/clanServices/coredns/flake-module.nix @@ -0,0 +1,18 @@ +{ lib, ... }: +let + module = lib.modules.importApply ./default.nix { }; +in +{ + clan.modules = { + coredns = module; + }; + perSystem = + { ... }: + { + clan.nixosTests.coredns = { + imports = [ ./tests/vm/default.nix ]; + + clan.modules."@clan/coredns" = module; + }; + }; +} diff --git a/clanServices/coredns/tests/vm/default.nix b/clanServices/coredns/tests/vm/default.nix new file mode 100644 index 000000000..d6347d0a0 --- /dev/null +++ b/clanServices/coredns/tests/vm/default.nix @@ -0,0 +1,113 @@ +{ + ... +}: +{ + name = "coredns"; + + clan = { + directory = ./.; + test.useContainers = true; + inventory = { + + machines = { + dns = { }; # 192.168.1.2 + server01 = { }; # 192.168.1.3 + server02 = { }; # 192.168.1.4 + client = { }; # 192.168.1.1 + }; + + instances = { + coredns = { + + module.name = "@clan/coredns"; + module.input = "self"; + + roles.default.tags.all = { }; + + # First service + 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" ]; + }; + + # DNS server + roles.server.machines."dns".settings = { + ip = "192.168.1.2"; + tld = "foo"; + }; + }; + }; + }; + }; + + nodes = { + dns = + { pkgs, ... }: + { + environment.systemPackages = [ pkgs.net-tools ]; + }; + + client = + { pkgs, ... }: + { + environment.systemPackages = [ pkgs.net-tools ]; + }; + + server01 = { + services.nginx = { + enable = true; + virtualHosts."one.foo" = { + locations."/" = { + return = "200 'test server response one'"; + extraConfig = "add_header Content-Type text/plain;"; + }; + }; + }; + }; + server02 = { + services.nginx = { + enable = true; + virtualHosts."two.foo" = { + locations."/" = { + return = "200 'test server response two'"; + extraConfig = "add_header Content-Type text/plain;"; + }; + }; + }; + }; + }; + + testScript = '' + import json + start_all() + + machines = [server01, server02, dns, client] + + for m in machines: + m.systemctl("start network-online.target") + + for m in machines: + m.wait_for_unit("network-online.target") + + # import time + # time.sleep(2333333) + + # This should work, but is borken in tests i think? Instead we dig directly + + # client.succeed("curl -k -v http://one.foo") + # client.succeed("curl -k -v http://two.foo") + + answer = client.succeed("dig @192.168.1.2 one.foo") + assert "192.168.1.3" in answer, "IP not found" + + answer = client.succeed("dig @192.168.1.2 two.foo") + assert "192.168.1.4" in answer, "IP not found" + + ''; +} diff --git a/clanServices/coredns/tests/vm/sops/users/admin/key.json b/clanServices/coredns/tests/vm/sops/users/admin/key.json new file mode 100644 index 000000000..e408aa96b --- /dev/null +++ b/clanServices/coredns/tests/vm/sops/users/admin/key.json @@ -0,0 +1,4 @@ +{ + "publickey": "age1qm0p4vf9jvcnn43s6l4prk8zn6cx0ep9gzvevxecv729xz540v8qa742eg", + "type": "age" +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b07f00431..60376108a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -94,6 +94,7 @@ nav: - reference/clanServices/index.md - reference/clanServices/admin.md - reference/clanServices/borgbackup.md + - reference/clanServices/coredns.md - reference/clanServices/data-mesher.md - reference/clanServices/dyndns.md - reference/clanServices/emergency-access.md