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