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