at 25.11-pre 18 kB view raw
1srv: 2{ 3 configIniOfService, 4 srvsrht ? "${srv}srht", # Because "buildsrht" does not follow that pattern (missing an "s"). 5 iniKey ? "${srv}.sr.ht", 6 webhooks ? false, 7 extraTimers ? { }, 8 mainService ? { }, 9 extraServices ? { }, 10 extraConfig ? { }, 11 port, 12}: 13{ 14 config, 15 lib, 16 pkgs, 17 ... 18}: 19 20let 21 inherit (lib) types; 22 inherit (lib.attrsets) mapAttrs optionalAttrs; 23 inherit (lib.lists) optional; 24 inherit (lib.modules) 25 mkBefore 26 mkDefault 27 mkForce 28 mkIf 29 mkMerge 30 ; 31 inherit (lib.options) mkEnableOption mkOption; 32 inherit (lib.strings) concatStringsSep hasSuffix optionalString; 33 inherit (config.services) postgresql; 34 redis = config.services.redis.servers."sourcehut-${srvsrht}"; 35 inherit (config.users) users; 36 cfg = config.services.sourcehut; 37 configIni = configIniOfService srv; 38 srvCfg = cfg.${srv}; 39 baseService = 40 serviceName: 41 { 42 allowStripe ? false, 43 }: 44 extraService: 45 let 46 runDir = "/run/sourcehut/${serviceName}"; 47 rootDir = "/run/sourcehut/chroots/${serviceName}"; 48 in 49 mkMerge [ 50 extraService 51 { 52 after = 53 [ "network.target" ] 54 ++ optional cfg.postgresql.enable "postgresql.service" 55 ++ optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service"; 56 requires = 57 optional cfg.postgresql.enable "postgresql.service" 58 ++ optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service"; 59 path = [ pkgs.gawk ]; 60 environment.HOME = runDir; 61 serviceConfig = { 62 User = mkDefault srvCfg.user; 63 Group = mkDefault srvCfg.group; 64 RuntimeDirectory = [ 65 "sourcehut/${serviceName}" 66 # Used by *srht-keys which reads ../config.ini 67 "sourcehut/${serviceName}/subdir" 68 "sourcehut/chroots/${serviceName}" 69 ]; 70 RuntimeDirectoryMode = "2750"; 71 # No need for the chroot path once inside the chroot 72 InaccessiblePaths = [ "-+${rootDir}" ]; 73 # g+rx is for group members (eg. fcgiwrap or nginx) 74 # to read Git/Mercurial repositories, buildlogs, etc. 75 # o+x is for intermediate directories created by BindPaths= and like, 76 # as they're owned by root:root. 77 UMask = "0026"; 78 RootDirectory = rootDir; 79 RootDirectoryStartOnly = true; 80 PrivateTmp = true; 81 MountAPIVFS = true; 82 # config.ini is looked up in there, before /etc/srht/config.ini 83 # Note that it fails to be set in ExecStartPre= 84 WorkingDirectory = mkDefault ("-" + runDir); 85 BindReadOnlyPaths = 86 [ 87 builtins.storeDir 88 "/etc" 89 "/run/booted-system" 90 "/run/current-system" 91 "/run/systemd" 92 ] 93 ++ optional cfg.postgresql.enable "/run/postgresql" 94 ++ optional cfg.redis.enable "/run/redis-sourcehut-${srvsrht}"; 95 # LoadCredential= are unfortunately not available in ExecStartPre= 96 # Hence this one is run as root (the +) with RootDirectoryStartOnly= 97 # to reach credentials wherever they are. 98 # Note that each systemd service gets its own ${runDir}/config.ini file. 99 ExecStartPre = mkBefore [ 100 ( 101 "+" 102 + pkgs.writeShellScript "${serviceName}-credentials" '' 103 set -x 104 # Replace values beginning with a '<' by the content of the file whose name is after. 105 gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} | 106 ${optionalString (!allowStripe) "gawk '!/^stripe-secret-key=/' |"} 107 install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini 108 '' 109 ) 110 ]; 111 # The following options are only for optimizing: 112 # systemd-analyze security 113 AmbientCapabilities = ""; 114 CapabilityBoundingSet = ""; 115 # ProtectClock= adds DeviceAllow=char-rtc r 116 DeviceAllow = ""; 117 LockPersonality = true; 118 MemoryDenyWriteExecute = true; 119 NoNewPrivileges = true; 120 PrivateDevices = true; 121 PrivateMounts = true; 122 PrivateNetwork = mkDefault false; 123 PrivateUsers = true; 124 ProcSubset = "pid"; 125 ProtectClock = true; 126 ProtectControlGroups = true; 127 ProtectHome = true; 128 ProtectHostname = true; 129 ProtectKernelLogs = true; 130 ProtectKernelModules = true; 131 ProtectKernelTunables = true; 132 ProtectProc = "invisible"; 133 ProtectSystem = "strict"; 134 RemoveIPC = true; 135 RestrictAddressFamilies = [ 136 "AF_UNIX" 137 "AF_INET" 138 "AF_INET6" 139 ]; 140 RestrictNamespaces = true; 141 RestrictRealtime = true; 142 RestrictSUIDSGID = true; 143 #SocketBindAllow = [ "tcp:${toString srvCfg.port}" "tcp:${toString srvCfg.prometheusPort}" ]; 144 #SocketBindDeny = "any"; 145 SystemCallFilter = [ 146 "@system-service" 147 "~@aio" 148 "~@keyring" 149 "~@memlock" 150 "~@privileged" 151 "~@timer" 152 "@chown" 153 "@setuid" 154 ]; 155 SystemCallArchitectures = "native"; 156 }; 157 } 158 ]; 159in 160{ 161 options.services.sourcehut.${srv} = 162 { 163 enable = mkEnableOption "${srv} service"; 164 165 user = mkOption { 166 type = types.str; 167 default = srvsrht; 168 description = '' 169 User for ${srv}.sr.ht. 170 ''; 171 }; 172 173 group = mkOption { 174 type = types.str; 175 default = srvsrht; 176 description = '' 177 Group for ${srv}.sr.ht. 178 Membership grants access to the Git/Mercurial repositories by default, 179 but not to the config.ini file (where secrets are). 180 ''; 181 }; 182 183 port = mkOption { 184 type = types.port; 185 default = port; 186 description = '' 187 Port on which the "${srv}" backend should listen. 188 ''; 189 }; 190 191 redis = { 192 host = mkOption { 193 type = types.str; 194 default = "unix:///run/redis-sourcehut-${srvsrht}/redis.sock?db=0"; 195 example = "redis://shared.wireguard:6379/0"; 196 description = '' 197 The redis host URL. This is used for caching and temporary storage, and must 198 be shared between nodes (e.g. git1.sr.ht and git2.sr.ht), but need not be 199 shared between services. It may be shared between services, however, with no 200 ill effect, if this better suits your infrastructure. 201 ''; 202 }; 203 }; 204 205 postgresql = { 206 database = mkOption { 207 type = types.str; 208 default = "${srv}.sr.ht"; 209 description = '' 210 PostgreSQL database name for the ${srv}.sr.ht service, 211 used if [](#opt-services.sourcehut.postgresql.enable) is `true`. 212 ''; 213 }; 214 }; 215 216 gunicorn = { 217 extraArgs = mkOption { 218 type = with types; listOf str; 219 default = [ 220 "--timeout 120" 221 "--workers 1" 222 "--log-level=info" 223 ]; 224 description = "Extra arguments passed to Gunicorn."; 225 }; 226 }; 227 } 228 // optionalAttrs webhooks { 229 webhooks = { 230 extraArgs = mkOption { 231 type = with types; listOf str; 232 default = [ 233 "--loglevel DEBUG" 234 "--pool eventlet" 235 "--without-heartbeat" 236 ]; 237 description = "Extra arguments passed to the Celery responsible for webhooks."; 238 }; 239 celeryConfig = mkOption { 240 type = types.lines; 241 default = ""; 242 description = "Content of the `celeryconfig.py` used by the Celery responsible for webhooks."; 243 }; 244 }; 245 }; 246 247 config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [ 248 extraConfig 249 { 250 users = { 251 users = { 252 "${srvCfg.user}" = { 253 isSystemUser = true; 254 group = mkDefault srvCfg.group; 255 description = mkDefault "sourcehut user for ${srv}.sr.ht"; 256 }; 257 }; 258 groups = 259 { 260 "${srvCfg.group}" = { }; 261 } 262 // 263 optionalAttrs 264 (cfg.postgresql.enable && hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")) 265 { 266 "postgres".members = [ srvCfg.user ]; 267 } 268 // optionalAttrs (cfg.redis.enable && hasSuffix "0" (redis.settings.unixsocketperm or "")) { 269 "redis-sourcehut-${srvsrht}".members = [ srvCfg.user ]; 270 }; 271 }; 272 273 services.nginx = mkIf cfg.nginx.enable { 274 virtualHosts."${srv}.${cfg.settings."sr.ht".global-domain}" = mkMerge [ 275 { 276 forceSSL = mkDefault true; 277 locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}"; 278 locations."/static" = { 279 root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}"; 280 extraConfig = mkDefault '' 281 expires 30d; 282 ''; 283 }; 284 locations."/query" = mkIf (cfg.settings.${iniKey} ? api-origin) { 285 proxyPass = cfg.settings.${iniKey}.api-origin; 286 extraConfig = '' 287 add_header 'Access-Control-Allow-Origin' '*'; 288 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 289 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; 290 291 if ($request_method = 'OPTIONS') { 292 add_header 'Access-Control-Max-Age' 1728000; 293 add_header 'Content-Type' 'text/plain; charset=utf-8'; 294 add_header 'Content-Length' 0; 295 return 204; 296 } 297 298 add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; 299 ''; 300 }; 301 } 302 cfg.nginx.virtualHost 303 ]; 304 }; 305 306 services.postgresql = mkIf cfg.postgresql.enable { 307 authentication = '' 308 local ${srvCfg.postgresql.database} ${srvCfg.user} trust 309 ''; 310 ensureDatabases = [ srvCfg.postgresql.database ]; 311 ensureUsers = map (name: { 312 inherit name; 313 # We don't use it because we have a special default database name with dots. 314 # TODO(for maintainers of sourcehut): migrate away from custom preStart script. 315 ensureDBOwnership = false; 316 }) [ srvCfg.user ]; 317 }; 318 319 services.sourcehut.settings = mkMerge [ 320 { 321 "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}"; 322 } 323 324 (mkIf cfg.postgresql.enable { 325 "${srv}.sr.ht".connection-string = 326 mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql"; 327 }) 328 ]; 329 330 services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable { 331 enable = true; 332 databases = 3; 333 syslog = true; 334 # TODO: set a more informed value 335 save = mkDefault [ 336 [ 337 1800 338 10 339 ] 340 [ 341 300 342 100 343 ] 344 ]; 345 settings = { 346 # TODO: set a more informed value 347 maxmemory = "128MB"; 348 maxmemory-policy = "volatile-ttl"; 349 }; 350 }; 351 352 systemd.services = mkMerge [ 353 { 354 "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [ 355 { 356 description = "sourcehut ${srv}.sr.ht website service"; 357 before = optional cfg.nginx.enable "nginx.service"; 358 wants = optional cfg.nginx.enable "nginx.service"; 359 wantedBy = [ "multi-user.target" ]; 360 path = optional cfg.postgresql.enable postgresql.package; 361 # Beware: change in credentials' content will not trigger restart. 362 restartTriggers = [ configIni ]; 363 serviceConfig = { 364 Type = "simple"; 365 Restart = mkDefault "always"; 366 #RestartSec = mkDefault "2min"; 367 StateDirectory = [ "sourcehut/${srvsrht}" ]; 368 StateDirectoryMode = "2750"; 369 ExecStart = 370 "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " 371 + concatStringsSep " " srvCfg.gunicorn.extraArgs; 372 }; 373 preStart = 374 let 375 package = pkgs.sourcehut.${srvsrht}; 376 version = package.version; 377 stateDir = "/var/lib/sourcehut/${srvsrht}"; 378 in 379 mkBefore '' 380 set -x 381 # Use the /run/sourcehut/${srvsrht}/config.ini 382 # installed by a previous ExecStartPre= in baseService 383 cd /run/sourcehut/${srvsrht} 384 385 if test ! -e ${stateDir}/db; then 386 # Setup the initial database. 387 # Note that it stamps the alembic head afterward 388 ${package}/bin/${srvsrht}-initdb 389 echo ${version} >${stateDir}/db 390 fi 391 392 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade '' 393 if [ "$(cat ${stateDir}/db)" != "${version}" ]; then 394 # Manage schema migrations using alembic 395 ${package}/bin/${srvsrht}-migrate -a upgrade head 396 echo ${version} >${stateDir}/db 397 fi 398 ''} 399 400 # Update copy of each users' profile to the latest 401 # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain> 402 if test ! -e ${stateDir}/webhook; then 403 # Update ${iniKey}'s users' profile copy to the latest 404 ${cfg.python}/bin/srht-update-profiles ${iniKey} 405 touch ${stateDir}/webhook 406 fi 407 ''; 408 } 409 mainService 410 ]); 411 } 412 413 (mkIf webhooks { 414 "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" { } { 415 description = "sourcehut ${srv}.sr.ht webhooks service"; 416 after = [ "${srvsrht}.service" ]; 417 wantedBy = [ "${srvsrht}.service" ]; 418 partOf = [ "${srvsrht}.service" ]; 419 preStart = '' 420 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \ 421 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py 422 ''; 423 serviceConfig = { 424 Type = "simple"; 425 Restart = "always"; 426 ExecStart = 427 "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " 428 + concatStringsSep " " srvCfg.webhooks.extraArgs; 429 # Avoid crashing: os.getloadavg() 430 ProcSubset = mkForce "all"; 431 }; 432 }; 433 }) 434 435 (mapAttrs ( 436 timerName: timer: 437 (baseService timerName { } (mkMerge [ 438 { 439 description = "sourcehut ${timerName} service"; 440 after = [ 441 "network.target" 442 "${srvsrht}.service" 443 ]; 444 serviceConfig = { 445 Type = "oneshot"; 446 ExecStart = "${pkgs.sourcehut.${srvsrht}}/bin/${timerName}"; 447 }; 448 } 449 (timer.service or { }) 450 ])) 451 ) extraTimers) 452 453 (mapAttrs ( 454 serviceName: extraService: 455 baseService serviceName { } (mkMerge [ 456 { 457 description = "sourcehut ${serviceName} service"; 458 # So that extraServices have the PostgreSQL database initialized. 459 after = [ "${srvsrht}.service" ]; 460 wantedBy = [ "${srvsrht}.service" ]; 461 partOf = [ "${srvsrht}.service" ]; 462 serviceConfig = { 463 Type = "simple"; 464 Restart = mkDefault "always"; 465 }; 466 } 467 extraService 468 ]) 469 ) extraServices) 470 471 # Work around 'pq: permission denied for schema public' with postgres v15. 472 # See https://github.com/NixOS/nixpkgs/issues/216989 473 # Workaround taken from nixos/forgejo: https://github.com/NixOS/nixpkgs/pull/262741 474 # TODO(to maintainers of sourcehut): please migrate away from this workaround 475 # by migrating away from database name defaults with dots. 476 (lib.mkIf 477 ( 478 cfg.postgresql.enable 479 && lib.strings.versionAtLeast config.services.postgresql.package.version "15.0" 480 ) 481 { 482 postgresql.postStart = ( 483 lib.mkAfter '' 484 $PSQL -tAc 'ALTER DATABASE "${srvCfg.postgresql.database}" OWNER TO "${srvCfg.user}";' 485 '' 486 ); 487 } 488 ) 489 ]; 490 491 systemd.timers = mapAttrs (timerName: timer: { 492 description = "sourcehut timer for ${timerName}"; 493 wantedBy = [ "timers.target" ]; 494 inherit (timer) timerConfig; 495 }) extraTimers; 496 } 497 ]); 498}