diff --git a/nixos/modules/services/networking/unbound.nix b/nixos/modules/services/networking/unbound.nix
index 07e58481a77a..2650de4ebeba 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 9a7a652b4052..dc8e5a9d3ed8 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")
'';
})