]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/misc/radicle.nix
mermet: knot: change dnssec-policy to ed25519
[sourcephile-nix.git] / nixos / modules / services / misc / radicle.nix
1 { config, lib, pkgs, ... }:
2 with lib;
3 let
4 cfg = config.services.radicle;
5
6 json = pkgs.formats.json { };
7
8 env = rec {
9 # rad fails if it cannot stat $HOME/.gitconfig
10 HOME = "/var/lib/radicle";
11 RAD_HOME = HOME;
12 };
13
14 # Convenient wrapper to run `rad` in the namespaces of `radicle-node.service`
15 rad-system = pkgs.writeShellScriptBin "rad-system" ''
16 set -o allexport
17 ${toShellVars env}
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"} "$@"
24 '';
25
26 commonServiceConfig = serviceName: {
27 environment = env // {
28 RUST_LOG = mkDefault "info";
29 };
30 path = [
31 pkgs.gitMinimal
32 ];
33 documentation = [
34 "https://docs.radicle.xyz/guides/seeder"
35 ];
36 after = [
37 "network.target"
38 "network-online.target"
39 ];
40 requires = [
41 "network-online.target"
42 ];
43 wantedBy = [ "multi-user.target" ];
44 serviceConfig = mkMerge [
45 {
46 BindReadOnlyPaths = [
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"
49 ];
50 KillMode = "process";
51 StateDirectory = [ "radicle" ];
52 User = config.users.users.radicle.name;
53 Group = config.users.groups.radicle.name;
54 WorkingDirectory = env.HOME;
55 }
56 # The following options are only for optimizing:
57 # systemd-analyze security ${serviceName}
58 {
59 BindReadOnlyPaths = [
60 "-/etc/resolv.conf"
61 "/etc/ssl/certs/ca-certificates.crt"
62 "/run/systemd"
63 ];
64 AmbientCapabilities = "";
65 CapabilityBoundingSet = "";
66 DeviceAllow = ""; # ProtectClock= adds DeviceAllow=char-rtc r
67 LockPersonality = true;
68 MemoryDenyWriteExecute = true;
69 NoNewPrivileges = true;
70 PrivateTmp = true;
71 ProcSubset = "pid";
72 ProtectClock = true;
73 ProtectHome = true;
74 ProtectHostname = true;
75 ProtectKernelLogs = true;
76 ProtectProc = "invisible";
77 ProtectSystem = "strict";
78 RemoveIPC = true;
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";
86 SystemCallFilter = [
87 "@system-service"
88 "~@aio"
89 "~@chown"
90 "~@keyring"
91 "~@memlock"
92 "~@privileged"
93 "~@resources"
94 "~@setuid"
95 "~@timer"
96 ];
97 SystemCallArchitectures = "native";
98 # This is for BindPaths= and BindReadOnlyPaths=
99 # to allow traversal of directories they create inside RootDirectory=
100 UMask = "0066";
101 }
102 ];
103 confinement = {
104 enable = true;
105 mode = "full-apivfs";
106 packages = [
107 pkgs.gitMinimal
108 cfg.package
109 pkgs.iana-etc
110 (getLib pkgs.nss)
111 pkgs.tzdata
112 ];
113 };
114 };
115 in
116 {
117 options = {
118 services.radicle = {
119 enable = mkEnableOption "Radicle Seed Node";
120 package = mkPackageOption pkgs "radicle-node" { };
121 privateKeyFile = mkOption {
122 type = with types; either path str;
123 description = ''
124 SSH private key generated by `rad auth`.
125
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`.
129 '';
130 };
131 publicKeyFile = mkOption {
132 type = with types; either path str;
133 description = ''
134 SSH public key generated by `rad auth`.
135 '';
136 };
137 node = {
138 listenAddress = mkOption {
139 type = types.str;
140 default = "0.0.0.0";
141 example = "127.0.0.1";
142 description = "The IP address on which `radicle-node` listens.";
143 };
144 listenPort = mkOption {
145 type = types.port;
146 default = 8776;
147 description = "The port on which `radicle-node` listens.";
148 };
149 openFirewall = mkEnableOption "opening the firewall for `radicle-node`";
150 extraArgs = mkOption {
151 type = with types; listOf str;
152 default = [ ];
153 description = "Extra arguments for `radicle-node`";
154 };
155 };
156 configFile = mkOption {
157 type = types.package;
158 internal = true;
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"
166 export RAD_HOME=$PWD
167 ${getExe' pkgs.buildPackages.radicle-node "rad"} config >/dev/null || {
168 cat -n config.json
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."
172 exit 1
173 } >&2
174 '';
175 });
176 };
177 checkConfig = mkEnableOption "checking the {file}`config.json` file resulting from {option}`services.radicle.settings`" // { default = true; };
178 settings = mkOption {
179 description = ''
180 See https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/radicle/src/node/config.rs#L275
181 '';
182 default = { };
183 type = types.submodule {
184 freeformType = json.type;
185 };
186 };
187 httpd = {
188 enable = mkEnableOption "Radicle HTTP gateway to radicle-node";
189 package = mkPackageOption pkgs "radicle-httpd" { };
190 listenAddress = mkOption {
191 type = types.str;
192 default = "127.0.0.1";
193 description = "The IP address on which `radicle-httpd` listens.";
194 };
195 listenPort = mkOption {
196 type = types.port;
197 default = 8080;
198 description = "The port on which `radicle-httpd` listens.";
199 };
200 nginx = mkOption {
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}";
207 };
208 }
209 ));
210 default = null;
211 example = literalExpression ''
212 {
213 serverAliases = [
214 "seed.''${config.networking.domain}"
215 ];
216 enableACME = false;
217 useACMEHost = config.networking.domain;
218 }
219 '';
220 description = ''
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.
227 '';
228 };
229 extraArgs = mkOption {
230 type = with types; listOf str;
231 default = [ ];
232 description = "Extra arguments for `radicle-httpd`";
233 };
234 };
235 };
236 };
237
238 config = mkIf cfg.enable (mkMerge [
239 {
240 systemd.services.radicle-node = mkMerge [
241 (commonServiceConfig "radicle-node")
242 {
243 description = "Radicle Node";
244 documentation = [ "man:radicle-node(1)" ];
245 serviceConfig = {
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";
248 RestartSec = "30";
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
252 "@timer"
253 ];
254 };
255 confinement.packages = [
256 cfg.package
257 ];
258 }
259 # Give only access to the private key to radicle-node.
260 {
261 serviceConfig =
262 let keyCred = builtins.split ":" "${cfg.privateKeyFile}"; in
263 if length keyCred > 1
264 then {
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" ];
268 }
269 else {
270 LoadCredential = [ "radicle:${cfg.privateKeyFile}" ];
271 BindReadOnlyPaths = [ "/run/credentials/radicle-node.service/radicle:${env.RAD_HOME}/keys/radicle" ];
272 };
273 }
274 ];
275
276 environment.systemPackages = [
277 rad-system
278 ];
279
280 networking.firewall = mkIf cfg.node.openFirewall {
281 allowedTCPPorts = [ cfg.node.listenPort ];
282 };
283
284 users = {
285 users.radicle = {
286 isSystemUser = true;
287 group = "radicle";
288 description = "Radicle";
289 home = env.HOME;
290 };
291 groups.radicle = {
292 };
293 };
294 }
295
296 (mkIf cfg.httpd.enable (mkMerge [
297 {
298 systemd.services.radicle-httpd = mkMerge [
299 (commonServiceConfig "radicle-httpd")
300 {
301 description = "Radicle HTTP gateway to radicle-node";
302 documentation = [ "man:radicle-httpd(1)" ];
303 serviceConfig = {
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";
306 RestartSec = "10";
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
310 "@timer"
311 ];
312 };
313 confinement.packages = [
314 cfg.httpd.package
315 ];
316 }
317 ];
318 }
319
320 (mkIf (cfg.httpd.nginx != null) {
321 services.nginx.virtualHosts.${cfg.httpd.nginx.serverName} = lib.mkMerge [
322 cfg.httpd.nginx
323 {
324 forceSSL = mkDefault true;
325 enableACME = mkDefault true;
326 locations."/" = {
327 proxyPass = "http://${cfg.httpd.listenAddress}:${toString cfg.httpd.listenPort}";
328 recommendedProxySettings = true;
329 };
330 }
331 ];
332
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}"
337 ];
338 };
339 })
340 ]))
341 ]);
342
343 meta.maintainers = with lib.maintainers; [
344 julm
345 lorenzleutgeb
346 ];
347 }