1{ config, lib, pkgs, ... }:
2
3let
4 cfg = config.services.mastodon;
5 # We only want to create a database if we're actually going to connect to it.
6 databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "/run/postgresql";
7
8 env = {
9 RAILS_ENV = "production";
10 NODE_ENV = "production";
11
12 DB_USER = cfg.database.user;
13
14 REDIS_HOST = cfg.redis.host;
15 REDIS_PORT = toString(cfg.redis.port);
16 DB_HOST = cfg.database.host;
17 DB_PORT = toString(cfg.database.port);
18 DB_NAME = cfg.database.name;
19 LOCAL_DOMAIN = cfg.localDomain;
20 SMTP_SERVER = cfg.smtp.host;
21 SMTP_PORT = toString(cfg.smtp.port);
22 SMTP_FROM_ADDRESS = cfg.smtp.fromAddress;
23 PAPERCLIP_ROOT_PATH = "/var/lib/mastodon/public-system";
24 PAPERCLIP_ROOT_URL = "/system";
25 ES_ENABLED = if (cfg.elasticsearch.host != null) then "true" else "false";
26 ES_HOST = cfg.elasticsearch.host;
27 ES_PORT = toString(cfg.elasticsearch.port);
28
29 TRUSTED_PROXY_IP = cfg.trustedProxy;
30 }
31 // (if cfg.smtp.authenticate then { SMTP_LOGIN = cfg.smtp.user; } else {})
32 // cfg.extraConfig;
33
34 systemCallsList = [ "@clock" "@cpu-emulation" "@debug" "@keyring" "@module" "@mount" "@obsolete" "@raw-io" "@reboot" "@setuid" "@swap" ];
35
36 cfgService = {
37 # User and group
38 User = cfg.user;
39 Group = cfg.group;
40 # State directory and mode
41 StateDirectory = "mastodon";
42 StateDirectoryMode = "0750";
43 # Logs directory and mode
44 LogsDirectory = "mastodon";
45 LogsDirectoryMode = "0750";
46 # Access write directories
47 UMask = "0027";
48 # Capabilities
49 CapabilityBoundingSet = "";
50 # Security
51 NoNewPrivileges = true;
52 # Sandboxing
53 ProtectSystem = "strict";
54 ProtectHome = true;
55 PrivateTmp = true;
56 PrivateDevices = true;
57 PrivateUsers = true;
58 ProtectClock = true;
59 ProtectHostname = true;
60 ProtectKernelLogs = true;
61 ProtectKernelModules = true;
62 ProtectKernelTunables = true;
63 ProtectControlGroups = true;
64 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
65 RestrictNamespaces = true;
66 LockPersonality = true;
67 MemoryDenyWriteExecute = false;
68 RestrictRealtime = true;
69 RestrictSUIDSGID = true;
70 PrivateMounts = true;
71 # System Call Filtering
72 SystemCallArchitectures = "native";
73 };
74
75 envFile = pkgs.writeText "mastodon.env" (lib.concatMapStrings (s: s + "\n") (
76 (lib.concatLists (lib.mapAttrsToList (name: value:
77 if value != null then [
78 "${name}=\"${toString value}\""
79 ] else []
80 ) env))));
81
82 mastodonEnv = pkgs.writeShellScriptBin "mastodon-env" ''
83 set -a
84 source "${envFile}"
85 source /var/lib/mastodon/.secrets_env
86 eval -- "\$@"
87 '';
88
89in {
90
91 options = {
92 services.mastodon = {
93 enable = lib.mkEnableOption "Mastodon, a federated social network server";
94
95 configureNginx = lib.mkOption {
96 description = ''
97 Configure nginx as a reverse proxy for mastodon.
98 Note that this makes some assumptions on your setup, and sets settings that will
99 affect other virtualHosts running on your nginx instance, if any.
100 Alternatively you can configure a reverse-proxy of your choice to serve these paths:
101
102 <code>/ -> $(nix-instantiate --eval '<nixpkgs>' -A mastodon.outPath)/public</code>
103
104 <code>/ -> 127.0.0.1:{{ webPort }} </code>(If there was no file in the directory above.)
105
106 <code>/system/ -> /var/lib/mastodon/public-system/</code>
107
108 <code>/api/v1/streaming/ -> 127.0.0.1:{{ streamingPort }}</code>
109
110 Make sure that websockets are forwarded properly. You might want to set up caching
111 of some requests. Take a look at mastodon's provided nginx configuration at
112 <code>https://github.com/tootsuite/mastodon/blob/master/dist/nginx.conf</code>.
113 '';
114 type = lib.types.bool;
115 default = false;
116 };
117
118 user = lib.mkOption {
119 description = ''
120 User under which mastodon runs. If it is set to "mastodon",
121 that user will be created, otherwise it should be set to the
122 name of a user created elsewhere. In both cases,
123 <package>mastodon</package> and a package containing only
124 the shell script <code>mastodon-env</code> will be added to
125 the user's package set. To run a command from
126 <package>mastodon</package> such as <code>tootctl</code>
127 with the environment configured by this module use
128 <code>mastodon-env</code>, as in:
129
130 <code>mastodon-env tootctl accounts create newuser --email newuser@example.com</code>
131 '';
132 type = lib.types.str;
133 default = "mastodon";
134 };
135
136 group = lib.mkOption {
137 description = ''
138 Group under which mastodon runs.
139 '';
140 type = lib.types.str;
141 default = "mastodon";
142 };
143
144 streamingPort = lib.mkOption {
145 description = "TCP port used by the mastodon-streaming service.";
146 type = lib.types.port;
147 default = 55000;
148 };
149
150 webPort = lib.mkOption {
151 description = "TCP port used by the mastodon-web service.";
152 type = lib.types.port;
153 default = 55001;
154 };
155
156 sidekiqPort = lib.mkOption {
157 description = "TCP port used by the mastodon-sidekiq service";
158 type = lib.types.port;
159 default = 55002;
160 };
161
162 vapidPublicKeyFile = lib.mkOption {
163 description = ''
164 Path to file containing the public key used for Web Push
165 Voluntary Application Server Identification. A new keypair can
166 be generated by running:
167
168 <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys</code>
169
170 If <option>mastodon.vapidPrivateKeyFile</option>does not
171 exist, it and this file will be created with a new keypair.
172 '';
173 default = "/var/lib/mastodon/secrets/vapid-public-key";
174 type = lib.types.str;
175 };
176
177 localDomain = lib.mkOption {
178 description = "The domain serving your Mastodon instance.";
179 example = "social.example.org";
180 type = lib.types.str;
181 };
182
183 secretKeyBaseFile = lib.mkOption {
184 description = ''
185 Path to file containing the secret key base.
186 A new secret key base can be generated by running:
187
188 <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake secret</code>
189
190 If this file does not exist, it will be created with a new secret key base.
191 '';
192 default = "/var/lib/mastodon/secrets/secret-key-base";
193 type = lib.types.str;
194 };
195
196 otpSecretFile = lib.mkOption {
197 description = ''
198 Path to file containing the OTP secret.
199 A new OTP secret can be generated by running:
200
201 <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake secret</code>
202
203 If this file does not exist, it will be created with a new OTP secret.
204 '';
205 default = "/var/lib/mastodon/secrets/otp-secret";
206 type = lib.types.str;
207 };
208
209 vapidPrivateKeyFile = lib.mkOption {
210 description = ''
211 Path to file containing the private key used for Web Push
212 Voluntary Application Server Identification. A new keypair can
213 be generated by running:
214
215 <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys</code>
216
217 If this file does not exist, it will be created with a new
218 private key.
219 '';
220 default = "/var/lib/mastodon/secrets/vapid-private-key";
221 type = lib.types.str;
222 };
223
224 trustedProxy = lib.mkOption {
225 description = ''
226 You need to set it to the IP from which your reverse proxy sends requests to Mastodon's web process,
227 otherwise Mastodon will record the reverse proxy's own IP as the IP of all requests, which would be
228 bad because IP addresses are used for important rate limits and security functions.
229 '';
230 type = lib.types.str;
231 default = "127.0.0.1";
232 };
233
234 enableUnixSocket = lib.mkOption {
235 description = ''
236 Instead of binding to an IP address like 127.0.0.1, you may bind to a Unix socket. This variable
237 is process-specific, e.g. you need different values for every process, and it works for both web (Puma)
238 processes and streaming API (Node.js) processes.
239 '';
240 type = lib.types.bool;
241 default = true;
242 };
243
244 redis = {
245 createLocally = lib.mkOption {
246 description = "Configure local Redis server for Mastodon.";
247 type = lib.types.bool;
248 default = true;
249 };
250
251 host = lib.mkOption {
252 description = "Redis host.";
253 type = lib.types.str;
254 default = "127.0.0.1";
255 };
256
257 port = lib.mkOption {
258 description = "Redis port.";
259 type = lib.types.port;
260 default = 6379;
261 };
262 };
263
264 database = {
265 createLocally = lib.mkOption {
266 description = "Configure local PostgreSQL database server for Mastodon.";
267 type = lib.types.bool;
268 default = true;
269 };
270
271 host = lib.mkOption {
272 type = lib.types.str;
273 default = "/run/postgresql";
274 example = "192.168.23.42";
275 description = "Database host address or unix socket.";
276 };
277
278 port = lib.mkOption {
279 type = lib.types.int;
280 default = 5432;
281 description = "Database host port.";
282 };
283
284 name = lib.mkOption {
285 type = lib.types.str;
286 default = "mastodon";
287 description = "Database name.";
288 };
289
290 user = lib.mkOption {
291 type = lib.types.str;
292 default = "mastodon";
293 description = "Database user.";
294 };
295
296 passwordFile = lib.mkOption {
297 type = lib.types.nullOr lib.types.path;
298 default = "/var/lib/mastodon/secrets/db-password";
299 example = "/run/keys/mastodon-db-password";
300 description = ''
301 A file containing the password corresponding to
302 <option>database.user</option>.
303 '';
304 };
305 };
306
307 smtp = {
308 createLocally = lib.mkOption {
309 description = "Configure local Postfix SMTP server for Mastodon.";
310 type = lib.types.bool;
311 default = true;
312 };
313
314 authenticate = lib.mkOption {
315 description = "Authenticate with the SMTP server using username and password.";
316 type = lib.types.bool;
317 default = true;
318 };
319
320 host = lib.mkOption {
321 description = "SMTP host used when sending emails to users.";
322 type = lib.types.str;
323 default = "127.0.0.1";
324 };
325
326 port = lib.mkOption {
327 description = "SMTP port used when sending emails to users.";
328 type = lib.types.port;
329 default = 25;
330 };
331
332 fromAddress = lib.mkOption {
333 description = ''"From" address used when sending Emails to users.'';
334 type = lib.types.str;
335 };
336
337 user = lib.mkOption {
338 description = "SMTP login name.";
339 type = lib.types.str;
340 };
341
342 passwordFile = lib.mkOption {
343 description = ''
344 Path to file containing the SMTP password.
345 '';
346 default = "/var/lib/mastodon/secrets/smtp-password";
347 example = "/run/keys/mastodon-smtp-password";
348 type = lib.types.str;
349 };
350 };
351
352 elasticsearch = {
353 host = lib.mkOption {
354 description = ''
355 Elasticsearch host.
356 If it is not null, Elasticsearch full text search will be enabled.
357 '';
358 type = lib.types.nullOr lib.types.str;
359 default = null;
360 };
361
362 port = lib.mkOption {
363 description = "Elasticsearch port.";
364 type = lib.types.port;
365 default = 9200;
366 };
367 };
368
369 package = lib.mkOption {
370 type = lib.types.package;
371 default = pkgs.mastodon;
372 defaultText = "pkgs.mastodon";
373 description = "Mastodon package to use.";
374 };
375
376 extraConfig = lib.mkOption {
377 type = lib.types.attrs;
378 default = {};
379 description = ''
380 Extra environment variables to pass to all mastodon services.
381 '';
382 };
383
384 automaticMigrations = lib.mkOption {
385 type = lib.types.bool;
386 default = true;
387 description = ''
388 Do automatic database migrations.
389 '';
390 };
391 };
392 };
393
394 config = lib.mkIf cfg.enable {
395 assertions = [
396 {
397 assertion = databaseActuallyCreateLocally -> (cfg.user == cfg.database.user);
398 message = ''For local automatic database provisioning (services.mastodon.database.createLocally == true) with peer authentication (services.mastodon.database.host == "/run/postgresql") to work services.mastodon.user and services.mastodon.database.user must be identical.'';
399 }
400 ];
401
402 systemd.services.mastodon-init-dirs = {
403 script = ''
404 umask 077
405
406 if ! test -f ${cfg.secretKeyBaseFile}; then
407 mkdir -p $(dirname ${cfg.secretKeyBaseFile})
408 bin/rake secret > ${cfg.secretKeyBaseFile}
409 fi
410 if ! test -f ${cfg.otpSecretFile}; then
411 mkdir -p $(dirname ${cfg.otpSecretFile})
412 bin/rake secret > ${cfg.otpSecretFile}
413 fi
414 if ! test -f ${cfg.vapidPrivateKeyFile}; then
415 mkdir -p $(dirname ${cfg.vapidPrivateKeyFile}) $(dirname ${cfg.vapidPublicKeyFile})
416 keypair=$(bin/rake webpush:generate_keys)
417 echo $keypair | grep --only-matching "Private -> [^ ]\+" | sed 's/^Private -> //' > ${cfg.vapidPrivateKeyFile}
418 echo $keypair | grep --only-matching "Public -> [^ ]\+" | sed 's/^Public -> //' > ${cfg.vapidPublicKeyFile}
419 fi
420
421 cat > /var/lib/mastodon/.secrets_env <<EOF
422 SECRET_KEY_BASE="$(cat ${cfg.secretKeyBaseFile})"
423 OTP_SECRET="$(cat ${cfg.otpSecretFile})"
424 VAPID_PRIVATE_KEY="$(cat ${cfg.vapidPrivateKeyFile})"
425 VAPID_PUBLIC_KEY="$(cat ${cfg.vapidPublicKeyFile})"
426 DB_PASS="$(cat ${cfg.database.passwordFile})"
427 '' + (if cfg.smtp.authenticate then ''
428 SMTP_PASSWORD="$(cat ${cfg.smtp.passwordFile})"
429 '' else "") + ''
430 EOF
431 '';
432 environment = env;
433 serviceConfig = {
434 Type = "oneshot";
435 WorkingDirectory = cfg.package;
436 # System Call Filtering
437 SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]);
438 } // cfgService;
439
440 after = [ "network.target" ];
441 wantedBy = [ "multi-user.target" ];
442 };
443
444 systemd.services.mastodon-init-db = lib.mkIf cfg.automaticMigrations {
445 script = ''
446 if [ `psql ${cfg.database.name} -c \
447 "select count(*) from pg_class c \
448 join pg_namespace s on s.oid = c.relnamespace \
449 where s.nspname not in ('pg_catalog', 'pg_toast', 'information_schema') \
450 and s.nspname not like 'pg_temp%';" | sed -n 3p` -eq 0 ]; then
451 SAFETY_ASSURED=1 rails db:schema:load
452 rails db:seed
453 else
454 rails db:migrate
455 fi
456 '';
457 path = [ cfg.package pkgs.postgresql ];
458 environment = env;
459 serviceConfig = {
460 Type = "oneshot";
461 EnvironmentFile = "/var/lib/mastodon/.secrets_env";
462 WorkingDirectory = cfg.package;
463 # System Call Filtering
464 SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]);
465 } // cfgService;
466 after = [ "mastodon-init-dirs.service" "network.target" ] ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else []);
467 wantedBy = [ "multi-user.target" ];
468 };
469
470 systemd.services.mastodon-streaming = {
471 after = [ "network.target" ]
472 ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else [])
473 ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]);
474 description = "Mastodon streaming";
475 wantedBy = [ "multi-user.target" ];
476 environment = env // (if cfg.enableUnixSocket
477 then { SOCKET = "/run/mastodon-streaming/streaming.socket"; }
478 else { PORT = toString(cfg.streamingPort); }
479 );
480 serviceConfig = {
481 ExecStart = "${cfg.package}/run-streaming.sh";
482 Restart = "always";
483 RestartSec = 20;
484 EnvironmentFile = "/var/lib/mastodon/.secrets_env";
485 WorkingDirectory = cfg.package;
486 # Runtime directory and mode
487 RuntimeDirectory = "mastodon-streaming";
488 RuntimeDirectoryMode = "0750";
489 # System Call Filtering
490 SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@privileged" "@resources" ]);
491 } // cfgService;
492 };
493
494 systemd.services.mastodon-web = {
495 after = [ "network.target" ]
496 ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else [])
497 ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]);
498 description = "Mastodon web";
499 wantedBy = [ "multi-user.target" ];
500 environment = env // (if cfg.enableUnixSocket
501 then { SOCKET = "/run/mastodon-web/web.socket"; }
502 else { PORT = toString(cfg.webPort); }
503 );
504 serviceConfig = {
505 ExecStart = "${cfg.package}/bin/puma -C config/puma.rb";
506 Restart = "always";
507 RestartSec = 20;
508 EnvironmentFile = "/var/lib/mastodon/.secrets_env";
509 WorkingDirectory = cfg.package;
510 # Runtime directory and mode
511 RuntimeDirectory = "mastodon-web";
512 RuntimeDirectoryMode = "0750";
513 # System Call Filtering
514 SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]);
515 } // cfgService;
516 path = with pkgs; [ file imagemagick ffmpeg ];
517 };
518
519 systemd.services.mastodon-sidekiq = {
520 after = [ "network.target" ]
521 ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else [])
522 ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]);
523 description = "Mastodon sidekiq";
524 wantedBy = [ "multi-user.target" ];
525 environment = env // {
526 PORT = toString(cfg.sidekiqPort);
527 };
528 serviceConfig = {
529 ExecStart = "${cfg.package}/bin/sidekiq -c 25 -r ${cfg.package}";
530 Restart = "always";
531 RestartSec = 20;
532 EnvironmentFile = "/var/lib/mastodon/.secrets_env";
533 WorkingDirectory = cfg.package;
534 # System Call Filtering
535 SystemCallFilter = "~" + lib.concatStringsSep " " systemCallsList;
536 } // cfgService;
537 path = with pkgs; [ file imagemagick ffmpeg ];
538 };
539
540 services.nginx = lib.mkIf cfg.configureNginx {
541 enable = true;
542 recommendedProxySettings = true; # required for redirections to work
543 virtualHosts."${cfg.localDomain}" = {
544 root = "${cfg.package}/public/";
545 forceSSL = true; # mastodon only supports https
546 enableACME = true;
547
548 locations."/system/".alias = "/var/lib/mastodon/public-system/";
549
550 locations."/" = {
551 tryFiles = "$uri @proxy";
552 };
553
554 locations."@proxy" = {
555 proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-web/web.socket" else "http://127.0.0.1:${toString(cfg.webPort)}");
556 proxyWebsockets = true;
557 };
558
559 locations."/api/v1/streaming/" = {
560 proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-streaming/streaming.socket" else "http://127.0.0.1:${toString(cfg.streamingPort)}/");
561 proxyWebsockets = true;
562 };
563 };
564 };
565
566 services.postfix = lib.mkIf (cfg.smtp.createLocally && cfg.smtp.host == "127.0.0.1") {
567 enable = true;
568 };
569 services.redis = lib.mkIf (cfg.redis.createLocally && cfg.redis.host == "127.0.0.1") {
570 enable = true;
571 };
572 services.postgresql = lib.mkIf databaseActuallyCreateLocally {
573 enable = true;
574 ensureUsers = [
575 {
576 name = cfg.database.user;
577 ensurePermissions."DATABASE ${cfg.database.name}" = "ALL PRIVILEGES";
578 }
579 ];
580 ensureDatabases = [ cfg.database.name ];
581 };
582
583 users.users = lib.mkMerge [
584 (lib.mkIf (cfg.user == "mastodon") {
585 mastodon = {
586 isSystemUser = true;
587 home = cfg.package;
588 inherit (cfg) group;
589 };
590 })
591 (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package mastodonEnv ])
592 ];
593
594 users.groups.${cfg.group}.members = lib.optional cfg.configureNginx config.services.nginx.user;
595 };
596
597 meta.maintainers = with lib.maintainers; [ happy-river erictapen ];
598
599}