vuls: init at 0.27.0
[NixPkgs.git] / nixos / modules / services / misc / sourcehut / default.nix
blob94a96dba6790ff1bc86d96939567ca62c6d58260
1 { config, pkgs, lib, ... }:
3 let
4   inherit (builtins) head tail;
5   inherit (lib) generators maintainers types;
6   inherit (lib.attrsets) attrValues filterAttrs mapAttrs mapAttrsToList recursiveUpdate;
7   inherit (lib.lists) flatten optional optionals;
8   inherit (lib.options) literalExpression mkEnableOption mkOption mkPackageOption;
9   inherit (lib.strings) concatMapStringsSep concatStringsSep optionalString versionOlder;
10   inherit (lib.trivial) mapNullable;
11   inherit (lib.modules) mkBefore mkDefault mkForce mkIf mkMerge
12     mkRemovedOptionModule mkRenamedOptionModule;
13   inherit (config.services) nginx postfix postgresql redis;
14   inherit (config.users) users groups;
15   cfg = config.services.sourcehut;
16   domain = cfg.settings."sr.ht".global-domain;
17   settingsFormat = pkgs.formats.ini {
18     listToValue = concatMapStringsSep "," (generators.mkValueStringDefault {});
19     mkKeyValue = k: v:
20       optionalString (v != null)
21       (generators.mkKeyValueDefault {
22         mkValueString = v:
23           if v == true then "yes"
24           else if v == false then "no"
25           else generators.mkValueStringDefault {} v;
26       } "=" k v);
27   };
28   configIniOfService = srv: settingsFormat.generate "sourcehut-${srv}-config.ini"
29     # Each service needs access to only a subset of sections (and secrets).
30     (filterAttrs (k: v: v != null)
31     (mapAttrs (section: v:
32       let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht(::.*)?$" section; in
33       if srvMatch == null # Include sections shared by all services
34       || head srvMatch == srv # Include sections for the service being configured
35       then v
36       # Enable Web links and integrations between services.
37       else if tail srvMatch == [ null ] && cfg.${head srvMatch}.enable
38       then {
39         inherit (v) origin;
40         # mansrht crashes without it
41         oauth-client-id = v.oauth-client-id or null;
42       }
43       # Drop sub-sections of other services
44       else null)
45     (recursiveUpdate cfg.settings {
46       # Those paths are mounted using BindPaths= or BindReadOnlyPaths=
47       # for services needing access to them.
48       "builds.sr.ht::worker".buildlogs = "/var/log/sourcehut/buildsrht-worker";
49       "git.sr.ht".post-update-script = "/usr/bin/gitsrht-update-hook";
50       "git.sr.ht".repos = cfg.settings."git.sr.ht".repos;
51       "hg.sr.ht".changegroup-script = "/usr/bin/hgsrht-hook-changegroup";
52       "hg.sr.ht".repos = cfg.settings."hg.sr.ht".repos;
53       # Making this a per service option despite being in a global section,
54       # so that it uses the redis-server used by the service.
55       "sr.ht".redis-host = cfg.${srv}.redis.host;
56     })));
57   commonServiceSettings = srv: {
58     origin = mkOption {
59       description = "URL ${srv}.sr.ht is being served at (protocol://domain)";
60       type = types.str;
61       default = "https://${srv}.${domain}";
62       defaultText = "https://${srv}.example.com";
63     };
64     debug-host = mkOption {
65       description = "Address to bind the debug server to.";
66       type = with types; nullOr str;
67       default = null;
68     };
69     debug-port = mkOption {
70       description = "Port to bind the debug server to.";
71       type = with types; nullOr str;
72       default = null;
73     };
74     connection-string = mkOption {
75       description = "SQLAlchemy connection string for the database.";
76       type = types.str;
77       default = "postgresql:///localhost?user=${srv}srht&host=/run/postgresql";
78     };
79     migrate-on-upgrade = mkEnableOption "automatic migrations on package upgrade" // { default = true; };
80     oauth-client-id = mkOption {
81       description = "${srv}.sr.ht's OAuth client id for meta.sr.ht.";
82       type = types.str;
83     };
84     oauth-client-secret = mkOption {
85       description = "${srv}.sr.ht's OAuth client secret for meta.sr.ht.";
86       type = types.path;
87       apply = s: "<" + toString s;
88     };
89     api-origin = mkOption {
90       description = "Origin URL for the API";
91       type = types.str;
92       default = "http://${cfg.listenAddress}:${toString (cfg.${srv}.port + 100)}";
93       defaultText = lib.literalMD ''
94         `"http://''${`[](#opt-services.sourcehut.listenAddress)`}:''${toString (`[](#opt-services.sourcehut.${srv}.port)` + 100)}"`
95       '';
96     };
97   };
99   # Specialized python containing all the modules
100   python = pkgs.sourcehut.python.withPackages (ps: with ps; [
101     gunicorn
102     eventlet
103     # For monitoring Celery: sudo -u listssrht celery --app listssrht.process -b redis+socket:///run/redis-sourcehut/redis.sock?virtual_host=1 flower
104     flower
105     # Sourcehut services
106     srht
107     buildsrht
108     gitsrht
109     hgsrht
110     hubsrht
111     listssrht
112     mansrht
113     metasrht
114     # Not a python package
115     #pagessrht
116     pastesrht
117     todosrht
118   ]);
119   mkOptionNullOrStr = description: mkOption {
120     description = description;
121     type = with types; nullOr str;
122     default = null;
123   };
126   options.services.sourcehut = {
127     enable = mkEnableOption ''
128       sourcehut - git hosting, continuous integration, mailing list, ticket tracking, wiki
129       and account management services
130     '';
132     listenAddress = mkOption {
133       type = types.str;
134       default = "localhost";
135       description = "Address to bind to.";
136     };
138     python = mkOption {
139       internal = true;
140       type = types.package;
141       default = python;
142       description = ''
143         The python package to use. It should contain references to the *srht modules and also
144         gunicorn.
145       '';
146     };
148     minio = {
149       enable = mkEnableOption ''local minio integration'';
150     };
152     nginx = {
153       enable = mkEnableOption ''local nginx integration'';
154       virtualHost = mkOption {
155         type = types.attrs;
156         default = {};
157         description = "Virtual-host configuration merged with all Sourcehut's virtual-hosts.";
158       };
159     };
161     postfix = {
162       enable = mkEnableOption ''local postfix integration'';
163     };
165     postgresql = {
166       enable = mkEnableOption ''local postgresql integration'';
167     };
169     redis = {
170       enable = mkEnableOption ''local redis integration in a dedicated redis-server'';
171     };
173     settings = mkOption {
174       type = lib.types.submodule {
175         freeformType = settingsFormat.type;
176         options."sr.ht" = {
177           global-domain = mkOption {
178             description = "Global domain name.";
179             type = types.str;
180             example = "example.com";
181           };
182           environment = mkOption {
183             description = "Values other than \"production\" adds a banner to each page.";
184             type = types.enum [ "development" "production" ];
185             default = "development";
186           };
187           network-key = mkOption {
188             description = ''
189               An absolute file path (which should be outside the Nix-store)
190               to a secret key to encrypt internal messages with. Use `srht-keygen network` to
191               generate this key. It must be consistent between all services and nodes.
192             '';
193             type = types.path;
194             apply = s: "<" + toString s;
195           };
196           owner-email = mkOption {
197             description = "Owner's email.";
198             type = types.str;
199             default = "contact@example.com";
200           };
201           owner-name = mkOption {
202             description = "Owner's name.";
203             type = types.str;
204             default = "John Doe";
205           };
206           site-blurb = mkOption {
207             description = "Blurb for your site.";
208             type = types.str;
209             default = "the hacker's forge";
210           };
211           site-info = mkOption {
212             description = "The top-level info page for your site.";
213             type = types.str;
214             default = "https://sourcehut.org";
215           };
216           service-key = mkOption {
217             description = ''
218               An absolute file path (which should be outside the Nix-store)
219               to a key used for encrypting session cookies. Use `srht-keygen service` to
220               generate the service key. This must be shared between each node of the same
221               service (e.g. git1.sr.ht and git2.sr.ht), but different services may use
222               different keys. If you configure all of your services with the same
223               config.ini, you may use the same service-key for all of them.
224             '';
225             type = types.path;
226             apply = s: "<" + toString s;
227           };
228           site-name = mkOption {
229             description = "The name of your network of sr.ht-based sites.";
230             type = types.str;
231             default = "sourcehut";
232           };
233           source-url = mkOption {
234             description = "The source code for your fork of sr.ht.";
235             type = types.str;
236             default = "https://git.sr.ht/~sircmpwn/srht";
237           };
238         };
239         options.mail = {
240           smtp-host = mkOptionNullOrStr "Outgoing SMTP host.";
241           smtp-port = mkOption {
242             description = "Outgoing SMTP port.";
243             type = with types; nullOr port;
244             default = null;
245           };
246           smtp-user = mkOptionNullOrStr "Outgoing SMTP user.";
247           smtp-password = mkOptionNullOrStr "Outgoing SMTP password.";
248           smtp-from = mkOption {
249             type = types.str;
250             description = "Outgoing SMTP FROM.";
251           };
252           error-to = mkOptionNullOrStr "Address receiving application exceptions";
253           error-from = mkOptionNullOrStr "Address sending application exceptions";
254           pgp-privkey = mkOption {
255             type = types.str;
256             description = ''
257               An absolute file path (which should be outside the Nix-store)
258               to an OpenPGP private key.
260               Your PGP key information (DO NOT mix up pub and priv here)
261               You must remove the password from your secret key, if present.
262               You can do this with `gpg --edit-key [key-id]`,
263               then use the `passwd` command and do not enter a new password.
264             '';
265           };
266           pgp-pubkey = mkOption {
267             type = with types; either path str;
268             description = "OpenPGP public key.";
269           };
270           pgp-key-id = mkOption {
271             type = types.str;
272             description = "OpenPGP key identifier.";
273           };
274         };
275         options.objects = {
276           s3-upstream = mkOption {
277             description = "Configure the S3-compatible object storage service.";
278             type = with types; nullOr str;
279             default = null;
280           };
281           s3-access-key = mkOption {
282             description = "Access key to the S3-compatible object storage service";
283             type = with types; nullOr str;
284             default = null;
285           };
286           s3-secret-key = mkOption {
287             description = ''
288               An absolute file path (which should be outside the Nix-store)
289               to the secret key of the S3-compatible object storage service.
290             '';
291             type = with types; nullOr path;
292             default = null;
293             apply = mapNullable (s: "<" + toString s);
294           };
295         };
296         options.webhooks = {
297           private-key = mkOption {
298             description = ''
299               An absolute file path (which should be outside the Nix-store)
300               to a base64-encoded Ed25519 key for signing webhook payloads.
301               This should be consistent for all *.sr.ht sites,
302               as this key will be used to verify signatures
303               from other sites in your network.
304               Use the `srht-keygen webhook` command to generate a key.
305             '';
306             type = types.path;
307             apply = s: "<" + toString s;
308           };
309         };
311         options."builds.sr.ht" = commonServiceSettings "builds" // {
312           allow-free = mkEnableOption "nonpaying users to submit builds";
313           redis = mkOption {
314             description = "The Redis connection used for the Celery worker.";
315             type = types.str;
316             default = "redis+socket:///run/redis-sourcehut-buildsrht/redis.sock?virtual_host=2";
317           };
318           shell = mkOption {
319             description = ''
320               Scripts used to launch on SSH connection.
321               `/usr/bin/master-shell` on master,
322               `/usr/bin/runner-shell` on runner.
323               If master and worker are on the same system
324               set to `/usr/bin/runner-shell`.
325             '';
326             type = types.enum ["/usr/bin/master-shell" "/usr/bin/runner-shell"];
327             default = "/usr/bin/master-shell";
328           };
329         };
330         options."builds.sr.ht::worker" = {
331           bind-address = mkOption {
332             description = ''
333               HTTP bind address for serving local build information/monitoring.
334             '';
335             type = types.str;
336             default = "localhost:8080";
337           };
338           buildlogs = mkOption {
339             description = "Path to write build logs.";
340             type = types.str;
341             default = "/var/log/sourcehut/buildsrht-worker";
342           };
343           name = mkOption {
344             description = ''
345               Listening address and listening port
346               of the build runner (with HTTP port if not 80).
347             '';
348             type = types.str;
349             default = "localhost:5020";
350           };
351           timeout = mkOption {
352             description = ''
353               Max build duration.
354               See <https://golang.org/pkg/time/#ParseDuration>.
355             '';
356             type = types.str;
357             default = "3m";
358           };
359         };
361         options."git.sr.ht" = commonServiceSettings "git" // {
362           outgoing-domain = mkOption {
363             description = "Outgoing domain.";
364             type = types.str;
365             default = "https://git.localhost.localdomain";
366           };
367           post-update-script = mkOption {
368             description = ''
369               A post-update script which is installed in every git repo.
370               This setting is propagated to newer and existing repositories.
371             '';
372             type = types.path;
373             default = "${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
374             defaultText = "\${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
375           };
376           repos = mkOption {
377             description = ''
378               Path to git repositories on disk.
379               If changing the default, you must ensure that
380               the gitsrht's user as read and write access to it.
381             '';
382             type = types.str;
383             default = "/var/lib/sourcehut/gitsrht/repos";
384           };
385           webhooks = mkOption {
386             description = "The Redis connection used for the webhooks worker.";
387             type = types.str;
388             default = "redis+socket:///run/redis-sourcehut-gitsrht/redis.sock?virtual_host=1";
389           };
390         };
391         options."git.sr.ht::api" = {
392           internal-ipnet = mkOption {
393             description = ''
394               Set of IP subnets which are permitted to utilize internal API
395               authentication. This should be limited to the subnets
396               from which your *.sr.ht services are running.
397               See [](#opt-services.sourcehut.listenAddress).
398             '';
399             type = with types; listOf str;
400             default = [ "127.0.0.0/8" "::1/128" ];
401           };
402         };
404         options."hg.sr.ht" = commonServiceSettings "hg" // {
405           changegroup-script = mkOption {
406             description = ''
407               A changegroup script which is installed in every mercurial repo.
408               This setting is propagated to newer and existing repositories.
409             '';
410             type = types.str;
411             default = "${pkgs.sourcehut.hgsrht}/bin/hgsrht-hook-changegroup";
412             defaultText = "\${pkgs.sourcehut.hgsrht}/bin/hgsrht-hook-changegroup";
413           };
414           repos = mkOption {
415             description = ''
416               Path to mercurial repositories on disk.
417               If changing the default, you must ensure that
418               the hgsrht's user as read and write access to it.
419             '';
420             type = types.str;
421             default = "/var/lib/sourcehut/hgsrht/repos";
422           };
423           srhtext = mkOptionNullOrStr ''
424             Path to the srht mercurial extension
425             (defaults to where the hgsrht code is)
426           '';
427           clone_bundle_threshold = mkOption {
428             description = ".hg/store size (in MB) past which the nightly job generates clone bundles.";
429             type = types.ints.unsigned;
430             default = 50;
431           };
432           hg_ssh = mkOption {
433             description = "Path to hg-ssh (if not in $PATH).";
434             type = types.str;
435             default = "${pkgs.mercurial}/bin/hg-ssh";
436             defaultText = "\${pkgs.mercurial}/bin/hg-ssh";
437           };
438           webhooks = mkOption {
439             description = "The Redis connection used for the webhooks worker.";
440             type = types.str;
441             default = "redis+socket:///run/redis-sourcehut-hgsrht/redis.sock?virtual_host=1";
442           };
443         };
445         options."hub.sr.ht" = commonServiceSettings "hub" // {
446         };
448         options."lists.sr.ht" = commonServiceSettings "lists" // {
449           allow-new-lists = mkEnableOption "creation of new lists";
450           notify-from = mkOption {
451             description = "Outgoing email for notifications generated by users.";
452             type = types.str;
453             default = "lists-notify@localhost.localdomain";
454           };
455           posting-domain = mkOption {
456             description = "Posting domain.";
457             type = types.str;
458             default = "lists.localhost.localdomain";
459           };
460           redis = mkOption {
461             description = "The Redis connection used for the Celery worker.";
462             type = types.str;
463             default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=2";
464           };
465           webhooks = mkOption {
466             description = "The Redis connection used for the webhooks worker.";
467             type = types.str;
468             default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=1";
469           };
470         };
471         options."lists.sr.ht::worker" = {
472           reject-mimetypes = mkOption {
473             description = ''
474               Comma-delimited list of Content-Types to reject. Messages with Content-Types
475               included in this list are rejected. Multipart messages are always supported,
476               and each part is checked against this list.
478               Uses fnmatch for wildcard expansion.
479             '';
480             type = with types; listOf str;
481             default = ["text/html"];
482           };
483           reject-url = mkOption {
484             description = "Reject URL.";
485             type = types.str;
486             default = "https://man.sr.ht/lists.sr.ht/etiquette.md";
487           };
488           sock = mkOption {
489             description = ''
490               Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
491               Alternatively, specify IP:PORT and an SMTP server will be run instead.
492             '';
493             type = types.str;
494             default = "/tmp/lists.sr.ht-lmtp.sock";
495           };
496           sock-group = mkOption {
497             description = ''
498               The lmtp daemon will make the unix socket group-read/write
499               for users in this group.
500             '';
501             type = types.str;
502             default = "postfix";
503           };
504         };
506         options."man.sr.ht" = commonServiceSettings "man" // {
507         };
509         options."meta.sr.ht" =
510           removeAttrs (commonServiceSettings "meta")
511             ["oauth-client-id" "oauth-client-secret"] // {
512           webhooks = mkOption {
513             description = "The Redis connection used for the webhooks worker.";
514             type = types.str;
515             default = "redis+socket:///run/redis-sourcehut-metasrht/redis.sock?virtual_host=1";
516           };
517           welcome-emails = mkEnableOption "sending stock sourcehut welcome emails after signup";
518         };
519         options."meta.sr.ht::api" = {
520           internal-ipnet = mkOption {
521             description = ''
522               Set of IP subnets which are permitted to utilize internal API
523               authentication. This should be limited to the subnets
524               from which your *.sr.ht services are running.
525               See [](#opt-services.sourcehut.listenAddress).
526             '';
527             type = with types; listOf str;
528             default = [ "127.0.0.0/8" "::1/128" ];
529           };
530         };
531         options."meta.sr.ht::aliases" = mkOption {
532           description = "Aliases for the client IDs of commonly used OAuth clients.";
533           type = with types; attrsOf int;
534           default = {};
535           example = { "git.sr.ht" = 12345; };
536         };
537         options."meta.sr.ht::billing" = {
538           enabled = mkEnableOption "the billing system";
539           stripe-public-key = mkOptionNullOrStr "Public key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys";
540           stripe-secret-key = mkOptionNullOrStr ''
541             An absolute file path (which should be outside the Nix-store)
542             to a secret key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys
543           '' // {
544             apply = mapNullable (s: "<" + toString s);
545           };
546         };
547         options."meta.sr.ht::settings" = {
548           registration = mkEnableOption "public registration";
549           onboarding-redirect = mkOption {
550             description = "Where to redirect new users upon registration.";
551             type = types.str;
552             default = "https://meta.localhost.localdomain";
553           };
554           user-invites = mkOption {
555             description = ''
556               How many invites each user is issued upon registration
557               (only applicable if open registration is disabled).
558             '';
559             type = types.ints.unsigned;
560             default = 5;
561           };
562         };
564         options."pages.sr.ht" = commonServiceSettings "pages" // {
565           gemini-certs = mkOption {
566             description = ''
567               An absolute file path (which should be outside the Nix-store)
568               to Gemini certificates.
569             '';
570             type = with types; nullOr path;
571             default = null;
572           };
573           max-site-size = mkOption {
574             description = "Maximum size of any given site (post-gunzip), in MiB.";
575             type = types.int;
576             default = 1024;
577           };
578           user-domain = mkOption {
579             description = ''
580               Configures the user domain, if enabled.
581               All users are given \<username\>.this.domain.
582             '';
583             type = with types; nullOr str;
584             default = null;
585           };
586         };
587         options."pages.sr.ht::api" = {
588           internal-ipnet = mkOption {
589             description = ''
590               Set of IP subnets which are permitted to utilize internal API
591               authentication. This should be limited to the subnets
592               from which your *.sr.ht services are running.
593               See [](#opt-services.sourcehut.listenAddress).
594             '';
595             type = with types; listOf str;
596             default = [ "127.0.0.0/8" "::1/128" ];
597           };
598         };
600         options."paste.sr.ht" = commonServiceSettings "paste" // {
601         };
603         options."todo.sr.ht" = commonServiceSettings "todo" // {
604           notify-from = mkOption {
605             description = "Outgoing email for notifications generated by users.";
606             type = types.str;
607             default = "todo-notify@localhost.localdomain";
608           };
609           webhooks = mkOption {
610             description = "The Redis connection used for the webhooks worker.";
611             type = types.str;
612             default = "redis+socket:///run/redis-sourcehut-todosrht/redis.sock?virtual_host=1";
613           };
614         };
615         options."todo.sr.ht::mail" = {
616           posting-domain = mkOption {
617             description = "Posting domain.";
618             type = types.str;
619             default = "todo.localhost.localdomain";
620           };
621           sock = mkOption {
622             description = ''
623               Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
624               Alternatively, specify IP:PORT and an SMTP server will be run instead.
625             '';
626             type = types.str;
627             default = "/tmp/todo.sr.ht-lmtp.sock";
628           };
629           sock-group = mkOption {
630             description = ''
631               The lmtp daemon will make the unix socket group-read/write
632               for users in this group.
633             '';
634             type = types.str;
635             default = "postfix";
636           };
637         };
638       };
639       default = { };
640       description = ''
641         The configuration for the sourcehut network.
642       '';
643     };
645     builds = {
646       enableWorker = mkEnableOption ''
647         worker for builds.sr.ht
649         ::: {.warning}
650         For smaller deployments, job runners can be installed alongside the master server
651         but even if you only build your own software, integration with other services
652         may cause you to run untrusted builds
653         (e.g. automatic testing of patches via listssrht).
654         See <https://man.sr.ht/builds.sr.ht/configuration.md#security-model>.
655         :::
656       '';
658       images = mkOption {
659         type = with types; attrsOf (attrsOf (attrsOf package));
660         default = { };
661         example = lib.literalExpression ''(let
662             # Pinning unstable to allow usage with flakes and limit rebuilds.
663             pkgs_unstable = builtins.fetchGit {
664                 url = "https://github.com/NixOS/nixpkgs";
665                 rev = "ff96a0fa5635770390b184ae74debea75c3fd534";
666                 ref = "nixos-unstable";
667             };
668             image_from_nixpkgs = (import ("''${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") {
669               pkgs = (import pkgs_unstable {});
670             });
671           in
672           {
673             nixos.unstable.x86_64 = image_from_nixpkgs;
674           }
675         )'';
676         description = ''
677           Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2.
678         '';
679       };
680     };
682     git = {
683       package = mkPackageOption pkgs "git" {
684         example = "gitFull";
685       };
686       fcgiwrap.preforkProcess = mkOption {
687         description = "Number of fcgiwrap processes to prefork.";
688         type = types.int;
689         default = 4;
690       };
691     };
693     hg = {
694       package = mkPackageOption pkgs "mercurial" { };
695       cloneBundles = mkOption {
696         type = types.bool;
697         default = false;
698         description = ''
699           Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories).
700         '';
701       };
702     };
704     lists = {
705       process = {
706         extraArgs = mkOption {
707           type = with types; listOf str;
708           default = [ "--loglevel DEBUG" "--pool eventlet" "--without-heartbeat" ];
709           description = "Extra arguments passed to the Celery responsible for processing mails.";
710         };
711         celeryConfig = mkOption {
712           type = types.lines;
713           default = "";
714           description = "Content of the `celeryconfig.py` used by the Celery of `listssrht-process`.";
715         };
716       };
717     };
718   };
720   config = mkIf cfg.enable (mkMerge [
721     {
722       environment.systemPackages = [ pkgs.sourcehut.coresrht ];
724       services.sourcehut.settings = {
725         "git.sr.ht".outgoing-domain = mkDefault "https://git.${domain}";
726         "lists.sr.ht".notify-from = mkDefault "lists-notify@${domain}";
727         "lists.sr.ht".posting-domain = mkDefault "lists.${domain}";
728         "meta.sr.ht::settings".onboarding-redirect = mkDefault "https://meta.${domain}";
729         "todo.sr.ht".notify-from = mkDefault "todo-notify@${domain}";
730         "todo.sr.ht::mail".posting-domain = mkDefault "todo.${domain}";
731       };
732     }
733     (mkIf cfg.postgresql.enable {
734       assertions = [
735         { assertion = postgresql.enable;
736           message = "postgresql must be enabled and configured";
737         }
738       ];
739     })
740     (mkIf cfg.postfix.enable {
741       assertions = [
742         { assertion = postfix.enable;
743           message = "postfix must be enabled and configured";
744         }
745       ];
746       # Needed for sharing the LMTP sockets with JoinsNamespaceOf=
747       systemd.services.postfix.serviceConfig.PrivateTmp = true;
748     })
749     (mkIf cfg.redis.enable {
750       services.redis.vmOverCommit = mkDefault true;
751     })
752     (mkIf cfg.nginx.enable {
753       assertions = [
754         { assertion = nginx.enable;
755           message = "nginx must be enabled and configured";
756         }
757       ];
758       # For proxyPass= in virtual-hosts for Sourcehut services.
759       services.nginx.recommendedProxySettings = mkDefault true;
760     })
761     (mkIf (cfg.builds.enable || cfg.git.enable || cfg.hg.enable) {
762       services.openssh = {
763         # Note that sshd will continue to honor AuthorizedKeysFile.
764         # Note that you may want automatically rotate
765         # or link to /dev/null the following log files:
766         # - /var/log/gitsrht-dispatch
767         # - /var/log/{build,git,hg}srht-keys
768         # - /var/log/{git,hg}srht-shell
769         # - /var/log/gitsrht-update-hook
770         authorizedKeysCommand = ''/etc/ssh/sourcehut/subdir/srht-dispatch "%u" "%h" "%t" "%k"'';
771         # srht-dispatch will setuid/setgid according to [git.sr.ht::dispatch]
772         authorizedKeysCommandUser = "root";
773         extraConfig = ''
774           PermitUserEnvironment SRHT_*
775         '';
776         startWhenNeeded = false;
777       };
778       environment.etc."ssh/sourcehut/config.ini".source =
779         settingsFormat.generate "sourcehut-dispatch-config.ini"
780           (filterAttrs (k: v: k == "git.sr.ht::dispatch")
781           cfg.settings);
782       environment.etc."ssh/sourcehut/subdir/srht-dispatch" = {
783         # sshd_config(5): The program must be owned by root, not writable by group or others
784         mode = "0755";
785         source = pkgs.writeShellScript "srht-dispatch-wrapper" ''
786           set -e
787           set -x
788           cd /etc/ssh/sourcehut/subdir
789           ${pkgs.sourcehut.gitsrht}/bin/gitsrht-dispatch "$@"
790         '';
791       };
792       systemd.tmpfiles.settings."10-sourcehut-gitsrht" = mkIf cfg.git.enable (
793         mkMerge [
794           (builtins.listToAttrs (map (name: {
795             name = "/var/log/sourcehut/gitsrht-${name}";
796             value.f = {
797               inherit (cfg.git) user group;
798               mode = "0644";
799             };
800           }) [ "keys" "shell" "update-hook" ]))
801           {
802             ${cfg.settings."git.sr.ht".repos}.d = {
803               inherit (cfg.git) user group;
804               mode = "0644";
805             };
806           }
807         ]
808       );
809       systemd.services.sshd = {
810         preStart = mkIf cfg.hg.enable ''
811           chown ${cfg.hg.user}:${cfg.hg.group} /var/log/sourcehut/hgsrht-keys
812         '';
813         serviceConfig = {
814           LogsDirectory = "sourcehut";
815           BindReadOnlyPaths =
816             # Note that those /usr/bin/* paths are hardcoded in multiple places in *.sr.ht,
817             # for instance to get the user from the [git.sr.ht::dispatch] settings.
818             # *srht-keys needs to:
819             # - access a redis-server in [sr.ht] redis-host,
820             # - access the PostgreSQL server in [*.sr.ht] connection-string,
821             # - query metasrht-api (through the HTTP API).
822             # Using this has the side effect of creating empty files in /usr/bin/
823             optionals cfg.builds.enable [
824               "${pkgs.writeShellScript "buildsrht-keys-wrapper" ''
825                 set -e
826                 cd /run/sourcehut/buildsrht/subdir
827                 exec -a "$0" ${pkgs.sourcehut.buildsrht}/bin/buildsrht-keys "$@"
828               ''}:/usr/bin/buildsrht-keys"
829               "${pkgs.sourcehut.buildsrht}/bin/master-shell:/usr/bin/master-shell"
830               "${pkgs.sourcehut.buildsrht}/bin/runner-shell:/usr/bin/runner-shell"
831             ] ++
832             optionals cfg.git.enable [
833               # /path/to/gitsrht-keys calls /path/to/gitsrht-shell,
834               # or [git.sr.ht] shell= if set.
835               "${pkgs.writeShellScript "gitsrht-keys-wrapper" ''
836                 set -e
837                 cd /run/sourcehut/gitsrht/subdir
838                 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-keys "$@"
839               ''}:/usr/bin/gitsrht-keys"
840               "${pkgs.writeShellScript "gitsrht-shell-wrapper" ''
841                 set -e
842                 cd /run/sourcehut/gitsrht/subdir
843                 export PATH="${cfg.git.package}/bin:$PATH"
844                 export SRHT_CONFIG=/run/sourcehut/gitsrht/config.ini
845                 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-shell "$@"
846               ''}:/usr/bin/gitsrht-shell"
847               "${pkgs.writeShellScript "gitsrht-update-hook" ''
848                 set -e
849                 export SRHT_CONFIG=/run/sourcehut/gitsrht/config.ini
850                 # hooks/post-update calls /usr/bin/gitsrht-update-hook as hooks/stage-3
851                 # but this wrapper being a bash script, it overrides $0 with /usr/bin/gitsrht-update-hook
852                 # hence this hack to put hooks/stage-3 back into gitsrht-update-hook's $0
853                 if test "''${STAGE3:+set}"
854                 then
855                   exec -a hooks/stage-3 ${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook "$@"
856                 else
857                   export STAGE3=set
858                   exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook "$@"
859                 fi
860               ''}:/usr/bin/gitsrht-update-hook"
861             ] ++
862             optionals cfg.hg.enable [
863               # /path/to/hgsrht-keys calls /path/to/hgsrht-shell,
864               # or [hg.sr.ht] shell= if set.
865               "${pkgs.writeShellScript "hgsrht-keys-wrapper" ''
866                 set -e
867                 cd /run/sourcehut/hgsrht/subdir
868                 exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-keys "$@"
869               ''}:/usr/bin/hgsrht-keys"
870               "${pkgs.writeShellScript "hgsrht-shell-wrapper" ''
871                 set -e
872                 cd /run/sourcehut/hgsrht/subdir
873                 exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-shell "$@"
874               ''}:/usr/bin/hgsrht-shell"
875               # Mercurial's changegroup hooks are run relative to their repository's directory,
876               # but hgsrht-hook-changegroup looks up ./config.ini
877               "${pkgs.writeShellScript "hgsrht-hook-changegroup" ''
878                 set -e
879                 test -e "''$PWD"/config.ini ||
880                 ln -s /run/sourcehut/hgsrht/config.ini "''$PWD"/config.ini
881                 exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-hook-changegroup "$@"
882               ''}:/usr/bin/hgsrht-hook-changegroup"
883             ];
884         };
885       };
886     })
887   ]);
889   imports = [
891     (import ./service.nix "builds" {
892       inherit configIniOfService;
893       srvsrht = "buildsrht";
894       port = 5002;
895       extraServices.buildsrht-api = {
896         serviceConfig.Restart = "always";
897         serviceConfig.RestartSec = "5s";
898         serviceConfig.ExecStart = "${pkgs.sourcehut.buildsrht}/bin/buildsrht-api -b ${cfg.listenAddress}:${toString (cfg.builds.port + 100)}";
899       };
900       # TODO: a celery worker on the master and worker are apparently needed
901       extraServices.buildsrht-worker = let
902         qemuPackage = pkgs.qemu_kvm;
903         serviceName = "buildsrht-worker";
904         statePath = "/var/lib/sourcehut/${serviceName}";
905         in mkIf cfg.builds.enableWorker {
906         path = [ pkgs.openssh pkgs.docker ];
907         preStart = ''
908           set -x
909           if test -z "$(docker images -q qemu:latest 2>/dev/null)" \
910           || test "$(cat ${statePath}/docker-image-qemu)" != "${qemuPackage.version}"
911           then
912             # Create and import qemu:latest image for docker
913             ${pkgs.dockerTools.streamLayeredImage {
914               name = "qemu";
915               tag = "latest";
916               contents = [ qemuPackage ];
917             }} | docker load
918             # Mark down current package version
919             echo '${qemuPackage.version}' >${statePath}/docker-image-qemu
920           fi
921         '';
922         serviceConfig = {
923           ExecStart = "${pkgs.sourcehut.buildsrht}/bin/buildsrht-worker";
924           BindPaths = [ cfg.settings."builds.sr.ht::worker".buildlogs ];
925           LogsDirectory = [ "sourcehut/${serviceName}" ];
926           RuntimeDirectory = [ "sourcehut/${serviceName}/subdir" ];
927           StateDirectory = [ "sourcehut/${serviceName}" ];
928           TimeoutStartSec = "1800s";
929           # buildsrht-worker looks up ../config.ini
930           WorkingDirectory = "-"+"/run/sourcehut/${serviceName}/subdir";
931         };
932       };
933       extraConfig = let
934         image_dirs = flatten (
935           mapAttrsToList (distro: revs:
936             mapAttrsToList (rev: archs:
937               mapAttrsToList (arch: image:
938                 pkgs.runCommand "buildsrht-images" { } ''
939                   mkdir -p $out/${distro}/${rev}/${arch}
940                   ln -s ${image}/*.qcow2 $out/${distro}/${rev}/${arch}/root.img.qcow2
941                 ''
942               ) archs
943             ) revs
944           ) cfg.builds.images
945         );
946         image_dir_pre = pkgs.symlinkJoin {
947           name = "buildsrht-worker-images-pre";
948           paths = image_dirs;
949             # FIXME: not working, apparently because ubuntu/latest is a broken link
950             # ++ [ "${pkgs.sourcehut.buildsrht}/lib/images" ];
951         };
952         image_dir = pkgs.runCommand "buildsrht-worker-images" { } ''
953           mkdir -p $out/images
954           cp -Lr ${image_dir_pre}/* $out/images
955         '';
956         in mkMerge [
957         {
958           users.users.${cfg.builds.user}.shell = pkgs.bash;
960           virtualisation.docker.enable = true;
962           services.sourcehut.settings = mkMerge [
963             { # Note that git.sr.ht::dispatch is not a typo,
964               # gitsrht-dispatch always use this section
965               "git.sr.ht::dispatch"."/usr/bin/buildsrht-keys" =
966                 mkDefault "${cfg.builds.user}:${cfg.builds.group}";
967             }
968             (mkIf cfg.builds.enableWorker {
969               "builds.sr.ht::worker".shell = "/usr/bin/runner-shell";
970               "builds.sr.ht::worker".images = mkDefault "${image_dir}/images";
971               "builds.sr.ht::worker".controlcmd = mkDefault "${image_dir}/images/control";
972             })
973           ];
974         }
975         (mkIf cfg.builds.enableWorker {
976           users.groups = {
977             docker.members = [ cfg.builds.user ];
978           };
979         })
980         (mkIf (cfg.builds.enableWorker && cfg.nginx.enable) {
981           # Allow nginx access to buildlogs
982           users.users.${nginx.user}.extraGroups = [ cfg.builds.group ];
983           systemd.services.nginx = {
984             serviceConfig.BindReadOnlyPaths = [ cfg.settings."builds.sr.ht::worker".buildlogs ];
985           };
986           services.nginx.virtualHosts."logs.${domain}" = mkMerge [ {
987             /* FIXME: is a listen needed?
988             listen = with builtins;
989               # FIXME: not compatible with IPv6
990               let address = split ":" cfg.settings."builds.sr.ht::worker".name; in
991               [{ addr = elemAt address 0; port = lib.toInt (elemAt address 2); }];
992             */
993             locations."/logs/".alias = cfg.settings."builds.sr.ht::worker".buildlogs + "/";
994           } cfg.nginx.virtualHost ];
995         })
996       ];
997     })
999     (import ./service.nix "git" (let
1000       baseService = {
1001         path = [ cfg.git.package ];
1002         serviceConfig.BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1003       };
1004       in {
1005       inherit configIniOfService;
1006       mainService = mkMerge [ baseService {
1007         serviceConfig.StateDirectory = [ "sourcehut/gitsrht" "sourcehut/gitsrht/repos" ];
1008         preStart = mkIf (versionOlder config.system.stateVersion "22.05") (mkBefore ''
1009           # Fix Git hooks of repositories pre-dating https://github.com/NixOS/nixpkgs/pull/133984
1010           (
1011           set +f
1012           shopt -s nullglob
1013           for h in /var/lib/sourcehut/gitsrht/repos/~*/*/hooks/{pre-receive,update,post-update}
1014           do ln -fnsv /usr/bin/gitsrht-update-hook "$h"; done
1015           )
1016         '');
1017       } ];
1018       port = 5001;
1019       webhooks = true;
1020       extraTimers.gitsrht-periodic = {
1021         service = baseService;
1022         timerConfig.OnCalendar = ["*:0/20"];
1023       };
1024       extraConfig = mkMerge [
1025         {
1026           # https://stackoverflow.com/questions/22314298/git-push-results-in-fatal-protocol-error-bad-line-length-character-this
1027           # Probably could use gitsrht-shell if output is restricted to just parameters...
1028           users.users.${cfg.git.user}.shell = pkgs.bash;
1029           services.sourcehut.settings = {
1030             "git.sr.ht::dispatch"."/usr/bin/gitsrht-keys" =
1031               mkDefault "${cfg.git.user}:${cfg.git.group}";
1032           };
1033           systemd.services.sshd = baseService;
1034         }
1035         (mkIf cfg.nginx.enable {
1036           services.nginx.virtualHosts."git.${domain}" = {
1037             locations."/authorize" = {
1038               proxyPass = "http://${cfg.listenAddress}:${toString cfg.git.port}";
1039               extraConfig = ''
1040                 proxy_pass_request_body off;
1041                 proxy_set_header Content-Length "";
1042                 proxy_set_header X-Original-URI $request_uri;
1043               '';
1044             };
1045             locations."~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$" = {
1046               root = "/var/lib/sourcehut/gitsrht/repos";
1047               fastcgiParams = {
1048                 GIT_HTTP_EXPORT_ALL = "";
1049                 GIT_PROJECT_ROOT = "$document_root";
1050                 PATH_INFO = "$uri";
1051                 SCRIPT_FILENAME = "${cfg.git.package}/bin/git-http-backend";
1052               };
1053               extraConfig = ''
1054                 auth_request /authorize;
1055                 fastcgi_read_timeout 500s;
1056                 fastcgi_pass unix:/run/gitsrht-fcgiwrap.sock;
1057                 gzip off;
1058               '';
1059             };
1060           };
1061           systemd.sockets.gitsrht-fcgiwrap = {
1062             before = [ "nginx.service" ];
1063             wantedBy = [ "sockets.target" "gitsrht.service" ];
1064             # This path remains accessible to nginx.service, which has no RootDirectory=
1065             socketConfig.ListenStream = "/run/gitsrht-fcgiwrap.sock";
1066             socketConfig.SocketUser = nginx.user;
1067             socketConfig.SocketMode = "600";
1068           };
1069         })
1070       ];
1071       extraServices.gitsrht-api.serviceConfig = {
1072         Restart = "always";
1073         RestartSec = "5s";
1074         ExecStart = "${pkgs.sourcehut.gitsrht}/bin/gitsrht-api -b ${cfg.listenAddress}:${toString (cfg.git.port + 100)}";
1075         BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1076       };
1077       extraServices.gitsrht-fcgiwrap = mkIf cfg.nginx.enable {
1078         serviceConfig = {
1079           # Socket is passed by gitsrht-fcgiwrap.socket
1080           ExecStart = "${pkgs.fcgiwrap}/sbin/fcgiwrap -c ${toString cfg.git.fcgiwrap.preforkProcess}";
1081           # No need for config.ini
1082           ExecStartPre = mkForce [];
1083           User = null;
1084           DynamicUser = true;
1085           BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1086           IPAddressDeny = "any";
1087           InaccessiblePaths = [ "-+/run/postgresql" "-+/run/redis-sourcehut" ];
1088           PrivateNetwork = true;
1089           RestrictAddressFamilies = mkForce [ "none" ];
1090           SystemCallFilter = mkForce [
1091             "@system-service"
1092             "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid"
1093             # @timer is needed for alarm()
1094           ];
1095         };
1096       };
1097     }))
1099     (import ./service.nix "hg" (let
1100       baseService = {
1101         path = [ cfg.hg.package ];
1102         serviceConfig.BindPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/sourcehut/hgsrht/repos" ];
1103       };
1104       in {
1105       inherit configIniOfService;
1106       mainService = mkMerge [ baseService {
1107         serviceConfig.StateDirectory = [ "sourcehut/hgsrht" "sourcehut/hgsrht/repos" ];
1108       } ];
1109       port = 5010;
1110       webhooks = true;
1111       extraTimers.hgsrht-periodic = {
1112         service = baseService;
1113         timerConfig.OnCalendar = ["*:0/20"];
1114       };
1115       extraTimers.hgsrht-clonebundles = mkIf cfg.hg.cloneBundles {
1116         service = baseService;
1117         timerConfig.OnCalendar = ["daily"];
1118         timerConfig.AccuracySec = "1h";
1119       };
1120       extraServices.hgsrht-api = {
1121         serviceConfig.Restart = "always";
1122         serviceConfig.RestartSec = "5s";
1123         serviceConfig.ExecStart = "${pkgs.sourcehut.hgsrht}/bin/hgsrht-api -b ${cfg.listenAddress}:${toString (cfg.hg.port + 100)}";
1124       };
1125       extraConfig = mkMerge [
1126         {
1127           users.users.${cfg.hg.user}.shell = pkgs.bash;
1128           services.sourcehut.settings = {
1129             # Note that git.sr.ht::dispatch is not a typo,
1130             # gitsrht-dispatch always uses this section.
1131             "git.sr.ht::dispatch"."/usr/bin/hgsrht-keys" =
1132               mkDefault "${cfg.hg.user}:${cfg.hg.group}";
1133           };
1134           systemd.services.sshd = baseService;
1135         }
1136         (mkIf cfg.nginx.enable {
1137           # Allow nginx access to repositories
1138           users.users.${nginx.user}.extraGroups = [ cfg.hg.group ];
1139           services.nginx.virtualHosts."hg.${domain}" = {
1140             locations."/authorize" = {
1141               proxyPass = "http://${cfg.listenAddress}:${toString cfg.hg.port}";
1142               extraConfig = ''
1143                 proxy_pass_request_body off;
1144                 proxy_set_header Content-Length "";
1145                 proxy_set_header X-Original-URI $request_uri;
1146               '';
1147             };
1148             # Let clients reach pull bundles. We don't really need to lock this down even for
1149             # private repos because the bundles are named after the revision hashes...
1150             # so someone would need to know or guess a SHA value to download anything.
1151             # TODO: proxyPass to an hg serve service?
1152             locations."~ ^/[~^][a-z0-9_]+/[a-zA-Z0-9_.-]+/\\.hg/bundles/.*$" = {
1153               root = "/var/lib/nginx/hgsrht/repos";
1154               extraConfig = ''
1155                 auth_request /authorize;
1156                 gzip off;
1157               '';
1158             };
1159           };
1160           systemd.services.nginx = {
1161             serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/nginx/hgsrht/repos" ];
1162           };
1163         })
1164       ];
1165     }))
1167     (import ./service.nix "hub" {
1168       inherit configIniOfService;
1169       port = 5014;
1170       extraConfig = {
1171         services.nginx = mkIf cfg.nginx.enable {
1172           virtualHosts."hub.${domain}" = mkMerge [ {
1173             serverAliases = [ domain ];
1174           } cfg.nginx.virtualHost ];
1175         };
1176       };
1177     })
1179     (import ./service.nix "lists" (let
1180       srvsrht = "listssrht";
1181       in {
1182       inherit configIniOfService;
1183       port = 5006;
1184       webhooks = true;
1185       extraServices.listssrht-api = {
1186         serviceConfig.Restart = "always";
1187         serviceConfig.RestartSec = "5s";
1188         serviceConfig.ExecStart = "${pkgs.sourcehut.listssrht}/bin/listssrht-api -b ${cfg.listenAddress}:${toString (cfg.lists.port + 100)}";
1189       };
1190       # Receive the mail from Postfix and enqueue them into Redis and PostgreSQL
1191       extraServices.listssrht-lmtp = {
1192         wants = [ "postfix.service" ];
1193         unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1194         serviceConfig.ExecStart = "${pkgs.sourcehut.listssrht}/bin/listssrht-lmtp";
1195         # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1196         serviceConfig.PrivateUsers = mkForce false;
1197       };
1198       # Dequeue the mails from Redis and dispatch them
1199       extraServices.listssrht-process = {
1200         serviceConfig = {
1201           preStart = ''
1202             cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" cfg.lists.process.celeryConfig} \
1203                /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
1204           '';
1205           ExecStart = "${cfg.python}/bin/celery --app listssrht.process worker --hostname listssrht-process@%%h " + concatStringsSep " " cfg.lists.process.extraArgs;
1206           # Avoid crashing: os.getloadavg()
1207           ProcSubset = mkForce "all";
1208         };
1209       };
1210       extraConfig = mkIf cfg.postfix.enable {
1211         users.groups.${postfix.group}.members = [ cfg.lists.user ];
1212         services.sourcehut.settings."lists.sr.ht::mail".sock-group = postfix.group;
1213         services.postfix = {
1214           destination = [ "lists.${domain}" ];
1215           # FIXME: an accurate recipient list should be queried
1216           # from the lists.sr.ht PostgreSQL database to avoid backscattering.
1217           # But usernames are unfortunately not in that database but in meta.sr.ht.
1218           # Note that two syntaxes are allowed:
1219           # - ~username/list-name@lists.${domain}
1220           # - u.username.list-name@lists.${domain}
1221           localRecipients = [ "@lists.${domain}" ];
1222           transport = ''
1223             lists.${domain} lmtp:unix:${cfg.settings."lists.sr.ht::worker".sock}
1224           '';
1225         };
1226       };
1227     }))
1229     (import ./service.nix "man" {
1230       inherit configIniOfService;
1231       port = 5004;
1232     })
1234     (import ./service.nix "meta" {
1235       inherit configIniOfService;
1236       port = 5000;
1237       webhooks = true;
1238       extraTimers.metasrht-daily.timerConfig = {
1239         OnCalendar = ["daily"];
1240         AccuracySec = "1h";
1241       };
1242       extraServices.metasrht-api = {
1243         serviceConfig.Restart = "always";
1244         serviceConfig.RestartSec = "5s";
1245         preStart = "set -x\n" + concatStringsSep "\n\n" (attrValues (mapAttrs (k: s:
1246           let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht$" k;
1247               srv = head srvMatch;
1248           in
1249           # Configure client(s) as "preauthorized"
1250           optionalString (srvMatch != null && cfg.${srv}.enable && ((s.oauth-client-id or null) != null)) ''
1251             # Configure ${srv}'s OAuth client as "preauthorized"
1252             ${postgresql.package}/bin/psql '${cfg.settings."meta.sr.ht".connection-string}' \
1253               -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${s.oauth-client-id}'"
1254           ''
1255           ) cfg.settings));
1256         serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b ${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
1257       };
1258       extraConfig = {
1259         assertions = [
1260           { assertion = let s = cfg.settings."meta.sr.ht::billing"; in
1261                         s.enabled == "yes" -> (s.stripe-public-key != null && s.stripe-secret-key != null);
1262             message = "If meta.sr.ht::billing is enabled, the keys must be defined.";
1263           }
1264         ];
1265         environment.systemPackages = optional cfg.meta.enable
1266           (pkgs.writeShellScriptBin "metasrht-manageuser" ''
1267             set -eux
1268             if test "$(${pkgs.coreutils}/bin/id -n -u)" != '${cfg.meta.user}'
1269             then exec sudo -u '${cfg.meta.user}' "$0" "$@"
1270             else
1271               # In order to load config.ini
1272               if cd /run/sourcehut/metasrht
1273               then exec ${pkgs.sourcehut.metasrht}/bin/metasrht-manageuser "$@"
1274               else cat <<EOF
1275                 Please run: sudo systemctl start metasrht
1276             EOF
1277                 exit 1
1278               fi
1279             fi
1280           '');
1281       };
1282     })
1284     (import ./service.nix "pages" {
1285       inherit configIniOfService;
1286       port = 5112;
1287       mainService = let
1288         srvsrht = "pagessrht";
1289         version = pkgs.sourcehut.${srvsrht}.version;
1290         stateDir = "/var/lib/sourcehut/${srvsrht}";
1291         iniKey = "pages.sr.ht";
1292         in {
1293         preStart = mkBefore ''
1294           set -x
1295           # Use the /run/sourcehut/${srvsrht}/config.ini
1296           # installed by a previous ExecStartPre= in baseService
1297           cd /run/sourcehut/${srvsrht}
1299           if test ! -e ${stateDir}/db; then
1300             ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f ${pkgs.sourcehut.pagessrht}/share/sql/schema.sql
1301             echo ${version} >${stateDir}/db
1302           fi
1304           ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
1305             # Just try all the migrations because they're not linked to the version
1306             for sql in ${pkgs.sourcehut.pagessrht}/share/sql/migrations/*.sql; do
1307               ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f "$sql" || true
1308             done
1309           ''}
1311           # Disable webhook
1312           touch ${stateDir}/webhook
1313         '';
1314         serviceConfig = {
1315           ExecStart = mkForce "${pkgs.sourcehut.pagessrht}/bin/pages.sr.ht -b ${cfg.listenAddress}:${toString cfg.pages.port}";
1316         };
1317       };
1318     })
1320     (import ./service.nix "paste" {
1321       inherit configIniOfService;
1322       port = 5011;
1323       extraServices.pastesrht-api = {
1324         serviceConfig.Restart = "always";
1325         serviceConfig.RestartSec = "5s";
1326         serviceConfig.ExecStart = "${pkgs.sourcehut.pastesrht}/bin/pastesrht-api -b ${cfg.listenAddress}:${toString (cfg.paste.port + 100)}";
1327       };
1328     })
1330     (import ./service.nix "todo" {
1331       inherit configIniOfService;
1332       port = 5003;
1333       webhooks = true;
1334       extraServices.todosrht-api = {
1335         serviceConfig.Restart = "always";
1336         serviceConfig.RestartSec = "5s";
1337         serviceConfig.ExecStart = "${pkgs.sourcehut.todosrht}/bin/todosrht-api -b ${cfg.listenAddress}:${toString (cfg.todo.port + 100)}";
1338       };
1339       extraServices.todosrht-lmtp = {
1340         wants = [ "postfix.service" ];
1341         unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1342         serviceConfig.ExecStart = "${pkgs.sourcehut.todosrht}/bin/todosrht-lmtp";
1343         # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1344         serviceConfig.PrivateUsers = mkForce false;
1345       };
1346       extraConfig = mkIf cfg.postfix.enable {
1347         users.groups.${postfix.group}.members = [ cfg.todo.user ];
1348         services.sourcehut.settings."todo.sr.ht::mail".sock-group = postfix.group;
1349         services.postfix = {
1350           destination = [ "todo.${domain}" ];
1351           # FIXME: an accurate recipient list should be queried
1352           # from the todo.sr.ht PostgreSQL database to avoid backscattering.
1353           # But usernames are unfortunately not in that database but in meta.sr.ht.
1354           # Note that two syntaxes are allowed:
1355           # - ~username/tracker-name@todo.${domain}
1356           # - u.username.tracker-name@todo.${domain}
1357           localRecipients = [ "@todo.${domain}" ];
1358           transport = ''
1359             todo.${domain} lmtp:unix:${cfg.settings."todo.sr.ht::mail".sock}
1360           '';
1361         };
1362       };
1363     })
1365     (mkRenamedOptionModule [ "services" "sourcehut" "originBase" ]
1366                            [ "services" "sourcehut" "settings" "sr.ht" "global-domain" ])
1367     (mkRenamedOptionModule [ "services" "sourcehut" "address" ]
1368                            [ "services" "sourcehut" "listenAddress" ])
1370     (mkRemovedOptionModule [ "services" "sourcehut" "dispatch" ] ''
1371         dispatch is deprecated. See https://sourcehut.org/blog/2022-08-01-dispatch-deprecation-plans/
1372         for more information.
1373     '')
1375     (mkRemovedOptionModule [ "services" "sourcehut" "services"] ''
1376         This option was removed in favor of individual <service>.enable flags.
1377     '')
1378   ];
1380   meta.doc = ./default.md;
1381   meta.maintainers = with maintainers; [ tomberek nessdoor christoph-heiss ];