summary refs log tree commit diff
path: root/nixos/tests/unbound.nix
blob: fcfa222299c8869e2b066a012f147a9ff0205052 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/*
 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)

 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.
*/
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;

        # we want to test the full-variant of the package to also get DoH support
        services.unbound.package = pkgs.unbound-full;
      };
    };

    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";
    meta = with pkgs.lib.maintainers; {
      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;
          settings = {
            server = {
              interface = [ "192.168.0.1" "fd21::1" "::1" "127.0.0.1" ];
              access-control = [ "192.168.0.0/24 allow" "fd21::/64 allow" "::1 allow" "127.0.0.0/8 allow" ];
              local-data = [
                ''"example.local. IN A 1.2.3.4"''
                ''"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
          443 # DNS over HTTPS
        ];
        networking.firewall.allowedUDPPorts = [ 53 ];

        services.unbound = {
          enable = true;
          settings = {
            server = {
              interface = [ "::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"
                            "192.168.0.2@443" "fd21::2@443" "::1@443" "127.0.0.1@443" ];
              access-control = [ "192.168.0.0/24 allow" "fd21::/64 allow" "::1 allow" "127.0.0.0/8 allow" ];
              tls-service-pem = "${cert}/cert.pem";
              tls-service-key = "${cert}/key.pem";
            };
            forward-zone = [
              {
                name = ".";
                forward-addr = [
                  (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv6.addresses).address
                  (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv4.addresses).address
                ];
              }
            ];
          };
        };
      };

      # machine that runs a local unbound that will be reconfigured during test execution
      local_resolver = { lib, nodes, config, ... }: {
        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;
          settings = {
            server = {
              interface = [ "::1" "127.0.0.1" ];
              access-control = [ "::1 allow" "127.0.0.0/8 allow" ];
            };
            include = "/etc/unbound/extra*.conf";
          };
          localControlSocketPath = "/run/unbound/unbound.ctl";
        };

        users.users = {
          # user that is permitted to access the unix socket
          someuser = {
            isSystemUser = true;
            extraGroups = [
              config.users.users.unbound.group
            ];
          };

          # user that is not permitted to access the unix socket
          unauthorizeduser = { isSystemUser = true; };
        };

        # Used for testing configuration reloading
        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

      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,
                      )
                      query(
                          machine,
                          remote,
                          query_type,
                          zone,
                          expected,
                          ["+https"] + 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")

      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

      # 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"])

      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")


      # 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"):
          local_resolver.succeed("unbound-control reload")
          r = [("A", "3.4.5.6")]
          test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r)
    '';
  })