1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 inherit (lib)
10 any
11 attrsets
12 attrByPath
13 catAttrs
14 collect
15 concatMapStrings
16 concatStringsSep
17 escape
18 escapeShellArg
19 escapeShellArgs
20 hasInfix
21 isAttrs
22 isList
23 isStorePath
24 isString
25 mapAttrs
26 mapAttrsToList
27 optionalString
28 optionals
29 replaceStrings
30 splitString
31 substring
32 versionOlder
33
34 readFile
35
36 literalExpression
37 literalMD
38 mkBefore
39 mkEnableOption
40 mkIf
41 mkMerge
42 mkOption
43 mkOptionType
44 mkPackageOption
45 types
46 ;
47
48 cfg = config.services.akkoma;
49 ex = cfg.config;
50 db = ex.":pleroma"."Pleroma.Repo";
51 web = ex.":pleroma"."Pleroma.Web.Endpoint";
52
53 format = pkgs.formats.elixirConf { elixir = cfg.package.elixirPackage; };
54 inherit (format.lib)
55 mkAtom
56 mkMap
57 mkRaw
58 mkTuple
59 ;
60
61 isConfined = config.systemd.services.akkoma.confinement.enable;
62 hasSmtp =
63 (attrByPath [ ":pleroma" "Pleroma.Emails.Mailer" "adapter" "value" ] null ex)
64 == "Swoosh.Adapters.SMTP";
65
66 isAbsolutePath = v: isString v && substring 0 1 v == "/";
67 isSecret = v: isAttrs v && v ? _secret && isAbsolutePath v._secret;
68
69 absolutePath =
70 with types;
71 mkOptionType {
72 name = "absolutePath";
73 description = "absolute path";
74 descriptionClass = "noun";
75 check = isAbsolutePath;
76 inherit (str) merge;
77 };
78
79 secret = mkOptionType {
80 name = "secret";
81 description = "secret value";
82 descriptionClass = "noun";
83 check = isSecret;
84 nestedTypes = {
85 _secret = absolutePath;
86 };
87 };
88
89 ipAddress =
90 with types;
91 mkOptionType {
92 name = "ipAddress";
93 description = "IPv4 or IPv6 address";
94 descriptionClass = "conjunction";
95 check = x: str.check x && builtins.match "[.0-9:A-Fa-f]+" x != null;
96 inherit (str) merge;
97 };
98
99 elixirValue =
100 let
101 elixirValue' =
102 with types;
103 nullOr (oneOf [
104 bool
105 int
106 float
107 str
108 (attrsOf elixirValue')
109 (listOf elixirValue')
110 ])
111 // {
112 description = "Elixir value";
113 };
114 in
115 elixirValue';
116
117 frontend = {
118 options = {
119 package = mkOption {
120 type = types.package;
121 description = "Akkoma frontend package.";
122 example = literalExpression "pkgs.akkoma-fe";
123 };
124
125 name = mkOption {
126 type = types.nonEmptyStr;
127 description = "Akkoma frontend name.";
128 example = "akkoma-fe";
129 };
130
131 ref = mkOption {
132 type = types.nonEmptyStr;
133 description = "Akkoma frontend reference.";
134 example = "stable";
135 };
136 };
137 };
138
139 sha256 = builtins.hashString "sha256";
140
141 replaceSec =
142 let
143 replaceSec' =
144 { }@args:
145 v:
146 if isAttrs v then
147 if v ? _secret then
148 if isAbsolutePath v._secret then
149 sha256 v._secret
150 else
151 abort "Invalid secret path (_secret = ${v._secret})"
152 else
153 mapAttrs (_: val: replaceSec' args val) v
154 else if isList v then
155 map (replaceSec' args) v
156 else
157 v;
158 in
159 replaceSec' { };
160
161 # Erlang/Elixir uses a somewhat special format for IP addresses
162 erlAddr =
163 addr:
164 let
165 isIPv4 = (lib.match "^([0-9]+\\.){3}[0-9]+$" addr) != null;
166 in
167 if isIPv4 then
168 "{${lib.concatStringsSep "," (lib.splitString "." addr)}}"
169 else
170 let
171 inherit (lib.network.ipv6.fromString addr) address;
172 parsed = lib.map (x: "16#${x}") (lib.splitString ":" address);
173 in
174 "{${lib.concatStringsSep "," parsed}}";
175
176 configFile = format.generate "config.exs" (
177 replaceSec (
178 attrsets.updateManyAttrsByPath [
179 {
180 path = [
181 ":pleroma"
182 "Pleroma.Web.Endpoint"
183 "http"
184 "ip"
185 ];
186 update =
187 addr:
188 if isAbsolutePath addr then
189 mkTuple [
190 (mkAtom ":local")
191 addr
192 ]
193 else
194 mkRaw (erlAddr addr);
195 }
196 ] cfg.config
197 )
198 );
199
200 writeShell =
201 {
202 name,
203 text,
204 runtimeInputs ? [ ],
205 }:
206 pkgs.writeShellApplication { inherit name text runtimeInputs; } + "/bin/${name}";
207
208 genScript = writeShell {
209 name = "akkoma-gen-cookie";
210 runtimeInputs = with pkgs; [
211 coreutils
212 util-linux
213 ];
214 text = ''
215 install -m 0400 \
216 -o ${escapeShellArg cfg.user} \
217 -g ${escapeShellArg cfg.group} \
218 <(hexdump -n 16 -e '"%02x"' /dev/urandom) \
219 "''${RUNTIME_DIRECTORY%%:*}/cookie"
220 '';
221 };
222
223 copyScript = writeShell {
224 name = "akkoma-copy-cookie";
225 runtimeInputs = with pkgs; [ coreutils ];
226 text = ''
227 install -m 0400 \
228 -o ${escapeShellArg cfg.user} \
229 -g ${escapeShellArg cfg.group} \
230 ${escapeShellArg cfg.dist.cookie._secret} \
231 "''${RUNTIME_DIRECTORY%%:*}/cookie"
232 '';
233 };
234
235 secretPaths = catAttrs "_secret" (collect isSecret cfg.config);
236
237 vapidKeygen = pkgs.writeText "vapidKeygen.exs" ''
238 [public_path, private_path] = System.argv()
239 {public_key, private_key} = :crypto.generate_key :ecdh, :prime256v1
240 File.write! public_path, Base.url_encode64(public_key, padding: false)
241 File.write! private_path, Base.url_encode64(private_key, padding: false)
242 '';
243
244 initSecretsScript = writeShell {
245 name = "akkoma-init-secrets";
246 runtimeInputs = with pkgs; [
247 coreutils
248 cfg.package.elixirPackage
249 ];
250 text =
251 let
252 key-base = web.secret_key_base;
253 jwt-signer = ex.":joken".":default_signer";
254 signing-salt = web.signing_salt;
255 liveview-salt = web.live_view.signing_salt;
256 vapid-private = ex.":web_push_encryption".":vapid_details".private_key;
257 vapid-public = ex.":web_push_encryption".":vapid_details".public_key;
258 in
259 ''
260 secret() {
261 # Generate default secret if non‐existent
262 test -e "$2" || install -D -m 0600 <(tr -dc 'A-Za-z-._~' </dev/urandom | head -c "$1") "$2"
263 if [ "$(stat --dereference --format='%s' "$2")" -lt "$1" ]; then
264 echo "Secret '$2' is smaller than minimum size of $1 bytes." >&2
265 exit 65
266 fi
267 }
268
269 secret 64 ${escapeShellArg key-base._secret}
270 secret 64 ${escapeShellArg jwt-signer._secret}
271 secret 8 ${escapeShellArg signing-salt._secret}
272 secret 8 ${escapeShellArg liveview-salt._secret}
273
274 ${optionalString (isSecret vapid-public) ''
275 { test -e ${escapeShellArg vapid-private._secret} && \
276 test -e ${escapeShellArg vapid-public._secret}; } || \
277 elixir ${
278 escapeShellArgs [
279 vapidKeygen
280 vapid-public._secret
281 vapid-private._secret
282 ]
283 }
284 ''}
285 '';
286 };
287
288 configScript = writeShell {
289 name = "akkoma-config";
290 runtimeInputs = with pkgs; [
291 coreutils
292 replace-secret
293 ];
294 text = ''
295 cd "''${RUNTIME_DIRECTORY%%:*}"
296 tmp="$(mktemp config.exs.XXXXXXXXXX)"
297 trap 'rm -f "$tmp"' EXIT TERM
298
299 cat ${escapeShellArg configFile} >"$tmp"
300 ${concatMapStrings (file: ''
301 replace-secret ${
302 escapeShellArgs [
303 (sha256 file)
304 file
305 ]
306 } "$tmp"
307 '') secretPaths}
308
309 chown ${escapeShellArg cfg.user}:${escapeShellArg cfg.group} "$tmp"
310 chmod 0400 "$tmp"
311 mv -f "$tmp" config.exs
312 '';
313 };
314
315 pgpass =
316 let
317 esc = escape [
318 ":"
319 ''\''
320 ];
321 in
322 if (cfg.initDb.password != null) then
323 pkgs.writeText "pgpass.conf" ''
324 *:*:*${esc cfg.initDb.username}:${esc (sha256 cfg.initDb.password._secret)}
325 ''
326 else
327 null;
328
329 escapeSqlId = x: ''"${replaceStrings [ ''"'' ] [ ''""'' ] x}"'';
330 escapeSqlStr = x: "'${replaceStrings [ "'" ] [ "''" ] x}'";
331
332 setupSql = pkgs.writeText "setup.psql" ''
333 \set ON_ERROR_STOP on
334
335 ALTER ROLE ${escapeSqlId db.username}
336 LOGIN PASSWORD ${
337 if db ? password then "${escapeSqlStr (sha256 db.password._secret)}" else "NULL"
338 };
339
340 ALTER DATABASE ${escapeSqlId db.database}
341 OWNER TO ${escapeSqlId db.username};
342
343 \connect ${escapeSqlId db.database}
344 CREATE EXTENSION IF NOT EXISTS citext;
345 CREATE EXTENSION IF NOT EXISTS pg_trgm;
346 CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
347 '';
348
349 dbHost =
350 if db ? socket_dir then
351 db.socket_dir
352 else if db ? socket then
353 db.socket
354 else if db ? hostname then
355 db.hostname
356 else
357 null;
358
359 initDbScript = writeShell {
360 name = "akkoma-initdb";
361 runtimeInputs = with pkgs; [
362 coreutils
363 replace-secret
364 config.services.postgresql.package
365 ];
366 text = ''
367 pgpass="$(mktemp -t pgpass-XXXXXXXXXX.conf)"
368 setupSql="$(mktemp -t setup-XXXXXXXXXX.psql)"
369 trap 'rm -f "$pgpass $setupSql"' EXIT TERM
370
371 ${optionalString (dbHost != null) ''
372 export PGHOST=${escapeShellArg dbHost}
373 ''}
374 export PGUSER=${escapeShellArg cfg.initDb.username}
375 ${optionalString (pgpass != null) ''
376 cat ${escapeShellArg pgpass} >"$pgpass"
377 replace-secret ${
378 escapeShellArgs [
379 (sha256 cfg.initDb.password._secret)
380 cfg.initDb.password._secret
381 ]
382 } "$pgpass"
383 export PGPASSFILE="$pgpass"
384 ''}
385
386 cat ${escapeShellArg setupSql} >"$setupSql"
387 ${optionalString (db ? password) ''
388 replace-secret ${
389 escapeShellArgs [
390 (sha256 db.password._secret)
391 db.password._secret
392 ]
393 } "$setupSql"
394 ''}
395
396 # Create role if non‐existent
397 psql -tAc "SELECT 1 FROM pg_roles
398 WHERE rolname = "${escapeShellArg (escapeSqlStr db.username)} | grep -F -q 1 || \
399 psql -tAc "CREATE ROLE "${escapeShellArg (escapeSqlId db.username)}
400
401 # Create database if non‐existent
402 psql -tAc "SELECT 1 FROM pg_database
403 WHERE datname = "${escapeShellArg (escapeSqlStr db.database)} | grep -F -q 1 || \
404 psql -tAc "CREATE DATABASE "${escapeShellArg (escapeSqlId db.database)}"
405 OWNER "${escapeShellArg (escapeSqlId db.username)}"
406 TEMPLATE template0
407 ENCODING 'utf8'
408 LOCALE 'C'"
409
410 psql -f "$setupSql"
411 '';
412 };
413
414 envWrapper =
415 let
416 script = writeShell {
417 name = "akkoma-env";
418 text = ''
419 cd "${cfg.package}"
420
421 RUNTIME_DIRECTORY="''${RUNTIME_DIRECTORY:-/run/akkoma}"
422 CACHE_DIRECTORY="''${CACHE_DIRECTORY:-/var/cache/akkoma}" \
423 AKKOMA_CONFIG_PATH="''${RUNTIME_DIRECTORY%%:*}/config.exs" \
424 ERL_EPMD_ADDRESS="${cfg.dist.address}" \
425 ERL_EPMD_PORT="${toString cfg.dist.epmdPort}" \
426 ERL_FLAGS="${
427 escapeShellArgs (
428 [
429 "-kernel"
430 "inet_dist_use_interface"
431 (erlAddr cfg.dist.address)
432 "-kernel"
433 "inet_dist_listen_min"
434 (toString cfg.dist.portMin)
435 "-kernel"
436 "inet_dist_listen_max"
437 (toString cfg.dist.portMax)
438 ]
439 ++ cfg.dist.extraFlags
440 )
441 }" \
442 RELEASE_COOKIE="$(<"''${RUNTIME_DIRECTORY%%:*}/cookie")" \
443 RELEASE_NAME="akkoma" \
444 exec "${cfg.package}/bin/$(basename "$0")" "$@"
445 '';
446 };
447 in
448 pkgs.runCommand "akkoma-env"
449 {
450 preferLocalBuild = true;
451 }
452 ''
453 mkdir -p "$out/bin"
454
455 ln -r -s ${escapeShellArg script} "$out/bin/pleroma"
456 ln -r -s ${escapeShellArg script} "$out/bin/pleroma_ctl"
457 '';
458
459 userWrapper = pkgs.writeShellApplication {
460 name = "pleroma_ctl";
461 text = ''
462 if [ "''${1-}" == "update" ]; then
463 echo "OTP releases are not supported on NixOS." >&2
464 exit 64
465 fi
466
467 exec sudo -u ${escapeShellArg cfg.user} \
468 "${envWrapper}/bin/pleroma_ctl" "$@"
469 '';
470 };
471
472 socketScript =
473 if isAbsolutePath web.http.ip then
474 writeShell {
475 name = "akkoma-socket";
476 runtimeInputs = with pkgs; [
477 coreutils
478 inotify-tools
479 ];
480 text = ''
481 coproc {
482 inotifywait -q -m -e create ${escapeShellArg (dirOf web.http.ip)}
483 }
484
485 trap 'kill "$COPROC_PID"' EXIT TERM
486
487 until test -S ${escapeShellArg web.http.ip}
488 do read -r -u "''${COPROC[0]}"
489 done
490
491 chmod 0666 ${escapeShellArg web.http.ip}
492 '';
493 }
494 else
495 null;
496
497 staticDir = ex.":pleroma".":instance".static_dir;
498 uploadDir = ex.":pleroma".":instance".upload_dir;
499
500 staticFiles =
501 pkgs.runCommand "akkoma-static"
502 {
503 preferLocalBuild = true;
504 }
505 ''
506 ${concatStringsSep "\n" (
507 mapAttrsToList (key: val: ''
508 mkdir -p $out/frontends/${escapeShellArg val.name}/
509 ln -s ${escapeShellArg val.package} $out/frontends/${escapeShellArg val.name}/${escapeShellArg val.ref}
510 '') cfg.frontends
511 )}
512
513 ${optionalString (cfg.extraStatic != null) (
514 concatStringsSep "\n" (
515 mapAttrsToList (key: val: ''
516 mkdir -p "$out/$(dirname ${escapeShellArg key})"
517 ln -s ${escapeShellArg val} $out/${escapeShellArg key}
518 '') cfg.extraStatic
519 )
520 )}
521 '';
522in
523{
524 options = {
525 services.akkoma = {
526 enable = mkEnableOption "Akkoma";
527
528 package = mkPackageOption pkgs "akkoma" { };
529
530 user = mkOption {
531 type = types.nonEmptyStr;
532 default = "akkoma";
533 description = "User account under which Akkoma runs.";
534 };
535
536 group = mkOption {
537 type = types.nonEmptyStr;
538 default = "akkoma";
539 description = "Group account under which Akkoma runs.";
540 };
541
542 initDb = {
543 enable = mkOption {
544 type = types.bool;
545 default = true;
546 description = ''
547 Whether to automatically initialise the database on startup. This will create a
548 database role and database if they do not already exist, and (re)set the role password
549 and the ownership of the database.
550
551 This setting can be used safely even if the database already exists and contains data.
552
553 The database settings are configured through
554 [{option}`config.services.akkoma.config.":pleroma"."Pleroma.Repo"`](#opt-services.akkoma.config.__pleroma_._Pleroma.Repo_).
555
556 If disabled, the database has to be set up manually:
557
558 ```SQL
559 CREATE ROLE akkoma LOGIN;
560
561 CREATE DATABASE akkoma
562 OWNER akkoma
563 TEMPLATE template0
564 ENCODING 'utf8'
565 LOCALE 'C';
566
567 \connect akkoma
568 CREATE EXTENSION IF NOT EXISTS citext;
569 CREATE EXTENSION IF NOT EXISTS pg_trgm;
570 CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
571 ```
572 '';
573 };
574
575 username = mkOption {
576 type = types.nonEmptyStr;
577 default = config.services.postgresql.superUser;
578 defaultText = literalExpression "config.services.postgresql.superUser";
579 description = ''
580 Name of the database user to initialise the database with.
581
582 This user is required to have the `CREATEROLE` and `CREATEDB` capabilities.
583 '';
584 };
585
586 password = mkOption {
587 type = types.nullOr secret;
588 default = null;
589 description = ''
590 Password of the database user to initialise the database with.
591
592 If set to `null`, no password will be used.
593
594 The attribute `_secret` should point to a file containing the secret.
595 '';
596 };
597 };
598
599 initSecrets = mkOption {
600 type = types.bool;
601 default = true;
602 description = ''
603 Whether to initialise non‐existent secrets with random values.
604
605 If enabled, appropriate secrets for the following options will be created automatically
606 if the files referenced in the `_secrets` attribute do not exist during startup.
607
608 - {option}`config.":pleroma"."Pleroma.Web.Endpoint".secret_key_base`
609 - {option}`config.":pleroma"."Pleroma.Web.Endpoint".signing_salt`
610 - {option}`config.":pleroma"."Pleroma.Web.Endpoint".live_view.signing_salt`
611 - {option}`config.":web_push_encryption".":vapid_details".private_key`
612 - {option}`config.":web_push_encryption".":vapid_details".public_key`
613 - {option}`config.":joken".":default_signer"`
614 '';
615 };
616
617 installWrapper = mkOption {
618 type = types.bool;
619 default = true;
620 description = ''
621 Whether to install a wrapper around `pleroma_ctl` to simplify administration of the
622 Akkoma instance.
623 '';
624 };
625
626 extraPackages = mkOption {
627 type = with types; listOf package;
628 default = with pkgs; [
629 exiftool
630 ffmpeg-headless
631 imagemagick
632 ];
633 defaultText = literalExpression "with pkgs; [ exiftool ffmpeg-headless imagemagick ]";
634 example = literalExpression "with pkgs; [ exiftool ffmpeg-full imagemagick ]";
635 description = ''
636 List of extra packages to include in the executable search path of the service unit.
637 These are needed by various configurable components such as:
638
639 - ExifTool for the `Pleroma.Upload.Filter.Exiftool` upload filter,
640 - ImageMagick for still image previews in the media proxy as well as for the
641 `Pleroma.Upload.Filters.Mogrify` upload filter, and
642 - ffmpeg for video previews in the media proxy.
643 '';
644 };
645
646 frontends = mkOption {
647 description = "Akkoma frontends.";
648 type = with types; attrsOf (submodule frontend);
649 default = {
650 primary = {
651 package = pkgs.akkoma-fe;
652 name = "akkoma-fe";
653 ref = "stable";
654 };
655 admin = {
656 package = pkgs.akkoma-admin-fe;
657 name = "admin-fe";
658 ref = "stable";
659 };
660 };
661 defaultText = literalExpression ''
662 {
663 primary = {
664 package = pkgs.akkoma-fe;
665 name = "akkoma-fe";
666 ref = "stable";
667 };
668 admin = {
669 package = pkgs.akkoma-admin-fe;
670 name = "admin-fe";
671 ref = "stable";
672 };
673 }
674 '';
675 };
676
677 extraStatic = mkOption {
678 type = with types; nullOr (attrsOf package);
679 description = ''
680 Attribute set of extra packages to add to the static files directory.
681
682 Do not add frontends here. These should be configured through
683 [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends).
684 '';
685 default = null;
686 example = literalExpression ''
687 {
688 "emoji/blobs.gg" = pkgs.blobs_gg;
689 "static/terms-of-service.html" = pkgs.writeText "terms-of-service.html" '''
690 …
691 ''';
692 "favicon.png" = let
693 rev = "697a8211b0f427a921e7935a35d14bb3e32d0a2c";
694 in pkgs.stdenvNoCC.mkDerivation {
695 name = "favicon.png";
696
697 src = pkgs.fetchurl {
698 url = "https://raw.githubusercontent.com/TilCreator/NixOwO/''${rev}/NixOwO_plain.svg";
699 hash = "sha256-tWhHMfJ3Od58N9H5yOKPMfM56hYWSOnr/TGCBi8bo9E=";
700 };
701
702 nativeBuildInputs = with pkgs; [ librsvg ];
703
704 dontUnpack = true;
705 installPhase = '''
706 rsvg-convert -o $out -w 96 -h 96 $src
707 ''';
708 };
709 }
710 '';
711 };
712
713 dist = {
714 address = mkOption {
715 type = ipAddress;
716 default = "127.0.0.1";
717 description = ''
718 Listen address for Erlang distribution protocol and Port Mapper Daemon (epmd).
719 '';
720 };
721
722 epmdPort = mkOption {
723 type = types.port;
724 default = 4369;
725 description = "TCP port to bind Erlang Port Mapper Daemon to.";
726 };
727
728 extraFlags = mkOption {
729 type = with types; listOf str;
730 default = [ ];
731 description = "Extra flags to pass to Erlang";
732 example = [
733 "+sbwt"
734 "none"
735 "+sbwtdcpu"
736 "none"
737 "+sbwtdio"
738 "none"
739 ];
740 };
741
742 portMin = mkOption {
743 type = types.port;
744 default = 49152;
745 description = "Lower bound for Erlang distribution protocol TCP port.";
746 };
747
748 portMax = mkOption {
749 type = types.port;
750 default = 65535;
751 description = "Upper bound for Erlang distribution protocol TCP port.";
752 };
753
754 cookie = mkOption {
755 type = types.nullOr secret;
756 default = null;
757 example = {
758 _secret = "/var/lib/secrets/akkoma/releaseCookie";
759 };
760 description = ''
761 Erlang release cookie.
762
763 If set to `null`, a temporary random cookie will be generated.
764 '';
765 };
766 };
767
768 config = mkOption {
769 description = ''
770 Configuration for Akkoma. The attributes are serialised to Elixir DSL.
771
772 Refer to <https://docs.akkoma.dev/stable/configuration/cheatsheet/> for
773 configuration options.
774
775 Settings containing secret data should be set to an attribute set containing the
776 attribute `_secret` - a string pointing to a file containing the value the option
777 should be set to.
778 '';
779 type = types.submodule {
780 freeformType = format.type;
781 options = {
782 ":pleroma" = {
783 ":instance" = {
784 name = mkOption {
785 type = types.nonEmptyStr;
786 description = "Instance name.";
787 };
788
789 email = mkOption {
790 type = types.nonEmptyStr;
791 description = "Instance administrator email.";
792 };
793
794 description = mkOption {
795 type = types.nonEmptyStr;
796 description = "Instance description.";
797 };
798
799 static_dir = mkOption {
800 type = types.path;
801 default = toString staticFiles;
802 defaultText = literalMD ''
803 Derivation gathering the following paths into a directory:
804
805 - [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends)
806 - [{option}`services.akkoma.extraStatic`](#opt-services.akkoma.extraStatic)
807 '';
808 description = ''
809 Directory of static files.
810
811 This directory can be built using a derivation, or it can be managed as mutable
812 state by setting the option to an absolute path.
813 '';
814 };
815
816 upload_dir = mkOption {
817 type = absolutePath;
818 default = "/var/lib/akkoma/uploads";
819 description = ''
820 Directory where Akkoma will put uploaded files.
821 '';
822 };
823 };
824
825 "Pleroma.Repo" = mkOption {
826 type = elixirValue;
827 default = {
828 adapter = mkRaw "Ecto.Adapters.Postgres";
829 socket_dir = "/run/postgresql";
830 username = cfg.user;
831 database = "akkoma";
832 };
833 defaultText = literalExpression ''
834 {
835 adapter = (pkgs.formats.elixirConf { }).lib.mkRaw "Ecto.Adapters.Postgres";
836 socket_dir = "/run/postgresql";
837 username = config.services.akkoma.user;
838 database = "akkoma";
839 }
840 '';
841 description = ''
842 Database configuration.
843
844 Refer to
845 <https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options>
846 for options.
847 '';
848 };
849
850 "Pleroma.Web.Endpoint" = {
851 url = {
852 host = mkOption {
853 type = types.nonEmptyStr;
854 default = config.networking.fqdn;
855 defaultText = literalExpression "config.networking.fqdn";
856 description = "Domain name of the instance.";
857 };
858
859 scheme = mkOption {
860 type = types.nonEmptyStr;
861 default = "https";
862 description = "URL scheme.";
863 };
864
865 port = mkOption {
866 type = types.port;
867 default = 443;
868 description = "External port number.";
869 };
870 };
871
872 http = {
873 ip = mkOption {
874 type = types.either absolutePath ipAddress;
875 default = "/run/akkoma/socket";
876 example = "::1";
877 description = ''
878 Listener IP address or Unix socket path.
879
880 The value is automatically converted to Elixir’s internal address
881 representation during serialisation.
882 '';
883 };
884
885 port = mkOption {
886 type = types.port;
887 default = if isAbsolutePath web.http.ip then 0 else 4000;
888 defaultText = literalExpression ''
889 if isAbsolutePath config.services.akkoma.config.:pleroma"."Pleroma.Web.Endpoint".http.ip
890 then 0
891 else 4000;
892 '';
893 description = ''
894 Listener port number.
895
896 Must be 0 if using a Unix socket.
897 '';
898 };
899 };
900
901 secret_key_base = mkOption {
902 type = secret;
903 default = {
904 _secret = "/var/lib/secrets/akkoma/key-base";
905 };
906 description = ''
907 Secret key used as a base to generate further secrets for encrypting and
908 signing data.
909
910 The attribute `_secret` should point to a file containing the secret.
911
912 This key can generated can be generated as follows:
913
914 ```ShellSession
915 $ tr -dc 'A-Za-z-._~' </dev/urandom | head -c 64
916 ```
917 '';
918 };
919
920 live_view = {
921 signing_salt = mkOption {
922 type = secret;
923 default = {
924 _secret = "/var/lib/secrets/akkoma/liveview-salt";
925 };
926 description = ''
927 LiveView signing salt.
928
929 The attribute `_secret` should point to a file containing the secret.
930
931 This salt can be generated as follows:
932
933 ```ShellSession
934 $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
935 ```
936 '';
937 };
938 };
939
940 signing_salt = mkOption {
941 type = secret;
942 default = {
943 _secret = "/var/lib/secrets/akkoma/signing-salt";
944 };
945 description = ''
946 Signing salt.
947
948 The attribute `_secret` should point to a file containing the secret.
949
950 This salt can be generated as follows:
951
952 ```ShellSession
953 $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
954 ```
955 '';
956 };
957 };
958
959 "Pleroma.Upload" =
960 let
961 httpConf = cfg.config.":pleroma"."Pleroma.Web.Endpoint".url;
962 in
963 {
964 base_url = mkOption {
965 type = types.nonEmptyStr;
966 default =
967 if versionOlder config.system.stateVersion "24.05" then
968 "${httpConf.scheme}://${httpConf.host}:${builtins.toString httpConf.port}/media/"
969 else
970 null;
971 defaultText = literalExpression ''
972 if lib.versionOlder config.system.stateVersion "24.05"
973 then "$\{httpConf.scheme}://$\{httpConf.host}:$\{builtins.toString httpConf.port}/media/"
974 else null;
975 '';
976 description = ''
977 Base path which uploads will be stored at.
978 Whilst this can just be set to a subdirectory of the main domain, it is now recommended to use a different subdomain.
979 '';
980 };
981 };
982
983 ":frontends" = mkOption {
984 type = elixirValue;
985 default = mapAttrs (
986 key: val:
987 mkMap {
988 name = val.name;
989 ref = val.ref;
990 }
991 ) cfg.frontends;
992 defaultText = literalExpression ''
993 lib.mapAttrs (key: val:
994 (pkgs.formats.elixirConf { }).lib.mkMap { name = val.name; ref = val.ref; })
995 config.services.akkoma.frontends;
996 '';
997 description = ''
998 Frontend configuration.
999
1000 Users should rely on the default value and prefer to configure frontends through
1001 [{option}`config.services.akkoma.frontends`](#opt-services.akkoma.frontends).
1002 '';
1003 };
1004
1005 ":media_proxy" =
1006 let
1007 httpConf = cfg.config.":pleroma"."Pleroma.Web.Endpoint".url;
1008 in
1009 {
1010 enabled = mkOption {
1011 type = types.bool;
1012 default = false;
1013 defaultText = literalExpression "false";
1014 description = ''
1015 Whether to enable proxying of remote media through the instance's proxy.
1016 '';
1017 };
1018 base_url = mkOption {
1019 type = types.nullOr types.nonEmptyStr;
1020 default =
1021 if versionOlder config.system.stateVersion "24.05" then
1022 "${httpConf.scheme}://${httpConf.host}:${builtins.toString httpConf.port}"
1023 else
1024 null;
1025 defaultText = literalExpression ''
1026 if lib.versionOlder config.system.stateVersion "24.05"
1027 then "$\{httpConf.scheme}://$\{httpConf.host}:$\{builtins.toString httpConf.port}"
1028 else null;
1029 '';
1030 description = ''
1031 Base path for the media proxy.
1032 Whilst this can just be set to a subdirectory of the main domain, it is now recommended to use a different subdomain.
1033 '';
1034 };
1035 };
1036
1037 };
1038
1039 ":web_push_encryption" = mkOption {
1040 default = { };
1041 description = ''
1042 Web Push Notifications configuration.
1043
1044 The necessary key pair can be generated as follows:
1045
1046 ```ShellSession
1047 $ nix-shell -p nodejs --run 'npx web-push generate-vapid-keys'
1048 ```
1049 '';
1050 type = types.submodule {
1051 freeformType = elixirValue;
1052 options = {
1053 ":vapid_details" = {
1054 subject = mkOption {
1055 type = types.nonEmptyStr;
1056 default = "mailto:${ex.":pleroma".":instance".email}";
1057 defaultText = literalExpression ''
1058 "mailto:''${config.services.akkoma.config.":pleroma".":instance".email}"
1059 '';
1060 description = "mailto URI for administrative contact.";
1061 };
1062
1063 public_key = mkOption {
1064 type = with types; either nonEmptyStr secret;
1065 default = {
1066 _secret = "/var/lib/secrets/akkoma/vapid-public";
1067 };
1068 description = "base64-encoded public ECDH key.";
1069 };
1070
1071 private_key = mkOption {
1072 type = secret;
1073 default = {
1074 _secret = "/var/lib/secrets/akkoma/vapid-private";
1075 };
1076 description = ''
1077 base64-encoded private ECDH key.
1078
1079 The attribute `_secret` should point to a file containing the secret.
1080 '';
1081 };
1082 };
1083 };
1084 };
1085 };
1086
1087 ":joken" = {
1088 ":default_signer" = mkOption {
1089 type = secret;
1090 default = {
1091 _secret = "/var/lib/secrets/akkoma/jwt-signer";
1092 };
1093 description = ''
1094 JWT signing secret.
1095
1096 The attribute `_secret` should point to a file containing the secret.
1097
1098 This secret can be generated as follows:
1099
1100 ```ShellSession
1101 $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 64
1102 ```
1103 '';
1104 };
1105 };
1106
1107 ":logger" = {
1108 ":backends" = mkOption {
1109 type = types.listOf elixirValue;
1110 visible = false;
1111 default = [
1112 (mkTuple [
1113 (mkRaw "ExSyslogger")
1114 (mkAtom ":ex_syslogger")
1115 ])
1116 ];
1117 };
1118
1119 ":ex_syslogger" = {
1120 ident = mkOption {
1121 type = types.str;
1122 visible = false;
1123 default = "akkoma";
1124 };
1125
1126 level = mkOption {
1127 type = types.nonEmptyStr;
1128 apply = mkAtom;
1129 default = ":info";
1130 example = ":warning";
1131 description = ''
1132 Log level.
1133
1134 Refer to
1135 <https://hexdocs.pm/logger/Logger.html#module-levels>
1136 for options.
1137 '';
1138 };
1139 };
1140 };
1141
1142 ":tzdata" = {
1143 ":data_dir" = mkOption {
1144 type = elixirValue;
1145 internal = true;
1146 default = mkRaw ''
1147 Path.join(System.fetch_env!("CACHE_DIRECTORY"), "tzdata")
1148 '';
1149 };
1150 };
1151 };
1152 };
1153 };
1154
1155 nginx = mkOption {
1156 type =
1157 with types;
1158 nullOr (submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }));
1159 default = null;
1160 description = ''
1161 Extra configuration for the nginx virtual host of Akkoma.
1162
1163 If set to `null`, no virtual host will be added to the nginx configuration.
1164 '';
1165 };
1166 };
1167 };
1168
1169 config = mkIf cfg.enable {
1170 assertions =
1171 optionals
1172 (
1173 cfg.config.":pleroma".":media_proxy".enabled
1174 && cfg.config.":pleroma".":media_proxy".base_url == null
1175 )
1176 [
1177 ''
1178 `services.akkoma.config.":pleroma".":media_proxy".base_url` must be set when the media proxy is enabled.
1179 ''
1180 ];
1181 warnings =
1182 optionals (with config.security; cfg.installWrapper && (!sudo.enable) && (!sudo-rs.enable))
1183 [
1184 ''
1185 The pleroma_ctl wrapper enabled by the installWrapper option relies on
1186 sudo, which appears to have been disabled through security.sudo.enable.
1187 ''
1188 ];
1189
1190 users = {
1191 users."${cfg.user}" = {
1192 description = "Akkoma user";
1193 group = cfg.group;
1194 isSystemUser = true;
1195 };
1196 groups."${cfg.group}" = { };
1197 };
1198
1199 # Confinement of the main service unit requires separation of the
1200 # configuration generation into a separate unit to permit access to secrets
1201 # residing outside of the chroot.
1202 systemd.services.akkoma-config = {
1203 description = "Akkoma social network configuration";
1204 reloadTriggers = [ configFile ] ++ secretPaths;
1205
1206 unitConfig.PropagatesReloadTo = [ "akkoma.service" ];
1207 serviceConfig = {
1208 Type = "oneshot";
1209 RemainAfterExit = true;
1210 UMask = "0077";
1211
1212 RuntimeDirectory = mkBefore "akkoma";
1213
1214 ExecStart = mkMerge [
1215 (mkIf (cfg.dist.cookie == null) [ genScript ])
1216 (mkIf (cfg.dist.cookie != null) [ copyScript ])
1217 (mkIf cfg.initSecrets [ initSecretsScript ])
1218 [ configScript ]
1219 ];
1220
1221 ExecReload = mkMerge [
1222 (mkIf cfg.initSecrets [ initSecretsScript ])
1223 [ configScript ]
1224 ];
1225 };
1226 };
1227
1228 systemd.services.akkoma-initdb = mkIf cfg.initDb.enable {
1229 description = "Akkoma social network database setup";
1230 requires = [ "akkoma-config.service" ];
1231 requiredBy = [ "akkoma.service" ];
1232 after = [
1233 "akkoma-config.service"
1234 "postgresql.service"
1235 ];
1236 before = [ "akkoma.service" ];
1237
1238 serviceConfig = {
1239 Type = "oneshot";
1240 User = mkIf (db ? socket_dir || db ? socket) cfg.initDb.username;
1241 RemainAfterExit = true;
1242 UMask = "0077";
1243 ExecStart = initDbScript;
1244 PrivateTmp = true;
1245 };
1246 };
1247
1248 systemd.services.akkoma =
1249 let
1250 runtimeInputs =
1251 with pkgs;
1252 [
1253 coreutils
1254 gawk
1255 gnused
1256 ]
1257 ++ cfg.extraPackages;
1258 in
1259 {
1260 description = "Akkoma social network";
1261 documentation = [ "https://docs.akkoma.dev/stable/" ];
1262
1263 # This service depends on network-online.target and is sequenced after
1264 # it because it requires access to the Internet to function properly.
1265 bindsTo = [ "akkoma-config.service" ];
1266 wants = [ "network-online.target" ];
1267 wantedBy = [ "multi-user.target" ];
1268 after = [
1269 "akkoma-config.target"
1270 "network.target"
1271 "network-online.target"
1272 "postgresql.service"
1273 ];
1274
1275 confinement.packages = mkIf isConfined runtimeInputs;
1276 path = runtimeInputs;
1277
1278 serviceConfig = {
1279 Type = "exec";
1280 User = cfg.user;
1281 Group = cfg.group;
1282 UMask = "0077";
1283
1284 # The run‐time directory is preserved as it is managed by the akkoma-config.service unit.
1285 RuntimeDirectory = "akkoma";
1286 RuntimeDirectoryPreserve = true;
1287
1288 CacheDirectory = "akkoma";
1289
1290 BindPaths = [ "${uploadDir}:${uploadDir}:norbind" ];
1291 BindReadOnlyPaths = mkMerge [
1292 (mkIf (!isStorePath staticDir) [ "${staticDir}:${staticDir}:norbind" ])
1293 (mkIf isConfined (mkMerge [
1294 [
1295 "/etc/hosts"
1296 "/etc/resolv.conf"
1297 ]
1298 (mkIf (isStorePath staticDir) (
1299 map (dir: "${dir}:${dir}:norbind") (
1300 splitString "\n" (readFile ((pkgs.closureInfo { rootPaths = staticDir; }) + "/store-paths"))
1301 )
1302 ))
1303 (mkIf (db ? socket_dir) [ "${db.socket_dir}:${db.socket_dir}:norbind" ])
1304 (mkIf (db ? socket) [ "${db.socket}:${db.socket}:norbind" ])
1305 ]))
1306 ];
1307
1308 ExecStartPre = "${envWrapper}/bin/pleroma_ctl migrate";
1309 ExecStart = "${envWrapper}/bin/pleroma start";
1310 ExecStartPost = socketScript;
1311 ExecStop = "${envWrapper}/bin/pleroma stop";
1312 ExecStopPost = mkIf (isAbsolutePath web.http.ip) "${pkgs.coreutils}/bin/rm -f '${web.http.ip}'";
1313
1314 ProtectProc = "noaccess";
1315 ProcSubset = "pid";
1316 ProtectSystem = "strict";
1317 ProtectHome = true;
1318 PrivateTmp = true;
1319 PrivateDevices = true;
1320 PrivateIPC = true;
1321 ProtectHostname = true;
1322 ProtectClock = true;
1323 ProtectKernelTunables = true;
1324 ProtectKernelModules = true;
1325 ProtectKernelLogs = true;
1326 ProtectControlGroups = true;
1327
1328 RestrictAddressFamilies = [
1329 "AF_UNIX"
1330 "AF_INET"
1331 "AF_INET6"
1332 ];
1333 RestrictNamespaces = true;
1334 LockPersonality = true;
1335 RestrictRealtime = true;
1336 RestrictSUIDSGID = true;
1337 RemoveIPC = true;
1338
1339 CapabilityBoundingSet = mkIf (any (port: port > 0 && port < 1024) [
1340 web.http.port
1341 cfg.dist.epmdPort
1342 cfg.dist.portMin
1343 ]) [ "CAP_NET_BIND_SERVICE" ];
1344
1345 NoNewPrivileges = true;
1346 SystemCallFilter = [
1347 "@system-service"
1348 "~@privileged"
1349 "@chown"
1350 ];
1351 SystemCallArchitectures = "native";
1352
1353 DeviceAllow = null;
1354 DevicePolicy = "closed";
1355
1356 # SMTP adapter uses dynamic port 0 binding, which is incompatible with bind address filtering
1357 SocketBindAllow = mkIf (!hasSmtp) (mkMerge [
1358 [
1359 "tcp:${toString cfg.dist.epmdPort}"
1360 "tcp:${toString cfg.dist.portMin}-${toString cfg.dist.portMax}"
1361 ]
1362 (mkIf (web.http.port != 0) [ "tcp:${toString web.http.port}" ])
1363 ]);
1364 SocketBindDeny = mkIf (!hasSmtp) "any";
1365 };
1366 };
1367
1368 systemd.tmpfiles.rules = [
1369 "d ${uploadDir} 0700 ${cfg.user} ${cfg.group} - -"
1370 "Z ${uploadDir} ~0700 ${cfg.user} ${cfg.group} - -"
1371 ];
1372
1373 environment.systemPackages = mkIf (cfg.installWrapper) [ userWrapper ];
1374
1375 services.nginx.virtualHosts = mkIf (cfg.nginx != null) {
1376 ${web.url.host} = mkMerge [
1377 cfg.nginx
1378 {
1379 locations."/" = {
1380 proxyPass =
1381 if isAbsolutePath web.http.ip then
1382 "http://unix:${web.http.ip}"
1383 else if hasInfix ":" web.http.ip then
1384 "http://[${web.http.ip}]:${toString web.http.port}"
1385 else
1386 "http://${web.http.ip}:${toString web.http.port}";
1387
1388 proxyWebsockets = true;
1389 recommendedProxySettings = true;
1390 };
1391 }
1392 ];
1393 };
1394 };
1395
1396 meta.maintainers = with lib.maintainers; [ mvs ];
1397 meta.doc = ./akkoma.md;
1398}