1 { config, lib, pkgs, ... }:
3 cfg = config.services.radicle;
5 json = pkgs.formats.json { };
8 # rad fails if it cannot stat $HOME/.gitconfig
9 HOME = "/var/lib/radicle";
13 # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
14 rad-system = pkgs.writeShellScriptBin "rad-system" ''
16 ${lib.toShellVars env}
17 # Note that --env is not used to preserve host's envvars like $TERM
18 exec ${lib.getExe' pkgs.util-linux "nsenter"} -a \
19 -t "$(${lib.getExe' config.systemd.package "systemctl"} show -P MainPID radicle-node.service)" \
20 -S "$(${lib.getExe' config.systemd.package "systemctl"} show -P UID radicle-node.service)" \
21 -G "$(${lib.getExe' config.systemd.package "systemctl"} show -P GID radicle-node.service)" \
22 ${lib.getExe' cfg.package "rad"} "$@"
25 commonServiceConfig = serviceName: {
26 environment = env // {
27 RUST_LOG = lib.mkDefault "info";
33 "https://docs.radicle.xyz/guides/seeder"
37 "network-online.target"
40 "network-online.target"
42 wantedBy = [ "multi-user.target" ];
43 serviceConfig = lib.mkMerge [
46 "${cfg.configFile}:${env.RAD_HOME}/config.json"
47 "${if lib.types.path.check cfg.publicKey then cfg.publicKey else pkgs.writeText "radicle.pub" cfg.publicKey}:${env.RAD_HOME}/keys/radicle.pub"
50 StateDirectory = [ "radicle" ];
51 User = config.users.users.radicle.name;
52 Group = config.users.groups.radicle.name;
53 WorkingDirectory = env.HOME;
55 # The following options are only for optimizing:
56 # systemd-analyze security ${serviceName}
60 "/etc/ssl/certs/ca-certificates.crt"
63 AmbientCapabilities = "";
64 CapabilityBoundingSet = "";
65 DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
66 LockPersonality = true;
67 MemoryDenyWriteExecute = true;
68 NoNewPrivileges = true;
73 ProtectHostname = true;
74 ProtectKernelLogs = true;
75 ProtectProc = "invisible";
76 ProtectSystem = "strict";
78 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
79 RestrictNamespaces = true;
80 RestrictRealtime = true;
81 RestrictSUIDSGID = true;
82 RuntimeDirectoryMode = "700";
83 SocketBindDeny = [ "any" ];
84 StateDirectoryMode = "0750";
96 SystemCallArchitectures = "native";
97 # This is for BindPaths= and BindReadOnlyPaths=
98 # to allow traversal of directories they create inside RootDirectory=
104 mode = "full-apivfs";
109 (lib.getLib pkgs.nss)
118 enable = lib.mkEnableOption "Radicle Seed Node";
119 package = lib.mkPackageOption pkgs "radicle-node" { };
120 privateKeyFile = lib.mkOption {
121 # Note that a key encrypted by systemd-creds is not a path but a str.
122 type = with lib.types; either path str;
124 Absolute file path to an SSH private key,
125 usually generated by `rad auth`.
127 If it contains a colon (`:`) the string before the colon
128 is taken as the credential name
129 and the string after as a path encrypted with `systemd-creds`.
132 publicKey = lib.mkOption {
133 type = with lib.types; either path str;
135 An SSH public key (as an absolute file path or directly as a string),
136 usually generated by `rad auth`.
140 listenAddress = lib.mkOption {
141 type = lib.types.str;
143 example = "127.0.0.1";
144 description = "The IP address on which `radicle-node` listens.";
146 listenPort = lib.mkOption {
147 type = lib.types.port;
149 description = "The port on which `radicle-node` listens.";
151 openFirewall = lib.mkEnableOption "opening the firewall for `radicle-node`";
152 extraArgs = lib.mkOption {
153 type = with lib.types; listOf str;
155 description = "Extra arguments for `radicle-node`";
158 configFile = lib.mkOption {
159 type = lib.types.package;
161 default = (json.generate "config.json" cfg.settings).overrideAttrs (previousAttrs: {
162 preferLocalBuild = true;
163 # None of the usual phases are run here because runCommandWith uses buildCommand,
164 # so just append to buildCommand what would usually be a checkPhase.
165 buildCommand = previousAttrs.buildCommand + lib.optionalString cfg.checkConfig ''
166 ln -s $out config.json
167 install -D -m 644 /dev/stdin keys/radicle.pub <<<"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgFMhajUng+Rjj/sCFXI9PzG8BQjru2n7JgUVF1Kbv5 snakeoil"
169 ${lib.getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null || {
171 echo "Invalid config.json according to rad."
172 echo "Please double-check your services.radicle.settings (producing the config.json above),"
173 echo "some settings may be missing or have the wrong type."
179 checkConfig = lib.mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
180 settings = lib.mkOption {
182 See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
185 example = lib.literalExpression ''
187 web.pinned.repositories = [
188 "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5" # heartwood
189 "rad:z3trNYnLWS11cJWC6BbxDs5niGo82" # rips
193 type = lib.types.submodule {
194 freeformType = json.type;
198 enable = lib.mkEnableOption "Radicle HTTP gateway to radicle-node";
199 package = lib.mkPackageOption pkgs "radicle-httpd" { };
200 listenAddress = lib.mkOption {
201 type = lib.types.str;
202 default = "127.0.0.1";
203 description = "The IP address on which `radicle-httpd` listens.";
205 listenPort = lib.mkOption {
206 type = lib.types.port;
208 description = "The port on which `radicle-httpd` listens.";
210 nginx = lib.mkOption {
211 # Type of a single virtual host, or null.
212 type = lib.types.nullOr (lib.types.submodule (
213 lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
214 options.serverName = {
215 default = "radicle-${config.networking.hostName}.${config.networking.domain}";
216 defaultText = "radicle-\${config.networking.hostName}.\${config.networking.domain}";
221 example = lib.literalExpression ''
224 "seed.''${config.networking.domain}"
227 useACMEHost = config.networking.domain;
231 With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`.
232 Set to `{}` if you do not need any customization to the virtual host.
233 If enabled, then by default, the {option}`serverName` is
234 `radicle-''${config.networking.hostName}.''${config.networking.domain}`,
235 TLS is active, and certificates are acquired via ACME.
236 If this is set to null (the default), no nginx virtual host will be configured.
239 extraArgs = lib.mkOption {
240 type = with lib.types; listOf str;
242 description = "Extra arguments for `radicle-httpd`";
248 config = lib.mkIf cfg.enable (lib.mkMerge [
250 systemd.services.radicle-node = lib.mkMerge [
251 (commonServiceConfig "radicle-node")
253 description = "Radicle Node";
254 documentation = [ "man:radicle-node(1)" ];
256 ExecStart = "${lib.getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${lib.escapeShellArgs cfg.node.extraArgs}";
257 Restart = lib.mkDefault "on-failure";
259 SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
260 SystemCallFilter = lib.mkAfter [
261 # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone
265 confinement.packages = [
269 # Give only access to the private key to radicle-node.
272 let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in
273 if lib.length keyCred > 1
275 LoadCredentialEncrypted = [ cfg.privateKeyFile ];
276 # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
277 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/${lib.head keyCred}:${env.RAD_HOME}/keys/radicle" ];
280 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
281 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ];
286 environment.systemPackages = [
290 networking.firewall = lib.mkIf cfg.node.openFirewall {
291 allowedTCPPorts = [ cfg.node.listenPort ];
296 description = "Radicle";
306 (lib.mkIf cfg.httpd.enable (lib.mkMerge [
308 systemd.services.radicle-httpd = lib.mkMerge [
309 (commonServiceConfig "radicle-httpd")
311 description = "Radicle HTTP gateway to radicle-node";
312 documentation = [ "man:radicle-httpd(1)" ];
314 ExecStart = "${lib.getExe' cfg.httpd.package "radicle-httpd"} --listen ${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort} ${lib.escapeShellArgs cfg.httpd.extraArgs}";
315 Restart = lib.mkDefault "on-failure";
317 SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ];
318 SystemCallFilter = lib.mkAfter [
319 # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone
323 confinement.packages = [
330 (lib.mkIf (cfg.httpd.nginx != null) {
331 services.nginx.virtualHosts.${cfg.httpd.nginx.serverName} = lib.mkMerge [
334 forceSSL = lib.mkDefault true;
335 enableACME = lib.mkDefault true;
337 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
338 recommendedProxySettings = true;
343 services.radicle.settings = {
344 node.alias = lib.mkDefault cfg.httpd.nginx.serverName;
345 node.externalAddresses = lib.mkDefault [
346 "${cfg.httpd.nginx.serverName}:${toString cfg.node.listenPort}"
353 meta.maintainers = with lib.maintainers; [