at 25.11-pre 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 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}