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 {
251 control-enable = mkDefault false;
252 control-interface = mkDefault ([ "127.0.0.1" ] ++ (optional config.networking.enableIPv6 "::1"));
253 server-key-file = mkDefault "${cfg.stateDir}/unbound_server.key";
254 server-cert-file = mkDefault "${cfg.stateDir}/unbound_server.pem";
255 control-key-file = mkDefault "${cfg.stateDir}/unbound_control.key";
256 control-cert-file = mkDefault "${cfg.stateDir}/unbound_control.pem";
257 }
258 // optionalAttrs (cfg.localControlSocketPath != null) {
259 control-enable = true;
260 control-interface = cfg.localControlSocketPath;
261 };
262 };
263
264 environment.systemPackages = [ cfg.package ];
265
266 users.users = mkIf (cfg.user == "unbound") {
267 unbound = {
268 description = "unbound daemon user";
269 isSystemUser = true;
270 group = cfg.group;
271 };
272 };
273
274 users.groups = mkIf (cfg.group == "unbound") {
275 unbound = { };
276 };
277
278 networking = mkIf cfg.resolveLocalQueries {
279 resolvconf = {
280 useLocalResolver = mkDefault true;
281 };
282 };
283
284 environment.etc."unbound/unbound.conf".source = confFile;
285
286 systemd.services.unbound = {
287 description = "Unbound recursive Domain Name Server";
288 after = [ "network.target" ];
289 before = [ "nss-lookup.target" ];
290 wantedBy = [
291 "multi-user.target"
292 "nss-lookup.target"
293 ];
294
295 path = mkIf cfg.settings.remote-control.control-enable [ pkgs.openssl ];
296
297 preStart = ''
298 ${optionalString cfg.enableRootTrustAnchor ''
299 ${cfg.package}/bin/unbound-anchor -a ${rootTrustAnchorFile} || echo "Root anchor updated!"
300 ''}
301 ${optionalString cfg.settings.remote-control.control-enable ''
302 ${cfg.package}/bin/unbound-control-setup -d ${cfg.stateDir}
303 ''}
304 '';
305
306 restartTriggers = [
307 confFile
308 ];
309
310 serviceConfig = {
311 ExecStart = "${cfg.package}/bin/unbound -p -d -c /etc/unbound/unbound.conf";
312 ExecReload = "+/run/current-system/sw/bin/kill -HUP $MAINPID";
313
314 NotifyAccess = "main";
315 Type = "notify";
316
317 AmbientCapabilities = [
318 "CAP_NET_BIND_SERVICE"
319 "CAP_NET_RAW" # needed if ip-transparent is set to true
320 ];
321 CapabilityBoundingSet = [
322 "CAP_NET_BIND_SERVICE"
323 "CAP_NET_RAW"
324 ];
325
326 User = cfg.user;
327 Group = cfg.group;
328
329 MemoryDenyWriteExecute = true;
330 NoNewPrivileges = true;
331 PrivateDevices = true;
332 PrivateTmp = true;
333 ProtectHome = true;
334 ProtectControlGroups = true;
335 ProtectKernelModules = true;
336 ProtectSystem = "strict";
337 ProtectClock = true;
338 ProtectHostname = true;
339 ProtectProc = "invisible";
340 ProcSubset = "pid";
341 ProtectKernelLogs = true;
342 ProtectKernelTunables = true;
343 RuntimeDirectory = "unbound";
344 ConfigurationDirectory = "unbound";
345 StateDirectory = "unbound";
346 RestrictAddressFamilies = [
347 "AF_INET"
348 "AF_INET6"
349 "AF_NETLINK"
350 "AF_UNIX"
351 ];
352 RestrictRealtime = true;
353 SystemCallArchitectures = "native";
354 SystemCallFilter = [ "@system-service" ];
355 RestrictNamespaces = true;
356 LockPersonality = true;
357 RestrictSUIDSGID = true;
358
359 ReadWritePaths = [ cfg.stateDir ];
360
361 Restart = "on-failure";
362 RestartSec = "5s";
363 };
364 };
365 };
366
367 imports = [
368 (mkRenamedOptionModule
369 [ "services" "unbound" "interfaces" ]
370 [ "services" "unbound" "settings" "server" "interface" ]
371 )
372 (mkChangedOptionModule
373 [ "services" "unbound" "allowedAccess" ]
374 [ "services" "unbound" "settings" "server" "access-control" ]
375 (
376 config:
377 map (value: "${value} allow") (getAttrFromPath [ "services" "unbound" "allowedAccess" ] config)
378 )
379 )
380 (mkRemovedOptionModule [ "services" "unbound" "forwardAddresses" ] ''
381 Add a new setting:
382 services.unbound.settings.forward-zone = [{
383 name = ".";
384 forward-addr = [ # Your current services.unbound.forwardAddresses ];
385 }];
386 If any of those addresses are local addresses (127.0.0.1 or ::1), you must
387 also set services.unbound.settings.server.do-not-query-localhost to false.
388 '')
389 (mkRemovedOptionModule [ "services" "unbound" "extraConfig" ] ''
390 You can use services.unbound.settings to add any configuration you want.
391 '')
392 ];
393}