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