lib.packagesFromDirectoryRecursive: Improved documentation (#359898)
[NixPkgs.git] / nixos / modules / services / web-apps / akkoma.nix
blob17c221778d89a502089a9a667deadb7472b8a308
1 { config, lib, pkgs, ... }:
3 with lib;
4 let
5   cfg = config.services.akkoma;
6   ex = cfg.config;
7   db = ex.":pleroma"."Pleroma.Repo";
8   web = ex.":pleroma"."Pleroma.Web.Endpoint";
10   isConfined = config.systemd.services.akkoma.confinement.enable;
11   hasSmtp = (attrByPath [ ":pleroma" "Pleroma.Emails.Mailer" "adapter" "value" ] null ex) == "Swoosh.Adapters.SMTP";
13   isAbsolutePath = v: isString v && substring 0 1 v == "/";
14   isSecret = v: isAttrs v && v ? _secret && isAbsolutePath v._secret;
16   absolutePath = with types; mkOptionType {
17     name = "absolutePath";
18     description = "absolute path";
19     descriptionClass = "noun";
20     check = isAbsolutePath;
21     inherit (str) merge;
22   };
24   secret = mkOptionType {
25     name = "secret";
26     description = "secret value";
27     descriptionClass = "noun";
28     check = isSecret;
29     nestedTypes = {
30       _secret = absolutePath;
31     };
32   };
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   };
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';
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       };
57       name = mkOption {
58         type = types.nonEmptyStr;
59         description = "Akkoma frontend name.";
60         example = "akkoma-fe";
61       };
63       ref = mkOption {
64         type = types.nonEmptyStr;
65         description = "Akkoma frontend reference.";
66         example = "stable";
67       };
68     };
69   };
71   sha256 = builtins.hashString "sha256";
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' { };
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"'');
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));
111   writeShell = { name, text, runtimeInputs ? [ ] }:
112     pkgs.writeShellApplication { inherit name text runtimeInputs; } + "/bin/${name}";
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   };
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   };
138   secretPaths = catAttrs "_secret" (collect isSecret cfg.config);
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   '';
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       }
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}
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   };
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
188       cat ${escapeShellArg configFile} >"$tmp"
189       ${concatMapStrings (file: ''
190         replace-secret ${escapeShellArgs [ (sha256 file) file ]} "$tmp"
191       '') secretPaths}
193       chown ${escapeShellArg cfg.user}:${escapeShellArg cfg.group} "$tmp"
194       chmod 0400 "$tmp"
195       mv -f "$tmp" config.exs
196     '';
197   };
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;
207   escapeSqlId = x: ''"${replaceStrings [ ''"'' ] [ ''""'' ] x}"'';
208   escapeSqlStr = x: "'${replaceStrings [ "'" ] [ "''" ] x}'";
210   setupSql = pkgs.writeText "setup.psql" ''
211     \set ON_ERROR_STOP on
213     ALTER ROLE ${escapeSqlId db.username}
214       LOGIN PASSWORD ${if db ? password
215         then "${escapeSqlStr (sha256 db.password._secret)}"
216         else "NULL"};
218     ALTER DATABASE ${escapeSqlId db.database}
219       OWNER TO ${escapeSqlId db.username};
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   '';
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;
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
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       ''}
251       cat ${escapeShellArg setupSql} >"$setupSql"
252       ${optionalString (db ? password) ''
253         replace-secret ${escapeShellArgs [
254          (sha256 db.password._secret) db.password._secret ]} "$setupSql"
255       ''}
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)}
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'"
271       psql -f "$setupSql"
272     '';
273   };
275   envWrapper = let
276     script = writeShell {
277       name = "akkoma-env";
278       text = ''
279         cd "${cfg.package}"
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"
298     ln -r -s ${escapeShellArg script} "$out/bin/pleroma"
299     ln -r -s ${escapeShellArg script} "$out/bin/pleroma_ctl"
300   '';
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
310       exec sudo -u ${escapeShellArg cfg.user} \
311         "${envWrapper}/bin/pleroma_ctl" "$@"
312     '';
313   };
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         }
324         trap 'kill "$COPROC_PID"' EXIT TERM
326         until test -S ${escapeShellArg web.http.ip}
327           do read -r -u "''${COPROC[0]}"
328         done
330         chmod 0666 ${escapeShellArg web.http.ip}
331       '';
332     }
333     else null;
335   staticDir = ex.":pleroma".":instance".static_dir;
336   uploadDir = ex.":pleroma".":instance".upload_dir;
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)}
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   '';
350 in {
351   options = {
352     services.akkoma = {
353       enable = mkEnableOption "Akkoma";
355       package = mkPackageOption pkgs "akkoma" { };
357       user = mkOption {
358         type = types.nonEmptyStr;
359         default = "akkoma";
360         description = "User account under which Akkoma runs.";
361       };
363       group = mkOption {
364         type = types.nonEmptyStr;
365         default = "akkoma";
366         description = "Group account under which Akkoma runs.";
367       };
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.
378             This setting can be used safely even if the database already exists and contains data.
380             The database settings are configured through
381             [{option}`config.services.akkoma.config.":pleroma"."Pleroma.Repo"`](#opt-services.akkoma.config.__pleroma_._Pleroma.Repo_).
383             If disabled, the database has to be set up manually:
385             ```SQL
386             CREATE ROLE akkoma LOGIN;
388             CREATE DATABASE akkoma
389               OWNER akkoma
390               TEMPLATE template0
391               ENCODING 'utf8'
392               LOCALE 'C';
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         };
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.
409             This user is required to have the `CREATEROLE` and `CREATEDB` capabilities.
410           '';
411         };
413         password = mkOption {
414           type = types.nullOr secret;
415           default = null;
416           description = ''
417             Password of the database user to initialise the database with.
419             If set to `null`, no password will be used.
421             The attribute `_secret` should point to a file containing the secret.
422           '';
423         };
424       };
426       initSecrets = mkOption {
427         type = types.bool;
428         default = true;
429         description = ''
430           Whether to initialise non‐existent secrets with random values.
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.
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       };
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       };
453       extraPackages = mkOption {
454         type = with types; listOf package;
455         default = with pkgs; [ exiftool ffmpeg-headless graphicsmagick-imagemagick-compat ];
456         defaultText = literalExpression "with pkgs; [ exiftool ffmpeg-headless graphicsmagick-imagemagick-compat ]";
457         example = literalExpression "with pkgs; [ exiftool ffmpeg-full imagemagick ]";
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:
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       };
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       };
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.
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";
520               src = pkgs.fetchurl {
521                 url = "https://raw.githubusercontent.com/TilCreator/NixOwO/''${rev}/NixOwO_plain.svg";
522                 hash = "sha256-tWhHMfJ3Od58N9H5yOKPMfM56hYWSOnr/TGCBi8bo9E=";
523               };
525               nativeBuildInputs = with pkgs; [ librsvg ];
527               dontUnpack = true;
528               installPhase = '''
529                 rsvg-convert -o $out -w 96 -h 96 $src
530               ''';
531             };
532           }
533         '';
534       };
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         };
545         epmdPort = mkOption {
546           type = types.port;
547           default = 4369;
548           description = "TCP port to bind Erlang Port Mapper Daemon to.";
549         };
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         };
558         portMin = mkOption {
559           type = types.port;
560           default = 49152;
561           description = "Lower bound for Erlang distribution protocol TCP port.";
562         };
564         portMax = mkOption {
565           type = types.port;
566           default = 65535;
567           description = "Upper bound for Erlang distribution protocol TCP port.";
568         };
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.
577             If set to `null`, a temporary random cookie will be generated.
578           '';
579         };
580       };
582       config = mkOption {
583         description = ''
584           Configuration for Akkoma. The attributes are serialised to Elixir DSL.
586           Refer to <https://docs.akkoma.dev/stable/configuration/cheatsheet/> for
587           configuration options.
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                 };
603                 email = mkOption {
604                   type = types.nonEmptyStr;
605                   description = "Instance administrator email.";
606                 };
608                 description = mkOption {
609                   type = types.nonEmptyStr;
610                   description = "Instance description.";
611                 };
613                 static_dir = mkOption {
614                   type = types.path;
615                   default = toString staticFiles;
616                   defaultText = literalMD ''
617                     Derivation gathering the following paths into a directory:
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.
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                 };
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               };
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.
658                   Refer to
659                   <https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options>
660                   for options.
661                 '';
662               };
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                   };
673                   scheme = mkOption {
674                     type = types.nonEmptyStr;
675                     default = "https";
676                     description = "URL scheme.";
677                   };
679                   port = mkOption {
680                     type = types.port;
681                     default = 443;
682                     description = "External port number.";
683                   };
684                 };
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.
694                       The value is automatically converted to Elixir’s internal address
695                       representation during serialisation.
696                     '';
697                   };
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.
710                       Must be 0 if using a Unix socket.
711                     '';
712                   };
713                 };
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.
722                     The attribute `_secret` should point to a file containing the secret.
724                     This key can generated can be generated as follows:
726                     ```ShellSession
727                     $ tr -dc 'A-Za-z-._~' </dev/urandom | head -c 64
728                     ```
729                   '';
730                 };
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.
739                       The attribute `_secret` should point to a file containing the secret.
741                       This salt can be generated as follows:
743                       ```ShellSession
744                       $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
745                       ```
746                     '';
747                   };
748                 };
750                 signing_salt = mkOption {
751                   type = secret;
752                   default = { _secret = "/var/lib/secrets/akkoma/signing-salt"; };
753                   description = ''
754                     Signing salt.
756                     The attribute `_secret` should point to a file containing the secret.
758                     This salt can be generated as follows:
760                     ```ShellSession
761                     $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
762                     ```
763                   '';
764                 };
765               };
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               };
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.
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               };
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               };
834             };
836             ":web_push_encryption" = mkOption {
837               default = { };
838               description = ''
839                 Web Push Notifications configuration.
841                 The necessary key pair can be generated as follows:
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                     };
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                     };
866                     private_key = mkOption {
867                       type = secret;
868                       default = { _secret = "/var/lib/secrets/akkoma/vapid-private"; };
869                       description = ''
870                         base64-encoded private ECDH key.
872                         The attribute `_secret` should point to a file containing the secret.
873                       '';
874                     };
875                   };
876                 };
877               };
878             };
880             ":joken" = {
881               ":default_signer" = mkOption {
882                 type = secret;
883                 default = { _secret = "/var/lib/secrets/akkoma/jwt-signer"; };
884                 description = ''
885                   JWT signing secret.
887                   The attribute `_secret` should point to a file containing the secret.
889                   This secret can be generated as follows:
891                   ```ShellSession
892                   $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 64
893                   ```
894                 '';
895               };
896             };
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               };
907               ":ex_syslogger" = {
908                 ident = mkOption {
909                   type = types.str;
910                   visible = false;
911                   default = "akkoma";
912                 };
914                 level = mkOption {
915                   type = types.nonEmptyStr;
916                   apply = format.lib.mkAtom;
917                   default = ":info";
918                   example = ":warning";
919                   description = ''
920                     Log level.
922                     Refer to
923                     <https://hexdocs.pm/logger/Logger.html#module-levels>
924                     for options.
925                   '';
926                 };
927               };
928             };
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       };
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.
950           If set to `null`, no virtual host will be added to the nginx configuration.
951         '';
952       };
953     };
954   };
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     ''];
965     users = {
966       users."${cfg.user}" = {
967         description = "Akkoma user";
968         group = cfg.group;
969         isSystemUser = true;
970       };
971       groups."${cfg.group}" = { };
972     };
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;
981       unitConfig.PropagatesReloadTo = [ "akkoma.service" ];
982       serviceConfig = {
983         Type = "oneshot";
984         RemainAfterExit = true;
985         UMask = "0077";
987         RuntimeDirectory = mkBefore "akkoma";
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         ];
996         ExecReload = mkMerge [
997           (mkIf cfg.initSecrets [ initSecretsScript ])
998           [ configScript ]
999         ];
1000       };
1001     };
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" ];
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     };
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/" ];
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       ];
1039       confinement.packages = mkIf isConfined runtimeInputs;
1040       path = runtimeInputs;
1042       serviceConfig = {
1043         Type = "exec";
1044         User = cfg.user;
1045         Group = cfg.group;
1046         UMask = "0077";
1048         # The run‐time directory is preserved as it is managed by the akkoma-config.service unit.
1049         RuntimeDirectory = "akkoma";
1050         RuntimeDirectoryPreserve = true;
1052         CacheDirectory = "akkoma";
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         ];
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}'";
1073         ProtectProc = "noaccess";
1074         ProcSubset = "pid";
1075         ProtectSystem = "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;
1087         RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
1088         RestrictNamespaces = true;
1089         LockPersonality = true;
1090         RestrictRealtime = true;
1091         RestrictSUIDSGID = true;
1092         RemoveIPC = true;
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" ];
1099         NoNewPrivileges = true;
1100         SystemCallFilter = [ "@system-service" "~@privileged" "@chown" ];
1101         SystemCallArchitectures = "native";
1103         DeviceAllow = null;
1104         DevicePolicy = "closed";
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     };
1115     systemd.tmpfiles.rules = [
1116       "d ${uploadDir}  0700 ${cfg.user} ${cfg.group} - -"
1117       "Z ${uploadDir} ~0700 ${cfg.user} ${cfg.group} - -"
1118     ];
1120     environment.systemPackages = mkIf (cfg.installWrapper) [ userWrapper ];
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}";
1132           proxyWebsockets = true;
1133           recommendedProxySettings = true;
1134         };
1135       }];
1136     };
1137   };
1139   meta.maintainers = with maintainers; [ mvs ];
1140   meta.doc = ./akkoma.md;