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)
10
11 In the below test setup we are trying to implement all of those use cases.
12
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.
18*/
19{ pkgs, lib, ... }:
20let
21 # common client configuration that we can just use for the multitude of
22 # clients we are constructing
23 common =
24 { lib, pkgs, ... }:
25 {
26 config = {
27 environment.systemPackages = [ pkgs.knot-dns ];
28
29 # disable the root anchor update as we do not have internet access during
30 # the test execution
31 services.unbound.enableRootTrustAnchor = false;
32
33 # we want to test the full-variant of the package to also get DoH support
34 services.unbound.package = pkgs.unbound-full;
35 };
36 };
37
38 cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } ''
39 openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -subj '/CN=dns.example.local'
40 mkdir -p $out
41 cp key.pem cert.pem $out
42 '';
43in
44{
45 name = "unbound";
46 meta = with pkgs.lib.maintainers; {
47 maintainers = [ andir ];
48 };
49
50 nodes = {
51
52 # The server that actually serves our zones, this tests unbounds authoriative mode
53 authoritative =
54 {
55 lib,
56 pkgs,
57 config,
58 ...
59 }:
60 {
61 imports = [ common ];
62 networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
63 {
64 address = "192.168.0.1";
65 prefixLength = 24;
66 }
67 ];
68 networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
69 {
70 address = "fd21::1";
71 prefixLength = 64;
72 }
73 ];
74 networking.firewall.allowedTCPPorts = [ 53 ];
75 networking.firewall.allowedUDPPorts = [ 53 ];
76
77 services.unbound = {
78 enable = true;
79 settings = {
80 server = {
81 interface = [
82 "192.168.0.1"
83 "fd21::1"
84 "::1"
85 "127.0.0.1"
86 ];
87 access-control = [
88 "192.168.0.0/24 allow"
89 "fd21::/64 allow"
90 "::1 allow"
91 "127.0.0.0/8 allow"
92 ];
93 local-data = [
94 ''"example.local. IN A 1.2.3.4"''
95 ''"example.local. IN AAAA abcd::eeff"''
96 ];
97 };
98 };
99 };
100 };
101
102 # The resolver that knows that forwards (only) to the authoritative server
103 # and listens on UDP/53, TCP/53 & TCP/853.
104 resolver =
105 { lib, nodes, ... }:
106 {
107 imports = [ common ];
108 networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
109 {
110 address = "192.168.0.2";
111 prefixLength = 24;
112 }
113 ];
114 networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
115 {
116 address = "fd21::2";
117 prefixLength = 64;
118 }
119 ];
120 networking.firewall.allowedTCPPorts = [
121 53 # regular DNS
122 853 # DNS over TLS
123 443 # DNS over HTTPS
124 ];
125 networking.firewall.allowedUDPPorts = [ 53 ];
126
127 services.unbound = {
128 enable = true;
129 settings = {
130 server = {
131 interface = [
132 "::1"
133 "127.0.0.1"
134 "192.168.0.2"
135 "fd21::2"
136 "192.168.0.2@853"
137 "fd21::2@853"
138 "::1@853"
139 "127.0.0.1@853"
140 "192.168.0.2@443"
141 "fd21::2@443"
142 "::1@443"
143 "127.0.0.1@443"
144 ];
145 access-control = [
146 "192.168.0.0/24 allow"
147 "fd21::/64 allow"
148 "::1 allow"
149 "127.0.0.0/8 allow"
150 ];
151 tls-service-pem = "${cert}/cert.pem";
152 tls-service-key = "${cert}/key.pem";
153 };
154 forward-zone = [
155 {
156 name = ".";
157 forward-addr = [
158 (lib.head nodes.authoritative.networking.interfaces.eth1.ipv6.addresses).address
159 (lib.head nodes.authoritative.networking.interfaces.eth1.ipv4.addresses).address
160 ];
161 }
162 ];
163 };
164 };
165 };
166
167 # machine that runs a local unbound that will be reconfigured during test execution
168 local_resolver =
169 {
170 lib,
171 nodes,
172 config,
173 ...
174 }:
175 {
176 imports = [ common ];
177 networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
178 {
179 address = "192.168.0.3";
180 prefixLength = 24;
181 }
182 ];
183 networking.interfaces.eth1.ipv6.addresses = lib.mkForce [
184 {
185 address = "fd21::3";
186 prefixLength = 64;
187 }
188 ];
189 networking.firewall.allowedTCPPorts = [
190 53 # regular DNS
191 ];
192 networking.firewall.allowedUDPPorts = [ 53 ];
193
194 services.unbound = {
195 enable = true;
196 settings = {
197 server = {
198 interface = [
199 "::1"
200 "127.0.0.1"
201 ];
202 access-control = [
203 "::1 allow"
204 "127.0.0.0/8 allow"
205 ];
206 };
207 include = "/etc/unbound/extra*.conf";
208 };
209 localControlSocketPath = "/run/unbound/unbound.ctl";
210 };
211
212 users.users = {
213 # user that is permitted to access the unix socket
214 someuser = {
215 isSystemUser = true;
216 group = "someuser";
217 extraGroups = [
218 config.users.users.unbound.group
219 ];
220 };
221
222 # user that is not permitted to access the unix socket
223 unauthorizeduser = {
224 isSystemUser = true;
225 group = "unauthorizeduser";
226 };
227
228 };
229 users.groups = {
230 someuser = { };
231 unauthorizeduser = { };
232 };
233
234 # Used for testing configuration reloading
235 environment.etc = {
236 "unbound-extra1.conf".text = ''
237 forward-zone:
238 name: "example.local."
239 forward-addr: ${(lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address}
240 forward-addr: ${(lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address}
241 '';
242 "unbound-extra2.conf".text = ''
243 auth-zone:
244 name: something.local.
245 zonefile: ${pkgs.writeText "zone" ''
246 something.local. IN A 3.4.5.6
247 ''}
248 '';
249 };
250 };
251
252 # plain node that only has network access and doesn't run any part of the
253 # resolver software locally
254 client =
255 { lib, nodes, ... }:
256 {
257 imports = [ common ];
258 networking.nameservers = [
259 (lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address
260 (lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address
261 ];
262 networking.interfaces.eth1.ipv4.addresses = [
263 {
264 address = "192.168.0.10";
265 prefixLength = 24;
266 }
267 ];
268 networking.interfaces.eth1.ipv6.addresses = [
269 {
270 address = "fd21::10";
271 prefixLength = 64;
272 }
273 ];
274 };
275 };
276
277 testScript =
278 { nodes, ... }:
279 ''
280 import typing
281
282 zone = "example.local."
283 records = [("AAAA", "abcd::eeff"), ("A", "1.2.3.4")]
284
285
286 def query(
287 machine,
288 host: str,
289 query_type: str,
290 query: str,
291 expected: typing.Optional[str] = None,
292 args: typing.Optional[typing.List[str]] = None,
293 ):
294 """
295 Execute a single query and compare the result with expectation
296 """
297 text_args = ""
298 if args:
299 text_args = " ".join(args)
300
301 out = machine.succeed(
302 f"kdig {text_args} {query} {query_type} @{host} +short"
303 ).strip()
304 machine.log(f"{host} replied with {out}")
305 if expected:
306 assert expected == out, f"Expected `{expected}` but got `{out}`"
307
308
309 def test(machine, remotes, /, doh=False, zone=zone, records=records, args=[]):
310 """
311 Run queries for the given remotes on the given machine.
312 """
313 for query_type, expected in records:
314 for remote in remotes:
315 query(machine, remote, query_type, zone, expected, args)
316 query(machine, remote, query_type, zone, expected, ["+tcp"] + args)
317 if doh:
318 query(
319 machine,
320 remote,
321 query_type,
322 zone,
323 expected,
324 ["+tcp", "+tls"] + args,
325 )
326 query(
327 machine,
328 remote,
329 query_type,
330 zone,
331 expected,
332 ["+https"] + args,
333 )
334
335
336 client.start()
337 authoritative.wait_for_unit("unbound.service")
338
339 # verify that we can resolve locally
340 with subtest("test the authoritative servers local responses"):
341 test(authoritative, ["::1", "127.0.0.1"])
342
343 resolver.wait_for_unit("unbound.service")
344
345 with subtest("root is unable to use unbounc-control when the socket is not configured"):
346 resolver.succeed("which unbound-control") # the binary must exist
347 resolver.fail("unbound-control list_forwards") # the invocation must fail
348
349 # verify that the resolver is able to resolve on all the local protocols
350 with subtest("test that the resolver resolves on all protocols and transports"):
351 test(resolver, ["::1", "127.0.0.1"], doh=True)
352
353 resolver.wait_for_unit("multi-user.target")
354
355 with subtest("client should be able to query the resolver"):
356 test(client, ["${(lib.head nodes.resolver.networking.interfaces.eth1.ipv6.addresses).address}", "${(lib.head nodes.resolver.networking.interfaces.eth1.ipv4.addresses).address}"], doh=True)
357
358 # discard the client we do not need anymore
359 client.shutdown()
360
361 local_resolver.wait_for_unit("multi-user.target")
362
363 # link a new config file to /etc/unbound/extra.conf
364 local_resolver.succeed("ln -s /etc/unbound-extra1.conf /etc/unbound/extra1.conf")
365
366 # reload the server & ensure the forwarding works
367 with subtest("test that the local resolver resolves on all protocols and transports"):
368 local_resolver.succeed("systemctl reload unbound")
369 print(local_resolver.succeed("journalctl -u unbound -n 1000"))
370 test(local_resolver, ["::1", "127.0.0.1"], args=["+timeout=60"])
371
372 with subtest("test that we can use the unbound control socket"):
373 out = local_resolver.succeed(
374 "sudo -u someuser -- unbound-control list_forwards"
375 ).strip()
376
377 # Thank you black! Can't really break this line into a readable version.
378 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}"
379 assert out == expected, f"Expected `{expected}` but got `{out}` instead."
380 local_resolver.fail("sudo -u unauthorizeduser -- unbound-control list_forwards")
381
382
383 # link a new config file to /etc/unbound/extra.conf
384 local_resolver.succeed("ln -sf /etc/unbound-extra2.conf /etc/unbound/extra2.conf")
385
386 # reload the server & ensure the new local zone works
387 with subtest("test that we can query the new local zone"):
388 local_resolver.succeed("unbound-control reload")
389 r = [("A", "3.4.5.6")]
390 test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r)
391 '';
392}