]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/misc/sourcehut/service.nix
sourcehut: WIP
[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 ? null
6 , redisDatabase ? null
7 , extraTimers ? {}
8 , mainService ? {}
9 , extraServices ? {}
10 , extraConfig ? {}
11 , port
12 }:
13 { config, lib, pkgs, ... }:
14
15 with lib;
16 let
17 inherit (config.services) postgresql redis;
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 path = [ pkgs.gawk ];
28 environment.HOME = runDir;
29 serviceConfig = {
30 User = mkDefault srvCfg.user;
31 Group = mkDefault srvCfg.group;
32 RuntimeDirectory = [
33 "sourcehut/${serviceName}"
34 "sourcehut/chroots/${serviceName}"
35 ];
36 RuntimeDirectoryMode = "2750";
37 # No need for the chroot path once inside the chroot
38 InaccessiblePaths = [ "-+${rootDir}" ];
39 # g+rx is for group members (eg. nginx)
40 # to read Git/Mercurial repositories, buildlogs, etc.
41 # o+x is for intermediate directories created by BindPaths= and like,
42 # as they're owned by root:root.
43 UMask = "0026";
44 RootDirectory = rootDir;
45 RootDirectoryStartOnly = true;
46 PrivateTmp = true;
47 MountAPIVFS = true;
48 # config.ini is looked up in there, before /etc/srht/config.ini
49 # Note that it fails to be set in ExecStartPre=
50 WorkingDirectory = mkDefault ("-"+runDir);
51 BindReadOnlyPaths = [
52 builtins.storeDir
53 "/etc"
54 "/run/booted-system"
55 "/run/current-system"
56 "/run/systemd"
57 ] ++
58 optional cfg.postgresql.enable "/run/postgresql" ++
59 optional cfg.redis.enable "/run/redis";
60 # LoadCredential= are unfortunately not available in ExecStartPre=
61 # Hence this one is run as root (the +) with RootDirectoryStartOnly=
62 # to reach credentials wherever they are.
63 # Note that each systemd service gets its own ${runDir}/config.ini file.
64 ExecStartPre = mkBefore [("+"+pkgs.writeShellScript "${serviceName}-credentials" ''
65 set -x
66 # Replace values begining with a '<' by the content of the file whose name is after.
67 gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} |
68 ${optionalString (!allowStripe) "gawk '!/^stripe-secret-key=/' |"}
69 install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini
70 '')];
71 # The following options are only for optimizing:
72 # systemd-analyze security
73 AmbientCapabilities = "";
74 CapabilityBoundingSet = "";
75 # ProtectClock= adds DeviceAllow=char-rtc r
76 DeviceAllow = "";
77 LockPersonality = true;
78 MemoryDenyWriteExecute = true;
79 NoNewPrivileges = true;
80 PrivateDevices = true;
81 PrivateMounts = true;
82 PrivateNetwork = mkDefault false;
83 PrivateUsers = true;
84 ProcSubset = "pid";
85 ProtectClock = true;
86 ProtectControlGroups = true;
87 ProtectHome = true;
88 ProtectHostname = true;
89 ProtectKernelLogs = true;
90 ProtectKernelModules = true;
91 ProtectKernelTunables = true;
92 ProtectProc = "invisible";
93 ProtectSystem = "strict";
94 RemoveIPC = true;
95 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
96 RestrictNamespaces = true;
97 RestrictRealtime = true;
98 RestrictSUIDSGID = true;
99 #SocketBindAllow = [ "tcp:${toString srvCfg.port}" "tcp:${toString srvCfg.prometheusPort}" ];
100 #SocketBindDeny = "any";
101 SystemCallFilter = [
102 "@system-service"
103 "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@timer"
104 "@chown" "@setuid"
105 ];
106 SystemCallArchitectures = "native";
107 };
108 } ];
109 in
110 {
111 options.services.sourcehut.${srv} = {
112 enable = mkEnableOption "${srv} service";
113
114 user = mkOption {
115 type = types.str;
116 default = srvsrht;
117 description = ''
118 User for ${srv}.sr.ht.
119 '';
120 };
121
122 group = mkOption {
123 type = types.str;
124 default = srvsrht;
125 description = ''
126 Group for ${srv}.sr.ht.
127 Membership grants access to the Git/Mercurial repositories by default,
128 but not to the config.ini file (where secrets are).
129 '';
130 };
131
132 port = mkOption {
133 type = types.port;
134 default = port;
135 description = ''
136 Port on which the "${srv}" module should listen.
137 '';
138 };
139
140 database = mkOption {
141 type = types.str;
142 default = "${srv}.sr.ht";
143 description = ''
144 PostgreSQL database name for ${srv}.sr.ht.
145 '';
146 };
147 };
148
149 config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [ extraConfig {
150 users = {
151 users = {
152 "${srvCfg.user}" = {
153 isSystemUser = true;
154 group = mkDefault srvCfg.group;
155 description = mkDefault "sourcehut user for ${srv}.sr.ht";
156 };
157 };
158 groups = {
159 "${srvCfg.group}" = { };
160 } // optionalAttrs (cfg.postgresql.enable
161 && hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")) {
162 "postgres".members = [ srvCfg.user ];
163 };
164 };
165
166 services.nginx = mkIf cfg.nginx.enable {
167 virtualHosts."${srv}.${cfg.settings."sr.ht".global-domain}" = {
168 forceSSL = true;
169 locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}";
170 locations."/query".proxyPass = cfg.settings."meta.sr.ht".api-origin;
171 locations."/static" = {
172 root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}";
173 extraConfig = mkDefault ''
174 expires 30d;
175 '';
176 };
177 };
178 };
179
180 services.postgresql = mkIf cfg.postgresql.enable {
181 authentication = ''
182 local ${srvCfg.database} ${srvCfg.user} trust
183 ''
184 # shrt-keys stores SSH keys in the PostgreSQL database of the service calling it
185 + optionalString (elem srv ["builds" "git" "hg"]) ''
186 local ${srvCfg.database} ${users."sshsrht".name} trust
187 '';
188 ensureDatabases = [ srvCfg.database ];
189 ensureUsers = map (name: {
190 inherit name;
191 ensurePermissions = { "DATABASE \"${srvCfg.database}\"" = "ALL PRIVILEGES"; };
192 }) ([srvCfg.user] ++ optional (elem srv ["builds" "git" "hg"]) users."sshsrht".name);
193 };
194
195 services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable)
196 [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]);
197
198 services.sourcehut.settings = mkMerge [
199 {
200 "${srv}.sr.ht" = {
201 origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
202 };
203 }
204
205 (mkIf (cfg.redis.enable && webhooks != null) {
206 "${srv}.sr.ht".webhooks = mkDefault "redis://localhost:${toString redis.port}/${toString (cfg.redis.firstDatabase + webhooks.redisDatabase)}";
207 })
208
209 (mkIf (cfg.redis.enable && redisDatabase != null) {
210 "${srv}.sr.ht".redis = mkDefault "redis://localhost:${toString redis.port}/${toString (cfg.redis.firstDatabase + redisDatabase)}";
211 })
212
213 (mkIf cfg.postgresql.enable {
214 "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.database}?user=${srvCfg.user}&host=/run/postgresql";
215 })
216 ];
217
218 systemd.services = mkMerge [
219 {
220 "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
221 {
222 description = "sourcehut ${srv}.sr.ht website service";
223 after = [ "network.target" ]
224 ++ optional cfg.postgresql.enable "postgresql.service"
225 ++ optional (srv != "meta" && cfg.meta.enable) "metasrht-api.service";
226 requires = optional cfg.postgresql.enable "postgresql.service";
227 wantedBy = [ "multi-user.target" ];
228 before = optional cfg.nginx.enable "nginx.service";
229 path = optional cfg.postgresql.enable config.services.postgresql.package;
230 # Beware: change in credentials' content will not trigger restart.
231 restartTriggers = [ configIni ];
232 serviceConfig = {
233 Type = "simple";
234 Restart = mkDefault "always";
235 #RestartSec = mkDefault "2min";
236 StateDirectory = [ "sourcehut/${srvsrht}" ];
237 StateDirectoryMode = "2750";
238 ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app -b ${cfg.listenAddress}:${toString srvCfg.port}";
239 };
240 preStart = let
241 version = pkgs.sourcehut.${srvsrht}.version;
242 stateDir = "/var/lib/sourcehut/${srvsrht}";
243 in mkBefore ''
244 set -x
245 # Use the /run/sourcehut/${srvsrht}/config.ini
246 # installed by a previous ExecStartPre= in baseService
247 cd /run/sourcehut/${srvsrht}
248
249 if test ! -e ${stateDir}/db; then
250 # Setup the initial database.
251 # Note that it stamps the alembic head afterward
252 ${cfg.python}/bin/${srvsrht}-initdb
253 echo ${version} >${stateDir}/db
254 fi
255
256 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
257 if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
258 # Manage schema migrations using alembic
259 ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head
260 echo ${version} >${stateDir}/db
261 fi
262 ''}
263
264 # Update copy of each users' profile to the latest
265 # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
266 if test ! -e ${stateDir}/webhook; then
267 # Update ${iniKey}'s users' profile copy to the latest
268 ${cfg.python}/bin/srht-update-profiles ${iniKey}
269 touch ${stateDir}/webhook
270 fi
271 '';
272 } mainService ]);
273 }
274
275 (mkIf (webhooks != null) {
276 "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {} (mkMerge [
277 {
278 description = "sourcehut ${srv}.sr.ht webhooks service";
279 after = [ "${srvsrht}.service" ]
280 ++ optional cfg.redis.enable "redis.service";
281 requires = [ "${srvsrht}.service" ]
282 ++ optional cfg.redis.enable "redis.service";
283 wantedBy = [ "${srvsrht}.service" ];
284 requiredBy = [ "${srvsrht}.service" ];
285 serviceConfig = {
286 Type = "simple";
287 Restart = "always";
288 ExecStart = "${cfg.python}/bin/celery -A ${srvsrht}.webhooks worker --loglevel INFO --pool eventlet";
289 # Avoid crashing: os.getloadavg()
290 ProcSubset = mkForce "all";
291 InaccessiblePaths = [ "-/run/postgresql" ];
292 };
293 }
294 (webhooks.service or {})
295 ]);
296 })
297
298 (mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [
299 {
300 description = "sourcehut ${timerName} service";
301 after = [ "${srvsrht}.service" ];
302 requires = [ "${srvsrht}.service" ];
303 serviceConfig = {
304 Type = "oneshot";
305 Restart = "no";
306 ExecStart = "${cfg.python}/bin/${timerName}";
307 };
308 }
309 (timer.service or {})
310 ]))) extraTimers)
311
312 (mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [
313 {
314 description = "sourcehut ${serviceName}";
315 after = [ "${srvsrht}.service" ];
316 requires = [ "${srvsrht}.service" ];
317 wantedBy = [ "multi-user.target" ];
318 serviceConfig = {
319 Type = "simple";
320 Restart = mkDefault "always";
321 };
322 }
323 extraService
324 ])) extraServices)
325 ];
326
327 systemd.timers = mapAttrs (timerName: timer:
328 {
329 description = "sourcehut timer for ${timerName}";
330 wantedBy = [ "timers.target" ];
331 inherit (timer) timerConfig;
332 }) extraTimers;
333 } ]);
334 }