]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/misc/sourcehut/service.nix
syncoid: use DynamicUser=
[sourcephile-nix.git] / nixos / modules / services / misc / sourcehut / service.nix
1 srv:
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
14 with lib;
15 let
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 begining 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" "~@resources" "~@timer"
112 "@chown" "@setuid"
113 ];
114 SystemCallArchitectures = "native";
115 };
116 } ];
117 in
118 {
119 options.services.sourcehut.${srv} = {
120 enable = mkEnableOption "${srv} service";
121
122 user = mkOption {
123 type = types.str;
124 default = srvsrht;
125 description = ''
126 User for ${srv}.sr.ht.
127 '';
128 };
129
130 group = mkOption {
131 type = types.str;
132 default = srvsrht;
133 description = ''
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 = ''
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 = ''
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 = ''
167 PostgreSQL database name for the ${srv}.sr.ht service,
168 used if <xref linkend="opt-services.sourcehut.postgresql.enable"/> is <literal>true</literal>.
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 = "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 = "Extra arguments passed to the Celery responsible for webhooks.";
186 };
187 celeryConfig = mkOption {
188 type = types.lines;
189 default = "";
190 description = "Content of the <literal>celeryconfig.py</literal> 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 = 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 } cfg.nginx.virtualHost ];
226 };
227
228 services.postgresql = mkIf cfg.postgresql.enable {
229 authentication = ''
230 local ${srvCfg.postgresql.database} ${srvCfg.user} trust
231 '';
232 ensureDatabases = [ srvCfg.postgresql.database ];
233 ensureUsers = map (name: {
234 inherit name;
235 ensurePermissions = { "DATABASE \"${srvCfg.postgresql.database}\"" = "ALL PRIVILEGES"; };
236 }) [srvCfg.user];
237 };
238
239 services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable)
240 [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]);
241
242 services.sourcehut.settings = mkMerge [
243 {
244 "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
245 }
246
247 (mkIf cfg.postgresql.enable {
248 "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql";
249 })
250 ];
251
252 services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable {
253 enable = true;
254 databases = 3;
255 syslog = true;
256 # TODO: set a more informed value
257 save = mkDefault [ [1800 10] [300 100] ];
258 settings = {
259 # TODO: set a more informed value
260 maxmemory = "128MB";
261 maxmemory-policy = "volatile-ttl";
262 };
263 };
264
265 systemd.services = mkMerge [
266 {
267 "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
268 {
269 description = "sourcehut ${srv}.sr.ht website service";
270 before = optional cfg.nginx.enable "nginx.service";
271 wants = optional cfg.nginx.enable "nginx.service";
272 wantedBy = [ "multi-user.target" ];
273 path = optional cfg.postgresql.enable postgresql.package;
274 # Beware: change in credentials' content will not trigger restart.
275 restartTriggers = [ configIni ];
276 serviceConfig = {
277 Type = "simple";
278 Restart = mkDefault "always";
279 #RestartSec = mkDefault "2min";
280 StateDirectory = [ "sourcehut/${srvsrht}" ];
281 StateDirectoryMode = "2750";
282 ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs;
283 };
284 preStart = let
285 version = pkgs.sourcehut.${srvsrht}.version;
286 stateDir = "/var/lib/sourcehut/${srvsrht}";
287 in mkBefore ''
288 set -x
289 # Use the /run/sourcehut/${srvsrht}/config.ini
290 # installed by a previous ExecStartPre= in baseService
291 cd /run/sourcehut/${srvsrht}
292
293 if test ! -e ${stateDir}/db; then
294 # Setup the initial database.
295 # Note that it stamps the alembic head afterward
296 ${cfg.python}/bin/${srvsrht}-initdb
297 echo ${version} >${stateDir}/db
298 fi
299
300 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
301 if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
302 # Manage schema migrations using alembic
303 ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head
304 echo ${version} >${stateDir}/db
305 fi
306 ''}
307
308 # Update copy of each users' profile to the latest
309 # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
310 if test ! -e ${stateDir}/webhook; then
311 # Update ${iniKey}'s users' profile copy to the latest
312 ${cfg.python}/bin/srht-update-profiles ${iniKey}
313 touch ${stateDir}/webhook
314 fi
315 '';
316 } mainService ]);
317 }
318
319 (mkIf webhooks {
320 "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {}
321 {
322 description = "sourcehut ${srv}.sr.ht webhooks service";
323 after = [ "${srvsrht}.service" ];
324 wantedBy = [ "${srvsrht}.service" ];
325 partOf = [ "${srvsrht}.service" ];
326 preStart = ''
327 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \
328 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
329 '';
330 serviceConfig = {
331 Type = "simple";
332 Restart = "always";
333 ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs;
334 # Avoid crashing: os.getloadavg()
335 ProcSubset = mkForce "all";
336 };
337 };
338 })
339
340 (mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [
341 {
342 description = "sourcehut ${timerName} service";
343 after = [ "network.target" "${srvsrht}.service" ];
344 serviceConfig = {
345 Type = "oneshot";
346 ExecStart = "${cfg.python}/bin/${timerName}";
347 };
348 }
349 (timer.service or {})
350 ]))) extraTimers)
351
352 (mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [
353 {
354 description = "sourcehut ${serviceName} service";
355 # So that extraServices have the PostgreSQL database initialized.
356 after = [ "${srvsrht}.service" ];
357 wantedBy = [ "${srvsrht}.service" ];
358 partOf = [ "${srvsrht}.service" ];
359 serviceConfig = {
360 Type = "simple";
361 Restart = mkDefault "always";
362 };
363 }
364 extraService
365 ])) extraServices)
366 ];
367
368 systemd.timers = mapAttrs (timerName: timer:
369 {
370 description = "sourcehut timer for ${timerName}";
371 wantedBy = [ "timers.target" ];
372 inherit (timer) timerConfig;
373 }) extraTimers;
374 } ]);
375 }