grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / web-apps / mobilizon.nix
blobb7fad7f3066e82f45300f57f0ef2cd2eb1c5a25f
1 { pkgs, lib, config, ... }:
3 with lib;
5 let
6   cfg = config.services.mobilizon;
8   user = "mobilizon";
9   group = "mobilizon";
11   settingsFormat = pkgs.formats.elixirConf { elixir = cfg.package.elixirPackage; };
13   configFile = settingsFormat.generate "mobilizon-config.exs" cfg.settings;
15   # Make a package containing launchers with the correct envirenment, instead of
16   # setting it with systemd services, so that the user can also use them without
17   # troubles
18   launchers = pkgs.stdenv.mkDerivation rec {
19     pname = "${cfg.package.pname}-launchers";
20     inherit (cfg.package) version;
22     src = cfg.package;
24     nativeBuildInputs = with pkgs; [ makeWrapper ];
26     dontBuild = true;
28     installPhase = ''
29       mkdir -p $out/bin
31       makeWrapper \
32         $src/bin/mobilizon \
33         $out/bin/mobilizon \
34         --run '. ${secretEnvFile}' \
35         --set MOBILIZON_CONFIG_PATH "${configFile}" \
36         --set-default RELEASE_TMP "/tmp"
38       makeWrapper \
39         $src/bin/mobilizon_ctl \
40         $out/bin/mobilizon_ctl \
41         --run '. ${secretEnvFile}' \
42         --set MOBILIZON_CONFIG_PATH "${configFile}" \
43         --set-default RELEASE_TMP "/tmp"
44     '';
45   };
47   repoSettings = cfg.settings.":mobilizon"."Mobilizon.Storage.Repo";
48   instanceSettings = cfg.settings.":mobilizon".":instance";
50   isLocalPostgres = repoSettings.socket_dir != null;
52   dbUser = if repoSettings.username != null then repoSettings.username else "mobilizon";
54   postgresql = config.services.postgresql.package;
55   postgresqlSocketDir = "/var/run/postgresql";
57   secretEnvFile = "/var/lib/mobilizon/secret-env.sh";
60   options = {
61     services.mobilizon = {
62       enable = mkEnableOption "Mobilizon federated organization and mobilization platform";
64       nginx.enable = lib.mkOption {
65         type = lib.types.bool;
66         default = true;
67         description = ''
68           Whether an Nginx virtual host should be
69           set up to serve Mobilizon.
70         '';
71       };
73       package = mkPackageOption pkgs "mobilizon" { };
75       settings = mkOption {
76         type =
77           let
78             elixirTypes = settingsFormat.lib.types;
79           in
80           types.submodule {
81             freeformType = settingsFormat.type;
83             options = {
84               ":mobilizon" = {
86                 "Mobilizon.Web.Endpoint" = {
87                   url.host = mkOption {
88                     type = elixirTypes.str;
89                     defaultText = lib.literalMD ''
90                       ''${settings.":mobilizon".":instance".hostname}
91                     '';
92                     description = ''
93                       Your instance's hostname for generating URLs throughout the app
94                     '';
95                   };
97                   http = {
98                     port = mkOption {
99                       type = elixirTypes.port;
100                       default = 4000;
101                       description = ''
102                         The port to run the server
103                       '';
104                     };
105                     ip = mkOption {
106                       type = elixirTypes.tuple;
107                       default = settingsFormat.lib.mkTuple [ 0 0 0 0 0 0 0 1 ];
108                       description = ''
109                         The IP address to listen on. Defaults to [::1] notated as a byte tuple.
110                       '';
111                     };
112                   };
114                   has_reverse_proxy = mkOption {
115                     type = elixirTypes.bool;
116                     default = true;
117                     description = ''
118                       Whether you use a reverse proxy
119                     '';
120                   };
121                 };
123                 ":instance" = {
124                   name = mkOption {
125                     type = elixirTypes.str;
126                     description = ''
127                       The fallback instance name if not configured into the admin UI
128                     '';
129                   };
131                   hostname = mkOption {
132                     type = elixirTypes.str;
133                     description = ''
134                       Your instance's hostname
135                     '';
136                   };
138                   email_from = mkOption {
139                     type = elixirTypes.str;
140                     defaultText = literalExpression ''
141                       noreply@''${settings.":mobilizon".":instance".hostname}
142                     '';
143                     description = ''
144                       The email for the From: header in emails
145                     '';
146                   };
148                   email_reply_to = mkOption {
149                     type = elixirTypes.str;
150                     defaultText = literalExpression ''
151                       ''${email_from}
152                     '';
153                     description = ''
154                       The email for the Reply-To: header in emails
155                     '';
156                   };
157                 };
159                 "Mobilizon.Storage.Repo" = {
160                   socket_dir = mkOption {
161                     type = types.nullOr elixirTypes.str;
162                     default = postgresqlSocketDir;
163                     description = ''
164                       Path to the postgres socket directory.
166                       Set this to null if you want to connect to a remote database.
168                       If non-null, the local PostgreSQL server will be configured with
169                       the configured database, permissions, and required extensions.
171                       If connecting to a remote database, please follow the
172                       instructions on how to setup your database:
173                       <https://docs.joinmobilizon.org/administration/install/release/#database-setup>
174                     '';
175                   };
177                   username = mkOption {
178                     type = types.nullOr elixirTypes.str;
179                     default = user;
180                     description = ''
181                       User used to connect to the database
182                     '';
183                   };
185                   database = mkOption {
186                     type = types.nullOr elixirTypes.str;
187                     default = "mobilizon_prod";
188                     description = ''
189                       Name of the database
190                     '';
191                   };
192                 };
193               };
194             };
195           };
196         default = { };
198         description = ''
199           Mobilizon Elixir documentation, see
200           <https://docs.joinmobilizon.org/administration/configure/reference/>
201           for supported values.
202         '';
203       };
204     };
205   };
207   config = mkIf cfg.enable {
209     assertions = [
210       {
211         assertion = cfg.nginx.enable -> (cfg.settings.":mobilizon"."Mobilizon.Web.Endpoint".http.ip == settingsFormat.lib.mkTuple [ 0 0 0 0 0 0 0 1 ]);
212         message = "Setting the IP mobilizon listens on is only possible when the nginx config is not used, as it is hardcoded there.";
213       }
214     ];
216     services.mobilizon.settings = {
217       ":mobilizon" = {
218         "Mobilizon.Web.Endpoint" = {
219           server = true;
220           url.host = mkDefault instanceSettings.hostname;
221           secret_key_base =
222             settingsFormat.lib.mkGetEnv { envVariable = "MOBILIZON_INSTANCE_SECRET"; };
223         };
225         "Mobilizon.Web.Auth.Guardian".secret_key =
226           settingsFormat.lib.mkGetEnv { envVariable = "MOBILIZON_AUTH_SECRET"; };
228         ":instance" = {
229           registrations_open = mkDefault false;
230           demo = mkDefault false;
231           email_from = mkDefault "noreply@${instanceSettings.hostname}";
232           email_reply_to = mkDefault instanceSettings.email_from;
233         };
235         "Mobilizon.Storage.Repo" = {
236           # Forced by upstream since it uses PostgreSQL-specific extensions
237           adapter = settingsFormat.lib.mkAtom "Ecto.Adapters.Postgres";
238           pool_size = mkDefault 10;
239         };
240       };
242       ":tzdata".":data_dir" = "/var/lib/mobilizon/tzdata/";
243     };
245     # This somewhat follows upstream's systemd service here:
246     # https://framagit.org/framasoft/mobilizon/-/blob/master/support/systemd/mobilizon.service
247     systemd.services.mobilizon = {
248       description = "Mobilizon federated organization and mobilization platform";
250       wantedBy = [ "multi-user.target" ];
252       path = with pkgs; [
253         gawk
254         imagemagick
255         libwebp
256         file
258         # Optional:
259         gifsicle
260         jpegoptim
261         optipng
262         pngquant
263       ];
265       serviceConfig = {
266         ExecStartPre = "${launchers}/bin/mobilizon_ctl migrate";
267         ExecStart = "${launchers}/bin/mobilizon start";
268         ExecStop = "${launchers}/bin/mobilizon stop";
270         User = user;
271         Group = group;
273         StateDirectory = "mobilizon";
275         Restart = "on-failure";
277         PrivateTmp = true;
278         ProtectSystem = "full";
279         NoNewPrivileges = true;
281         ReadWritePaths = mkIf isLocalPostgres postgresqlSocketDir;
282       };
283     };
285     # Create the needed secrets before running Mobilizon, so that they are not
286     # in the nix store
287     #
288     # Since some of these tasks are quite common for Elixir projects (COOKIE for
289     # every BEAM project, Phoenix and Guardian are also quite common), this
290     # service could be abstracted in the future, and used by other Elixir
291     # projects.
292     systemd.services.mobilizon-setup-secrets = {
293       description = "Mobilizon setup secrets";
294       before = [ "mobilizon.service" ];
295       wantedBy = [ "mobilizon.service" ];
297       script =
298         let
299           # Taken from here:
300           # https://framagit.org/framasoft/mobilizon/-/blob/1.0.7/lib/mix/tasks/mobilizon/instance.ex#L132-133
301           genSecret =
302             "IO.puts(:crypto.strong_rand_bytes(64)" +
303             "|> Base.encode64()" +
304             "|> binary_part(0, 64))";
306           # Taken from here:
307           # https://github.com/elixir-lang/elixir/blob/v1.11.3/lib/mix/lib/mix/release.ex#L499
308           genCookie = "IO.puts(Base.encode32(:crypto.strong_rand_bytes(32)))";
310           evalElixir = str: ''
311             ${cfg.package.elixirPackage}/bin/elixir --eval '${str}'
312           '';
313         in
314         ''
315           set -euxo pipefail
317           if [ ! -f "${secretEnvFile}" ]; then
318             install -m 600 /dev/null "${secretEnvFile}"
319             cat > "${secretEnvFile}" <<EOF
320           # This file was automatically generated by mobilizon-setup-secrets.service
321           export MOBILIZON_AUTH_SECRET='$(${evalElixir genSecret})'
322           export MOBILIZON_INSTANCE_SECRET='$(${evalElixir genSecret})'
323           export RELEASE_COOKIE='$(${evalElixir genCookie})'
324           EOF
325           fi
326         '';
328       serviceConfig = {
329         Type = "oneshot";
330         User = user;
331         Group = group;
332         StateDirectory = "mobilizon";
333       };
334     };
336     # Add the required PostgreSQL extensions to the local PostgreSQL server,
337     # if local PostgreSQL is configured.
338     systemd.services.mobilizon-postgresql = mkIf isLocalPostgres {
339       description = "Mobilizon PostgreSQL setup";
341       after = [ "postgresql.service" ];
342       before = [ "mobilizon.service" "mobilizon-setup-secrets.service" ];
343       wantedBy = [ "mobilizon.service" ];
345       path = [ postgresql ];
347       # Taken from here:
348       # https://framagit.org/framasoft/mobilizon/-/blob/1.1.0/priv/templates/setup_db.eex
349       # TODO(to maintainers of mobilizon): the owner database alteration is necessary
350       # as PostgreSQL 15 changed their behaviors w.r.t. to privileges.
351       # See https://github.com/NixOS/nixpkgs/issues/216989 to get rid
352       # of that workaround.
353       script =
354         ''
355           psql "${repoSettings.database}" -c "\
356             CREATE EXTENSION IF NOT EXISTS postgis; \
357             CREATE EXTENSION IF NOT EXISTS pg_trgm; \
358             CREATE EXTENSION IF NOT EXISTS unaccent;"
359           psql -tAc 'ALTER DATABASE "${repoSettings.database}" OWNER TO "${dbUser}";'
361         '';
363       serviceConfig = {
364         Type = "oneshot";
365         User = config.services.postgresql.superUser;
366       };
367     };
369     systemd.tmpfiles.rules = [
370       "d /var/lib/mobilizon/uploads/exports/csv 700 mobilizon mobilizon - -"
371       "Z /var/lib/mobilizon 700 mobilizon mobilizon - -"
372     ];
374     services.postgresql = mkIf isLocalPostgres {
375       enable = true;
376       ensureDatabases = [ repoSettings.database ];
377       ensureUsers = [
378         {
379           name = dbUser;
380           # Given that `dbUser` is potentially arbitrarily custom, we will perform
381           # manual fixups in mobilizon-postgres.
382           # TODO(to maintainers of mobilizon): Feel free to simplify your setup by using `ensureDBOwnership`.
383           ensureDBOwnership = false;
384         }
385       ];
386       extraPlugins = ps: with ps; [ postgis ];
387     };
389     # Nginx config taken from support/nginx/mobilizon-release.conf
390     services.nginx =
391       let
392         inherit (cfg.settings.":mobilizon".":instance") hostname;
393         proxyPass = "http://[::1]:"
394           + toString cfg.settings.":mobilizon"."Mobilizon.Web.Endpoint".http.port;
395       in
396       lib.mkIf cfg.nginx.enable {
397         enable = true;
398         virtualHosts."${hostname}" = {
399           enableACME = lib.mkDefault true;
400           forceSSL = lib.mkDefault true;
401           extraConfig = ''
402             proxy_http_version 1.1;
403             proxy_set_header Upgrade $http_upgrade;
404             proxy_set_header Connection "upgrade";
405             proxy_set_header Host $host;
406             proxy_set_header X-Real-IP $remote_addr;
407             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
408             proxy_set_header X-Forwarded-Proto $scheme;
409           '';
410           locations."/" = {
411             inherit proxyPass;
412           };
413           locations."~ ^/(js|css|img)" = {
414             root = "${cfg.package}/lib/mobilizon-${cfg.package.version}/priv/static";
415             extraConfig = ''
416               etag off;
417               access_log off;
418               add_header Cache-Control "public, max-age=31536000, immutable";
419             '';
420           };
421           locations."~ ^/(media|proxy)" = {
422             inherit proxyPass;
423             extraConfig = ''
424               etag off;
425               access_log off;
426               add_header Cache-Control "public, max-age=31536000, immutable";
427             '';
428           };
429         };
430       };
432     users.users.${user} = {
433       description = "Mobilizon daemon user";
434       group = group;
435       isSystemUser = true;
436     };
438     users.groups.${group} = { };
440     # So that we have the `mobilizon` and `mobilizon_ctl` commands.
441     # The `mobilizon remote` command is useful for dropping a shell into the
442     # running Mobilizon instance, and `mobilizon_ctl` is used for common
443     # management tasks (e.g. adding users).
444     environment.systemPackages = [ launchers ];
445   };
447   meta.maintainers = with lib.maintainers; [ minijackson erictapen ];