1 { config, lib, pkgs, options, ... }:
4 cfg = config.services.radicle;
6 json = pkgs.formats.json { };
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"
16 ${getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null
21 # rad fails if it cannot stat $HOME/.gitconfig
22 HOME = "/var/lib/radicle";
26 # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
27 rad-system = pkgs.writeShellScriptBin "rad-system" ''
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"} "$@"
41 enable = mkEnableOption "Radicle Seed Node";
42 package = mkPackageOption pkgs "radicle-node" { };
43 privateKeyFile = mkOption {
44 type = with types; either path str;
46 SSH private key generated by `rad auth`.
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`.
53 publicKeyFile = mkOption {
54 type = with types; either path str;
56 SSH public key generated by `rad auth`.
60 listenAddress = mkOption {
63 example = "127.0.0.1";
64 description = "The IP address on which `radicle-node` listens.";
66 listenPort = mkOption {
69 description = "The port on which `radicle-node` listens.";
71 openFirewall = mkEnableOption "opening the firewall for `radicle-node`";
72 extraArgs = mkOption {
73 type = with types; listOf str;
75 description = "Extra arguments for `radicle-node`";
78 checkConfig = mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
81 See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
84 type = types.submodule {
85 freeformType = json.type;
90 description = "Whether to show hints or not in the CLI";
96 default = config.networking.hostName;
97 defaultText = "config.networking.hostName";
98 description = "Node alias";
101 type = with types; listOf str;
104 Peers to connect to on startup.
105 Connections to these peers will be maintained
109 journalMode = mkOption {
111 default = "rollback";
115 externalAddresses = mkOption {
116 type = with types; listOf str;
118 description = "Specify the node's public addresses";
123 type = types.ints.unsigned;
125 description = "Max inbound connections";
127 outbound = mkOption {
128 type = types.ints.unsigned;
130 description = "Max outbound connections";
133 fetchConcurrency = mkOption {
134 type = types.ints.positive;
136 description = "Maximum number of concurrent fetches per peer connection";
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";
144 maxOpenFiles = mkOption {
145 type = types.ints.positive;
147 description = "Maximum number of open files";
151 capacity = mkOption {
152 type = types.ints.positive;
154 description = "Inbound capacity limit for a single connection";
156 fillRate = mkOption {
157 type = types.numbers.positive;
159 description = "Inbound fill rate for a single connection";
163 capacity = mkOption {
164 type = types.ints.positive;
166 description = "Outbound capacity limit for a single connection";
168 fillRate = mkOption {
169 type = types.numbers.positive;
171 description = "Outbound fill rate for a single connection";
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";
181 routingMaxSize = mkOption {
182 type = types.ints.positive;
184 description = "Number of routing table entries before we start pruning";
188 type = with types; listOf str;
190 description = "Address to listen on";
193 type = types.enum [ "ERROR" "WARN" "INFO" "DEBUG" "TRACE" ];
195 description = "Log level";
200 description = "Peer-to-peer network";
203 type = types.nullOr (types.submodule {
204 options.mode = mkOption {
205 type = with types; nullOr (enum [ "proxy" "forward" ]);
207 "proxy" proxies connections to the given address.
209 "forward" forward address to the next layer. Either this is the global proxy,
210 or the operating system, via DNS.
213 options.address = mkOption {
214 type = with types; nullOr str;
216 description = "Address for the \"proxy\" mode";
220 description = "Onion address config";
224 type = types.enum [ "dynamic" "static" ];
227 "dynamic" for a dynamic peer set
229 "static" for a static peer set.
230 Connect to the configured peers and maintain the connections.
234 type = types.ints.unsigned;
236 description = "Number of outbound dynamic peers";
240 type = types.enum [ "allow" "block" ];
242 description = "Default seeding policy";
245 type = with types; nullOr str;
247 description = "Global proxy";
250 type = types.enum [ "auto" "always" "never" ];
252 description = "Whether or not our node should relay messages";
255 type = types.enum [ "all" "followed" ];
257 description = "Default seeding scope";
260 type = types.ints.positive;
262 description = "Number of worker threads to spawn";
265 options.preferredSeeds = mkOption {
266 type = with types; listOf str;
268 "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776"
269 "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776"
272 Preferred seeds. These seeds will be used for explorer links
273 and in other situations when a seed needs to be chosen
276 options.publicExplorer = mkOption {
278 default = "https://app.radicle.xyz/nodes/$host/$rid$path";
280 Public explorer. This is used for generating links
285 repositories = mkOption {
286 type = with types; listOf str;
289 Pinned repositories on a Web client.
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 {
302 default = "127.0.0.1";
303 description = "The IP address on which `radicle-httpd` listens.";
305 listenPort = mkOption {
308 description = "The port on which `radicle-httpd` listens.";
311 # Type of a single virtual host, or null.
312 type = types.nullOr options.services.nginx.virtualHosts.type.functor.wrapped;
314 example = literalExpression ''
317 "seed.''${config.networking.domain}"
320 useACMEHost = config.networking.domain;
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.
332 extraArgs = mkOption {
333 type = with types; listOf str;
335 description = "Extra arguments for `radicle-httpd`";
341 config = mkIf cfg.enable {
344 commonConfig = serviceName:
346 environment = env // {
347 RUST_LOG = mkDefault "info";
353 "https://docs.radicle.xyz/guides/seeder"
357 "network-online.target"
360 "network-online.target"
362 wantedBy = [ "multi-user.target" ];
363 serviceConfig = mkMerge [
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"
369 KillMode = "process";
370 StateDirectory = [ "radicle" ];
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.
376 WorkingDirectory = env.HOME;
378 # The following options are only for optimizing:
379 # systemd-analyze security ${serviceName}
381 BindReadOnlyPaths = [
383 "/etc/ssl/certs/ca-certificates.crt"
386 AmbientCapabilities = "";
387 CapabilityBoundingSet = "";
388 DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
389 LockPersonality = true;
390 MemoryDenyWriteExecute = 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";
414 SystemCallArchitectures = "native";
415 # This is for BindPaths= and BindReadOnlyPaths=
416 # to allow traversal of directories they create inside RootDirectory=
422 mode = "full-apivfs";
434 radicle-node = mkMerge [
435 (commonConfig "radicle-node")
437 description = "Radicle Node";
438 documentation = [ "man:radicle-node(1)" ];
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"
444 Restart = mkDefault "on-failure";
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
453 # Give only access to the private key to radicle-node.
456 let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in
457 if length keyCred > 1
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" ];
464 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
465 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ];
469 radicle-httpd = mkMerge [
470 (commonConfig "radicle-httpd")
472 description = "Radicle HTTP gateway to radicle-node";
473 documentation = [ "man:radicle-httpd(1)" ];
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";
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
488 environment.systemPackages = [
492 networking.firewall = mkIf cfg.node.openFirewall {
493 allowedTCPPorts = [ cfg.node.listenPort ];
496 networking.nftables.ruleset = mkIf config.networking.nftables.enable (mkBefore ''
498 # A set containing the dynamic UID of the radicle-node systemd service of NixOS
499 set nixos_radicle_node_uids { typeof meta skuid; }
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 [
509 forceSSL = mkDefault true;
510 enableACME = mkDefault true;
512 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
513 recommendedProxySettings = true;
519 meta.maintainers = with lib.maintainers; [