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