1 { config, pkgs, lib, ... }:
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 {});
20 optionalString (v != null)
21 (generators.mkKeyValueDefault {
23 if v == true then "yes"
24 else if v == false then "no"
25 else generators.mkValueStringDefault {} v;
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
36 # Enable Web links and integrations between services.
37 else if tail srvMatch == [ null ] && cfg.${head srvMatch}.enable
40 # mansrht crashes without it
41 oauth-client-id = v.oauth-client-id or null;
43 # Drop sub-sections of other services
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;
57 commonServiceSettings = srv: {
59 description = "URL ${srv}.sr.ht is being served at (protocol://domain)";
61 default = "https://${srv}.${domain}";
62 defaultText = "https://${srv}.example.com";
64 debug-host = mkOption {
65 description = "Address to bind the debug server to.";
66 type = with types; nullOr str;
69 debug-port = mkOption {
70 description = "Port to bind the debug server to.";
71 type = with types; nullOr str;
74 connection-string = mkOption {
75 description = "SQLAlchemy connection string for the database.";
77 default = "postgresql:///localhost?user=${srv}srht&host=/run/postgresql";
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.";
84 oauth-client-secret = mkOption {
85 description = "${srv}.sr.ht's OAuth client secret for meta.sr.ht.";
87 apply = s: "<" + toString s;
89 api-origin = mkOption {
90 description = "Origin URL for the API";
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)}"`
99 # Specialized python containing all the modules
100 python = pkgs.sourcehut.python.withPackages (ps: with ps; [
103 # For monitoring Celery: sudo -u listssrht celery --app listssrht.process -b redis+socket:///run/redis-sourcehut/redis.sock?virtual_host=1 flower
114 # Not a python package
119 mkOptionNullOrStr = description: mkOption {
120 description = description;
121 type = with types; nullOr str;
126 options.services.sourcehut = {
127 enable = mkEnableOption ''
128 sourcehut - git hosting, continuous integration, mailing list, ticket tracking, wiki
129 and account management services
132 listenAddress = mkOption {
134 default = "localhost";
135 description = "Address to bind to.";
140 type = types.package;
143 The python package to use. It should contain references to the *srht modules and also
149 enable = mkEnableOption ''local minio integration'';
153 enable = mkEnableOption ''local nginx integration'';
154 virtualHost = mkOption {
157 description = "Virtual-host configuration merged with all Sourcehut's virtual-hosts.";
162 enable = mkEnableOption ''local postfix integration'';
166 enable = mkEnableOption ''local postgresql integration'';
170 enable = mkEnableOption ''local redis integration in a dedicated redis-server'';
173 settings = mkOption {
174 type = lib.types.submodule {
175 freeformType = settingsFormat.type;
177 global-domain = mkOption {
178 description = "Global domain name.";
180 example = "example.com";
182 environment = mkOption {
183 description = "Values other than \"production\" adds a banner to each page.";
184 type = types.enum [ "development" "production" ];
185 default = "development";
187 network-key = mkOption {
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.
194 apply = s: "<" + toString s;
196 owner-email = mkOption {
197 description = "Owner's email.";
199 default = "contact@example.com";
201 owner-name = mkOption {
202 description = "Owner's name.";
204 default = "John Doe";
206 site-blurb = mkOption {
207 description = "Blurb for your site.";
209 default = "the hacker's forge";
211 site-info = mkOption {
212 description = "The top-level info page for your site.";
214 default = "https://sourcehut.org";
216 service-key = mkOption {
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.
226 apply = s: "<" + toString s;
228 site-name = mkOption {
229 description = "The name of your network of sr.ht-based sites.";
231 default = "sourcehut";
233 source-url = mkOption {
234 description = "The source code for your fork of sr.ht.";
236 default = "https://git.sr.ht/~sircmpwn/srht";
240 smtp-host = mkOptionNullOrStr "Outgoing SMTP host.";
241 smtp-port = mkOption {
242 description = "Outgoing SMTP port.";
243 type = with types; nullOr port;
246 smtp-user = mkOptionNullOrStr "Outgoing SMTP user.";
247 smtp-password = mkOptionNullOrStr "Outgoing SMTP password.";
248 smtp-from = mkOption {
250 description = "Outgoing SMTP FROM.";
252 error-to = mkOptionNullOrStr "Address receiving application exceptions";
253 error-from = mkOptionNullOrStr "Address sending application exceptions";
254 pgp-privkey = mkOption {
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.
266 pgp-pubkey = mkOption {
267 type = with types; either path str;
268 description = "OpenPGP public key.";
270 pgp-key-id = mkOption {
272 description = "OpenPGP key identifier.";
276 s3-upstream = mkOption {
277 description = "Configure the S3-compatible object storage service.";
278 type = with types; nullOr str;
281 s3-access-key = mkOption {
282 description = "Access key to the S3-compatible object storage service";
283 type = with types; nullOr str;
286 s3-secret-key = mkOption {
288 An absolute file path (which should be outside the Nix-store)
289 to the secret key of the S3-compatible object storage service.
291 type = with types; nullOr path;
293 apply = mapNullable (s: "<" + toString s);
297 private-key = mkOption {
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.
307 apply = s: "<" + toString s;
311 options."builds.sr.ht" = commonServiceSettings "builds" // {
312 allow-free = mkEnableOption "nonpaying users to submit builds";
314 description = "The Redis connection used for the Celery worker.";
316 default = "redis+socket:///run/redis-sourcehut-buildsrht/redis.sock?virtual_host=2";
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`.
326 type = types.enum ["/usr/bin/master-shell" "/usr/bin/runner-shell"];
327 default = "/usr/bin/master-shell";
330 options."builds.sr.ht::worker" = {
331 bind-address = mkOption {
333 HTTP bind address for serving local build information/monitoring.
336 default = "localhost:8080";
338 buildlogs = mkOption {
339 description = "Path to write build logs.";
341 default = "/var/log/sourcehut/buildsrht-worker";
345 Listening address and listening port
346 of the build runner (with HTTP port if not 80).
349 default = "localhost:5020";
354 See <https://golang.org/pkg/time/#ParseDuration>.
361 options."git.sr.ht" = commonServiceSettings "git" // {
362 outgoing-domain = mkOption {
363 description = "Outgoing domain.";
365 default = "https://git.localhost.localdomain";
367 post-update-script = mkOption {
369 A post-update script which is installed in every git repo.
370 This setting is propagated to newer and existing repositories.
373 default = "${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
374 defaultText = "\${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
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.
383 default = "/var/lib/sourcehut/gitsrht/repos";
385 webhooks = mkOption {
386 description = "The Redis connection used for the webhooks worker.";
388 default = "redis+socket:///run/redis-sourcehut-gitsrht/redis.sock?virtual_host=1";
391 options."git.sr.ht::api" = {
392 internal-ipnet = mkOption {
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).
399 type = with types; listOf str;
400 default = [ "127.0.0.0/8" "::1/128" ];
404 options."hg.sr.ht" = commonServiceSettings "hg" // {
405 changegroup-script = mkOption {
407 A changegroup script which is installed in every mercurial repo.
408 This setting is propagated to newer and existing repositories.
411 default = "${pkgs.sourcehut.hgsrht}/bin/hgsrht-hook-changegroup";
412 defaultText = "\${pkgs.sourcehut.hgsrht}/bin/hgsrht-hook-changegroup";
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.
421 default = "/var/lib/sourcehut/hgsrht/repos";
423 srhtext = mkOptionNullOrStr ''
424 Path to the srht mercurial extension
425 (defaults to where the hgsrht code is)
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;
433 description = "Path to hg-ssh (if not in $PATH).";
435 default = "${pkgs.mercurial}/bin/hg-ssh";
436 defaultText = "\${pkgs.mercurial}/bin/hg-ssh";
438 webhooks = mkOption {
439 description = "The Redis connection used for the webhooks worker.";
441 default = "redis+socket:///run/redis-sourcehut-hgsrht/redis.sock?virtual_host=1";
445 options."hub.sr.ht" = commonServiceSettings "hub" // {
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.";
453 default = "lists-notify@localhost.localdomain";
455 posting-domain = mkOption {
456 description = "Posting domain.";
458 default = "lists.localhost.localdomain";
461 description = "The Redis connection used for the Celery worker.";
463 default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=2";
465 webhooks = mkOption {
466 description = "The Redis connection used for the webhooks worker.";
468 default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=1";
471 options."lists.sr.ht::worker" = {
472 reject-mimetypes = mkOption {
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.
480 type = with types; listOf str;
481 default = ["text/html"];
483 reject-url = mkOption {
484 description = "Reject URL.";
486 default = "https://man.sr.ht/lists.sr.ht/etiquette.md";
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.
494 default = "/tmp/lists.sr.ht-lmtp.sock";
496 sock-group = mkOption {
498 The lmtp daemon will make the unix socket group-read/write
499 for users in this group.
506 options."man.sr.ht" = commonServiceSettings "man" // {
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.";
515 default = "redis+socket:///run/redis-sourcehut-metasrht/redis.sock?virtual_host=1";
517 welcome-emails = mkEnableOption "sending stock sourcehut welcome emails after signup";
519 options."meta.sr.ht::api" = {
520 internal-ipnet = mkOption {
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).
527 type = with types; listOf str;
528 default = [ "127.0.0.0/8" "::1/128" ];
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;
535 example = { "git.sr.ht" = 12345; };
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
544 apply = mapNullable (s: "<" + toString s);
547 options."meta.sr.ht::settings" = {
548 registration = mkEnableOption "public registration";
549 onboarding-redirect = mkOption {
550 description = "Where to redirect new users upon registration.";
552 default = "https://meta.localhost.localdomain";
554 user-invites = mkOption {
556 How many invites each user is issued upon registration
557 (only applicable if open registration is disabled).
559 type = types.ints.unsigned;
564 options."pages.sr.ht" = commonServiceSettings "pages" // {
565 gemini-certs = mkOption {
567 An absolute file path (which should be outside the Nix-store)
568 to Gemini certificates.
570 type = with types; nullOr path;
573 max-site-size = mkOption {
574 description = "Maximum size of any given site (post-gunzip), in MiB.";
578 user-domain = mkOption {
580 Configures the user domain, if enabled.
581 All users are given \<username\>.this.domain.
583 type = with types; nullOr str;
587 options."pages.sr.ht::api" = {
588 internal-ipnet = mkOption {
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).
595 type = with types; listOf str;
596 default = [ "127.0.0.0/8" "::1/128" ];
600 options."paste.sr.ht" = commonServiceSettings "paste" // {
603 options."todo.sr.ht" = commonServiceSettings "todo" // {
604 notify-from = mkOption {
605 description = "Outgoing email for notifications generated by users.";
607 default = "todo-notify@localhost.localdomain";
609 webhooks = mkOption {
610 description = "The Redis connection used for the webhooks worker.";
612 default = "redis+socket:///run/redis-sourcehut-todosrht/redis.sock?virtual_host=1";
615 options."todo.sr.ht::mail" = {
616 posting-domain = mkOption {
617 description = "Posting domain.";
619 default = "todo.localhost.localdomain";
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.
627 default = "/tmp/todo.sr.ht-lmtp.sock";
629 sock-group = mkOption {
631 The lmtp daemon will make the unix socket group-read/write
632 for users in this group.
641 The configuration for the sourcehut network.
646 enableWorker = mkEnableOption ''
647 worker for builds.sr.ht
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>.
659 type = with types; attrsOf (attrsOf (attrsOf package));
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";
668 image_from_nixpkgs = (import ("''${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") {
669 pkgs = (import pkgs_unstable {});
673 nixos.unstable.x86_64 = image_from_nixpkgs;
677 Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2.
683 package = mkPackageOption pkgs "git" {
686 fcgiwrap.preforkProcess = mkOption {
687 description = "Number of fcgiwrap processes to prefork.";
694 package = mkPackageOption pkgs "mercurial" { };
695 cloneBundles = mkOption {
699 Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories).
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.";
711 celeryConfig = mkOption {
714 description = "Content of the `celeryconfig.py` used by the Celery of `listssrht-process`.";
720 config = mkIf cfg.enable (mkMerge [
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}";
733 (mkIf cfg.postgresql.enable {
735 { assertion = postgresql.enable;
736 message = "postgresql must be enabled and configured";
740 (mkIf cfg.postfix.enable {
742 { assertion = postfix.enable;
743 message = "postfix must be enabled and configured";
746 # Needed for sharing the LMTP sockets with JoinsNamespaceOf=
747 systemd.services.postfix.serviceConfig.PrivateTmp = true;
749 (mkIf cfg.redis.enable {
750 services.redis.vmOverCommit = mkDefault true;
752 (mkIf cfg.nginx.enable {
754 { assertion = nginx.enable;
755 message = "nginx must be enabled and configured";
758 # For proxyPass= in virtual-hosts for Sourcehut services.
759 services.nginx.recommendedProxySettings = mkDefault true;
761 (mkIf (cfg.builds.enable || cfg.git.enable || cfg.hg.enable) {
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";
774 PermitUserEnvironment SRHT_*
776 startWhenNeeded = false;
778 environment.etc."ssh/sourcehut/config.ini".source =
779 settingsFormat.generate "sourcehut-dispatch-config.ini"
780 (filterAttrs (k: v: k == "git.sr.ht::dispatch")
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
785 source = pkgs.writeShellScript "srht-dispatch-wrapper" ''
788 cd /etc/ssh/sourcehut/subdir
789 ${pkgs.sourcehut.gitsrht}/bin/gitsrht-dispatch "$@"
792 systemd.tmpfiles.settings."10-sourcehut-gitsrht" = mkIf cfg.git.enable (
794 (builtins.listToAttrs (map (name: {
795 name = "/var/log/sourcehut/gitsrht-${name}";
797 inherit (cfg.git) user group;
800 }) [ "keys" "shell" "update-hook" ]))
802 ${cfg.settings."git.sr.ht".repos}.d = {
803 inherit (cfg.git) user group;
809 systemd.services.sshd = {
810 preStart = mkIf cfg.hg.enable ''
811 chown ${cfg.hg.user}:${cfg.hg.group} /var/log/sourcehut/hgsrht-keys
814 LogsDirectory = "sourcehut";
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" ''
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"
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" ''
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" ''
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" ''
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}"
855 exec -a hooks/stage-3 ${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook "$@"
858 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook "$@"
860 ''}:/usr/bin/gitsrht-update-hook"
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" ''
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" ''
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" ''
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"
891 (import ./service.nix "builds" {
892 inherit configIniOfService;
893 srvsrht = "buildsrht";
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)}";
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 ];
909 if test -z "$(docker images -q qemu:latest 2>/dev/null)" \
910 || test "$(cat ${statePath}/docker-image-qemu)" != "${qemuPackage.version}"
912 # Create and import qemu:latest image for docker
913 ${pkgs.dockerTools.streamLayeredImage {
916 contents = [ qemuPackage ];
918 # Mark down current package version
919 echo '${qemuPackage.version}' >${statePath}/docker-image-qemu
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";
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
946 image_dir_pre = pkgs.symlinkJoin {
947 name = "buildsrht-worker-images-pre";
949 # FIXME: not working, apparently because ubuntu/latest is a broken link
950 # ++ [ "${pkgs.sourcehut.buildsrht}/lib/images" ];
952 image_dir = pkgs.runCommand "buildsrht-worker-images" { } ''
954 cp -Lr ${image_dir_pre}/* $out/images
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}";
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";
975 (mkIf cfg.builds.enableWorker {
977 docker.members = [ cfg.builds.user ];
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 ];
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); }];
993 locations."/logs/".alias = cfg.settings."builds.sr.ht::worker".buildlogs + "/";
994 } cfg.nginx.virtualHost ];
999 (import ./service.nix "git" (let
1001 path = [ cfg.git.package ];
1002 serviceConfig.BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
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
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
1020 extraTimers.gitsrht-periodic = {
1021 service = baseService;
1022 timerConfig.OnCalendar = ["*:0/20"];
1024 extraConfig = mkMerge [
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}";
1033 systemd.services.sshd = baseService;
1035 (mkIf cfg.nginx.enable {
1036 services.nginx.virtualHosts."git.${domain}" = {
1037 locations."/authorize" = {
1038 proxyPass = "http://${cfg.listenAddress}:${toString cfg.git.port}";
1040 proxy_pass_request_body off;
1041 proxy_set_header Content-Length "";
1042 proxy_set_header X-Original-URI $request_uri;
1045 locations."~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$" = {
1046 root = "/var/lib/sourcehut/gitsrht/repos";
1048 GIT_HTTP_EXPORT_ALL = "";
1049 GIT_PROJECT_ROOT = "$document_root";
1051 SCRIPT_FILENAME = "${cfg.git.package}/bin/git-http-backend";
1054 auth_request /authorize;
1055 fastcgi_read_timeout 500s;
1056 fastcgi_pass unix:/run/gitsrht-fcgiwrap.sock;
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";
1071 extraServices.gitsrht-api.serviceConfig = {
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" ];
1077 extraServices.gitsrht-fcgiwrap = mkIf cfg.nginx.enable {
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 [];
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 [
1092 "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid"
1093 # @timer is needed for alarm()
1099 (import ./service.nix "hg" (let
1101 path = [ cfg.hg.package ];
1102 serviceConfig.BindPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/sourcehut/hgsrht/repos" ];
1105 inherit configIniOfService;
1106 mainService = mkMerge [ baseService {
1107 serviceConfig.StateDirectory = [ "sourcehut/hgsrht" "sourcehut/hgsrht/repos" ];
1111 extraTimers.hgsrht-periodic = {
1112 service = baseService;
1113 timerConfig.OnCalendar = ["*:0/20"];
1115 extraTimers.hgsrht-clonebundles = mkIf cfg.hg.cloneBundles {
1116 service = baseService;
1117 timerConfig.OnCalendar = ["daily"];
1118 timerConfig.AccuracySec = "1h";
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)}";
1125 extraConfig = mkMerge [
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}";
1134 systemd.services.sshd = baseService;
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}";
1143 proxy_pass_request_body off;
1144 proxy_set_header Content-Length "";
1145 proxy_set_header X-Original-URI $request_uri;
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";
1155 auth_request /authorize;
1160 systemd.services.nginx = {
1161 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/nginx/hgsrht/repos" ];
1167 (import ./service.nix "hub" {
1168 inherit configIniOfService;
1171 services.nginx = mkIf cfg.nginx.enable {
1172 virtualHosts."hub.${domain}" = mkMerge [ {
1173 serverAliases = [ domain ];
1174 } cfg.nginx.virtualHost ];
1179 (import ./service.nix "lists" (let
1180 srvsrht = "listssrht";
1182 inherit configIniOfService;
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)}";
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;
1198 # Dequeue the mails from Redis and dispatch them
1199 extraServices.listssrht-process = {
1202 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" cfg.lists.process.celeryConfig} \
1203 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
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";
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}" ];
1223 lists.${domain} lmtp:unix:${cfg.settings."lists.sr.ht::worker".sock}
1229 (import ./service.nix "man" {
1230 inherit configIniOfService;
1234 (import ./service.nix "meta" {
1235 inherit configIniOfService;
1238 extraTimers.metasrht-daily.timerConfig = {
1239 OnCalendar = ["daily"];
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;
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}'"
1256 serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b ${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
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.";
1265 environment.systemPackages = optional cfg.meta.enable
1266 (pkgs.writeShellScriptBin "metasrht-manageuser" ''
1268 if test "$(${pkgs.coreutils}/bin/id -n -u)" != '${cfg.meta.user}'
1269 then exec sudo -u '${cfg.meta.user}' "$0" "$@"
1271 # In order to load config.ini
1272 if cd /run/sourcehut/metasrht
1273 then exec ${pkgs.sourcehut.metasrht}/bin/metasrht-manageuser "$@"
1275 Please run: sudo systemctl start metasrht
1284 (import ./service.nix "pages" {
1285 inherit configIniOfService;
1288 srvsrht = "pagessrht";
1289 version = pkgs.sourcehut.${srvsrht}.version;
1290 stateDir = "/var/lib/sourcehut/${srvsrht}";
1291 iniKey = "pages.sr.ht";
1293 preStart = mkBefore ''
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
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
1312 touch ${stateDir}/webhook
1315 ExecStart = mkForce "${pkgs.sourcehut.pagessrht}/bin/pages.sr.ht -b ${cfg.listenAddress}:${toString cfg.pages.port}";
1320 (import ./service.nix "paste" {
1321 inherit configIniOfService;
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)}";
1330 (import ./service.nix "todo" {
1331 inherit configIniOfService;
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)}";
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;
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}" ];
1359 todo.${domain} lmtp:unix:${cfg.settings."todo.sr.ht::mail".sock}
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.
1375 (mkRemovedOptionModule [ "services" "sourcehut" "services"] ''
1376 This option was removed in favor of individual <service>.enable flags.
1380 meta.doc = ./default.md;
1381 meta.maintainers = with maintainers; [ tomberek nessdoor christoph-heiss ];