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