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}