From 8ed7fd0f3a722b911b2d301157023229e5c4e5d9 Mon Sep 17 00:00:00 2001 From: Julien Moutinho Date: Sat, 14 Aug 2021 12:39:21 +0200 Subject: [PATCH] nixos/sourcehut: full rewrite, with fixes and hardening --- .../services/misc/sourcehut/default.nix | 1424 +++++++++++++++-- .../services/misc/sourcehut/service.nix | 415 ++++- .../services/misc/sourcehut/sourcehut.xml | 24 +- 3 files changed, 1662 insertions(+), 201 deletions(-) diff --git a/nixos/modules/services/misc/sourcehut/default.nix b/nixos/modules/services/misc/sourcehut/default.nix index c84a75b0ca02..e6565e292f7d 100644 --- a/nixos/modules/services/misc/sourcehut/default.nix +++ b/nixos/modules/services/misc/sourcehut/default.nix @@ -1,14 +1,90 @@ { config, pkgs, lib, ... }: - with lib; let + inherit (config.services) nginx postfix postgresql redis; + inherit (config.users) users groups; cfg = config.services.sourcehut; - cfgIni = cfg.settings; - settingsFormat = pkgs.formats.ini { }; + domain = cfg.settings."sr.ht".global-domain; + settingsFormat = pkgs.formats.ini { + listToValue = concatMapStringsSep "," (generators.mkValueStringDefault {}); + mkKeyValue = k: v: + if v == null then "" + else generators.mkKeyValueDefault { + mkValueString = v: + if v == true then "yes" + else if v == false then "no" + else generators.mkValueStringDefault {} v; + } "=" k v; + }; + configIniOfService = srv: settingsFormat.generate "sourcehut-${srv}-config.ini" + # Each service needs access to only a subset of sections (and secrets). + (filterAttrs (k: v: v != null) + (mapAttrs (section: v: + let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht(::.*)?$" section; in + if srvMatch == null # Include sections shared by all services + || head srvMatch == srv # Include sections for the service being configured + then v + # Enable Web links and integrations between services. + else if tail srvMatch == [ null ] && elem (head srvMatch) cfg.services + then { + inherit (v) origin; + # mansrht crashes without it + oauth-client-id = v.oauth-client-id or null; + } + # Drop sub-sections of other services + else null) + (recursiveUpdate cfg.settings { + # Those paths are mounted using BindPaths= or BindReadOnlyPaths= + # for services needing access to them. + "builds.sr.ht::worker".buildlogs = "/var/log/sourcehut/buildsrht/logs"; + "git.sr.ht".post-update-script = "/var/lib/sourcehut/gitsrht/bin/post-update-script"; + "git.sr.ht".repos = "/var/lib/sourcehut/gitsrht/repos"; + "hg.sr.ht".changegroup-script = "/var/lib/sourcehut/hgsrht/bin/changegroup-script"; + "hg.sr.ht".repos = "/var/lib/sourcehut/hgsrht/repos"; + # Making this a per service option despite being in a global section, + # so that it uses the redis-server used by the service. + "sr.ht".redis-host = cfg.${srv}.redis.host; + }))); + commonServiceSettings = srv: { + origin = mkOption { + description = "URL ${srv}.sr.ht is being served at (protocol://domain)"; + type = types.str; + default = "https://${srv}.${domain}"; + defaultText = "https://${srv}.example.com"; + }; + debug-host = mkOption { + description = "Address to bind the debug server to."; + type = with types; nullOr str; + default = null; + }; + debug-port = mkOption { + description = "Port to bind the debug server to."; + type = with types; nullOr str; + default = null; + }; + connection-string = mkOption { + description = "SQLAlchemy connection string for the database."; + type = types.str; + default = "postgresql:///localhost?user=${srv}srht&host=/run/postgresql"; + }; + migrate-on-upgrade = mkEnableOption "automatic migrations on package upgrade" // { default = true; }; + oauth-client-id = mkOption { + description = "${srv}.sr.ht's OAuth client id for meta.sr.ht."; + type = types.str; + }; + oauth-client-secret = mkOption { + description = "${srv}.sr.ht's OAuth client secret for meta.sr.ht."; + type = types.path; + apply = s: "<" + toString s; + }; + }; # Specialized python containing all the modules python = pkgs.sourcehut.python.withPackages (ps: with ps; [ gunicorn + eventlet + # For monitoring Celery: sudo -u listssrht celery --app listssrht.process -b redis+socket:///run/redis-sourcehut/redis.sock?virtual_host=5 flower + flower # Sourcehut services srht buildsrht @@ -19,72 +95,37 @@ let listssrht mansrht metasrht + # Not a python package + #pagessrht pastesrht todosrht ]); + mkOptionNullOrStr = description: mkOption { + inherit description; + type = with types; nullOr str; + default = null; + }; in { - imports = - [ - ./git.nix - ./hg.nix - ./hub.nix - ./todo.nix - ./man.nix - ./meta.nix - ./paste.nix - ./builds.nix - ./lists.nix - ./dispatch.nix - (mkRemovedOptionModule [ "services" "sourcehut" "nginx" "enable" ] '' - The sourcehut module supports `nginx` as a local reverse-proxy by default and doesn't - support other reverse-proxies officially. - - However it's possible to use an alternative reverse-proxy by - - * disabling nginx - * adjusting the relevant settings for server addresses and ports directly - - Further details about this can be found in the `Sourcehut`-section of the NixOS-manual. - '') - ]; - options.services.sourcehut = { - enable = mkOption { - type = types.bool; - default = false; - description = '' - Enable sourcehut - git hosting, continuous integration, mailing list, ticket tracking, - task dispatching, wiki and account management services - ''; - }; + enable = mkEnableOption '' + sourcehut - git hosting, continuous integration, mailing list, ticket tracking, + task dispatching, wiki and account management services + ''; services = mkOption { - type = types.nonEmptyListOf (types.enum [ "builds" "dispatch" "git" "hub" "hg" "lists" "man" "meta" "paste" "todo" ]); - default = [ "man" "meta" "paste" ]; - example = [ "builds" "dispatch" "git" "hub" "hg" "lists" "man" "meta" "paste" "todo" ]; + type = with types; listOf (enum + [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]); + defaultText = "locally enabled services"; description = '' - Services to enable on the sourcehut network. + Services that may be displayed as links in the title bar of the Web interface. ''; }; - originBase = mkOption { + listenAddress = mkOption { type = types.str; - default = with config.networking; hostName + lib.optionalString (domain != null) ".${domain}"; - defaultText = literalExpression '' - with config.networking; hostName + optionalString (domain != null) ".''${domain}" - ''; - description = '' - Host name used by reverse-proxy and for default settings. Will host services at git."''${originBase}". For example: git.sr.ht - ''; - }; - - address = mkOption { - type = types.str; - default = "127.0.0.1"; - description = '' - Address to bind to. - ''; + default = "localhost"; + description = "Address to bind to."; }; python = mkOption { @@ -97,105 +138,1212 @@ in ''; }; - statePath = mkOption { - type = types.path; - default = "/var/lib/sourcehut"; - description = '' - Root state path for the sourcehut network. If left as the default value - this directory will automatically be created before the sourcehut server - starts, otherwise the sysadmin is responsible for ensuring the - directory exists with appropriate ownership and permissions. - ''; + minio = { + enable = mkEnableOption ''local minio integration''; + }; + + nginx = { + enable = mkEnableOption ''local nginx integration''; + virtualHost = mkOption { + type = types.attrs; + default = {}; + description = "Virtual-host configuration merged with all Sourcehut's virtual-hosts."; + }; + }; + + postfix = { + enable = mkEnableOption ''local postfix integration''; + }; + + postgresql = { + enable = mkEnableOption ''local postgresql integration''; + }; + + redis = { + enable = mkEnableOption ''local redis integration in a dedicated redis-server''; }; settings = mkOption { type = lib.types.submodule { freeformType = settingsFormat.type; + options."sr.ht" = { + global-domain = mkOption { + description = "Global domain name."; + type = types.str; + example = "example.com"; + }; + environment = mkOption { + description = "Values other than \"production\" adds a banner to each page."; + type = types.enum [ "development" "production" ]; + default = "development"; + }; + network-key = mkOption { + description = '' + An absolute file path (which should be outside the Nix-store) + to a secret key to encrypt internal messages with. Use srht-keygen network to + generate this key. It must be consistent between all services and nodes. + ''; + type = types.path; + apply = s: "<" + toString s; + }; + owner-email = mkOption { + description = "Owner's email."; + type = types.str; + default = "contact@example.com"; + }; + owner-name = mkOption { + description = "Owner's name."; + type = types.str; + default = "John Doe"; + }; + site-blurb = mkOption { + description = "Blurb for your site."; + type = types.str; + default = "the hacker's forge"; + }; + site-info = mkOption { + description = "The top-level info page for your site."; + type = types.str; + default = "https://sourcehut.org"; + }; + service-key = mkOption { + description = '' + An absolute file path (which should be outside the Nix-store) + to a key used for encrypting session cookies. Use srht-keygen service to + generate the service key. This must be shared between each node of the same + service (e.g. git1.sr.ht and git2.sr.ht), but different services may use + different keys. If you configure all of your services with the same + config.ini, you may use the same service-key for all of them. + ''; + type = types.path; + apply = s: "<" + toString s; + }; + site-name = mkOption { + description = "The name of your network of sr.ht-based sites."; + type = types.str; + default = "sourcehut"; + }; + source-url = mkOption { + description = "The source code for your fork of sr.ht."; + type = types.str; + default = "https://git.sr.ht/~sircmpwn/srht"; + }; + }; + options.mail = { + smtp-host = mkOptionNullOrStr "Outgoing SMTP host."; + smtp-port = mkOption { + description = "Outgoing SMTP port."; + type = with types; nullOr port; + default = null; + }; + smtp-user = mkOptionNullOrStr "Outgoing SMTP user."; + smtp-password = mkOptionNullOrStr "Outgoing SMTP password."; + smtp-from = mkOptionNullOrStr "Outgoing SMTP FROM."; + error-to = mkOptionNullOrStr "Address receiving application exceptions"; + error-from = mkOptionNullOrStr "Address sending application exceptions"; + pgp-privkey = mkOptionNullOrStr '' + An absolute file path (which should be outside the Nix-store) + to an OpenPGP private key. + + Your PGP key information (DO NOT mix up pub and priv here) + You must remove the password from your secret key, if present. + You can do this with gpg --edit-key [key-id], + then use the passwd command and do not enter a new password. + ''; + pgp-pubkey = mkOptionNullOrStr "OpenPGP public key."; + pgp-key-id = mkOptionNullOrStr "OpenPGP key identifier."; + }; + options.objects = { + s3-upstream = mkOption { + description = "Configure the S3-compatible object storage service."; + type = with types; nullOr str; + default = null; + }; + s3-access-key = mkOption { + description = "Access key to the S3-compatible object storage service"; + type = with types; nullOr str; + default = null; + }; + s3-secret-key = mkOption { + description = '' + An absolute file path (which should be outside the Nix-store) + to the secret key of the S3-compatible object storage service. + ''; + type = with types; nullOr path; + default = null; + apply = mapNullable (s: "<" + toString s); + }; + }; + options.webhooks = { + private-key = mkOption { + description = '' + An absolute file path (which should be outside the Nix-store) + to a base64-encoded Ed25519 key for signing webhook payloads. + This should be consistent for all *.sr.ht sites, + as this key will be used to verify signatures + from other sites in your network. + Use the srht-keygen webhook command to generate a key. + ''; + type = types.path; + apply = s: "<" + toString s; + }; + }; + + options."dispatch.sr.ht" = commonServiceSettings "dispatch" // { + }; + options."dispatch.sr.ht::github" = { + oauth-client-id = mkOptionNullOrStr "OAuth client id."; + oauth-client-secret = mkOptionNullOrStr "OAuth client secret."; + }; + options."dispatch.sr.ht::gitlab" = { + enabled = mkEnableOption "GitLab integration"; + canonical-upstream = mkOption { + type = types.str; + description = "Canonical upstream."; + default = "gitlab.com"; + }; + repo-cache = mkOption { + type = types.str; + description = "Repository cache directory."; + default = "./repo-cache"; + }; + "gitlab.com" = mkOption { + type = with types; nullOr str; + description = "GitLab id and secret."; + default = null; + example = "GitLab:application id:secret"; + }; + }; + + options."builds.sr.ht" = commonServiceSettings "builds" // { + allow-free = mkEnableOption "nonpaying users to submit builds"; + redis = mkOption { + description = "The Redis connection used for the Celery worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-buildsrht/redis.sock?virtual_host=2"; + }; + shell = mkOption { + description = '' + Scripts used to launch on SSH connection. + /usr/bin/master-shell on master, + /usr/bin/runner-shell on runner. + If master and worker are on the same system + set to /usr/bin/runner-shell. + ''; + type = types.enum ["/usr/bin/master-shell" "/usr/bin/runner-shell"]; + default = "/usr/bin/master-shell"; + }; + }; + options."builds.sr.ht::worker" = { + bind-address = mkOption { + description = '' + HTTP bind address for serving local build information/monitoring. + ''; + type = types.str; + default = "localhost:8080"; + }; + buildlogs = mkOption { + description = "Path to write build logs."; + type = types.str; + default = "/var/log/sourcehut/buildsrht"; + }; + name = mkOption { + description = '' + Listening address and listening port + of the build runner (with HTTP port if not 80). + ''; + type = types.str; + default = "localhost:5020"; + }; + timeout = mkOption { + description = '' + Max build duration. + See . + ''; + type = types.str; + default = "3m"; + }; + }; + + options."git.sr.ht" = commonServiceSettings "git" // { + outgoing-domain = mkOption { + description = "Outgoing domain."; + type = types.str; + default = "https://git.localhost.localdomain"; + }; + post-update-script = mkOption { + description = '' + A post-update script which is installed in every git repo. + This setting is propagated to newer and existing repositories. + ''; + type = types.path; + default = "${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook"; + defaultText = "\${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook"; + # Git hooks are run relative to their repository's directory, + # but gitsrht-update-hook looks up ../config.ini + apply = p: pkgs.writeShellScript "update-hook-wrapper" '' + set -e + test -e "''${PWD%/*}"/config.ini || + ln -s /run/sourcehut/gitsrht/config.ini "''${PWD%/*}"/config.ini + exec -a "$0" '${p}' "$@" + ''; + }; + repos = mkOption { + description = '' + Path to git repositories on disk. + If changing the default, you must ensure that + the gitsrht's user as read and write access to it. + ''; + type = types.str; + default = "/var/lib/sourcehut/gitsrht/repos"; + }; + webhooks = mkOption { + description = "The Redis connection used for the webhooks worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-gitsrht/redis.sock?virtual_host=1"; + }; + }; + options."git.sr.ht::api" = { + internal-ipnet = mkOption { + description = '' + Set of IP subnets which are permitted to utilize internal API + authentication. This should be limited to the subnets + from which your *.sr.ht services are running. + See . + ''; + type = with types; listOf str; + default = [ "127.0.0.0/8" "::1/128" ]; + }; + }; + + options."hg.sr.ht" = commonServiceSettings "hg" // { + changegroup-script = mkOption { + description = '' + A changegroup script which is installed in every mercurial repo. + This setting is propagated to newer and existing repositories. + ''; + type = types.str; + default = "${cfg.python}/bin/hgsrht-hook-changegroup"; + defaultText = "\${cfg.python}/bin/hgsrht-hook-changegroup"; + # Mercurial's changegroup hooks are run relative to their repository's directory, + # but hgsrht-hook-changegroup looks up ./config.ini + apply = p: pkgs.writeShellScript "hook-changegroup-wrapper" '' + set -e + test -e "''$PWD"/config.ini || + ln -s /run/sourcehut/hgsrht/config.ini "''$PWD"/config.ini + exec -a "$0" '${p}' "$@" + ''; + }; + repos = mkOption { + description = '' + Path to mercurial repositories on disk. + If changing the default, you must ensure that + the hgsrht's user as read and write access to it. + ''; + type = types.str; + default = "/var/lib/sourcehut/hgsrht/repos"; + }; + srhtext = mkOptionNullOrStr '' + Path to the srht mercurial extension + (defaults to where the hgsrht code is) + ''; + clone_bundle_threshold = mkOption { + description = ".hg/store size (in MB) past which the nightly job generates clone bundles."; + type = types.ints.unsigned; + default = 50; + }; + hg_ssh = mkOption { + description = "Path to hg-ssh (if not in $PATH)."; + type = types.str; + default = "${pkgs.mercurial}/bin/hg-ssh"; + defaultText = "\${pkgs.mercurial}/bin/hg-ssh"; + }; + webhooks = mkOption { + description = "The Redis connection used for the webhooks worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-hgsrht/redis.sock?virtual_host=1"; + }; + }; + + options."hub.sr.ht" = commonServiceSettings "hub" // { + }; + + options."lists.sr.ht" = commonServiceSettings "lists" // { + allow-new-lists = mkEnableOption "Allow creation of new lists."; + notify-from = mkOption { + description = "Outgoing email for notifications generated by users."; + type = types.str; + default = "lists-notify@localhost.localdomain"; + }; + posting-domain = mkOption { + description = "Posting domain."; + type = types.str; + default = "lists.localhost.localdomain"; + }; + redis = mkOption { + description = "The Redis connection used for the Celery worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=2"; + }; + webhooks = mkOption { + description = "The Redis connection used for the webhooks worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-listssrht/redis.sock?virtual_host=1"; + }; + }; + options."lists.sr.ht::worker" = { + reject-mimetypes = mkOption { + description = '' + Comma-delimited list of Content-Types to reject. Messages with Content-Types + included in this list are rejected. Multipart messages are always supported, + and each part is checked against this list. + + Uses fnmatch for wildcard expansion. + ''; + type = with types; listOf str; + default = ["text/html"]; + }; + reject-url = mkOption { + description = "Reject URL."; + type = types.str; + default = "https://man.sr.ht/lists.sr.ht/etiquette.md"; + }; + sock = mkOption { + description = '' + Path for the lmtp daemon's unix socket. Direct incoming mail to this socket. + Alternatively, specify IP:PORT and an SMTP server will be run instead. + ''; + type = types.str; + default = "/tmp/lists.sr.ht-lmtp.sock"; + }; + sock-group = mkOption { + description = '' + The lmtp daemon will make the unix socket group-read/write + for users in this group. + ''; + type = types.str; + default = "postfix"; + }; + }; + + options."man.sr.ht" = commonServiceSettings "man" // { + }; + + options."meta.sr.ht" = + removeAttrs (commonServiceSettings "meta") + ["oauth-client-id" "oauth-client-secret"] // { + api-origin = mkOption { + description = "Origin URL for API, 100 more than web."; + type = types.str; + default = "http://${cfg.listenAddress}:${toString (cfg.meta.port + 100)}"; + defaultText = ''http://:''${toString ( + 100)}''; + }; + webhooks = mkOption { + description = "The Redis connection used for the webhooks worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-metasrht/redis.sock?virtual_host=1"; + }; + welcome-emails = mkEnableOption "sending stock sourcehut welcome emails after signup"; + }; + options."meta.sr.ht::api" = { + internal-ipnet = mkOption { + description = '' + Set of IP subnets which are permitted to utilize internal API + authentication. This should be limited to the subnets + from which your *.sr.ht services are running. + See . + ''; + type = with types; listOf str; + default = [ "127.0.0.0/8" "::1/128" ]; + }; + }; + options."meta.sr.ht::aliases" = mkOption { + description = "Aliases for the client IDs of commonly used OAuth clients."; + type = with types; attrsOf int; + default = {}; + example = { "git.sr.ht" = 12345; }; + }; + options."meta.sr.ht::billing" = { + enabled = mkEnableOption "the billing system"; + stripe-public-key = mkOptionNullOrStr "Public key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys"; + stripe-secret-key = mkOptionNullOrStr '' + An absolute file path (which should be outside the Nix-store) + to a secret key for Stripe. Get your keys at https://dashboard.stripe.com/account/apikeys + '' // { + apply = mapNullable (s: "<" + toString s); + }; + }; + options."meta.sr.ht::settings" = { + registration = mkEnableOption "public registration"; + onboarding-redirect = mkOption { + description = "Where to redirect new users upon registration."; + type = types.str; + default = "https://meta.localhost.localdomain"; + }; + user-invites = mkOption { + description = '' + How many invites each user is issued upon registration + (only applicable if open registration is disabled). + ''; + type = types.ints.unsigned; + default = 5; + }; + }; + + options."pages.sr.ht" = commonServiceSettings "pages" // { + gemini-certs = mkOption { + description = '' + An absolute file path (which should be outside the Nix-store) + to Gemini certificates. + ''; + type = with types; nullOr path; + default = null; + }; + max-site-size = mkOption { + description = "Maximum size of any given site (post-gunzip), in MiB."; + type = types.int; + default = 1024; + }; + user-domain = mkOption { + description = '' + Configures the user domain, if enabled. + All users are given <username>.this.domain. + ''; + type = with types; nullOr str; + default = null; + }; + }; + options."pages.sr.ht::api" = { + internal-ipnet = mkOption { + description = '' + Set of IP subnets which are permitted to utilize internal API + authentication. This should be limited to the subnets + from which your *.sr.ht services are running. + See . + ''; + type = with types; listOf str; + default = [ "127.0.0.0/8" "::1/128" ]; + }; + }; + + options."paste.sr.ht" = commonServiceSettings "paste" // { + }; + + options."todo.sr.ht" = commonServiceSettings "todo" // { + notify-from = mkOption { + description = "Outgoing email for notifications generated by users."; + type = types.str; + default = "todo-notify@localhost.localdomain"; + }; + webhooks = mkOption { + description = "The Redis connection used for the webhooks worker."; + type = types.str; + default = "redis+socket:///run/redis-sourcehut-todosrht/redis.sock?virtual_host=1"; + }; + }; + options."todo.sr.ht::mail" = { + posting-domain = mkOption { + description = "Posting domain."; + type = types.str; + default = "todo.localhost.localdomain"; + }; + sock = mkOption { + description = '' + Path for the lmtp daemon's unix socket. Direct incoming mail to this socket. + Alternatively, specify IP:PORT and an SMTP server will be run instead. + ''; + type = types.str; + default = "/tmp/todo.sr.ht-lmtp.sock"; + }; + sock-group = mkOption { + description = '' + The lmtp daemon will make the unix socket group-read/write + for users in this group. + ''; + type = types.str; + default = "postfix"; + }; + }; }; default = { }; description = '' The configuration for the sourcehut network. ''; }; - }; - config = mkIf cfg.enable { - assertions = - [ - { - assertion = with cfgIni.webhooks; private-key != null && stringLength private-key == 44; - message = "The webhook's private key must be defined and of a 44 byte length."; - } + builds = { + enableWorker = mkEnableOption "worker for builds.sr.ht"; - { - assertion = hasAttrByPath [ "meta.sr.ht" "origin" ] cfgIni && cfgIni."meta.sr.ht".origin != null; - message = "meta.sr.ht's origin must be defined."; - } - ]; + images = mkOption { + type = with types; attrsOf (attrsOf (attrsOf package)); + default = { }; + example = lib.literalExpression ''(let + # Pinning unstable to allow usage with flakes and limit rebuilds. + pkgs_unstable = builtins.fetchGit { + url = "https://github.com/NixOS/nixpkgs"; + rev = "ff96a0fa5635770390b184ae74debea75c3fd534"; + ref = "nixos-unstable"; + }; + image_from_nixpkgs = (import ("${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") { + pkgs = (import pkgs_unstable {}); + }); + in + { + nixos.unstable.x86_64 = image_from_nixpkgs; + } + )''; + description = '' + Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2. + ''; + }; + }; - virtualisation.docker.enable = true; - environment.etc."sr.ht/config.ini".source = - settingsFormat.generate "sourcehut-config.ini" (mapAttrsRecursive - ( - path: v: if v == null then "" else v - ) - cfg.settings); + git = { + package = mkOption { + type = types.package; + default = pkgs.git; + example = literalExpression "pkgs.gitFull"; + description = '' + Git package for git.sr.ht. This can help silence collisions. + ''; + }; + fcgiwrap.preforkProcess = mkOption { + description = "Number of fcgiwrap processes to prefork."; + type = types.int; + default = 4; + }; + }; - environment.systemPackages = [ pkgs.sourcehut.coresrht ]; + hg = { + package = mkOption { + type = types.package; + default = pkgs.mercurial; + description = '' + Mercurial package for hg.sr.ht. This can help silence collisions. + ''; + }; + cloneBundles = mkOption { + type = types.bool; + default = false; + description = '' + Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories). + ''; + }; + }; - # PostgreSQL server - services.postgresql.enable = mkOverride 999 true; - # Mail server - services.postfix.enable = mkOverride 999 true; - # Cron daemon - services.cron.enable = mkOverride 999 true; - # Redis server - services.redis.enable = mkOverride 999 true; - services.redis.bind = mkOverride 999 "127.0.0.1"; - - services.sourcehut.settings = { - # The name of your network of sr.ht-based sites - "sr.ht".site-name = mkDefault "sourcehut"; - # The top-level info page for your site - "sr.ht".site-info = mkDefault "https://sourcehut.org"; - # {{ site-name }}, {{ site-blurb }} - "sr.ht".site-blurb = mkDefault "the hacker's forge"; - # If this != production, we add a banner to each page - "sr.ht".environment = mkDefault "development"; - # Contact information for the site owners - "sr.ht".owner-name = mkDefault "Drew DeVault"; - "sr.ht".owner-email = mkDefault "sir@cmpwn.com"; - # The source code for your fork of sr.ht - "sr.ht".source-url = mkDefault "https://git.sr.ht/~sircmpwn/srht"; - # A secret key to encrypt session cookies with - "sr.ht".secret-key = mkDefault null; - "sr.ht".global-domain = mkDefault null; - - # Outgoing SMTP settings - mail.smtp-host = mkDefault null; - mail.smtp-port = mkDefault null; - mail.smtp-user = mkDefault null; - mail.smtp-password = mkDefault null; - mail.smtp-from = mkDefault null; - # Application exceptions are emailed to this address - mail.error-to = mkDefault null; - mail.error-from = mkDefault null; - # Your PGP key information (DO NOT mix up pub and priv here) - # You must remove the password from your secret key, if present. - # You can do this with gpg --edit-key [key-id], then use the passwd - # command and do not enter a new password. - mail.pgp-privkey = mkDefault null; - mail.pgp-pubkey = mkDefault null; - mail.pgp-key-id = mkDefault null; - - # base64-encoded Ed25519 key for signing webhook payloads. This should be - # consistent for all *.sr.ht sites, as we'll use this key to verify signatures - # from other sites in your network. - # - # Use the srht-webhook-keygen command to generate a key. - webhooks.private-key = mkDefault null; + lists = { + process = { + extraArgs = mkOption { + type = with types; listOf str; + default = [ "--loglevel DEBUG" "--pool eventlet" "--without-heartbeat" ]; + description = "Extra arguments passed to the Celery responsible for processing mails."; + }; + celeryConfig = mkOption { + type = types.lines; + default = ""; + description = "Content of the celeryconfig.py used by the Celery of listssrht-process."; + }; + }; }; }; + + config = mkIf cfg.enable (mkMerge [ + { + environment.systemPackages = [ pkgs.sourcehut.coresrht ]; + + services.sourcehut.settings = { + "git.sr.ht".outgoing-domain = mkDefault "https://git.${domain}"; + "lists.sr.ht".notify-from = mkDefault "lists-notify@${domain}"; + "lists.sr.ht".posting-domain = mkDefault "lists.${domain}"; + "meta.sr.ht::settings".onboarding-redirect = mkDefault "https://meta.${domain}"; + "todo.sr.ht".notify-from = mkDefault "todo-notify@${domain}"; + "todo.sr.ht::mail".posting-domain = mkDefault "todo.${domain}"; + }; + } + (mkIf cfg.postgresql.enable { + assertions = [ + { assertion = postgresql.enable; + message = "postgresql must be enabled and configured"; + } + ]; + }) + (mkIf cfg.postfix.enable { + assertions = [ + { assertion = postfix.enable; + message = "postfix must be enabled and configured"; + } + ]; + # Needed for sharing the LMTP sockets with JoinsNamespaceOf= + systemd.services.postfix.serviceConfig.PrivateTmp = true; + }) + (mkIf cfg.redis.enable { + services.redis.vmOverCommit = mkDefault true; + }) + (mkIf cfg.nginx.enable { + assertions = [ + { assertion = nginx.enable; + message = "nginx must be enabled and configured"; + } + ]; + # For proxyPass= in virtual-hosts for Sourcehut services. + services.nginx.recommendedProxySettings = mkDefault true; + }) + (mkIf (cfg.builds.enable || cfg.git.enable || cfg.hg.enable) { + services.openssh = { + # Note that sshd will continue to honor AuthorizedKeysFile. + # Note that you may want automatically rotate + # or link to /dev/null the following log files: + # - /var/log/gitsrht-dispatch + # - /var/log/{build,git,hg}srht-keys + # - /var/log/{git,hg}srht-shell + # - /var/log/gitsrht-update-hook + authorizedKeysCommand = ''/etc/ssh/sourcehut/subdir/srht-dispatch "%u" "%h" "%t" "%k"''; + # srht-dispatch will setuid/setgid according to [git.sr.ht::dispatch] + authorizedKeysCommandUser = "root"; + extraConfig = '' + PermitUserEnvironment SRHT_* + ''; + }; + environment.etc."ssh/sourcehut/config.ini".source = + settingsFormat.generate "sourcehut-dispatch-config.ini" + (filterAttrs (k: v: k == "git.sr.ht::dispatch") + cfg.settings); + environment.etc."ssh/sourcehut/subdir/srht-dispatch" = { + # sshd_config(5): The program must be owned by root, not writable by group or others + mode = "0755"; + source = pkgs.writeShellScript "srht-dispatch" '' + set -e + cd /etc/ssh/sourcehut/subdir + ${cfg.python}/bin/gitsrht-dispatch "$@" + ''; + }; + systemd.services.sshd = { + #path = optional cfg.git.enable [ cfg.git.package ]; + serviceConfig = { + BindReadOnlyPaths = + # Note that those /usr/bin/* paths are hardcoded in multiple places in *.sr.ht, + # for instance to get the user from the [git.sr.ht::dispatch] settings. + # *srht-keys needs to: + # - access a redis-server in [sr.ht] redis-host, + # - access the PostgreSQL server in [*.sr.ht] connection-string, + # - query metasrht-api (through the HTTP API). + # Using this has the side effect of creating empty files in /usr/bin/ + optionals cfg.builds.enable [ + "${pkgs.writeShellScript "buildsrht-keys-wrapper" '' + set -ex + cd /run/sourcehut/buildsrht/subdir + exec -a "$0" ${pkgs.sourcehut.buildsrht}/bin/buildsrht-keys "$@" + ''}:/usr/bin/buildsrht-keys" + "${pkgs.sourcehut.buildsrht}/bin/master-shell:/usr/bin/master-shell" + "${pkgs.sourcehut.buildsrht}/bin/runner-shell:/usr/bin/runner-shell" + ] ++ + optionals cfg.git.enable [ + # /path/to/gitsrht-keys calls /path/to/gitsrht-shell, + # or [git.sr.ht] shell= if set. + "${pkgs.writeShellScript "gitsrht-keys-wrapper" '' + set -ex + cd /run/sourcehut/gitsrht/subdir + exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-keys "$@" + ''}:/usr/bin/gitsrht-keys" + "${pkgs.writeShellScript "gitsrht-shell-wrapper" '' + set -e + cd /run/sourcehut/gitsrht/subdir + exec -a "$0" ${pkgs.sourcehut.gitsrht}/bin/gitsrht-shell "$@" + ''}:/usr/bin/gitsrht-shell" + ] ++ + optionals cfg.hg.enable [ + # /path/to/hgsrht-keys calls /path/to/hgsrht-shell, + # or [hg.sr.ht] shell= if set. + "${pkgs.writeShellScript "hgsrht-keys-wrapper" '' + set -ex + cd /run/sourcehut/hgsrht/subdir + exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-keys "$@" + ''}:/usr/bin/hgsrht-keys" + ":/usr/bin/hgsrht-shell" + "${pkgs.writeShellScript "hgsrht-shell-wrapper" '' + set -e + cd /run/sourcehut/hgsrht/subdir + exec -a "$0" ${pkgs.sourcehut.hgsrht}/bin/hgsrht-shell "$@" + ''}:/usr/bin/hgsrht-shell" + ]; + }; + }; + }) + ]); + + imports = [ + + (import ./service.nix "builds" { + inherit configIniOfService; + srvsrht = "buildsrht"; + port = 5002; + # TODO: a celery worker on the master and worker are apparently needed + extraServices.buildsrht-worker = let + qemuPackage = pkgs.qemu_kvm; + serviceName = "buildsrht-worker"; + statePath = "/var/lib/sourcehut/${serviceName}"; + in mkIf cfg.builds.enableWorker { + path = [ pkgs.openssh pkgs.docker ]; + preStart = '' + set -x + if test -z "$(docker images -q qemu:latest 2>/dev/null)" \ + || test "$(cat ${statePath}/docker-image-qemu)" != "${qemuPackage.version}" + then + # Create and import qemu:latest image for docker + ${pkgs.dockerTools.streamLayeredImage { + name = "qemu"; + tag = "latest"; + contents = [ qemuPackage ]; + }} | docker load + # Mark down current package version + echo '${qemuPackage.version}' >${statePath}/docker-image-qemu + fi + ''; + serviceConfig = { + ExecStart = "${pkgs.sourcehut.buildsrht}/bin/builds.sr.ht-worker"; + RuntimeDirectory = [ "sourcehut/${serviceName}/subdir" ]; + # builds.sr.ht-worker looks up ../config.ini + LogsDirectory = [ "sourcehut/${serviceName}" ]; + StateDirectory = [ "sourcehut/${serviceName}" ]; + WorkingDirectory = "-"+"/run/sourcehut/${serviceName}/subdir"; + }; + }; + extraConfig = let + image_dirs = flatten ( + mapAttrsToList (distro: revs: + mapAttrsToList (rev: archs: + mapAttrsToList (arch: image: + pkgs.runCommand "buildsrht-images" { } '' + mkdir -p $out/${distro}/${rev}/${arch} + ln -s ${image}/*.qcow2 $out/${distro}/${rev}/${arch}/root.img.qcow2 + '' + ) archs + ) revs + ) cfg.builds.images + ); + image_dir_pre = pkgs.symlinkJoin { + name = "builds.sr.ht-worker-images-pre"; + paths = image_dirs; + # FIXME: not working, apparently because ubuntu/latest is a broken link + # ++ [ "${pkgs.sourcehut.buildsrht}/lib/images" ]; + }; + image_dir = pkgs.runCommand "builds.sr.ht-worker-images" { } '' + mkdir -p $out/images + cp -Lr ${image_dir_pre}/* $out/images + ''; + in mkMerge [ + { + users.users.${cfg.builds.user}.shell = pkgs.bash; + + virtualisation.docker.enable = true; + + services.sourcehut.settings = mkMerge [ + { # Note that git.sr.ht::dispatch is not a typo, + # gitsrht-dispatch always use this section + "git.sr.ht::dispatch"."/usr/bin/buildsrht-keys" = + mkDefault "${cfg.builds.user}:${cfg.builds.group}"; + } + (mkIf cfg.builds.enableWorker { + "builds.sr.ht::worker".shell = "/usr/bin/runner-shell"; + "builds.sr.ht::worker".images = mkDefault "${image_dir}/images"; + "builds.sr.ht::worker".controlcmd = mkDefault "${image_dir}/images/control"; + }) + ]; + } + (mkIf cfg.builds.enableWorker { + users.groups = { + docker.members = [ cfg.builds.user ]; + }; + }) + (mkIf (cfg.builds.enableWorker && cfg.nginx.enable) { + # Allow nginx access to buildlogs + users.users.${nginx.user}.extraGroups = [ cfg.builds.group ]; + systemd.services.nginx = { + serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."builds.sr.ht::worker".buildlogs}:/var/log/nginx/buildsrht/logs" ]; + }; + services.nginx.virtualHosts."logs.${domain}" = mkMerge [ { + /* FIXME: is a listen needed? + listen = with builtins; + # FIXME: not compatible with IPv6 + let address = split ":" cfg.settings."builds.sr.ht::worker".name; in + [{ addr = elemAt address 0; port = lib.toInt (elemAt address 2); }]; + */ + locations."/logs/".alias = "/var/log/nginx/buildsrht/logs/"; + } cfg.nginx.virtualHost ]; + }) + ]; + }) + + (import ./service.nix "dispatch" { + inherit configIniOfService; + port = 5005; + }) + + (import ./service.nix "git" (let + baseService = { + path = [ cfg.git.package ]; + serviceConfig.BindPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ]; + serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".post-update-script}:/var/lib/sourcehut/gitsrht/bin/post-update-script" ]; + }; + in { + inherit configIniOfService; + mainService = mkMerge [ baseService { + serviceConfig.StateDirectory = [ "sourcehut/gitsrht" "sourcehut/gitsrht/repos" ]; + } ]; + port = 5001; + webhooks = true; + extraTimers.gitsrht-periodic = { + service = baseService; + timerConfig.OnCalendar = ["20min"]; + }; + extraConfig = mkMerge [ + { + # https://stackoverflow.com/questions/22314298/git-push-results-in-fatal-protocol-error-bad-line-length-character-this + # Probably could use gitsrht-shell if output is restricted to just parameters... + users.users.${cfg.git.user}.shell = pkgs.bash; + services.sourcehut.settings = { + "git.sr.ht::dispatch"."/usr/bin/gitsrht-keys" = + mkDefault "${cfg.git.user}:${cfg.git.group}"; + }; + systemd.services.sshd = baseService; + } + (mkIf cfg.nginx.enable { + services.nginx.virtualHosts."git.${domain}" = { + locations."/authorize" = { + proxyPass = "http://${cfg.listenAddress}:${toString cfg.git.port}"; + extraConfig = '' + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + ''; + }; + locations."~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$" = { + root = "/var/lib/sourcehut/gitsrht/repos"; + fastcgiParams = { + GIT_HTTP_EXPORT_ALL = ""; + GIT_PROJECT_ROOT = "$document_root"; + PATH_INFO = "$uri"; + SCRIPT_FILENAME = "${cfg.git.package}/bin/git-http-backend"; + }; + extraConfig = '' + auth_request /authorize; + fastcgi_read_timeout 500s; + fastcgi_pass unix:/run/gitsrht-fcgiwrap.sock; + gzip off; + ''; + }; + }; + systemd.sockets.gitsrht-fcgiwrap = { + before = [ "nginx.service" ]; + wantedBy = [ "sockets.target" "gitsrht.service" ]; + # This path remains accessible to nginx.service, which has no RootDirectory= + socketConfig.ListenStream = "/run/gitsrht-fcgiwrap.sock"; + socketConfig.SocketUser = nginx.user; + socketConfig.SocketMode = "600"; + }; + }) + ]; + extraServices.gitsrht-fcgiwrap = mkIf cfg.nginx.enable { + serviceConfig = { + # Socket is passed by gitsrht-fcgiwrap.socket + ExecStart = "${pkgs.fcgiwrap}/sbin/fcgiwrap -c ${toString cfg.git.fcgiwrap.preforkProcess}"; + # No need for config.ini + ExecStartPre = mkForce []; + User = null; + DynamicUser = true; + BindReadOnlyPaths = [ "${cfg.settings."git.sr.ht".repos}:/var/lib/sourcehut/gitsrht/repos" ]; + IPAddressDeny = "any"; + InaccessiblePaths = [ "-+/run/postgresql" "-+/run/redis-sourcehut" ]; + PrivateNetwork = true; + RestrictAddressFamilies = mkForce [ "none" ]; + SystemCallFilter = mkForce [ + "@system-service" + "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid" + # @timer is needed for alarm() + ]; + }; + }; + })) + + (import ./service.nix "hg" (let + baseService = { + path = [ cfg.hg.package ]; + serviceConfig.BindPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/sourcehut/hgsrht/repos" ]; + serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."ht.sr.ht".changegroup-script}:/var/lib/sourcehut/hgsrht/bin/changegroup-script" ]; + }; + in { + inherit configIniOfService; + mainService = mkMerge [ baseService { + serviceConfig.StateDirectory = [ "sourcehut/hgsrht" "sourcehut/hgsrht/repos" ]; + } ]; + port = 5010; + webhooks = true; + extraTimers.hgsrht-periodic = { + service = baseService; + timerConfig.OnCalendar = ["20min"]; + }; + extraTimers.hgsrht-clonebundles = mkIf cfg.hg.cloneBundles { + service = baseService; + timerConfig.OnCalendar = ["daily"]; + timerConfig.AccuracySec = "1h"; + }; + extraConfig = mkMerge [ + { + users.users.${cfg.hg.user}.shell = pkgs.bash; + services.sourcehut.settings = { + # Note that git.sr.ht::dispatch is not a typo, + # gitsrht-dispatch always uses this section. + "git.sr.ht::dispatch"."/usr/bin/hgsrht-keys" = + mkDefault "${cfg.hg.user}:${cfg.hg.group}"; + }; + systemd.services.sshd = baseService; + } + (mkIf cfg.nginx.enable { + # Allow nginx access to repositories + users.users.${nginx.user}.extraGroups = [ cfg.hg.group ]; + services.nginx.virtualHosts."hg.${domain}" = { + locations."/authorize" = { + proxyPass = "http://${cfg.listenAddress}:${toString cfg.hg.port}"; + extraConfig = '' + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + ''; + }; + # Let clients reach pull bundles. We don't really need to lock this down even for + # private repos because the bundles are named after the revision hashes... + # so someone would need to know or guess a SHA value to download anything. + # TODO: proxyPass to an hg serve service? + locations."~ ^/[~^][a-z0-9_]+/[a-zA-Z0-9_.-]+/\\.hg/bundles/.*$" = { + root = "/var/lib/nginx/hgsrht/repos"; + extraConfig = '' + auth_request /authorize; + gzip off; + ''; + }; + }; + systemd.services.nginx = { + serviceConfig.BindReadOnlyPaths = [ "${cfg.settings."hg.sr.ht".repos}:/var/lib/nginx/hgsrht/repos" ]; + }; + }) + ]; + })) + + (import ./service.nix "hub" { + inherit configIniOfService; + port = 5014; + extraConfig = { + services.nginx = mkIf cfg.nginx.enable { + virtualHosts."hub.${domain}" = mkMerge [ { + serverAliases = [ domain ]; + } cfg.nginx.virtualHost ]; + }; + }; + }) + + (import ./service.nix "lists" (let + srvsrht = "listssrht"; + in { + inherit configIniOfService; + port = 5006; + webhooks = true; + # Receive the mail from Postfix and enqueue them into Redis and PostgreSQL + extraServices.listssrht-lmtp = { + wants = [ "postfix.service" ]; + unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service"; + serviceConfig.ExecStart = "${cfg.python}/bin/listssrht-lmtp"; + # Avoid crashing: os.chown(sock, os.getuid(), sock_gid) + serviceConfig.PrivateUsers = mkForce false; + }; + # Dequeue the mails from Redis and dispatch them + extraServices.listssrht-process = { + serviceConfig = { + preStart = '' + cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" cfg.lists.process.celeryConfig} \ + /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py + ''; + ExecStart = "${cfg.python}/bin/celery --app listssrht.process worker --hostname listssrht-process@%%h " + concatStringsSep " " cfg.lists.process.extraArgs; + # Avoid crashing: os.getloadavg() + ProcSubset = mkForce "all"; + }; + }; + extraConfig = mkIf cfg.postfix.enable { + users.groups.${postfix.group}.members = [ cfg.lists.user ]; + services.sourcehut.settings."lists.sr.ht::mail".sock-group = postfix.group; + services.postfix = { + destination = [ "lists.${domain}" ]; + # FIXME: an accurate recipient list should be queried + # from the lists.sr.ht PostgreSQL database to avoid backscattering. + # But usernames are unfortunately not in that database but in meta.sr.ht. + # Note that two syntaxes are allowed: + # - ~username/list-name@lists.${domain} + # - u.username.list-name@lists.${domain} + localRecipients = [ "@lists.${domain}" ]; + transport = '' + lists.${domain} lmtp:unix:${cfg.settings."lists.sr.ht::worker".sock} + ''; + }; + }; + })) + + (import ./service.nix "man" { + inherit configIniOfService; + port = 5004; + }) + + (import ./service.nix "meta" { + inherit configIniOfService; + port = 5000; + webhooks = true; + extraServices.metasrht-api = { + serviceConfig.Restart = "always"; + serviceConfig.RestartSec = "2s"; + preStart = "set -x\n" + concatStringsSep "\n\n" (attrValues (mapAttrs (k: s: + let srvMatch = builtins.match "^([a-z]*)\\.sr\\.ht$" k; + srv = head srvMatch; + in + # Configure client(s) as "preauthorized" + optionalString (srvMatch != null && cfg.${srv}.enable && ((s.oauth-client-id or null) != null)) '' + # Configure ${srv}'s OAuth client as "preauthorized" + ${postgresql.package}/bin/psql '${cfg.settings."meta.sr.ht".connection-string}' \ + -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${s.oauth-client-id}'" + '' + ) cfg.settings)); + serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b ${cfg.listenAddress}:${toString (cfg.meta.port + 100)}"; + }; + extraTimers.metasrht-daily.timerConfig = { + OnCalendar = ["daily"]; + AccuracySec = "1h"; + }; + extraConfig = mkMerge [ + { + assertions = [ + { assertion = let s = cfg.settings."meta.sr.ht::billing"; in + s.enabled == "yes" -> (s.stripe-public-key != null && s.stripe-secret-key != null); + message = "If meta.sr.ht::billing is enabled, the keys must be defined."; + } + ]; + environment.systemPackages = optional cfg.meta.enable + (pkgs.writeShellScriptBin "metasrht-manageuser" '' + set -eux + if test "$(${pkgs.coreutils}/bin/id -n -u)" != '${cfg.meta.user}' + then exec sudo -u '${cfg.meta.user}' "$0" "$@" + else + # In order to load config.ini + if cd /run/sourcehut/metasrht + then exec ${cfg.python}/bin/metasrht-manageuser "$@" + else cat <${stateDir}/db + fi + + ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade '' + # Just try all the migrations because they're not linked to the version + for sql in ${pkgs.sourcehut.pagessrht}/share/sql/migrations/*.sql; do + ${postgresql.package}/bin/psql '${cfg.settings.${iniKey}.connection-string}' -f "$sql" || true + done + ''} + + # Disable webhook + touch ${stateDir}/webhook + ''; + serviceConfig = { + ExecStart = mkForce "${pkgs.sourcehut.pagessrht}/bin/pages.sr.ht -b ${cfg.listenAddress}:${toString cfg.pages.port}"; + }; + }; + }) + + (import ./service.nix "paste" { + inherit configIniOfService; + port = 5011; + }) + + (import ./service.nix "todo" { + inherit configIniOfService; + port = 5003; + webhooks = true; + extraServices.todosrht-lmtp = { + wants = [ "postfix.service" ]; + unitConfig.JoinsNamespaceOf = optional cfg.postfix.enable "postfix.service"; + serviceConfig.ExecStart = "${cfg.python}/bin/todosrht-lmtp"; + # Avoid crashing: os.chown(sock, os.getuid(), sock_gid) + serviceConfig.PrivateUsers = mkForce false; + }; + extraConfig = mkIf cfg.postfix.enable { + users.groups.${postfix.group}.members = [ cfg.todo.user ]; + services.sourcehut.settings."todo.sr.ht::mail".sock-group = postfix.group; + services.postfix = { + destination = [ "todo.${domain}" ]; + # FIXME: an accurate recipient list should be queried + # from the todo.sr.ht PostgreSQL database to avoid backscattering. + # But usernames are unfortunately not in that database but in meta.sr.ht. + # Note that two syntaxes are allowed: + # - ~username/tracker-name@todo.${domain} + # - u.username.tracker-name@todo.${domain} + localRecipients = [ "@todo.${domain}" ]; + transport = '' + todo.${domain} lmtp:unix:${cfg.settings."todo.sr.ht::mail".sock} + ''; + }; + }; + }) + + (mkRenamedOptionModule [ "services" "sourcehut" "originBase" ] + [ "services" "sourcehut" "settings" "sr.ht" "global-domain" ]) + (mkRenamedOptionModule [ "services" "sourcehut" "address" ] + [ "services" "sourcehut" "listenAddress" ]) + + ]; + meta.doc = ./sourcehut.xml; - meta.maintainers = with maintainers; [ tomberek ]; + meta.maintainers = with maintainers; [ julm tomberek ]; } diff --git a/nixos/modules/services/misc/sourcehut/service.nix b/nixos/modules/services/misc/sourcehut/service.nix index 65b4ad020f9a..b3c0efc07ddf 100644 --- a/nixos/modules/services/misc/sourcehut/service.nix +++ b/nixos/modules/services/misc/sourcehut/service.nix @@ -1,66 +1,375 @@ -{ config, pkgs, lib }: -serviceCfg: serviceDrv: iniKey: attrs: +srv: +{ configIniOfService +, srvsrht ? "${srv}srht" # Because "buildsrht" does not follow that pattern (missing an "s"). +, iniKey ? "${srv}.sr.ht" +, webhooks ? false +, extraTimers ? {} +, mainService ? {} +, extraServices ? {} +, extraConfig ? {} +, port +}: +{ config, lib, pkgs, ... }: + +with lib; let + inherit (config.services) postgresql; + redis = config.services.redis.servers."sourcehut-${srvsrht}"; + inherit (config.users) users; cfg = config.services.sourcehut; - cfgIni = cfg.settings."${iniKey}"; - pgSuperUser = config.services.postgresql.superUser; - - setupDB = pkgs.writeScript "${serviceDrv.pname}-gen-db" '' - #! ${cfg.python}/bin/python - from ${serviceDrv.pname}.app import db - db.create() - ''; + configIni = configIniOfService srv; + srvCfg = cfg.${srv}; + baseService = serviceName: { allowStripe ? false }: extraService: let + runDir = "/run/sourcehut/${serviceName}"; + rootDir = "/run/sourcehut/chroots/${serviceName}"; + in + mkMerge [ extraService { + after = [ "network.target" ] ++ + optional cfg.postgresql.enable "postgresql.service" ++ + optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service"; + requires = + optional cfg.postgresql.enable "postgresql.service" ++ + optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service"; + path = [ pkgs.gawk ]; + environment.HOME = runDir; + serviceConfig = { + User = mkDefault srvCfg.user; + Group = mkDefault srvCfg.group; + RuntimeDirectory = [ + "sourcehut/${serviceName}" + # Used by *srht-keys which reads ../config.ini + "sourcehut/${serviceName}/subdir" + "sourcehut/chroots/${serviceName}" + ]; + RuntimeDirectoryMode = "2750"; + # No need for the chroot path once inside the chroot + InaccessiblePaths = [ "-+${rootDir}" ]; + # g+rx is for group members (eg. fcgiwrap or nginx) + # to read Git/Mercurial repositories, buildlogs, etc. + # o+x is for intermediate directories created by BindPaths= and like, + # as they're owned by root:root. + UMask = "0026"; + RootDirectory = rootDir; + RootDirectoryStartOnly = true; + PrivateTmp = true; + MountAPIVFS = true; + # config.ini is looked up in there, before /etc/srht/config.ini + # Note that it fails to be set in ExecStartPre= + WorkingDirectory = mkDefault ("-"+runDir); + BindReadOnlyPaths = [ + builtins.storeDir + "/etc" + "/run/booted-system" + "/run/current-system" + "/run/systemd" + ] ++ + optional cfg.postgresql.enable "/run/postgresql" ++ + optional cfg.redis.enable "/run/redis-sourcehut-${srvsrht}"; + # LoadCredential= are unfortunately not available in ExecStartPre= + # Hence this one is run as root (the +) with RootDirectoryStartOnly= + # to reach credentials wherever they are. + # Note that each systemd service gets its own ${runDir}/config.ini file. + ExecStartPre = mkBefore [("+"+pkgs.writeShellScript "${serviceName}-credentials" '' + set -x + # Replace values begining with a '<' by the content of the file whose name is after. + gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} | + ${optionalString (!allowStripe) "gawk '!/^stripe-secret-key=/' |"} + install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini + '')]; + # The following options are only for optimizing: + # systemd-analyze security + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateNetwork = mkDefault false; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + #SocketBindAllow = [ "tcp:${toString srvCfg.port}" "tcp:${toString srvCfg.prometheusPort}" ]; + #SocketBindDeny = "any"; + SystemCallFilter = [ + "@system-service" + "~@aio" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@timer" + "@chown" "@setuid" + ]; + SystemCallArchitectures = "native"; + }; + } ]; in -with serviceCfg; with lib; recursiveUpdate { - environment.HOME = statePath; - path = [ config.services.postgresql.package ] ++ (attrs.path or [ ]); - restartTriggers = [ config.environment.etc."sr.ht/config.ini".source ]; - serviceConfig = { - Type = "simple"; - User = user; - Group = user; - Restart = "always"; - WorkingDirectory = statePath; - } // (if (cfg.statePath == "/var/lib/sourcehut/${serviceDrv.pname}") then { - StateDirectory = [ "sourcehut/${serviceDrv.pname}" ]; - } else {}) - ; + options.services.sourcehut.${srv} = { + enable = mkEnableOption "${srv} service"; - preStart = '' - if ! test -e ${statePath}/db; then - # Setup the initial database - ${setupDB} + user = mkOption { + type = types.str; + default = srvsrht; + description = '' + User for ${srv}.sr.ht. + ''; + }; - # Set the initial state of the database for future database upgrades - if test -e ${cfg.python}/bin/${serviceDrv.pname}-migrate; then - # Run alembic stamp head once to tell alembic the schema is up-to-date - ${cfg.python}/bin/${serviceDrv.pname}-migrate stamp head - fi + group = mkOption { + type = types.str; + default = srvsrht; + description = '' + Group for ${srv}.sr.ht. + Membership grants access to the Git/Mercurial repositories by default, + but not to the config.ini file (where secrets are). + ''; + }; - printf "%s" "${serviceDrv.version}" > ${statePath}/db - fi + port = mkOption { + type = types.port; + default = port; + description = '' + Port on which the "${srv}" backend should listen. + ''; + }; - # Update copy of each users' profile to the latest - # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain> - if ! test -e ${statePath}/webhook; then - # Update ${iniKey}'s users' profile copy to the latest - ${cfg.python}/bin/srht-update-profiles ${iniKey} + redis = { + host = mkOption { + type = types.str; + default = "unix:/run/redis-sourcehut-${srvsrht}/redis.sock?db=0"; + example = "redis://shared.wireguard:6379/0"; + description = '' + The redis host URL. This is used for caching and temporary storage, and must + be shared between nodes (e.g. git1.sr.ht and git2.sr.ht), but need not be + shared between services. It may be shared between services, however, with no + ill effect, if this better suits your infrastructure. + ''; + }; + }; - touch ${statePath}/webhook - fi + postgresql = { + database = mkOption { + type = types.str; + default = "${srv}.sr.ht"; + description = '' + PostgreSQL database name for the ${srv}.sr.ht service, + used if is true. + ''; + }; + }; - ${optionalString (builtins.hasAttr "migrate-on-upgrade" cfgIni && cfgIni.migrate-on-upgrade == "yes") '' - if [ "$(cat ${statePath}/db)" != "${serviceDrv.version}" ]; then - # Manage schema migrations using alembic - ${cfg.python}/bin/${serviceDrv.pname}-migrate -a upgrade head + gunicorn = { + extraArgs = mkOption { + type = with types; listOf str; + default = ["--timeout 120" "--workers 1" "--log-level=info"]; + description = "Extra arguments passed to Gunicorn."; + }; + }; + } // optionalAttrs webhooks { + webhooks = { + extraArgs = mkOption { + type = with types; listOf str; + default = ["--loglevel DEBUG" "--pool eventlet" "--without-heartbeat"]; + description = "Extra arguments passed to the Celery responsible for webhooks."; + }; + celeryConfig = mkOption { + type = types.lines; + default = ""; + description = "Content of the celeryconfig.py used by the Celery responsible for webhooks."; + }; + }; + }; - # Mark down current package version - printf "%s" "${serviceDrv.version}" > ${statePath}/db - fi - ''} + config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [ extraConfig { + users = { + users = { + "${srvCfg.user}" = { + isSystemUser = true; + group = mkDefault srvCfg.group; + description = mkDefault "sourcehut user for ${srv}.sr.ht"; + }; + }; + groups = { + "${srvCfg.group}" = { }; + } // optionalAttrs (cfg.postgresql.enable + && hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")) { + "postgres".members = [ srvCfg.user ]; + } // optionalAttrs (cfg.redis.enable + && hasSuffix "0" (redis.settings.unixsocketperm or "")) { + "redis-sourcehut-${srvsrht}".members = [ srvCfg.user ]; + }; + }; - ${attrs.preStart or ""} - ''; + services.nginx = mkIf cfg.nginx.enable { + virtualHosts."${srv}.${cfg.settings."sr.ht".global-domain}" = mkMerge [ { + forceSSL = true; + locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}"; + locations."/static" = { + root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}"; + extraConfig = mkDefault '' + expires 30d; + ''; + }; + } cfg.nginx.virtualHost ]; + }; + + services.postgresql = mkIf cfg.postgresql.enable { + authentication = '' + local ${srvCfg.postgresql.database} ${srvCfg.user} trust + ''; + ensureDatabases = [ srvCfg.postgresql.database ]; + ensureUsers = map (name: { + inherit name; + ensurePermissions = { "DATABASE \"${srvCfg.postgresql.database}\"" = "ALL PRIVILEGES"; }; + }) [srvCfg.user]; + }; + + services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable) + [ "builds" "dispatch" "git" "hg" "hub" "lists" "man" "meta" "pages" "paste" "todo" ]); + + services.sourcehut.settings = mkMerge [ + { + "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}"; + } + + (mkIf cfg.postgresql.enable { + "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql"; + }) + ]; + + services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable { + enable = true; + databases = 3; + syslog = true; + # TODO: set a more informed value + save = mkDefault [ [1800 10] [300 100] ]; + settings = { + # TODO: set a more informed value + maxmemory = "128MB"; + maxmemory-policy = "volatile-ttl"; + }; + }; + + systemd.services = mkMerge [ + { + "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [ + { + description = "sourcehut ${srv}.sr.ht website service"; + before = optional cfg.nginx.enable "nginx.service"; + wants = optional cfg.nginx.enable "nginx.service"; + wantedBy = [ "multi-user.target" ]; + path = optional cfg.postgresql.enable postgresql.package; + # Beware: change in credentials' content will not trigger restart. + restartTriggers = [ configIni ]; + serviceConfig = { + Type = "simple"; + Restart = mkDefault "always"; + #RestartSec = mkDefault "2min"; + StateDirectory = [ "sourcehut/${srvsrht}" ]; + StateDirectoryMode = "2750"; + ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs; + }; + preStart = let + version = pkgs.sourcehut.${srvsrht}.version; + stateDir = "/var/lib/sourcehut/${srvsrht}"; + in mkBefore '' + set -x + # Use the /run/sourcehut/${srvsrht}/config.ini + # installed by a previous ExecStartPre= in baseService + cd /run/sourcehut/${srvsrht} + + if test ! -e ${stateDir}/db; then + # Setup the initial database. + # Note that it stamps the alembic head afterward + ${cfg.python}/bin/${srvsrht}-initdb + echo ${version} >${stateDir}/db + fi + + ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade '' + if [ "$(cat ${stateDir}/db)" != "${version}" ]; then + # Manage schema migrations using alembic + ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head + echo ${version} >${stateDir}/db + fi + ''} + + # Update copy of each users' profile to the latest + # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain> + if test ! -e ${stateDir}/webhook; then + # Update ${iniKey}'s users' profile copy to the latest + ${cfg.python}/bin/srht-update-profiles ${iniKey} + touch ${stateDir}/webhook + fi + ''; + } mainService ]); + } + + (mkIf webhooks { + "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {} + { + description = "sourcehut ${srv}.sr.ht webhooks service"; + after = [ "${srvsrht}.service" ]; + wantedBy = [ "${srvsrht}.service" ]; + partOf = [ "${srvsrht}.service" ]; + preStart = '' + cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \ + /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py + ''; + serviceConfig = { + Type = "simple"; + Restart = "always"; + ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs; + # Avoid crashing: os.getloadavg() + ProcSubset = mkForce "all"; + }; + }; + }) + + (mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [ + { + description = "sourcehut ${timerName} service"; + after = [ "network.target" "${srvsrht}.service" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${cfg.python}/bin/${timerName}"; + }; + } + (timer.service or {}) + ]))) extraTimers) + + (mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [ + { + description = "sourcehut ${serviceName} service"; + # So that extraServices have the PostgreSQL database initialized. + after = [ "${srvsrht}.service" ]; + wantedBy = [ "${srvsrht}.service" ]; + partOf = [ "${srvsrht}.service" ]; + serviceConfig = { + Type = "simple"; + Restart = mkDefault "always"; + }; + } + extraService + ])) extraServices) + ]; + + systemd.timers = mapAttrs (timerName: timer: + { + description = "sourcehut timer for ${timerName}"; + wantedBy = [ "timers.target" ]; + inherit (timer) timerConfig; + }) extraTimers; + } ]); } - (builtins.removeAttrs attrs [ "path" "preStart" ]) diff --git a/nixos/modules/services/misc/sourcehut/sourcehut.xml b/nixos/modules/services/misc/sourcehut/sourcehut.xml index ab9a8c6cb4be..41094f65a94d 100644 --- a/nixos/modules/services/misc/sourcehut/sourcehut.xml +++ b/nixos/modules/services/misc/sourcehut/sourcehut.xml @@ -14,13 +14,12 @@ Basic usage Sourcehut is a Python and Go based set of applications. - services.sourcehut - by default will use + This NixOS module also provides basic configuration integrating Sourcehut into locally running services.nginx, - services.redis, - services.cron, + services.redis.servers.sourcehut, + services.postfix and - services.postgresql. + services.postgresql services. @@ -42,18 +41,23 @@ in { services.sourcehut = { enable = true; - originBase = fqdn; - services = [ "meta" "man" "git" ]; + git.enable = true; + man.enable = true; + meta.enable = true; + nginx.enable = true; + postfix.enable = true; + postgresql.enable = true; + redis.enable = true; settings = { "sr.ht" = { environment = "production"; global-domain = fqdn; origin = "https://${fqdn}"; # Produce keys with srht-keygen from sourcehut.coresrht. - network-key = "SECRET"; - service-key = "SECRET"; + network-key = "/run/keys/path/to/network-key"; + service-key = "/run/keys/path/to/service-key"; }; - webhooks.private-key= "SECRET"; + webhooks.private-key= "/run/keys/path/to/webhook-key"; }; };