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