at master 14 kB view raw
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}