Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions nixos/doc/manual/release-notes/rl-2105.xml
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,23 @@ environment.systemPackages = [
default in the CLI tooling which in turn enables us to use
<literal>unbound-control</literal> without passing a custom configuration location.
</para>

<para>
The module has also been reworked to be <link
xlink:href="https://github.com/NixOS/rfcs/blob/master/rfcs/0042-config-option.md">RFC
0042</link> compliant. As such,
<option>sevices.unbound.extraConfig</option> has been removed and replaced
by <xref linkend="opt-services.unbound.settings"/>. <option>services.unbound.interfaces</option>
has been renamed to <option>services.unbound.settings.server.interface</option>.
</para>

<para>
<option>services.unbound.forwardAddresses</option> and
<option>services.unbound.allowedAccess</option> have also been changed to
use the new settings interface. You can follow the instructions when
executing <literal>nixos-rebuild</literal> to upgrade your configuration to
use the new interface.
</para>
</listitem>
<listitem>
<para>
Expand Down
253 changes: 169 additions & 84 deletions nixos/modules/services/networking/unbound.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,28 @@ with lib;
let
cfg = config.services.unbound;

stateDir = "/var/lib/unbound";

access = concatMapStringsSep "\n " (x: "access-control: ${x} allow") cfg.allowedAccess;

interfaces = concatMapStringsSep "\n " (x: "interface: ${x}") cfg.interfaces;

isLocalAddress = x: substring 0 3 x == "::1" || substring 0 9 x == "127.0.0.1";

forward =
optionalString (any isLocalAddress cfg.forwardAddresses) ''
do-not-query-localhost: no
''
+ optionalString (cfg.forwardAddresses != []) ''
forward-zone:
name: .
''
+ concatMapStringsSep "\n" (x: " forward-addr: ${x}") cfg.forwardAddresses;

rootTrustAnchorFile = "${stateDir}/root.key";

trustAnchor = optionalString cfg.enableRootTrustAnchor
"auto-trust-anchor-file: ${rootTrustAnchorFile}";

confFile = pkgs.writeText "unbound.conf" ''
server:
ip-freebind: yes
directory: "${stateDir}"
username: unbound
chroot: ""
pidfile: ""
# when running under systemd there is no need to daemonize
do-daemonize: no
${interfaces}
${access}
${trustAnchor}
${lib.optionalString (cfg.localControlSocketPath != null) ''
remote-control:
control-enable: yes
control-interface: ${cfg.localControlSocketPath}
''}
${cfg.extraConfig}
${forward}
'';
in
{
yesOrNo = v: if v then "yes" else "no";

toOption = indent: n: v: "${indent}${toString n}: ${v}";

toConf = indent: n: v:
if builtins.isFloat v then (toOption indent n (builtins.toJSON v))
else if isInt v then (toOption indent n (toString v))
else if isBool v then (toOption indent n (yesOrNo v))
else if isString v then (toOption indent n v)
else if isList v then (concatMapStringsSep "\n" (toConf indent n) v)
else if isAttrs v then (concatStringsSep "\n" (
["${indent}${n}:"] ++ (
mapAttrsToList (toConf "${indent} ") v
)
))
else throw (traceSeq v "services.unbound.settings: unexpected type");

confFile = pkgs.writeText "unbound.conf" (concatStringsSep "\n" ((mapAttrsToList (toConf "") cfg.settings) ++ [""]));

rootTrustAnchorFile = "${cfg.stateDir}/root.key";

in {

###### interface

Expand All @@ -64,25 +41,30 @@ in
description = "The unbound package to use";
};

allowedAccess = mkOption {
default = [ "127.0.0.0/24" ];
type = types.listOf types.str;
description = "What networks are allowed to use unbound as a resolver.";
user = mkOption {
type = types.str;
default = "unbound";
description = "User account under which unbound runs.";
};

interfaces = mkOption {
default = [ "127.0.0.1" ] ++ optional config.networking.enableIPv6 "::1";
type = types.listOf types.str;
description = ''
What addresses the server should listen on. This supports the interface syntax documented in
<citerefentry><refentrytitle>unbound.conf</refentrytitle><manvolnum>8</manvolnum></citerefentry>.
'';
group = mkOption {
type = types.str;
default = "unbound";
description = "Group under which unbound runs.";
};

forwardAddresses = mkOption {
default = [];
type = types.listOf types.str;
description = "What servers to forward queries to.";
stateDir = mkOption {
default = "/var/lib/unbound";
description = "Directory holding all state for unbound to run.";
};

resolveLocalQueries = mkOption {
type = types.bool;
default = true;
description = ''
Whether unbound should resolve local queries (i.e. add 127.0.0.1 to
/etc/resolv.conf).
'';
};

enableRootTrustAnchor = mkOption {
Expand All @@ -106,47 +88,123 @@ in
and group will be <literal>nogroup</literal>.

Users that should be permitted to access the socket must be in the
<literal>unbound</literal> group.
<literal>config.services.unbound.group</literal> group.

If this option is <literal>null</literal> remote control will not be
configured at all. Unbounds default values apply.
enabled. Unbounds default values apply.
'';
};

extraConfig = mkOption {
default = "";
type = types.lines;
settings = mkOption {
default = {};
type = with types; submodule {

freeformType = let
validSettingsPrimitiveTypes = oneOf [ int str bool float ];
validSettingsTypes = oneOf [ validSettingsPrimitiveTypes (listOf validSettingsPrimitiveTypes) ];
settingsType = (attrsOf validSettingsTypes);
in attrsOf (oneOf [ string settingsType (listOf settingsType) ])
// { description = ''
unbound.conf configuration type. The format consist of an attribute
set of settings. Each settings can be either one value, a list of
values or an attribute set. The allowed values are integers,
strings, booleans or floats.
'';
};

options = {
remote-control.control-enable = mkOption {
type = bool;
default = false;
internal = true;
};
};
};
example = literalExample ''
{
server = {
interface = [ "127.0.0.1" ];
};
forward-zone = [
{
name = ".";
forward-addr = "1.1.1.1@853#cloudflare-dns.com";
}
{
name = "example.org.";
forward-addr = [
"1.1.1.1@853#cloudflare-dns.com"
"1.0.0.1@853#cloudflare-dns.com"
];
}
];
remote-control.control-enable = true;
};
'';
description = ''
Extra unbound config. See
<citerefentry><refentrytitle>unbound.conf</refentrytitle><manvolnum>8
</manvolnum></citerefentry>.
Declarative Unbound configuration
See the <citerefentry><refentrytitle>unbound.conf</refentrytitle>
<manvolnum>5</manvolnum></citerefentry> manpage for a list of
available options.
'';
};

};
};

###### implementation

config = mkIf cfg.enable {

services.unbound.settings = {
server = {
directory = mkDefault cfg.stateDir;
username = cfg.user;
chroot = ''""'';
pidfile = ''""'';
# when running under systemd there is no need to daemonize
do-daemonize = false;
interface = mkDefault ([ "127.0.0.1" ] ++ (optional config.networking.enableIPv6 "::1"));
access-control = mkDefault ([ "127.0.0.0/8 allow" ] ++ (optional config.networking.enableIPv6 "::1/128 allow"));
auto-trust-anchor-file = mkIf cfg.enableRootTrustAnchor rootTrustAnchorFile;
tls-cert-bundle = mkDefault "/etc/ssl/certs/ca-certificates.crt";
# prevent race conditions on system startup when interfaces are not yet
# configured
ip-freebind = mkDefault true;
};
remote-control = {
control-enable = mkDefault false;
control-interface = mkDefault ([ "127.0.0.1" ] ++ (optional config.networking.enableIPv6 "::1"));
server-key-file = mkDefault "${cfg.stateDir}/unbound_server.key";
server-cert-file = mkDefault "${cfg.stateDir}/unbound_server.pem";
control-key-file = mkDefault "${cfg.stateDir}/unbound_control.key";
control-cert-file = mkDefault "${cfg.stateDir}/unbound_control.pem";
} // optionalAttrs (cfg.localControlSocketPath != null) {
control-enable = true;
control-interface = cfg.localControlSocketPath;
};
};

environment.systemPackages = [ cfg.package ];

users.users.unbound = {
description = "unbound daemon user";
isSystemUser = true;
group = lib.mkIf (cfg.localControlSocketPath != null) (lib.mkDefault "unbound");
users.users = mkIf (cfg.user == "unbound") {
unbound = {
description = "unbound daemon user";
isSystemUser = true;
group = cfg.group;
};
};

# 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) {
users.groups = mkIf (cfg.group == "unbound") {
unbound = {};
};

networking.resolvconf.useLocalResolver = mkDefault true;
networking = mkIf cfg.resolveLocalQueries {
resolvconf = {
useLocalResolver = mkDefault true;
};

networkmanager.dns = "unbound";
};

environment.etc."unbound/unbound.conf".source = confFile;

Expand All @@ -156,8 +214,15 @@ in
before = [ "nss-lookup.target" ];
wantedBy = [ "multi-user.target" "nss-lookup.target" ];

preStart = lib.mkIf cfg.enableRootTrustAnchor ''
${cfg.package}/bin/unbound-anchor -a ${rootTrustAnchorFile} || echo "Root anchor updated!"
path = mkIf cfg.settings.remote-control.control-enable [ pkgs.openssl ];

preStart = ''
${optionalString cfg.enableRootTrustAnchor ''
${cfg.package}/bin/unbound-anchor -a ${rootTrustAnchorFile} || echo "Root anchor updated!"
''}
${optionalString cfg.settings.remote-control.control-enable ''
${cfg.package}/bin/unbound-control-setup -d ${cfg.stateDir}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add another test case that verifies that this does indeed work? I think otherwise we cover most of the options in the test and only this piece is missing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't that already tested by test that we can use the unbound control socket?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The socket only uses file-level permission. The control setup is using tls certificates/keys and UDP/TCP to access unbound.

''}
'';

restartTriggers = [
Expand All @@ -181,8 +246,8 @@ in
"CAP_SYS_RESOURCE"
];

User = "unbound";
Group = lib.mkIf (cfg.localControlSocketPath != null) (lib.mkDefault "unbound");
User = cfg.user;
Group = cfg.group;

MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
Expand Down Expand Up @@ -211,9 +276,29 @@ in
RestrictNamespaces = true;
LockPersonality = true;
RestrictSUIDSGID = true;

Restart = "on-failure";
RestartSec = "5s";
};
};
# If networkmanager is enabled, ask it to interface with unbound.
networking.networkmanager.dns = "unbound";
};

imports = [
(mkRenamedOptionModule [ "services" "unbound" "interfaces" ] [ "services" "unbound" "settings" "server" "interface" ])
(mkChangedOptionModule [ "services" "unbound" "allowedAccess" ] [ "services" "unbound" "settings" "server" "access-control" ] (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why those were removed? IIRC the RFC was meant as an addition to existing options (but correct me if I'm wrong).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, but I thought it might make more sense to rename those to alert the user of changes in the module. For the access-control option, there are more options than just allow and deny, so the user should be able to use those too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, isn't the RFC meant as an addition for config options? Explicitly defined options for important stuff (and I'd consider the interfaces and allowedAccess sufficiently important) is still fine and with this we even have documentation and explicit type-checking for those as well.

For the access-control option, there are more options than just allow and deny, so the user should be able to use those too.

How about fixing the option then?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree about modifying allowedAccess in case of future modifications upstream. I think renaming interfaces makes sense as the option doesn't change.

config: map (value: "${value} allow") (getAttrFromPath [ "services" "unbound" "allowedAccess" ] config)
))
(mkRemovedOptionModule [ "services" "unbound" "forwardAddresses" ] ''
Add a new setting:
services.unbound.settings.forward-zone = [{
name = ".";
forward-addr = [ # Your current services.unbound.forwardAddresses ];
}];
If any of those addresses are local addresses (127.0.0.1 or ::1), you must
also set services.unbound.settings.server.do-not-query-localhost to false.
'')
(mkRemovedOptionModule [ "services" "unbound" "extraConfig" ] ''
You can use services.unbound.settings to add any configuration you want.
'')
];
}
Loading