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 = "/var/lib/sourcehut/gitsrht/bin/post-update-script";
41 "git.sr.ht".repos = "/var/lib/sourcehut/gitsrht/repos";
42 "hg.sr.ht".changegroup-script = "/var/lib/sourcehut/hgsrht/bin/changegroup-script";
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";
382 # Git hooks are run relative to their repository's directory,
383 # but gitsrht-update-hook looks up ../config.ini
384 apply = p: pkgs.writeShellScript "update-hook-wrapper" ''
386 test -e "''${PWD%/*}"/config.ini ||
387 ln -s /run/sourcehut/gitsrht/config.ini "''${PWD%/*}"/config.ini
388 exec -a "$0" '${p}' "$@"
393 Path to git repositories on disk.
394 If changing the default, you must ensure that
395 the gitsrht's user as read and write access to it.
398 default = "/var/lib/sourcehut/gitsrht/repos";
400 webhooks = mkOption {
401 description = "The Redis connection used for the webhooks worker.";
403 default = "redis+socket:///run/redis-sourcehut-gitsrht/redis.sock?virtual_host=1";
406 options."git.sr.ht::api" = {
407 internal-ipnet = mkOption {
409 Set of IP subnets which are permitted to utilize internal API
410 authentication. This should be limited to the subnets
411 from which your *.sr.ht services are running.
412 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
414 type = with types; listOf str;
415 default = [ "127.0.0.0/8" "::1/128" ];
419 options."hg.sr.ht" = commonServiceSettings "hg" // {
420 changegroup-script = mkOption {
422 A changegroup script which is installed in every mercurial repo.
423 This setting is propagated to newer and existing repositories.
426 default = "${cfg.python}/bin/hgsrht-hook-changegroup";
427 defaultText = "\${cfg.python}/bin/hgsrht-hook-changegroup";
428 # Mercurial's changegroup hooks are run relative to their repository's directory,
429 # but hgsrht-hook-changegroup looks up ./config.ini
430 apply = p: pkgs.writeShellScript "hook-changegroup-wrapper" ''
432 test -e "''$PWD"/config.ini ||
433 ln -s /run/sourcehut/hgsrht/config.ini "''$PWD"/config.ini
434 exec -a "$0" '${p}' "$@"
439 Path to mercurial repositories on disk.
440 If changing the default, you must ensure that
441 the hgsrht's user as read and write access to it.
444 default = "/var/lib/sourcehut/hgsrht/repos";
446 srhtext = mkOptionNullOrStr ''
447 Path to the srht mercurial extension
448 (defaults to where the hgsrht code is)
450 clone_bundle_threshold = mkOption {
451 description = ".hg/store size (in MB) past which the nightly job generates clone bundles.";
452 type = types.ints.unsigned;
456 description = "Path to hg-ssh (if not in $PATH).";
458 default = "${pkgs.mercurial}/bin/hg-ssh";
459 defaultText = "\${pkgs.mercurial}/bin/hg-ssh";
461 webhooks = mkOption {
462 description = "The Redis connection used for the webhooks worker.";
464 default = "redis+socket:///run/redis-sourcehut-hgsrht/redis.sock?virtual_host=1";
468 options."hub.sr.ht" = commonServiceSettings "hub" // {
471 options."lists.sr.ht" = commonServiceSettings "lists" // {
472 allow-new-lists = mkEnableOption "Allow creation of new lists.";
473 notify-from = mkOption {
474 description = "Outgoing email for notifications generated by users.";
476 default = "lists-notify@localhost.localdomain";
478 posting-domain = mkOption {
479 description = "Posting domain.";
481 default = "lists.localhost.localdomain";
484 description = "The Redis connection used for the Celery worker.";
486 default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=2";
488 webhooks = mkOption {
489 description = "The Redis connection used for the webhooks worker.";
491 default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=1";
494 options."lists.sr.ht::worker" = {
495 reject-mimetypes = mkOption {
497 Comma-delimited list of Content-Types to reject. Messages with Content-Types
498 included in this list are rejected. Multipart messages are always supported,
499 and each part is checked against this list.
501 Uses fnmatch for wildcard expansion.
503 type = with types; listOf str;
504 default = ["text/html"];
506 reject-url = mkOption {
507 description = "Reject URL.";
509 default = "https://man.sr.ht/lists.sr.ht/etiquette.md";
513 Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
514 Alternatively, specify IP:PORT and an SMTP server will be run instead.
517 default = "/tmp/lists.sr.ht-lmtp.sock";
519 sock-group = mkOption {
521 The lmtp daemon will make the unix socket group-read/write
522 for users in this group.
529 options."man.sr.ht" = commonServiceSettings "man" // {
532 options."meta.sr.ht" =
533 removeAttrs (commonServiceSettings "meta")
534 ["oauth-client-id" "oauth-client-secret"] // {
535 api-origin = mkOption {
536 description = "Origin URL for API, 100 more than web.";
538 default = "http://${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
539 defaultText = ''http://<xref linkend="opt-services.sourcehut.listenAddress"/>:''${toString (<xref linkend="opt-services.sourcehut.meta.port"/> + 100)}'';
541 webhooks = mkOption {
542 description = "The Redis connection used for the webhooks worker.";
544 default = "redis+socket:///run/redis-sourcehut-metasrht/redis.sock?virtual_host=1";
546 welcome-emails = mkEnableOption "sending stock sourcehut welcome emails after signup";
548 options."meta.sr.ht::api" = {
549 internal-ipnet = mkOption {
551 Set of IP subnets which are permitted to utilize internal API
552 authentication. This should be limited to the subnets
553 from which your *.sr.ht services are running.
554 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
556 type = with types; listOf str;
557 default = [ "127.0.0.0/8" "::1/128" ];
560 options."meta.sr.ht::aliases" = mkOption {
561 description = "Aliases for the client IDs of commonly used OAuth clients.";
562 type = with types; attrsOf int;
564 example = { "git.sr.ht" = 12345; };
566 options."meta.sr.ht::billing" = {
567 enabled = mkEnableOption "the billing system";
568 stripe-public-key = mkOptionNullOrStr "Public key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys";
569 stripe-secret-key = mkOptionNullOrStr ''
570 An absolute file path (which should be outside the Nix-store)
571 to a secret key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys
573 apply = mapNullable (s: "<" + toString s);
576 options."meta.sr.ht::settings" = {
577 registration = mkEnableOption "public registration";
578 onboarding-redirect = mkOption {
579 description = "Where to redirect new users upon registration.";
581 default = "https://meta.localhost.localdomain";
583 user-invites = mkOption {
585 How many invites each user is issued upon registration
586 (only applicable if open registration is disabled).
588 type = types.ints.unsigned;
593 options."pages.sr.ht" = commonServiceSettings "pages" // {
594 gemini-certs = mkOption {
596 An absolute file path (which should be outside the Nix-store)
597 to Gemini certificates.
599 type = with types; nullOr path;
602 max-site-size = mkOption {
603 description = "Maximum size of any given site (post-gunzip), in MiB.";
607 user-domain = mkOption {
609 Configures the user domain, if enabled.
610 All users are given <username>.this.domain.
612 type = with types; nullOr str;
616 options."pages.sr.ht::api" = {
617 internal-ipnet = mkOption {
619 Set of IP subnets which are permitted to utilize internal API
620 authentication. This should be limited to the subnets
621 from which your *.sr.ht services are running.
622 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
624 type = with types; listOf str;
625 default = [ "127.0.0.0/8" "::1/128" ];
629 options."paste.sr.ht" = commonServiceSettings "paste" // {
632 options."todo.sr.ht" = commonServiceSettings "todo" // {
633 notify-from = mkOption {
634 description = "Outgoing email for notifications generated by users.";
636 default = "todo-notify@localhost.localdomain";
638 webhooks = mkOption {
639 description = "The Redis connection used for the webhooks worker.";
641 default = "redis+socket:///run/redis-sourcehut-todosrht/redis.sock?virtual_host=1";
644 options."todo.sr.ht::mail" = {
645 posting-domain = mkOption {
646 description = "Posting domain.";
648 default = "todo.localhost.localdomain";
652 Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
653 Alternatively, specify IP:PORT and an SMTP server will be run instead.
656 default = "/tmp/todo.sr.ht-lmtp.sock";
658 sock-group = mkOption {
660 The lmtp daemon will make the unix socket group-read/write
661 for users in this group.
670 The configuration for the sourcehut network.
675 enableWorker = mkEnableOption "worker for builds.sr.ht";
678 type = with types; attrsOf (attrsOf (attrsOf package));
680 example = lib.literalExample ''(let
681 # Pinning unstable to allow usage with flakes and limit rebuilds.
682 pkgs_unstable = builtins.fetchGit {
683 url = "https://github.com/NixOS/nixpkgs";
684 rev = "ff96a0fa5635770390b184ae74debea75c3fd534";
685 ref = "nixos-unstable";
687 image_from_nixpkgs = (import ("${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") {
688 pkgs = (import pkgs_unstable {});
692 nixos.unstable.x86_64 = image_from_nixpkgs;
696 Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2.
703 type = types.package;
705 example = literalExample "pkgs.gitFull";
707 Git package for git.sr.ht. This can help silence collisions.
710 fcgiwrap.preforkProcess = mkOption {
711 description = "Number of fcgiwrap processes to prefork.";
719 type = types.package;
720 default = pkgs.mercurial;
722 Mercurial package for hg.sr.ht. This can help silence collisions.
725 cloneBundles = mkOption {
729 Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories).
736 extraArgs = mkOption {
737 type = with types; listOf str;
738 default = [ "--loglevel DEBUG" "--pool eventlet" "--without-heartbeat" ];
739 description = "Extra arguments passed to the Celery responsible for processing mails.";
741 celeryConfig = mkOption {
744 description = "Content of the <literal>celeryconfig.py</literal> used by the Celery of <literal>listssrht-process</literal>.";
750 config = mkIf cfg.enable (mkMerge [
752 environment.systemPackages = [ pkgs.sourcehut.coresrht ];
754 services.sourcehut.settings = {
755 "git.sr.ht".outgoing-domain = mkDefault "https://git.${domain}";
756 "lists.sr.ht".notify-from = mkDefault "lists-notify@${domain}";
757 "lists.sr.ht".posting-domain = mkDefault "lists.${domain}";
758 "meta.sr.ht::settings".onboarding-redirect = mkDefault "https://meta.${domain}";
759 "todo.sr.ht".notify-from = mkDefault "todo-notify@${domain}";
760 "todo.sr.ht::mail".posting-domain = mkDefault "todo.${domain}";
763 (mkIf cfg.postgresql.enable {
765 { assertion = postgresql.enable;
766 message = "postgresql must be enabled and configured";
770 (mkIf cfg.postfix.enable {
772 { assertion = postfix.enable;
773 message = "postfix must be enabled and configured";
776 # Needed for sharing the LMTP sockets with JoinsNamespaceOf=
777 systemd.services.postfix.serviceConfig.PrivateTmp = true;
779 (mkIf cfg.redis.enable {
780 services.redis.vmOverCommit = mkDefault true;
782 (mkIf cfg.nginx.enable {
784 { assertion = nginx.enable;
785 message = "nginx must be enabled and configured";
788 # For proxyPass= in virtual-hosts for Sourcehut services.
789 services.nginx.recommendedProxySettings = mkDefault true;
791 (mkIf (cfg.builds.enable || cfg.git.enable || cfg.hg.enable) {
793 # Note that sshd will continue to honor AuthorizedKeysFile.
794 # Note that you may want automatically rotate
795 # or link to /dev/null the following log files:
796 # - /var/log/gitsrht-dispatch
797 # - /var/log/{build,git,hg}srht-keys
798 # - /var/log/{git,hg}srht-shell
799 # - /var/log/gitsrht-update-hook
800 authorizedKeysCommand = ''/etc/ssh/sourcehut/subdir/srht-dispatch "%u" "%h" "%t" "%k"'';
801 # srht-dispatch will setuid/setgid according to [git.sr.ht::dispatch]
802 authorizedKeysCommandUser = "root";
804 PermitUserEnvironment SRHT_*
807 environment.etc."ssh/sourcehut/config.ini".source =
808 settingsFormat.generate "sourcehut-dispatch-config.ini"
809 (filterAttrs (k: v: k == "git.sr.ht::dispatch")
811 environment.etc."ssh/sourcehut/subdir/srht-dispatch" = {
812 # sshd_config(5): The program must be owned by root, not writable by group or others
814 source = pkgs.writeShellScript "srht-dispatch" ''
816 cd /etc/ssh/sourcehut/subdir
817 ${cfg.python}/bin/gitsrht-dispatch "$@"
820 systemd.services.sshd = {
821 #path = optional cfg.git.enable [ cfg.git.package ];
824 # Note that those /usr/bin/* paths are hardcoded in multiple places in *.sr.ht,
825 # for instance to get the user from the [git.sr.ht::dispatch] settings.
826 # *srht-keys needs to:
827 # - access a redis-server in [sr.ht] redis-host,
828 # - access the PostgreSQL server in [*.sr.ht] connection-string,
829 # - query metasrht-api (through the HTTP API).
830 # Using this has the side effect of creating empty files in /usr/bin/
831 optionals cfg.builds.enable [
832 "${pkgs.writeShellScript "buildsrht-keys-wrapper" ''
834 cd /run/sourcehut/buildsrht/subdir
835 exec -a "$0" ${pkgs.sourcehut.buildsrht}/bin/buildsrht-keys "$@"
836 ''}:/usr/bin/buildsrht-keys"
837 "${pkgs.sourcehut.buildsrht}/bin/master-shell:/usr/bin/master-shell"
838 "${pkgs.sourcehut.buildsrht}/bin/runner-shell:/usr/bin/runner-shell"
840 optionals cfg.git.enable [
841 # /path/to/gitsrht-keys calls /path/to/gitsrht-shell,
842 # or [git.sr.ht] shell= if set.
843 "${pkgs.writeShellScript "gitsrht-keys-wrapper" ''
845 cd /run/sourcehut/gitsrht/subdir
846 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-keys "$@"
847 ''}:/usr/bin/gitsrht-keys"
848 "${pkgs.writeShellScript "gitsrht-shell-wrapper" ''
850 cd /run/sourcehut/gitsrht/subdir
851 exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-shell "$@"
852 ''}:/usr/bin/gitsrht-shell"
854 optionals cfg.hg.enable [
855 # /path/to/hgsrht-keys calls /path/to/hgsrht-shell,
856 # or [hg.sr.ht] shell= if set.
857 "${pkgs.writeShellScript "hgsrht-keys-wrapper" ''
859 cd /run/sourcehut/hgsrht/subdir
860 exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-keys "$@"
861 ''}:/usr/bin/hgsrht-keys"
862 ":/usr/bin/hgsrht-shell"
863 "${pkgs.writeShellScript "hgsrht-shell-wrapper" ''
865 cd /run/sourcehut/hgsrht/subdir
866 exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-shell "$@"
867 ''}:/usr/bin/hgsrht-shell"
876 (import ./service.nix "builds" {
877 inherit configIniOfService;
878 srvsrht = "buildsrht";
880 # TODO: a celery worker on the master and worker are apparently needed
881 extraServices.buildsrht-worker = let
882 qemuPackage = pkgs.qemu_kvm;
883 serviceName = "buildsrht-worker";
884 statePath = "/var/lib/sourcehut/${serviceName}";
885 in mkIf cfg.builds.enableWorker {
886 path = [ pkgs.openssh pkgs.docker ];
889 if test -z "$(docker images -q qemu:latest 2>/dev/null)" \
890 || test "$(cat ${statePath}/docker-image-qemu)" != "${qemuPackage.version}"
892 # Create and import qemu:latest image for docker
893 ${pkgs.dockerTools.streamLayeredImage {
896 contents = [ qemuPackage ];
898 # Mark down current package version
899 echo '${qemuPackage.version}' >${statePath}/docker-image-qemu
903 ExecStart = "${pkgs.sourcehut.buildsrht}/bin/builds.sr.ht-worker";
904 RuntimeDirectory = [ "sourcehut/${serviceName}/subdir" ];
905 # builds.sr.ht-worker looks up ../config.ini
906 LogsDirectory = [ "sourcehut/${serviceName}" ];
907 StateDirectory = [ "sourcehut/${serviceName}" ];
908 WorkingDirectory = "-"+"/run/sourcehut/${serviceName}/subdir";
912 image_dirs = flatten (
913 mapAttrsToList (distro: revs:
914 mapAttrsToList (rev: archs:
915 mapAttrsToList (arch: image:
916 pkgs.runCommand "buildsrht-images" { } ''
917 mkdir -p $out/${distro}/${rev}/${arch}
918 ln -s ${image}/*.qcow2 $out/${distro}/${rev}/${arch}/root.img.qcow2
924 image_dir_pre = pkgs.symlinkJoin {
925 name = "builds.sr.ht-worker-images-pre";
927 # FIXME: not working, apparently because ubuntu/latest is a broken link
928 # ++ [ "${pkgs.sourcehut.buildsrht}/lib/images" ];
930 image_dir = pkgs.runCommand "builds.sr.ht-worker-images" { } ''
932 cp -Lr ${image_dir_pre}/* $out/images
936 users.users.${cfg.builds.user}.shell = pkgs.bash;
938 virtualisation.docker.enable = true;
940 services.sourcehut.settings = mkMerge [
941 { # Note that git.sr.ht::dispatch is not a typo,
942 # gitsrht-dispatch always use this section
943 "git.sr.ht::dispatch"."/usr/bin/buildsrht-keys" =
944 mkDefault "${cfg.builds.user}:${cfg.builds.group}";
946 (mkIf cfg.builds.enableWorker {
947 "builds.sr.ht::worker".shell = "/usr/bin/runner-shell";
948 "builds.sr.ht::worker".images = mkDefault "${image_dir}/images";
949 "builds.sr.ht::worker".controlcmd = mkDefault "${image_dir}/images/control";
953 (mkIf cfg.builds.enableWorker {
955 docker.members = [ cfg.builds.user ];
958 (mkIf (cfg.builds.enableWorker && cfg.nginx.enable) {
959 # Allow nginx access to buildlogs
960 users.users.${nginx.user}.extraGroups = [ cfg.builds.group ];
961 systemd.services.nginx = {
962 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."builds.sr.ht::worker".buildlogs}:/var/log/nginx/buildsrht/logs" ];
964 services.nginx.virtualHosts."logs.${domain}" = mkMerge [ {
965 /* FIXME: is a listen needed?
966 listen = with builtins;
967 # FIXME: not compatible with IPv6
968 let address = split ":" cfg.settings."builds.sr.ht::worker".name; in
969 [{ addr = elemAt address 0; port = lib.toInt (elemAt address 2); }];
971 locations."/logs/".alias = "/var/log/nginx/buildsrht/logs/";
972 } cfg.nginx.virtualHost ];
977 (import ./service.nix "dispatch" {
978 inherit configIniOfService;
982 (import ./service.nix "git" (let
984 path = [ cfg.git.package ];
985 serviceConfig.BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
986 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".post-update-script}:/var/lib/sourcehut/gitsrht/bin/post-update-script" ];
989 inherit configIniOfService;
990 mainService = mkMerge [ baseService {
991 serviceConfig.StateDirectory = [ "sourcehut/gitsrht" "sourcehut/gitsrht/repos" ];
995 extraTimers.gitsrht-periodic = {
996 service = baseService;
997 timerConfig.OnCalendar = ["20min"];
999 extraConfig = mkMerge [
1001 # https://stackoverflow.com/questions/22314298/git-push-results-in-fatal-protocol-error-bad-line-length-character-this
1002 # Probably could use gitsrht-shell if output is restricted to just parameters...
1003 users.users.${cfg.git.user}.shell = pkgs.bash;
1004 services.sourcehut.settings = {
1005 "git.sr.ht::dispatch"."/usr/bin/gitsrht-keys" =
1006 mkDefault "${cfg.git.user}:${cfg.git.group}";
1008 systemd.services.sshd = baseService;
1010 (mkIf cfg.nginx.enable {
1011 services.nginx.virtualHosts."git.${domain}" = {
1012 locations."/authorize" = {
1013 proxyPass = "http://${cfg.listenAddress}:${toString cfg.git.port}";
1015 proxy_pass_request_body off;
1016 proxy_set_header Content-Length "";
1017 proxy_set_header X-Original-URI $request_uri;
1020 locations."~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$" = {
1021 root = "/var/lib/sourcehut/gitsrht/repos";
1023 GIT_HTTP_EXPORT_ALL = "";
1024 GIT_PROJECT_ROOT = "$document_root";
1026 SCRIPT_FILENAME = "${cfg.git.package}/bin/git-http-backend";
1029 auth_request /authorize;
1030 fastcgi_read_timeout 500s;
1031 fastcgi_pass unix:/run/gitsrht-fcgiwrap.sock;
1036 systemd.sockets.gitsrht-fcgiwrap = {
1037 before = [ "nginx.service" ];
1038 wantedBy = [ "sockets.target" "gitsrht.service" ];
1039 # This path remains accessible to nginx.service, which has no RootDirectory=
1040 socketConfig.ListenStream = "/run/gitsrht-fcgiwrap.sock";
1041 socketConfig.SocketUser = nginx.user;
1042 socketConfig.SocketMode = "600";
1046 extraServices.gitsrht-fcgiwrap = mkIf cfg.nginx.enable {
1048 # Socket is passed by gitsrht-fcgiwrap.socket
1049 ExecStart = "${pkgs.fcgiwrap}/sbin/fcgiwrap -c ${toString cfg.git.fcgiwrap.preforkProcess}";
1050 # No need for config.ini
1051 ExecStartPre = mkForce [];
1054 BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1055 IPAddressDeny = "any";
1056 InaccessiblePaths = [ "-+/run/postgresql" "-+/run/redis-sourcehut" ];
1057 PrivateNetwork = true;
1058 RestrictAddressFamilies = mkForce [ "none" ];
1059 SystemCallFilter = mkForce [
1061 "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid"
1062 # @timer is needed for alarm()
1068 (import ./service.nix "hg" (let
1070 path = [ cfg.hg.package ];
1071 serviceConfig.BindPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/sourcehut/hgsrht/repos" ];
1072 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."ht.sr.ht".changegroup-script}:/var/lib/sourcehut/hgsrht/bin/changegroup-script" ];
1075 inherit configIniOfService;
1076 mainService = mkMerge [ baseService {
1077 serviceConfig.StateDirectory = [ "sourcehut/hgsrht" "sourcehut/hgsrht/repos" ];
1081 extraTimers.hgsrht-periodic = {
1082 service = baseService;
1083 timerConfig.OnCalendar = ["20min"];
1085 extraTimers.hgsrht-clonebundles = mkIf cfg.hg.cloneBundles {
1086 service = baseService;
1087 timerConfig.OnCalendar = ["daily"];
1088 timerConfig.AccuracySec = "1h";
1090 extraConfig = mkMerge [
1092 users.users.${cfg.hg.user}.shell = pkgs.bash;
1093 services.sourcehut.settings = {
1094 # Note that git.sr.ht::dispatch is not a typo,
1095 # gitsrht-dispatch always uses this section.
1096 "git.sr.ht::dispatch"."/usr/bin/hgsrht-keys" =
1097 mkDefault "${cfg.hg.user}:${cfg.hg.group}";
1099 systemd.services.sshd = baseService;
1101 (mkIf cfg.nginx.enable {
1102 # Allow nginx access to repositories
1103 users.users.${nginx.user}.extraGroups = [ cfg.hg.group ];
1104 services.nginx.virtualHosts."hg.${domain}" = {
1105 locations."/authorize" = {
1106 proxyPass = "http://${cfg.listenAddress}:${toString cfg.hg.port}";
1108 proxy_pass_request_body off;
1109 proxy_set_header Content-Length "";
1110 proxy_set_header X-Original-URI $request_uri;
1113 # Let clients reach pull bundles. We don't really need to lock this down even for
1114 # private repos because the bundles are named after the revision hashes...
1115 # so someone would need to know or guess a SHA value to download anything.
1116 # TODO: proxyPass to an hg serve service?
1117 locations."~ ^/[~^][a-z0-9_]+/[a-zA-Z0-9_.-]+/\\.hg/bundles/.*$" = {
1118 root = "/var/lib/nginx/hgsrht/repos";
1120 auth_request /authorize;
1125 systemd.services.nginx = {
1126 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/nginx/hgsrht/repos" ];
1132 (import ./service.nix "hub" {
1133 inherit configIniOfService;
1136 services.nginx = mkIf cfg.nginx.enable {
1137 virtualHosts."hub.${domain}" = mkMerge [ {
1138 serverAliases = [ domain ];
1139 } cfg.nginx.virtualHost ];
1144 (import ./service.nix "lists" (let
1145 srvsrht = "listssrht";
1147 inherit configIniOfService;
1150 # Receive the mail from Postfix and enqueue them into Redis and PostgreSQL
1151 extraServices.listssrht-lmtp = {
1152 wants = [ "postfix.service" ];
1153 unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1154 serviceConfig.ExecStart = "${cfg.python}/bin/listssrht-lmtp";
1155 # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1156 serviceConfig.PrivateUsers = mkForce false;
1158 # Dequeue the mails from Redis and dispatch them
1159 extraServices.listssrht-process = {
1162 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" cfg.lists.process.celeryConfig} \
1163 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
1165 ExecStart = "${cfg.python}/bin/celery --app listssrht.process worker --hostname listssrht-process@%%h " + concatStringsSep " " cfg.lists.process.extraArgs;
1166 # Avoid crashing: os.getloadavg()
1167 ProcSubset = mkForce "all";
1170 extraConfig = mkIf cfg.postfix.enable {
1171 users.groups.${postfix.group}.members = [ cfg.lists.user ];
1172 services.sourcehut.settings."lists.sr.ht::mail".sock-group = postfix.group;
1173 services.postfix = {
1174 destination = [ "lists.${domain}" ];
1175 # FIXME: an accurate recipient list should be queried
1176 # from the lists.sr.ht PostgreSQL database to avoid backscattering.
1177 # But usernames are unfortunately not in that database but in meta.sr.ht.
1178 # Note that two syntaxes are allowed:
1179 # - ~username/list-name@lists.${domain}
1180 # - u.username.list-name@lists.${domain}
1181 localRecipients = [ "@lists.${domain}" ];
1183 lists.${domain} lmtp:unix:${cfg.settings."lists.sr.ht::worker".sock}
1189 (import ./service.nix "man" {
1190 inherit configIniOfService;
1194 (import ./service.nix "meta" {
1195 inherit configIniOfService;
1198 extraServices.metasrht-api = {
1199 serviceConfig.Restart = "always";
1200 serviceConfig.RestartSec = "2s";
1201 preStart = "set -x\n" + concatStringsSep "\n\n" (attrValues (mapAttrs (k: s:
1202 let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht$" k;
1203 srv = head srvMatch;
1205 # Configure client(s) as "preauthorized"
1206 optionalString (srvMatch != null && cfg.${srv}.enable && ((s.oauth-client-id or null) != null)) ''
1207 # Configure ${srv}'s OAuth client as "preauthorized"
1208 ${postgresql.package}/bin/psql '${cfg.settings."meta.sr.ht".connection-string}' \
1209 -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${s.oauth-client-id}'"
1212 serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b ${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
1214 extraTimers.metasrht-daily.timerConfig = {
1215 OnCalendar = ["daily"];
1218 extraConfig = mkMerge [
1221 { assertion = let s = cfg.settings."meta.sr.ht::billing"; in
1222 s.enabled == "yes" -> (s.stripe-public-key != null && s.stripe-secret-key != null);
1223 message = "If meta.sr.ht::billing is enabled, the keys must be defined.";
1226 environment.systemPackages = optional cfg.meta.enable
1227 (pkgs.writeShellScriptBin "metasrht-manageuser" ''
1229 if test "$(${pkgs.coreutils}/bin/id -n -u)" != '${cfg.meta.user}'
1230 then exec sudo -u '${cfg.meta.user}' "$0" "$@"
1232 # In order to load config.ini
1233 if cd /run/sourcehut/metasrht
1234 then exec ${cfg.python}/bin/metasrht-manageuser "$@"
1236 Please run: sudo systemctl start metasrht
1243 (mkIf cfg.nginx.enable {
1244 services.nginx.virtualHosts."meta.${domain}" = {
1245 locations."/query" = {
1246 proxyPass = cfg.settings."meta.sr.ht".api-origin;
1248 if ($request_method = 'OPTIONS') {
1249 add_header 'Access-Control-Allow-Origin' '*';
1250 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
1251 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
1252 add_header 'Access-Control-Max-Age' 1728000;
1253 add_header 'Content-Type' 'text/plain; charset=utf-8';
1254 add_header 'Content-Length' 0;
1258 add_header 'Access-Control-Allow-Origin' '*';
1259 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
1260 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
1261 add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
1269 (import ./service.nix "pages" {
1270 inherit configIniOfService;
1273 srvsrht = "pagessrht";
1274 version = pkgs.sourcehut.${srvsrht}.version;
1275 stateDir = "/var/lib/sourcehut/${srvsrht}";
1276 iniKey = "pages.sr.ht";
1278 preStart = mkBefore ''
1280 # Use the /run/sourcehut/${srvsrht}/config.ini
1281 # installed by a previous ExecStartPre= in baseService
1282 cd /run/sourcehut/${srvsrht}
1284 if test ! -e ${stateDir}/db; then
1285 ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f ${pkgs.sourcehut.pagessrht}/share/sql/schema.sql
1286 echo ${version} >${stateDir}/db
1289 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
1290 # Just try all the migrations because they're not linked to the version
1291 for sql in ${pkgs.sourcehut.pagessrht}/share/sql/migrations/*.sql; do
1292 ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f "$sql" || true
1297 touch ${stateDir}/webhook
1300 ExecStart = mkForce "${pkgs.sourcehut.pagessrht}/bin/pages.sr.ht -b ${cfg.listenAddress}:${toString cfg.pages.port}";
1305 (import ./service.nix "paste" {
1306 inherit configIniOfService;
1310 (import ./service.nix "todo" {
1311 inherit configIniOfService;
1314 extraServices.todosrht-lmtp = {
1315 wants = [ "postfix.service" ];
1316 unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1317 serviceConfig.ExecStart = "${cfg.python}/bin/todosrht-lmtp";
1318 # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1319 serviceConfig.PrivateUsers = mkForce false;
1321 extraConfig = mkIf cfg.postfix.enable {
1322 users.groups.${postfix.group}.members = [ cfg.todo.user ];
1323 services.sourcehut.settings."todo.sr.ht::mail".sock-group = postfix.group;
1324 services.postfix = {
1325 destination = [ "todo.${domain}" ];
1326 # FIXME: an accurate recipient list should be queried
1327 # from the todo.sr.ht PostgreSQL database to avoid backscattering.
1328 # But usernames are unfortunately not in that database but in meta.sr.ht.
1329 # Note that two syntaxes are allowed:
1330 # - ~username/tracker-name@todo.${domain}
1331 # - u.username.tracker-name@todo.${domain}
1332 localRecipients = [ "@todo.${domain}" ];
1334 todo.${domain} lmtp:unix:${cfg.settings."todo.sr.ht::mail".sock}
1340 (mkRenamedOptionModule [ "services" "sourcehut" "originBase" ]
1341 [ "services" "sourcehut" "settings" "sr.ht" "global-domain" ])
1342 (mkRenamedOptionModule [ "services" "sourcehut" "address" ]
1343 [ "services" "sourcehut" "listenAddress" ])
1347 meta.doc = ./sourcehut.xml;
1348 meta.maintainers = with maintainers; [ julm tomberek ];