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" "~@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 locations."/query" = mkIf (cfg.settings.${iniKey} ? api-origin) {
226 proxyPass = cfg.settings.${iniKey}.api-origin;
227 extraConfig = ''
228 add_header 'Access-Control-Allow-Origin' '*';
229 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
230 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
231
232 if ($request_method = 'OPTIONS') {
233 add_header 'Access-Control-Max-Age' 1728000;
234 add_header 'Content-Type' 'text/plain; charset=utf-8';
235 add_header 'Content-Length' 0;
236 return 204;
237 }
238
239 add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
240 '';
241 };
242 } cfg.nginx.virtualHost ];
243 };
244
245 services.postgresql = mkIf cfg.postgresql.enable {
246 authentication = ''
247 local ${srvCfg.postgresql.database} ${srvCfg.user} trust
248 '';
249 ensureDatabases = [ srvCfg.postgresql.database ];
250 ensureUsers = map (name: {
251 inherit name;
252 # We don't use it because we have a special default database name with dots.
253 # TODO(for maintainers of sourcehut): migrate away from custom preStart script.
254 ensureDBOwnership = false;
255 }) [srvCfg.user];
256 };
257
258
259 services.sourcehut.settings = mkMerge [
260 {
261 "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
262 }
263
264 (mkIf cfg.postgresql.enable {
265 "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql";
266 })
267 ];
268
269 services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable {
270 enable = true;
271 databases = 3;
272 syslog = true;
273 # TODO: set a more informed value
274 save = mkDefault [ [1800 10] [300 100] ];
275 settings = {
276 # TODO: set a more informed value
277 maxmemory = "128MB";
278 maxmemory-policy = "volatile-ttl";
279 };
280 };
281
282 systemd.services = mkMerge [
283 {
284 "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
285 {
286 description = "sourcehut ${srv}.sr.ht website service";
287 before = optional cfg.nginx.enable "nginx.service";
288 wants = optional cfg.nginx.enable "nginx.service";
289 wantedBy = [ "multi-user.target" ];
290 path = optional cfg.postgresql.enable postgresql.package;
291 # Beware: change in credentials' content will not trigger restart.
292 restartTriggers = [ configIni ];
293 serviceConfig = {
294 Type = "simple";
295 Restart = mkDefault "always";
296 #RestartSec = mkDefault "2min";
297 StateDirectory = [ "sourcehut/${srvsrht}" ];
298 StateDirectoryMode = "2750";
299 ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs;
300 };
301 preStart = let
302 version = pkgs.sourcehut.${srvsrht}.version;
303 stateDir = "/var/lib/sourcehut/${srvsrht}";
304 in mkBefore ''
305 set -x
306 # Use the /run/sourcehut/${srvsrht}/config.ini
307 # installed by a previous ExecStartPre= in baseService
308 cd /run/sourcehut/${srvsrht}
309
310 if test ! -e ${stateDir}/db; then
311 # Setup the initial database.
312 # Note that it stamps the alembic head afterward
313 ${cfg.python}/bin/${srvsrht}-initdb
314 echo ${version} >${stateDir}/db
315 fi
316
317 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
318 if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
319 # Manage schema migrations using alembic
320 ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head
321 echo ${version} >${stateDir}/db
322 fi
323 ''}
324
325 # Update copy of each users' profile to the latest
326 # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
327 if test ! -e ${stateDir}/webhook; then
328 # Update ${iniKey}'s users' profile copy to the latest
329 ${cfg.python}/bin/srht-update-profiles ${iniKey}
330 touch ${stateDir}/webhook
331 fi
332 '';
333 } mainService ]);
334 }
335
336 (mkIf webhooks {
337 "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {}
338 {
339 description = "sourcehut ${srv}.sr.ht webhooks service";
340 after = [ "${srvsrht}.service" ];
341 wantedBy = [ "${srvsrht}.service" ];
342 partOf = [ "${srvsrht}.service" ];
343 preStart = ''
344 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \
345 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
346 '';
347 serviceConfig = {
348 Type = "simple";
349 Restart = "always";
350 ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs;
351 # Avoid crashing: os.getloadavg()
352 ProcSubset = mkForce "all";
353 };
354 };
355 })
356
357 (mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [
358 {
359 description = "sourcehut ${timerName} service";
360 after = [ "network.target" "${srvsrht}.service" ];
361 serviceConfig = {
362 Type = "oneshot";
363 ExecStart = "${cfg.python}/bin/${timerName}";
364 };
365 }
366 (timer.service or {})
367 ]))) extraTimers)
368
369 (mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [
370 {
371 description = "sourcehut ${serviceName} service";
372 # So that extraServices have the PostgreSQL database initialized.
373 after = [ "${srvsrht}.service" ];
374 wantedBy = [ "${srvsrht}.service" ];
375 partOf = [ "${srvsrht}.service" ];
376 serviceConfig = {
377 Type = "simple";
378 Restart = mkDefault "always";
379 };
380 }
381 extraService
382 ])) extraServices)
383
384 # Work around 'pq: permission denied for schema public' with postgres v15.
385 # See https://github.com/NixOS/nixpkgs/issues/216989
386 # Workaround taken from nixos/forgejo: https://github.com/NixOS/nixpkgs/pull/262741
387 # TODO(to maintainers of sourcehut): please migrate away from this workaround
388 # by migrating away from database name defaults with dots.
389 (lib.mkIf (
390 cfg.postgresql.enable
391 && lib.strings.versionAtLeast config.services.postgresql.package.version "15.0"
392 ) {
393 postgresql.postStart = (lib.mkAfter ''
394 $PSQL -tAc 'ALTER DATABASE "${srvCfg.postgresql.database}" OWNER TO "${srvCfg.user}";'
395 '');
396 }
397 )
398 ];
399
400 systemd.timers = mapAttrs (timerName: timer:
401 {
402 description = "sourcehut timer for ${timerName}";
403 wantedBy = [ "timers.target" ];
404 inherit (timer) timerConfig;
405 }) extraTimers;
406 } ]);
407}