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