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