1{ pkgs, lib, config, ... }:
2
3with lib;
4
5let
6 cfg = config.services.mobilizon;
7
8 user = "mobilizon";
9 group = "mobilizon";
10
11 settingsFormat = pkgs.formats.elixirConf { elixir = cfg.package.elixirPackage; };
12
13 configFile = settingsFormat.generate "mobilizon-config.exs" cfg.settings;
14
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;
21
22 src = cfg.package;
23
24 nativeBuildInputs = with pkgs; [ makeWrapper ];
25
26 dontBuild = true;
27
28 installPhase = ''
29 mkdir -p $out/bin
30
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"
37
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 };
46
47 repoSettings = cfg.settings.":mobilizon"."Mobilizon.Storage.Repo";
48 instanceSettings = cfg.settings.":mobilizon".":instance";
49
50 isLocalPostgres = repoSettings.socket_dir != null;
51
52 dbUser = if repoSettings.username != null then repoSettings.username else "mobilizon";
53
54 postgresql = config.services.postgresql.package;
55 postgresqlSocketDir = "/var/run/postgresql";
56
57 secretEnvFile = "/var/lib/mobilizon/secret-env.sh";
58in
59{
60 options = {
61 services.mobilizon = {
62 enable = mkEnableOption "Mobilizon federated organization and mobilization platform";
63
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 };
72
73 package = mkPackageOption pkgs "mobilizon" { };
74
75 settings = mkOption {
76 type =
77 let
78 elixirTypes = settingsFormat.lib.types;
79 in
80 types.submodule {
81 freeformType = settingsFormat.type;
82
83 options = {
84 ":mobilizon" = {
85
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 };
96
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 };
113
114 has_reverse_proxy = mkOption {
115 type = elixirTypes.bool;
116 default = true;
117 description = ''
118 Whether you use a reverse proxy
119 '';
120 };
121 };
122
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 };
130
131 hostname = mkOption {
132 type = elixirTypes.str;
133 description = ''
134 Your instance's hostname
135 '';
136 };
137
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 };
147
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 };
158
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.
165
166 Set this to null if you want to connect to a remote database.
167
168 If non-null, the local PostgreSQL server will be configured with
169 the configured database, permissions, and required extensions.
170
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 };
176
177 username = mkOption {
178 type = types.nullOr elixirTypes.str;
179 default = user;
180 description = ''
181 User used to connect to the database
182 '';
183 };
184
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 = { };
197
198 description = ''
199 Mobilizon Elixir documentation, see
200 <https://docs.joinmobilizon.org/administration/configure/reference/>
201 for supported values.
202 '';
203 };
204 };
205 };
206
207 config = mkIf cfg.enable {
208
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 ];
215
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 };
224
225 "Mobilizon.Web.Auth.Guardian".secret_key =
226 settingsFormat.lib.mkGetEnv { envVariable = "MOBILIZON_AUTH_SECRET"; };
227
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 };
234
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 };
241
242 ":tzdata".":data_dir" = "/var/lib/mobilizon/tzdata/";
243 };
244
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";
249
250 wantedBy = [ "multi-user.target" ];
251
252 path = with pkgs; [
253 gawk
254 imagemagick
255 libwebp
256 file
257
258 # Optional:
259 gifsicle
260 jpegoptim
261 optipng
262 pngquant
263 ];
264
265 serviceConfig = {
266 ExecStartPre = "${launchers}/bin/mobilizon_ctl migrate";
267 ExecStart = "${launchers}/bin/mobilizon start";
268 ExecStop = "${launchers}/bin/mobilizon stop";
269
270 User = user;
271 Group = group;
272
273 StateDirectory = "mobilizon";
274
275 Restart = "on-failure";
276
277 PrivateTmp = true;
278 ProtectSystem = "full";
279 NoNewPrivileges = true;
280
281 ReadWritePaths = mkIf isLocalPostgres postgresqlSocketDir;
282 };
283 };
284
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" ];
296
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))";
305
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)))";
309
310 evalElixir = str: ''
311 ${cfg.package.elixirPackage}/bin/elixir --eval '${str}'
312 '';
313 in
314 ''
315 set -euxo pipefail
316
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 '';
327
328 serviceConfig = {
329 Type = "oneshot";
330 User = user;
331 Group = group;
332 StateDirectory = "mobilizon";
333 };
334 };
335
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";
340
341 after = [ "postgresql.service" ];
342 before = [ "mobilizon.service" "mobilizon-setup-secrets.service" ];
343 wantedBy = [ "mobilizon.service" ];
344
345 path = [ postgresql ];
346
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}";'
360
361 '';
362
363 serviceConfig = {
364 Type = "oneshot";
365 User = config.services.postgresql.superUser;
366 };
367 };
368
369 systemd.tmpfiles.rules = [
370 "d /var/lib/mobilizon/uploads/exports/csv 700 mobilizon mobilizon - -"
371 "Z /var/lib/mobilizon 700 mobilizon mobilizon - -"
372 ];
373
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 };
388
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 };
431
432 users.users.${user} = {
433 description = "Mobilizon daemon user";
434 group = group;
435 isSystemUser = true;
436 };
437
438 users.groups.${group} = { };
439
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 };
446
447 meta.maintainers = with lib.maintainers; [ minijackson erictapen ];
448}