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