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