vuls: init at 0.27.0
[NixPkgs.git] / nixos / tests / unbound.nix
blob39a01259edeb5be710713e984e4644d510288524
1 /*
2  Test that our unbound module indeed works as most users would expect.
3  There are a few settings that we must consider when modifying the test. The
4  usual use-cases for unbound are
5    * running a recursive DNS resolver on the local machine
6    * running a recursive DNS resolver on the local machine, forwarding to a local DNS server via UDP/53 & TCP/53
7    * running a recursive DNS resolver on the local machine, forwarding to a local DNS server via TCP/853 (DoT)
8    * running a recursive DNS resolver on a machine in the network awaiting input from clients over TCP/53 & UDP/53
9    * running a recursive DNS resolver on a machine in the network awaiting input from clients over TCP/853 (DoT)
11  In the below test setup we are trying to implement all of those use cases.
13  Another aspect that we cover is access to the local control UNIX socket. It
14  can optionally be enabled and users can optionally be in a group to gain
15  access. Users that are not in the group (except for root) should not have
16  access to that socket. Also, when there is no socket configured, users
17  shouldn't be able to access the control socket at all. Not even root.
19 import ./make-test-python.nix ({ pkgs, lib, ... }:
20   let
21     # common client configuration that we can just use for the multitude of
22     # clients we are constructing
23     common = { lib, pkgs, ... }: {
24       config = {
25         environment.systemPackages = [ pkgs.knot-dns ];
27         # disable the root anchor update as we do not have internet access during
28         # the test execution
29         services.unbound.enableRootTrustAnchor = false;
31         # we want to test the full-variant of the package to also get DoH support
32         services.unbound.package = pkgs.unbound-full;
33       };
34     };
36     cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } ''
37       openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -subj '/CN=dns.example.local'
38       mkdir -p $out
39       cp key.pem cert.pem $out
40     '';
41   in
42   {
43     name = "unbound";
44     meta = with pkgs.lib.maintainers; {
45       maintainers = [ andir ];
46     };
48     nodes = {
50       # The server that actually serves our zones, this tests unbounds authoriative mode
51       authoritative = { lib, pkgs, config, ... }: {
52         imports = [ common ];
53         networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
54           { address = "192.168.0.1"; prefixLength = 24; }
55         ];
56         networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
57           { address = "fd21::1"; prefixLength = 64; }
58         ];
59         networking.firewall.allowedTCPPorts = [ 53 ];
60         networking.firewall.allowedUDPPorts = [ 53 ];
62         services.unbound = {
63           enable = true;
64           settings = {
65             server = {
66               interface = [ "192.168.0.1" "fd21::1" "::1" "127.0.0.1" ];
67               access-control = [ "192.168.0.0/24 allow" "fd21::/64 allow" "::1 allow" "127.0.0.0/8 allow" ];
68               local-data = [
69                 ''"example.local. IN A 1.2.3.4"''
70                 ''"example.local. IN AAAA abcd::eeff"''
71               ];
72             };
73           };
74         };
75       };
77       # The resolver that knows that forwards (only) to the authoritative server
78       # and listens on UDP/53, TCP/53 & TCP/853.
79       resolver = { lib, nodes, ... }: {
80         imports = [ common ];
81         networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
82           { address = "192.168.0.2"; prefixLength = 24; }
83         ];
84         networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
85           { address = "fd21::2"; prefixLength = 64; }
86         ];
87         networking.firewall.allowedTCPPorts = [
88           53 # regular DNS
89           853 # DNS over TLS
90           443 # DNS over HTTPS
91         ];
92         networking.firewall.allowedUDPPorts = [ 53 ];
94         services.unbound = {
95           enable = true;
96           settings = {
97             server = {
98               interface = [ "::1" "127.0.0.1" "192.168.0.2" "fd21::2"
99                             "192.168.0.2@853" "fd21::2@853" "::1@853" "127.0.0.1@853"
100                             "192.168.0.2@443" "fd21::2@443" "::1@443" "127.0.0.1@443" ];
101               access-control = [ "192.168.0.0/24 allow" "fd21::/64 allow" "::1 allow" "127.0.0.0/8 allow" ];
102               tls-service-pem = "${cert}/cert.pem";
103               tls-service-key = "${cert}/key.pem";
104             };
105             forward-zone = [
106               {
107                 name = ".";
108                 forward-addr = [
109                   (lib.head nodes.authoritative.networking.interfaces.eth1.ipv6.addresses).address
110                   (lib.head nodes.authoritative.networking.interfaces.eth1.ipv4.addresses).address
111                 ];
112               }
113             ];
114           };
115         };
116       };
118       # machine that runs a local unbound that will be reconfigured during test execution
119       local_resolver = { lib, nodes, config, ... }: {
120         imports = [ common ];
121         networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
122           { address = "192.168.0.3"; prefixLength = 24; }
123         ];
124         networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
125           { address = "fd21::3"; prefixLength = 64; }
126         ];
127         networking.firewall.allowedTCPPorts = [
128           53 # regular DNS
129         ];
130         networking.firewall.allowedUDPPorts = [ 53 ];
132         services.unbound = {
133           enable = true;
134           settings = {
135             server = {
136               interface = [ "::1" "127.0.0.1" ];
137               access-control = [ "::1 allow" "127.0.0.0/8 allow" ];
138             };
139             include = "/etc/unbound/extra*.conf";
140           };
141           localControlSocketPath = "/run/unbound/unbound.ctl";
142         };
144         users.users = {
145           # user that is permitted to access the unix socket
146           someuser = {
147             isSystemUser = true;
148             group = "someuser";
149             extraGroups = [
150               config.users.users.unbound.group
151             ];
152           };
154           # user that is not permitted to access the unix socket
155           unauthorizeduser = {
156             isSystemUser = true;
157             group = "unauthorizeduser";
158           };
160         };
161         users.groups = {
162           someuser = {};
163           unauthorizeduser = {};
164         };
166         # Used for testing configuration reloading
167         environment.etc = {
168           "unbound-extra1.conf".text = ''
169             forward-zone:
170             name: "example.local."
171             forward-addr: ${(lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address}
172             forward-addr: ${(lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address}
173           '';
174           "unbound-extra2.conf".text = ''
175             auth-zone:
176               name: something.local.
177               zonefile: ${pkgs.writeText "zone" ''
178                 something.local. IN A 3.4.5.6
179               ''}
180           '';
181         };
182       };
185       # plain node that only has network access and doesn't run any part of the
186       # resolver software locally
187       client = { lib, nodes, ... }: {
188         imports = [ common ];
189         networking.nameservers = [
190           (lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address
191           (lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address
192         ];
193         networking.interfaces.eth1.ipv4.addresses = [
194           { address = "192.168.0.10"; prefixLength = 24; }
195         ];
196         networking.interfaces.eth1.ipv6.addresses = [
197           { address = "fd21::10"; prefixLength = 64; }
198         ];
199       };
200     };
202     testScript = { nodes, ... }: ''
203       import typing
205       zone = "example.local."
206       records = [("AAAA", "abcd::eeff"), ("A", "1.2.3.4")]
209       def query(
210           machine,
211           host: str,
212           query_type: str,
213           query: str,
214           expected: typing.Optional[str] = None,
215           args: typing.Optional[typing.List[str]] = None,
216       ):
217           """
218           Execute a single query and compare the result with expectation
219           """
220           text_args = ""
221           if args:
222               text_args = " ".join(args)
224           out = machine.succeed(
225               f"kdig {text_args} {query} {query_type} @{host} +short"
226           ).strip()
227           machine.log(f"{host} replied with {out}")
228           if expected:
229               assert expected == out, f"Expected `{expected}` but got `{out}`"
232       def test(machine, remotes, /, doh=False, zone=zone, records=records, args=[]):
233           """
234           Run queries for the given remotes on the given machine.
235           """
236           for query_type, expected in records:
237               for remote in remotes:
238                   query(machine, remote, query_type, zone, expected, args)
239                   query(machine, remote, query_type, zone, expected, ["+tcp"] + args)
240                   if doh:
241                       query(
242                           machine,
243                           remote,
244                           query_type,
245                           zone,
246                           expected,
247                           ["+tcp", "+tls"] + args,
248                       )
249                       query(
250                           machine,
251                           remote,
252                           query_type,
253                           zone,
254                           expected,
255                           ["+https"] + args,
256                       )
259       client.start()
260       authoritative.wait_for_unit("unbound.service")
262       # verify that we can resolve locally
263       with subtest("test the authoritative servers local responses"):
264           test(authoritative, ["::1", "127.0.0.1"])
266       resolver.wait_for_unit("unbound.service")
268       with subtest("root is unable to use unbounc-control when the socket is not configured"):
269           resolver.succeed("which unbound-control")  # the binary must exist
270           resolver.fail("unbound-control list_forwards")  # the invocation must fail
272       # verify that the resolver is able to resolve on all the local protocols
273       with subtest("test that the resolver resolves on all protocols and transports"):
274           test(resolver, ["::1", "127.0.0.1"], doh=True)
276       resolver.wait_for_unit("multi-user.target")
278       with subtest("client should be able to query the resolver"):
279           test(client, ["${(lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address}", "${(lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address}"], doh=True)
281       # discard the client we do not need anymore
282       client.shutdown()
284       local_resolver.wait_for_unit("multi-user.target")
286       # link a new config file to /etc/unbound/extra.conf
287       local_resolver.succeed("ln -s /etc/unbound-extra1.conf /etc/unbound/extra1.conf")
289       # reload the server & ensure the forwarding works
290       with subtest("test that the local resolver resolves on all protocols and transports"):
291           local_resolver.succeed("systemctl reload unbound")
292           print(local_resolver.succeed("journalctl -u unbound -n 1000"))
293           test(local_resolver, ["::1", "127.0.0.1"], args=["+timeout=60"])
295       with subtest("test that we can use the unbound control socket"):
296           out = local_resolver.succeed(
297               "sudo -u someuser -- unbound-control list_forwards"
298           ).strip()
300           # Thank you black! Can't really break this line into a readable version.
301           expected = "example.local. IN forward ${(lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address} ${(lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address}"
302           assert out == expected, f"Expected `{expected}` but got `{out}` instead."
303           local_resolver.fail("sudo -u unauthorizeduser -- unbound-control list_forwards")
306       # link a new config file to /etc/unbound/extra.conf
307       local_resolver.succeed("ln -sf /etc/unbound-extra2.conf /etc/unbound/extra2.conf")
309       # reload the server & ensure the new local zone works
310       with subtest("test that we can query the new local zone"):
311           local_resolver.succeed("unbound-control reload")
312           r = [("A", "3.4.5.6")]
313           test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r)
314     '';
315   })