1 { config, lib, pkgs, ... }:
4 cfg = config.services.radicle;
6 json = pkgs.formats.json { };
9 # rad fails if it cannot stat $HOME/.gitconfig
10 HOME = "/var/lib/radicle";
14 # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
15 rad-system = pkgs.writeShellScriptBin "rad-system" ''
18 # Note that --env is not used to preserve host's envvars like $TERM
19 exec ${getExe' pkgs.util-linux "nsenter"} -a \
20 -t "$(${getExe' config.systemd.package "systemctl"} show -P MainPID radicle-node.service)" \
21 -S "$(${getExe' config.systemd.package "systemctl"} show -P UID radicle-node.service)" \
22 -G "$(${getExe' config.systemd.package "systemctl"} show -P GID radicle-node.service)" \
23 ${getExe' cfg.package "rad"} "$@"
26 commonServiceConfig = serviceName: {
27 environment = env // {
28 RUST_LOG = mkDefault "info";
34 "https://docs.radicle.xyz/guides/seeder"
38 "network-online.target"
41 "network-online.target"
43 wantedBy = [ "multi-user.target" ];
44 serviceConfig = mkMerge [
47 "${cfg.configFile}:${env.RAD_HOME}/config.json"
48 "${if isPath cfg.publicKeyFile then cfg.publicKeyFile else pkgs.writeText "radicle.pub" cfg.publicKeyFile}:${env.RAD_HOME}/keys/radicle.pub"
51 StateDirectory = [ "radicle" ];
52 User = config.users.users.radicle.name;
53 Group = config.users.groups.radicle.name;
54 WorkingDirectory = env.HOME;
56 # The following options are only for optimizing:
57 # systemd-analyze security ${serviceName}
61 "/etc/ssl/certs/ca-certificates.crt"
64 AmbientCapabilities = "";
65 CapabilityBoundingSet = "";
66 DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
67 LockPersonality = true;
68 MemoryDenyWriteExecute = true;
69 NoNewPrivileges = true;
74 ProtectHostname = true;
75 ProtectKernelLogs = true;
76 ProtectProc = "invisible";
77 ProtectSystem = "strict";
79 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
80 RestrictNamespaces = true;
81 RestrictRealtime = true;
82 RestrictSUIDSGID = true;
83 RuntimeDirectoryMode = "700";
84 SocketBindDeny = [ "any" ];
85 StateDirectoryMode = "0750";
97 SystemCallArchitectures = "native";
98 # This is for BindPaths= and BindReadOnlyPaths=
99 # to allow traversal of directories they create inside RootDirectory=
105 mode = "full-apivfs";
119 enable = mkEnableOption "Radicle Seed Node";
120 package = mkPackageOption pkgs "radicle-node" { };
121 privateKeyFile = mkOption {
122 type = with types; either path str;
124 SSH private key generated by `rad auth`.
126 If it contains a colon (`:`) the string before the colon
127 is taken as the credential name
128 and the string after as a path encrypted with `systemd-creds`.
131 publicKeyFile = mkOption {
132 type = with types; either path str;
134 SSH public key generated by `rad auth`.
138 listenAddress = mkOption {
141 example = "127.0.0.1";
142 description = "The IP address on which `radicle-node` listens.";
144 listenPort = mkOption {
147 description = "The port on which `radicle-node` listens.";
149 openFirewall = mkEnableOption "opening the firewall for `radicle-node`";
150 extraArgs = mkOption {
151 type = with types; listOf str;
153 description = "Extra arguments for `radicle-node`";
156 configFile = mkOption {
157 type = types.package;
159 default = (json.generate "config.json" cfg.settings).overrideAttrs (previousAttrs: {
160 preferLocalBuild = true;
161 # None of the usual phases are run here because runCommandWith uses buildCommand,
162 # so just append to buildCommand what would usually be a checkPhase.
163 buildCommand = previousAttrs.buildCommand + optionalString cfg.checkConfig ''
164 ln -s $out config.json
165 install -D -m 644 /dev/stdin keys/radicle.pub <<<"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgFMhajUng+Rjj/sCFXI9PzG8BQjru2n7JgUVF1Kbv5 snakeoil"
167 ${getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null || {
169 echo "Invalid config.json according to rad."
170 echo "Please double-check your services.radicle.settings (producing the config.json above),"
171 echo "some settings may be missing or have the wrong type."
177 checkConfig = mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
178 settings = mkOption {
180 See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
183 type = types.submodule {
184 freeformType = json.type;
188 enable = mkEnableOption "Radicle HTTP gateway to radicle-node";
189 package = mkPackageOption pkgs "radicle-httpd" { };
190 listenAddress = mkOption {
192 default = "127.0.0.1";
193 description = "The IP address on which `radicle-httpd` listens.";
195 listenPort = mkOption {
198 description = "The port on which `radicle-httpd` listens.";
201 # Type of a single virtual host, or null.
202 type = types.nullOr (types.submodule (
203 recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
204 options.serverName = {
205 default = "radicle-${config.networking.hostName}.${config.networking.domain}";
206 defaultText = "radicle-\${config.networking.hostName}.\${config.networking.domain}";
211 example = literalExpression ''
214 "seed.''${config.networking.domain}"
217 useACMEHost = config.networking.domain;
221 With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`.
222 Set to `{}` if you do not need any customization to the virtual host.
223 If enabled, then by default, the {option}`serverName` is
224 `radicle-''${config.networking.hostName}.''${config.networking.domain}`,
225 TLS is active, and certificates are acquired via ACME.
226 If this is set to null (the default), no nginx virtual host will be configured.
229 extraArgs = mkOption {
230 type = with types; listOf str;
232 description = "Extra arguments for `radicle-httpd`";
238 config = mkIf cfg.enable (mkMerge [
240 systemd.services.radicle-node = mkMerge [
241 (commonServiceConfig "radicle-node")
243 description = "Radicle Node";
244 documentation = [ "man:radicle-node(1)" ];
246 ExecStart = "${getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${escapeShellArgs cfg.node.extraArgs}";
247 Restart = mkDefault "on-failure";
249 SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
250 SystemCallFilter = mkAfter [
251 # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone
255 confinement.packages = [
259 # Give only access to the private key to radicle-node.
262 let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in
263 if length keyCred > 1
265 LoadCredentialEncrypted = [ cfg.privateKeyFile ];
266 # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
267 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/${head keyCred}:${env.RAD_HOME}/keys/radicle" ];
270 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
271 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ];
276 environment.systemPackages = [
280 networking.firewall = mkIf cfg.node.openFirewall {
281 allowedTCPPorts = [ cfg.node.listenPort ];
288 description = "Radicle";
296 (mkIf cfg.httpd.enable (mkMerge [
298 systemd.services.radicle-httpd = mkMerge [
299 (commonServiceConfig "radicle-httpd")
301 description = "Radicle HTTP gateway to radicle-node";
302 documentation = [ "man:radicle-httpd(1)" ];
304 ExecStart = "${getExe' cfg.httpd.package "radicle-httpd"} --listen ${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort} ${escapeShellArgs cfg.httpd.extraArgs}";
305 Restart = mkDefault "on-failure";
307 SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ];
308 SystemCallFilter = mkAfter [
309 # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone
313 confinement.packages = [
320 (mkIf (cfg.httpd.nginx != null) {
321 services.nginx.virtualHosts.${cfg.httpd.nginx.serverName} = lib.mkMerge [
324 forceSSL = mkDefault true;
325 enableACME = mkDefault true;
327 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
328 recommendedProxySettings = true;
333 services.radicle.settings = {
334 node.alias = mkDefault cfg.httpd.nginx.serverName;
335 node.externalAddresses = mkDefault [
336 "${cfg.httpd.nginx.serverName}:${toString cfg.node.listenPort}"
343 meta.maintainers = with lib.maintainers; [