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 ususal 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 ({ 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 ];
26
27 # disable the root anchor update as we do not have internet access during
28 # the test execution
29 services.unbound.enableRootTrustAnchor = false;
30
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 };
35
36 cert = pkgs.runCommandNoCC "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 };
47
48 nodes = {
49
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 ];
61
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 };
76
77 # The resolver that knows that fowards (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 ];
93
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.config.networking.interfaces.eth1.ipv6.addresses).address
110 (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv4.addresses).address
111 ];
112 }
113 ];
114 };
115 };
116 };
117
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 ];
131
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 };
143
144 users.users = {
145 # user that is permitted to access the unix socket
146 someuser = {
147 isSystemUser = true;
148 extraGroups = [
149 config.users.users.unbound.group
150 ];
151 };
152
153 # user that is not permitted to access the unix socket
154 unauthorizeduser = { isSystemUser = true; };
155 };
156
157 # Used for testing configuration reloading
158 environment.etc = {
159 "unbound-extra1.conf".text = ''
160 forward-zone:
161 name: "example.local."
162 forward-addr: ${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv6.addresses).address}
163 forward-addr: ${(lib.head nodes.resolver.config.networking.interfaces.eth1.ipv4.addresses).address}
164 '';
165 "unbound-extra2.conf".text = ''
166 auth-zone:
167 name: something.local.
168 zonefile: ${pkgs.writeText "zone" ''
169 something.local. IN A 3.4.5.6
170 ''}
171 '';
172 };
173 };
174
175
176 # plain node that only has network access and doesn't run any part of the
177 # resolver software locally
178 client = { lib, nodes, ... }: {
179 imports = [ common ];
180 networking.nameservers = [
181 (lib.head nodes.resolver.config.networking.interfaces.eth1.ipv6.addresses).address
182 (lib.head nodes.resolver.config.networking.interfaces.eth1.ipv4.addresses).address
183 ];
184 networking.interfaces.eth1.ipv4.addresses = [
185 { address = "192.168.0.10"; prefixLength = 24; }
186 ];
187 networking.interfaces.eth1.ipv6.addresses = [
188 { address = "fd21::10"; prefixLength = 64; }
189 ];
190 };
191 };
192
193 testScript = { nodes, ... }: ''
194 import typing
195
196 zone = "example.local."
197 records = [("AAAA", "abcd::eeff"), ("A", "1.2.3.4")]
198
199
200 def query(
201 machine,
202 host: str,
203 query_type: str,
204 query: str,
205 expected: typing.Optional[str] = None,
206 args: typing.Optional[typing.List[str]] = None,
207 ):
208 """
209 Execute a single query and compare the result with expectation
210 """
211 text_args = ""
212 if args:
213 text_args = " ".join(args)
214
215 out = machine.succeed(
216 f"kdig {text_args} {query} {query_type} @{host} +short"
217 ).strip()
218 machine.log(f"{host} replied with {out}")
219 if expected:
220 assert expected == out, f"Expected `{expected}` but got `{out}`"
221
222
223 def test(machine, remotes, /, doh=False, zone=zone, records=records, args=[]):
224 """
225 Run queries for the given remotes on the given machine.
226 """
227 for query_type, expected in records:
228 for remote in remotes:
229 query(machine, remote, query_type, zone, expected, args)
230 query(machine, remote, query_type, zone, expected, ["+tcp"] + args)
231 if doh:
232 query(
233 machine,
234 remote,
235 query_type,
236 zone,
237 expected,
238 ["+tcp", "+tls"] + args,
239 )
240 query(
241 machine,
242 remote,
243 query_type,
244 zone,
245 expected,
246 ["+https"] + args,
247 )
248
249
250 client.start()
251 authoritative.wait_for_unit("unbound.service")
252
253 # verify that we can resolve locally
254 with subtest("test the authoritative servers local responses"):
255 test(authoritative, ["::1", "127.0.0.1"])
256
257 resolver.wait_for_unit("unbound.service")
258
259 with subtest("root is unable to use unbounc-control when the socket is not configured"):
260 resolver.succeed("which unbound-control") # the binary must exist
261 resolver.fail("unbound-control list_forwards") # the invocation must fail
262
263 # verify that the resolver is able to resolve on all the local protocols
264 with subtest("test that the resolver resolves on all protocols and transports"):
265 test(resolver, ["::1", "127.0.0.1"], doh=True)
266
267 resolver.wait_for_unit("multi-user.target")
268
269 with subtest("client should be able to query the resolver"):
270 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)
271
272 # discard the client we do not need anymore
273 client.shutdown()
274
275 local_resolver.wait_for_unit("multi-user.target")
276
277 # link a new config file to /etc/unbound/extra.conf
278 local_resolver.succeed("ln -s /etc/unbound-extra1.conf /etc/unbound/extra1.conf")
279
280 # reload the server & ensure the forwarding works
281 with subtest("test that the local resolver resolves on all protocols and transports"):
282 local_resolver.succeed("systemctl reload unbound")
283 print(local_resolver.succeed("journalctl -u unbound -n 1000"))
284 test(local_resolver, ["::1", "127.0.0.1"], args=["+timeout=60"])
285
286 with subtest("test that we can use the unbound control socket"):
287 out = local_resolver.succeed(
288 "sudo -u someuser -- unbound-control list_forwards"
289 ).strip()
290
291 # Thank you black! Can't really break this line into a readable version.
292 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}"
293 assert out == expected, f"Expected `{expected}` but got `{out}` instead."
294 local_resolver.fail("sudo -u unauthorizeduser -- unbound-control list_forwards")
295
296
297 # link a new config file to /etc/unbound/extra.conf
298 local_resolver.succeed("ln -sf /etc/unbound-extra2.conf /etc/unbound/extra2.conf")
299
300 # reload the server & ensure the new local zone works
301 with subtest("test that we can query the new local zone"):
302 local_resolver.succeed("unbound-control reload")
303 r = [("A", "3.4.5.6")]
304 test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r)
305 '';
306 })