This repository has no description
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}: let
7 cfg = config.services.tangled.knot-rs;
8
9 inherit (lib) literalExpression mkEnableOption mkOption types;
10
11 settingsFormat = pkgs.formats.toml {};
12
13 addrType = types.strMatching "^([[][0-9a-fA-F:]+[]]|[^:]+):[0-9]+$";
14 absPathType = types.strMatching "^/.+";
15
16 portOf = addr: lib.toInt (lib.last (lib.splitString ":" addr));
17 hostOf = addr: lib.concatStringsSep ":" (lib.init (lib.splitString ":" addr));
18 isLoopback = addr: lib.elem (hostOf addr) ["127.0.0.1" "[::1]"];
19
20 inherit (cfg.settings) server tls;
21
22 sshPort = portOf server.ssh_listen_addr;
23 listenPort = portOf server.listen_addr;
24 internalPort = portOf server.internal_listen_addr;
25
26 tlsEnabled = tls.acme_enabled || tls.cert_path != null;
27
28 publicTcpPorts =
29 lib.optional (!isLoopback server.ssh_listen_addr) sshPort
30 ++ lib.optional (!isLoopback server.listen_addr) listenPort
31 ++ lib.optional (tls.mtls_enabled && !isLoopback server.internal_listen_addr) internalPort;
32
33 publicUdpPorts =
34 lib.optional (tlsEnabled && tls.http3 && !isLoopback server.listen_addr) listenPort;
35
36 bindsPrivilegedPort =
37 lib.any (port: port < 1024)
38 ([sshPort listenPort] ++ lib.optional tls.mtls_enabled internalPort);
39
40 stateDirs = lib.unique (
41 [cfg.stateDir cfg.settings.repo.scan_path]
42 ++ lib.optional (cfg.settings.lfs.store_path != null) cfg.settings.lfs.store_path
43 ++ lib.optional tls.acme_enabled tls.acme_cache_dir
44 );
45
46 keyDirs = lib.subtractLists stateDirs (lib.unique [
47 (dirOf cfg.settings.secrets.sealed_key_file)
48 (dirOf server.ssh_host_key_file)
49 ]);
50
51 writablePaths = stateDirs ++ keyDirs;
52
53 usesHomePath =
54 lib.any
55 (path: lib.any (prefix: lib.hasPrefix prefix "${path}/") ["/home/" "/root/"])
56 writablePaths;
57
58 populated = lib.filterAttrsRecursive (_: value: value != null) cfg.settings;
59
60 rendered =
61 settingsFormat.generate "knot.toml"
62 (lib.filterAttrs (_: value: value != {}) populated);
63
64 configFile =
65 if pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform
66 then
67 pkgs.runCommandLocal "knot-config.toml" {
68 nativeBuildInputs = [cfg.package];
69 } ''
70 knot-server validate --config-only ${rendered}
71 ln -s ${rendered} $out
72 ''
73 else rendered;
74in {
75 _class = "nixos";
76
77 options.services.tangled.knot-rs = {
78 enable = mkEnableOption "the knot git server";
79
80 package = mkOption {
81 type = types.package;
82 description = "Package providing the knot-server binary";
83 };
84
85 migratePackage = mkOption {
86 type = types.package;
87 description = "Package providing the knot-migrate binary";
88 };
89
90 installMigrateTool = mkOption {
91 type = types.bool;
92 default = false;
93 description = ''
94 Whether to instlal {option}`migratePackage` system-wide.
95 Only needed if doing a one-time migration from the Go knot.
96 '';
97 };
98
99 user = mkOption {
100 type = types.str;
101 default = "knot";
102 description = "User the knot runs as and the owner of the repositories";
103 };
104
105 group = mkOption {
106 type = types.str;
107 default = cfg.user;
108 description = "Group the knot runs as";
109 };
110
111 stateDir = mkOption {
112 type = absPathType;
113 default = "/var/lib/knot";
114 description = "Directory the knot stores its repositories, sealed key, and ssh host key in";
115 };
116
117 openFirewall = mkOption {
118 type = types.bool;
119 default = true;
120 description = ''
121 Whether to open the port of each listen address that isn't loopback,
122 plus the matching UDP port when HTTP3 serves over TLS.
123 '';
124 };
125
126 environmentFile = mkOption {
127 type = types.nullOr absPathType;
128 default = null;
129 example = "/etc/secrets/knot.env";
130 description = ''
131 Environment file as defined in {manpage}`systemd.exec(5)`,
132 which sets the master key and any other secret
133 so they stay out of the nix store.
134 Every `KNOT_*` variable it sets
135 also overrides the matching key in {option}`settings`.
136 '';
137 };
138
139 settings = mkOption {
140 type = types.submodule {
141 freeformType = settingsFormat.type;
142
143 options = {
144 server = {
145 hostname = mkOption {
146 type = types.str;
147 example = "knot.oyster.cafe";
148 description = "Public hostname, which is also the knot's did:web identity";
149 };
150
151 admins = mkOption {
152 type = types.nonEmptyListOf types.str;
153 example = ["did:plc:boltless"];
154 description = ''
155 DIDs with knot-admin authority.
156 The knot reports the first entry as its service owner,
157 so reordering this list changes the owner it advertises.
158 '';
159 };
160
161 listen_addr = mkOption {
162 type = addrType;
163 default = "127.0.0.1:5555";
164 description = ''
165 Address the HTTP surface listens on.
166 The module default suits a reverse proxy in front,
167 while the binary's own default is `[::]:5555`.
168 '';
169 };
170
171 internal_listen_addr = mkOption {
172 type = addrType;
173 default = "[::1]:5444";
174 description = "Address the mTLS admin surface listens on when {option}`settings.tls.mtls_enabled` is set";
175 };
176
177 ssh_listen_addr = mkOption {
178 type = addrType;
179 default = "[::]:2222";
180 description = ''
181 Address the knot's own ssh server listens on.
182 Moving this to port 22 collides with {option}`services.openssh`
183 unless that also moves.
184 '';
185 };
186
187 ssh_host_key_file = mkOption {
188 type = absPathType;
189 default = "${cfg.stateDir}/ssh_host_ed25519_key";
190 defaultText = literalExpression ''"''${stateDir}/ssh_host_ed25519_key"'';
191 description = ''
192 Private ssh host key the knot presents.
193 The knot creates one on first start when the file is absent,
194 so its directory must be writable.
195 Keep this off the nix store.
196 '';
197 };
198 };
199
200 acl.admission = mkOption {
201 type = types.enum ["closed" "open"];
202 default = "closed";
203 description = "Whether repository creation needs knot membership";
204 };
205
206 repo.scan_path = mkOption {
207 type = absPathType;
208 default = "${cfg.stateDir}/repos";
209 defaultText = literalExpression ''"''${stateDir}/repos"'';
210 description = "Directory the knot serves repositories from";
211 };
212
213 git.object_format = mkOption {
214 type = types.enum ["sha1" "sha256"];
215 default = "sha256";
216 description = "Object format for repositories the knot creates";
217 };
218
219 secrets = {
220 sealed_key_file = mkOption {
221 type = absPathType;
222 default = "${cfg.stateDir}/knot.sealed";
223 defaultText = literalExpression ''"''${stateDir}/knot.sealed"'';
224 description = ''
225 Sealed store for the knot signing key.
226 The knot creates one on first start when the file is absent,
227 so its directory must be writable.
228 '';
229 };
230
231 master_key_env = mkOption {
232 type = types.strMatching "^[A-Z_][A-Z0-9_]*$";
233 default = "KNOT_MASTER_KEY";
234 description = ''
235 Name of the environment variable with the base64 master key that unseals
236 {option}`settings.secrets.sealed_key_file`.
237 Set the value itself in {option}`environmentFile`.
238 Losing it makes every sealed key unreadable.
239 '';
240 };
241 };
242
243 atproto.plc_directory = mkOption {
244 type = types.str;
245 example = "https://plc.directory";
246 description = "atproto PLC directory. This has no default so that the plcdir is an explicit choice.";
247 };
248
249 xrpc.trusted_proxy_header = mkOption {
250 type = types.nullOr types.str;
251 default = null;
252 example = "x-forwarded-for";
253 description = ''
254 Header a trusted reverse proxy appends the client address to.
255 Rate limiting keys every request on the proxy's own address while this is null.
256 Only set it if a trusted proxy overwrites the header,
257 since a client can forge it otherwise.
258 '';
259 };
260
261 lfs.store_path = mkOption {
262 type = types.nullOr absPathType;
263 default = null;
264 description = "Directory for Git LFS objects. The knot won't serve LFS while this is null.";
265 };
266
267 tls = {
268 cert_path = mkOption {
269 type = types.nullOr absPathType;
270 default = null;
271 example = "/etc/knot/tls/cert.pem";
272 description = ''
273 Certificate chain the knot presents.
274 The knot serves plain HTTP
275 while this and {option}`settings.tls.acme_enabled` are both unset.
276 That suits a reverse proxy in front.
277 '';
278 };
279
280 key_path = mkOption {
281 type = types.nullOr absPathType;
282 default = null;
283 example = "/etc/knot/tls/key.pem";
284 description = ''
285 Private key for {option}`settings.tls.cert_path`.
286 Set both or neither.
287 Keep this off the nix store.
288 '';
289 };
290
291 http3 = mkOption {
292 type = types.bool;
293 default = true;
294 description = "Whether to serve HTTP/3 over QUIC on the UDP port matching {option}`settings.server.listen_addr`";
295 };
296
297 acme_enabled = mkOption {
298 type = types.bool;
299 default = false;
300 description = "Whether to obtain certificates over ACME instead of reading {option}`settings.tls.cert_path`";
301 };
302
303 acme_cache_dir = mkOption {
304 type = absPathType;
305 default = "${cfg.stateDir}/acme";
306 defaultText = literalExpression ''"''${stateDir}/acme"'';
307 description = "Directory for the ACME account key and issued certificates";
308 };
309
310 acme_contact = mkOption {
311 type = types.nullOr types.str;
312 default = null;
313 example = "nel@oyster.cafe";
314 description = "Contact email the knot registers the ACME account with, required when ACME is enabled";
315 };
316
317 acme_staging = mkOption {
318 type = types.bool;
319 default = false;
320 description = ''
321 Whether to use the Let's Encrypt staging directory.
322 Set it while testing so a typo doesn't exhaust the production rate limit.
323 '';
324 };
325
326 mtls_enabled = mkOption {
327 type = types.bool;
328 default = false;
329 description = "Whether to serve the mTLS admin surface on {option}`settings.server.internal_listen_addr`";
330 };
331
332 mtls_client_ca_path = mkOption {
333 type = types.nullOr absPathType;
334 default = null;
335 example = "/etc/knot/tls/admin-ca.pem";
336 description = "CA that signs admin client certificates, required when mTLS is enabled";
337 };
338
339 mtls_admin_spki_pin = mkOption {
340 type = types.nullOr types.str;
341 default = null;
342 description = "Base64 SHA-256 SPKI pin of the admin client certificate, required when mTLS is enabled";
343 };
344 };
345 };
346 };
347
348 description = ''
349 Configuration the module renders to `/etc/knot/config.toml`.
350 The knot reads that file on startup.
351 Keys beyond the ones declared here pass through unchanged,
352 and `knot-server validate --config-only` checks the result at build time
353 when the build platform can run the knot binary.
354 Run `nix run .#knot-rs -- config-template` for the full key list.
355 Put secrets in {option}`environmentFile`.
356 '';
357 };
358 };
359
360 config = lib.mkIf cfg.enable {
361 assertions = [
362 {
363 assertion = cfg.environmentFile != null;
364 message = "services.tangled.knot-rs.environmentFile must be set, since the knot reads its master key from ${cfg.settings.secrets.master_key_env} in the environment and won't start without it";
365 }
366 {
367 assertion = !config.services.openssh.enable || !(lib.elem sshPort config.services.openssh.ports);
368 message = "services.tangled.knot-rs.settings.server.ssh_listen_addr takes port ${toString sshPort}, which services.openssh already listens on";
369 }
370 {
371 assertion = (tls.cert_path == null) == (tls.key_path == null);
372 message = "services.tangled.knot-rs.settings.tls.cert_path and tls.key_path must both be set or both unset";
373 }
374 {
375 assertion = !(tls.acme_enabled && tls.cert_path != null);
376 message = "services.tangled.knot-rs.settings.tls.acme_enabled can't combine with a static tls.cert_path";
377 }
378 {
379 assertion = !tls.acme_enabled || tls.acme_contact != null;
380 message = "services.tangled.knot-rs.settings.tls.acme_contact is required when tls.acme_enabled is set";
381 }
382 {
383 assertion = !tls.acme_enabled || !isLoopback server.listen_addr;
384 message = "services.tangled.knot-rs.settings.tls.acme_enabled needs a certificate authority to reach settings.server.listen_addr, and ${server.listen_addr} is loopback";
385 }
386 {
387 assertion = !tls.mtls_enabled || tlsEnabled;
388 message = "services.tangled.knot-rs.settings.tls.mtls_enabled requires a certificate from tls.cert_path or ACME";
389 }
390 {
391 assertion = !tls.mtls_enabled || (tls.mtls_client_ca_path != null && tls.mtls_admin_spki_pin != null);
392 message = "services.tangled.knot-rs.settings.tls.mtls_enabled requires tls.mtls_client_ca_path and tls.mtls_admin_spki_pin";
393 }
394 ];
395
396 warnings =
397 lib.optional (tls.acme_enabled && listenPort != 443)
398 "services.tangled.knot-rs validates over TLS-ALPN-01, which a certificate authority reaches on TCP 443, and settings.server.listen_addr uses port ${toString listenPort}. Map 443 to that port.";
399
400 environment.systemPackages =
401 [cfg.package]
402 ++ lib.optional cfg.installMigrateTool cfg.migratePackage;
403
404 environment.etc."knot/config.toml".source = configFile;
405
406 users.users.${cfg.user} = {
407 isSystemUser = true;
408 home = cfg.stateDir;
409 inherit (cfg) group;
410 };
411
412 users.groups.${cfg.group} = {};
413
414 systemd.tmpfiles.settings."10-knot-rs" = lib.genAttrs stateDirs (path: {
415 d = {
416 mode =
417 if path == tls.acme_cache_dir
418 then "0700"
419 else "0750";
420 inherit (cfg) user group;
421 };
422 });
423
424 systemd.services.knot-rs = {
425 description = "knot git server";
426 after = ["network-online.target"];
427 wants = ["network-online.target"];
428 wantedBy = ["multi-user.target"];
429
430 restartTriggers = [configFile];
431
432 startLimitIntervalSec = 60;
433 startLimitBurst = 5;
434
435 serviceConfig = {
436 User = cfg.user;
437 Group = cfg.group;
438 UMask = "0077";
439 WorkingDirectory = cfg.stateDir;
440 EnvironmentFile = cfg.environmentFile;
441 ExecStart = "${lib.getExe cfg.package} /etc/knot/config.toml";
442 Restart = "on-failure";
443 RestartSec = 5;
444 TimeoutStopSec = 120;
445 LimitNOFILE = 65536;
446 AmbientCapabilities = lib.mkIf bindsPrivilegedPort ["CAP_NET_BIND_SERVICE"];
447 CapabilityBoundingSet =
448 if bindsPrivilegedPort
449 then ["CAP_NET_BIND_SERVICE"]
450 else [];
451 ReadWritePaths = stateDirs ++ map (dir: "-${dir}") keyDirs;
452 NoNewPrivileges = true;
453 ProtectProc = "invisible";
454 ProtectSystem = "strict";
455 ProtectHome = !usesHomePath;
456 PrivateTmp = true;
457 PrivateDevices = true;
458 PrivateUsers = !bindsPrivilegedPort;
459 ProtectHostname = true;
460 ProtectClock = true;
461 ProtectKernelTunables = true;
462 ProtectKernelModules = true;
463 ProtectKernelLogs = true;
464 ProtectControlGroups = true;
465 RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_NETLINK" "AF_UNIX"];
466 RestrictNamespaces = true;
467 LockPersonality = true;
468 MemoryDenyWriteExecute = true;
469 RestrictRealtime = true;
470 RestrictSUIDSGID = true;
471 RemoveIPC = true;
472 PrivateMounts = true;
473 SystemCallFilter = ["@system-service" "~@privileged @resources"];
474 SystemCallArchitectures = "native";
475 };
476 };
477
478 networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall publicTcpPorts;
479 networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall publicUdpPorts;
480 };
481}