1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8
9let
10 inherit (lib)
11 attrNames
12 concatMapStrings
13 concatMapStringsSep
14 concatStrings
15 concatStringsSep
16 elem
17 filter
18 flip
19 hasAttr
20 hasPrefix
21 isAttrs
22 isBool
23 isDerivation
24 isList
25 mapAttrsToList
26 mkChangedOptionModule
27 mkEnableOption
28 mkIf
29 mkOption
30 mkPackageOption
31 optionals
32 types
33 ;
34
35 inherit (utils)
36 escapeSystemdExecArgs
37 ;
38
39 cfg = config.services.knot;
40
41 yamlConfig =
42 let
43 result =
44 assert secsCheck;
45 nix2yaml cfg.settings;
46
47 secAllow =
48 n:
49 hasPrefix "mod-" n
50 || elem n [
51 "module"
52 "server"
53 "xdp"
54 "control"
55 "log"
56 "statistics"
57 "database"
58 "keystore"
59 "key"
60 "remote"
61 "remotes"
62 "acl"
63 "submission"
64 "policy"
65 "template"
66 "zone"
67 "include"
68 ];
69 secsCheck =
70 let
71 secsBad = filter (n: !secAllow n) (attrNames cfg.settings);
72 in
73 if secsBad == [ ] then
74 true
75 else
76 throw ("services.knot.settings contains unknown sections: " + toString secsBad);
77
78 nix2yaml =
79 nix_def:
80 concatStrings (
81 # We output the config section in the upstream-mandated order.
82 # Ordering is important due to forward-references not being allowed.
83 # See definition of conf_export and 'const yp_item_t conf_schema'
84 # upstream for reference. Last updated for 3.3.
85 # When changing the set of sections, also update secAllow above.
86 [ (sec_list_fa "id" nix_def "module") ]
87 ++ map (sec_plain nix_def) [
88 "server"
89 "xdp"
90 "control"
91 ]
92 ++ [ (sec_list_fa "target" nix_def "log") ]
93 ++ map (sec_plain nix_def) [
94 "statistics"
95 "database"
96 ]
97 ++ map (sec_list_fa "id" nix_def) [
98 "keystore"
99 "key"
100 "remote"
101 "remotes"
102 "acl"
103 "submission"
104 "policy"
105 ]
106
107 # Export module sections before the template section.
108 ++ map (sec_list_fa "id" nix_def) (filter (hasPrefix "mod-") (attrNames nix_def))
109
110 ++ [ (sec_list_fa "id" nix_def "template") ]
111 ++ [ (sec_list_fa "domain" nix_def "zone") ]
112 ++ [ (sec_plain nix_def "include") ]
113 ++ [ (sec_plain nix_def "clear") ]
114 );
115
116 # A plain section contains directly attributes (we don't really check that ATM).
117 sec_plain =
118 nix_def: sec_name:
119 if !hasAttr sec_name nix_def then "" else n2y "" { ${sec_name} = nix_def.${sec_name}; };
120
121 # This section contains a list of attribute sets. In each of the sets
122 # there's an attribute (`fa_name`, typically "id") that must exist and come first.
123 # Alternatively we support using attribute sets instead of lists; example diff:
124 # -template = [ { id = "default"; /* other attributes */ } { id = "foo"; } ]
125 # +template = { default = { /* those attributes */ }; foo = { }; }
126 sec_list_fa =
127 fa_name: nix_def: sec_name:
128 if !hasAttr sec_name nix_def then
129 ""
130 else
131 let
132 elem2yaml =
133 fa_val: other_attrs:
134 " - " + n2y "" { ${fa_name} = fa_val; } + " " + n2y " " other_attrs + "\n";
135 sec = nix_def.${sec_name};
136 in
137 sec_name
138 + ":\n"
139 + (
140 if isList sec then
141 flip concatMapStrings sec (elem: elem2yaml elem.${fa_name} (removeAttrs elem [ fa_name ]))
142 else
143 concatStrings (mapAttrsToList elem2yaml sec)
144 );
145
146 # This convertor doesn't care about ordering of attributes.
147 # TODO: it could probably be simplified even more, now that it's not
148 # to be used directly, but we might want some other tweaks, too.
149 n2y =
150 indent: val:
151 if doRecurse val then
152 concatStringsSep "\n${indent}" (
153 mapAttrsToList
154 # This is a bit wacky - set directly under a set would start on bad indent,
155 # so we start those on a new line, but not other types of attribute values.
156 (
157 aname: aval:
158 "${aname}:${if doRecurse aval then "\n${indent} " else " "}" + n2y (indent + " ") aval
159 )
160 val
161 )
162 + "\n"
163 else
164 /*
165 if isList val && stringLength indent < 4 then concatMapStrings
166 (elem: "\n${indent}- " + n2y (indent + " ") elem)
167 val
168 else
169 */
170 if
171 isList val # and long indent
172 then
173 "[ " + concatMapStringsSep ", " quoteString val + " ]"
174 else if isBool val then
175 (if val then "on" else "off")
176 else
177 quoteString val;
178
179 # We don't want paths like ./my-zone.txt be converted to plain strings.
180 quoteString = s: ''"${if builtins.typeOf s == "path" then s else toString s}"'';
181 # We don't want to walk the insides of derivation attributes.
182 doRecurse = val: isAttrs val && !isDerivation val;
183
184 in
185 result;
186
187 configFile =
188 if cfg.settingsFile != null then
189 # Note: with extraConfig, the 23.05 compat code did include keyFiles from settingsFile.
190 assert cfg.settings == { } && (cfg.keyFiles == [ ] || cfg.extraConfig != null);
191 cfg.settingsFile
192 else
193 mkConfigFile yamlConfig;
194
195 mkConfigFile =
196 configString:
197 pkgs.writeTextFile {
198 name = "knot.conf";
199 text = (concatMapStringsSep "\n" (file: "include: ${file}") cfg.keyFiles) + "\n" + configString;
200 checkPhase = lib.optionalString cfg.checkConfig ''
201 ${cfg.package}/bin/knotc --config=$out conf-check
202 '';
203 };
204
205 socketFile = "/run/knot/knot.sock";
206
207 knot-cli-wrappers = pkgs.stdenv.mkDerivation {
208 name = "knot-cli-wrappers";
209 nativeBuildInputs = [ pkgs.makeWrapper ];
210 buildCommand = ''
211 mkdir -p $out/bin
212 makeWrapper ${cfg.package}/bin/knotc "$out/bin/knotc" \
213 --add-flags "--config=${configFile}" \
214 --add-flags "--socket=${socketFile}"
215 makeWrapper ${cfg.package}/bin/keymgr "$out/bin/keymgr" \
216 --add-flags "--config=${configFile}"
217 makeWrapper ${cfg.package}/bin/kzonesign "$out/bin/kzonesign" \
218 --add-flags "--config=${configFile}"
219 makeWrapper ${cfg.package}/bin/kcatalogprint "$out/bin/kcatalogprint" \
220 --add-flags "--config=${configFile}"
221 for executable in kdig khost kjournalprint knsec3hash knsupdate kzonecheck kxdpgun
222 do
223 ln -s "${cfg.package}/bin/$executable" "$out/bin/$executable"
224 done
225 mkdir -p "$out/share"
226 ln -s '${cfg.package}/share/man' "$out/share/"
227 '';
228 };
229in
230{
231 options = {
232 services.knot = {
233 enable = mkEnableOption "Knot authoritative-only DNS server";
234
235 enableXDP = mkOption {
236 type = types.bool;
237 default = lib.hasAttrByPath [ "xdp" "listen" ] cfg.settings;
238 defaultText = ''
239 Enabled when the `xdp.listen` setting is configured through `settings`.
240 '';
241 example = true;
242 description = ''
243 Extends the systemd unit with permissions to allow for the use of
244 the eXpress Data Path (XDP).
245
246 ::: {.note}
247 Make sure to read up on functional [limitations](https://www.knot-dns.cz/docs/latest/singlehtml/index.html#mode-xdp-limitations)
248 when running in XDP mode.
249 :::
250 '';
251 };
252
253 checkConfig = mkOption {
254 type = types.bool;
255 # TODO: maybe we could do some checks even when private keys complicate this?
256 # conf-check fails hard on missing IPs/devices with XDP
257 default = cfg.keyFiles == [ ] && !cfg.enableXDP;
258 defaultText = ''
259 Disabled when the config uses `keyFiles` or `enableXDP`.
260 '';
261 example = false;
262 description = ''
263 Toggles the configuration test at build time. It runs in a
264 sandbox, and therefore cannot be used in all scenarios.
265 '';
266 };
267
268 extraArgs = mkOption {
269 type = types.listOf types.str;
270 default = [ ];
271 description = ''
272 List of additional command line parameters for knotd
273 '';
274 };
275
276 keyFiles = mkOption {
277 type = types.listOf types.path;
278 default = [ ];
279 description = ''
280 A list of files containing additional configuration
281 to be included using the include directive. This option
282 allows to include configuration like TSIG keys without
283 exposing them to the nix store readable to any process.
284 Note that using this option will also disable configuration
285 checks at build time.
286 '';
287 };
288
289 settings = mkOption {
290 type = (pkgs.formats.yaml { }).type;
291 default = { };
292 description = ''
293 Extra configuration as nix values.
294 '';
295 };
296
297 settingsFile = mkOption {
298 type = types.nullOr types.path;
299 default = null;
300 description = ''
301 As alternative to ``settings``, you can provide whole configuration
302 directly in the almost-YAML format of Knot DNS.
303 You might want to utilize ``pkgs.writeText "knot.conf" "longConfigString"`` for this.
304 '';
305 };
306
307 package = mkPackageOption pkgs "knot-dns" { };
308 };
309 };
310 imports = [
311 # Compatibility with NixOS 23.05.
312 (mkChangedOptionModule [ "services" "knot" "extraConfig" ] [ "services" "knot" "settingsFile" ] (
313 config: mkConfigFile config.services.knot.extraConfig
314 ))
315 ];
316
317 config = mkIf config.services.knot.enable {
318 users.groups.knot = { };
319 users.users.knot = {
320 isSystemUser = true;
321 group = "knot";
322 description = "Knot daemon user";
323 };
324
325 environment.etc."knot/knot.conf".source = configFile; # just for user's convenience
326
327 systemd.services.knot = {
328 unitConfig.Documentation = "man:knotd(8) man:knot.conf(5) man:knotc(8) https://www.knot-dns.cz/docs/${cfg.package.version}/html/";
329 description = cfg.package.meta.description;
330 wantedBy = [ "multi-user.target" ];
331 wants = [ "network.target" ];
332 after = [ "network.target" ];
333
334 serviceConfig =
335 let
336 # https://www.knot-dns.cz/docs/3.3/singlehtml/index.html#pre-requisites
337 xdpCapabilities =
338 lib.optionals (cfg.enableXDP) [
339 "CAP_NET_ADMIN"
340 "CAP_NET_RAW"
341 "CAP_SYS_ADMIN"
342 "CAP_IPC_LOCK"
343 ]
344 ++ lib.optionals (lib.versionOlder config.boot.kernelPackages.kernel.version "5.11") [
345 "CAP_SYS_RESOURCE"
346 ];
347 in
348 {
349 Type = "notify";
350 ExecStart = escapeSystemdExecArgs (
351 [
352 (lib.getExe cfg.package)
353 "--config=${configFile}"
354 "--socket=${socketFile}"
355 ]
356 ++ cfg.extraArgs
357 );
358 ExecReload = escapeSystemdExecArgs [
359 "${knot-cli-wrappers}/bin/knotc"
360 "reload"
361 ];
362 User = "knot";
363 Group = "knot";
364
365 AmbientCapabilities = [
366 "CAP_NET_BIND_SERVICE"
367 ] ++ xdpCapabilities;
368 CapabilityBoundingSet = [
369 "CAP_NET_BIND_SERVICE"
370 ] ++ xdpCapabilities;
371 DeviceAllow = "";
372 DevicePolicy = "closed";
373 LockPersonality = true;
374 MemoryDenyWriteExecute = true;
375 NoNewPrivileges = true;
376 PrivateDevices = true;
377 PrivateTmp = true;
378 PrivateUsers = false; # breaks capability passing
379 ProcSubset = "pid";
380 ProtectClock = true;
381 ProtectControlGroups = true;
382 ProtectHome = true;
383 ProtectHostname = true;
384 ProtectKernelLogs = true;
385 ProtectKernelModules = true;
386 ProtectKernelTunables = true;
387 ProtectProc = "invisible";
388 ProtectSystem = "strict";
389 RemoveIPC = true;
390 Restart = "on-abort";
391 RestrictAddressFamilies =
392 [
393 "AF_INET"
394 "AF_INET6"
395 "AF_UNIX"
396 ]
397 ++ optionals (cfg.enableXDP) [
398 "AF_NETLINK"
399 "AF_XDP"
400 ];
401 RestrictNamespaces = true;
402 RestrictRealtime = true;
403 RestrictSUIDSGID = true;
404 RuntimeDirectory = "knot";
405 StateDirectory = "knot";
406 StateDirectoryMode = "0700";
407 SystemCallArchitectures = "native";
408 SystemCallFilter =
409 [
410 "@system-service"
411 "~@privileged"
412 "@chown"
413 ]
414 ++ optionals (cfg.enableXDP) [
415 "bpf"
416 ];
417 UMask = "0077";
418 };
419 };
420
421 environment.systemPackages = [ knot-cli-wrappers ];
422 };
423}