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