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 = "Akkoma frontend package.";
54 example = literalExpression "pkgs.akkoma-frontends.akkoma-fe";
55 };
56
57 name = mkOption {
58 type = types.nonEmptyStr;
59 description = "Akkoma frontend name.";
60 example = "akkoma-fe";
61 };
62
63 ref = mkOption {
64 type = types.nonEmptyStr;
65 description = "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 = [ cfg.package.elixirPackage ];
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 { elixir = cfg.package.elixirPackage; };
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 cfg.package.elixirPackage ];
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=${lib.escapeShellArg (lib.escapeShellArgs ([
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 ] ++ cfg.dist.extraFlags))} \
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 "Akkoma";
354
355 package = mkPackageOption pkgs "akkoma" { };
356
357 user = mkOption {
358 type = types.nonEmptyStr;
359 default = "akkoma";
360 description = "User account under which Akkoma runs.";
361 };
362
363 group = mkOption {
364 type = types.nonEmptyStr;
365 default = "akkoma";
366 description = "Group account under which Akkoma runs.";
367 };
368
369 initDb = {
370 enable = mkOption {
371 type = types.bool;
372 default = true;
373 description = ''
374 Whether to automatically initialise the database on startup. This will create a
375 database role and database if they do not already exist, and (re)set the role password
376 and the ownership of the database.
377
378 This setting can be used safely even if the database already exists and contains data.
379
380 The database settings are configured through
381 [{option}`config.services.akkoma.config.":pleroma"."Pleroma.Repo"`](#opt-services.akkoma.config.__pleroma_._Pleroma.Repo_).
382
383 If disabled, the database has to be set up manually:
384
385 ```SQL
386 CREATE ROLE akkoma LOGIN;
387
388 CREATE DATABASE akkoma
389 OWNER akkoma
390 TEMPLATE template0
391 ENCODING 'utf8'
392 LOCALE 'C';
393
394 \connect akkoma
395 CREATE EXTENSION IF NOT EXISTS citext;
396 CREATE EXTENSION IF NOT EXISTS pg_trgm;
397 CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
398 ```
399 '';
400 };
401
402 username = mkOption {
403 type = types.nonEmptyStr;
404 default = config.services.postgresql.superUser;
405 defaultText = literalExpression "config.services.postgresql.superUser";
406 description = ''
407 Name of the database user to initialise the database with.
408
409 This user is required to have the `CREATEROLE` and `CREATEDB` capabilities.
410 '';
411 };
412
413 password = mkOption {
414 type = types.nullOr secret;
415 default = null;
416 description = ''
417 Password of the database user to initialise the database with.
418
419 If set to `null`, no password will be used.
420
421 The attribute `_secret` should point to a file containing the secret.
422 '';
423 };
424 };
425
426 initSecrets = mkOption {
427 type = types.bool;
428 default = true;
429 description = ''
430 Whether to initialise non‐existent secrets with random values.
431
432 If enabled, appropriate secrets for the following options will be created automatically
433 if the files referenced in the `_secrets` attribute do not exist during startup.
434
435 - {option}`config.":pleroma"."Pleroma.Web.Endpoint".secret_key_base`
436 - {option}`config.":pleroma"."Pleroma.Web.Endpoint".signing_salt`
437 - {option}`config.":pleroma"."Pleroma.Web.Endpoint".live_view.signing_salt`
438 - {option}`config.":web_push_encryption".":vapid_details".private_key`
439 - {option}`config.":web_push_encryption".":vapid_details".public_key`
440 - {option}`config.":joken".":default_signer"`
441 '';
442 };
443
444 installWrapper = mkOption {
445 type = types.bool;
446 default = true;
447 description = ''
448 Whether to install a wrapper around `pleroma_ctl` to simplify administration of the
449 Akkoma instance.
450 '';
451 };
452
453 extraPackages = mkOption {
454 type = with types; listOf package;
455 default = with pkgs; [ exiftool ffmpeg_5-headless graphicsmagick-imagemagick-compat ];
456 defaultText = literalExpression "with pkgs; [ exiftool graphicsmagick-imagemagick-compat ffmpeg_5-headless ]";
457 example = literalExpression "with pkgs; [ exiftool imagemagick ffmpeg_5-full ]";
458 description = ''
459 List of extra packages to include in the executable search path of the service unit.
460 These are needed by various configurable components such as:
461
462 - ExifTool for the `Pleroma.Upload.Filter.Exiftool` upload filter,
463 - ImageMagick for still image previews in the media proxy as well as for the
464 `Pleroma.Upload.Filters.Mogrify` upload filter, and
465 - ffmpeg for video previews in the media proxy.
466 '';
467 };
468
469 frontends = mkOption {
470 description = "Akkoma frontends.";
471 type = with types; attrsOf (submodule frontend);
472 default = {
473 primary = {
474 package = pkgs.akkoma-frontends.akkoma-fe;
475 name = "akkoma-fe";
476 ref = "stable";
477 };
478 admin = {
479 package = pkgs.akkoma-frontends.admin-fe;
480 name = "admin-fe";
481 ref = "stable";
482 };
483 };
484 defaultText = literalExpression ''
485 {
486 primary = {
487 package = pkgs.akkoma-frontends.akkoma-fe;
488 name = "akkoma-fe";
489 ref = "stable";
490 };
491 admin = {
492 package = pkgs.akkoma-frontends.admin-fe;
493 name = "admin-fe";
494 ref = "stable";
495 };
496 }
497 '';
498 };
499
500 extraStatic = mkOption {
501 type = with types; nullOr (attrsOf package);
502 description = ''
503 Attribute set of extra packages to add to the static files directory.
504
505 Do not add frontends here. These should be configured through
506 [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends).
507 '';
508 default = null;
509 example = literalExpression ''
510 {
511 "emoji/blobs.gg" = pkgs.akkoma-emoji.blobs_gg;
512 "static/terms-of-service.html" = pkgs.writeText "terms-of-service.html" '''
513 …
514 ''';
515 "favicon.png" = let
516 rev = "697a8211b0f427a921e7935a35d14bb3e32d0a2c";
517 in pkgs.stdenvNoCC.mkDerivation {
518 name = "favicon.png";
519
520 src = pkgs.fetchurl {
521 url = "https://raw.githubusercontent.com/TilCreator/NixOwO/''${rev}/NixOwO_plain.svg";
522 hash = "sha256-tWhHMfJ3Od58N9H5yOKPMfM56hYWSOnr/TGCBi8bo9E=";
523 };
524
525 nativeBuildInputs = with pkgs; [ librsvg ];
526
527 dontUnpack = true;
528 installPhase = '''
529 rsvg-convert -o $out -w 96 -h 96 $src
530 ''';
531 };
532 }
533 '';
534 };
535
536 dist = {
537 address = mkOption {
538 type = ipAddress;
539 default = "127.0.0.1";
540 description = ''
541 Listen address for Erlang distribution protocol and Port Mapper Daemon (epmd).
542 '';
543 };
544
545 epmdPort = mkOption {
546 type = types.port;
547 default = 4369;
548 description = "TCP port to bind Erlang Port Mapper Daemon to.";
549 };
550
551 extraFlags = mkOption {
552 type = with types; listOf str;
553 default = [ ];
554 description = "Extra flags to pass to Erlang";
555 example = [ "+sbwt" "none" "+sbwtdcpu" "none" "+sbwtdio" "none" ];
556 };
557
558 portMin = mkOption {
559 type = types.port;
560 default = 49152;
561 description = "Lower bound for Erlang distribution protocol TCP port.";
562 };
563
564 portMax = mkOption {
565 type = types.port;
566 default = 65535;
567 description = "Upper bound for Erlang distribution protocol TCP port.";
568 };
569
570 cookie = mkOption {
571 type = types.nullOr secret;
572 default = null;
573 example = { _secret = "/var/lib/secrets/akkoma/releaseCookie"; };
574 description = ''
575 Erlang release cookie.
576
577 If set to `null`, a temporary random cookie will be generated.
578 '';
579 };
580 };
581
582 config = mkOption {
583 description = ''
584 Configuration for Akkoma. The attributes are serialised to Elixir DSL.
585
586 Refer to <https://docs.akkoma.dev/stable/configuration/cheatsheet/> for
587 configuration options.
588
589 Settings containing secret data should be set to an attribute set containing the
590 attribute `_secret` - a string pointing to a file containing the value the option
591 should be set to.
592 '';
593 type = types.submodule {
594 freeformType = format.type;
595 options = {
596 ":pleroma" = {
597 ":instance" = {
598 name = mkOption {
599 type = types.nonEmptyStr;
600 description = "Instance name.";
601 };
602
603 email = mkOption {
604 type = types.nonEmptyStr;
605 description = "Instance administrator email.";
606 };
607
608 description = mkOption {
609 type = types.nonEmptyStr;
610 description = "Instance description.";
611 };
612
613 static_dir = mkOption {
614 type = types.path;
615 default = toString staticFiles;
616 defaultText = literalMD ''
617 Derivation gathering the following paths into a directory:
618
619 - [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends)
620 - [{option}`services.akkoma.extraStatic`](#opt-services.akkoma.extraStatic)
621 '';
622 description = ''
623 Directory of static files.
624
625 This directory can be built using a derivation, or it can be managed as mutable
626 state by setting the option to an absolute path.
627 '';
628 };
629
630 upload_dir = mkOption {
631 type = absolutePath;
632 default = "/var/lib/akkoma/uploads";
633 description = ''
634 Directory where Akkoma will put uploaded files.
635 '';
636 };
637 };
638
639 "Pleroma.Repo" = mkOption {
640 type = elixirValue;
641 default = {
642 adapter = format.lib.mkRaw "Ecto.Adapters.Postgres";
643 socket_dir = "/run/postgresql";
644 username = cfg.user;
645 database = "akkoma";
646 };
647 defaultText = literalExpression ''
648 {
649 adapter = (pkgs.formats.elixirConf { }).lib.mkRaw "Ecto.Adapters.Postgres";
650 socket_dir = "/run/postgresql";
651 username = config.services.akkoma.user;
652 database = "akkoma";
653 }
654 '';
655 description = ''
656 Database configuration.
657
658 Refer to
659 <https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options>
660 for options.
661 '';
662 };
663
664 "Pleroma.Web.Endpoint" = {
665 url = {
666 host = mkOption {
667 type = types.nonEmptyStr;
668 default = config.networking.fqdn;
669 defaultText = literalExpression "config.networking.fqdn";
670 description = "Domain name of the instance.";
671 };
672
673 scheme = mkOption {
674 type = types.nonEmptyStr;
675 default = "https";
676 description = "URL scheme.";
677 };
678
679 port = mkOption {
680 type = types.port;
681 default = 443;
682 description = "External port number.";
683 };
684 };
685
686 http = {
687 ip = mkOption {
688 type = types.either absolutePath ipAddress;
689 default = "/run/akkoma/socket";
690 example = "::1";
691 description = ''
692 Listener IP address or Unix socket path.
693
694 The value is automatically converted to Elixir’s internal address
695 representation during serialisation.
696 '';
697 };
698
699 port = mkOption {
700 type = types.port;
701 default = if isAbsolutePath web.http.ip then 0 else 4000;
702 defaultText = literalExpression ''
703 if isAbsolutePath config.services.akkoma.config.:pleroma"."Pleroma.Web.Endpoint".http.ip
704 then 0
705 else 4000;
706 '';
707 description = ''
708 Listener port number.
709
710 Must be 0 if using a Unix socket.
711 '';
712 };
713 };
714
715 secret_key_base = mkOption {
716 type = secret;
717 default = { _secret = "/var/lib/secrets/akkoma/key-base"; };
718 description = ''
719 Secret key used as a base to generate further secrets for encrypting and
720 signing data.
721
722 The attribute `_secret` should point to a file containing the secret.
723
724 This key can generated can be generated as follows:
725
726 ```ShellSession
727 $ tr -dc 'A-Za-z-._~' </dev/urandom | head -c 64
728 ```
729 '';
730 };
731
732 live_view = {
733 signing_salt = mkOption {
734 type = secret;
735 default = { _secret = "/var/lib/secrets/akkoma/liveview-salt"; };
736 description = ''
737 LiveView signing salt.
738
739 The attribute `_secret` should point to a file containing the secret.
740
741 This salt can be generated as follows:
742
743 ```ShellSession
744 $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
745 ```
746 '';
747 };
748 };
749
750 signing_salt = mkOption {
751 type = secret;
752 default = { _secret = "/var/lib/secrets/akkoma/signing-salt"; };
753 description = ''
754 Signing salt.
755
756 The attribute `_secret` should point to a file containing the secret.
757
758 This salt can be generated as follows:
759
760 ```ShellSession
761 $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
762 ```
763 '';
764 };
765 };
766
767 "Pleroma.Upload" = let
768 httpConf = cfg.config.":pleroma"."Pleroma.Web.Endpoint".url;
769 in {
770 base_url = mkOption {
771 type = types.nonEmptyStr;
772 default = if lib.versionOlder config.system.stateVersion "24.05"
773 then "${httpConf.scheme}://${httpConf.host}:${builtins.toString httpConf.port}/media/"
774 else null;
775 defaultText = literalExpression ''
776 if lib.versionOlder config.system.stateVersion "24.05"
777 then "$\{httpConf.scheme}://$\{httpConf.host}:$\{builtins.toString httpConf.port}/media/"
778 else null;
779 '';
780 description = ''
781 Base path which uploads will be stored at.
782 Whilst this can just be set to a subdirectory of the main domain, it is now recommended to use a different subdomain.
783 '';
784 };
785 };
786
787 ":frontends" = mkOption {
788 type = elixirValue;
789 default = mapAttrs
790 (key: val: format.lib.mkMap { name = val.name; ref = val.ref; })
791 cfg.frontends;
792 defaultText = literalExpression ''
793 lib.mapAttrs (key: val:
794 (pkgs.formats.elixirConf { }).lib.mkMap { name = val.name; ref = val.ref; })
795 config.services.akkoma.frontends;
796 '';
797 description = ''
798 Frontend configuration.
799
800 Users should rely on the default value and prefer to configure frontends through
801 [{option}`config.services.akkoma.frontends`](#opt-services.akkoma.frontends).
802 '';
803 };
804
805
806 ":media_proxy" = let
807 httpConf = cfg.config.":pleroma"."Pleroma.Web.Endpoint".url;
808 in {
809 enabled = mkOption {
810 type = types.bool;
811 default = false;
812 defaultText = literalExpression "false";
813 description = ''
814 Whether to enable proxying of remote media through the instance's proxy.
815 '';
816 };
817 base_url = mkOption {
818 type = types.nullOr types.nonEmptyStr;
819 default = if lib.versionOlder config.system.stateVersion "24.05"
820 then "${httpConf.scheme}://${httpConf.host}:${builtins.toString httpConf.port}"
821 else null;
822 defaultText = literalExpression ''
823 if lib.versionOlder config.system.stateVersion "24.05"
824 then "$\{httpConf.scheme}://$\{httpConf.host}:$\{builtins.toString httpConf.port}"
825 else null;
826 '';
827 description = ''
828 Base path for the media proxy.
829 Whilst this can just be set to a subdirectory of the main domain, it is now recommended to use a different subdomain.
830 '';
831 };
832 };
833
834 };
835
836 ":web_push_encryption" = mkOption {
837 default = { };
838 description = ''
839 Web Push Notifications configuration.
840
841 The necessary key pair can be generated as follows:
842
843 ```ShellSession
844 $ nix-shell -p nodejs --run 'npx web-push generate-vapid-keys'
845 ```
846 '';
847 type = types.submodule {
848 freeformType = elixirValue;
849 options = {
850 ":vapid_details" = {
851 subject = mkOption {
852 type = types.nonEmptyStr;
853 default = "mailto:${ex.":pleroma".":instance".email}";
854 defaultText = literalExpression ''
855 "mailto:''${config.services.akkoma.config.":pleroma".":instance".email}"
856 '';
857 description = "mailto URI for administrative contact.";
858 };
859
860 public_key = mkOption {
861 type = with types; either nonEmptyStr secret;
862 default = { _secret = "/var/lib/secrets/akkoma/vapid-public"; };
863 description = "base64-encoded public ECDH key.";
864 };
865
866 private_key = mkOption {
867 type = secret;
868 default = { _secret = "/var/lib/secrets/akkoma/vapid-private"; };
869 description = ''
870 base64-encoded private ECDH key.
871
872 The attribute `_secret` should point to a file containing the secret.
873 '';
874 };
875 };
876 };
877 };
878 };
879
880 ":joken" = {
881 ":default_signer" = mkOption {
882 type = secret;
883 default = { _secret = "/var/lib/secrets/akkoma/jwt-signer"; };
884 description = ''
885 JWT signing secret.
886
887 The attribute `_secret` should point to a file containing the secret.
888
889 This secret can be generated as follows:
890
891 ```ShellSession
892 $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 64
893 ```
894 '';
895 };
896 };
897
898 ":logger" = {
899 ":backends" = mkOption {
900 type = types.listOf elixirValue;
901 visible = false;
902 default = with format.lib; [
903 (mkTuple [ (mkRaw "ExSyslogger") (mkAtom ":ex_syslogger") ])
904 ];
905 };
906
907 ":ex_syslogger" = {
908 ident = mkOption {
909 type = types.str;
910 visible = false;
911 default = "akkoma";
912 };
913
914 level = mkOption {
915 type = types.nonEmptyStr;
916 apply = format.lib.mkAtom;
917 default = ":info";
918 example = ":warning";
919 description = ''
920 Log level.
921
922 Refer to
923 <https://hexdocs.pm/logger/Logger.html#module-levels>
924 for options.
925 '';
926 };
927 };
928 };
929
930 ":tzdata" = {
931 ":data_dir" = mkOption {
932 type = elixirValue;
933 internal = true;
934 default = format.lib.mkRaw ''
935 Path.join(System.fetch_env!("CACHE_DIRECTORY"), "tzdata")
936 '';
937 };
938 };
939 };
940 };
941 };
942
943 nginx = mkOption {
944 type = with types; nullOr (submodule
945 (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }));
946 default = null;
947 description = ''
948 Extra configuration for the nginx virtual host of Akkoma.
949
950 If set to `null`, no virtual host will be added to the nginx configuration.
951 '';
952 };
953 };
954 };
955
956 config = mkIf cfg.enable {
957 assertions = optionals (cfg.config.":pleroma".":media_proxy".enabled && cfg.config.":pleroma".":media_proxy".base_url == null) [''
958 `services.akkoma.config.":pleroma".":media_proxy".base_url` must be set when the media proxy is enabled.
959 ''];
960 warnings = optionals (with config.security; cfg.installWrapper && (!sudo.enable) && (!sudo-rs.enable)) [''
961 The pleroma_ctl wrapper enabled by the installWrapper option relies on
962 sudo, which appears to have been disabled through security.sudo.enable.
963 ''];
964
965 users = {
966 users."${cfg.user}" = {
967 description = "Akkoma user";
968 group = cfg.group;
969 isSystemUser = true;
970 };
971 groups."${cfg.group}" = { };
972 };
973
974 # Confinement of the main service unit requires separation of the
975 # configuration generation into a separate unit to permit access to secrets
976 # residing outside of the chroot.
977 systemd.services.akkoma-config = {
978 description = "Akkoma social network configuration";
979 reloadTriggers = [ configFile ] ++ secretPaths;
980
981 unitConfig.PropagatesReloadTo = [ "akkoma.service" ];
982 serviceConfig = {
983 Type = "oneshot";
984 RemainAfterExit = true;
985 UMask = "0077";
986
987 RuntimeDirectory = "akkoma";
988
989 ExecStart = mkMerge [
990 (mkIf (cfg.dist.cookie == null) [ genScript ])
991 (mkIf (cfg.dist.cookie != null) [ copyScript ])
992 (mkIf cfg.initSecrets [ initSecretsScript ])
993 [ configScript ]
994 ];
995
996 ExecReload = mkMerge [
997 (mkIf cfg.initSecrets [ initSecretsScript ])
998 [ configScript ]
999 ];
1000 };
1001 };
1002
1003 systemd.services.akkoma-initdb = mkIf cfg.initDb.enable {
1004 description = "Akkoma social network database setup";
1005 requires = [ "akkoma-config.service" ];
1006 requiredBy = [ "akkoma.service" ];
1007 after = [ "akkoma-config.service" "postgresql.service" ];
1008 before = [ "akkoma.service" ];
1009
1010 serviceConfig = {
1011 Type = "oneshot";
1012 User = mkIf (db ? socket_dir || db ? socket)
1013 cfg.initDb.username;
1014 RemainAfterExit = true;
1015 UMask = "0077";
1016 ExecStart = initDbScript;
1017 PrivateTmp = true;
1018 };
1019 };
1020
1021 systemd.services.akkoma = let
1022 runtimeInputs = with pkgs; [ coreutils gawk gnused ] ++ cfg.extraPackages;
1023 in {
1024 description = "Akkoma social network";
1025 documentation = [ "https://docs.akkoma.dev/stable/" ];
1026
1027 # This service depends on network-online.target and is sequenced after
1028 # it because it requires access to the Internet to function properly.
1029 bindsTo = [ "akkoma-config.service" ];
1030 wants = [ "network-online.target" ];
1031 wantedBy = [ "multi-user.target" ];
1032 after = [
1033 "akkoma-config.target"
1034 "network.target"
1035 "network-online.target"
1036 "postgresql.service"
1037 ];
1038
1039 confinement.packages = mkIf isConfined runtimeInputs;
1040 path = runtimeInputs;
1041
1042 serviceConfig = {
1043 Type = "exec";
1044 User = cfg.user;
1045 Group = cfg.group;
1046 UMask = "0077";
1047
1048 # The run‐time directory is preserved as it is managed by the akkoma-config.service unit.
1049 RuntimeDirectory = "akkoma";
1050 RuntimeDirectoryPreserve = true;
1051
1052 CacheDirectory = "akkoma";
1053
1054 BindPaths = [ "${uploadDir}:${uploadDir}:norbind" ];
1055 BindReadOnlyPaths = mkMerge [
1056 (mkIf (!isStorePath staticDir) [ "${staticDir}:${staticDir}:norbind" ])
1057 (mkIf isConfined (mkMerge [
1058 [ "/etc/hosts" "/etc/resolv.conf" ]
1059 (mkIf (isStorePath staticDir) (map (dir: "${dir}:${dir}:norbind")
1060 (splitString "\n" (readFile ((pkgs.closureInfo { rootPaths = staticDir; }) + "/store-paths")))))
1061 (mkIf (db ? socket_dir) [ "${db.socket_dir}:${db.socket_dir}:norbind" ])
1062 (mkIf (db ? socket) [ "${db.socket}:${db.socket}:norbind" ])
1063 ]))
1064 ];
1065
1066 ExecStartPre = "${envWrapper}/bin/pleroma_ctl migrate";
1067 ExecStart = "${envWrapper}/bin/pleroma start";
1068 ExecStartPost = socketScript;
1069 ExecStop = "${envWrapper}/bin/pleroma stop";
1070 ExecStopPost = mkIf (isAbsolutePath web.http.ip)
1071 "${pkgs.coreutils}/bin/rm -f '${web.http.ip}'";
1072
1073 ProtectProc = "noaccess";
1074 ProcSubset = "pid";
1075 ProtectSystem = mkIf (!isConfined) "strict";
1076 ProtectHome = true;
1077 PrivateTmp = true;
1078 PrivateDevices = true;
1079 PrivateIPC = true;
1080 ProtectHostname = true;
1081 ProtectClock = true;
1082 ProtectKernelTunables = true;
1083 ProtectKernelModules = true;
1084 ProtectKernelLogs = true;
1085 ProtectControlGroups = true;
1086
1087 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
1088 RestrictNamespaces = true;
1089 LockPersonality = true;
1090 RestrictRealtime = true;
1091 RestrictSUIDSGID = true;
1092 RemoveIPC = true;
1093
1094 CapabilityBoundingSet = mkIf
1095 (any (port: port > 0 && port < 1024)
1096 [ web.http.port cfg.dist.epmdPort cfg.dist.portMin ])
1097 [ "CAP_NET_BIND_SERVICE" ];
1098
1099 NoNewPrivileges = true;
1100 SystemCallFilter = [ "@system-service" "~@privileged" "@chown" ];
1101 SystemCallArchitectures = "native";
1102
1103 DeviceAllow = null;
1104 DevicePolicy = "closed";
1105
1106 # SMTP adapter uses dynamic port 0 binding, which is incompatible with bind address filtering
1107 SocketBindAllow = mkIf (!hasSmtp) (mkMerge [
1108 [ "tcp:${toString cfg.dist.epmdPort}" "tcp:${toString cfg.dist.portMin}-${toString cfg.dist.portMax}" ]
1109 (mkIf (web.http.port != 0) [ "tcp:${toString web.http.port}" ])
1110 ]);
1111 SocketBindDeny = mkIf (!hasSmtp) "any";
1112 };
1113 };
1114
1115 systemd.tmpfiles.rules = [
1116 "d ${uploadDir} 0700 ${cfg.user} ${cfg.group} - -"
1117 "Z ${uploadDir} ~0700 ${cfg.user} ${cfg.group} - -"
1118 ];
1119
1120 environment.systemPackages = mkIf (cfg.installWrapper) [ userWrapper ];
1121
1122 services.nginx.virtualHosts = mkIf (cfg.nginx != null) {
1123 ${web.url.host} = mkMerge [ cfg.nginx {
1124 locations."/" = {
1125 proxyPass =
1126 if isAbsolutePath web.http.ip
1127 then "http://unix:${web.http.ip}"
1128 else if hasInfix ":" web.http.ip
1129 then "http://[${web.http.ip}]:${toString web.http.port}"
1130 else "http://${web.http.ip}:${toString web.http.port}";
1131
1132 proxyWebsockets = true;
1133 recommendedProxySettings = true;
1134 };
1135 }];
1136 };
1137 };
1138
1139 meta.maintainers = with maintainers; [ mvs tcmal ];
1140 meta.doc = ./akkoma.md;
1141}