3 , srvsrht ? "${srv}srht" # Because "buildsrht" does not follow that pattern (missing an "s").
4 , iniKey ? "${srv}.sr.ht"
12 { config, lib, pkgs, ... }:
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;
27 baseService = serviceName: { allowStripe ? false }: extraService:
29 runDir = "/run/sourcehut/${serviceName}";
30 rootDir = "/run/sourcehut/chroots/${serviceName}";
35 after = [ "network.target" ] ++
36 optional cfg.postgresql.enable "postgresql.service" ++
37 optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
39 optional cfg.postgresql.enable "postgresql.service" ++
40 optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
42 environment.HOME = runDir;
44 User = mkDefault srvCfg.user;
45 Group = mkDefault srvCfg.group;
47 "sourcehut/${serviceName}"
48 # Used by *srht-keys which reads ../config.ini
49 "sourcehut/${serviceName}/subdir"
50 "sourcehut/chroots/${serviceName}"
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.
60 RootDirectory = rootDir;
61 RootDirectoryStartOnly = 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);
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" ''
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
89 # The following options are only for optimizing:
90 # systemd-analyze security
91 AmbientCapabilities = "";
92 CapabilityBoundingSet = "";
93 # ProtectClock= adds DeviceAllow=char-rtc r
95 LockPersonality = true;
96 MemoryDenyWriteExecute = true;
97 NoNewPrivileges = true;
98 PrivateDevices = true;
100 PrivateNetwork = mkDefault false;
104 ProtectControlGroups = true;
106 ProtectHostname = true;
107 ProtectKernelLogs = true;
108 ProtectKernelModules = true;
109 ProtectKernelTunables = true;
110 ProtectProc = "invisible";
111 ProtectSystem = "strict";
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";
129 SystemCallArchitectures = "native";
135 options.services.sourcehut.${srv} = {
136 enable = mkEnableOption "${srv} service";
142 User for ${srv}.sr.ht.
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).
160 Port on which the "${srv}" backend should listen.
167 default = "unix:///run/redis-sourcehut-${srvsrht}/redis.sock?db=0";
168 example = "redis://shared.wireguard:6379/0";
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.
179 database = mkOption {
181 default = "${srv}.sr.ht";
183 PostgreSQL database name for the ${srv}.sr.ht service,
184 used if [](#opt-services.sourcehut.postgresql.enable) is `true`.
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.";
196 } // optionalAttrs 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.";
203 celeryConfig = mkOption {
206 description = "Content of the `celeryconfig.py` used by the Celery responsible for webhooks.";
211 config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [
218 group = mkDefault srvCfg.group;
219 description = mkDefault "sourcehut user for ${srv}.sr.ht";
223 "${srvCfg.group}" = { };
225 (cfg.postgresql.enable
226 && hasSuffix "0" (postgresql.settings.unix_socket_permissions or ""))
228 "postgres".members = [ srvCfg.user ];
231 && hasSuffix "0" (redis.settings.unixsocketperm or ""))
233 "redis-sourcehut-${srvsrht}".members = [ srvCfg.user ];
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 ''
247 locations."/query" = mkIf (cfg.settings.${iniKey} ? api-origin) {
248 proxyPass = cfg.settings.${iniKey}.api-origin;
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';
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;
261 add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
265 cfg.nginx.virtualHost];
268 services.postgresql = mkIf cfg.postgresql.enable {
270 local ${srvCfg.postgresql.database} ${srvCfg.user} trust
272 ensureDatabases = [ srvCfg.postgresql.database ];
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;
283 services.sourcehut.settings = mkMerge [
285 "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
288 (mkIf cfg.postgresql.enable {
289 "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql";
293 services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable {
297 # TODO: set a more informed value
298 save = mkDefault [ [ 1800 10 ] [ 300 100 ] ];
300 # TODO: set a more informed value
302 maxmemory-policy = "volatile-ttl";
306 systemd.services = mkMerge [
308 "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
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 ];
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;
327 package = pkgs.sourcehut.${srvsrht};
328 version = package.version;
329 stateDir = "/var/lib/sourcehut/${srvsrht}";
333 # Use the /run/sourcehut/${srvsrht}/config.ini
334 # installed by a previous ExecStartPre= in baseService
335 cd /run/sourcehut/${srvsrht}
337 if test ! -e ${stateDir}/db; then
338 # Setup the initial database.
339 # Note that it stamps the alembic head afterward
340 ${package}/bin/${srvsrht}-initdb
341 echo ${version} >${stateDir}/db
344 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
345 if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
346 # Manage schema migrations using alembic
347 ${package}/bin/${srvsrht}-migrate -a upgrade head
348 echo ${version} >${stateDir}/db
352 # Update copy of each users' profile to the latest
353 # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
354 if test ! -e ${stateDir}/webhook; then
355 # Update ${iniKey}'s users' profile copy to the latest
356 ${cfg.python}/bin/srht-update-profiles ${iniKey}
357 touch ${stateDir}/webhook
366 "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" { }
368 description = "sourcehut ${srv}.sr.ht webhooks service";
369 after = [ "${srvsrht}.service" ];
370 wantedBy = [ "${srvsrht}.service" ];
371 partOf = [ "${srvsrht}.service" ];
373 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \
374 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
379 ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs;
380 # Avoid crashing: os.getloadavg()
381 ProcSubset = mkForce "all";
387 (timerName: timer: (baseService timerName { } (mkMerge [
389 description = "sourcehut ${timerName} service";
390 after = [ "network.target" "${srvsrht}.service" ];
393 ExecStart = "${pkgs.sourcehut.${srvsrht}}/bin/${timerName}";
396 (timer.service or { })
401 (serviceName: extraService: baseService serviceName { } (mkMerge [
403 description = "sourcehut ${serviceName} service";
404 # So that extraServices have the PostgreSQL database initialized.
405 after = [ "${srvsrht}.service" ];
406 wantedBy = [ "${srvsrht}.service" ];
407 partOf = [ "${srvsrht}.service" ];
410 Restart = mkDefault "always";
417 # Work around 'pq: permission denied for schema public' with postgres v15.
418 # See https://github.com/NixOS/nixpkgs/issues/216989
419 # Workaround taken from nixos/forgejo: https://github.com/NixOS/nixpkgs/pull/262741
420 # TODO(to maintainers of sourcehut): please migrate away from this workaround
421 # by migrating away from database name defaults with dots.
424 cfg.postgresql.enable
425 && lib.strings.versionAtLeast config.services.postgresql.package.version "15.0"
428 postgresql.postStart = (lib.mkAfter ''
429 $PSQL -tAc 'ALTER DATABASE "${srvCfg.postgresql.database}" OWNER TO "${srvCfg.user}";'
435 systemd.timers = mapAttrs
438 description = "sourcehut timer for ${timerName}";
439 wantedBy = [ "timers.target" ];
440 inherit (timer) timerConfig;