1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.radicle;
9
10 json = pkgs.formats.json { };
11
12 env = rec {
13 # rad fails if it cannot stat $HOME/.gitconfig
14 HOME = "/var/lib/radicle";
15 RAD_HOME = HOME;
16 };
17
18 # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
19 rad-system = pkgs.writeShellScriptBin "rad-system" ''
20 set -o allexport
21 ${lib.toShellVars env}
22 # Note that --env is not used to preserve host's envvars like $TERM
23 exec ${lib.getExe' pkgs.util-linux "nsenter"} -a \
24 -t "$(${lib.getExe' config.systemd.package "systemctl"} show -P MainPID radicle-node.service)" \
25 -S "$(${lib.getExe' config.systemd.package "systemctl"} show -P UID radicle-node.service)" \
26 -G "$(${lib.getExe' config.systemd.package "systemctl"} show -P GID radicle-node.service)" \
27 ${lib.getExe' cfg.package "rad"} "$@"
28 '';
29
30 commonServiceConfig = serviceName: {
31 environment = env // {
32 RUST_LOG = lib.mkDefault "info";
33 };
34 path = [
35 pkgs.gitMinimal
36 ];
37 documentation = [
38 "https://docs.radicle.xyz/guides/seeder"
39 ];
40 after = [
41 "network.target"
42 "network-online.target"
43 ];
44 requires = [
45 "network-online.target"
46 ];
47 wantedBy = [ "multi-user.target" ];
48 serviceConfig = lib.mkMerge [
49 {
50 BindReadOnlyPaths = [
51 "${cfg.configFile}:${env.RAD_HOME}/config.json"
52 "${
53 if lib.types.path.check cfg.publicKey then
54 cfg.publicKey
55 else
56 pkgs.writeText "radicle.pub" cfg.publicKey
57 }:${env.RAD_HOME}/keys/radicle.pub"
58 "${config.security.pki.caBundle}:/etc/ssl/certs/ca-certificates.crt"
59 ];
60 KillMode = "process";
61 StateDirectory = [ "radicle" ];
62 User = config.users.users.radicle.name;
63 Group = config.users.groups.radicle.name;
64 WorkingDirectory = env.HOME;
65 }
66 # The following options are only for optimizing:
67 # systemd-analyze security ${serviceName}
68 {
69 BindReadOnlyPaths = [
70 "-/etc/resolv.conf"
71 "/run/systemd"
72 ];
73 AmbientCapabilities = "";
74 CapabilityBoundingSet = "";
75 DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
76 LockPersonality = true;
77 MemoryDenyWriteExecute = true;
78 NoNewPrivileges = true;
79 PrivateTmp = true;
80 ProcSubset = "pid";
81 ProtectClock = true;
82 ProtectHome = true;
83 ProtectHostname = true;
84 ProtectKernelLogs = true;
85 ProtectProc = "invisible";
86 ProtectSystem = "strict";
87 RemoveIPC = true;
88 RestrictAddressFamilies = [
89 "AF_UNIX"
90 "AF_INET"
91 "AF_INET6"
92 ];
93 RestrictNamespaces = true;
94 RestrictRealtime = true;
95 RestrictSUIDSGID = true;
96 RuntimeDirectoryMode = "700";
97 SocketBindDeny = [ "any" ];
98 StateDirectoryMode = "0750";
99 SystemCallFilter = [
100 "@system-service"
101 "~@aio"
102 "~@chown"
103 "~@keyring"
104 "~@memlock"
105 "~@privileged"
106 "~@resources"
107 "~@setuid"
108 "~@timer"
109 ];
110 SystemCallArchitectures = "native";
111 # This is for BindPaths= and BindReadOnlyPaths=
112 # to allow traversal of directories they create inside RootDirectory=
113 UMask = "0066";
114 }
115 ];
116 confinement = {
117 enable = true;
118 mode = "full-apivfs";
119 packages = [
120 pkgs.gitMinimal
121 cfg.package
122 pkgs.iana-etc
123 (lib.getLib pkgs.nss)
124 pkgs.tzdata
125 ];
126 };
127 };
128in
129{
130 options = {
131 services.radicle = {
132 enable = lib.mkEnableOption "Radicle Seed Node";
133 package = lib.mkPackageOption pkgs "radicle-node" { };
134 privateKeyFile = lib.mkOption {
135 # Note that a key encrypted by systemd-creds is not a path but a str.
136 type = with lib.types; either path str;
137 description = ''
138 Absolute file path to an SSH private key,
139 usually generated by `rad auth`.
140
141 If it contains a colon (`:`) the string before the colon
142 is taken as the credential name
143 and the string after as a path encrypted with `systemd-creds`.
144 '';
145 };
146 publicKey = lib.mkOption {
147 type = with lib.types; either path str;
148 description = ''
149 An SSH public key (as an absolute file path or directly as a string),
150 usually generated by `rad auth`.
151 '';
152 };
153 node = {
154 listenAddress = lib.mkOption {
155 type = lib.types.str;
156 default = "[::]";
157 example = "127.0.0.1";
158 description = "The IP address on which `radicle-node` listens.";
159 };
160 listenPort = lib.mkOption {
161 type = lib.types.port;
162 default = 8776;
163 description = "The port on which `radicle-node` listens.";
164 };
165 openFirewall = lib.mkEnableOption "opening the firewall for `radicle-node`";
166 extraArgs = lib.mkOption {
167 type = with lib.types; listOf str;
168 default = [ ];
169 description = "Extra arguments for `radicle-node`";
170 };
171 };
172 configFile = lib.mkOption {
173 type = lib.types.package;
174 internal = true;
175 default = (json.generate "config.json" cfg.settings).overrideAttrs (previousAttrs: {
176 preferLocalBuild = true;
177 # None of the usual phases are run here because runCommandWith uses buildCommand,
178 # so just append to buildCommand what would usually be a checkPhase.
179 buildCommand =
180 previousAttrs.buildCommand
181 + lib.optionalString cfg.checkConfig ''
182 ln -s $out config.json
183 install -D -m 644 /dev/stdin keys/radicle.pub <<<"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBgFMhajUng+Rjj/sCFXI9PzG8BQjru2n7JgUVF1Kbv5 snakeoil"
184 export RAD_HOME=$PWD
185 ${lib.getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null || {
186 cat -n config.json
187 echo "Invalid config.json according to rad."
188 echo "Please double-check your services.radicle.settings (producing the config.json above),"
189 echo "some settings may be missing or have the wrong type."
190 exit 1
191 } >&2
192 '';
193 });
194 };
195 checkConfig =
196 lib.mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`"
197 // {
198 default = true;
199 };
200 settings = lib.mkOption {
201 description = ''
202 See <https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275>
203 '';
204 default = { };
205 example = lib.literalExpression ''
206 {
207 web.pinned.repositories = [
208 "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5" # heartwood
209 "rad:z3trNYnLWS11cJWC6BbxDs5niGo82" # rips
210 ];
211 }
212 '';
213 type = lib.types.submodule {
214 freeformType = json.type;
215 };
216 };
217 httpd = {
218 enable = lib.mkEnableOption "Radicle HTTP gateway to radicle-node";
219 package = lib.mkPackageOption pkgs "radicle-httpd" { };
220 listenAddress = lib.mkOption {
221 type = lib.types.str;
222 default = "127.0.0.1";
223 description = "The IP address on which `radicle-httpd` listens.";
224 };
225 listenPort = lib.mkOption {
226 type = lib.types.port;
227 default = 8080;
228 description = "The port on which `radicle-httpd` listens.";
229 };
230 aliases = lib.mkOption {
231 type = lib.types.attrsOf lib.types.str;
232 description = "Alias and RID pairs to shorten git clone commands for repositories.";
233 default = { };
234 example = lib.literalExpression ''
235 {
236 heartwood = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
237 }
238 '';
239 };
240 nginx = lib.mkOption {
241 # Type of a single virtual host, or null.
242 type = lib.types.nullOr (
243 lib.types.submodule (
244 lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
245 options.serverName = {
246 default = "radicle-${config.networking.hostName}.${config.networking.domain}";
247 defaultText = "radicle-\${config.networking.hostName}.\${config.networking.domain}";
248 };
249 }
250 )
251 );
252 default = null;
253 example = lib.literalExpression ''
254 {
255 serverAliases = [
256 "seed.''${config.networking.domain}"
257 ];
258 enableACME = false;
259 useACMEHost = config.networking.domain;
260 }
261 '';
262 description = ''
263 With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`.
264 Set to `{}` if you do not need any customization to the virtual host.
265 If enabled, then by default, the {option}`serverName` is
266 `radicle-''${config.networking.hostName}.''${config.networking.domain}`,
267 TLS is active, and certificates are acquired via ACME.
268 If this is set to null (the default), no nginx virtual host will be configured.
269 '';
270 };
271 extraArgs = lib.mkOption {
272 type = with lib.types; listOf str;
273 default = [ ];
274 description = "Extra arguments for `radicle-httpd`";
275 };
276 };
277 };
278 };
279
280 config = lib.mkIf cfg.enable (
281 lib.mkMerge [
282 {
283 systemd.services.radicle-node = lib.mkMerge [
284 (commonServiceConfig "radicle-node")
285 {
286 description = "Radicle Node";
287 documentation = [ "man:radicle-node(1)" ];
288 serviceConfig = {
289 ExecStart = "${lib.getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${lib.escapeShellArgs cfg.node.extraArgs}";
290 Restart = lib.mkDefault "on-failure";
291 RestartSec = "30";
292 SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
293 SystemCallFilter = lib.mkAfter [
294 # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone
295 "@timer"
296 ];
297 };
298 confinement.packages = [
299 cfg.package
300 ];
301 }
302 # Give only access to the private key to radicle-node.
303 {
304 serviceConfig =
305 let
306 keyCred = builtins.split ":" "${cfg.privateKeyFile}";
307 in
308 if lib.length keyCred > 1 then
309 {
310 LoadCredentialEncrypted = [ cfg.privateKeyFile ];
311 # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
312 BindReadOnlyPaths = [
313 "/run/credentials/radicle-node.service/${lib.head keyCred}:${env.RAD_HOME}/keys/radicle"
314 ];
315 }
316 else
317 {
318 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
319 BindReadOnlyPaths = [
320 "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle"
321 ];
322 };
323 }
324 ];
325
326 environment.systemPackages = [
327 rad-system
328 ];
329
330 networking.firewall = lib.mkIf cfg.node.openFirewall {
331 allowedTCPPorts = [ cfg.node.listenPort ];
332 };
333
334 users = {
335 users.radicle = {
336 description = "Radicle";
337 group = "radicle";
338 home = env.HOME;
339 isSystemUser = true;
340 };
341 groups.radicle = {
342 };
343 };
344 }
345
346 (lib.mkIf cfg.httpd.enable (
347 lib.mkMerge [
348 {
349 systemd.services.radicle-httpd = lib.mkMerge [
350 (commonServiceConfig "radicle-httpd")
351 {
352 description = "Radicle HTTP gateway to radicle-node";
353 documentation = [ "man:radicle-httpd(1)" ];
354 serviceConfig = {
355 ExecStart = lib.escapeShellArgs (
356 [
357 (lib.getExe' cfg.httpd.package "radicle-httpd")
358 "--listen=${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}"
359 ]
360 ++ lib.flatten (
361 lib.mapAttrsToList (alias: rid: [
362 "--alias"
363 alias
364 rid
365 ]) cfg.httpd.aliases
366 )
367 ++ cfg.httpd.extraArgs
368 );
369 Restart = lib.mkDefault "on-failure";
370 RestartSec = "10";
371 SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ];
372 SystemCallFilter = lib.mkAfter [
373 # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone
374 "@timer"
375 ];
376 };
377 confinement.packages = [
378 cfg.httpd.package
379 ];
380 }
381 ];
382 }
383
384 (lib.mkIf (cfg.httpd.nginx != null) {
385 services.nginx.virtualHosts.${cfg.httpd.nginx.serverName} = lib.mkMerge [
386 cfg.httpd.nginx
387 {
388 forceSSL = lib.mkDefault true;
389 enableACME = lib.mkDefault true;
390 locations."/" = {
391 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
392 recommendedProxySettings = true;
393 };
394 }
395 ];
396
397 services.radicle.settings = {
398 node.alias = lib.mkDefault cfg.httpd.nginx.serverName;
399 node.externalAddresses = lib.mkDefault [
400 "${cfg.httpd.nginx.serverName}:${toString cfg.node.listenPort}"
401 ];
402 };
403 })
404 ]
405 ))
406 ]
407 );
408
409 meta.maintainers = with lib.maintainers; [
410 julm
411 lorenzleutgeb
412 ];
413}