From a040a8a2e3e598d24be81d36b66fd8c195c019da Mon Sep 17 00:00:00 2001 From: Andreas Rammhold Date: Wed, 21 Oct 2020 01:34:24 +0200 Subject: nixos/tests/unbound: init --- nixos/tests/all-tests.nix | 1 + nixos/tests/unbound.nix | 247 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 nixos/tests/unbound.nix (limited to 'nixos/tests') diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 4e4d8b5e68943..f1d14c9f2d5d6 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -362,6 +362,7 @@ in trezord = handleTest ./trezord.nix {}; trickster = handleTest ./trickster.nix {}; tuptime = handleTest ./tuptime.nix {}; + unbound = handleTest ./unbound.nix {}; udisks2 = handleTest ./udisks2.nix {}; unit-php = handleTest ./web-servers/unit-php.nix {}; upnp = handleTest ./upnp.nix {}; diff --git a/nixos/tests/unbound.nix b/nixos/tests/unbound.nix new file mode 100644 index 0000000000000..bbccfa9c043ab --- /dev/null +++ b/nixos/tests/unbound.nix @@ -0,0 +1,247 @@ +/* + 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 + without creating a bazillion machines. +*/ +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; + }; + }; + + 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.stdenv.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; + interfaces = [ "192.168.0.1" "fd21::1" "::1" "127.0.0.1" ]; + allowedAccess = [ "192.168.0.0/24" "fd21::/64" "::1" "127.0.0.0/8" ]; + extraConfig = '' + server: + local-data: "example.local. IN A 1.2.3.4" + local-data: "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 + ]; + networking.firewall.allowedUDPPorts = [ 53 ]; + + services.unbound = { + enable = true; + allowedAccess = [ "192.168.0.0/24" "fd21::/64" "::1" "127.0.0.0/8" ]; + interfaces = [ "::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" ]; + forwardAddresses = [ + (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv6.addresses).address + (lib.head nodes.authoritative.config.networking.interfaces.eth1.ipv4.addresses).address + ]; + extraConfig = '' + server: + tls-service-pem: ${cert}/cert.pem + tls-service-key: ${cert}/key.pem + ''; + }; + }; + + # machine that runs a local unbound that will be reconfigured during test execution + local_resolver = { lib, nodes, ... }: { + 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; + allowedAccess = [ "::1" "127.0.0.0/8" ]; + interfaces = [ "::1" "127.0.0.1" ]; + extraConfig = '' + include: "/etc/unbound/extra*.conf" + ''; + }; + + 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 + import json + + 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, + ) + + + 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") + + # 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"]) + + # 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("systemctl reload unbound") + r = [("A", "3.4.5.6")] + test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r) + ''; + }) -- cgit 1.4.1 From b67cc6298e366aae63a381a895cf21c3b75ed649 Mon Sep 17 00:00:00 2001 From: Andreas Rammhold Date: Fri, 23 Oct 2020 22:14:28 +0200 Subject: nixos/tests/unbound: add test to verify control sockets work --- nixos/tests/unbound.nix | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'nixos/tests') diff --git a/nixos/tests/unbound.nix b/nixos/tests/unbound.nix index bbccfa9c043ab..9a7a652b4052f 100644 --- a/nixos/tests/unbound.nix +++ b/nixos/tests/unbound.nix @@ -132,6 +132,12 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: something.local. IN A 3.4.5.6 ''} ''; + "unbound-extra3.conf".text = '' + remote-control: + control-enable: yes + control-interface: /run/unbound/unbound.ctl + ''; + }; }; @@ -243,5 +249,10 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: local_resolver.succeed("systemctl reload unbound") r = [("A", "3.4.5.6")] test(local_resolver, ["::1", "127.0.0.1"], zone="something.local.", records=r) + + with subtest("test that we can enable unbound control sockets on the fly"): + local_resolver.succeed("ln -sf /etc/unbound-extra3.conf /etc/unbound/extra3.conf") + local_resolver.succeed("systemctl reload unbound") + local_resolver.succeed("unbound-control list_forwards") ''; }) -- cgit 1.4.1 From 2aa64e5df5819f7ebeaacfdefb8324736f7f68ba Mon Sep 17 00:00:00 2001 From: Andreas Rammhold Date: Sun, 1 Nov 2020 22:15:42 +0100 Subject: nixos/unbound: add option to configure the local control socket path This option allows users to specify a local UNIX control socket to "remote control" the daemon. System users, that should be permitted to access the daemon, must be in the `unbound` group in order to access the socket. When a socket path is configured we are also creating the required group. Currently this only supports the UNIX socket mode while unbound actually supports more advanced types. Users are still able to configure more complex scenarios via the `extraConfig` attribute. When this option is set to `null` (the default) it doesn't affect the system configuration at all. The unbound defaults for control sockets apply and no additional groups are created. --- nixos/modules/services/networking/unbound.nix | 36 +++++++++++++++++++ nixos/tests/unbound.nix | 50 +++++++++++++++++++-------- 2 files changed, 71 insertions(+), 15 deletions(-) (limited to 'nixos/tests') diff --git a/nixos/modules/services/networking/unbound.nix b/nixos/modules/services/networking/unbound.nix index 07e58481a77a6..2650de4ebebab 100644 --- a/nixos/modules/services/networking/unbound.nix +++ b/nixos/modules/services/networking/unbound.nix @@ -39,6 +39,11 @@ let ${interfaces} ${access} ${trustAnchor} + ${lib.optionalString (cfg.localControlSocketPath != null) '' + remote-control: + control-enable: yes + control-interface: ${cfg.localControlSocketPath} + ''} ${cfg.extraConfig} ${forward} ''; @@ -86,6 +91,28 @@ in description = "Use and update root trust anchor for DNSSEC validation."; }; + localControlSocketPath = mkOption { + default = null; + # FIXME: What is the proper type here so users can specify strings, + # paths and null? + # My guess would be `types.nullOr (types.either types.str types.path)` + # but I haven't verified yet. + type = types.nullOr types.str; + example = "/run/unbound/unbound.ctl"; + description = '' + When not set to null this option defines the path + at which the unbound remote control socket should be created at. The + socket will be owned by the unbound user (unbound) + and group will be nogroup. + + Users that should be permitted to access the socket must be in the + unbound group. + + If this option is null remote control will not be + configured at all. Unbounds default values apply. + ''; + }; + extraConfig = mkOption { default = ""; type = types.lines; @@ -108,6 +135,14 @@ in users.users.unbound = { description = "unbound daemon user"; isSystemUser = true; + group = lib.mkIf (cfg.localControlSocketPath != null) (lib.mkDefault "unbound"); + }; + + # We need a group so that we can give users access to the configured + # control socket. Unbound allows access to the socket only to the unbound + # user and the primary group. + users.groups = lib.mkIf (cfg.localControlSocketPath != null) { + unbound = {}; }; networking.resolvconf.useLocalResolver = mkDefault true; @@ -148,6 +183,7 @@ in ]; User = "unbound"; + Group = lib.mkIf (cfg.localControlSocketPath != null) (lib.mkDefault "unbound"); MemoryDenyWriteExecute = true; NoNewPrivileges = true; diff --git a/nixos/tests/unbound.nix b/nixos/tests/unbound.nix index 9a7a652b4052f..dc8e5a9d3ed8c 100644 --- a/nixos/tests/unbound.nix +++ b/nixos/tests/unbound.nix @@ -8,8 +8,13 @@ * 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 - without creating a bazillion machines. + 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 @@ -96,7 +101,7 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: }; # machine that runs a local unbound that will be reconfigured during test execution - local_resolver = { lib, nodes, ... }: { + local_resolver = { lib, nodes, config, ... }: { imports = [ common ]; networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ { address = "192.168.0.3"; prefixLength = 24; } @@ -113,11 +118,22 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: enable = true; allowedAccess = [ "::1" "127.0.0.0/8" ]; interfaces = [ "::1" "127.0.0.1" ]; + localControlSocketPath = "/run/unbound/unbound.ctl"; extraConfig = '' include: "/etc/unbound/extra*.conf" ''; }; + users.users = { + # user that is permitted to access the unix socket + someuser.extraGroups = [ + config.users.users.unbound.group + ]; + + # user that is not permitted to access the unix socket + unauthorizeduser = {}; + }; + environment.etc = { "unbound-extra1.conf".text = '' forward-zone: @@ -132,12 +148,6 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: something.local. IN A 3.4.5.6 ''} ''; - "unbound-extra3.conf".text = '' - remote-control: - control-enable: yes - control-interface: /run/unbound/unbound.ctl - ''; - }; }; @@ -218,6 +228,10 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: 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) @@ -241,18 +255,24 @@ import ./make-test-python.nix ({ pkgs, lib, ... }: 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("systemctl reload unbound") + 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) - - with subtest("test that we can enable unbound control sockets on the fly"): - local_resolver.succeed("ln -sf /etc/unbound-extra3.conf /etc/unbound/extra3.conf") - local_resolver.succeed("systemctl reload unbound") - local_resolver.succeed("unbound-control list_forwards") ''; }) -- cgit 1.4.1