1{
2 lib,
3 pkgs,
4 config,
5 options,
6 ...
7}:
8
9let
10 cfg = config.services.mastodon;
11 opt = options.services.mastodon;
12
13 # We only want to create a Redis and PostgreSQL databases if we're actually going to connect to it local.
14 redisActuallyCreateLocally =
15 cfg.redis.createLocally && (cfg.redis.host == "127.0.0.1" || cfg.redis.enableUnixSocket);
16 databaseActuallyCreateLocally =
17 cfg.database.createLocally && cfg.database.host == "/run/postgresql";
18
19 env =
20 {
21 RAILS_ENV = "production";
22 NODE_ENV = "production";
23
24 BOOTSNAP_CACHE_DIR = "/var/cache/mastodon/precompile";
25 LD_PRELOAD = "${pkgs.jemalloc}/lib/libjemalloc.so";
26
27 MASTODON_USE_LIBVIPS = "true";
28
29 # Concurrency mastodon-web
30 WEB_CONCURRENCY = toString cfg.webProcesses;
31 MAX_THREADS = toString cfg.webThreads;
32
33 DB_USER = cfg.database.user;
34
35 DB_HOST = cfg.database.host;
36 DB_NAME = cfg.database.name;
37 LOCAL_DOMAIN = cfg.localDomain;
38 SMTP_SERVER = cfg.smtp.host;
39 SMTP_PORT = toString cfg.smtp.port;
40 SMTP_FROM_ADDRESS = cfg.smtp.fromAddress;
41 PAPERCLIP_ROOT_PATH = "/var/lib/mastodon/public-system";
42 PAPERCLIP_ROOT_URL = "/system";
43 ES_ENABLED = if (cfg.elasticsearch.host != null) then "true" else "false";
44
45 TRUSTED_PROXY_IP = cfg.trustedProxy;
46 }
47 // lib.optionalAttrs (cfg.redis.host != null) { REDIS_HOST = cfg.redis.host; }
48 // lib.optionalAttrs (cfg.redis.port != null) { REDIS_PORT = toString cfg.redis.port; }
49 // lib.optionalAttrs (cfg.redis.createLocally && cfg.redis.enableUnixSocket) {
50 REDIS_URL = "unix://${config.services.redis.servers.mastodon.unixSocket}";
51 }
52 // lib.optionalAttrs (cfg.database.host != "/run/postgresql" && cfg.database.port != null) {
53 DB_PORT = toString cfg.database.port;
54 }
55 // lib.optionalAttrs cfg.smtp.authenticate { SMTP_LOGIN = cfg.smtp.user; }
56 // lib.optionalAttrs (cfg.elasticsearch.host != null) { ES_HOST = cfg.elasticsearch.host; }
57 // lib.optionalAttrs (cfg.elasticsearch.host != null) { ES_PORT = toString cfg.elasticsearch.port; }
58 // lib.optionalAttrs (cfg.elasticsearch.host != null && cfg.elasticsearch.prefix != null) {
59 ES_PREFIX = cfg.elasticsearch.prefix;
60 }
61 // lib.optionalAttrs (cfg.elasticsearch.host != null) { ES_PRESET = cfg.elasticsearch.preset; }
62 // lib.optionalAttrs (cfg.elasticsearch.user != null) { ES_USER = cfg.elasticsearch.user; }
63 // cfg.extraConfig;
64
65 systemCallsList = [
66 "@cpu-emulation"
67 "@debug"
68 "@keyring"
69 "@ipc"
70 "@mount"
71 "@obsolete"
72 "@privileged"
73 "@setuid"
74 ];
75
76 cfgService = {
77 # User and group
78 User = cfg.user;
79 Group = cfg.group;
80 # Working directory
81 WorkingDirectory = cfg.package;
82 # Cache directory and mode
83 CacheDirectory = "mastodon";
84 CacheDirectoryMode = "0750";
85 # State directory and mode
86 StateDirectory = "mastodon";
87 StateDirectoryMode = "0750";
88 # Logs directory and mode
89 LogsDirectory = "mastodon";
90 LogsDirectoryMode = "0750";
91 # Proc filesystem
92 ProcSubset = "pid";
93 ProtectProc = "invisible";
94 # Access write directories
95 UMask = "0027";
96 # Capabilities
97 CapabilityBoundingSet = "";
98 # Security
99 NoNewPrivileges = true;
100 # Sandboxing
101 ProtectSystem = "strict";
102 ProtectHome = true;
103 PrivateTmp = true;
104 PrivateDevices = true;
105 PrivateUsers = true;
106 ProtectClock = true;
107 ProtectHostname = true;
108 ProtectKernelLogs = true;
109 ProtectKernelModules = true;
110 ProtectKernelTunables = true;
111 ProtectControlGroups = true;
112 RestrictAddressFamilies = [
113 "AF_UNIX"
114 "AF_INET"
115 "AF_INET6"
116 "AF_NETLINK"
117 ];
118 RestrictNamespaces = true;
119 LockPersonality = true;
120 MemoryDenyWriteExecute = false;
121 RestrictRealtime = true;
122 RestrictSUIDSGID = true;
123 RemoveIPC = true;
124 PrivateMounts = true;
125 # System Call Filtering
126 SystemCallArchitectures = "native";
127 };
128
129 # Services that all Mastodon units After= and Requires= on
130 commonServices =
131 lib.optional redisActuallyCreateLocally "redis-mastodon.service"
132 ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
133 ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
134
135 envFile = pkgs.writeText "mastodon.env" (
136 lib.concatMapStrings (s: s + "\n") (
137 (lib.concatLists (
138 lib.mapAttrsToList (name: value: lib.optional (value != null) ''${name}="${toString value}"'') env
139 ))
140 )
141 );
142
143 mastodonTootctl =
144 let
145 sourceExtraEnv = lib.concatMapStrings (p: "source ${p}\n") cfg.extraEnvFiles;
146 in
147 pkgs.writeShellScriptBin "mastodon-tootctl" ''
148 set -a
149 export RAILS_ROOT="${cfg.package}"
150 source "${envFile}"
151 source /var/lib/mastodon/.secrets_env
152 ${sourceExtraEnv}
153
154 sudo=exec
155 if [[ "$USER" != ${cfg.user} ]]; then
156 sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} --preserve-env'
157 fi
158 $sudo ${cfg.package}/bin/tootctl "$@"
159 '';
160
161 sidekiqUnits = lib.attrsets.mapAttrs' (
162 name: processCfg:
163 lib.nameValuePair "mastodon-sidekiq-${name}" (
164 let
165 jobClassArgs = toString (builtins.map (c: "-q ${c}") processCfg.jobClasses);
166 jobClassLabel = toString ([ "" ] ++ processCfg.jobClasses);
167 threads = toString (if processCfg.threads == null then cfg.sidekiqThreads else processCfg.threads);
168 in
169 {
170 after = [
171 "network.target"
172 "mastodon-init-dirs.service"
173 ] ++ commonServices;
174 requires = [ "mastodon-init-dirs.service" ] ++ commonServices;
175 description = "Mastodon sidekiq${jobClassLabel}";
176 wantedBy = [ "mastodon.target" ];
177 environment = env // {
178 PORT = toString cfg.sidekiqPort;
179 DB_POOL = threads;
180 };
181 serviceConfig = {
182 ExecStart = "${cfg.package}/bin/sidekiq ${jobClassArgs} -c ${threads} -r ${cfg.package}";
183 Restart = "always";
184 RestartSec = 20;
185 EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles;
186 WorkingDirectory = cfg.package;
187 LimitNOFILE = "1024000";
188 # System Call Filtering
189 SystemCallFilter = [
190 ("~" + lib.concatStringsSep " " systemCallsList)
191 "@chown"
192 "pipe"
193 "pipe2"
194 ];
195 } // cfgService;
196 path = with pkgs; [
197 ffmpeg-headless
198 file
199 imagemagick
200 ];
201 }
202 )
203 ) cfg.sidekiqProcesses;
204
205 streamingUnits = builtins.listToAttrs (
206 map (i: {
207 name = "mastodon-streaming-${toString i}";
208 value = {
209 after = [
210 "network.target"
211 "mastodon-init-dirs.service"
212 ] ++ commonServices;
213 requires = [ "mastodon-init-dirs.service" ] ++ commonServices;
214 wantedBy = [
215 "mastodon.target"
216 "mastodon-streaming.target"
217 ];
218 description = "Mastodon streaming ${toString i}";
219 environment = env // {
220 SOCKET = "/run/mastodon-streaming/streaming-${toString i}.socket";
221 };
222 serviceConfig = {
223 ExecStart = "${cfg.package}/run-streaming.sh";
224 Restart = "always";
225 RestartSec = 20;
226 EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles;
227 WorkingDirectory = cfg.package;
228 # Runtime directory and mode
229 RuntimeDirectory = "mastodon-streaming";
230 RuntimeDirectoryMode = "0750";
231 # System Call Filtering
232 SystemCallFilter = [
233 (
234 "~"
235 + lib.concatStringsSep " " (
236 systemCallsList
237 ++ [
238 "@memlock"
239 "@resources"
240 ]
241 )
242 )
243 "pipe"
244 "pipe2"
245 ];
246 } // cfgService;
247 };
248 }) (lib.range 1 cfg.streamingProcesses)
249 );
250
251in
252{
253
254 imports = [
255 (lib.mkRemovedOptionModule [
256 "services"
257 "mastodon"
258 "streamingPort"
259 ] "Mastodon currently doesn't support streaming via TCP ports. Please open a PR if you need this.")
260 ];
261
262 options = {
263 services.mastodon = {
264 enable = lib.mkEnableOption "Mastodon, a federated social network server";
265
266 configureNginx = lib.mkOption {
267 description = ''
268 Configure nginx as a reverse proxy for mastodon.
269 Note that this makes some assumptions on your setup, and sets settings that will
270 affect other virtualHosts running on your nginx instance, if any.
271 Alternatively you can configure a reverse-proxy of your choice to serve these paths:
272
273 `/ -> ''${pkgs.mastodon}/public`
274
275 `/ -> 127.0.0.1:{{ webPort }} `(If there was no file in the directory above.)
276
277 `/system/ -> /var/lib/mastodon/public-system/`
278
279 `/api/v1/streaming/ -> 127.0.0.1:{{ streamingPort }}`
280
281 Make sure that websockets are forwarded properly. You might want to set up caching
282 of some requests. Take a look at mastodon's provided nginx configuration at
283 `https://github.com/mastodon/mastodon/blob/master/dist/nginx.conf`.
284 '';
285 type = lib.types.bool;
286 default = false;
287 };
288
289 user = lib.mkOption {
290 description = ''
291 User under which mastodon runs. If it is set to "mastodon",
292 that user will be created, otherwise it should be set to the
293 name of a user created elsewhere.
294 In both cases, the `mastodon` package will be added to the user's package set
295 and a tootctl wrapper to system packages that switches to the configured account
296 and load the right environment.
297 '';
298 type = lib.types.str;
299 default = "mastodon";
300 };
301
302 group = lib.mkOption {
303 description = ''
304 Group under which mastodon runs.
305 '';
306 type = lib.types.str;
307 default = "mastodon";
308 };
309
310 streamingProcesses = lib.mkOption {
311 description = ''
312 Number of processes used by the mastodon-streaming service.
313 Please define this explicitly, recommended is the amount of your CPU cores minus one.
314 '';
315 type = lib.types.ints.positive;
316 example = 3;
317 };
318
319 webPort = lib.mkOption {
320 description = "TCP port used by the mastodon-web service.";
321 type = lib.types.port;
322 default = 55001;
323 };
324 webProcesses = lib.mkOption {
325 description = "Processes used by the mastodon-web service.";
326 type = lib.types.int;
327 default = 2;
328 };
329 webThreads = lib.mkOption {
330 description = "Threads per process used by the mastodon-web service.";
331 type = lib.types.int;
332 default = 5;
333 };
334
335 sidekiqPort = lib.mkOption {
336 description = "TCP port used by the mastodon-sidekiq service.";
337 type = lib.types.port;
338 default = 55002;
339 };
340
341 sidekiqThreads = lib.mkOption {
342 description = "Worker threads used by the mastodon-sidekiq-all service. If `sidekiqProcesses` is configured and any processes specify null `threads`, this value is used.";
343 type = lib.types.int;
344 default = 25;
345 };
346
347 sidekiqProcesses = lib.mkOption {
348 description = "How many Sidekiq processes should be used to handle background jobs, and which job classes they handle. *Read the [upstream documentation](https://docs.joinmastodon.org/admin/scaling/#sidekiq) before configuring this!*";
349 type =
350 with lib.types;
351 attrsOf (submodule {
352 options = {
353 jobClasses = lib.mkOption {
354 type = listOf (enum [
355 "default"
356 "push"
357 "pull"
358 "mailers"
359 "scheduler"
360 "ingress"
361 ]);
362 description = "If not empty, which job classes should be executed by this process. *Only one process should handle the 'scheduler' class. If left empty, this process will handle the 'scheduler' class.*";
363 };
364 threads = lib.mkOption {
365 type = nullOr int;
366 description = "Number of threads this process should use for executing jobs. If null, the configured `sidekiqThreads` are used.";
367 };
368 };
369 });
370 default = {
371 all = {
372 jobClasses = [ ];
373 threads = null;
374 };
375 };
376 example = {
377 all = {
378 jobClasses = [ ];
379 threads = null;
380 };
381 ingress = {
382 jobClasses = [ "ingress" ];
383 threads = 5;
384 };
385 default = {
386 jobClasses = [ "default" ];
387 threads = 10;
388 };
389 push-pull = {
390 jobClasses = [
391 "push"
392 "pull"
393 ];
394 threads = 5;
395 };
396 };
397 };
398
399 vapidPublicKeyFile = lib.mkOption {
400 description = ''
401 Path to file containing the public key used for Web Push
402 Voluntary Application Server Identification. A new keypair can
403 be generated by running:
404
405 `nix build -f '<nixpkgs>' mastodon; cd result; RAILS_ENV=production bin/rake webpush:generate_keys`
406
407 If {option}`mastodon.vapidPrivateKeyFile`does not
408 exist, it and this file will be created with a new keypair.
409 '';
410 default = "/var/lib/mastodon/secrets/vapid-public-key";
411 type = lib.types.str;
412 };
413
414 vapidPrivateKeyFile = lib.mkOption {
415 description = ''
416 Path to file containing the private key used for Web Push
417 Voluntary Application Server Identification. A new keypair can
418 be generated by running:
419
420 `nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys`
421
422 If this file does not exist, it will be created with a new
423 private key.
424 '';
425 default = "/var/lib/mastodon/secrets/vapid-private-key";
426 type = lib.types.str;
427 };
428
429 localDomain = lib.mkOption {
430 description = "The domain serving your Mastodon instance.";
431 example = "social.example.org";
432 type = lib.types.str;
433 };
434
435 activeRecordEncryptionDeterministicKeyFile = lib.mkOption {
436 description = ''
437 This key must be set to enable the Active Record Encryption feature within
438 Rails that Mastodon uses to encrypt and decrypt some database attributes.
439 A new Active Record keys can be generated by running:
440
441 `nix build -f '<nixpkgs>' mastodon; cd result; RAILS_ENV=production ./bin/rails db:encryption:init`
442
443 If this file does not exist, it will be created with a new Active Record
444 keys.
445 '';
446 default = "/var/lib/mastodon/secrets/active-record-encryption-deterministic-key";
447 type = lib.types.str;
448 };
449
450 activeRecordEncryptionKeyDerivationSaltFile = lib.mkOption {
451 description = ''
452 This key must be set to enable the Active Record Encryption feature within
453 Rails that Mastodon uses to encrypt and decrypt some database attributes.
454 A new Active Record keys can be generated by running:
455
456 `nix build -f '<nixpkgs>' mastodon; cd result; RAILS_ENV=production ./bin/rails db:encryption:init`
457
458 If this file does not exist, it will be created with a new Active Record
459 keys.
460 '';
461 default = "/var/lib/mastodon/secrets/active-record-encryption-key-derivation-salt";
462 type = lib.types.str;
463 };
464
465 activeRecordEncryptionPrimaryKeyFile = lib.mkOption {
466 description = ''
467 This key must be set to enable the Active Record Encryption feature within
468 Rails that Mastodon uses to encrypt and decrypt some database attributes.
469 A new Active Record keys can be generated by running:
470
471 `nix build -f '<nixpkgs>' mastodon; cd result; RAILS_ENV=production ./bin/rails db:encryption:init`
472
473 If this file does not exist, it will be created with a new Active Record
474 keys.
475 '';
476 default = "/var/lib/mastodon/secrets/active-record-encryption-primary-key";
477 type = lib.types.str;
478 };
479
480 secretKeyBaseFile = lib.mkOption {
481 description = ''
482 Path to file containing the secret key base.
483 A new secret key base can be generated by running:
484
485 `nix build -f '<nixpkgs>' mastodon; cd result; bin/bundle exec rails secret`
486
487 If this file does not exist, it will be created with a new secret key base.
488 '';
489 default = "/var/lib/mastodon/secrets/secret-key-base";
490 type = lib.types.str;
491 };
492
493 otpSecretFile = lib.mkOption {
494 description = ''
495 Path to file containing the OTP secret.
496 A new OTP secret can be generated by running:
497
498 `nix build -f '<nixpkgs>' mastodon; cd result; bin/bundle exec rails secret`
499
500 If this file does not exist, it will be created with a new OTP secret.
501 '';
502 default = "/var/lib/mastodon/secrets/otp-secret";
503 type = lib.types.str;
504 };
505
506 trustedProxy = lib.mkOption {
507 description = ''
508 You need to set it to the IP from which your reverse proxy sends requests to Mastodon's web process,
509 otherwise Mastodon will record the reverse proxy's own IP as the IP of all requests, which would be
510 bad because IP addresses are used for important rate limits and security functions.
511 '';
512 type = lib.types.str;
513 default = "127.0.0.1";
514 };
515
516 enableUnixSocket = lib.mkOption {
517 description = ''
518 Instead of binding to an IP address like 127.0.0.1, you may bind to a Unix socket. This variable
519 is process-specific, e.g. you need different values for every process, and it works for both web (Puma)
520 processes and streaming API (Node.js) processes.
521 '';
522 type = lib.types.bool;
523 default = true;
524 };
525
526 redis = {
527 createLocally = lib.mkOption {
528 description = "Configure local Redis server for Mastodon.";
529 type = lib.types.bool;
530 default = true;
531 };
532
533 host = lib.mkOption {
534 description = "Redis host.";
535 type = lib.types.nullOr lib.types.str;
536 default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then "127.0.0.1" else null;
537 defaultText = lib.literalExpression ''
538 if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket} then "127.0.0.1" else null
539 '';
540 };
541
542 port = lib.mkOption {
543 description = "Redis port.";
544 type = lib.types.nullOr lib.types.port;
545 default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then 31637 else null;
546 defaultText = lib.literalExpression ''
547 if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket} then 31637 else null
548 '';
549 };
550
551 passwordFile = lib.mkOption {
552 description = "A file containing the password for Redis database.";
553 type = lib.types.nullOr lib.types.path;
554 default = null;
555 example = "/run/keys/mastodon-redis-password";
556 };
557
558 enableUnixSocket = lib.mkOption {
559 description = "Use Unix socket";
560 type = lib.types.bool;
561 default = true;
562 };
563 };
564
565 database = {
566 createLocally = lib.mkOption {
567 description = "Configure local PostgreSQL database server for Mastodon.";
568 type = lib.types.bool;
569 default = true;
570 };
571
572 host = lib.mkOption {
573 type = lib.types.str;
574 default = "/run/postgresql";
575 example = "192.168.23.42";
576 description = "Database host address or unix socket.";
577 };
578
579 port = lib.mkOption {
580 type = lib.types.nullOr lib.types.port;
581 default = if cfg.database.createLocally then null else 5432;
582 defaultText = lib.literalExpression ''
583 if config.${opt.database.createLocally}
584 then null
585 else 5432
586 '';
587 description = "Database host port.";
588 };
589
590 name = lib.mkOption {
591 type = lib.types.str;
592 default = "mastodon";
593 description = "Database name.";
594 };
595
596 user = lib.mkOption {
597 type = lib.types.str;
598 default = "mastodon";
599 description = "Database user.";
600 };
601
602 passwordFile = lib.mkOption {
603 type = lib.types.nullOr lib.types.path;
604 default = null;
605 example = "/var/lib/mastodon/secrets/db-password";
606 description = ''
607 A file containing the password corresponding to
608 {option}`database.user`.
609 '';
610 };
611 };
612
613 smtp = {
614 createLocally = lib.mkOption {
615 description = "Configure local Postfix SMTP server for Mastodon.";
616 type = lib.types.bool;
617 default = true;
618 };
619
620 authenticate = lib.mkOption {
621 description = "Authenticate with the SMTP server using username and password.";
622 type = lib.types.bool;
623 default = false;
624 };
625
626 host = lib.mkOption {
627 description = "SMTP host used when sending emails to users.";
628 type = lib.types.str;
629 default = "127.0.0.1";
630 };
631
632 port = lib.mkOption {
633 description = "SMTP port used when sending emails to users.";
634 type = lib.types.port;
635 default = 25;
636 };
637
638 fromAddress = lib.mkOption {
639 description = ''"From" address used when sending Emails to users.'';
640 type = lib.types.str;
641 };
642
643 user = lib.mkOption {
644 type = lib.types.nullOr lib.types.str;
645 default = null;
646 example = "mastodon@example.com";
647 description = "SMTP login name.";
648 };
649
650 passwordFile = lib.mkOption {
651 type = lib.types.nullOr lib.types.path;
652 default = null;
653 example = "/var/lib/mastodon/secrets/smtp-password";
654 description = ''
655 Path to file containing the SMTP password.
656 '';
657 };
658 };
659
660 elasticsearch = {
661 host = lib.mkOption {
662 description = ''
663 Elasticsearch host.
664 If it is not null, Elasticsearch full text search will be enabled.
665 '';
666 type = lib.types.nullOr lib.types.str;
667 default = null;
668 };
669
670 port = lib.mkOption {
671 description = "Elasticsearch port.";
672 type = lib.types.port;
673 default = 9200;
674 };
675
676 prefix = lib.mkOption {
677 description = ''
678 If provided, adds a prefix to indexes in Elasticsearch. This allows to use the same
679 Elasticsearch cluster between different projects or Mastodon servers.
680 '';
681 type = lib.types.nullOr lib.types.str;
682 default = null;
683 example = "mastodon";
684 };
685
686 preset = lib.mkOption {
687 description = ''
688 It controls the ElasticSearch indices configuration (number of shards and replica).
689 '';
690 type = lib.types.enum [
691 "single_node_cluster"
692 "small_cluster"
693 "large_cluster"
694 ];
695 default = "single_node_cluster";
696 example = "large_cluster";
697 };
698
699 user = lib.mkOption {
700 description = "Used for optionally authenticating with Elasticsearch.";
701 type = lib.types.nullOr lib.types.str;
702 default = null;
703 example = "elasticsearch-mastodon";
704 };
705
706 passwordFile = lib.mkOption {
707 description = ''
708 Path to file containing password for optionally authenticating with Elasticsearch.
709 '';
710 type = lib.types.nullOr lib.types.path;
711 default = null;
712 example = "/var/lib/mastodon/secrets/elasticsearch-password";
713 };
714 };
715
716 package = lib.mkOption {
717 type = lib.types.package;
718 default = pkgs.mastodon;
719 defaultText = lib.literalExpression "pkgs.mastodon";
720 description = "Mastodon package to use.";
721 };
722
723 extraConfig = lib.mkOption {
724 type = lib.types.attrs;
725 default = { };
726 description = ''
727 Extra environment variables to pass to all mastodon services.
728 '';
729 };
730
731 extraEnvFiles = lib.mkOption {
732 type = with lib.types; listOf path;
733 default = [ ];
734 description = ''
735 Extra environment files to pass to all mastodon services. Useful for passing down environmental secrets.
736 '';
737 example = [ "/etc/mastodon/s3config.env" ];
738 };
739
740 automaticMigrations = lib.mkOption {
741 type = lib.types.bool;
742 default = true;
743 description = ''
744 Do automatic database migrations.
745 '';
746 };
747
748 mediaAutoRemove = {
749 enable = lib.mkOption {
750 type = lib.types.bool;
751 default = true;
752 example = false;
753 description = ''
754 Automatically remove remote media attachments and preview cards older than the configured amount of days.
755
756 Recommended in <https://docs.joinmastodon.org/admin/setup/>.
757 '';
758 };
759
760 startAt = lib.mkOption {
761 type = lib.types.str;
762 default = "daily";
763 example = "hourly";
764 description = ''
765 How often to remove remote media.
766
767 The format is described in {manpage}`systemd.time(7)`.
768 '';
769 };
770
771 olderThanDays = lib.mkOption {
772 type = lib.types.int;
773 default = 30;
774 example = 14;
775 description = ''
776 How old remote media needs to be in order to be removed.
777 '';
778 };
779 };
780 };
781 };
782
783 config = lib.mkIf cfg.enable (
784 lib.mkMerge [
785 {
786 assertions = [
787 {
788 assertion =
789 !redisActuallyCreateLocally -> (cfg.redis.host != "127.0.0.1" && cfg.redis.port != null);
790 message = ''
791 `services.mastodon.redis.host` and `services.mastodon.redis.port` need to be set if
792 `services.mastodon.redis.createLocally` is not enabled.
793 '';
794 }
795 {
796 assertion =
797 redisActuallyCreateLocally
798 -> (!cfg.redis.enableUnixSocket || (cfg.redis.host == null && cfg.redis.port == null));
799 message = ''
800 `services.mastodon.redis.enableUnixSocket` needs to be disabled if
801 `services.mastodon.redis.host` and `services.mastodon.redis.port` is used.
802 '';
803 }
804 {
805 assertion =
806 redisActuallyCreateLocally -> (!cfg.redis.enableUnixSocket || cfg.redis.passwordFile == null);
807 message = ''
808 <option>services.mastodon.redis.enableUnixSocket</option> needs to be disabled if
809 <option>services.mastodon.redis.passwordFile</option> is used.
810 '';
811 }
812 {
813 assertion =
814 databaseActuallyCreateLocally
815 -> (cfg.user == cfg.database.user && cfg.database.user == cfg.database.name);
816 message = ''
817 For local automatic database provisioning (services.mastodon.database.createLocally == true) with peer
818 authentication (services.mastodon.database.host == "/run/postgresql") to work services.mastodon.user
819 and services.mastodon.database.user must be identical.
820 '';
821 }
822 {
823 assertion = !databaseActuallyCreateLocally -> (cfg.database.host != "/run/postgresql");
824 message = ''
825 <option>services.mastodon.database.host</option> needs to be set if
826 <option>services.mastodon.database.createLocally</option> is not enabled.
827 '';
828 }
829 {
830 assertion = cfg.smtp.authenticate -> (cfg.smtp.user != null);
831 message = ''
832 <option>services.mastodon.smtp.user</option> needs to be set if
833 <option>services.mastodon.smtp.authenticate</option> is enabled.
834 '';
835 }
836 {
837 assertion = cfg.smtp.authenticate -> (cfg.smtp.passwordFile != null);
838 message = ''
839 <option>services.mastodon.smtp.passwordFile</option> needs to be set if
840 <option>services.mastodon.smtp.authenticate</option> is enabled.
841 '';
842 }
843 {
844 assertion =
845 1 == (lib.count (x: x) (
846 lib.mapAttrsToList (
847 _: v: builtins.elem "scheduler" v.jobClasses || v.jobClasses == [ ]
848 ) cfg.sidekiqProcesses
849 ));
850 message = "There must be exactly one Sidekiq queue in services.mastodon.sidekiqProcesses with jobClass \"scheduler\".";
851 }
852 ];
853
854 environment.systemPackages = [ mastodonTootctl ];
855
856 systemd.targets.mastodon = {
857 description = "Target for all Mastodon services";
858 wantedBy = [ "multi-user.target" ];
859 after = [ "network.target" ];
860 };
861
862 systemd.targets.mastodon-streaming = {
863 description = "Target for all Mastodon streaming services";
864 wantedBy = [
865 "multi-user.target"
866 "mastodon.target"
867 ];
868 after = [ "network.target" ];
869 };
870
871 systemd.services.mastodon-init-dirs = {
872 script =
873 ''
874 umask 077
875
876 if ! test -d /var/cache/mastodon/precompile; then
877 ${cfg.package}/bin/bundle exec bootsnap precompile --gemfile ${cfg.package}/app ${cfg.package}/lib
878 fi
879 if ! test -f ${cfg.activeRecordEncryptionDeterministicKeyFile}; then
880 mkdir -p $(dirname ${cfg.activeRecordEncryptionDeterministicKeyFile})
881 bin/rails db:encryption:init | grep --only-matching "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=[^ ]\+" | sed 's/^ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=//' > ${cfg.activeRecordEncryptionDeterministicKeyFile}
882 fi
883 if ! test -f ${cfg.activeRecordEncryptionKeyDerivationSaltFile}; then
884 mkdir -p $(dirname ${cfg.activeRecordEncryptionKeyDerivationSaltFile})
885 bin/rails db:encryption:init | grep --only-matching "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=[^ ]\+" | sed 's/^ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=//' > ${cfg.activeRecordEncryptionKeyDerivationSaltFile}
886 fi
887 if ! test -f ${cfg.activeRecordEncryptionPrimaryKeyFile}; then
888 mkdir -p $(dirname ${cfg.activeRecordEncryptionPrimaryKeyFile})
889 bin/rails db:encryption:init | grep --only-matching "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=[^ ]\+" | sed 's/^ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=//' > ${cfg.activeRecordEncryptionPrimaryKeyFile}
890 fi
891 if ! test -f ${cfg.secretKeyBaseFile}; then
892 mkdir -p $(dirname ${cfg.secretKeyBaseFile})
893 bin/bundle exec rails secret > ${cfg.secretKeyBaseFile}
894 fi
895 if ! test -f ${cfg.otpSecretFile}; then
896 mkdir -p $(dirname ${cfg.otpSecretFile})
897 bin/bundle exec rails secret > ${cfg.otpSecretFile}
898 fi
899 if ! test -f ${cfg.vapidPrivateKeyFile}; then
900 mkdir -p $(dirname ${cfg.vapidPrivateKeyFile}) $(dirname ${cfg.vapidPublicKeyFile})
901 keypair=$(bin/rake webpush:generate_keys)
902 echo $keypair | grep --only-matching "Private -> [^ ]\+" | sed 's/^Private -> //' > ${cfg.vapidPrivateKeyFile}
903 echo $keypair | grep --only-matching "Public -> [^ ]\+" | sed 's/^Public -> //' > ${cfg.vapidPublicKeyFile}
904 fi
905
906 cat > /var/lib/mastodon/.secrets_env <<EOF
907 ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="$(cat ${cfg.activeRecordEncryptionDeterministicKeyFile})"
908 ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="$(cat ${cfg.activeRecordEncryptionKeyDerivationSaltFile})"
909 ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="$(cat ${cfg.activeRecordEncryptionPrimaryKeyFile})"
910 SECRET_KEY_BASE="$(cat ${cfg.secretKeyBaseFile})"
911 OTP_SECRET="$(cat ${cfg.otpSecretFile})"
912 VAPID_PRIVATE_KEY="$(cat ${cfg.vapidPrivateKeyFile})"
913 VAPID_PUBLIC_KEY="$(cat ${cfg.vapidPublicKeyFile})"
914 ''
915 + lib.optionalString (cfg.redis.passwordFile != null) ''
916 REDIS_PASSWORD="$(cat ${cfg.redis.passwordFile})"
917 ''
918 + lib.optionalString (cfg.database.passwordFile != null) ''
919 DB_PASS="$(cat ${cfg.database.passwordFile})"
920 ''
921 + lib.optionalString cfg.smtp.authenticate ''
922 SMTP_PASSWORD="$(cat ${cfg.smtp.passwordFile})"
923 ''
924 + lib.optionalString (cfg.elasticsearch.passwordFile != null) ''
925 ES_PASS="$(cat ${cfg.elasticsearch.passwordFile})"
926 ''
927 + ''
928 EOF
929 '';
930 environment = env;
931 serviceConfig = {
932 Type = "oneshot";
933 SyslogIdentifier = "mastodon-init-dirs";
934 # System Call Filtering
935 SystemCallFilter = [
936 ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]))
937 "@chown"
938 "pipe"
939 "pipe2"
940 ];
941 } // cfgService;
942
943 after = [ "network.target" ];
944 };
945
946 systemd.services.mastodon-init-db = lib.mkIf cfg.automaticMigrations {
947 script =
948 lib.optionalString (!databaseActuallyCreateLocally) ''
949 umask 077
950 export PGPASSWORD="$(cat '${cfg.database.passwordFile}')"
951 ''
952 + ''
953 result="$(psql -t --csv -c \
954 "select count(*) from pg_class c \
955 join pg_namespace s on s.oid = c.relnamespace \
956 where s.nspname not in ('pg_catalog', 'pg_toast', 'information_schema') \
957 and s.nspname not like 'pg_temp%';")" || error_code=$?
958 if [ "''${error_code:-0}" -ne 0 ]; then
959 echo "Failure checking if database is seeded. psql gave exit code $error_code"
960 exit "$error_code"
961 fi
962 if [ "$result" -eq 0 ]; then
963 echo "Seeding database"
964 SAFETY_ASSURED=1 rails db:schema:load
965 rails db:seed
966 else
967 echo "Migrating database (this might be a noop)"
968 rails db:migrate
969 fi
970 ''
971 + lib.optionalString (!databaseActuallyCreateLocally) ''
972 unset PGPASSWORD
973 '';
974 path = [
975 cfg.package
976 config.services.postgresql.package
977 ];
978 environment =
979 env
980 // lib.optionalAttrs (!databaseActuallyCreateLocally) {
981 PGHOST = cfg.database.host;
982 PGPORT = toString cfg.database.port;
983 PGDATABASE = cfg.database.name;
984 PGUSER = cfg.database.user;
985 };
986 serviceConfig = {
987 Type = "oneshot";
988 EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles;
989 WorkingDirectory = cfg.package;
990 # System Call Filtering
991 SystemCallFilter = [
992 ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]))
993 "@chown"
994 "pipe"
995 "pipe2"
996 ];
997 } // cfgService;
998 after = [
999 "network.target"
1000 "mastodon-init-dirs.service"
1001 ] ++ lib.optional databaseActuallyCreateLocally "postgresql.service";
1002 requires = [
1003 "mastodon-init-dirs.service"
1004 ] ++ lib.optional databaseActuallyCreateLocally "postgresql.service";
1005 };
1006
1007 systemd.services.mastodon-web = {
1008 after = [
1009 "network.target"
1010 "mastodon-init-dirs.service"
1011 ] ++ commonServices;
1012 requires = [ "mastodon-init-dirs.service" ] ++ commonServices;
1013 wantedBy = [ "mastodon.target" ];
1014 description = "Mastodon web";
1015 environment =
1016 env
1017 // (
1018 if cfg.enableUnixSocket then
1019 { SOCKET = "/run/mastodon-web/web.socket"; }
1020 else
1021 { PORT = toString cfg.webPort; }
1022 );
1023 serviceConfig = {
1024 ExecStart = "${cfg.package}/bin/puma -C config/puma.rb";
1025 Restart = "always";
1026 RestartSec = 20;
1027 EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles;
1028 WorkingDirectory = cfg.package;
1029 # Runtime directory and mode
1030 RuntimeDirectory = "mastodon-web";
1031 RuntimeDirectoryMode = "0750";
1032 # System Call Filtering
1033 SystemCallFilter = [
1034 ("~" + lib.concatStringsSep " " systemCallsList)
1035 "@chown"
1036 "pipe"
1037 "pipe2"
1038 ];
1039 } // cfgService;
1040 path = with pkgs; [
1041 ffmpeg-headless
1042 file
1043 ];
1044 };
1045
1046 systemd.services.mastodon-media-auto-remove = lib.mkIf cfg.mediaAutoRemove.enable {
1047 description = "Mastodon media auto remove";
1048 environment = env;
1049 serviceConfig = {
1050 Type = "oneshot";
1051 EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles;
1052 } // cfgService;
1053 script =
1054 let
1055 olderThanDays = toString cfg.mediaAutoRemove.olderThanDays;
1056 in
1057 ''
1058 ${cfg.package}/bin/tootctl media remove --days=${olderThanDays}
1059 ${cfg.package}/bin/tootctl preview_cards remove --days=${olderThanDays}
1060 '';
1061 startAt = cfg.mediaAutoRemove.startAt;
1062 };
1063
1064 services.nginx = lib.mkIf cfg.configureNginx {
1065 enable = true;
1066 recommendedProxySettings = true; # required for redirections to work
1067 virtualHosts."${cfg.localDomain}" = {
1068 root = "${cfg.package}/public/";
1069 # mastodon only supports https, but you can override this if you offload tls elsewhere.
1070 forceSSL = lib.mkDefault true;
1071 enableACME = lib.mkDefault true;
1072
1073 locations."/system/".alias = "/var/lib/mastodon/public-system/";
1074
1075 locations."/" = {
1076 tryFiles = "$uri @proxy";
1077 };
1078
1079 locations."@proxy" = {
1080 proxyPass = (
1081 if cfg.enableUnixSocket then
1082 "http://unix:/run/mastodon-web/web.socket"
1083 else
1084 "http://127.0.0.1:${toString cfg.webPort}"
1085 );
1086 proxyWebsockets = true;
1087 };
1088
1089 locations."/api/v1/streaming/" = {
1090 proxyPass = "http://mastodon-streaming";
1091 proxyWebsockets = true;
1092 };
1093 };
1094 upstreams.mastodon-streaming = {
1095 extraConfig = ''
1096 least_conn;
1097 '';
1098 servers = builtins.listToAttrs (
1099 map (i: {
1100 name = "unix:/run/mastodon-streaming/streaming-${toString i}.socket";
1101 value = { };
1102 }) (lib.range 1 cfg.streamingProcesses)
1103 );
1104 };
1105 };
1106
1107 services.postfix = lib.mkIf (cfg.smtp.createLocally && cfg.smtp.host == "127.0.0.1") {
1108 enable = true;
1109 hostname = lib.mkDefault "${cfg.localDomain}";
1110 };
1111
1112 services.redis.servers.mastodon = lib.mkIf redisActuallyCreateLocally (
1113 lib.mkMerge [
1114 {
1115 enable = true;
1116 }
1117 (lib.mkIf (!cfg.redis.enableUnixSocket) {
1118 port = cfg.redis.port;
1119 })
1120 ]
1121 );
1122
1123 services.postgresql = lib.mkIf databaseActuallyCreateLocally {
1124 enable = true;
1125 ensureUsers = [
1126 {
1127 name = cfg.database.name;
1128 ensureDBOwnership = true;
1129 }
1130 ];
1131 ensureDatabases = [ cfg.database.name ];
1132 };
1133
1134 users.users = lib.mkMerge [
1135 (lib.mkIf (cfg.user == "mastodon") {
1136 mastodon = {
1137 isSystemUser = true;
1138 home = cfg.package;
1139 inherit (cfg) group;
1140 };
1141 })
1142 (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package ])
1143 (lib.mkIf (cfg.redis.createLocally && cfg.redis.enableUnixSocket) {
1144 ${config.services.mastodon.user}.extraGroups = [ "redis-mastodon" ];
1145 })
1146 ];
1147
1148 users.groups.${cfg.group}.members = lib.optional cfg.configureNginx config.services.nginx.user;
1149 }
1150 {
1151 systemd.services = lib.mkMerge [
1152 sidekiqUnits
1153 streamingUnits
1154 ];
1155 }
1156 ]
1157 );
1158
1159 meta.maintainers = with lib.maintainers; [
1160 happy-river
1161 erictapen
1162 ];
1163
1164}