]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/misc/radicle.nix
nix: update to nixos-24.05
[sourcephile-nix.git] / nixos / modules / services / misc / radicle.nix
1 { config, lib, pkgs, options, ... }:
2 with lib;
3 let
4 cfg = config.services.radicle;
5
6 json = pkgs.formats.json { };
7
8 configFile = (json.generate "config.json" cfg.settings).overrideAttrs (previousAttrs: {
9 preferLocalBuild = true;
10 # None of the usual phases are run here because runCommandWith uses buildCommand,
11 # so just append to buildCommand what would usually be a checkPhase.
12 buildCommand = previousAttrs.buildCommand + optionalString cfg.checkConfig ''
13 ln -s $out config.json
14 install -D -m 644 /dev/stdin keys/radicle.pub <<<"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgFMhajUng+Rjj/sCFXI9PzG8BQjru2n7JgUVF1Kbv5 snakeoil"
15 export RAD_HOME=$PWD
16 ${getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null
17 '';
18 });
19
20 env = rec {
21 # rad fails if it cannot stat $HOME/.gitconfig
22 HOME = "/var/lib/radicle";
23 RAD_HOME = HOME;
24 };
25
26 # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
27 rad-system = pkgs.writeShellScriptBin "rad-system" ''
28 set -o allexport
29 ${toShellVars env}
30 # Note that --env is not used to preserve host's envvars like $TERM
31 exec ${getExe' pkgs.util-linux "nsenter"} -a \
32 -t "$(${getExe' config.systemd.package "systemctl"} show -P MainPID radicle-node.service)" \
33 -S "$(${getExe' config.systemd.package "systemctl"} show -P UID radicle-node.service)" \
34 -G "$(${getExe' config.systemd.package "systemctl"} show -P GID radicle-node.service)" \
35 ${getExe' cfg.package "rad"} "$@"
36 '';
37 in
38 {
39 options = {
40 services.radicle = {
41 enable = mkEnableOption "Radicle Seed Node";
42 package = mkPackageOption pkgs "radicle-node" { };
43 privateKeyFile = mkOption {
44 type = with types; either path str;
45 description = ''
46 SSH private key generated by `rad auth`.
47
48 If it contains a colon (`:`) the string before the colon
49 is taken as the credential name
50 and the string after as a path encrypted with `systemd-creds`.
51 '';
52 };
53 publicKeyFile = mkOption {
54 type = with types; either path str;
55 description = ''
56 SSH public key generated by `rad auth`.
57 '';
58 };
59 node = {
60 listenAddress = mkOption {
61 type = types.str;
62 default = "0.0.0.0";
63 example = "127.0.0.1";
64 description = "The IP address on which `radicle-node` listens.";
65 };
66 listenPort = mkOption {
67 type = types.port;
68 default = 8776;
69 description = "The port on which `radicle-node` listens.";
70 };
71 openFirewall = mkEnableOption "opening the firewall for `radicle-node`";
72 extraArgs = mkOption {
73 type = with types; listOf str;
74 default = [ ];
75 description = "Extra arguments for `radicle-node`";
76 };
77 };
78 checkConfig = mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
79 settings = mkOption {
80 description = ''
81 See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
82 '';
83 default = { };
84 type = types.submodule {
85 freeformType = json.type;
86 options.cli = {
87 hints = mkOption {
88 type = types.bool;
89 default = true;
90 description = "Whether to show hints or not in the CLI";
91 };
92 };
93 options.node = {
94 alias = mkOption {
95 type = types.str;
96 default = config.networking.hostName;
97 defaultText = "config.networking.hostName";
98 description = "Node alias";
99 };
100 connect = mkOption {
101 type = with types; listOf str;
102 default = [ ];
103 description = ''
104 Peers to connect to on startup.
105 Connections to these peers will be maintained
106 '';
107 };
108 db = {
109 journalMode = mkOption {
110 type = types.str;
111 default = "rollback";
112 description = "";
113 };
114 };
115 externalAddresses = mkOption {
116 type = with types; listOf str;
117 default = [ ];
118 description = "Specify the node's public addresses";
119 };
120 limits = {
121 connection = {
122 inbound = mkOption {
123 type = types.ints.unsigned;
124 default = 128;
125 description = "Max inbound connections";
126 };
127 outbound = mkOption {
128 type = types.ints.unsigned;
129 default = 16;
130 description = "Max outbound connections";
131 };
132 };
133 fetchConcurrency = mkOption {
134 type = types.ints.positive;
135 default = 1;
136 description = "Maximum number of concurrent fetches per peer connection";
137 };
138 gossipMaxAge = mkOption {
139 type = types.ints.positive;
140 default = 2 * 7 * 24 * 60 * 60; # 2 weeks
141 defaultText = "2 * 7 * 24 * 60 * 60";
142 description = "How many seconds to keep a gossip message entry before pruning it";
143 };
144 maxOpenFiles = mkOption {
145 type = types.ints.positive;
146 default = 4096;
147 description = "Maximum number of open files";
148 };
149 rate = {
150 inbound = {
151 capacity = mkOption {
152 type = types.ints.positive;
153 default = 1024;
154 description = "Inbound capacity limit for a single connection";
155 };
156 fillRate = mkOption {
157 type = types.numbers.positive;
158 default = 5.0;
159 description = "Inbound fill rate for a single connection";
160 };
161 };
162 outbound = {
163 capacity = mkOption {
164 type = types.ints.positive;
165 default = 2048;
166 description = "Outbound capacity limit for a single connection";
167 };
168 fillRate = mkOption {
169 type = types.numbers.positive;
170 default = 10.0;
171 description = "Outbound fill rate for a single connection";
172 };
173 };
174 };
175 routingMaxAge = mkOption {
176 type = types.ints.positive;
177 default = 7 * 24 * 60 * 60; # 1 week
178 defaultText = "7 * 24 * 60 * 60";
179 description = "How many seconds to keep a routing table entry before being pruned";
180 };
181 routingMaxSize = mkOption {
182 type = types.ints.positive;
183 default = 1000;
184 description = "Number of routing table entries before we start pruning";
185 };
186 };
187 listen = mkOption {
188 type = with types; listOf str;
189 default = [ ];
190 description = "Address to listen on";
191 };
192 log = mkOption {
193 type = types.enum [ "ERROR" "WARN" "INFO" "DEBUG" "TRACE" ];
194 default = "INFO";
195 description = "Log level";
196 };
197 network = mkOption {
198 type = types.str;
199 default = "main";
200 description = "Peer-to-peer network";
201 };
202 onion = mkOption {
203 type = types.nullOr (types.submodule {
204 options.mode = mkOption {
205 type = with types; nullOr (enum [ "proxy" "forward" ]);
206 description = ''
207 "proxy" proxies connections to the given address.
208
209 "forward" forward address to the next layer. Either this is the global proxy,
210 or the operating system, via DNS.
211 '';
212 };
213 options.address = mkOption {
214 type = with types; nullOr str;
215 default = null;
216 description = "Address for the \"proxy\" mode";
217 };
218 });
219 default = null;
220 description = "Onion address config";
221 };
222 peers = {
223 type = mkOption {
224 type = types.enum [ "dynamic" "static" ];
225 default = "dynamic";
226 description = ''
227 "dynamic" for a dynamic peer set
228
229 "static" for a static peer set.
230 Connect to the configured peers and maintain the connections.
231 '';
232 };
233 target = mkOption {
234 type = types.ints.unsigned;
235 default = 8;
236 description = "Number of outbound dynamic peers";
237 };
238 };
239 policy = mkOption {
240 type = types.enum [ "allow" "block" ];
241 default = "block";
242 description = "Default seeding policy";
243 };
244 proxy = mkOption {
245 type = with types; nullOr str;
246 default = null;
247 description = "Global proxy";
248 };
249 relay = mkOption {
250 type = types.enum [ "auto" "always" "never" ];
251 default = "never";
252 description = "Whether or not our node should relay messages";
253 };
254 scope = mkOption {
255 type = types.enum [ "all" "followed" ];
256 default = "all";
257 description = "Default seeding scope";
258 };
259 workers = mkOption {
260 type = types.ints.positive;
261 default = 8;
262 description = "Number of worker threads to spawn";
263 };
264 };
265 options.preferredSeeds = mkOption {
266 type = with types; listOf str;
267 default = [
268 "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776"
269 "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
270 ];
271 description = ''
272 Preferred seeds. These seeds will be used for explorer links
273 and in other situations when a seed needs to be chosen
274 '';
275 };
276 options.publicExplorer = mkOption {
277 type = types.str;
278 default = "https://app.radicle.xyz/nodes/$host/$rid$path";
279 description = ''
280 Public explorer. This is used for generating links
281 '';
282 };
283 options.web = {
284 pinned = {
285 repositories = mkOption {
286 type = with types; listOf str;
287 default = [ ];
288 description = ''
289 Pinned repositories on a Web client.
290 '';
291 };
292 };
293 };
294 };
295 };
296 httpd = {
297 enable = mkEnableOption "Radicle HTTP gateway";
298 # TODO: use radicale-httpd when the packages have been split
299 package = mkPackageOption pkgs "radicle-node" { };
300 listenAddress = mkOption {
301 type = types.str;
302 default = "127.0.0.1";
303 description = "The IP address on which `radicle-httpd` listens.";
304 };
305 listenPort = mkOption {
306 type = types.port;
307 default = 8080;
308 description = "The port on which `radicle-httpd` listens.";
309 };
310 nginx = mkOption {
311 # Type of a single virtual host, or null.
312 type = types.nullOr options.services.nginx.virtualHosts.type.functor.wrapped;
313 default = null;
314 example = literalExpression ''
315 {
316 serverAliases = [
317 "seed.''${config.networking.domain}"
318 ];
319 enableACME = false;
320 useACMEHost = config.networking.domain;
321 }
322 '';
323 description = ''
324 With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`.
325 Set to `{}` if you do not need any customization to the virtual host.
326 If enabled, then by default, the {option}`serverName` is
327 `radicle-''${config.networking.hostName}.''${config.networking.domain}`,
328 TLS is active, and certificates are acquired via ACME.
329 If this is set to null (the default), no nginx virtual host will be configured.
330 '';
331 };
332 extraArgs = mkOption {
333 type = with types; listOf str;
334 default = [ ];
335 description = "Extra arguments for `radicle-httpd`";
336 };
337 };
338 };
339 };
340
341 config = mkIf cfg.enable {
342 systemd.services =
343 let
344 commonConfig = serviceName:
345 {
346 environment = env // {
347 RUST_LOG = mkDefault "info";
348 };
349 path = [
350 pkgs.gitMinimal
351 ];
352 documentation = [
353 "https://docs.radicle.xyz/guides/seeder"
354 ];
355 after = [
356 "network.target"
357 "network-online.target"
358 ];
359 requires = [
360 "network-online.target"
361 ];
362 wantedBy = [ "multi-user.target" ];
363 serviceConfig = mkMerge [
364 {
365 BindReadOnlyPaths = [
366 "${configFile}:${env.RAD_HOME}/config.json"
367 "${if isPath cfg.publicKeyFile then cfg.publicKeyFile else pkgs.writeText "radicle.pub" cfg.publicKeyFile}:${env.RAD_HOME}/keys/radicle.pub"
368 ];
369 KillMode = "process";
370 StateDirectory = [ "radicle" ];
371 DynamicUser = true;
372 # The "radicale" user will be allocated when the first of the "radicle-*" services starts,
373 # it will be shared among them, and be released when the last one of them exits.
374 User = "radicle";
375 Group = "radicle";
376 WorkingDirectory = env.HOME;
377 }
378 # The following options are only for optimizing:
379 # systemd-analyze security ${serviceName}
380 {
381 BindReadOnlyPaths = [
382 "-/etc/resolv.conf"
383 "/etc/ssl/certs/ca-certificates.crt"
384 "/run/systemd"
385 ];
386 AmbientCapabilities = "";
387 CapabilityBoundingSet = "";
388 DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
389 LockPersonality = true;
390 MemoryDenyWriteExecute = true;
391 ProcSubset = "pid";
392 ProtectClock = true;
393 ProtectHome = true;
394 ProtectHostname = true;
395 ProtectKernelLogs = true;
396 ProtectProc = "invisible";
397 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
398 RestrictNamespaces = true;
399 RestrictRealtime = true;
400 RuntimeDirectoryMode = "700";
401 SocketBindDeny = [ "any" ];
402 StateDirectoryMode = "0750";
403 SystemCallFilter = [
404 "@system-service"
405 "~@aio"
406 "~@chown"
407 "~@keyring"
408 "~@memlock"
409 "~@privileged"
410 "~@resources"
411 "~@setuid"
412 "~@timer"
413 ];
414 SystemCallArchitectures = "native";
415 # This is for BindPaths= and BindReadOnlyPaths=
416 # to allow traversal of directories they create inside RootDirectory=
417 UMask = "0066";
418 }
419 ];
420 confinement = {
421 enable = true;
422 mode = "full-apivfs";
423 packages = [
424 pkgs.gitMinimal
425 cfg.package
426 pkgs.iana-etc
427 (getLib pkgs.nss)
428 pkgs.tzdata
429 ];
430 };
431 };
432 in
433 {
434 radicle-node = mkMerge [
435 (commonConfig "radicle-node")
436 {
437 description = "Radicle Node";
438 documentation = [ "man:radicle-node(1)" ];
439 serviceConfig = {
440 ExecStart = "${getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${escapeShellArgs cfg.node.extraArgs}";
441 NFTSet = optionals config.networking.nftables.enable [
442 "user:inet:filter:nixos_radicle_node_uids"
443 ];
444 Restart = mkDefault "on-failure";
445 RestartSec = "30";
446 SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
447 SystemCallFilter = mkAfter [
448 # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone
449 "@timer"
450 ];
451 };
452 }
453 # Give only access to the private key to radicle-node.
454 {
455 serviceConfig =
456 let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in
457 if length keyCred > 1
458 then {
459 LoadCredentialEncrypted = [ cfg.privateKeyFile ];
460 # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
461 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/${head keyCred}:${env.RAD_HOME}/keys/radicle" ];
462 }
463 else {
464 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
465 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ];
466 };
467 }
468 ];
469 radicle-httpd = mkMerge [
470 (commonConfig "radicle-httpd")
471 {
472 description = "Radicle HTTP gateway to radicle-node";
473 documentation = [ "man:radicle-httpd(1)" ];
474 serviceConfig = {
475 ExecStart = "${getExe' cfg.httpd.package "radicle-httpd"} --listen ${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort} ${escapeShellArgs cfg.httpd.extraArgs}";
476 Restart = mkDefault "on-failure";
477 RestartSec = "10";
478 SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ];
479 SystemCallFilter = mkAfter [
480 # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone
481 "@timer"
482 ];
483 };
484 }
485 ];
486 };
487
488 environment.systemPackages = [
489 rad-system
490 ];
491
492 networking.firewall = mkIf cfg.node.openFirewall {
493 allowedTCPPorts = [ cfg.node.listenPort ];
494 };
495
496 networking.nftables.ruleset = mkIf config.networking.nftables.enable (mkBefore ''
497 table inet filter {
498 # A set containing the dynamic UID of the radicle-node systemd service of NixOS
499 set nixos_radicle_node_uids { typeof meta skuid; }
500 }
501 '');
502
503 services.radicle.httpd.nginx.serverName = mkDefault
504 "radicle-${config.networking.hostName}.${config.networking.domain}";
505 services.nginx.virtualHosts = mkIf (cfg.httpd.nginx != null) {
506 "${cfg.httpd.nginx.serverName}" = lib.mkMerge [
507 cfg.httpd.nginx
508 {
509 forceSSL = mkDefault true;
510 enableACME = mkDefault true;
511 locations."/" = {
512 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
513 recommendedProxySettings = true;
514 };
515 }
516 ];
517 };
518
519 meta.maintainers = with lib.maintainers; [
520 julm
521 lorenzleutgeb
522 ];
523 };
524 }