{ config, lib, pkgs, options, ... }: with lib; let cfg = config.services.radicle; json = pkgs.formats.json { }; configFile = (json.generate "config.json" cfg.settings).overrideAttrs (previousAttrs: { preferLocalBuild = true; # None of the usual phases are run here because runCommandWith uses buildCommand, # so just append to buildCommand what would usually be a checkPhase. buildCommand = previousAttrs.buildCommand + optionalString cfg.checkConfig '' ln -s $out config.json install -D -m 644 /dev/stdin keys/radicle.pub <<<"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgFMhajUng+Rjj/sCFXI9PzG8BQjru2n7JgUVF1Kbv5 snakeoil" export RAD_HOME=$PWD ${getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null ''; }); env = rec { # rad fails if it cannot stat $HOME/.gitconfig HOME = "/var/lib/radicle"; RAD_HOME = HOME; }; # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service` rad-system = pkgs.writeShellScriptBin "rad-system" '' set -o allexport ${toShellVars env} # Note that --env is not used to preserve host's envvars like $TERM exec ${getExe' pkgs.util-linux "nsenter"} -a \ -t "$(${getExe' config.systemd.package "systemctl"} show -P MainPID radicle-node.service)" \ -S "$(${getExe' config.systemd.package "systemctl"} show -P UID radicle-node.service)" \ -G "$(${getExe' config.systemd.package "systemctl"} show -P GID radicle-node.service)" \ ${getExe' cfg.package "rad"} "$@" ''; in { options = { services.radicle = { enable = mkEnableOption "Radicle Seed Node"; package = mkPackageOption pkgs "radicle-node" { }; privateKeyFile = mkOption { type = with types; either path str; description = '' SSH private key generated by `rad auth`. If it contains a colon (`:`) the string before the colon is taken as the credential name and the string after as a path encrypted with `systemd-creds`. ''; }; publicKeyFile = mkOption { type = with types; either path str; description = '' SSH public key generated by `rad auth`. ''; }; node = { listenAddress = mkOption { type = types.str; default = "0.0.0.0"; example = "127.0.0.1"; description = "The IP address on which `radicle-node` listens."; }; listenPort = mkOption { type = types.port; default = 8776; description = "The port on which `radicle-node` listens."; }; openFirewall = mkEnableOption "opening the firewall for `radicle-node`"; extraArgs = mkOption { type = with types; listOf str; default = [ ]; description = "Extra arguments for `radicle-node`"; }; }; checkConfig = mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; }; settings = mkOption { description = '' See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275 ''; default = { }; type = types.submodule { freeformType = json.type; options.cli = { hints = mkOption { type = types.bool; default = true; description = "Whether to show hints or not in the CLI"; }; }; options.node = { alias = mkOption { type = types.str; default = config.networking.hostName; defaultText = "config.networking.hostName"; description = "Node alias"; }; connect = mkOption { type = with types; listOf str; default = [ ]; description = '' Peers to connect to on startup. Connections to these peers will be maintained ''; }; db = { journalMode = mkOption { type = types.str; default = "rollback"; description = ""; }; }; externalAddresses = mkOption { type = with types; listOf str; default = [ ]; description = "Specify the node's public addresses"; }; limits = { connection = { inbound = mkOption { type = types.ints.unsigned; default = 128; description = "Max inbound connections"; }; outbound = mkOption { type = types.ints.unsigned; default = 16; description = "Max outbound connections"; }; }; fetchConcurrency = mkOption { type = types.ints.positive; default = 1; description = "Maximum number of concurrent fetches per peer connection"; }; gossipMaxAge = mkOption { type = types.ints.positive; default = 2 * 7 * 24 * 60 * 60; # 2 weeks defaultText = "2 * 7 * 24 * 60 * 60"; description = "How many seconds to keep a gossip message entry before pruning it"; }; maxOpenFiles = mkOption { type = types.ints.positive; default = 4096; description = "Maximum number of open files"; }; rate = { inbound = { capacity = mkOption { type = types.ints.positive; default = 1024; description = "Inbound capacity limit for a single connection"; }; fillRate = mkOption { type = types.numbers.positive; default = 5.0; description = "Inbound fill rate for a single connection"; }; }; outbound = { capacity = mkOption { type = types.ints.positive; default = 2048; description = "Outbound capacity limit for a single connection"; }; fillRate = mkOption { type = types.numbers.positive; default = 10.0; description = "Outbound fill rate for a single connection"; }; }; }; routingMaxAge = mkOption { type = types.ints.positive; default = 7 * 24 * 60 * 60; # 1 week defaultText = "7 * 24 * 60 * 60"; description = "How many seconds to keep a routing table entry before being pruned"; }; routingMaxSize = mkOption { type = types.ints.positive; default = 1000; description = "Number of routing table entries before we start pruning"; }; }; listen = mkOption { type = with types; listOf str; default = [ ]; description = "Address to listen on"; }; log = mkOption { type = types.enum [ "ERROR" "WARN" "INFO" "DEBUG" "TRACE" ]; default = "INFO"; description = "Log level"; }; network = mkOption { type = types.str; default = "main"; description = "Peer-to-peer network"; }; onion = mkOption { type = types.nullOr (types.submodule { options.mode = mkOption { type = with types; nullOr (enum [ "proxy" "forward" ]); description = '' "proxy" proxies connections to the given address. "forward" forward address to the next layer. Either this is the global proxy, or the operating system, via DNS. ''; }; options.address = mkOption { type = with types; nullOr str; default = null; description = "Address for the \"proxy\" mode"; }; }); default = null; description = "Onion address config"; }; peers = { type = mkOption { type = types.enum [ "dynamic" "static" ]; default = "dynamic"; description = '' "dynamic" for a dynamic peer set "static" for a static peer set. Connect to the configured peers and maintain the connections. ''; }; target = mkOption { type = types.ints.unsigned; default = 8; description = "Number of outbound dynamic peers"; }; }; policy = mkOption { type = types.enum [ "allow" "block" ]; default = "block"; description = "Default seeding policy"; }; proxy = mkOption { type = with types; nullOr str; default = null; description = "Global proxy"; }; relay = mkOption { type = types.enum [ "auto" "always" "never" ]; default = "never"; description = "Whether or not our node should relay messages"; }; scope = mkOption { type = types.enum [ "all" "followed" ]; default = "all"; description = "Default seeding scope"; }; workers = mkOption { type = types.ints.positive; default = 8; description = "Number of worker threads to spawn"; }; }; options.preferredSeeds = mkOption { type = with types; listOf str; default = [ "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@seed.radicle.garden:8776" "z6Mkmqogy2qEM2ummccUthFEaaHvyYmYBYh3dbe9W4ebScxo@ash.radicle.garden:8776" ]; description = '' Preferred seeds. These seeds will be used for explorer links and in other situations when a seed needs to be chosen ''; }; options.publicExplorer = mkOption { type = types.str; default = "https://app.radicle.xyz/nodes/$host/$rid$path"; description = '' Public explorer. This is used for generating links ''; }; options.web = { pinned = { repositories = mkOption { type = with types; listOf str; default = [ ]; description = '' Pinned repositories on a Web client. ''; }; }; }; }; }; httpd = { enable = mkEnableOption "Radicle HTTP gateway"; # TODO: use radicale-httpd when the packages have been split package = mkPackageOption pkgs "radicle-node" { }; listenAddress = mkOption { type = types.str; default = "127.0.0.1"; description = "The IP address on which `radicle-httpd` listens."; }; listenPort = mkOption { type = types.port; default = 8080; description = "The port on which `radicle-httpd` listens."; }; nginx = mkOption { # Type of a single virtual host, or null. type = types.nullOr options.services.nginx.virtualHosts.type.functor.wrapped; default = null; example = literalExpression '' { serverAliases = [ "seed.''${config.networking.domain}" ]; enableACME = false; useACMEHost = config.networking.domain; } ''; description = '' With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`. Set to `{}` if you do not need any customization to the virtual host. If enabled, then by default, the {option}`serverName` is `radicle-''${config.networking.hostName}.''${config.networking.domain}`, TLS is active, and certificates are acquired via ACME. If this is set to null (the default), no nginx virtual host will be configured. ''; }; extraArgs = mkOption { type = with types; listOf str; default = [ ]; description = "Extra arguments for `radicle-httpd`"; }; }; }; }; config = mkIf cfg.enable { systemd.services = let commonConfig = serviceName: { environment = env // { RUST_LOG = mkDefault "info"; }; path = [ pkgs.gitMinimal ]; documentation = [ "https://docs.radicle.xyz/guides/seeder" ]; after = [ "network.target" "network-online.target" ]; requires = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = mkMerge [ { BindReadOnlyPaths = [ "${configFile}:${env.RAD_HOME}/config.json" "${if isPath cfg.publicKeyFile then cfg.publicKeyFile else pkgs.writeText "radicle.pub" cfg.publicKeyFile}:${env.RAD_HOME}/keys/radicle.pub" ]; KillMode = "process"; StateDirectory = [ "radicle" ]; DynamicUser = true; # The "radicale" user will be allocated when the first of the "radicle-*" services starts, # it will be shared among them, and be released when the last one of them exits. User = "radicle"; Group = "radicle"; WorkingDirectory = env.HOME; } # The following options are only for optimizing: # systemd-analyze security ${serviceName} { BindReadOnlyPaths = [ "-/etc/resolv.conf" "/etc/ssl/certs/ca-certificates.crt" "/run/systemd" ]; AmbientCapabilities = ""; CapabilityBoundingSet = ""; DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r LockPersonality = true; MemoryDenyWriteExecute = true; ProcSubset = "pid"; ProtectClock = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectProc = "invisible"; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RuntimeDirectoryMode = "700"; SocketBindDeny = [ "any" ]; StateDirectoryMode = "0750"; SystemCallFilter = [ "@system-service" "~@aio" "~@chown" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid" "~@timer" ]; SystemCallArchitectures = "native"; # This is for BindPaths= and BindReadOnlyPaths= # to allow traversal of directories they create inside RootDirectory= UMask = "0066"; } ]; confinement = { enable = true; mode = "full-apivfs"; packages = [ pkgs.gitMinimal cfg.package pkgs.iana-etc (getLib pkgs.nss) pkgs.tzdata ]; }; }; in { radicle-node = mkMerge [ (commonConfig "radicle-node") { description = "Radicle Node"; documentation = [ "man:radicle-node(1)" ]; serviceConfig = { ExecStart = "${getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${escapeShellArgs cfg.node.extraArgs}"; NFTSet = optionals config.networking.nftables.enable [ "user:inet:filter:nixos_radicle_node_uids" ]; Restart = mkDefault "on-failure"; RestartSec = "30"; SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ]; SystemCallFilter = mkAfter [ # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone "@timer" ]; }; } # Give only access to the private key to radicle-node. { serviceConfig = let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in if length keyCred > 1 then { LoadCredentialEncrypted = [ cfg.privateKeyFile ]; # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths= BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/${head keyCred}:${env.RAD_HOME}/keys/radicle" ]; } else { LoadCredential = [ "radicle:${cfg.privateKeyFile}" ]; BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ]; }; } ]; radicle-httpd = mkMerge [ (commonConfig "radicle-httpd") { description = "Radicle HTTP gateway to radicle-node"; documentation = [ "man:radicle-httpd(1)" ]; serviceConfig = { ExecStart = "${getExe' cfg.httpd.package "radicle-httpd"} --listen ${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort} ${escapeShellArgs cfg.httpd.extraArgs}"; Restart = mkDefault "on-failure"; RestartSec = "10"; SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ]; SystemCallFilter = mkAfter [ # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone "@timer" ]; }; } ]; }; environment.systemPackages = [ rad-system ]; networking.firewall = mkIf cfg.node.openFirewall { allowedTCPPorts = [ cfg.node.listenPort ]; }; networking.nftables.ruleset = mkIf config.networking.nftables.enable (mkBefore '' table inet filter { # A set containing the dynamic UID of the radicle-node systemd service of NixOS set nixos_radicle_node_uids { typeof meta skuid; } } ''); services.radicle.httpd.nginx.serverName = mkDefault "radicle-${config.networking.hostName}.${config.networking.domain}"; services.nginx.virtualHosts = mkIf (cfg.httpd.nginx != null) { "${cfg.httpd.nginx.serverName}" = lib.mkMerge [ cfg.httpd.nginx { forceSSL = mkDefault true; enableACME = mkDefault true; locations."/" = { proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}"; recommendedProxySettings = true; }; } ]; }; meta.maintainers = with lib.maintainers; [ julm lorenzleutgeb ]; }; }