srv: { configIniOfService , srvsrht ? "${srv}srht" # Because "buildsrht" does not follow that pattern (missing an "s"). , iniKey ? "${srv}.sr.ht" , webhooks ? null , redisDatabase ? null , extraTimers ? {} , commonService ? {} , mainService ? {} , extraServices ? {} , extraConfig ? {} , port }: { config, lib, pkgs, ... }: with lib; let inherit (config.services) postgresql redis; inherit (config.users) users; cfg = config.services.sourcehut; configIni = configIniOfService srv; domain = cfg.settings."sr.ht".global-domain; srvCfg = cfg.${srv}; baseService = serviceName: { noStripe ? true }: extraService: let runDir = "/run/sourcehut/${serviceName}"; rootDir = "/run/sourcehut/chroots/${serviceName}"; in mkMerge [ commonService extraService { path = [ pkgs.gawk ]; environment.HOME = runDir; serviceConfig = { RuntimeDirectory = [ "sourcehut/${serviceName}" "sourcehut/chroots/${serviceName}" ]; RuntimeDirectoryMode = "2750"; # No need for the chroot path once inside the chroot InaccessiblePaths = [ "-+${rootDir}" ]; # For intermediate directories created by BindPaths= and others UMask = "0066"; RootDirectory = rootDir; RootDirectoryStartOnly = true; PrivateTmp = true; MountAPIVFS = true; # config.ini is looked up in there, before /etc/srht/config.ini # Note that it fails to be set in ExecStartPre= WorkingDirectory = "-"+runDir; BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run/booted-system" "/run/current-system" "/run/systemd" "/run/wrappers" ] ++ optional cfg.redis.enable "/run/redis" ++ optional cfg.postgresql.enable "/run/postgresql"; # LoadCredential= are unfortunately not available in ExecStartPre= # Hence this one is run as root (the +) with RootDirectoryStartOnly= # to reach credentials wherever they are. # Note that each systemd service gets its own ${runDir}/config.ini file. ExecStartPre = mkBefore [("+"+pkgs.writeShellScript "${serviceName}-credentials" '' set -x # Replace values begining with a '<' by the content of the file whose name is after. gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} | ${optionalString noStripe "gawk '!/^stripe-secret-key=/' |"} install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini '')]; LogsDirectory = [ "sourcehut/${serviceName}" ]; # The following options are only for optimizing: # systemd-analyze security AmbientCapabilities = ""; CapabilityBoundingSet = ""; # ProtectClock= adds DeviceAllow=char-rtc r DeviceAllow = ""; LockPersonality = true; MemoryDenyWriteExecute = true; NoNewPrivileges = true; PrivateDevices = true; PrivateMounts = true; PrivateNetwork = mkDefault false; PrivateUsers = true; ProcSubset = "pid"; ProtectClock = true; ProtectControlGroups = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectProc = "invisible"; ProtectSystem = "strict"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; #SocketBindAllow = [ "tcp:${toString srvCfg.port}" "tcp:${toString srvCfg.prometheusPort}" ]; #SocketBindDeny = "any"; SystemCallFilter = [ "@system-service" "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@timer" "@chown" "@setuid" ]; SystemCallArchitectures = "native"; }; } ]; in { options.services.sourcehut.${srv} = { enable = mkEnableOption "${srv} service"; user = mkOption { type = types.str; default = srvsrht; description = '' User for ${srv}.sr.ht. ''; }; port = mkOption { type = types.port; default = port; description = '' Port on which the "${srv}" module should listen. ''; }; database = mkOption { type = types.str; default = "${srv}.sr.ht"; description = '' PostgreSQL database name for ${srv}.sr.ht. ''; }; }; config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [ extraConfig { users = { users = { "${srvCfg.user}" = { isSystemUser = true; group = mkDefault srvCfg.user; description = mkDefault "sourcehut user for ${srv}.sr.ht"; }; }; groups = { "${srvCfg.user}" = { }; } // optionalAttrs (cfg.postgresql.enable && hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")) { "postgres".members = [ srvCfg.user ]; }; }; services.nginx.virtualHosts = mkIf cfg.nginx.enable { "${srv}.${domain}" = { forceSSL = true; locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}"; locations."/query".proxyPass = cfg.settings."meta.sr.ht".api-origin; locations."/static".root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}"; }; }; services.postgresql = mkIf cfg.postgresql.enable { authentication = '' local ${srvCfg.database} ${srvCfg.user} trust '' # shrt-keys stores SSH keys in the PostgreSQL database of the service calling it + optionalString (elem srv ["builds" "git" "hg"]) '' local ${srvCfg.database} ${users."sshsrht".name} trust ''; ensureDatabases = [ srvCfg.database ]; ensureUsers = map (name: { inherit name; ensurePermissions = { "DATABASE \"${srvCfg.database}\"" = "ALL PRIVILEGES"; }; }) ([srvCfg.user] ++ optional (elem srv ["builds" "git" "hg"]) users."sshsrht".name); }; services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable) [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]); services.sourcehut.settings = mkMerge [ { "${srv}.sr.ht" = { origin = mkDefault "https://${srv}.${domain}"; }; } (mkIf (cfg.redis.enable && webhooks != null) { "${srv}.sr.ht".webhooks = mkDefault "redis://localhost:${toString redis.port}/${toString (cfg.redis.firstDatabase + webhooks.redisDatabase)}"; }) (mkIf (cfg.redis.enable && redisDatabase != null) { "${srv}.sr.ht".redis = mkDefault "redis://localhost:${toString redis.port}/${toString (cfg.redis.firstDatabase + redisDatabase)}"; }) (mkIf cfg.postgresql.enable { "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.database}?user=${srvCfg.user}&host=/run/postgresql"; }) ]; systemd.services = mkMerge [ { "${srvsrht}" = baseService srvsrht { noStripe = srv != "meta"; } (mkMerge [ { description = "sourcehut ${srv}.sr.ht website service"; after = [ "network.target" ] ++ optional cfg.postgresql.enable "postgresql.service" ++ optional (srv != "meta" && cfg.meta.enable) "metasrht-api.service"; requires = optional cfg.postgresql.enable "postgresql.service"; wantedBy = [ "multi-user.target" ]; path = optional cfg.postgresql.enable config.services.postgresql.package; # Beware: change in credentials' content will not trigger restart. restartTriggers = [ configIni ]; serviceConfig = { Type = "simple"; User = srvCfg.user; Group = srvCfg.user; Restart = mkDefault "always"; #RestartSec = mkDefault "2min"; StateDirectory = [ "sourcehut/${srvsrht}" ]; StateDirectoryMode = "2750"; ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app -b ${cfg.listenAddress}:${toString srvCfg.port}"; }; preStart = let version = pkgs.sourcehut.${srvsrht}.version; stateDir = "/var/lib/sourcehut/${srvsrht}"; in mkBefore '' set -x # Use the /run/sourcehut/${srvsrht}/config.ini # installed by a previous ExecStartPre= in baseService cd /run/sourcehut/${srvsrht} if test ! -e ${stateDir}/db; then # Setup the initial database ${cfg.python}/bin/python < ${stateDir}/db fi # Update copy of each users' profile to the latest # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain> if test ! -e ${stateDir}/webhook; then # Update ${iniKey}'s users' profile copy to the latest ${cfg.python}/bin/srht-update-profiles ${iniKey} touch ${stateDir}/webhook fi ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade '' if [ "$(cat ${stateDir}/db)" != "${version}" ]; then # Manage schema migrations using alembic ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head # Mark down current package version echo ${version} > ${stateDir}/db fi ''} ''; } mainService ]); } (mkIf (webhooks != null) { "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {} { description = "sourcehut ${srv}.sr.ht webhooks service"; after = [ "${srvsrht}.service" ] ++ optional cfg.redis.enable "redis.service"; requires = [ "${srvsrht}.service" ] ++ optional cfg.redis.enable "redis.service"; wantedBy = [ "${srvsrht}.service" ]; requiredBy = [ "${srvsrht}.service" ]; serviceConfig = { Type = "simple"; User = srvCfg.user; Group = mkDefault srvCfg.user; Restart = "always"; ExecStart = "${cfg.python}/bin/celery -A ${srvsrht}.webhooks worker --loglevel INFO --pool eventlet"; # Avoid crashing: os.getloadavg() ProcSubset = mkForce "all"; }; }; }) (mapAttrs (timerName: timerConfig: (baseService timerName {} { description = "sourcehut ${timerName} service"; after = [ "${srvsrht}.service" ]; requires = [ "${srvsrht}.service" ]; serviceConfig = { Type = "oneshot"; User = srvCfg.user; Group = mkDefault srvCfg.user; Restart = "no"; ExecStart = "${cfg.python}/bin/${timerName}"; }; }) ) extraTimers) (mapAttrs (serviceName: service: baseService serviceName {} (mkMerge [ service { description = "sourcehut ${serviceName}"; after = [ "${srvsrht}.service" ]; requires = [ "${srvsrht}.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; User = srvCfg.user; Group = mkDefault srvCfg.user; Restart = mkDefault "always"; }; } ]) ) extraServices) ]; systemd.timers = mapAttrs (timerName: timerConfig: { description = "sourcehut timer for ${timerName}"; wantedBy = [ "timers.target" ]; inherit timerConfig; }) extraTimers; } ]); }