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 # *srht-{dispatch,keys,shell,update-hook} share the same config.ini
28 else if srv == "ssh" && elem (head srvMatch) ["builds" "git" "hg"] && cfg.${head srvMatch}.enable then v
29 # Enable Web links and integrations between services.
30 else if tail srvMatch == [ null ] && elem (head srvMatch) cfg.services
33 # mansrht crashes without it
34 oauth-client-id = v.oauth-client-id or null;
36 # Drop sub-sections of other services
38 (recursiveUpdate cfg.settings {
39 # Those paths are mounted using BindPaths= or BindReadOnlyPaths=
40 # for services needing access to them.
41 "builds.sr.ht::worker".buildlogs = "/var/log/sourcehut/buildsrht/logs";
42 "git.sr.ht".post-update-script = "/var/lib/sourcehut/gitsrht/bin/post-update-script";
43 "git.sr.ht".repos = "/var/lib/sourcehut/gitsrht/repos";
44 "hg.sr.ht".changegroup-script = "/var/lib/sourcehut/hgsrht/bin/changegroup-script";
45 "hg.sr.ht".repos = "/var/lib/sourcehut/hgsrht/repos";
47 commonServiceSettings = srv: {
49 description = "URL ${srv}.sr.ht is being served at (protocol://domain)";
51 default = "https://${srv}.${domain}";
52 defaultText = "https://${srv}.example.com";
54 debug-host = mkOption {
55 description = "Address to bind the debug server to.";
56 type = with types; nullOr str;
59 debug-port = mkOption {
60 description = "Port to bind the debug server to.";
61 type = with types; nullOr str;
64 connection-string = mkOption {
65 description = "SQLAlchemy connection string for the database.";
67 default = "postgresql:///localhost?user=${srv}srht&host=/run/postgresql";
69 migrate-on-upgrade = mkEnableOption "automatic migrations on package upgrade" // { default = true; };
70 oauth-client-id = mkOption {
71 description = "${srv}.sr.ht's OAuth client id for meta.sr.ht.";
74 oauth-client-secret = mkOption {
75 description = "${srv}.sr.ht's OAuth client secret for meta.sr.ht.";
77 apply = s: "<" + toString s;
81 # Specialized python containing all the modules
82 python = pkgs.sourcehut.python.withPackages (ps: with ps; [
95 # Not a python package
100 mkOptionNullOrStr = description: mkOption {
102 type = with types; nullOr str;
107 options.services.sourcehut = {
108 enable = mkEnableOption ''
109 sourcehut - git hosting, continuous integration, mailing list, ticket tracking,
110 task dispatching, wiki and account management services
113 services = mkOption {
114 type = with types; listOf (enum
115 [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]);
116 defaultText = "locally enabled services";
118 Services that may be displayed as links in the title bar of the Web interface.
122 listenAddress = mkOption {
124 default = "localhost";
125 description = "Address to bind to.";
130 type = types.package;
133 The python package to use. It should contain references to the *srht modules and also
139 enable = mkEnableOption ''local minio integration'';
143 enable = mkEnableOption ''local nginx integration'';
144 virtualHost = mkOption {
147 description = "Virtual-host configuration merged with all Sourcehut's virtual-hosts.";
152 enable = mkEnableOption ''local postfix integration'';
156 enable = mkEnableOption ''local postgresql integration'';
160 enable = mkEnableOption ''local redis integration'';
163 default = "redis://localhost:${toString redis.port}/0";
164 defaultText = "redis://localhost:6379/0";
166 The URL to the Redis database used by Celery
167 for Sourcehut's webhooks and other ad-hoc workers.
172 settings = mkOption {
173 type = lib.types.submodule {
174 freeformType = settingsFormat.type;
176 global-domain = mkOption {
177 description = "Global domain name.";
179 example = "example.com";
181 environment = mkOption {
182 description = "Values other than \"production\" adds a banner to each page.";
183 type = types.enum [ "development" "production" ];
184 default = "development";
186 network-key = mkOption {
188 An absolute file path (which should be outside the Nix-store)
189 to a secret key to encrypt internal messages with. Use <code>srht-keygen network</code> to
190 generate this key. It must be consistent between all services and nodes.
193 apply = s: "<" + toString s;
195 owner-email = mkOption {
196 description = "Owner's email.";
198 default = "contact@example.com";
200 owner-name = mkOption {
201 description = "Owner's name.";
203 default = "John Doe";
205 redis-host = mkOption {
206 type = with types; nullOr str;
208 example = "redis://shared.wireguard:6379/1";
210 The redis host URL. This is used for caching and temporary storage, and must
211 be shared between nodes (e.g. g - 1it1.sr.ht and git2.sr.ht), but need not be
212 shared between services. It may be shared between services, however, with no
213 ill effect, if this better suits your infrastructure.
216 site-blurb = mkOption {
217 description = "Blurb for your site.";
219 default = "the hacker's forge";
221 site-info = mkOption {
222 description = "The top-level info page for your site.";
224 default = "https://sourcehut.org";
226 service-key = mkOption {
228 An absolute file path (which should be outside the Nix-store)
229 to a key used for encrypting session cookies. Use <code>srht-keygen service</code> to
230 generate the service key. This must be shared between each node of the same
231 service (e.g. git1.sr.ht and git2.sr.ht), but different services may use
232 different keys. If you configure all of your services with the same
233 config.ini, you may use the same service-key for all of them.
236 apply = s: "<" + toString s;
238 site-name = mkOption {
239 description = "The name of your network of sr.ht-based sites.";
241 default = "sourcehut";
243 source-url = mkOption {
244 description = "The source code for your fork of sr.ht.";
246 default = "https://git.sr.ht/~sircmpwn/srht";
250 smtp-host = mkOptionNullOrStr "Outgoing SMTP host.";
251 smtp-port = mkOption {
252 description = "Outgoing SMTP port.";
253 type = with types; nullOr port;
256 smtp-user = mkOptionNullOrStr "Outgoing SMTP user.";
257 smtp-password = mkOptionNullOrStr "Outgoing SMTP password.";
258 smtp-from = mkOptionNullOrStr "Outgoing SMTP FROM.";
259 error-to = mkOptionNullOrStr "Address receiving application exceptions";
260 error-from = mkOptionNullOrStr "Address sending application exceptions";
261 pgp-privkey = mkOptionNullOrStr ''
262 An absolute file path (which should be outside the Nix-store)
263 to an OpenPGP private key.
265 Your PGP key information (DO NOT mix up pub and priv here)
266 You must remove the password from your secret key, if present.
267 You can do this with <code>gpg --edit-key [key-id]</code>,
268 then use the <code>passwd</code> command and do not enter a new password.
270 pgp-pubkey = mkOptionNullOrStr "OpenPGP public key.";
271 pgp-key-id = mkOptionNullOrStr "OpenPGP key identifier.";
274 s3-upstream = mkOption {
275 description = "Configure the S3-compatible object storage service.";
276 type = with types; nullOr str;
279 s3-access-key = mkOption {
280 description = "Access key to the S3-compatible object storage service";
281 type = with types; nullOr str;
284 s3-secret-key = mkOption {
286 An absolute file path (which should be outside the Nix-store)
287 to the secret key of the S3-compatible object storage service.
289 type = with types; nullOr path;
291 apply = mapNullable (s: "<" + toString s);
295 private-key = mkOption {
297 An absolute file path (which should be outside the Nix-store)
298 to a base64-encoded Ed25519 key for signing webhook payloads.
299 This should be consistent for all *.sr.ht sites,
300 as this key will be used to verify signatures
301 from other sites in your network.
302 Use the <code>srht-keygen webhook</code> command to generate a key.
305 apply = s: "<" + toString s;
309 options."dispatch.sr.ht" = commonServiceSettings "dispatch" // {
311 options."dispatch.sr.ht::github" = {
312 oauth-client-id = mkOptionNullOrStr "OAuth client id.";
313 oauth-client-secret = mkOptionNullOrStr "OAuth client secret.";
315 options."dispatch.sr.ht::gitlab" = {
316 enabled = mkEnableOption "GitLab integration";
317 canonical-upstream = mkOption {
319 description = "Canonical upstream.";
320 default = "gitlab.com";
322 repo-cache = mkOption {
324 description = "Repository cache directory.";
325 default = "./repo-cache";
327 "gitlab.com" = mkOption {
328 type = with types; nullOr str;
329 description = "GitLab id and secret.";
331 example = "GitLab:application id:secret";
335 options."builds.sr.ht" = commonServiceSettings "builds" // {
336 allow-free = mkEnableOption "nonpaying users to submit builds";
338 description = "The Redis connection used for the celery worker.";
340 default = cfg.redis.url;
341 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
345 Scripts used to launch on SSH connection.
346 <literal>/usr/bin/master-shell</literal> on master,
347 <literal>/usr/bin/runner-shell</literal> on runner.
348 If master and worker are on the same system
349 set to <literal>/usr/bin/runner-shell</literal>.
351 type = types.enum ["/usr/bin/master-shell" "/usr/bin/runner-shell"];
352 default = "/usr/bin/master-shell";
355 options."builds.sr.ht::worker" = {
356 bind-address = mkOption {
358 HTTP bind address for serving local build information/monitoring.
361 default = "localhost:8080";
363 buildlogs = mkOption {
364 description = "Path to write build logs.";
366 default = "/var/log/sourcehut/buildsrht";
370 Listening address and listening port
371 of the build runner (with HTTP port if not 80).
374 default = "localhost:5020";
379 See <link xlink:href="https://golang.org/pkg/time/#ParseDuration"/>.
386 options."git.sr.ht" = commonServiceSettings "git" // {
387 outgoing-domain = mkOption {
388 description = "Outgoing domain.";
390 default = "https://git.localhost.localdomain";
392 post-update-script = mkOption {
394 A post-update script which is installed in every git repo.
395 This setting is propagated to newer and existing repositories.
398 default = "${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
399 defaultText = "\${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
400 # Git hooks are run relative to their repository's directory,
401 # but gitsrht-update-hook looks up ../config.ini
402 apply = p: pkgs.writeShellScript "update-hook-wrapper" ''
403 test -e "''${PWD%/*}"/config.ini ||
404 ln -s ${users."sshsrht".home}/../config.ini "''${PWD%/*}"/config.ini
405 exec -a "$0" '${p}' "$@"
410 Path to git repositories on disk.
411 If changing the default, you must ensure that
412 the gitsrht's user as read and write access to it.
415 default = "/var/lib/sourcehut/gitsrht/repos";
417 webhooks = mkOption {
418 description = "The Redis connection used for the webhooks worker.";
420 default = cfg.redis.url;
421 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
424 options."git.sr.ht::api" = {
425 internal-ipnet = mkOption {
427 Set of IP subnets which are permitted to utilize internal API
428 authentication. This should be limited to the subnets
429 from which your *.sr.ht services are running.
430 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
432 type = with types; listOf str;
433 default = [ "127.0.0.0/24" "::1/64" ];
437 options."hg.sr.ht" = commonServiceSettings "hg" // {
438 changegroup-script = mkOption {
440 A changegroup script which is installed in every mercurial repo.
441 This setting is propagated to newer and existing repositories.
444 default = "${cfg.python}/bin/hgsrht-hook-changegroup";
445 defaultText = "\${cfg.python}/bin/hgsrht-hook-changegroup";
446 # Mercurial's changegroup hooks are run relative to their repository's directory,
447 # but hgsrht-hook-changegroup looks up ./config.ini
448 apply = p: pkgs.writeShellScript "hook-changegroup-wrapper" ''
449 test -e "''$PWD"/config.ini ||
450 ln -s ${users."sshsrht".home}/../config.ini "''$PWD"/config.ini
451 exec -a "$0" '${p}' "$@"
456 Path to mercurial repositories on disk.
457 If changing the default, you must ensure that
458 the hgsrht's user as read and write access to it.
461 default = "/var/lib/sourcehut/hgsrht/repos";
463 srhtext = mkOptionNullOrStr ''
464 Path to the srht mercurial extension
465 (defaults to where the hgsrht code is)
467 clone_bundle_threshold = mkOption {
468 description = ".hg/store size (in MB) past which the nightly job generates clone bundles.";
469 type = types.ints.unsigned;
473 description = "Path to hg-ssh (if not in $PATH).";
475 default = "${pkgs.mercurial}/bin/hg-ssh";
476 defaultText = "\${pkgs.mercurial}/bin/hg-ssh";
478 webhooks = mkOption {
479 description = "The Redis connection used for the webhooks worker.";
481 default = cfg.redis.url;
482 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
486 options."hub.sr.ht" = commonServiceSettings "hub" // {
489 options."lists.sr.ht" = commonServiceSettings "lists" // {
490 allow-new-lists = mkEnableOption "Allow creation of new lists.";
491 notify-from = mkOption {
492 description = "Outgoing email for notifications generated by users.";
494 default = "lists-notify@localhost.localdomain";
496 posting-domain = mkOption {
497 description = "Posting domain.";
499 default = "lists.localhost.localdomain";
502 description = "The Redis connection used for the celery worker.";
504 default = cfg.redis.url;
505 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
507 webhooks = mkOption {
508 description = "The Redis connection used for the webhooks worker.";
510 default = cfg.redis.url;
511 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
514 options."lists.sr.ht::worker" = {
515 reject-mimetypes = mkOption {
517 Comma-delimited list of Content-Types to reject. Messages with Content-Types
518 included in this list are rejected. Multipart messages are always supported,
519 and each part is checked against this list.
521 Uses fnmatch for wildcard expansion.
523 type = with types; listOf str;
524 default = ["text/html"];
526 reject-url = mkOption {
527 description = "Reject URL.";
529 default = "https://man.sr.ht/lists.sr.ht/etiquette.md";
533 Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
534 Alternatively, specify IP:PORT and an SMTP server will be run instead.
537 default = "/tmp/lists.sr.ht-lmtp.sock";
539 sock-group = mkOption {
541 The lmtp daemon will make the unix socket group-read/write
542 for users in this group.
549 options."man.sr.ht" = commonServiceSettings "man" // {
552 options."meta.sr.ht" =
553 removeAttrs (commonServiceSettings "meta")
554 ["oauth-client-id" "oauth-client-secret"] // {
555 api-origin = mkOption {
556 description = "Origin URL for API, 100 more than web.";
558 default = "https://meta.${domain}:${toString (cfg.meta.port + 100)}";
559 defaultText = ''https://meta.example.com:''${toString (<xref linkend="opt-services.sourcehut.meta.port"/> + 100)}'';
561 webhooks = mkOption {
562 description = "The Redis connection used for the webhooks worker.";
564 default = cfg.redis.url;
565 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
567 welcome-emails = mkEnableOption "sending stock sourcehut welcome emails after signup";
569 options."meta.sr.ht::api" = {
570 internal-ipnet = mkOption {
572 Set of IP subnets which are permitted to utilize internal API
573 authentication. This should be limited to the subnets
574 from which your *.sr.ht services are running.
575 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
577 type = with types; listOf str;
578 default = [ "127.0.0.0/24" "::1/64" ];
581 options."meta.sr.ht::aliases" = mkOption {
582 description = "Aliases for the client IDs of commonly used OAuth clients.";
583 type = with types; attrsOf int;
585 example = { "git.sr.ht" = 12345; };
587 options."meta.sr.ht::billing" = {
588 enabled = mkEnableOption "the billing system";
589 stripe-public-key = mkOptionNullOrStr "Public key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys";
590 stripe-secret-key = mkOptionNullOrStr ''
591 An absolute file path (which should be outside the Nix-store)
592 to a secret key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys
594 apply = mapNullable (s: "<" + toString s);
597 options."meta.sr.ht::settings" = {
598 registration = mkEnableOption "public registration";
599 onboarding-redirect = mkOption {
600 description = "Where to redirect new users upon registration.";
602 default = "https://meta.localhost.localdomain";
604 user-invites = mkOption {
606 How many invites each user is issued upon registration
607 (only applicable if open registration is disabled).
609 type = types.ints.unsigned;
614 options."pages.sr.ht" = commonServiceSettings "pages" // {
615 gemini-certs = mkOption {
617 An absolute file path (which should be outside the Nix-store)
618 to Gemini certificates.
620 type = with types; nullOr path;
623 max-site-size = mkOption {
624 description = "Maximum size of any given site (post-gunzip), in MiB.";
628 user-domain = mkOption {
630 Configures the user domain, if enabled.
631 All users are given <username>.this.domain.
633 type = with types; nullOr str;
637 options."pages.sr.ht::api" = {
638 internal-ipnet = mkOption {
640 Set of IP subnets which are permitted to utilize internal API
641 authentication. This should be limited to the subnets
642 from which your *.sr.ht services are running.
643 See <xref linkend="opt-services.sourcehut.listenAddress"/>.
645 type = with types; listOf str;
646 default = [ "127.0.0.0/24" "::1/64" ];
650 options."paste.sr.ht" = commonServiceSettings "paste" // {
653 options."todo.sr.ht" = commonServiceSettings "todo" // {
654 notify-from = mkOption {
655 description = "Outgoing email for notifications generated by users.";
657 default = "todo-notify@localhost.localdomain";
659 webhooks = mkOption {
660 description = "The Redis connection used for the webhooks worker.";
662 default = cfg.redis.url;
663 defaultText = ''<xref linkend="opt-services.redis.url"/>'';
666 options."todo.sr.ht::mail" = {
667 posting-domain = mkOption {
668 description = "Posting domain.";
670 default = "todo.localhost.localdomain";
674 Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
675 Alternatively, specify IP:PORT and an SMTP server will be run instead.
678 default = "/tmp/todo.sr.ht-lmtp.sock";
680 sock-group = mkOption {
682 The lmtp daemon will make the unix socket group-read/write
683 for users in this group.
692 The configuration for the sourcehut network.
697 enableWorker = mkEnableOption "worker for builds.sr.ht";
700 type = with types; attrsOf (attrsOf (attrsOf package));
702 example = lib.literalExample ''(let
703 # Pinning unstable to allow usage with flakes and limit rebuilds.
704 pkgs_unstable = builtins.fetchGit {
705 url = "https://github.com/NixOS/nixpkgs";
706 rev = "ff96a0fa5635770390b184ae74debea75c3fd534";
707 ref = "nixos-unstable";
709 image_from_nixpkgs = (import ("${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") {
710 pkgs = (import pkgs_unstable {});
714 nixos.unstable.x86_64 = image_from_nixpkgs;
718 Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2.
725 type = types.package;
727 example = literalExample "pkgs.gitFull";
729 Git package for git.sr.ht. This can help silence collisions.
732 fcgiwrap.preforkProcess = mkOption {
733 description = "Number of fcgiwrap processes to prefork.";
741 type = types.package;
742 default = pkgs.mercurial;
744 Mercurial package for hg.sr.ht. This can help silence collisions.
747 cloneBundles = mkOption {
751 Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories).
757 config = mkIf cfg.enable (mkMerge [
759 environment.systemPackages = [ pkgs.sourcehut.coresrht ];
761 services.sourcehut.settings = {
762 "git.sr.ht".outgoing-domain = mkDefault "https://git.${domain}";
763 "lists.sr.ht".notify-from = mkDefault "lists-notify@${domain}";
764 "lists.sr.ht".posting-domain = mkDefault "lists.${domain}";
765 "meta.sr.ht::settings".onboarding-redirect = mkDefault "https://meta.${domain}";
766 "todo.sr.ht".notify-from = mkDefault "todo-notify@${domain}";
767 "todo.sr.ht::mail".posting-domain = mkDefault "todo.${domain}";
770 (mkIf cfg.postgresql.enable {
772 { assertion = postgresql.enable;
773 message = "postgresql must be enabled and configured";
777 (mkIf cfg.postfix.enable {
779 { assertion = postfix.enable;
780 message = "postfix must be enabled and configured";
783 # Needed for sharing the LMTP sockets with JoinsNamespaceOf=
784 systemd.services.postfix.serviceConfig.PrivateTmp = true;
786 (mkIf cfg.redis.enable {
788 { assertion = redis.enable;
789 message = "Redis must be enabled and configured";
793 (mkIf cfg.nginx.enable {
795 { assertion = nginx.enable;
796 message = "nginx must be enabled and configured";
799 # For proxyPass= in virtual-hosts for Sourcehut services.
800 services.nginx.recommendedProxySettings = mkDefault true;
802 (mkIf (cfg.builds.enable || cfg.git.enable || cfg.hg.enable) {
804 # Note that sshd will continue to honor AuthorizedKeysFile
805 authorizedKeysCommand = ''/etc/ssh/srht-dispatch "%u" "%h" "%t" "%k"'';
806 # The sshsrht-dispatch user needs:
807 # 1. to read ${users."sshsrht".home}/../config.ini,
808 # 2. to access the Redis server in redis-host,
809 # 3. to access the PostgreSQL server in the service's connection-string,
810 # 4. to query metasrht-api (through the HTTP API).
811 # Note that *srht-{dispatch,keys,shell,update-hook} will likely fail
812 # to write their log on /var/log with that user, and will fallback to stderr,
813 # making their log visible in sshd's log when sshd is in debug mode (-d).
814 # Alternatively, you can touch and chown sshsrht /var/log/gitsrht-{dispatch,keys,shell,update-hook}
816 authorizedKeysCommandUser = users."sshsrht".name;
818 PermitUserEnvironment SRHT_*
821 environment.etc."ssh/srht-dispatch" = {
822 # sshd_config(5): The program must be owned by root, not writable by group or others
824 source = pkgs.writeShellScript "srht-dispatch" ''
826 cd ${users."sshsrht".home}
827 exec ${cfg.python}/bin/gitsrht-dispatch "$@"
830 systemd.services.sshd = let configIni = configIniOfService "ssh"; in {
831 #path = optional cfg.git.enable [ cfg.git.package ];
832 restartTriggers = [ configIni ];
834 RuntimeDirectory = [ "sourcehut/sshsrht/subdir" ];
836 # Note that those /usr/bin/* paths are hardcoded in multiple places in *.sr.ht,
837 # for instance to get the user from the [*.sr.ht::dispatch] settings.
838 optionals cfg.builds.enable [
839 "${pkgs.sourcehut.buildsrht}/bin/buildsrht-keys:/usr/bin/buildsrht-keys"
840 "${pkgs.sourcehut.buildsrht}/bin/master-shell:/usr/bin/master-shell"
841 "${pkgs.sourcehut.buildsrht}/bin/runner-shell:/usr/bin/runner-shell"
843 optionals cfg.git.enable [
844 "${pkgs.sourcehut.gitsrht}/bin/gitsrht-keys:/usr/bin/gitsrht-keys"
845 "${pkgs.sourcehut.gitsrht}/bin/gitsrht-shell:/usr/bin/gitsrht-shell"
847 optionals cfg.hg.enable [
848 "${pkgs.sourcehut.hgsrht}/bin/hgsrht-keys:/usr/bin/htsrht-keys"
849 "${pkgs.sourcehut.hgsrht}/bin/hgsrht-shell:/usr/bin/htsrht-shell"
851 ExecStartPre = mkBefore [("+"+pkgs.writeShellScript "sshsrht-credentials" ''
852 # Replace values begining with a '<' by the content of the file whose name is after.
853 ${pkgs.gawk}/bin/gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} |
854 install -o ${users."sshsrht".name} -g ${groups."sshsrht".name} -m 440 \
855 /dev/stdin ${users."sshsrht".home}/../config.ini
862 # srht-dispatch, *srht-keys, and *srht-shell
863 # look up in ../config.ini from this directory;
864 # that config.ini being set in *srht.service's ExecStartPre=
865 home = "/run/sourcehut/sshsrht/subdir";
867 # Unfortunately, AuthorizedKeysCommandUser does not honor supplementary groups,
868 # hence the main group is used.
869 if cfg.postgresql.enable
870 && hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")
871 then groups.postgres.name
872 else groups.nogroup.name;
873 description = "sourcehut user for sshd's AuthorizedKeysCommandUser";
875 groups."sshsrht" = {};
881 (import ./service.nix "builds" {
882 inherit configIniOfService;
883 srvsrht = "buildsrht";
885 # TODO: a celery worker on the master and worker are apparently needed
886 extraServices.buildsrht-worker = let
887 qemuPackage = pkgs.qemu_kvm;
888 serviceName = "buildsrht-worker";
889 statePath = "/var/lib/sourcehut/${serviceName}";
890 in mkIf cfg.builds.enableWorker {
891 path = [ pkgs.openssh pkgs.docker ];
894 if test -z "$(docker images -q qemu:latest 2>/dev/null)" \
895 || test "$(cat ${statePath}/docker-image-qemu)" != "${qemuPackage.version}"
897 # Create and import qemu:latest image for docker
898 ${pkgs.dockerTools.streamLayeredImage {
901 contents = [ qemuPackage ];
903 # Mark down current package version
904 echo '${qemuPackage.version}' >${statePath}/docker-image-qemu
908 ExecStart = "${pkgs.sourcehut.buildsrht}/bin/builds.sr.ht-worker";
909 RuntimeDirectory = [ "sourcehut/${serviceName}/subdir" ];
910 # builds.sr.ht-worker looks up ../config.ini
911 LogsDirectory = [ "sourcehut/${serviceName}" ];
912 StateDirectory = [ "sourcehut/${serviceName}" ];
913 WorkingDirectory = "-"+"/run/sourcehut/${serviceName}/subdir";
917 image_dirs = flatten (
918 mapAttrsToList (distro: revs:
919 mapAttrsToList (rev: archs:
920 mapAttrsToList (arch: image:
921 pkgs.runCommand "buildsrht-images" { } ''
922 mkdir -p $out/${distro}/${rev}/${arch}
923 ln -s ${image}/*.qcow2 $out/${distro}/${rev}/${arch}/root.img.qcow2
929 image_dir_pre = pkgs.symlinkJoin {
930 name = "builds.sr.ht-worker-images-pre";
932 # FIXME: not working, apparently because ubuntu/latest is a broken link
933 # ++ [ "${pkgs.sourcehut.buildsrht}/lib/images" ];
935 image_dir = pkgs.runCommand "builds.sr.ht-worker-images" { } ''
937 cp -Lr ${image_dir_pre}/* $out/images
941 users.users.${cfg.builds.user} = {
943 # Allow reading of ${users."sshsrht".home}/../config.ini
944 extraGroups = [ groups."sshsrht".name ];
947 virtualisation.docker.enable = true;
949 services.sourcehut.settings = mkMerge [
950 { # Register the builds.sr.ht dispatcher
951 "git.sr.ht::dispatch"."/usr/bin/buildsrht-keys" =
952 mkDefault "${cfg.builds.user}:${cfg.builds.group}";
954 (mkIf cfg.builds.enableWorker {
955 "builds.sr.ht::worker".shell = "/usr/bin/runner-shell";
956 "builds.sr.ht::worker".images = mkDefault "${image_dir}/images";
957 "builds.sr.ht::worker".controlcmd = mkDefault "${image_dir}/images/control";
961 (mkIf cfg.builds.enableWorker {
963 docker.members = [ cfg.builds.user ];
966 (mkIf (cfg.builds.enableWorker && cfg.nginx.enable) {
967 # Allow nginx access to buildlogs
968 users.users.${nginx.user}.extraGroups = [ cfg.builds.group ];
969 systemd.services.nginx = {
970 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."builds.sr.ht::worker".buildlogs}:/var/log/nginx/buildsrht/logs" ];
972 services.nginx.virtualHosts."logs.${domain}" = mkMerge [ {
973 /* FIXME: is a listen needed?
974 listen = with builtins;
975 # FIXME: not compatible with IPv6
976 let address = split ":" cfg.settings."builds.sr.ht::worker".name; in
977 [{ addr = elemAt address 0; port = lib.toInt (elemAt address 2); }];
979 locations."/logs/".alias = "/var/log/nginx/buildsrht/logs/";
980 } cfg.nginx.virtualHost ];
984 (import ./service.nix "dispatch" {
985 inherit configIniOfService;
988 (import ./service.nix "git" (let
990 path = [ cfg.git.package ];
991 serviceConfig.BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
992 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".post-update-script}:/var/lib/sourcehut/gitsrht/bin/post-update-script" ];
994 mainService = mkMerge [ baseService {
995 serviceConfig.StateDirectory = [ "sourcehut/gitsrht" ];
998 inherit configIniOfService;
1002 extraTimers.gitsrht-periodic = {
1003 service = mainService;
1005 OnCalendar = ["20min"];
1008 extraConfig = mkMerge [
1010 users.users.${cfg.git.user} = {
1011 # https://stackoverflow.com/questions/22314298/git-push-results-in-fatal-protocol-error-bad-line-length-character-this
1012 # Probably could use gitsrht-shell if output is restricted to just parameters...
1014 # Allow reading of ${users."sshsrht".home}/../config.ini
1015 extraGroups = [ groups."sshsrht".name ];
1016 home = users.sshsrht.home;
1018 services.sourcehut.settings = {
1019 # Register the git.sr.ht dispatcher
1020 "git.sr.ht::dispatch"."/usr/bin/gitsrht-keys" =
1021 mkDefault "${cfg.git.user}:${cfg.git.group}";
1023 systemd.services.sshd = baseService;
1025 (mkIf cfg.nginx.enable {
1026 services.nginx.virtualHosts."git.${domain}" = {
1027 locations."/authorize" = {
1028 proxyPass = "http://${cfg.listenAddress}:${toString cfg.git.port}";
1030 proxy_pass_request_body off;
1031 proxy_set_header Content-Length "";
1032 proxy_set_header X-Original-URI $request_uri;
1035 locations."~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$" = {
1036 root = "/var/lib/sourcehut/gitsrht/repos";
1038 GIT_HTTP_EXPORT_ALL = "";
1039 GIT_PROJECT_ROOT = "$document_root";
1041 SCRIPT_FILENAME = "${cfg.git.package}/bin/git-http-backend";
1044 auth_request /authorize;
1045 fastcgi_read_timeout 500s;
1046 fastcgi_pass unix:/run/gitsrht-fcgiwrap.sock;
1051 systemd.sockets.gitsrht-fcgiwrap = {
1052 before = [ "nginx.service" ];
1053 wantedBy = [ "sockets.target" "gitsrht.service" ];
1054 # This path remains accessible to nginx.service, which has no RootDirectory=
1055 socketConfig.ListenStream = "/run/gitsrht-fcgiwrap.sock";
1056 socketConfig.SocketUser = nginx.user;
1057 socketConfig.SocketMode = "600";
1061 extraServices.gitsrht-fcgiwrap = mkIf cfg.nginx.enable {
1063 # Socket is passed by gitsrht-fcgiwrap.socket
1064 ExecStart = "${pkgs.fcgiwrap}/sbin/fcgiwrap -c ${toString cfg.git.fcgiwrap.preforkProcess}";
1065 # No need for config.ini
1066 ExecStartPre = mkForce [];
1069 BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ];
1070 IPAddressDeny = "any";
1071 InaccessiblePaths = [ "-+/run/postgresql" "-+/run/redis" ];
1072 PrivateNetwork = true;
1073 RestrictAddressFamilies = mkForce [ "none" ];
1074 SystemCallFilter = mkForce [
1076 "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid"
1077 # @timer is needed for alarm()
1082 (import ./service.nix "hg" (let
1084 path = [ cfg.hg.package ];
1085 serviceConfig.BindPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/sourcehut/hgsrht/repos" ];
1086 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."ht.sr.ht".changegroup-script}:/var/lib/sourcehut/hgsrht/bin/changegroup-script" ];
1088 mainService = mkMerge [ baseService {
1089 serviceConfig.StateDirectory = [ "sourcehut/hgsrht" ];
1092 inherit configIniOfService;
1093 inherit mainService;
1096 extraTimers.hgsrht-periodic = {
1097 service = mainService;
1099 OnCalendar = ["20min"];
1102 extraTimers.hgsrht-clonebundles = mkIf cfg.hg.cloneBundles {
1103 service = mainService;
1105 OnCalendar = ["daily"];
1109 extraConfig = mkMerge [
1111 users.users.${cfg.hg.user} = {
1113 # Allow reading of ${users."sshsrht".home}/../config.ini
1114 extraGroups = [ groups."sshsrht".name ];
1116 services.sourcehut.settings = {
1117 # Register the hg.sr.ht dispatcher
1118 "hg.sr.ht::dispatch"."/usr/bin/hgsrht-keys" =
1119 mkDefault "${cfg.hg.user}:${cfg.hg.group}";
1121 systemd.services.sshd = baseService;
1123 (mkIf cfg.nginx.enable {
1124 # Allow nginx access to repositories
1125 users.users.${nginx.user}.extraGroups = [ cfg.hg.group ];
1126 services.nginx.virtualHosts."hg.${domain}" = {
1127 locations."/authorize" = {
1128 proxyPass = "http://${cfg.listenAddress}:${toString cfg.hg.port}";
1130 proxy_pass_request_body off;
1131 proxy_set_header Content-Length "";
1132 proxy_set_header X-Original-URI $request_uri;
1135 # Let clients reach pull bundles. We don't really need to lock this down even for
1136 # private repos because the bundles are named after the revision hashes...
1137 # so someone would need to know or guess a SHA value to download anything.
1138 # TODO: proxyPass to an hg serve service?
1139 locations."~ ^/[~^][a-z0-9_]+/[a-zA-Z0-9_.-]+/\\.hg/bundles/.*$" = {
1140 root = "/var/lib/nginx/hgsrht/repos";
1142 auth_request /authorize;
1147 systemd.services.nginx = {
1148 serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/nginx/hgsrht/repos" ];
1153 (import ./service.nix "hub" {
1154 inherit configIniOfService;
1157 services.nginx = mkIf cfg.nginx.enable {
1158 virtualHosts."hub.${domain}" = mkMerge [ {
1159 serverAliases = [ domain ];
1160 } cfg.nginx.virtualHost ];
1164 (import ./service.nix "lists" {
1165 inherit configIniOfService;
1168 extraServices.listssrht-lmtp = {
1169 requires = [ "postfix.service" ];
1170 unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1171 serviceConfig.ExecStart = "${cfg.python}/bin/listssrht-lmtp";
1172 # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1173 serviceConfig.PrivateUsers = mkForce false;
1175 extraServices.listssrht-process = {
1176 serviceConfig.ExecStart = "${cfg.python}/bin/celery -A listssrht.process worker --queues sourcehut.listssrht-process --loglevel INFO --pool eventlet";
1177 # Avoid crashing: os.getloadavg()
1178 serviceConfig.ProcSubset = mkForce "all";
1180 extraConfig = mkIf cfg.postfix.enable {
1181 users.groups.${postfix.group}.members = [ cfg.lists.user ];
1182 services.sourcehut.settings."lists.sr.ht::mail".sock-group = postfix.group;
1183 services.postfix.transport = ''
1184 lists.${domain} lmtp:unix:${cfg.settings."lists.sr.ht::worker".sock}
1188 (import ./service.nix "man" {
1189 inherit configIniOfService;
1192 (import ./service.nix "meta" {
1193 inherit configIniOfService;
1196 extraServices.metasrht-api = {
1197 serviceConfig.Restart = "always";
1198 serviceConfig.RestartSec = "2s";
1199 preStart = "set -x\n" + concatStringsSep "\n\n" (attrValues (mapAttrs (k: s:
1200 let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht$" k;
1201 srv = head srvMatch;
1203 # Configure client(s) as "preauthorized"
1204 optionalString (srvMatch != null && cfg.${srv}.enable && ((s.oauth-client-id or null) != null)) ''
1205 # Configure ${srv}'s OAuth client as "preauthorized"
1206 ${postgresql.package}/bin/psql '${cfg.settings."meta.sr.ht".connection-string}' \
1207 -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${s.oauth-client-id}'"
1210 serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b ${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
1212 extraTimers.metasrht-daily.timerConfig = {
1213 OnCalendar = ["daily"];
1216 extraConfig = mkMerge [
1219 { assertion = let s = cfg.settings."meta.sr.ht::billing"; in
1220 s.enabled == "yes" -> (s.stripe-public-key != null && s.stripe-secret-key != null);
1221 message = "If meta.sr.ht::billing is enabled, the keys must be defined.";
1224 environment.systemPackages = optional cfg.meta.enable
1225 (pkgs.writeShellScriptBin "metasrht-manageuser" ''
1227 if test "$(${pkgs.coreutils}/bin/id -n -u)" != '${cfg.meta.user}'
1228 then exec sudo -u '${cfg.meta.user}' "$0" "$@"
1230 # In order to load config.ini
1231 if cd /run/sourcehut/metasrht
1232 then exec ${cfg.python}/bin/metasrht-manageuser "$@"
1234 Please run: sudo systemctl start metasrht
1241 (mkIf cfg.nginx.enable {
1242 services.nginx.virtualHosts."meta.${domain}" = {
1243 locations."/query" = {
1244 proxyPass = "http://${cfg.listenAddress}:${toString (cfg.meta.port + 100)}";
1246 # Remove those as they would make gql.sr.ht's call
1247 # to http.request.RemoteAddr fail to match against a sane
1248 # [meta.rt.ht::api] internal-ipnet set to localhost, as it should
1249 # because it's just a local nginx querying to a local metasrht-api.
1250 proxy_set_header X-Real-IP "";
1251 proxy_set_header X-Forwarded-For "";
1252 if ($request_method = 'OPTIONS') {
1253 add_header 'Access-Control-Allow-Origin' '*';
1254 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
1255 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
1256 add_header 'Access-Control-Max-Age' 1728000;
1257 add_header 'Content-Type' 'text/plain; charset=utf-8';
1258 add_header 'Content-Length' 0;
1262 add_header 'Access-Control-Allow-Origin' '*';
1263 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
1264 add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
1265 add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
1272 (import ./service.nix "pages" {
1273 inherit configIniOfService;
1276 srvsrht = "pagessrht";
1277 version = pkgs.sourcehut.${srvsrht}.version;
1278 stateDir = "/var/lib/sourcehut/${srvsrht}";
1279 iniKey = "pages.sr.ht";
1281 preStart = mkBefore ''
1283 # Use the /run/sourcehut/${srvsrht}/config.ini
1284 # installed by a previous ExecStartPre= in baseService
1285 cd /run/sourcehut/${srvsrht}
1287 if test ! -e ${stateDir}/db; then
1288 ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f ${pkgs.sourcehut.pagessrht}/share/sql/schema.sql
1289 echo ${version} >${stateDir}/db
1292 ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
1293 # Just try all the migrations because they're not linked to the version
1294 for sql in ${pkgs.sourcehut.pagessrht}/share/sql/migrations/*.sql; do
1295 ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f "$sql" || true
1300 touch ${stateDir}/webhook
1303 ExecStart = mkForce "${pkgs.sourcehut.pagessrht}/bin/pages.sr.ht -b ${cfg.listenAddress}:${toString cfg.pages.port}";
1307 (import ./service.nix "paste" {
1308 inherit configIniOfService;
1311 (import ./service.nix "todo" {
1312 inherit configIniOfService;
1315 extraServices.todosrht-lmtp = {
1316 requires = [ "postfix.service" ];
1317 unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service";
1318 serviceConfig.ExecStart = "${cfg.python}/bin/todosrht-lmtp";
1319 # Avoid crashing: os.chown(sock, os.getuid(), sock_gid)
1320 serviceConfig.PrivateUsers = mkForce false;
1322 extraConfig = mkIf cfg.postfix.enable {
1323 users.groups.${postfix.group}.members = [ cfg.todo.user ];
1324 services.sourcehut.settings."todo.sr.ht::mail".sock-group = postfix.group;
1325 services.postfix.transport = ''
1326 todo.${domain} lmtp:unix:${cfg.settings."todo.sr.ht::mail".sock}
1330 (mkRenamedOptionModule [ "services" "sourcehut" "originBase" ]
1331 [ "services" "sourcehut" "settings" "sr.ht" "global-domain" ])
1332 (mkRenamedOptionModule [ "services" "sourcehut" "address" ]
1333 [ "services" "sourcehut" "listenAddress" ])
1336 meta.doc = ./sourcehut.xml;
1337 meta.maintainers = with maintainers; [ julm tomberek ];