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 nginx = lib.mkOption {
231 # Type of a single virtual host, or null.
232 type = lib.types.nullOr (
233 lib.types.submodule (
234 lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {
235 options.serverName = {
236 default = "radicle-${config.networking.hostName}.${config.networking.domain}";
237 defaultText = "radicle-\${config.networking.hostName}.\${config.networking.domain}";
238 };
239 }
240 )
241 );
242 default = null;
243 example = lib.literalExpression ''
244 {
245 serverAliases = [
246 "seed.''${config.networking.domain}"
247 ];
248 enableACME = false;
249 useACMEHost = config.networking.domain;
250 }
251 '';
252 description = ''
253 With this option, you can customize an nginx virtual host which already has sensible defaults for `radicle-httpd`.
254 Set to `{}` if you do not need any customization to the virtual host.
255 If enabled, then by default, the {option}`serverName` is
256 `radicle-''${config.networking.hostName}.''${config.networking.domain}`,
257 TLS is active, and certificates are acquired via ACME.
258 If this is set to null (the default), no nginx virtual host will be configured.
259 '';
260 };
261 extraArgs = lib.mkOption {
262 type = with lib.types; listOf str;
263 default = [ ];
264 description = "Extra arguments for `radicle-httpd`";
265 };
266 };
267 };
268 };
269
270 config = lib.mkIf cfg.enable (
271 lib.mkMerge [
272 {
273 systemd.services.radicle-node = lib.mkMerge [
274 (commonServiceConfig "radicle-node")
275 {
276 description = "Radicle Node";
277 documentation = [ "man:radicle-node(1)" ];
278 serviceConfig = {
279 ExecStart = "${lib.getExe' cfg.package "radicle-node"} --force --listen ${cfg.node.listenAddress}:${toString cfg.node.listenPort} ${lib.escapeShellArgs cfg.node.extraArgs}";
280 Restart = lib.mkDefault "on-failure";
281 RestartSec = "30";
282 SocketBindAllow = [ "tcp:${toString cfg.node.listenPort}" ];
283 SystemCallFilter = lib.mkAfter [
284 # Needed by git upload-pack which calls alarm() and setitimer() when providing a rad clone
285 "@timer"
286 ];
287 };
288 confinement.packages = [
289 cfg.package
290 ];
291 }
292 # Give only access to the private key to radicle-node.
293 {
294 serviceConfig =
295 let
296 keyCred = builtins.split ":" "${cfg.privateKeyFile}";
297 in
298 if lib.length keyCred > 1 then
299 {
300 LoadCredentialEncrypted = [ cfg.privateKeyFile ];
301 # Note that neither %d nor ${CREDENTIALS_DIRECTORY} works in BindReadOnlyPaths=
302 BindReadOnlyPaths = [
303 "/run/credentials/radicle-node.service/${lib.head keyCred}:${env.RAD_HOME}/keys/radicle"
304 ];
305 }
306 else
307 {
308 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
309 BindReadOnlyPaths = [
310 "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle"
311 ];
312 };
313 }
314 ];
315
316 environment.systemPackages = [
317 rad-system
318 ];
319
320 networking.firewall = lib.mkIf cfg.node.openFirewall {
321 allowedTCPPorts = [ cfg.node.listenPort ];
322 };
323
324 users = {
325 users.radicle = {
326 description = "Radicle";
327 group = "radicle";
328 home = env.HOME;
329 isSystemUser = true;
330 };
331 groups.radicle = {
332 };
333 };
334 }
335
336 (lib.mkIf cfg.httpd.enable (
337 lib.mkMerge [
338 {
339 systemd.services.radicle-httpd = lib.mkMerge [
340 (commonServiceConfig "radicle-httpd")
341 {
342 description = "Radicle HTTP gateway to radicle-node";
343 documentation = [ "man:radicle-httpd(1)" ];
344 serviceConfig = {
345 ExecStart = "${lib.getExe' cfg.httpd.package "radicle-httpd"} --listen ${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort} ${lib.escapeShellArgs cfg.httpd.extraArgs}";
346 Restart = lib.mkDefault "on-failure";
347 RestartSec = "10";
348 SocketBindAllow = [ "tcp:${toString cfg.httpd.listenPort}" ];
349 SystemCallFilter = lib.mkAfter [
350 # Needed by git upload-pack which calls alarm() and setitimer() when providing a git clone
351 "@timer"
352 ];
353 };
354 confinement.packages = [
355 cfg.httpd.package
356 ];
357 }
358 ];
359 }
360
361 (lib.mkIf (cfg.httpd.nginx != null) {
362 services.nginx.virtualHosts.${cfg.httpd.nginx.serverName} = lib.mkMerge [
363 cfg.httpd.nginx
364 {
365 forceSSL = lib.mkDefault true;
366 enableACME = lib.mkDefault true;
367 locations."/" = {
368 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
369 recommendedProxySettings = true;
370 };
371 }
372 ];
373
374 services.radicle.settings = {
375 node.alias = lib.mkDefault cfg.httpd.nginx.serverName;
376 node.externalAddresses = lib.mkDefault [
377 "${cfg.httpd.nginx.serverName}:${toString cfg.node.listenPort}"
378 ];
379 };
380 })
381 ]
382 ))
383 ]
384 );
385
386 meta.maintainers = with lib.maintainers; [
387 julm
388 lorenzleutgeb
389 ];
390}