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.
This commit is contained in:
Andreas Rammhold 2020-11-01 22:15:42 +01:00
parent b67cc6298e
commit 2aa64e5df5
No known key found for this signature in database
GPG Key ID: E432E410B5E48C86
2 changed files with 71 additions and 15 deletions

View File

@ -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;

View File

@ -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")
'';
})