| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  | /*
 | 
					
						
							|  |  |  |  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) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |  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. | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  | */ | 
					
						
							|  |  |  | 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; | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |     }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     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"; | 
					
						
							| 
									
										
										
										
											2021-01-10 20:08:30 +01:00
										 |  |  |     meta = with pkgs.lib.maintainers; { | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |       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 | 
					
						
							|  |  |  |         ]; | 
					
						
							|  |  |  |         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" ]; | 
					
						
							|  |  |  |           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 | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |       local_resolver = { lib, nodes, config, ... }: { | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |         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" ]; | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |           localControlSocketPath = "/run/unbound/unbound.ctl"; | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |           extraConfig = ''
 | 
					
						
							|  |  |  |             include: "/etc/unbound/extra*.conf" | 
					
						
							|  |  |  |           '';
 | 
					
						
							|  |  |  |         }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |         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 = {}; | 
					
						
							|  |  |  |         }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |         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, | 
					
						
							|  |  |  |                       ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       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") | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |       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 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |       # 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"]) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |       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") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |       # 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"): | 
					
						
							| 
									
										
										
										
											2020-11-01 22:15:42 +01:00
										 |  |  |           local_resolver.succeed("unbound-control reload") | 
					
						
							| 
									
										
										
										
											2020-10-21 01:34:24 +02:00
										 |  |  |           r = [("A", "3.4.5.6")] | 
					
						
							|  |  |  |           test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r) | 
					
						
							|  |  |  |     '';
 | 
					
						
							|  |  |  |   }) |