293 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Nix
		
	
	
	
	
	
			
		
		
	
	
			293 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Nix
		
	
	
	
	
	
| /*
 | |
|  Test that our unbound module indeed works as most users would expect.
 | |
|  There are a few settings that we must consider when modifying the test. The
 | |
|  ususal use-cases for unbound are
 | |
|    * running a recursive DNS resolver on the local machine
 | |
|    * running a recursive DNS resolver on the local machine, forwarding to a local DNS server via UDP/53 & TCP/53
 | |
|    * running a recursive DNS resolver on the local machine, forwarding to a local DNS server via TCP/853 (DoT)
 | |
|    * running a recursive DNS resolver on a machine in the network awaiting input from clients over TCP/53 & UDP/53
 | |
|    * running a recursive DNS resolver on a machine in the network awaiting input from clients over TCP/853 (DoT)
 | |
| 
 | |
|  In the below test setup we are trying to implement all of those use cases.
 | |
| 
 | |
|  Another aspect that we cover is access to the local control UNIX socket. It
 | |
|  can optionally be enabled and users can optionally be in a group to gain
 | |
|  access. Users that are not in the group (except for root) should not have
 | |
|  access to that socket. Also, when there is no socket configured, users
 | |
|  shouldn't be able to access the control socket at all. Not even root.
 | |
| */
 | |
| import ./make-test-python.nix ({ pkgs, lib, ... }:
 | |
|   let
 | |
|     # common client configuration that we can just use for the multitude of
 | |
|     # clients we are constructing
 | |
|     common = { lib, pkgs, ... }: {
 | |
|       config = {
 | |
|         environment.systemPackages = [ pkgs.knot-dns ];
 | |
| 
 | |
|         # disable the root anchor update as we do not have internet access during
 | |
|         # the test execution
 | |
|         services.unbound.enableRootTrustAnchor = false;
 | |
| 
 | |
|         # we want to test the full-variant of the package to also get DoH support
 | |
|         services.unbound.package = pkgs.unbound-full;
 | |
|       };
 | |
|     };
 | |
| 
 | |
|     cert = pkgs.runCommandNoCC "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } ''
 | |
|       openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -subj '/CN=dns.example.local'
 | |
|       mkdir -p $out
 | |
|       cp key.pem cert.pem $out
 | |
|     '';
 | |
|   in
 | |
|   {
 | |
|     name = "unbound";
 | |
|     meta = with pkgs.lib.maintainers; {
 | |
|       maintainers = [ andir ];
 | |
|     };
 | |
| 
 | |
|     nodes = {
 | |
| 
 | |
|       # The server that actually serves our zones, this tests unbounds authoriative mode
 | |
|       authoritative = { lib, pkgs, config, ... }: {
 | |
|         imports = [ common ];
 | |
|         networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
 | |
|           { address = "192.168.0.1"; prefixLength = 24; }
 | |
|         ];
 | |
|         networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
 | |
|           { address = "fd21::1"; prefixLength = 64; }
 | |
|         ];
 | |
|         networking.firewall.allowedTCPPorts = [ 53 ];
 | |
|         networking.firewall.allowedUDPPorts = [ 53 ];
 | |
| 
 | |
|         services.unbound = {
 | |
|           enable = true;
 | |
|           interfaces = [ "192.168.0.1" "fd21::1" "::1" "127.0.0.1" ];
 | |
|           allowedAccess = [ "192.168.0.0/24" "fd21::/64" "::1" "127.0.0.0/8" ];
 | |
|           extraConfig = ''
 | |
|             server:
 | |
|               local-data: "example.local. IN A 1.2.3.4"
 | |
|               local-data: "example.local. IN AAAA abcd::eeff"
 | |
|           '';
 | |
|         };
 | |
|       };
 | |
| 
 | |
|       # The resolver that knows that fowards (only) to the authoritative server
 | |
|       # and listens on UDP/53, TCP/53 & TCP/853.
 | |
|       resolver = { lib, nodes, ... }: {
 | |
|         imports = [ common ];
 | |
|         networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
 | |
|           { address = "192.168.0.2"; prefixLength = 24; }
 | |
|         ];
 | |
|         networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
 | |
|           { address = "fd21::2"; prefixLength = 64; }
 | |
|         ];
 | |
|         networking.firewall.allowedTCPPorts = [
 | |
|           53 # regular DNS
 | |
|           853 # DNS over TLS
 | |
|           443 # DNS over HTTPS
 | |
|         ];
 | |
|         networking.firewall.allowedUDPPorts = [ 53 ];
 | |
| 
 | |
|         services.unbound = {
 | |
|           enable = true;
 | |
|           allowedAccess = [ "192.168.0.0/24" "fd21::/64" "::1" "127.0.0.0/8" ];
 | |
|           interfaces = [ "::1" "127.0.0.1" "192.168.0.2" "fd21::2"
 | |
|                          "192.168.0.2@853" "fd21::2@853" "::1@853" "127.0.0.1@853"
 | |
|                          "192.168.0.2@443" "fd21::2@443" "::1@443" "127.0.0.1@443" ];
 | |
|           forwardAddresses = [
 | |
|             (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv6.addresses).address
 | |
|             (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv4.addresses).address
 | |
|           ];
 | |
|           extraConfig = ''
 | |
|             server:
 | |
|               tls-service-pem: ${cert}/cert.pem
 | |
|               tls-service-key: ${cert}/key.pem
 | |
|           '';
 | |
|         };
 | |
|       };
 | |
| 
 | |
|       # machine that runs a local unbound that will be reconfigured during test execution
 | |
|       local_resolver = { lib, nodes, config, ... }: {
 | |
|         imports = [ common ];
 | |
|         networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
 | |
|           { address = "192.168.0.3"; prefixLength = 24; }
 | |
|         ];
 | |
|         networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
 | |
|           { address = "fd21::3"; prefixLength = 64; }
 | |
|         ];
 | |
|         networking.firewall.allowedTCPPorts = [
 | |
|           53 # regular DNS
 | |
|         ];
 | |
|         networking.firewall.allowedUDPPorts = [ 53 ];
 | |
| 
 | |
|         services.unbound = {
 | |
|           enable = true;
 | |
|           allowedAccess = [ "::1" "127.0.0.0/8" ];
 | |
|           interfaces = [ "::1" "127.0.0.1" ];
 | |
|           localControlSocketPath = "/run/unbound/unbound.ctl";
 | |
|           extraConfig = ''
 | |
|             include: "/etc/unbound/extra*.conf"
 | |
|           '';
 | |
|         };
 | |
| 
 | |
|         users.users = {
 | |
|           # user that is permitted to access the unix socket
 | |
|           someuser.extraGroups = [
 | |
|             config.users.users.unbound.group
 | |
|           ];
 | |
| 
 | |
|           # user that is not permitted to access the unix socket
 | |
|           unauthorizeduser = {};
 | |
|         };
 | |
| 
 | |
|         environment.etc = {
 | |
|           "unbound-extra1.conf".text = ''
 | |
|             forward-zone:
 | |
|               name: "example.local."
 | |
|               forward-addr: ${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv6.addresses).address}
 | |
|               forward-addr: ${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv4.addresses).address}
 | |
|           '';
 | |
|           "unbound-extra2.conf".text = ''
 | |
|             auth-zone:
 | |
|               name: something.local.
 | |
|               zonefile: ${pkgs.writeText "zone" ''
 | |
|                 something.local. IN A 3.4.5.6
 | |
|               ''}
 | |
|           '';
 | |
|         };
 | |
|       };
 | |
| 
 | |
| 
 | |
|       # plain node that only has network access and doesn't run any part of the
 | |
|       # resolver software locally
 | |
|       client = { lib, nodes, ... }: {
 | |
|         imports = [ common ];
 | |
|         networking.nameservers = [
 | |
|           (lib.head nodes.resolver.config.networking.interfaces.eth1.ipv6.addresses).address
 | |
|           (lib.head nodes.resolver.config.networking.interfaces.eth1.ipv4.addresses).address
 | |
|         ];
 | |
|         networking.interfaces.eth1.ipv4.addresses = [
 | |
|           { address = "192.168.0.10"; prefixLength = 24; }
 | |
|         ];
 | |
|         networking.interfaces.eth1.ipv6.addresses = [
 | |
|           { address = "fd21::10"; prefixLength = 64; }
 | |
|         ];
 | |
|       };
 | |
|     };
 | |
| 
 | |
|     testScript = { nodes, ... }: ''
 | |
|       import typing
 | |
|       import json
 | |
| 
 | |
|       zone = "example.local."
 | |
|       records = [("AAAA", "abcd::eeff"), ("A", "1.2.3.4")]
 | |
| 
 | |
| 
 | |
|       def query(
 | |
|           machine,
 | |
|           host: str,
 | |
|           query_type: str,
 | |
|           query: str,
 | |
|           expected: typing.Optional[str] = None,
 | |
|           args: typing.Optional[typing.List[str]] = None,
 | |
|       ):
 | |
|           """
 | |
|           Execute a single query and compare the result with expectation
 | |
|           """
 | |
|           text_args = ""
 | |
|           if args:
 | |
|               text_args = " ".join(args)
 | |
| 
 | |
|           out = machine.succeed(
 | |
|               f"kdig {text_args} {query} {query_type} @{host} +short"
 | |
|           ).strip()
 | |
|           machine.log(f"{host} replied with {out}")
 | |
|           if expected:
 | |
|               assert expected == out, f"Expected `{expected}` but got `{out}`"
 | |
| 
 | |
| 
 | |
|       def test(machine, remotes, /, doh=False, zone=zone, records=records, args=[]):
 | |
|           """
 | |
|           Run queries for the given remotes on the given machine.
 | |
|           """
 | |
|           for query_type, expected in records:
 | |
|               for remote in remotes:
 | |
|                   query(machine, remote, query_type, zone, expected, args)
 | |
|                   query(machine, remote, query_type, zone, expected, ["+tcp"] + args)
 | |
|                   if doh:
 | |
|                       query(
 | |
|                           machine,
 | |
|                           remote,
 | |
|                           query_type,
 | |
|                           zone,
 | |
|                           expected,
 | |
|                           ["+tcp", "+tls"] + args,
 | |
|                       )
 | |
|                       query(
 | |
|                           machine,
 | |
|                           remote,
 | |
|                           query_type,
 | |
|                           zone,
 | |
|                           expected,
 | |
|                           ["+https"] + args,
 | |
|                       )
 | |
| 
 | |
| 
 | |
|       client.start()
 | |
|       authoritative.wait_for_unit("unbound.service")
 | |
| 
 | |
|       # verify that we can resolve locally
 | |
|       with subtest("test the authoritative servers local responses"):
 | |
|           test(authoritative, ["::1", "127.0.0.1"])
 | |
| 
 | |
|       resolver.wait_for_unit("unbound.service")
 | |
| 
 | |
|       with subtest("root is unable to use unbounc-control when the socket is not configured"):
 | |
|           resolver.succeed("which unbound-control")  # the binary must exist
 | |
|           resolver.fail("unbound-control list_forwards")  # the invocation must fail
 | |
| 
 | |
|       # verify that the resolver is able to resolve on all the local protocols
 | |
|       with subtest("test that the resolver resolves on all protocols and transports"):
 | |
|           test(resolver, ["::1", "127.0.0.1"], doh=True)
 | |
| 
 | |
|       resolver.wait_for_unit("multi-user.target")
 | |
| 
 | |
|       with subtest("client should be able to query the resolver"):
 | |
|           test(client, ["${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv6.addresses).address}", "${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv4.addresses).address}"], doh=True)
 | |
| 
 | |
|       # discard the client we do not need anymore
 | |
|       client.shutdown()
 | |
| 
 | |
|       local_resolver.wait_for_unit("multi-user.target")
 | |
| 
 | |
|       # link a new config file to /etc/unbound/extra.conf
 | |
|       local_resolver.succeed("ln -s /etc/unbound-extra1.conf /etc/unbound/extra1.conf")
 | |
| 
 | |
|       # reload the server & ensure the forwarding works
 | |
|       with subtest("test that the local resolver resolves on all protocols and transports"):
 | |
|           local_resolver.succeed("systemctl reload unbound")
 | |
|           print(local_resolver.succeed("journalctl -u unbound -n 1000"))
 | |
|           test(local_resolver, ["::1", "127.0.0.1"], args=["+timeout=60"])
 | |
| 
 | |
|       with subtest("test that we can use the unbound control socket"):
 | |
|           out = local_resolver.succeed(
 | |
|               "sudo -u someuser -- unbound-control list_forwards"
 | |
|           ).strip()
 | |
| 
 | |
|           # Thank you black! Can't really break this line into a readable version.
 | |
|           expected = "example.local. IN forward ${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv6.addresses).address} ${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv4.addresses).address}"
 | |
|           assert out == expected, f"Expected `{expected}` but got `{out}` instead."
 | |
|           local_resolver.fail("sudo -u unauthorizeduser -- unbound-control list_forwards")
 | |
| 
 | |
| 
 | |
|       # link a new config file to /etc/unbound/extra.conf
 | |
|       local_resolver.succeed("ln -sf /etc/unbound-extra2.conf /etc/unbound/extra2.conf")
 | |
| 
 | |
|       # reload the server & ensure the new local zone works
 | |
|       with subtest("test that we can query the new local zone"):
 | |
|           local_resolver.succeed("unbound-control reload")
 | |
|           r = [("A", "3.4.5.6")]
 | |
|           test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r)
 | |
|     '';
 | |
|   })
 | 
