at 23.11-pre 14 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" "~@resources" "~@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 } cfg.nginx.virtualHost ]; 226 }; 227 228 services.postgresql = mkIf cfg.postgresql.enable { 229 authentication = '' 230 local ${srvCfg.postgresql.database} ${srvCfg.user} trust 231 ''; 232 ensureDatabases = [ srvCfg.postgresql.database ]; 233 ensureUsers = map (name: { 234 inherit name; 235 ensurePermissions = { "DATABASE \"${srvCfg.postgresql.database}\"" = "ALL PRIVILEGES"; }; 236 }) [srvCfg.user]; 237 }; 238 239 services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable) 240 [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]); 241 242 services.sourcehut.settings = mkMerge [ 243 { 244 "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}"; 245 } 246 247 (mkIf cfg.postgresql.enable { 248 "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql"; 249 }) 250 ]; 251 252 services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable { 253 enable = true; 254 databases = 3; 255 syslog = true; 256 # TODO: set a more informed value 257 save = mkDefault [ [1800 10] [300 100] ]; 258 settings = { 259 # TODO: set a more informed value 260 maxmemory = "128MB"; 261 maxmemory-policy = "volatile-ttl"; 262 }; 263 }; 264 265 systemd.services = mkMerge [ 266 { 267 "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [ 268 { 269 description = "sourcehut ${srv}.sr.ht website service"; 270 before = optional cfg.nginx.enable "nginx.service"; 271 wants = optional cfg.nginx.enable "nginx.service"; 272 wantedBy = [ "multi-user.target" ]; 273 path = optional cfg.postgresql.enable postgresql.package; 274 # Beware: change in credentials' content will not trigger restart. 275 restartTriggers = [ configIni ]; 276 serviceConfig = { 277 Type = "simple"; 278 Restart = mkDefault "always"; 279 #RestartSec = mkDefault "2min"; 280 StateDirectory = [ "sourcehut/${srvsrht}" ]; 281 StateDirectoryMode = "2750"; 282 ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs; 283 }; 284 preStart = let 285 version = pkgs.sourcehut.${srvsrht}.version; 286 stateDir = "/var/lib/sourcehut/${srvsrht}"; 287 in mkBefore '' 288 set -x 289 # Use the /run/sourcehut/${srvsrht}/config.ini 290 # installed by a previous ExecStartPre= in baseService 291 cd /run/sourcehut/${srvsrht} 292 293 if test ! -e ${stateDir}/db; then 294 # Setup the initial database. 295 # Note that it stamps the alembic head afterward 296 ${cfg.python}/bin/${srvsrht}-initdb 297 echo ${version} >${stateDir}/db 298 fi 299 300 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade '' 301 if [ "$(cat ${stateDir}/db)" != "${version}" ]; then 302 # Manage schema migrations using alembic 303 ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head 304 echo ${version} >${stateDir}/db 305 fi 306 ''} 307 308 # Update copy of each users' profile to the latest 309 # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain> 310 if test ! -e ${stateDir}/webhook; then 311 # Update ${iniKey}'s users' profile copy to the latest 312 ${cfg.python}/bin/srht-update-profiles ${iniKey} 313 touch ${stateDir}/webhook 314 fi 315 ''; 316 } mainService ]); 317 } 318 319 (mkIf webhooks { 320 "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {} 321 { 322 description = "sourcehut ${srv}.sr.ht webhooks service"; 323 after = [ "${srvsrht}.service" ]; 324 wantedBy = [ "${srvsrht}.service" ]; 325 partOf = [ "${srvsrht}.service" ]; 326 preStart = '' 327 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \ 328 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py 329 ''; 330 serviceConfig = { 331 Type = "simple"; 332 Restart = "always"; 333 ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs; 334 # Avoid crashing: os.getloadavg() 335 ProcSubset = mkForce "all"; 336 }; 337 }; 338 }) 339 340 (mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [ 341 { 342 description = "sourcehut ${timerName} service"; 343 after = [ "network.target" "${srvsrht}.service" ]; 344 serviceConfig = { 345 Type = "oneshot"; 346 ExecStart = "${cfg.python}/bin/${timerName}"; 347 }; 348 } 349 (timer.service or {}) 350 ]))) extraTimers) 351 352 (mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [ 353 { 354 description = "sourcehut ${serviceName} service"; 355 # So that extraServices have the PostgreSQL database initialized. 356 after = [ "${srvsrht}.service" ]; 357 wantedBy = [ "${srvsrht}.service" ]; 358 partOf = [ "${srvsrht}.service" ]; 359 serviceConfig = { 360 Type = "simple"; 361 Restart = mkDefault "always"; 362 }; 363 } 364 extraService 365 ])) extraServices) 366 ]; 367 368 systemd.timers = mapAttrs (timerName: timer: 369 { 370 description = "sourcehut timer for ${timerName}"; 371 wantedBy = [ "timers.target" ]; 372 inherit (timer) timerConfig; 373 }) extraTimers; 374 } ]); 375}