1 { config, pkgs, lib, ... }:
4 inherit (config.services) nginx postfix postgresql redis;
5 inherit (config.users) users groups;
6 cfg = config.services.sourcehut;
7 domain = cfg.settings."sr.ht".global-domain;
8 settingsFormat = pkgs.formats.ini {
9 listToValue = concatMapStringsSep "," (generators.mkValueStringDefault {});
12 else generators.mkKeyValueDefault {
14 if v == true then "yes"
15 else if v == false then "no"
16 else generators.mkValueStringDefault {} v;
19 configIniOfService = srv: settingsFormat.generate "sourcehut-${srv}-config.ini"
20 # Each service needs access to only a subset of sections (and secrets).
21 (filterAttrs (k: v: v != null)
22 (mapAttrs (section: v:
23 let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht(::.*)?$" section; in
24 if srvMatch == null # Include sections shared by all services
25 || head srvMatch == srv # Include sections for the service being configured
27 # Enable Web links and integrations between services.
28 else if tail srvMatch == [ null ] && elem (head srvMatch) cfg.services
31 # mansrht crashes without it
32 oauth-client-id = v.oauth-client-id or null;
34 # Drop sub-sections of other services
36 (recursiveUpdate cfg.settings {
37 # Those paths are mounted using BindPaths= or BindReadOnlyPaths=
38 # for services needing access to them.
39 "builds.sr.ht::worker".buildlogs = "/var/log/sourcehut/buildsrht/logs";
40 "git.sr.ht".post-update-script = "/usr/bin/gitsrht-update-hook";
41 "git.sr.ht".repos = "/var/lib/sourcehut/gitsrht/repos";
42 "hg.sr.ht".changegroup-script = "/usr/bin/hgsrht-hook-changegroup";
43 "hg.sr.ht".repos = "/var/lib/sourcehut/hgsrht/repos";
44 # Making this a per service option despite being in a global section,
45 # so that it uses the redis-server used by the service.
46 "sr.ht".redis-host = cfg.${srv}.redis.host;
48 commonServiceSettings = srv: {
50 description = "URL ${srv}.sr.ht is being served at (protocol://domain)";
52 default = "https://${srv}.${domain}";
53 defaultText = "https://${srv}.example.com";
55 debug-host = mkOption {
56 description = "Address to bind the debug server to.";
57 type = with types; nullOr str;
60 debug-port = mkOption {
61 description = "Port to bind the debug server to.";
62 type = with types; nullOr str;
65 connection-string = mkOption {
66 description = "SQLAlchemy connection string for the database.";
68 default = "postgresql:///localhost?user=${srv}srht&host=/run/postgresql";
70 migrate-on-upgrade = mkEnableOption "automatic migrations on package upgrade" // { default = true; };
71 oauth-client-id = mkOption {
72 description = "${srv}.sr.ht's OAuth client id for meta.sr.ht.";
75 oauth-client-secret = mkOption {
76 description = "${srv}.sr.ht's OAuth client secret for meta.sr.ht.";
78 apply = s: "<" + toString s;
82 # Specialized python containing all the modules
83 python = pkgs.sourcehut.python.withPackages (ps: with ps; [
86 # For monitoring Celery: sudo -u listssrht celery --app listssrht.process -b redis+socket:///run/redis-sourcehut/redis.sock?virtual_host=5 flower
98 # Not a python package
103 mkOptionNullOrStr = description: mkOption {
105 type = with types; nullOr str;
110 options.services.sourcehut = {
111 enable = mkEnableOption ''
112 sourcehut - git hosting, continuous integration, mailing list, ticket tracking,
113 task dispatching, wiki and account management services
116 services = mkOption {
117 type = with types; listOf (enum
118 [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]);
119 defaultText = "locally enabled services";
121 Services that may be displayed as links in the title bar of the Web interface.
125 listenAddress = mkOption {
127 default = "localhost";
128 description = "Address to bind to.";
133 type = types.package;
136 The python package to use. It should contain references to the *srht modules and also
142 enable = mkEnableOption ''local minio integration'';
146 enable = mkEnableOption ''local nginx integration'';
147 virtualHost = mkOption {
150 description = "Virtual-host configuration merged with all Sourcehut's virtual-hosts.";
155 enable = mkEnableOption ''local postfix integration'';
159 enable = mkEnableOption ''local postgresql integration'';
163 enable = mkEnableOption ''local redis integration in a dedicated redis-server'';
166 settings = mkOption {
167 type = lib.types.submodule {
168 freeformType = settingsFormat.type;
170 global-domain = mkOption {
171 description = "Global domain name.";
173 example = "example.com";
175 environment = mkOption {
176 description = "Values other than \"production\" adds a banner to each page.";
177 type = types.enum [ "development" "production" ];
178 default = "development";
180 network-key = mkOption {
182 An absolute file path (which should be outside the Nix-store)
183 to a secret key to encrypt internal messages with. Use <code>srht-keygen network</code> to
184 generate this key. It must be consistent between all services and nodes.
187 apply = s: "<" + toString s;
189 owner-email = mkOption {
190 description = "Owner's email.";
192 default = "contact@example.com";
194 owner-name = mkOption {
195 description = "Owner's name.";
197 default = "John Doe";
199 site-blurb = mkOption {
200 description = "Blurb for your site.";
202 default = "the hacker's forge";
204 site-info = mkOption {
205 description = "The top-level info page for your site.";
207 default = "https://sourcehut.org";
209 service-key = mkOption {
211 An absolute file path (which should be outside the Nix-store)
212 to a key used for encrypting session cookies. Use <code>srht-keygen service</code> to
213 generate the service key. This must be shared between each node of the same
214 service (e.g. git1.sr.ht and git2.sr.ht), but different services may use
215 different keys. If you configure all of your services with the same
216 config.ini, you may use the same service-key for all of them.
219 apply = s: "<" + toString s;
221 site-name = mkOption {
222 description = "The name of your network of sr.ht-based sites.";
224 default = "sourcehut";
226 source-url = mkOption {
227 description = "The source code for your fork of sr.ht.";
229 default = "https://git.sr.ht/~sircmpwn/srht";
233 smtp-host = mkOptionNullOrStr "Outgoing SMTP host.";
234 smtp-port = mkOption {
235 description = "Outgoing SMTP port.";
236 type = with types; nullOr port;
239 smtp-user = mkOptionNullOrStr "Outgoing SMTP user.";
240 smtp-password = mkOptionNullOrStr "Outgoing SMTP password.";
241 smtp-from = mkOptionNullOrStr "Outgoing SMTP FROM.";
242 error-to = mkOptionNullOrStr "Address receiving application exceptions";
243 error-from = mkOptionNullOrStr "Address sending application exceptions";
244 pgp-privkey = mkOptionNullOrStr ''
245 An absolute file path (which should be outside the Nix-store)
246 to an OpenPGP private key.
248 Your PGP key information (DO NOT mix up pub and priv here)
249 You must remove the password from your secret key, if present.
250 You can do this with <code>gpg --edit-key [key-id]</code>,
251 then use the <code>passwd</code> command and do not enter a new password.
253 pgp-pubkey = mkOptionNullOrStr "OpenPGP public key.";
254 pgp-key-id = mkOptionNullOrStr "OpenPGP key identifier.";
257 s3-upstream = mkOption {
258 description = "Configure the S3-compatible object storage service.";
259 type = with types; nullOr str;
262 s3-access-key = mkOption {
263 description = "Access key to the S3-compatible object storage service";
264 type = with types; nullOr str;
267 s3-secret-key = mkOption {
269 An absolute file path (which should be outside the Nix-store)
270 to the secret key of the S3-compatible object storage service.
272 type = with types; nullOr path;
274 apply = mapNullable (s: "<" + toString s);
278 private-key = mkOption {
280 An absolute file path (which should be outside the Nix-store)
281 to a base64-encoded Ed25519 key for signing webhook payloads.
282 This should be consistent for all *.sr.ht sites,
283 as this key will be used to verify signatures
284 from other sites in your network.
285 Use the <code>srht-keygen webhook</code> command to generate a key.
288 apply = s: "<" + toString s;
292 options."dispatch.sr.ht" = commonServiceSettings "dispatch" // {
294 options."dispatch.sr.ht::github" = {
295 oauth-client-id = mkOptionNullOrStr "OAuth client id.";
296 oauth-client-secret = mkOptionNullOrStr "OAuth client secret.";
298 options."dispatch.sr.ht::gitlab" = {
299 enabled = mkEnableOption "GitLab integration";
300 canonical-upstream = mkOption {
302 description = "Canonical upstream.";
303 default = "gitlab.com";
305 repo-cache = mkOption {
307 description = "Repository cache directory.";
308 default = "./repo-cache";
310 "gitlab.com" = mkOption {
311 type = with types; nullOr str;
312 description = "GitLab id and secret.";
314 example = "GitLab:application id:secret";
318 options."builds.sr.ht" = commonServiceSettings "builds" // {
319 allow-free = mkEnableOption "nonpaying users to submit builds";
321 description = "The Redis connection used for the Celery worker.";
323 default = "redis+socket:///run/redis-sourcehut-buildsrht/redis.sock?virtual_host=2";
327 Scripts used to launch on SSH connection.
328 <literal>/usr/bin/master-shell</literal> on master,
329 <literal>/usr/bin/runner-shell</literal> on runner.
330 If master and worker are on the same system
331 set to <literal>/usr/bin/runner-shell</literal>.
333 type = types.enum ["/usr/bin/master-shell" "/usr/bin/runner-shell"];
334 default = "/usr/bin/master-shell";
337 options."builds.sr.ht::worker" = {
338 bind-address = mkOption {
340 HTTP bind address for serving local build information/monitoring.
343 default = "localhost:8080";
345 buildlogs = mkOption {
346 description = "Path to write build logs.";
348 default = "/var/log/sourcehut/buildsrht";
352 Listening address and listening port
353 of the build runner (with HTTP port if not 80).
356 default = "localhost:5020";
361 See <link xlink:href="https://golang.org/pkg/time/#ParseDuration"/>.
368 options."git.sr.ht" = commonServiceSettings "git" // {
369 outgoing-domain = mkOption {
370 description = "Outgoing domain.";
372 default = "https://git.localhost.localdomain";
374 post-update-script = mkOption {
376 A post-update script which is installed in every git repo.
377 This setting is propagated to newer and existing repositories.
380 default = "${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
381 defaultText = "\${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
385 Path to git repositories on disk.
386 If changing the default, you must ensure that
387 the gitsrht's user as read and write access to it.
390 default = "/var/lib/sourcehut/gitsrht/repos";
392 webhooks = mkOption {
393 description = "The Redis connection used for the webhooks worker.";
395 default = "redis+socket:///run/redis-sourcehut-gitsrht/redis.sock?virtual_host=1";
398 options."git.sr.ht::api" = {
399 internal-ipnet = mkOption {
401 Set of IP subnets which are permitted to utilize internal API
402 authentication. This should be limited to the subnets
403 from which your *.sr.ht services are running.
404 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
406 type = with types; listOf str;
407 default = [ "127.0.0.0/8" "::1/128" ];
411 options."hg.sr.ht" = commonServiceSettings "hg" // {
412 changegroup-script = mkOption {
414 A changegroup script which is installed in every mercurial repo.
415 This setting is propagated to newer and existing repositories.
418 default = "${cfg.python}/bin/hgsrht-hook-changegroup";
419 defaultText = "\${cfg.python}/bin/hgsrht-hook-changegroup";
423 Path to mercurial repositories on disk.
424 If changing the default, you must ensure that
425 the hgsrht's user as read and write access to it.
428 default = "/var/lib/sourcehut/hgsrht/repos";
430 srhtext = mkOptionNullOrStr ''
431 Path to the srht mercurial extension
432 (defaults to where the hgsrht code is)
434 clone_bundle_threshold = mkOption {
435 description = ".hg/store size (in MB) past which the nightly job generates clone bundles.";
436 type = types.ints.unsigned;
440 description = "Path to hg-ssh (if not in $PATH).";
442 default = "${pkgs.mercurial}/bin/hg-ssh";
443 defaultText = "\${pkgs.mercurial}/bin/hg-ssh";
445 webhooks = mkOption {
446 description = "The Redis connection used for the webhooks worker.";
448 default = "redis+socket:///run/redis-sourcehut-hgsrht/redis.sock?virtual_host=1";
452 options."hub.sr.ht" = commonServiceSettings "hub" // {
455 options."lists.sr.ht" = commonServiceSettings "lists" // {
456 allow-new-lists = mkEnableOption "Allow creation of new lists.";
457 notify-from = mkOption {
458 description = "Outgoing email for notifications generated by users.";
460 default = "lists-notify@localhost.localdomain";
462 posting-domain = mkOption {
463 description = "Posting domain.";
465 default = "lists.localhost.localdomain";
468 description = "The Redis connection used for the Celery worker.";
470 default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=2";
472 webhooks = mkOption {
473 description = "The Redis connection used for the webhooks worker.";
475 default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=1";
478 options."lists.sr.ht::worker" = {
479 reject-mimetypes = mkOption {
481 Comma-delimited list of Content-Types to reject. Messages with Content-Types
482 included in this list are rejected. Multipart messages are always supported,
483 and each part is checked against this list.
485 Uses fnmatch for wildcard expansion.
487 type = with types; listOf str;
488 default = ["text/html"];
490 reject-url = mkOption {
491 description = "Reject URL.";
493 default = "https://man.sr.ht/lists.sr.ht/etiquette.md";
497 Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
498 Alternatively, specify IP:PORT and an SMTP server will be run instead.
501 default = "/tmp/lists.sr.ht-lmtp.sock";
503 sock-group = mkOption {
505 The lmtp daemon will make the unix socket group-read/write
506 for users in this group.
513 options."man.sr.ht" = commonServiceSettings "man" // {
516 options."meta.sr.ht" =
517 removeAttrs (commonServiceSettings "meta")
518 ["oauth-client-id" "oauth-client-secret"] // {
519 api-origin = mkOption {
520 description = "Origin URL for API, 100 more than web.";
522 default = "http://${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
523 defaultText = ''http://<xref linkend="opt-services.sourcehut.listenAddress"/>:''${toString (<xref linkend="opt-services.sourcehut.meta.port"/> + 100)}'';
525 webhooks = mkOption {
526 description = "The Redis connection used for the webhooks worker.";
528 default = "redis+socket:///run/redis-sourcehut-metasrht/redis.sock?virtual_host=1";
530 welcome-emails = mkEnableOption "sending stock sourcehut welcome emails after signup";
532 options."meta.sr.ht::api" = {
533 internal-ipnet = mkOption {
535 Set of IP subnets which are permitted to utilize internal API
536 authentication. This should be limited to the subnets
537 from which your *.sr.ht services are running.
538 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
540 type = with types; listOf str;
541 default = [ "127.0.0.0/8" "::1/128" ];
544 options."meta.sr.ht::aliases" = mkOption {
545 description = "Aliases for the client IDs of commonly used OAuth clients.";
546 type = with types; attrsOf int;
548 example = { "git.sr.ht" = 12345; };
550 options."meta.sr.ht::billing" = {
551 enabled = mkEnableOption "the billing system";
552 stripe-public-key = mkOptionNullOrStr "Public key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys";
553 stripe-secret-key = mkOptionNullOrStr ''
554 An absolute file path (which should be outside the Nix-store)
555 to a secret key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys
557 apply = mapNullable (s: "<" + toString s);
560 options."meta.sr.ht::settings" = {
561 registration = mkEnableOption "public registration";
562 onboarding-redirect = mkOption {
563 description = "Where to redirect new users upon registration.";
565 default = "https://meta.localhost.localdomain";
567 user-invites = mkOption {
569 How many invites each user is issued upon registration
570 (only applicable if open registration is disabled).
572 type = types.ints.unsigned;
577 options."pages.sr.ht" = commonServiceSettings "pages" // {
578 gemini-certs = mkOption {
580 An absolute file path (which should be outside the Nix-store)
581 to Gemini certificates.
583 type = with types; nullOr path;
586 max-site-size = mkOption {
587 description = "Maximum size of any given site (post-gunzip), in MiB.";
591 user-domain = mkOption {
593 Configures the user domain, if enabled.
594 All users are given <username>.this.domain.
596 type = with types; nullOr str;
600 options."pages.sr.ht::api" = {
601 internal-ipnet = mkOption {
603 Set of IP subnets which are permitted to utilize internal API
604 authentication. This should be limited to the subnets
605 from which your *.sr.ht services are running.
606 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
608 type = with types; listOf str;
609 default = [ "127.0.0.0/8" "::1/128" ];
613 options."paste.sr.ht" = commonServiceSettings "paste" // {
616 options."todo.sr.ht" = commonServiceSettings "todo" // {
617 notify-from = mkOption {
618 description = "Outgoing email for notifications generated by users.";
620 default = "todo-notify@localhost.localdomain";
622 webhooks = mkOption {
623 description = "The Redis connection used for the webhooks worker.";
625 default = "redis+socket:///run/redis-sourcehut-todosrht/redis.sock?virtual_host=1";
628 options."todo.sr.ht::mail" = {
629 posting-domain = mkOption {
630 description = "Posting domain.";
632 default = "todo.localhost.localdomain";
636 Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
637 Alternatively, specify IP:PORT and an SMTP server will be run instead.
640 default = "/tmp/todo.sr.ht-lmtp.sock";
642 sock-group = mkOption {
644 The lmtp daemon will make the unix socket group-read/write
645 for users in this group.
654 The configuration for the sourcehut network.
659 enableWorker = mkEnableOption "worker for builds.sr.ht";
662 type = with types; attrsOf (attrsOf (attrsOf package));
664 example = lib.literalExpression ''(let
665 # Pinning unstable to allow usage with flakes and limit rebuilds.
666 pkgs_unstable = builtins.fetchGit {
667 url = "https://github.com/NixOS/nixpkgs";
668 rev = "ff96a0fa5635770390b184ae74debea75c3fd534";
669 ref = "nixos-unstable";
671 image_from_nixpkgs = (import ("${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") {
672 pkgs = (import pkgs_unstable {});
676 nixos.unstable.x86_64 = image_from_nixpkgs;
680 Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2.
687 type = types.package;
689 example = literalExpression "pkgs.gitFull";
691 Git package for git.sr.ht. This can help silence collisions.
694 fcgiwrap.preforkProcess = mkOption {
695 description = "Number of fcgiwrap processes to prefork.";
703 type = types.package;
704 default = pkgs.mercurial;
706 Mercurial package for hg.sr.ht. This can help silence collisions.
709 cloneBundles = mkOption {
713 Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories).
720 extraArgs = mkOption {
721 type = with types; listOf str;
722 default = [ "--loglevel DEBUG" "--pool eventlet" "--without-heartbeat" ];
723 description = "Extra arguments passed to the Celery responsible for processing mails.";
725 celeryConfig = mkOption {
728 description = "Content of the <literal>celeryconfig.py</literal> used by the Celery of <literal>listssrht-process</literal>.";
734 config = mkIf cfg.enable (mkMerge [
736 environment.systemPackages = [ pkgs.sourcehut.coresrht ];
738 services.sourcehut.settings = {
739 "git.sr.ht".outgoing-domain = mkDefault "https://git.${domain}";
740 "lists.sr.ht".notify-from = mkDefault "lists-notify@${domain}";
741 "lists.sr.ht".posting-domain = mkDefault "lists.${domain}";
742 "meta.sr.ht::settings".onboarding-redirect = mkDefault "https://meta.${domain}";
743 "todo.sr.ht".notify-from = mkDefault "todo-notify@${domain}";
744 "todo.sr.ht::mail".posting-domain = mkDefault "todo.${domain}";
747 (mkIf cfg.postgresql.enable {
749 { assertion = postgresql.enable;
750 message = "postgresql must be enabled and configured";
754 (mkIf cfg.postfix.enable {
756 { assertion = postfix.enable;
757 message = "postfix must be enabled and configured";
760 # Needed for sharing the LMTP sockets with JoinsNamespaceOf=
761 systemd.services.postfix.serviceConfig.PrivateTmp = true;
763 (mkIf cfg.redis.enable {
764 services.redis.vmOverCommit = mkDefault true;
766 (mkIf cfg.nginx.enable {
768 { assertion = nginx.enable;
769 message = "nginx must be enabled and configured";
772 # For proxyPass= in virtual-hosts for Sourcehut services.
773 services.nginx.recommendedProxySettings = mkDefault true;
775 (mkIf (cfg.builds.enable || cfg.git.enable || cfg.hg.enable) {
777 # Note that sshd will continue to honor AuthorizedKeysFile.
778 # Note that you may want automatically rotate
779 # or link to /dev/null the following log files:
780 # - /var/log/gitsrht-dispatch
781 # - /var/log/{build,git,hg}srht-keys
782 # - /var/log/{git,hg}srht-shell
783 # - /var/log/gitsrht-update-hook
784 authorizedKeysCommand = ''/etc/ssh/sourcehut/subdir/srht-dispatch "%u" "%h" "%t" "%k"'';
785 # srht-dispatch will setuid/setgid according to [git.sr.ht::dispatch]
786 authorizedKeysCommandUser = "root";
788 PermitUserEnvironment SRHT_*
791 environment.etc."ssh/sourcehut/config.ini".source =
792 settingsFormat.generate "sourcehut-dispatch-config.ini"
793 (filterAttrs (k: v: k == "git.sr.ht::dispatch")
795 environment.etc."ssh/sourcehut/subdir/srht-dispatch" = {
796 # sshd_config(5): The program must be owned by root, not writable by group or others
798 source = pkgs.writeShellScript "srht-dispatch" ''
800 cd /etc/ssh/sourcehut/subdir
801 ${cfg.python}/bin/gitsrht-dispatch "$@"
804 systemd.services.sshd = {
805 #path = optional cfg.git.enable [ cfg.git.package ];
808 # Note that those /usr/bin/* paths are hardcoded in multiple places in *.sr.ht,
809 # for instance to get the user from the [git.sr.ht::dispatch] settings.
810 # *srht-keys needs to:
811 # - access a redis-server in [sr.ht] redis-host,
812 # - access the PostgreSQL server in [*.sr.ht] connection-string,
813 # - query metasrht-api (through the HTTP API).
814 # Using this has the side effect of creating empty files in /usr/bin/
815 optionals cfg.builds.enable [
816 "${pkgs.writeShellScript "buildsrht-keys-wrapper" ''
818 cd /run/sourcehut/buildsrht/subdir
820 exec -a "$0" ${pkgs.sourcehut.buildsrht}/bin/buildsrht-keys "$@"
821 ''}:/usr/bin/buildsrht-keys"
822 "${pkgs.sourcehut.buildsrht}/bin/master-shell:/usr/bin/master-shell"
823 "${pkgs.sourcehut.buildsrht}/bin/runner-shell:/usr/bin/runner-shell"
825 optionals cfg.git.enable [
826 # /path/to/gitsrht-keys calls /path/to/gitsrht-shell,
827 # or [git.sr.ht] shell= if set.
828 "${pkgs.writeShellScript "gitsrht-keys-wrapper" ''
830 cd /run/sourcehut/gitsrht/subdir
832 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-keys "$@"
833 ''}:/usr/bin/gitsrht-keys"
834 "${pkgs.writeShellScript "gitsrht-shell-wrapper" ''
836 cd /run/sourcehut/gitsrht/subdir
838 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-shell "$@"
839 ''}:/usr/bin/gitsrht-shell"
840 "${pkgs.writeShellScript "gitsrht-update-hook" ''
842 test -e "''${PWD%/*}"/config.ini ||
843 # Git hooks are run relative to their repository's directory,
844 # but gitsrht-update-hook looks up ../config.ini
845 ln -s /run/sourcehut/gitsrht/config.ini "''${PWD%/*}"/config.ini
846 # hooks/post-update calls /usr/bin/gitsrht-update-hook as hooks/stage-3
847 # but this wrapper being a bash script, it overrides $0 with /usr/bin/gitsrht-update-hook
848 # hence this hack to put hooks/stage-3 back into gitsrht-update-hook's $0
849 if test "''${STAGE3:+set}"
852 exec -a hooks/stage-3 ${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook "$@"
856 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook "$@"
858 ''}:/usr/bin/gitsrht-update-hook"
860 optionals cfg.hg.enable [
861 # /path/to/hgsrht-keys calls /path/to/hgsrht-shell,
862 # or [hg.sr.ht] shell= if set.
863 "${pkgs.writeShellScript "hgsrht-keys-wrapper" ''
865 cd /run/sourcehut/hgsrht/subdir
867 exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-keys "$@"
868 ''}:/usr/bin/hgsrht-keys"
869 "${pkgs.writeShellScript "hgsrht-shell-wrapper" ''
871 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
882 exec -a "$0" ${cfg.python}/bin/hgsrht-hook-changegroup "$@"
883 ''}:/usr/bin/hgsrht-hook-changegroup"
892 (import ./service.nix "builds" {
893 inherit configIniOfService;
894 srvsrht = "buildsrht";
896 # TODO: a celery worker on the master and worker are apparently needed
897 extraServices.buildsrht-worker = let
898 qemuPackage = pkgs.qemu_kvm;
899 serviceName = "buildsrht-worker";
900 statePath = "/var/lib/sourcehut/${serviceName}";
901 in mkIf cfg.builds.enableWorker {
902 path = [ pkgs.openssh pkgs.docker ];
905 if test -z "$(docker images -q qemu:latest 2>/dev/null)" \
906 || test "$(cat ${statePath}/docker-image-qemu)" != "${qemuPackage.version}"
908 # Create and import qemu:latest image for docker
909 ${pkgs.dockerTools.streamLayeredImage {
912 contents = [ qemuPackage ];
914 # Mark down current package version
915 echo '${qemuPackage.version}' >${statePath}/docker-image-qemu
919 ExecStart = "${pkgs.sourcehut.buildsrht}/bin/builds.sr.ht-worker";
920 RuntimeDirectory = [ "sourcehut/${serviceName}/subdir" ];
921 # builds.sr.ht-worker looks up ../config.ini
922 LogsDirectory = [ "sourcehut/${serviceName}" ];
923 StateDirectory = [ "sourcehut/${serviceName}" ];
924 WorkingDirectory = "-"+"/run/sourcehut/${serviceName}/subdir";
928 image_dirs = flatten (
929 mapAttrsToList (distro: revs:
930 mapAttrsToList (rev: archs:
931 mapAttrsToList (arch: image:
932 pkgs.runCommand "buildsrht-images" { } ''
933 mkdir -p $out/${distro}/${rev}/${arch}
934 ln -s ${image}/*.qcow2 $out/${distro}/${rev}/${arch}/root.img.qcow2
940 image_dir_pre = pkgs.symlinkJoin {
941 name = "builds.sr.ht-worker-images-pre";
943 # FIXME: not working, apparently because ubuntu/latest is a broken link
944 # ++ [ "${pkgs.sourcehut.buildsrht}/lib/images" ];
946 image_dir = pkgs.runCommand "builds.sr.ht-worker-images" { } ''
948 cp -Lr ${image_dir_pre}/* $out/images
952 users.users.${cfg.builds.user}.shell = pkgs.bash;
954 virtualisation.docker.enable = true;
956 services.sourcehut.settings = mkMerge [
957 { # Note that git.sr.ht::dispatch is not a typo,
958 # gitsrht-dispatch always use this section
959 "git.sr.ht::dispatch"."/usr/bin/buildsrht-keys" =
960 mkDefault "${cfg.builds.user}:${cfg.builds.group}";
962 (mkIf cfg.builds.enableWorker {
963 "builds.sr.ht::worker".shell = "/usr/bin/runner-shell";
964 "builds.sr.ht::worker".images = mkDefault "${image_dir}/images";
965 "builds.sr.ht::worker".controlcmd = mkDefault "${image_dir}/images/control";
969 (mkIf cfg.builds.enableWorker {
971 docker.members = [ cfg.builds.user ];
974 (mkIf (cfg.builds.enableWorker && cfg.nginx.enable) {
975 # Allow nginx access to buildlogs
976 users.users.${nginx.user}.extraGroups = [ cfg.builds.group ];
977 systemd.services.nginx = {
978 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."builds.sr.ht::worker".buildlogs}:/var/log/nginx/buildsrht/logs" ];
980 services.nginx.virtualHosts."logs.${domain}" = mkMerge [ {
981 /* FIXME: is a listen needed?
982 listen = with builtins;
983 # FIXME: not compatible with IPv6
984 let address = split ":" cfg.settings."builds.sr.ht::worker".name; in
985 [{ addr = elemAt address 0; port = lib.toInt (elemAt address 2); }];
987 locations."/logs/".alias = "/var/log/nginx/buildsrht/logs/";
988 } cfg.nginx.virtualHost ];
993 (import ./service.nix "dispatch" {
994 inherit configIniOfService;
998 (import ./service.nix "git" (let
1000 path = [ cfg.git.package ];
1001 serviceConfig.BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1004 inherit configIniOfService;
1005 mainService = mkMerge [ baseService {
1006 serviceConfig.StateDirectory = [ "sourcehut/gitsrht" "sourcehut/gitsrht/repos" ];
1007 preStart = mkIf (!versionAtLeast config.system.stateVersion "21.11") (mkBefore ''
1008 # Fix Git hooks of repositories pre-dating https://github.com/NixOS/nixpkgs/pull/133984
1012 for h in /var/lib/sourcehut/gitsrht/repos/~*/*/hooks/{pre-receive,update,post-update}
1013 do ln -fnsv /usr/bin/gitsrht-update-hook "$h"; done
1019 extraTimers.gitsrht-periodic = {
1020 service = baseService;
1021 timerConfig.OnCalendar = ["*:0/20"];
1023 extraConfig = mkMerge [
1025 # https://stackoverflow.com/questions/22314298/git-push-results-in-fatal-protocol-error-bad-line-length-character-this
1026 # Probably could use gitsrht-shell if output is restricted to just parameters...
1027 users.users.${cfg.git.user}.shell = pkgs.bash;
1028 services.sourcehut.settings = {
1029 "git.sr.ht::dispatch"."/usr/bin/gitsrht-keys" =
1030 mkDefault "${cfg.git.user}:${cfg.git.group}";
1032 systemd.services.sshd = baseService;
1034 (mkIf cfg.nginx.enable {
1035 services.nginx.virtualHosts."git.${domain}" = {
1036 locations."/authorize" = {
1037 proxyPass = "http://${cfg.listenAddress}:${toString cfg.git.port}";
1039 proxy_pass_request_body off;
1040 proxy_set_header Content-Length "";
1041 proxy_set_header X-Original-URI $request_uri;
1044 locations."~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$" = {
1045 root = "/var/lib/sourcehut/gitsrht/repos";
1047 GIT_HTTP_EXPORT_ALL = "";
1048 GIT_PROJECT_ROOT = "$document_root";
1050 SCRIPT_FILENAME = "${cfg.git.package}/bin/git-http-backend";
1053 auth_request /authorize;
1054 fastcgi_read_timeout 500s;
1055 fastcgi_pass unix:/run/gitsrht-fcgiwrap.sock;
1060 systemd.sockets.gitsrht-fcgiwrap = {
1061 before = [ "nginx.service" ];
1062 wantedBy = [ "sockets.target" "gitsrht.service" ];
1063 # This path remains accessible to nginx.service, which has no RootDirectory=
1064 socketConfig.ListenStream = "/run/gitsrht-fcgiwrap.sock";
1065 socketConfig.SocketUser = nginx.user;
1066 socketConfig.SocketMode = "600";
1070 extraServices.gitsrht-fcgiwrap = mkIf cfg.nginx.enable {
1072 # Socket is passed by gitsrht-fcgiwrap.socket
1073 ExecStart = "${pkgs.fcgiwrap}/sbin/fcgiwrap -c ${toString cfg.git.fcgiwrap.preforkProcess}";
1074 # No need for config.ini
1075 ExecStartPre = mkForce [];
1078 BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1079 IPAddressDeny = "any";
1080 InaccessiblePaths = [ "-+/run/postgresql" "-+/run/redis-sourcehut" ];
1081 PrivateNetwork = true;
1082 RestrictAddressFamilies = mkForce [ "none" ];
1083 SystemCallFilter = mkForce [
1085 "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid"
1086 # @timer is needed for alarm()
1092 (import ./service.nix "hg" (let
1094 path = [ cfg.hg.package ];
1095 serviceConfig.BindPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/sourcehut/hgsrht/repos" ];
1098 inherit configIniOfService;
1099 mainService = mkMerge [ baseService {
1100 serviceConfig.StateDirectory = [ "sourcehut/hgsrht" "sourcehut/hgsrht/repos" ];
1104 extraTimers.hgsrht-periodic = {
1105 service = baseService;
1106 timerConfig.OnCalendar = ["*:0/20"];
1108 extraTimers.hgsrht-clonebundles = mkIf cfg.hg.cloneBundles {
1109 service = baseService;
1110 timerConfig.OnCalendar = ["daily"];
1111 timerConfig.AccuracySec = "1h";
1113 extraConfig = mkMerge [
1115 users.users.${cfg.hg.user}.shell = pkgs.bash;
1116 services.sourcehut.settings = {
1117 # Note that git.sr.ht::dispatch is not a typo,
1118 # gitsrht-dispatch always uses this section.
1119 "git.sr.ht::dispatch"."/usr/bin/hgsrht-keys" =
1120 mkDefault "${cfg.hg.user}:${cfg.hg.group}";
1122 systemd.services.sshd = baseService;
1124 (mkIf cfg.nginx.enable {
1125 # Allow nginx access to repositories
1126 users.users.${nginx.user}.extraGroups = [ cfg.hg.group ];
1127 services.nginx.virtualHosts."hg.${domain}" = {
1128 locations."/authorize" = {
1129 proxyPass = "http://${cfg.listenAddress}:${toString cfg.hg.port}";
1131 proxy_pass_request_body off;
1132 proxy_set_header Content-Length "";
1133 proxy_set_header X-Original-URI $request_uri;
1136 # Let clients reach pull bundles. We don't really need to lock this down even for
1137 # private repos because the bundles are named after the revision hashes...
1138 # so someone would need to know or guess a SHA value to download anything.
1139 # TODO: proxyPass to an hg serve service?
1140 locations."~ ^/[~^][a-z0-9_]+/[a-zA-Z0-9_.-]+/\\.hg/bundles/.*$" = {
1141 root = "/var/lib/nginx/hgsrht/repos";
1143 auth_request /authorize;
1148 systemd.services.nginx = {
1149 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/nginx/hgsrht/repos" ];
1155 (import ./service.nix "hub" {
1156 inherit configIniOfService;
1159 services.nginx = mkIf cfg.nginx.enable {
1160 virtualHosts."hub.${domain}" = mkMerge [ {
1161 serverAliases = [ domain ];
1162 } cfg.nginx.virtualHost ];
1167 (import ./service.nix "lists" (let
1168 srvsrht = "listssrht";
1170 inherit configIniOfService;
1173 # Receive the mail from Postfix and enqueue them into Redis and PostgreSQL
1174 extraServices.listssrht-lmtp = {
1175 wants = [ "postfix.service" ];
1176 unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1177 serviceConfig.ExecStart = "${cfg.python}/bin/listssrht-lmtp";
1178 # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1179 serviceConfig.PrivateUsers = mkForce false;
1181 # Dequeue the mails from Redis and dispatch them
1182 extraServices.listssrht-process = {
1185 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" cfg.lists.process.celeryConfig} \
1186 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
1188 ExecStart = "${cfg.python}/bin/celery --app listssrht.process worker --hostname listssrht-process@%%h " + concatStringsSep " " cfg.lists.process.extraArgs;
1189 # Avoid crashing: os.getloadavg()
1190 ProcSubset = mkForce "all";
1193 extraConfig = mkIf cfg.postfix.enable {
1194 users.groups.${postfix.group}.members = [ cfg.lists.user ];
1195 services.sourcehut.settings."lists.sr.ht::mail".sock-group = postfix.group;
1196 services.postfix = {
1197 destination = [ "lists.${domain}" ];
1198 # FIXME: an accurate recipient list should be queried
1199 # from the lists.sr.ht PostgreSQL database to avoid backscattering.
1200 # But usernames are unfortunately not in that database but in meta.sr.ht.
1201 # Note that two syntaxes are allowed:
1202 # - ~username/list-name@lists.${domain}
1203 # - u.username.list-name@lists.${domain}
1204 localRecipients = [ "@lists.${domain}" ];
1206 lists.${domain} lmtp:unix:${cfg.settings."lists.sr.ht::worker".sock}
1212 (import ./service.nix "man" {
1213 inherit configIniOfService;
1217 (import ./service.nix "meta" {
1218 inherit configIniOfService;
1221 extraServices.metasrht-api = {
1222 serviceConfig.Restart = "always";
1223 serviceConfig.RestartSec = "2s";
1224 preStart = "set -x\n" + concatStringsSep "\n\n" (attrValues (mapAttrs (k: s:
1225 let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht$" k;
1226 srv = head srvMatch;
1228 # Configure client(s) as "preauthorized"
1229 optionalString (srvMatch != null && cfg.${srv}.enable && ((s.oauth-client-id or null) != null)) ''
1230 # Configure ${srv}'s OAuth client as "preauthorized"
1231 ${postgresql.package}/bin/psql '${cfg.settings."meta.sr.ht".connection-string}' \
1232 -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${s.oauth-client-id}'"
1235 serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b ${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
1237 extraTimers.metasrht-daily.timerConfig = {
1238 OnCalendar = ["daily"];
1241 extraConfig = mkMerge [
1244 { assertion = let s = cfg.settings."meta.sr.ht::billing"; in
1245 s.enabled == "yes" -> (s.stripe-public-key != null && s.stripe-secret-key != null);
1246 message = "If meta.sr.ht::billing is enabled, the keys must be defined.";
1249 environment.systemPackages = optional cfg.meta.enable
1250 (pkgs.writeShellScriptBin "metasrht-manageuser" ''
1252 if test "$(${pkgs.coreutils}/bin/id -n -u)" != '${cfg.meta.user}'
1253 then exec sudo -u '${cfg.meta.user}' "$0" "$@"
1255 # In order to load config.ini
1256 if cd /run/sourcehut/metasrht
1257 then exec ${cfg.python}/bin/metasrht-manageuser "$@"
1259 Please run: sudo systemctl start metasrht
1266 (mkIf cfg.nginx.enable {
1267 services.nginx.virtualHosts."meta.${domain}" = {
1268 locations."/query" = {
1269 proxyPass = cfg.settings."meta.sr.ht".api-origin;
1271 if ($request_method = 'OPTIONS') {
1272 add_header 'Access-Control-Allow-Origin' '*';
1273 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
1274 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
1275 add_header 'Access-Control-Max-Age' 1728000;
1276 add_header 'Content-Type' 'text/plain; charset=utf-8';
1277 add_header 'Content-Length' 0;
1281 add_header 'Access-Control-Allow-Origin' '*';
1282 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
1283 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
1284 add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
1292 (import ./service.nix "pages" {
1293 inherit configIniOfService;
1296 srvsrht = "pagessrht";
1297 version = pkgs.sourcehut.${srvsrht}.version;
1298 stateDir = "/var/lib/sourcehut/${srvsrht}";
1299 iniKey = "pages.sr.ht";
1301 preStart = mkBefore ''
1303 # Use the /run/sourcehut/${srvsrht}/config.ini
1304 # installed by a previous ExecStartPre= in baseService
1305 cd /run/sourcehut/${srvsrht}
1307 if test ! -e ${stateDir}/db; then
1308 ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f ${pkgs.sourcehut.pagessrht}/share/sql/schema.sql
1309 echo ${version} >${stateDir}/db
1312 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
1313 # Just try all the migrations because they're not linked to the version
1314 for sql in ${pkgs.sourcehut.pagessrht}/share/sql/migrations/*.sql; do
1315 ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f "$sql" || true
1320 touch ${stateDir}/webhook
1323 ExecStart = mkForce "${pkgs.sourcehut.pagessrht}/bin/pages.sr.ht -b ${cfg.listenAddress}:${toString cfg.pages.port}";
1328 (import ./service.nix "paste" {
1329 inherit configIniOfService;
1333 (import ./service.nix "todo" {
1334 inherit configIniOfService;
1337 extraServices.todosrht-lmtp = {
1338 wants = [ "postfix.service" ];
1339 unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1340 serviceConfig.ExecStart = "${cfg.python}/bin/todosrht-lmtp";
1341 # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1342 serviceConfig.PrivateUsers = mkForce false;
1344 extraConfig = mkIf cfg.postfix.enable {
1345 users.groups.${postfix.group}.members = [ cfg.todo.user ];
1346 services.sourcehut.settings."todo.sr.ht::mail".sock-group = postfix.group;
1347 services.postfix = {
1348 destination = [ "todo.${domain}" ];
1349 # FIXME: an accurate recipient list should be queried
1350 # from the todo.sr.ht PostgreSQL database to avoid backscattering.
1351 # But usernames are unfortunately not in that database but in meta.sr.ht.
1352 # Note that two syntaxes are allowed:
1353 # - ~username/tracker-name@todo.${domain}
1354 # - u.username.tracker-name@todo.${domain}
1355 localRecipients = [ "@todo.${domain}" ];
1357 todo.${domain} lmtp:unix:${cfg.settings."todo.sr.ht::mail".sock}
1363 (mkRenamedOptionModule [ "services" "sourcehut" "originBase" ]
1364 [ "services" "sourcehut" "settings" "sr.ht" "global-domain" ])
1365 (mkRenamedOptionModule [ "services" "sourcehut" "address" ]
1366 [ "services" "sourcehut" "listenAddress" ])
1370 meta.doc = ./sourcehut.xml;
1371 meta.maintainers = with maintainers; [ julm tomberek ];