1{ config, lib, pkgs, ... }:
2
3with lib;
4let
5 cfg = config.services.unbound;
6
7 yesOrNo = v: if v then "yes" else "no";
8
9 toOption = indent: n: v: "${indent}${toString n}: ${v}";
10
11 toConf = indent: n: v:
12 if builtins.isFloat v then (toOption indent n (builtins.toJSON v))
13 else if isInt v then (toOption indent n (toString v))
14 else if isBool v then (toOption indent n (yesOrNo v))
15 else if isString v then (toOption indent n v)
16 else if isList v then (concatMapStringsSep "\n" (toConf indent n) v)
17 else if isAttrs v then (concatStringsSep "\n" (
18 ["${indent}${n}:"] ++ (
19 mapAttrsToList (toConf "${indent} ") v
20 )
21 ))
22 else throw (traceSeq v "services.unbound.settings: unexpected type");
23
24 confNoServer = concatStringsSep "\n" ((mapAttrsToList (toConf "") (builtins.removeAttrs cfg.settings [ "server" ])) ++ [""]);
25 confServer = concatStringsSep "\n" (mapAttrsToList (toConf " ") (builtins.removeAttrs cfg.settings.server [ "define-tag" ]));
26
27 confFile = pkgs.writeText "unbound.conf" ''
28 server:
29 ${optionalString (cfg.settings.server.define-tag != "") (toOption " " "define-tag" cfg.settings.server.define-tag)}
30 ${confServer}
31 ${confNoServer}
32 '';
33
34 rootTrustAnchorFile = "${cfg.stateDir}/root.key";
35
36in {
37
38 ###### interface
39
40 options = {
41 services.unbound = {
42
43 enable = mkEnableOption (lib.mdDoc "Unbound domain name server");
44
45 package = mkOption {
46 type = types.package;
47 default = pkgs.unbound-with-systemd;
48 defaultText = literalExpression "pkgs.unbound-with-systemd";
49 description = lib.mdDoc "The unbound package to use";
50 };
51
52 user = mkOption {
53 type = types.str;
54 default = "unbound";
55 description = lib.mdDoc "User account under which unbound runs.";
56 };
57
58 group = mkOption {
59 type = types.str;
60 default = "unbound";
61 description = lib.mdDoc "Group under which unbound runs.";
62 };
63
64 stateDir = mkOption {
65 type = types.path;
66 default = "/var/lib/unbound";
67 description = lib.mdDoc "Directory holding all state for unbound to run.";
68 };
69
70 resolveLocalQueries = mkOption {
71 type = types.bool;
72 default = true;
73 description = lib.mdDoc ''
74 Whether unbound should resolve local queries (i.e. add 127.0.0.1 to
75 /etc/resolv.conf).
76 '';
77 };
78
79 enableRootTrustAnchor = mkOption {
80 default = true;
81 type = types.bool;
82 description = lib.mdDoc "Use and update root trust anchor for DNSSEC validation.";
83 };
84
85 localControlSocketPath = mkOption {
86 default = null;
87 # FIXME: What is the proper type here so users can specify strings,
88 # paths and null?
89 # My guess would be `types.nullOr (types.either types.str types.path)`
90 # but I haven't verified yet.
91 type = types.nullOr types.str;
92 example = "/run/unbound/unbound.ctl";
93 description = lib.mdDoc ''
94 When not set to `null` this option defines the path
95 at which the unbound remote control socket should be created at. The
96 socket will be owned by the unbound user (`unbound`)
97 and group will be `nogroup`.
98
99 Users that should be permitted to access the socket must be in the
100 `config.services.unbound.group` group.
101
102 If this option is `null` remote control will not be
103 enabled. Unbounds default values apply.
104 '';
105 };
106
107 settings = mkOption {
108 default = {};
109 type = with types; submodule {
110
111 freeformType = let
112 validSettingsPrimitiveTypes = oneOf [ int str bool float ];
113 validSettingsTypes = oneOf [ validSettingsPrimitiveTypes (listOf validSettingsPrimitiveTypes) ];
114 settingsType = oneOf [ str (attrsOf validSettingsTypes) ];
115 in attrsOf (oneOf [ settingsType (listOf settingsType) ])
116 // { description = ''
117 unbound.conf configuration type. The format consist of an attribute
118 set of settings. Each settings can be either one value, a list of
119 values or an attribute set. The allowed values are integers,
120 strings, booleans or floats.
121 '';
122 };
123
124 options = {
125 remote-control.control-enable = mkOption {
126 type = bool;
127 default = false;
128 internal = true;
129 };
130 };
131 };
132 example = literalExpression ''
133 {
134 server = {
135 interface = [ "127.0.0.1" ];
136 };
137 forward-zone = [
138 {
139 name = ".";
140 forward-addr = "1.1.1.1@853#cloudflare-dns.com";
141 }
142 {
143 name = "example.org.";
144 forward-addr = [
145 "1.1.1.1@853#cloudflare-dns.com"
146 "1.0.0.1@853#cloudflare-dns.com"
147 ];
148 }
149 ];
150 remote-control.control-enable = true;
151 };
152 '';
153 description = lib.mdDoc ''
154 Declarative Unbound configuration
155 See the {manpage}`unbound.conf(5)` manpage for a list of
156 available options.
157 '';
158 };
159 };
160 };
161
162 ###### implementation
163
164 config = mkIf cfg.enable {
165
166 services.unbound.settings = {
167 server = {
168 directory = mkDefault cfg.stateDir;
169 username = cfg.user;
170 chroot = ''""'';
171 pidfile = ''""'';
172 # when running under systemd there is no need to daemonize
173 do-daemonize = false;
174 interface = mkDefault ([ "127.0.0.1" ] ++ (optional config.networking.enableIPv6 "::1"));
175 access-control = mkDefault ([ "127.0.0.0/8 allow" ] ++ (optional config.networking.enableIPv6 "::1/128 allow"));
176 auto-trust-anchor-file = mkIf cfg.enableRootTrustAnchor rootTrustAnchorFile;
177 tls-cert-bundle = mkDefault "/etc/ssl/certs/ca-certificates.crt";
178 # prevent race conditions on system startup when interfaces are not yet
179 # configured
180 ip-freebind = mkDefault true;
181 define-tag = mkDefault "";
182 };
183 remote-control = {
184 control-enable = mkDefault false;
185 control-interface = mkDefault ([ "127.0.0.1" ] ++ (optional config.networking.enableIPv6 "::1"));
186 server-key-file = mkDefault "${cfg.stateDir}/unbound_server.key";
187 server-cert-file = mkDefault "${cfg.stateDir}/unbound_server.pem";
188 control-key-file = mkDefault "${cfg.stateDir}/unbound_control.key";
189 control-cert-file = mkDefault "${cfg.stateDir}/unbound_control.pem";
190 } // optionalAttrs (cfg.localControlSocketPath != null) {
191 control-enable = true;
192 control-interface = cfg.localControlSocketPath;
193 };
194 };
195
196 environment.systemPackages = [ cfg.package ];
197
198 users.users = mkIf (cfg.user == "unbound") {
199 unbound = {
200 description = "unbound daemon user";
201 isSystemUser = true;
202 group = cfg.group;
203 };
204 };
205
206 users.groups = mkIf (cfg.group == "unbound") {
207 unbound = {};
208 };
209
210 networking = mkIf cfg.resolveLocalQueries {
211 resolvconf = {
212 useLocalResolver = mkDefault true;
213 };
214
215 networkmanager.dns = "unbound";
216 };
217
218 environment.etc."unbound/unbound.conf".source = confFile;
219
220 systemd.services.unbound = {
221 description = "Unbound recursive Domain Name Server";
222 after = [ "network.target" ];
223 before = [ "nss-lookup.target" ];
224 wantedBy = [ "multi-user.target" "nss-lookup.target" ];
225
226 path = mkIf cfg.settings.remote-control.control-enable [ pkgs.openssl ];
227
228 preStart = ''
229 ${optionalString cfg.enableRootTrustAnchor ''
230 ${cfg.package}/bin/unbound-anchor -a ${rootTrustAnchorFile} || echo "Root anchor updated!"
231 ''}
232 ${optionalString cfg.settings.remote-control.control-enable ''
233 ${cfg.package}/bin/unbound-control-setup -d ${cfg.stateDir}
234 ''}
235 '';
236
237 restartTriggers = [
238 confFile
239 ];
240
241 serviceConfig = {
242 ExecStart = "${cfg.package}/bin/unbound -p -d -c /etc/unbound/unbound.conf";
243 ExecReload = "+/run/current-system/sw/bin/kill -HUP $MAINPID";
244
245 NotifyAccess = "main";
246 Type = "notify";
247
248 # FIXME: Which of these do we actually need, can we drop the chroot flag?
249 AmbientCapabilities = [
250 "CAP_NET_BIND_SERVICE"
251 "CAP_NET_RAW"
252 "CAP_SETGID"
253 "CAP_SETUID"
254 "CAP_SYS_CHROOT"
255 "CAP_SYS_RESOURCE"
256 ];
257
258 User = cfg.user;
259 Group = cfg.group;
260
261 MemoryDenyWriteExecute = true;
262 NoNewPrivileges = true;
263 PrivateDevices = true;
264 PrivateTmp = true;
265 ProtectHome = true;
266 ProtectControlGroups = true;
267 ProtectKernelModules = true;
268 ProtectSystem = "strict";
269 RuntimeDirectory = "unbound";
270 ConfigurationDirectory = "unbound";
271 StateDirectory = "unbound";
272 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_NETLINK" "AF_UNIX" ];
273 RestrictRealtime = true;
274 SystemCallArchitectures = "native";
275 SystemCallFilter = [
276 "~@clock"
277 "@cpu-emulation"
278 "@debug"
279 "@keyring"
280 "@module"
281 "mount"
282 "@obsolete"
283 "@resources"
284 ];
285 RestrictNamespaces = true;
286 LockPersonality = true;
287 RestrictSUIDSGID = true;
288
289 ReadWritePaths = [ cfg.stateDir ];
290
291 Restart = "on-failure";
292 RestartSec = "5s";
293 };
294 };
295 };
296
297 imports = [
298 (mkRenamedOptionModule [ "services" "unbound" "interfaces" ] [ "services" "unbound" "settings" "server" "interface" ])
299 (mkChangedOptionModule [ "services" "unbound" "allowedAccess" ] [ "services" "unbound" "settings" "server" "access-control" ] (
300 config: map (value: "${value} allow") (getAttrFromPath [ "services" "unbound" "allowedAccess" ] config)
301 ))
302 (mkRemovedOptionModule [ "services" "unbound" "forwardAddresses" ] ''
303 Add a new setting:
304 services.unbound.settings.forward-zone = [{
305 name = ".";
306 forward-addr = [ # Your current services.unbound.forwardAddresses ];
307 }];
308 If any of those addresses are local addresses (127.0.0.1 or ::1), you must
309 also set services.unbound.settings.server.do-not-query-localhost to false.
310 '')
311 (mkRemovedOptionModule [ "services" "unbound" "extraConfig" ] ''
312 You can use services.unbound.settings to add any configuration you want.
313 '')
314 ];
315}