about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--nixos/modules/services/networking/unbound.nix36
-rw-r--r--nixos/tests/unbound.nix50
2 files changed, 71 insertions, 15 deletions
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 <literal>null</literal> 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 (<literal>unbound</literal>)
+          and group will be <literal>nogroup</literal>.
+
+          Users that should be permitted to access the socket must be in the
+          <literal>unbound</literal> group.
+
+          If this option is <literal>null</literal> 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")
     '';
   })